Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-26-express-orders-plan.md

2758 lines
103 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Express Orders Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the slow form-per-part Direct Order wizard with a spreadsheet-style "Express Orders" view that supports fast batch entry — all lines on one screen, type-once-and-remember per-part defaults, masking + bake toggles per line that drive job-creation overrides.
**Architecture:** Reuse the existing persistent `fp.direct.order.wizard` + `fp.direct.order.line` models end-to-end (Q1=D). Add per-line Express flags (`masking_enabled`, `bake_instructions`, `customer_line_ref`) that persist as plain fields. At SO confirm, a new helper on `sale.order.line` reads those flags and writes `fp.job.node.override` rows + `fp.job.step.instructions` text in one pass. Build a parallel Express form view; the legacy view stays alive during a 4-phase soft retirement.
**Tech Stack:** Odoo 19 (Python 3.11), PostgreSQL, OWL 2 (custom widgets), QWeb XML, SCSS (compile-time dark-mode branching). Tests: `odoo.tests.common.TransactionCase`.
**Spec:** [docs/superpowers/specs/2026-05-26-express-orders-design.md](../specs/2026-05-26-express-orders-design.md)
**Visual reference:** [.claude/mockups/express_orders.html](../../../.claude/mockups/express_orders.html) (interactive light + dark; three tabs — Screen / Data Flow / Comparison)
---
## File Structure
### Files to CREATE
```
fusion_plating_configurator/
├── migrations/19.0.22.0.0/
│ ├── pre-migrate.py # rename notes → terms_and_conditions; backfill pricelist_id
│ └── post-migrate.py # drop currency_id; orphan-landing-action sweep (Phase 4)
├── models/
│ └── product_pricelist.py # context-aware display_name override (NEW small file)
├── views/
│ ├── fp_express_order_views.xml # Express form + action + menu item
│ └── fp_part_catalog_quick_create_views.xml # 4-field quick-create form for inline part create
├── static/src/
│ ├── js/
│ │ ├── express_part_cell.js # OWL widget — multi-row Part cell
│ │ └── express_bake_pill.js # OWL widget — bake pill with inline popover
│ ├── xml/
│ │ ├── express_part_cell.xml # Template for part cell widget
│ │ └── express_bake_pill.xml # Template for bake pill widget
│ └── scss/
│ ├── _express_tokens.scss # colour tokens, dark-mode compile-time branch
│ └── express_order.scss # styles for Express form
└── tests/
├── test_express_part_defaults.py
├── test_express_line_fields.py
├── test_express_so_line_fields.py
├── test_express_wizard_fields.py
├── test_express_sale_order_fields.py
├── test_express_overrides.py # _fp_apply_express_overrides_to_job — the meaty one
├── test_express_recipe_walker.py
├── test_express_onchange_autofill.py
├── test_express_part_writeback.py
├── test_express_drafts_routing.py
└── test_express_bulk_serial_trigger.py
```
### Files to MODIFY
```
fusion_plating_configurator/
├── __manifest__.py # bump 19.0.21.8.4 → 19.0.22.0.0; add data + assets
├── models/
│ ├── fp_part_catalog.py # +3 fields
│ ├── sale_order.py # +3 fields
│ └── sale_order_line.py # +3 fields, +4 methods
├── wizard/
│ ├── fp_direct_order_wizard.py # +5 fields, rename notes, retire currency_id, add view_source
│ ├── fp_direct_order_line.py # +3 fields, +3 mirror methods, extend onchange
│ └── fp_direct_order_wizard_views.xml # Phase 2 deprecation banner; drafts list view_source column
fusion_plating_jobs/
└── models/sale_order.py # extend _fp_auto_create_job to call new helper
fusion_plating/
└── models/fp_process_node.py # +1 helper method _fp_all_nodes_with_kind
```
### Module version bump
`fusion_plating_configurator/__manifest__.py`: `19.0.21.8.4``19.0.22.0.0`. Bump happens once in Task A0.
---
## How to run tests
**Local dev (Docker, Mac):**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev \
-u fusion_plating_configurator,fusion_plating_jobs,fusion_plating \
--test-enable --test-tags /fusion_plating_configurator \
--stop-after-init 2>&1 | tail -40
```
**Single test file:**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev \
-u fusion_plating_configurator \
--test-enable --test-tags /fusion_plating_configurator:TestExpressOverrides \
--stop-after-init 2>&1 | tail -40
```
**View parse check (XML only, no test):**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev \
-u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error|warning" | head -20
```
Expected outcome of a passing run: `Modules loaded.` near the end, no `ERROR` lines.
---
## Phase A — Schema layer
### Task A0: Bump module version + create migration directory
**Files:**
- Modify: `fusion_plating_configurator/__manifest__.py`
- Create: `fusion_plating_configurator/migrations/19.0.22.0.0/__init__.py` (empty)
- [ ] **Step 1: Bump version**
Edit `fusion_plating_configurator/__manifest__.py`:
```python
'version': '19.0.22.0.0',
```
- [ ] **Step 2: Create migration directory**
```bash
mkdir -p fusion_plating_configurator/migrations/19.0.22.0.0
touch fusion_plating_configurator/migrations/19.0.22.0.0/__init__.py
```
- [ ] **Step 3: Commit**
```bash
git add fusion_plating_configurator/__manifest__.py fusion_plating_configurator/migrations/19.0.22.0.0/__init__.py
git commit -m "chore(configurator): bump to 19.0.22.0.0 for Express Orders"
```
---
### Task A1: Add per-part Express defaults to `fp.part.catalog`
**Files:**
- Modify: `fusion_plating_configurator/models/fp_part_catalog.py`
- Create: `fusion_plating_configurator/tests/test_express_part_defaults.py`
- Modify: `fusion_plating_configurator/tests/__init__.py` (add the new test import)
- [ ] **Step 1: Write the failing test**
Create `fusion_plating_configurator/tests/test_express_part_defaults.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressPartDefaults(TransactionCase):
def test_new_fields_exist(self):
Part = self.env['fp.part.catalog']
self.assertIn('default_specification_text', Part._fields)
self.assertIn('default_bake_instructions', Part._fields)
self.assertIn('default_masking_enabled', Part._fields)
def test_default_masking_enabled_default_value(self):
partner = self.env['res.partner'].create({'name': 'Test Customer'})
part = self.env['fp.part.catalog'].create({
'partner_id': partner.id,
'part_number': 'TEST-001',
'revision': 'A',
'name': 'Test Part',
})
self.assertTrue(part.default_masking_enabled)
self.assertFalse(part.default_specification_text)
self.assertFalse(part.default_bake_instructions)
```
Add to `fusion_plating_configurator/tests/__init__.py`:
```python
from . import test_express_part_defaults
```
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev \
-u fusion_plating_configurator \
--test-enable --test-tags fp_express \
--stop-after-init 2>&1 | tail -30
```
Expected: `KeyError: 'default_specification_text'` or `AssertionError`.
- [ ] **Step 3: Add the fields**
Edit `fusion_plating_configurator/models/fp_part_catalog.py` — add inside the `FpPartCatalog` class (e.g. after the existing `x_fc_default_thickness_range` field):
```python
# ---- Express Orders defaults (2026-05-26) ----
default_specification_text = fields.Text(
string='Default Specification (Customer-Facing)',
help='Pre-fills the Specification cell when this part is added to an '
'Express Order. Written here automatically on order confirm — '
'type once, reuse forever.',
)
default_bake_instructions = fields.Text(
string='Default Bake Instructions',
help='Pre-fills the Bake cell. Empty = bake step is skipped on every '
'order; non-empty = bake step runs and these instructions show '
'on the operator tablet.',
)
default_masking_enabled = fields.Boolean(
string='Default Masking Enabled',
default=True,
help='Default state of the Masking checkbox on Express Order lines. '
'When False, masking + de-masking recipe nodes are skipped.',
)
```
- [ ] **Step 4: Run test to verify it passes**
Same command as Step 2.
Expected: `OK` and `Modules loaded.`
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/models/fp_part_catalog.py fusion_plating_configurator/tests/test_express_part_defaults.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): add Express Orders per-part defaults to fp.part.catalog"
```
---
### Task A2: Add Express flags to `fp.direct.order.line`
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
- Create: `fusion_plating_configurator/tests/test_express_line_fields.py`
- Modify: `fusion_plating_configurator/tests/__init__.py`
- [ ] **Step 1: Write the failing test**
Create `fusion_plating_configurator/tests/test_express_line_fields.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressLineFields(TransactionCase):
def test_new_fields_exist(self):
Line = self.env['fp.direct.order.line']
self.assertIn('customer_line_ref', Line._fields)
self.assertIn('masking_enabled', Line._fields)
self.assertIn('bake_instructions', Line._fields)
def test_masking_default_true(self):
partner = self.env['res.partner'].create({'name': 'Cust'})
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': partner.id})
line = self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id,
'quantity': 1,
})
self.assertTrue(line.masking_enabled)
self.assertFalse(line.customer_line_ref)
self.assertFalse(line.bake_instructions)
```
Add `from . import test_express_line_fields` to `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: `KeyError`.
- [ ] **Step 3: Add the fields**
Edit `fusion_plating_configurator/wizard/fp_direct_order_line.py` — add inside the `FpDirectOrderLine` class (near other line-level fields):
```python
# ---- Express Orders per-line flags (2026-05-26) ----
customer_line_ref = fields.Char(
string='Customer Line Job #',
help='Per-line customer sub-reference (e.g. ABC, DEF). Distinct from '
'the order-level Customer Job #. Prints on customer docs.',
)
masking_enabled = fields.Boolean(
string='Masking Enabled',
default=True,
help='When False, masking + de-masking recipe nodes are opted out '
'when the job is created.',
)
bake_instructions = fields.Text(
string='Bake Instructions',
help='Free-text bake instructions. Empty = bake steps are opted out. '
'Non-empty = bake step instructions on the operator tablet.',
)
```
- [ ] **Step 4: Run test to verify it passes**
Same command as Step 2.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_line.py fusion_plating_configurator/tests/test_express_line_fields.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): add Express flags to fp.direct.order.line"
```
---
### Task A3: Add Express flags to `sale.order.line`
**Files:**
- Modify: `fusion_plating_configurator/models/sale_order_line.py`
- Create: `fusion_plating_configurator/tests/test_express_so_line_fields.py`
- Modify: `fusion_plating_configurator/tests/__init__.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_express_so_line_fields.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressSoLineFields(TransactionCase):
def test_new_fields_exist(self):
Line = self.env['sale.order.line']
self.assertIn('x_fc_customer_line_ref', Line._fields)
self.assertIn('x_fc_masking_enabled', Line._fields)
self.assertIn('x_fc_bake_instructions', Line._fields)
def test_masking_default_true(self):
partner = self.env['res.partner'].create({'name': 'C'})
product = self.env['product.product'].search([('default_code', '=', 'FP-SERVICE')], limit=1) \
or self.env['product.product'].create({'name': 'svc', 'type': 'service', 'default_code': 'FP-SERVICE'})
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
})
line = so.order_line[:1]
self.assertTrue(line.x_fc_masking_enabled)
self.assertFalse(line.x_fc_customer_line_ref)
self.assertFalse(line.x_fc_bake_instructions)
```
Wire up in `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: `KeyError`.
- [ ] **Step 3: Add the fields**
Edit `fusion_plating_configurator/models/sale_order_line.py` — add inside the class:
```python
# ---- Express Orders per-line flags (2026-05-26) ----
# These mirror fp.direct.order.line.{customer_line_ref, masking_enabled, bake_instructions}
# and persist past wizard confirm so _fp_apply_express_overrides_to_job can read them.
x_fc_customer_line_ref = fields.Char(
string='Customer Line Job #',
help='Per-line customer sub-reference (e.g. ABC, DEF). '
'Prints on customer docs (quote, SO, invoice, packing slip).',
)
x_fc_masking_enabled = fields.Boolean(
string='Masking Enabled',
default=True,
help='When False, the job-creation hook spawns fp.job.node.override '
'(included=False) for every masking + de_masking node on the recipe.',
)
x_fc_bake_instructions = fields.Text(
string='Bake Instructions',
help='Empty = bake steps are opted out of the job. Non-empty = bake '
'steps run, with this text shown on the operator tablet under '
'fp.job.step.instructions.',
)
```
- [ ] **Step 4: Run test to verify it passes**
Same command as Step 2.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/models/sale_order_line.py fusion_plating_configurator/tests/test_express_so_line_fields.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): add Express flags to sale.order.line"
```
---
### Task A4: Add Express fields to `sale.order` (header-level x_fc_*)
**Files:**
- Modify: `fusion_plating_configurator/models/sale_order.py`
- Create: `fusion_plating_configurator/tests/test_express_sale_order_fields.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_sale_order_fields.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressSaleOrderFields(TransactionCase):
def test_new_fields_exist(self):
SO = self.env['sale.order']
self.assertIn('x_fc_material_process', SO._fields)
self.assertIn('x_fc_internal_notes', SO._fields)
self.assertIn('x_fc_print_terms', SO._fields)
def test_print_terms_default_true(self):
partner = self.env['res.partner'].create({'name': 'C'})
so = self.env['sale.order'].create({'partner_id': partner.id})
self.assertTrue(so.x_fc_print_terms)
```
Wire up in `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: `KeyError`.
- [ ] **Step 3: Add the fields**
Edit `fusion_plating_configurator/models/sale_order.py` — add inside the class:
```python
# ---- Express Orders header-level (2026-05-26) ----
x_fc_material_process = fields.Char(
string='Material / Process Tag',
help='Free-text order-level shop tag (e.g. ENP-STEEL-HP-ADVANCED). '
'Informational; not used by the workflow.',
)
x_fc_internal_notes = fields.Text(
string='Order-Level Internal Notes',
help='Notes visible only to the estimator / planner / shop. Never '
'prints on customer-facing PDFs. Distinct from sale.order.note '
'which IS customer-facing (Terms & Conditions).',
)
x_fc_print_terms = fields.Boolean(
string='Print Terms on Customer Documents',
default=True,
help='When False, the Terms & Conditions (sale.order.note) is '
'suppressed on quote / SO / invoice / packing slip PDFs.',
)
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/models/sale_order.py fusion_plating_configurator/tests/test_express_sale_order_fields.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): add Express header fields to sale.order"
```
---
### Task A5: Wizard schema — new fields + rename `notes` + retire `currency_id`
This task is bigger because it does three things at once. The model edits + the pre-migration script must land together so the upgrade is atomic.
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
- Create: `fusion_plating_configurator/migrations/19.0.22.0.0/pre-migrate.py`
- Create: `fusion_plating_configurator/tests/test_express_wizard_fields.py`
- Modify: `fusion_plating_configurator/tests/__init__.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_wizard_fields.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressWizardFields(TransactionCase):
def test_new_fields_exist(self):
Wiz = self.env['fp.direct.order.wizard']
for fname in ('material_process', 'pricelist_id', 'validity_date',
'internal_notes', 'terms_and_conditions', 'view_source'):
self.assertIn(fname, Wiz._fields, msg=f'Missing: {fname}')
def test_old_currency_id_retired(self):
# currency_id is gone from the model class (was M2O res.currency)
Wiz = self.env['fp.direct.order.wizard']
self.assertNotIn('currency_id', Wiz._fields)
def test_old_notes_retired(self):
Wiz = self.env['fp.direct.order.wizard']
self.assertNotIn('notes', Wiz._fields)
def test_view_source_default_express(self):
partner = self.env['res.partner'].create({'name': 'C'})
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': partner.id})
self.assertEqual(wiz.view_source, 'express')
```
Wire up in `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: multiple `AssertionError: Missing: …`.
- [ ] **Step 3a: Add new fields + rename + retire on the model**
Edit `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`:
(a) Find the `currency_id` field declaration and **delete it entirely** (lines 142145 in the current file).
(b) Find the `notes` field declaration and **replace** it with `terms_and_conditions`:
```python
terms_and_conditions = fields.Text(
string='Terms & Conditions',
help='Customer-facing terms — prints on quote / SO / invoice. '
'Seeded from res.company.invoice_terms_html with partner-level '
'override via res.partner.invoice_terms.',
)
internal_notes = fields.Text(
string='Order-Level Internal Notes',
help='Visible only to estimator / planner / shop. Never prints.',
)
```
(c) Add the remaining new fields (near other order-level fields):
```python
# ---- Express Orders header (2026-05-26) ----
material_process = fields.Char(
string='Material / Process Tag',
help='Free-text shop tag (e.g. ENP-STEEL-HP-ADVANCED).',
)
pricelist_id = fields.Many2one(
'product.pricelist',
string='Pricelist',
default=lambda self: self._fp_default_pricelist(),
help='Drives both order currency and price computation. Replaces the '
'legacy currency_id Many2one(res.currency).',
)
validity_date = fields.Date(
string='Quote Validity',
help='Mirrors sale.order.validity_date — when the quote/SO expires.',
)
view_source = fields.Selection(
[('express', 'Express Orders View'),
('legacy', 'Legacy Direct Order View')],
string='View Source',
default='express',
required=True,
readonly=True,
help='Which view created this draft. Drafts list routes click-action '
'to the matching form. Dropped at phase-out Phase 4.',
)
@api.model
def _fp_default_pricelist(self):
"""Default pricelist = company's default. Re-resolves on partner pick."""
return self.env.company.partner_id.property_product_pricelist.id or False
```
(d) Update `_prepare_order_vals()` — find the dict that builds the SO header (around line 528) and **replace**:
```python
'note': self.notes or False,
```
with:
```python
'note': self.terms_and_conditions or False,
'x_fc_internal_notes': self.internal_notes or False,
'x_fc_material_process': self.material_process or False,
'pricelist_id': self.pricelist_id.id if self.pricelist_id else False,
'validity_date': self.validity_date or False,
```
- [ ] **Step 3b: Add the pre-migration script**
Create `fusion_plating_configurator/migrations/19.0.22.0.0/pre-migrate.py`:
```python
# -*- coding: utf-8 -*-
"""Pre-migration for Express Orders (19.0.22.0.0).
Runs BEFORE Odoo's field-registration pass so the model can register
`terms_and_conditions` and `pricelist_id` against columns that already
hold data:
1. Rename `fp_direct_order_wizard.notes` → `terms_and_conditions`.
2. Add `pricelist_id` column.
3. Backfill `pricelist_id` from the now-retired `currency_id` via the
partner's property_product_pricelist, with fallback to any active
pricelist matching the wizard's currency.
4. Drop `currency_id` column (safe — we are in development on entech;
the spec explicitly says ignore past orders).
"""
def migrate(cr, version):
# 1. Rename notes → terms_and_conditions (same column, same data)
cr.execute("""
ALTER TABLE fp_direct_order_wizard
RENAME COLUMN notes TO terms_and_conditions
""")
# 2. Add internal_notes column (will be NULL for existing rows)
cr.execute("""
ALTER TABLE fp_direct_order_wizard
ADD COLUMN IF NOT EXISTS internal_notes TEXT
""")
# 3. Add pricelist_id, material_process, validity_date, view_source
cr.execute("""
ALTER TABLE fp_direct_order_wizard
ADD COLUMN IF NOT EXISTS pricelist_id INTEGER
REFERENCES product_pricelist(id) ON DELETE SET NULL
""")
cr.execute("""
ALTER TABLE fp_direct_order_wizard
ADD COLUMN IF NOT EXISTS material_process VARCHAR
""")
cr.execute("""
ALTER TABLE fp_direct_order_wizard
ADD COLUMN IF NOT EXISTS validity_date DATE
""")
cr.execute("""
ALTER TABLE fp_direct_order_wizard
ADD COLUMN IF NOT EXISTS view_source VARCHAR DEFAULT 'legacy'
""")
# Note: view_source defaults to 'legacy' for existing rows — they
# were created via the legacy view. New rows default to 'express'
# via the model definition.
# 4. Backfill pricelist_id from partner's property if available,
# else any active pricelist in the same currency.
cr.execute("""
UPDATE fp_direct_order_wizard w
SET pricelist_id = (
SELECT p.id
FROM product_pricelist p
WHERE p.currency_id = w.currency_id
AND p.active = TRUE
ORDER BY p.id
LIMIT 1
)
WHERE w.pricelist_id IS NULL
AND w.currency_id IS NOT NULL
""")
# 5. Drop the retired currency_id column (dev-stage, no legacy concerns)
cr.execute("""
ALTER TABLE fp_direct_order_wizard
DROP COLUMN IF EXISTS currency_id
""")
```
- [ ] **Step 4: Run test to verify it passes**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: `OK`. If the pre-migrate script errored on a column that doesn't exist (clean DB), the `IF NOT EXISTS` / `IF EXISTS` clauses cover it.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py fusion_plating_configurator/migrations/19.0.22.0.0/pre-migrate.py fusion_plating_configurator/tests/test_express_wizard_fields.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): wizard schema — rename notes, add Express fields, retire currency_id"
```
---
## Phase B — Backend logic
### Task B1: Recipe-walker helper `_fp_all_nodes_with_kind`
**Files:**
- Modify: `fusion_plating/models/fp_process_node.py`
- Create: `fusion_plating_configurator/tests/test_express_recipe_walker.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_recipe_walker.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressRecipeWalker(TransactionCase):
def setUp(self):
super().setUp()
Node = self.env['fusion.plating.process.node']
self.root = Node.create({'name': 'Root', 'node_type': 'recipe'})
self.op_mask = Node.create({
'name': 'Mask', 'node_type': 'operation',
'parent_id': self.root.id, 'default_kind': 'masking',
})
self.op_bake = Node.create({
'name': 'Bake @ 350', 'node_type': 'operation',
'parent_id': self.root.id, 'default_kind': 'baking',
})
self.op_demask = Node.create({
'name': 'De-Mask', 'node_type': 'operation',
'parent_id': self.root.id, 'default_kind': 'de_masking',
})
self.op_other = Node.create({
'name': 'Inspect', 'node_type': 'operation',
'parent_id': self.root.id, 'default_kind': 'inspection',
})
def test_finds_masking_pair(self):
nodes = self.root._fp_all_nodes_with_kind(('masking', 'de_masking'))
self.assertEqual(set(nodes.ids), {self.op_mask.id, self.op_demask.id})
def test_finds_baking(self):
nodes = self.root._fp_all_nodes_with_kind(('baking',))
self.assertEqual(set(nodes.ids), {self.op_bake.id})
def test_empty_kinds_returns_empty(self):
self.assertFalse(self.root._fp_all_nodes_with_kind(()))
def test_kind_not_present_returns_empty(self):
self.assertFalse(self.root._fp_all_nodes_with_kind(('nonexistent',)))
```
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator,fusion_plating --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: `AttributeError: 'fusion.plating.process.node' object has no attribute '_fp_all_nodes_with_kind'`.
- [ ] **Step 3: Add the helper**
Edit `fusion_plating/models/fp_process_node.py` — add as a method on the class:
```python
def _fp_all_nodes_with_kind(self, kinds):
"""Return all descendants (incl. self if applicable) whose default_kind ∈ kinds.
Uses _parent_store's parent_path for a single SQL hit. Added 2026-05-26
for Express Orders override-application helper.
:param kinds: tuple/list of default_kind strings, e.g. ('masking', 'de_masking')
:return: recordset of matching fusion.plating.process.node rows
"""
self.ensure_one()
if not kinds:
return self.browse([])
return self.search([
('id', 'child_of', self.id),
('default_kind', 'in', list(kinds)),
])
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating/models/fp_process_node.py fusion_plating_configurator/tests/test_express_recipe_walker.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(plating): recipe walker _fp_all_nodes_with_kind for Express Orders"
```
---
### Task B2: Override-application helper `_fp_apply_express_overrides_to_job`
This is the meatiest backend piece — it covers the 4 quadrants (masking on/off × bake empty/non-empty) plus audit + idempotency.
**Files:**
- Modify: `fusion_plating_configurator/models/sale_order_line.py`
- Create: `fusion_plating_configurator/tests/test_express_overrides.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_overrides.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressOverrides(TransactionCase):
def setUp(self):
super().setUp()
Node = self.env['fusion.plating.process.node']
# Build a minimal recipe: Mask → Plate → Bake → De-Mask → Inspect
self.recipe = Node.create({'name': 'Test ENP', 'node_type': 'recipe'})
self.n_mask = Node.create({
'name': 'Mask', 'node_type': 'operation',
'parent_id': self.recipe.id, 'default_kind': 'masking', 'sequence': 10,
})
self.n_plate = Node.create({
'name': 'Plate', 'node_type': 'operation',
'parent_id': self.recipe.id, 'default_kind': 'plating', 'sequence': 20,
})
self.n_bake = Node.create({
'name': 'Bake', 'node_type': 'operation',
'parent_id': self.recipe.id, 'default_kind': 'baking', 'sequence': 30,
})
self.n_demask = Node.create({
'name': 'De-Mask', 'node_type': 'operation',
'parent_id': self.recipe.id, 'default_kind': 'de_masking', 'sequence': 40,
})
# Build a minimal SO + line + fp.job + steps
partner = self.env['res.partner'].create({'name': 'C'})
product = self.env['product.product'].search([('default_code', '=', 'FP-SERVICE')], limit=1) \
or self.env['product.product'].create({'name': 'svc', 'type': 'service', 'default_code': 'FP-SERVICE'})
self.so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
})
self.line = self.so.order_line[:1]
self.job = self.env['fp.job'].create({
'partner_id': partner.id,
'recipe_id': self.recipe.id,
'qty': 1,
'sale_order_id': self.so.id,
})
# Spawn job steps from the recipe (one per operation node)
FpStep = self.env['fp.job.step']
for node in (self.n_mask, self.n_plate, self.n_bake, self.n_demask):
FpStep.create({
'job_id': self.job.id,
'recipe_node_id': node.id,
'name': node.name,
'sequence': node.sequence,
})
def _count_overrides(self, kinds, included=False):
return self.env['fp.job.node.override'].search_count([
('job_id', '=', self.job.id),
('node_id.default_kind', 'in', list(kinds)),
('included', '=', included),
])
def test_masking_off_creates_pair_overrides(self):
self.line.x_fc_masking_enabled = False
self.line.x_fc_bake_instructions = '350F x 4hr' # avoid bake opt-out
self.line._fp_apply_express_overrides_to_job(self.job)
self.assertEqual(self._count_overrides(('masking', 'de_masking')), 2)
def test_masking_on_creates_no_masking_overrides(self):
self.line.x_fc_masking_enabled = True
self.line.x_fc_bake_instructions = '350F x 4hr'
self.line._fp_apply_express_overrides_to_job(self.job)
self.assertEqual(self._count_overrides(('masking', 'de_masking')), 0)
def test_bake_empty_opts_out_baking_nodes(self):
self.line.x_fc_masking_enabled = True
self.line.x_fc_bake_instructions = ''
self.line._fp_apply_express_overrides_to_job(self.job)
self.assertEqual(self._count_overrides(('baking',)), 1)
def test_bake_whitespace_only_treated_as_empty(self):
self.line.x_fc_bake_instructions = ' \n '
self.line._fp_apply_express_overrides_to_job(self.job)
self.assertEqual(self._count_overrides(('baking',)), 1)
def test_bake_text_writes_to_step_instructions(self):
self.line.x_fc_masking_enabled = True
self.line.x_fc_bake_instructions = '350F x 4hr'
self.line._fp_apply_express_overrides_to_job(self.job)
bake_step = self.job.step_ids.filtered(
lambda s: s.recipe_node_id.default_kind == 'baking'
)
self.assertEqual(bake_step.instructions, '350F x 4hr')
def test_idempotent_rerun_does_not_duplicate(self):
self.line.x_fc_masking_enabled = False
self.line._fp_apply_express_overrides_to_job(self.job)
self.line._fp_apply_express_overrides_to_job(self.job)
# Second run should pre-delete prior rows then re-create — net same count
self.assertEqual(self._count_overrides(('masking', 'de_masking')), 2)
def test_no_recipe_no_op(self):
self.job.recipe_id = False
self.line._fp_apply_express_overrides_to_job(self.job) # should not raise
self.assertEqual(self._count_overrides(('masking', 'de_masking', 'baking')), 0)
```
Wire up in `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -50
```
Expected: `AttributeError: '_fp_apply_express_overrides_to_job'`.
- [ ] **Step 3: Implement the helper**
Edit `fusion_plating_configurator/models/sale_order_line.py` — add to the class:
```python
def _fp_apply_express_overrides_to_job(self, job):
"""Convert Express per-line flags into fp.job.node.override rows + step instructions.
Called from sale_order._fp_auto_create_job() immediately after the
job + steps are built. Idempotent — pre-deletes prior masking/bake
override rows this helper wrote, so re-running on SO un-confirm +
re-confirm doesn't duplicate.
Algorithm per handoff Q4 (masking pair) + Q5 (bake text):
- masking_enabled=False → opt out of masking + de_masking nodes
- bake_instructions empty → opt out of baking nodes
- bake_instructions non-empty → keep baking + write text to step.instructions
"""
self.ensure_one()
if not job or not job.recipe_id:
return
recipe = job.recipe_id
Override = self.env['fp.job.node.override']
# --- Idempotency: clear prior masking/bake overrides on this job
Override.search([
('job_id', '=', job.id),
('node_id.default_kind', 'in', ('masking', 'de_masking', 'baking')),
]).unlink()
msgs = []
# --- 1. Masking — opt out of masking + de_masking AS A PAIR
if not self.x_fc_masking_enabled:
nodes = recipe._fp_all_nodes_with_kind(('masking', 'de_masking'))
for node in nodes:
Override.create({
'job_id': job.id,
'node_id': node.id,
'included': False,
})
if nodes:
msgs.append('Masking + de-masking steps opted out (per SO line)')
# --- 2. Bake — empty = opt out; non-empty = keep + write step.instructions
bake_text = (self.x_fc_bake_instructions or '').strip()
bake_nodes = recipe._fp_all_nodes_with_kind(('baking',))
if not bake_text:
for node in bake_nodes:
Override.create({
'job_id': job.id,
'node_id': node.id,
'included': False,
})
if bake_nodes:
msgs.append('Baking steps opted out (per SO line)')
else:
bake_steps = job.step_ids.filtered(
lambda s: s.recipe_node_id.default_kind == 'baking'
)
if bake_steps:
bake_steps.write({'instructions': bake_text})
msgs.append('Bake step instructions set to: %s' % bake_text)
# --- 3. Audit chatter post on the job
if msgs:
job.message_post(body='\n'.join('' + m for m in msgs))
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK` with all 7 sub-tests passing.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/models/sale_order_line.py fusion_plating_configurator/tests/test_express_overrides.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): Express override-application helper on sale.order.line"
```
---
### Task B3: Hook helper into `_fp_auto_create_job`
**Files:**
- Modify: `fusion_plating_jobs/models/sale_order.py`
- Add an integration test inside `tests/test_express_overrides.py`
- [ ] **Step 1: Add the integration test**
Append to `tests/test_express_overrides.py`:
```python
def test_hook_fires_on_so_confirm(self):
"""Confirming the SO should auto-call the override helper for each line."""
# Configure line to opt out of masking + bake
self.line.x_fc_masking_enabled = False
self.line.x_fc_bake_instructions = ''
# Reset job/step state — let action_confirm create them
self.job.unlink()
# Wire SO line to use the recipe via the existing process_variant_id mechanism
# (this assumes the part-catalog → recipe linkage in production; for the test,
# we patch the line to point at the recipe directly via x_fc_process_variant_id)
self.line.x_fc_process_variant_id = self.recipe.id
self.so.action_confirm()
new_job = self.env['fp.job'].search([('sale_order_id', '=', self.so.id)], limit=1)
self.assertTrue(new_job, 'Job should have been auto-created')
overrides = self.env['fp.job.node.override'].search([
('job_id', '=', new_job.id),
('node_id.default_kind', 'in', ('masking', 'de_masking', 'baking')),
('included', '=', False),
])
# Expect 3 opt-outs: mask + de_mask + bake
self.assertEqual(len(overrides), 3)
```
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator,fusion_plating_jobs --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -40
```
Expected: `len(overrides) == 0` (hook hasn't been wired).
- [ ] **Step 3: Wire the hook**
Edit `fusion_plating_jobs/models/sale_order.py` — find the existing `_fp_auto_create_job` method. At the end (just before `return job`), add:
```python
# Express Orders: apply per-line masking / bake overrides
# (added 2026-05-26 — see Express Orders spec)
if job and job.recipe_id:
for line in self.order_line.filtered('x_fc_part_catalog_id'):
line._fp_apply_express_overrides_to_job(job)
```
If `_fp_auto_create_job` produces one job per contributing line (vs. a single consolidated job), adjust the loop accordingly — the helper call is per line, the loop wraps it. Verify the existing function's return shape by reading it first.
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_jobs/models/sale_order.py fusion_plating_configurator/tests/test_express_overrides.py
git commit -m "feat(jobs): hook Express override helper into _fp_auto_create_job"
```
---
### Task B4: Onchange auto-fill cascade — part-pick seeds Express cells
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
- Create: `fusion_plating_configurator/tests/test_express_onchange_autofill.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_onchange_autofill.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressOnchangeAutofill(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.part = self.env['fp.part.catalog'].create({
'partner_id': self.partner.id,
'part_number': 'TEST-001',
'revision': 'A',
'name': 'Test Part',
'default_specification_text': 'Per drawing rev A, gloss finish',
'default_bake_instructions': '300F x 2hr',
'default_masking_enabled': False,
})
self.wiz = self.env['fp.direct.order.wizard'].create({'partner_id': self.partner.id})
def test_onchange_seeds_express_fields_from_part_defaults(self):
line = self.env['fp.direct.order.line'].new({
'wizard_id': self.wiz.id,
'part_catalog_id': self.part.id,
})
line._onchange_part_catalog_id()
self.assertEqual(line.line_description, 'Per drawing rev A, gloss finish')
self.assertEqual(line.bake_instructions, '300F x 2hr')
self.assertFalse(line.masking_enabled) # part default is False
```
Wire up in `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --test-enable --test-tags fp_express --stop-after-init 2>&1 | tail -30
```
Expected: assertions fail (none of the Express fields are seeded by the existing onchange).
- [ ] **Step 3: Extend the onchange**
Edit `fusion_plating_configurator/wizard/fp_direct_order_line.py` — find the existing `_onchange_part_catalog_id` method. At the end, add:
```python
# Express Orders auto-fill (2026-05-26)
if self.part_catalog_id:
part = self.part_catalog_id
# Specification — seed line.line_description from part default (only if line is empty)
if not self.line_description and part.default_specification_text:
self.line_description = part.default_specification_text
# Bake — seed from part default (only if line is empty)
if not self.bake_instructions and part.default_bake_instructions:
self.bake_instructions = part.default_bake_instructions
# Masking — always seed from part default (Boolean is never "empty" — explicit
# field exists on the part for this purpose)
self.masking_enabled = part.default_masking_enabled
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_line.py fusion_plating_configurator/tests/test_express_onchange_autofill.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): auto-fill Express cells from part defaults on part pick"
```
---
### Task B5: Carry per-line Express flags to SO line in `_prepare_order_line_vals`
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
- Add a test to `tests/test_express_overrides.py` (or new file)
- [ ] **Step 1: Write the failing test**
Create `tests/test_express_line_to_so.py`:
```python
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressLineToSO(TransactionCase):
def test_express_flags_carried_on_confirm(self):
partner = self.env['res.partner'].create({'name': 'C'})
part = self.env['fp.part.catalog'].create({
'partner_id': partner.id, 'part_number': 'X-1', 'revision': 'A',
'name': 'X',
})
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': partner.id})
line = self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id,
'part_catalog_id': part.id,
'quantity': 1,
'customer_line_ref': 'ABC',
'masking_enabled': False,
'bake_instructions': '400F x 1hr',
})
vals = line._prepare_order_line_vals()
self.assertEqual(vals.get('x_fc_customer_line_ref'), 'ABC')
self.assertEqual(vals.get('x_fc_masking_enabled'), False)
self.assertEqual(vals.get('x_fc_bake_instructions'), '400F x 1hr')
```
Wire up in `tests/__init__.py`.
- [ ] **Step 2: Run test to verify it fails**
Same command pattern.
Expected: `KeyError` or all three keys missing from vals.
- [ ] **Step 3: Extend `_prepare_order_line_vals`**
Edit `fusion_plating_configurator/wizard/fp_direct_order_line.py` — find `_prepare_order_line_vals`. Append to the returned dict:
```python
# Express Orders per-line flags
vals['x_fc_customer_line_ref'] = self.customer_line_ref or False
vals['x_fc_masking_enabled'] = self.masking_enabled
vals['x_fc_bake_instructions'] = self.bake_instructions or False
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_line.py fusion_plating_configurator/tests/test_express_line_to_so.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): carry Express line flags through _prepare_order_line_vals"
```
---
### Task B6: Part-default write-back on confirm
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
- Create: `fusion_plating_configurator/tests/test_express_part_writeback.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_part_writeback.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressPartWriteback(TransactionCase):
def test_line_values_written_back_to_part_on_confirm(self):
partner = self.env['res.partner'].create({'name': 'C'})
# Part starts with empty defaults
part = self.env['fp.part.catalog'].create({
'partner_id': partner.id,
'part_number': 'WB-1',
'revision': 'A',
'name': 'Writeback test',
})
wiz = self.env['fp.direct.order.wizard'].create({
'partner_id': partner.id,
'po_pending': True, # bypass PO requirement
})
self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id,
'part_catalog_id': part.id,
'quantity': 1,
'unit_price': 10,
'line_description': 'Per drawing, no contact marks',
'bake_instructions': '375F x 3hr',
'thickness_range': '.0005-.0010',
'masking_enabled': False,
})
wiz.action_create_order()
part.invalidate_recordset()
self.assertEqual(part.default_specification_text, 'Per drawing, no contact marks')
self.assertEqual(part.default_bake_instructions, '375F x 3hr')
self.assertEqual(part.x_fc_default_thickness_range, '.0005-.0010')
self.assertFalse(part.default_masking_enabled)
```
Wire up.
- [ ] **Step 2: Run test to verify it fails**
Same command.
Expected: assertions fail — part defaults remain empty.
- [ ] **Step 3: Add the write-back method + invoke from action_create_order**
Edit `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`. Add the new method:
```python
def _fp_writeback_part_defaults(self):
"""For each line, write back Express values to the part's defaults.
Single direction (line → part). Editing the part's defaults later
does NOT retroactively update existing SO lines (they're frozen at
SO confirm).
"""
self.ensure_one()
for line in self.line_ids:
if not line.part_catalog_id:
continue
part = line.part_catalog_id
wb_vals = {}
if line.line_description:
wb_vals['default_specification_text'] = line.line_description
if line.bake_instructions:
wb_vals['default_bake_instructions'] = line.bake_instructions
if line.thickness_range:
wb_vals['x_fc_default_thickness_range'] = line.thickness_range
# Boolean: always write (no notion of "empty" for a Boolean)
wb_vals['default_masking_enabled'] = line.masking_enabled
if wb_vals:
part.write(wb_vals)
```
Find `action_create_order` and add the call near the end, after the SO has been created (just before `return` or before the state transition):
```python
# Write-back per-part defaults (Express Orders — 2026-05-26)
self._fp_writeback_part_defaults()
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py fusion_plating_configurator/tests/test_express_part_writeback.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): write-back Express line values to part defaults on confirm"
```
---
### Task B7: `action_open_serial_bulk_add` helpers on line + SO line
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
- Modify: `fusion_plating_configurator/models/sale_order_line.py`
- Create: `fusion_plating_configurator/tests/test_express_bulk_serial_trigger.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_bulk_serial_trigger.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressBulkSerialTrigger(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.part = self.env['fp.part.catalog'].create({
'partner_id': self.partner.id,
'part_number': 'BS-1', 'revision': 'A', 'name': 'BS',
})
def test_line_returns_wizard_action(self):
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': self.partner.id})
line = self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id,
'part_catalog_id': self.part.id,
'quantity': 5,
})
action = line.action_open_serial_bulk_add()
self.assertEqual(action['res_model'], 'fp.serial.bulk.add.wizard')
self.assertEqual(action['context']['default_target_model'], 'fp.direct.order.line')
self.assertEqual(action['context']['default_target_id'], line.id)
self.assertEqual(action['context']['default_qty_expected'], 5)
def test_so_line_returns_wizard_action(self):
product = self.env['product.product'].search([('default_code', '=', 'FP-SERVICE')], limit=1) \
or self.env['product.product'].create({'name': 'svc', 'type': 'service', 'default_code': 'FP-SERVICE'})
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 3})],
})
line = so.order_line[:1]
action = line.action_open_serial_bulk_add()
self.assertEqual(action['context']['default_target_model'], 'sale.order.line')
self.assertEqual(action['context']['default_target_id'], line.id)
self.assertEqual(action['context']['default_qty_expected'], 3)
```
Wire up.
- [ ] **Step 2: Run test to verify it fails**
Same pattern.
Expected: `AttributeError: action_open_serial_bulk_add`.
- [ ] **Step 3: Implement the helpers**
Edit `fusion_plating_configurator/wizard/fp_direct_order_line.py` — add:
```python
def action_open_serial_bulk_add(self):
"""Open the existing fp.serial.bulk.add.wizard targeting this line."""
self.ensure_one()
action = self.env.ref(
'fusion_plating_configurator.action_fp_serial_bulk_add_wizard'
).read()[0]
action['context'] = {
'default_target_model': 'fp.direct.order.line',
'default_target_id': self.id,
'default_qty_expected': self.quantity,
}
return action
```
Edit `fusion_plating_configurator/models/sale_order_line.py` — add the same helper, but with `sale.order.line` target model:
```python
def action_open_serial_bulk_add(self):
"""Open fp.serial.bulk.add.wizard targeting this SO line (post-confirm)."""
self.ensure_one()
action = self.env.ref(
'fusion_plating_configurator.action_fp_serial_bulk_add_wizard'
).read()[0]
action['context'] = {
'default_target_model': 'sale.order.line',
'default_target_id': self.id,
'default_qty_expected': self.product_uom_qty,
}
return action
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_line.py fusion_plating_configurator/models/sale_order_line.py fusion_plating_configurator/tests/test_express_bulk_serial_trigger.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): action_open_serial_bulk_add on line + SO line"
```
---
### Task B8: `action_upload_drawing` + `action_open_part` helpers
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
- Modify: `fusion_plating_configurator/models/sale_order_line.py`
- Append to existing `tests/test_express_bulk_serial_trigger.py` (rename file or add new)
- [ ] **Step 1: Write the failing test**
Append to `tests/test_express_bulk_serial_trigger.py` (or split into a new file `test_express_part_buttons.py`):
```python
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressPartButtons(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.part = self.env['fp.part.catalog'].create({
'partner_id': self.partner.id,
'part_number': 'PB-1', 'revision': 'A', 'name': 'PB',
})
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': self.partner.id})
self.line = self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id, 'part_catalog_id': self.part.id, 'quantity': 1,
})
def test_action_open_part_returns_form(self):
action = self.line.action_open_part()
self.assertEqual(action['res_model'], 'fp.part.catalog')
self.assertEqual(action['res_id'], self.part.id)
self.assertEqual(action['target'], 'new')
def test_action_upload_drawing_attaches_to_part(self):
# Simulate file picker: pass file data via context
import base64
fake_pdf = base64.b64encode(b'%PDF-1.4 fake').decode()
action = self.line.with_context(
fp_drawing_file=fake_pdf,
fp_drawing_filename='test.pdf',
).action_upload_drawing()
self.assertEqual(action.get('type'), 'ir.actions.client') # close-and-reload
# Check attachment linked
self.part.invalidate_recordset()
self.assertEqual(len(self.part.drawing_attachment_ids), 1)
self.assertEqual(self.part.drawing_attachment_ids[:1].name, 'test.pdf')
```
- [ ] **Step 2: Run test to verify it fails**
Same pattern.
Expected: `AttributeError`.
- [ ] **Step 3: Implement the helpers**
Edit `fusion_plating_configurator/wizard/fp_direct_order_line.py` — add:
```python
def action_open_part(self):
"""Open the linked fp.part.catalog form in a modal (target='new')."""
self.ensure_one()
if not self.part_catalog_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.part_catalog_id.display_name,
'res_model': 'fp.part.catalog',
'view_mode': 'form',
'res_id': self.part_catalog_id.id,
'target': 'new',
}
def action_upload_drawing(self):
"""Attach a file (passed via context) to the line's part as a drawing.
Frontend (OWL widget) reads the file picker, base64-encodes, sets
context keys fp_drawing_file + fp_drawing_filename, then calls this.
"""
self.ensure_one()
if not self.part_catalog_id:
raise UserError(_('Pick or create a part on this line first.'))
file_data = self.env.context.get('fp_drawing_file')
filename = self.env.context.get('fp_drawing_filename', 'drawing.pdf')
if not file_data:
raise UserError(_('No file data received.'))
att = self.env['ir.attachment'].create({
'name': filename,
'datas': file_data,
'res_model': 'fp.part.catalog',
'res_id': self.part_catalog_id.id,
})
self.part_catalog_id.write({
'drawing_attachment_ids': [(4, att.id)],
})
self.part_catalog_id.message_post(body=_(
'Drawing "%(name)s" uploaded by %(user)s from line %(line)s on draft %(draft)s.'
) % {
'name': filename,
'user': self.env.user.display_name,
'line': self.sequence or self.id,
'draft': self.wizard_id.name,
})
return {'type': 'ir.actions.client', 'tag': 'reload'}
```
Mirror the same two methods on `fusion_plating_configurator/models/sale_order_line.py` (similar shape, using `x_fc_part_catalog_id` instead of `part_catalog_id`).
Make sure `UserError` is imported at the top of both files: `from odoo.exceptions import UserError`.
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_line.py fusion_plating_configurator/models/sale_order_line.py fusion_plating_configurator/tests/test_express_bulk_serial_trigger.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): action_upload_drawing + action_open_part on line"
```
---
## Phase C — View XML layer
### Task C1: Currency picker — context-aware `display_name` override
**Files:**
- Create: `fusion_plating_configurator/models/product_pricelist.py`
- Modify: `fusion_plating_configurator/models/__init__.py` (add import)
- [ ] **Step 1: Write the failing test**
Append to `tests/test_express_wizard_fields.py`:
```python
def test_pricelist_display_name_currency_aware_context(self):
# Find or create a pricelist
currency_usd = self.env.ref('base.USD')
currency_usd.active = True
pl = self.env['product.pricelist'].create({
'name': 'Test USD Pricelist',
'currency_id': currency_usd.id,
})
# Without context: standard display name
self.assertNotIn('USD —', pl.display_name)
# With context: currency-prefixed
pl_ctx = pl.with_context(fp_express_currency_picker=True)
self.assertIn('USD', pl_ctx.display_name)
```
- [ ] **Step 2: Run test to verify it fails**
Same command.
Expected: assertion fails (no override yet).
- [ ] **Step 3: Implement the override**
Create `fusion_plating_configurator/models/product_pricelist.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# Part of the Fusion Plating product family.
from odoo import api, models
class ProductPricelist(models.Model):
_inherit = 'product.pricelist'
@api.depends('name', 'currency_id')
@api.depends_context('fp_express_currency_picker')
def _compute_display_name(self):
"""Currency-prefixed display in Express Orders picker; standard elsewhere."""
super()._compute_display_name()
if self.env.context.get('fp_express_currency_picker'):
for pl in self:
if pl.currency_id:
pl.display_name = f"{pl.currency_id.name}{pl.name}"
```
Add to `fusion_plating_configurator/models/__init__.py`:
```python
from . import product_pricelist
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/models/product_pricelist.py fusion_plating_configurator/models/__init__.py fusion_plating_configurator/tests/test_express_wizard_fields.py
git commit -m "feat(configurator): context-aware display_name on product.pricelist for currency picker"
```
---
### Task C2: Quick-create part view (4-field modal)
**Files:**
- Create: `fusion_plating_configurator/views/fp_part_catalog_quick_create_views.xml`
- Modify: `fusion_plating_configurator/__manifest__.py` (add to `data`)
- [ ] **Step 1: Create the view file**
`fusion_plating_configurator/views/fp_part_catalog_quick_create_views.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_part_catalog_quick_create_form" model="ir.ui.view">
<field name="name">fp.part.catalog.quick.create.form</field>
<field name="model">fp.part.catalog</field>
<field name="priority">99</field>
<field name="arch" type="xml">
<form string="Create Part">
<sheet>
<group>
<field name="part_number" required="1"
placeholder="ENG-1042"/>
<field name="revision" required="1"
placeholder="A"/>
<field name="name" placeholder="Part description"/>
<field name="partner_id" required="1"
options="{'no_create_edit': True}"
placeholder="Customer"/>
</group>
</sheet>
<footer>
<button string="Create &amp; Use" type="object"
name="action_quick_create_done"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_part_catalog_quick_create" model="ir.actions.act_window">
<field name="name">Create Part</field>
<field name="res_model">fp.part.catalog</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fp_part_catalog_quick_create_form"/>
<field name="target">new</field>
</record>
</odoo>
```
- [ ] **Step 2: Add `action_quick_create_done` method on `fp.part.catalog`**
Edit `fusion_plating_configurator/models/fp_part_catalog.py` — add:
```python
def action_quick_create_done(self):
"""Close the quick-create modal and return the new part's id to caller."""
self.ensure_one()
return {
'type': 'ir.actions.act_window_close',
'infos': {'created_part_id': self.id},
}
```
The caller (OWL widget on the line) reads `infos.created_part_id` from the close action and assigns it to `part_catalog_id` on the line.
- [ ] **Step 3: Register the view in the manifest**
Edit `fusion_plating_configurator/__manifest__.py` — add to the `data` list (preserve existing entries, just add the new one):
```python
'views/fp_part_catalog_quick_create_views.xml',
```
- [ ] **Step 4: Verify view parses + module reloads**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
```
Expected: no errors. `Modules loaded.` at the end.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/views/fp_part_catalog_quick_create_views.xml fusion_plating_configurator/models/fp_part_catalog.py fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): 4-field quick-create view for fp.part.catalog"
```
---
### Task C3: SCSS tokens + base styles (light + dark)
**Files:**
- Create: `fusion_plating_configurator/static/src/scss/_express_tokens.scss`
- Create: `fusion_plating_configurator/static/src/scss/express_order.scss`
- Modify: `fusion_plating_configurator/__manifest__.py` (add `assets`)
- [ ] **Step 1: Create token file (loads FIRST in bundle)**
`fusion_plating_configurator/static/src/scss/_express_tokens.scss`:
```scss
// Express Orders colour tokens — compile-time dark-mode branch
// per Odoo 19 convention (see CLAUDE.md "Dark Mode" section).
$o-webclient-color-scheme: bright !default;
$_xpr-page-hex: #f3f4f6;
$_xpr-card-hex: #ffffff;
$_xpr-card-soft-hex: #fafafa;
$_xpr-border-hex: #e5e7eb;
$_xpr-border-strong-hex: #d1d5db;
$_xpr-text-hex: #1f2937;
$_xpr-text-muted-hex: #6b7280;
$_xpr-text-dim-hex: #9ca3af;
$_xpr-accent-hex: #714b67;
$_xpr-accent-bg-hex: #faf5f8;
$_xpr-focus-hex: #fef9e7;
$_xpr-good-hex: #059669;
$_xpr-bad-hex: #dc2626;
@if $o-webclient-color-scheme == dark {
$_xpr-page-hex: #1a1d21 !global;
$_xpr-card-hex: #22262d !global;
$_xpr-card-soft-hex: #1e2128 !global;
$_xpr-border-hex: #374151 !global;
$_xpr-border-strong-hex: #4b5563 !global;
$_xpr-text-hex: #e5e7eb !global;
$_xpr-text-muted-hex: #9ca3af !global;
$_xpr-text-dim-hex: #6b7280 !global;
$_xpr-accent-hex: #b88fb5 !global;
$_xpr-accent-bg-hex: #2d2330 !global;
$_xpr-focus-hex: #3d3920 !global;
$_xpr-good-hex: #34d399 !global;
$_xpr-bad-hex: #f87171 !global;
}
$xpr-page: var(--xpr-page-bg, $_xpr-page-hex);
$xpr-card: var(--xpr-card-bg, $_xpr-card-hex);
$xpr-card-soft: var(--xpr-card-soft-bg, $_xpr-card-soft-hex);
$xpr-border: var(--xpr-border, $_xpr-border-hex);
$xpr-border-strong: var(--xpr-border-strong, $_xpr-border-strong-hex);
$xpr-text: var(--xpr-text, $_xpr-text-hex);
$xpr-text-muted: var(--xpr-text-muted, $_xpr-text-muted-hex);
$xpr-text-dim: var(--xpr-text-dim, $_xpr-text-dim-hex);
$xpr-accent: var(--xpr-accent, $_xpr-accent-hex);
$xpr-accent-bg: var(--xpr-accent-bg, $_xpr-accent-bg-hex);
$xpr-focus: var(--xpr-focus, $_xpr-focus-hex);
$xpr-good: var(--xpr-good, $_xpr-good-hex);
$xpr-bad: var(--xpr-bad, $_xpr-bad-hex);
```
- [ ] **Step 2: Create main stylesheet**
`fusion_plating_configurator/static/src/scss/express_order.scss`:
```scss
// Express Orders — main stylesheet
// Tokens are loaded FIRST via the manifest, so $xpr-* are in scope.
.o_fp_express_order {
background: $xpr-page;
color: $xpr-text;
// Part cell — multi-row stacked
.o_fp_part_cell {
display: flex;
flex-direction: column;
gap: 0;
min-width: 210px;
& > div {
padding: 5px 8px;
}
& > div:not(:last-child) {
border-bottom: 1px solid $xpr-border;
}
.o_fp_part_id {
display: flex; align-items: baseline; gap: 4px;
font-weight: 600;
.o_fp_sep { color: $xpr-text-dim; font-weight: 400; padding: 0 4px; }
}
.o_fp_part_name input { font-style: italic; font-size: 12px; }
.o_fp_part_serial input { font-size: 11px; color: $xpr-text-muted; }
}
// Bake pill
.o_fp_bake_pill {
display: inline-flex; align-items: center;
padding: 2px 10px; border-radius: 3px;
font-size: 12px; font-weight: 500;
border: 1px solid $xpr-border-strong;
cursor: pointer;
background: $xpr-card;
color: $xpr-text;
&.has-bake {
background: rgba(251, 146, 60, 0.15);
color: #c2410c;
border-color: #fb923c;
}
&.no-bake {
background: $xpr-card-soft;
color: $xpr-text-muted;
font-style: italic;
}
}
// Line number / drag handle hover swap
.o_fp_row_handle {
text-align: center;
.o_fp_row_num { color: $xpr-text-dim; font-weight: 700; font-size: 11px; }
.o_fp_row_grip { display: none; cursor: grab; color: $xpr-text-dim; }
}
.o_list_view tbody tr:hover .o_fp_row_handle {
.o_fp_row_num { display: none; }
.o_fp_row_grip { display: inline-block; }
}
}
```
- [ ] **Step 3: Register assets in the manifest**
Edit `fusion_plating_configurator/__manifest__.py` — add (if `assets` doesn't exist) or extend an existing `assets` block:
```python
'assets': {
'web.assets_backend': [
'fusion_plating_configurator/static/src/scss/_express_tokens.scss',
'fusion_plating_configurator/static/src/scss/express_order.scss',
],
},
```
**Important** — tokens MUST load FIRST. Odoo concatenates SCSS files in manifest order; `_express_tokens.scss` declares the variables, `express_order.scss` consumes them.
- [ ] **Step 4: Verify SCSS compiles**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "scss|sass|error" | head -10
```
Expected: no SCSS errors. (Visual check happens in Phase E once views render.)
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/static/src/scss/ fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): Express SCSS tokens + base styles (light + dark)"
```
---
### Task C4: OWL widget — `FpExpressPartCell` (multi-row Part cell)
**Files:**
- Create: `fusion_plating_configurator/static/src/js/express_part_cell.js`
- Create: `fusion_plating_configurator/static/src/xml/express_part_cell.xml`
- Modify: `fusion_plating_configurator/__manifest__.py` (add to assets)
- [ ] **Step 1: Create the OWL component**
`fusion_plating_configurator/static/src/js/express_part_cell.js`:
```javascript
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Many2OneField } from "@web/views/fields/many2one/many2one_field";
import { useService } from "@web/core/utils/hooks";
export class FpExpressPartCell extends Component {
static template = "fusion_plating_configurator.FpExpressPartCell";
static props = ["*"];
static components = { Many2OneField };
setup() {
this.action = useService("action");
}
get partName() {
return this.props.record.data.part_catalog_id?.[1] || "";
}
get partNumber() {
const part = this.props.record.data.part_catalog_id;
return part ? part[1].split(" Rev ")[0] : "";
}
get revision() {
return this.props.record.data.revision || "";
}
get serialDisplay() {
const serials = this.props.record.data.serial_ids?.records || [];
return serials.map(s => s.data.name).join(", ");
}
async onBulkAddClick() {
const recordId = this.props.record.resId;
if (!recordId) return;
const action = await this.env.services.orm.call(
this.props.record.resModel,
"action_open_serial_bulk_add",
[[recordId]],
);
this.action.doAction(action);
}
}
registry.category("fields").add("fp_express_part_cell", {
component: FpExpressPartCell,
supportedTypes: ["many2one"],
});
```
- [ ] **Step 2: Create the template**
`fusion_plating_configurator/static/src/xml/express_part_cell.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.FpExpressPartCell">
<div class="o_fp_part_cell">
<div class="o_fp_part_id">
<Many2OneField
record="props.record"
name="'part_catalog_id'"
readonly="false"/>
<span class="o_fp_sep">/</span>
<span><t t-esc="revision"/></span>
</div>
<div class="o_fp_part_name">
<span t-esc="partName"/>
</div>
<div class="o_fp_part_serial">
<span t-esc="serialDisplay or 'no serials yet'"/>
<button class="btn btn-sm btn-link"
t-on-click="onBulkAddClick"
title="Bulk add serials">+ bulk</button>
</div>
</div>
</t>
</templates>
```
- [ ] **Step 3: Register assets in the manifest**
Extend the `assets` block in `__manifest__.py`:
```python
'assets': {
'web.assets_backend': [
'fusion_plating_configurator/static/src/scss/_express_tokens.scss',
'fusion_plating_configurator/static/src/scss/express_order.scss',
'fusion_plating_configurator/static/src/js/express_part_cell.js',
'fusion_plating_configurator/static/src/xml/express_part_cell.xml',
],
},
```
- [ ] **Step 4: Verify module reloads + widget registers**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/static/src/js/express_part_cell.js fusion_plating_configurator/static/src/xml/express_part_cell.xml fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): OWL widget FpExpressPartCell (multi-row part cell)"
```
---
### Task C5: OWL widget — `FpExpressBakePill` (click-to-edit)
**Files:**
- Create: `fusion_plating_configurator/static/src/js/express_bake_pill.js`
- Create: `fusion_plating_configurator/static/src/xml/express_bake_pill.xml`
- Modify: `__manifest__.py`
- [ ] **Step 1: Create the OWL component**
`fusion_plating_configurator/static/src/js/express_bake_pill.js`:
```javascript
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class FpExpressBakePill extends Component {
static template = "fusion_plating_configurator.FpExpressBakePill";
static props = ["*"];
setup() {
this.state = useState({
editing: false,
draft: this.value,
});
}
get value() {
return this.props.record.data[this.props.name] || "";
}
get hasBake() {
return !!(this.value && this.value.trim());
}
get pillLabel() {
return this.hasBake ? this.value : "no bake";
}
openEditor() {
this.state.draft = this.value;
this.state.editing = true;
}
save() {
this.props.record.update({ [this.props.name]: this.state.draft || false });
this.state.editing = false;
}
clear() {
this.props.record.update({ [this.props.name]: false });
this.state.draft = "";
this.state.editing = false;
}
cancel() {
this.state.editing = false;
}
}
registry.category("fields").add("fp_express_bake_pill", {
component: FpExpressBakePill,
supportedTypes: ["text"],
});
```
- [ ] **Step 2: Create the template**
`fusion_plating_configurator/static/src/xml/express_bake_pill.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.FpExpressBakePill">
<t t-if="!state.editing">
<span class="o_fp_bake_pill"
t-att-class="{ 'has-bake': hasBake, 'no-bake': !hasBake }"
t-on-click="openEditor"
t-esc="pillLabel"/>
</t>
<t t-else="">
<div class="o_fp_bake_pill_editor">
<textarea t-model="state.draft" rows="2"
placeholder="e.g. 350F x 4hr"/>
<div>
<button class="btn btn-sm btn-primary" t-on-click="save">Save</button>
<button class="btn btn-sm btn-secondary" t-on-click="clear">Clear</button>
<button class="btn btn-sm btn-link" t-on-click="cancel">Cancel</button>
</div>
</div>
</t>
</t>
</templates>
```
- [ ] **Step 3: Register in manifest**
Add to `assets.web.assets_backend`:
```python
'fusion_plating_configurator/static/src/js/express_bake_pill.js',
'fusion_plating_configurator/static/src/xml/express_bake_pill.xml',
```
- [ ] **Step 4: Verify reload**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
```
Expected: clean.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/static/src/js/express_bake_pill.js fusion_plating_configurator/static/src/xml/express_bake_pill.xml fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): OWL widget FpExpressBakePill (click-to-edit bake pill)"
```
---
### Task C6: Express form view — header + lines + footer
This is the big view file. Single task, but the body is substantial. The view extends `view_fp_direct_order_wizard_form` is **not** the right approach — Express is a fresh view that targets the same model with a different layout. So this is a new `<record>` not an inheritance.
**Files:**
- Create: `fusion_plating_configurator/views/fp_express_order_views.xml`
- Modify: `__manifest__.py` (add to `data`)
- [ ] **Step 1: Create the Express form view**
`fusion_plating_configurator/views/fp_express_order_views.xml` (full file — paste verbatim):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_express_order_form" model="ir.ui.view">
<field name="name">fp.express.order.form</field>
<field name="model">fp.direct.order.wizard</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<form string="Express Order Entry" class="o_fp_express_order">
<header>
<button name="action_create_order" type="object"
string="Confirm Order"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_view_sale_order" type="object"
string="Open Sale Order"
class="btn-primary"
invisible="state != 'confirmed' or not sale_order_id"/>
<button name="action_switch_to_legacy" type="object"
string="Switch to Legacy View"
class="btn-secondary"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<!-- Header grid: Customer + Shipping (row 1), PO block + Job # + Sort (row 2), etc. -->
<group>
<group string="Customer">
<field name="partner_id" options="{'no_create_edit': True}"/>
<field name="partner_shipping_id"
options="{'no_create_edit': False}"
invisible="not partner_id"/>
<field name="customer_job_number"/>
<field name="job_sort_id"
options="{'no_create_edit': False, 'no_open': True}"
placeholder="Type to create a new bucket..."/>
<field name="material_process" placeholder="ENP-STEEL-HP-ADVANCED"/>
</group>
<group string="Purchase Order">
<field name="po_number"
placeholder="Enter the customer PO number"/>
<field name="po_attachment_file"
filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
<field name="po_pending" widget="boolean_toggle"/>
<field name="po_expected_date"
invisible="not po_pending"/>
</group>
</group>
<group>
<group string="Scheduling &amp; Lead Time">
<label for="lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="customer_deadline" string="Delivery Date"/>
<field name="validity_date" string="Quote Validity"/>
<field name="is_blanket_order"/>
</group>
<group string="Pricing &amp; Fulfilment">
<field name="pricelist_id"
string="Currency / Pricelist"
context="{'fp_express_currency_picker': True}"
options="{'no_create_edit': True}"/>
<field name="payment_term_id"
options="{'no_create': True}"/>
<field name="delivery_method"/>
<field name="invoice_strategy"/>
</group>
</group>
<notebook>
<page string="Lines" name="lines">
<field name="line_ids">
<list editable="bottom"
decoration-warning="is_missing_info">
<field name="is_missing_info" column_invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="part_catalog_id"
widget="fp_express_part_cell"
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"/>
<field name="line_description" string="Specification"/>
<field name="customer_line_ref" string="Line Job #"/>
<field name="thickness_range" placeholder="e.g. .0005-.0010"/>
<field name="masking_enabled" widget="boolean_toggle"/>
<field name="bake_instructions" widget="fp_express_bake_pill"/>
<field name="internal_description" string="Internal Notes"/>
<field name="quantity"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<button name="action_upload_drawing" type="object"
string="DWG" class="btn-link"
invisible="not part_catalog_id"/>
<button name="action_open_part" type="object"
string="OPEN" class="btn-link"
invisible="not part_catalog_id"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
<group class="mt-3">
<group>
<field name="total_line_count" readonly="1"/>
<field name="total_qty" readonly="1"/>
</group>
<group>
<field name="total_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1"/>
</group>
</group>
</page>
<page string="Notes &amp; Terms" name="notes">
<group>
<group string="Order-Level Internal Notes (never prints)">
<field name="internal_notes" nolabel="1"
placeholder="Visible only to estimator / planner / shop."/>
</group>
<group string="Terms &amp; Conditions (prints on customer docs)">
<field name="terms_and_conditions" nolabel="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_express_order" model="ir.actions.act_window">
<field name="name">+ New Express Order</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fp_express_order_form"/>
<field name="target">current</field>
<field name="context">{'default_view_source': 'express'}</field>
</record>
<menuitem id="menu_fp_express_order"
name="+ New Express Order"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_express_order"
sequence="2"/>
</odoo>
```
- [ ] **Step 2: Add `action_switch_to_legacy` method**
Edit `fusion_plating_configurator/wizard/fp_direct_order_wizard.py` — add:
```python
def action_switch_to_legacy(self):
"""Re-open this draft in the legacy view."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_plating_configurator.view_fp_direct_order_wizard_form'
).id,
'target': 'current',
}
```
Also add the mirror `action_switch_to_express` (for the legacy view to call):
```python
def action_switch_to_express(self):
"""Re-open this draft in the Express view."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_plating_configurator.view_fp_express_order_form'
).id,
'target': 'current',
}
```
- [ ] **Step 3: Register the view in the manifest**
Add to `__manifest__.py` `data` (after the quick-create view registered in Task C2):
```python
'views/fp_express_order_views.xml',
```
Confirm the menu parent `fusion_plating_configurator.menu_fp_sales` exists by greping. If the menu xmlid is different, adjust to match the existing parent.
- [ ] **Step 4: Verify view loads + menu appears**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error|warning" | head -20
```
Expected: clean. Open `http://localhost:8069/web` → Plating → Sales & Quoting → "+ New Express Order" should appear.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_configurator/views/fp_express_order_views.xml fusion_plating_configurator/wizard/fp_direct_order_wizard.py fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): Express Orders form view + action + menu item"
```
---
## Phase D — Drafts list + view switching
### Task D1: Drafts list `view_source` badge column + dual-routing
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml`
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py` (add `action_open_draft`)
- Create: `fusion_plating_configurator/tests/test_express_drafts_routing.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_express_drafts_routing.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressDraftsRouting(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
def test_express_draft_routes_to_express_view(self):
wiz = self.env['fp.direct.order.wizard'].create({
'partner_id': self.partner.id, 'view_source': 'express',
})
action = wiz.action_open_draft()
expected = self.env.ref('fusion_plating_configurator.view_fp_express_order_form').id
self.assertEqual(action['view_id'], expected)
def test_legacy_draft_routes_to_legacy_view(self):
wiz = self.env['fp.direct.order.wizard'].create({
'partner_id': self.partner.id, 'view_source': 'legacy',
})
action = wiz.action_open_draft()
expected = self.env.ref('fusion_plating_configurator.view_fp_direct_order_wizard_form').id
self.assertEqual(action['view_id'], expected)
```
Wire up.
- [ ] **Step 2: Run test to verify it fails**
Same command.
Expected: `AttributeError: action_open_draft`.
- [ ] **Step 3: Implement `action_open_draft`**
Add to `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`:
```python
def action_open_draft(self):
"""Route the drafts-list row-click to the form view matching view_source."""
self.ensure_one()
view_xmlid = (
'fusion_plating_configurator.view_fp_express_order_form'
if self.view_source == 'express'
else 'fusion_plating_configurator.view_fp_direct_order_wizard_form'
)
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(view_xmlid).id,
'target': 'current',
}
```
- [ ] **Step 4: Run test to verify it passes**
Same command.
Expected: `OK`.
- [ ] **Step 5: Add `view_source` badge column to the drafts list**
Edit `fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml` — find the `view_fp_direct_order_wizard_list` record and add a new field inside `<list>`:
```xml
<field name="view_source" widget="badge"
decoration-info="view_source == 'express'"
decoration-muted="view_source == 'legacy'"
optional="show"/>
```
Also change the list's row-click to route via the method:
```xml
<list ...
default_order="create_date desc"
action="action_open_draft"
type="object">
```
(Standard Odoo list view supports `action="..."` + `type="object"` to override the row-click target.)
- [ ] **Step 6: Verify view loads**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
```
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml fusion_plating_configurator/tests/test_express_drafts_routing.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): drafts list view_source badge + dual-routing click action"
```
---
### Task D2: Phase 2 deprecation banner on legacy view
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml`
This task is shipped LATER (Phase 2, ~Week 3 after launch). Tracking here for completeness so the writing-plans output captures the full timeline.
- [ ] **Step 1: Add the banner**
Find the legacy form's `<sheet>` opening tag in `view_fp_direct_order_wizard_form`. Just before it, add:
```xml
<div class="alert alert-warning py-2 mb-0 small"
role="alert">
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Legacy view.</strong> This form is being retired
in favour of the new Express Orders view, which is faster
for batch entry.
<button name="action_switch_to_express" type="object"
string="Switch to Express ➜"
class="btn-link btn-sm py-0"/>
</div>
```
- [ ] **Step 2: Verify view loads + banner appears**
```bash
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
```
Expected: clean. Manually verify banner appears on the legacy form.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
git commit -m "feat(configurator): Phase 2 deprecation banner on legacy direct-order view"
```
---
## Phase E — Smoke test runbook
### Task E1: End-to-end smoke test
This is a manual checklist, not an automated test. Run after all Phase AD tasks pass on local dev.
**Files:**
- Create: `fusion_plating_configurator/docs/express_orders_smoke_test.md` (in-repo runbook)
- [ ] **Step 1: Create the runbook**
`fusion_plating_configurator/docs/express_orders_smoke_test.md`:
```markdown
# Express Orders — Smoke Test Runbook
Run after deploy to confirm end-to-end flow. ~10 minutes.
## Setup
1. Open http://localhost:8069/web → Plating → Sales & Quoting → **+ New Express Order**
2. Confirm the form opens with the Express layout (NOT the legacy form)
## Test 1 — Happy path with mask + bake
1. Pick customer "WESTIN HEALTHCARE INC."
2. Confirm `partner_shipping_id` defaults to the customer's first address
3. Confirm `pricelist_id` defaults to the customer's default pricelist
4. Type PO# "PO-SMOKE-01"
5. Upload any PDF as the PO attachment
6. Add a line — pick part "ENG-1042 Rev B" (or create one via the inline + Create modal)
7. Confirm auto-fill: description, thickness, masking checkbox, bake pill all populate from part defaults
8. Set quantity 5, price 42.00
9. Click "Confirm Order"
10. Expected:
- SO is created
- `fp.job` row exists for the SO
- Job's recipe is the one linked to the part
- No `fp.job.node.override` rows (masking on, bake non-empty)
- Bake step's `instructions` field shows the bake text
- Job chatter has the audit post: "Bake step instructions set to: ..."
## Test 2 — Masking opt-out
1. Repeat Test 1 but uncheck the masking checkbox before confirming
2. Expected:
- `fp.job.node.override` rows exist with `included=False` for masking AND de_masking nodes
- Job chatter audit: "Masking + de-masking steps opted out (per SO line)"
## Test 3 — Bake opt-out
1. Repeat Test 1 but clear the bake pill (set to "no bake") before confirming
2. Expected:
- `fp.job.node.override` row exists with `included=False` for baking node
- Job chatter audit: "Baking steps opted out (per SO line)"
## Test 4 — Bulk-add serials
1. On a line, click the `+ bulk` button on the serial row
2. Bulk-add wizard opens
3. Switch to Range Fill mode, prefix SN-, start 1, end 5
4. Click Add Serials
5. Expected: line's `serial_ids` populated with SN-001 … SN-005
## Test 5 — DWG / OPEN buttons
1. On a line with a part selected, click DWG → upload a PDF
2. Expected: file attaches to `part.drawing_attachment_ids`, button shows updated count
3. Click OPEN
4. Expected: part form opens in a modal
## Test 6 — Currency switch
1. On a draft, change the pricelist from CAD to a USD pricelist
2. Confirm prompt: "Update Prices?" → click Yes
3. Expected: line `unit_price` values are recomputed in USD; currency pill on Grand Total flips to USD
## Test 7 — Round-trip with legacy view
1. Create a draft in Express view, save (no confirm)
2. Go to "Direct Order Drafts" list
3. Confirm the draft shows badge "Express"
4. Open the draft → confirms Express view loads
5. Click "Switch to Legacy View" header button
6. Confirm same data renders in the legacy form
7. Go back to drafts list, click the row → still routes to Express (because view_source='express')
```
- [ ] **Step 2: Commit**
```bash
git add fusion_plating_configurator/docs/
git commit -m "docs(configurator): Express Orders smoke test runbook"
```
---
## Phase F — Deferred: Phase 3 & 4 (post-launch)
These two tasks ship LATER (Weeks 45+ after launch). Captured here so the plan is complete.
### Task F1 (Phase 3, Week 4): Hide legacy menu + auto-redirect
**Files:**
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml`
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
Steps:
1. Add `groups="base.group_no_one"` (debug-mode-only) to the `+ New Direct Order` menuitem so it's hidden in normal mode.
2. Patch `action_open_draft` to ALWAYS return the Express view:
```python
def action_open_draft(self):
self.ensure_one()
# Phase 3 (2026-XX-XX): all drafts auto-route to Express
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_plating_configurator.view_fp_express_order_form'
).id,
'target': 'current',
}
```
3. Bump module version (e.g. 19.0.23.0.0).
4. Commit: `chore(configurator): Phase 3 — hide legacy menu, auto-redirect drafts`
### Task F2 (Phase 4, Week 5+): Full removal
**Files:**
- Delete: `view_fp_direct_order_wizard_form` record
- Delete: `action_fp_direct_order_wizard` record
- Delete: `+ New Direct Order` menuitem
- Create: `fusion_plating_configurator/migrations/19.0.24.0.0/post-migrate.py`
The post-migration:
```python
def migrate(cr, version):
# 1. Drop the view_source column (no longer used)
cr.execute("ALTER TABLE fp_direct_order_wizard DROP COLUMN IF EXISTS view_source")
# 2. Sweep orphan landing actions — any user whose landing pointed at the
# retired legacy action gets re-pointed at the Express action.
cr.execute("""
UPDATE res_users
SET x_fc_plating_landing_action_id = (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'action_fp_express_order'
AND model = 'ir.actions.act_window'
LIMIT 1
)
WHERE x_fc_plating_landing_action_id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'action_fp_direct_order_wizard'
AND model = 'ir.actions.act_window'
)
""")
```
Also remove `view_source` field from the model definition. Commit: `chore(configurator): Phase 4 — remove legacy direct-order view + view_source column`
---
## Self-Review
**Spec coverage check** — every NEW field + helper + view + button from the spec has a task above:
- `fp.part.catalog.default_specification_text/default_bake_instructions/default_masking_enabled` → A1 ✓
- `fp.direct.order.line.customer_line_ref/masking_enabled/bake_instructions` → A2 ✓
- `sale.order.line.x_fc_*` (3 fields) → A3 ✓
- `sale.order.x_fc_material_process/x_fc_internal_notes/x_fc_print_terms` → A4 ✓
- `fp.direct.order.wizard.material_process/pricelist_id/validity_date/internal_notes/terms_and_conditions/view_source` + rename notes + retire currency_id → A5 ✓
- `_fp_all_nodes_with_kind` helper → B1 ✓
- `_fp_apply_express_overrides_to_job` helper + 4 quadrants + audit + idempotency → B2 ✓
- Hook into `_fp_auto_create_job` → B3 ✓
- Onchange auto-fill cascade → B4 ✓
- `_prepare_order_line_vals` carry-through → B5 ✓
- Part-default write-back on confirm → B6 ✓
- `action_open_serial_bulk_add` → B7 ✓
- `action_upload_drawing` + `action_open_part` → B8 ✓
- Currency picker context-aware display_name → C1 ✓
- Quick-create part view → C2 ✓
- SCSS tokens + light/dark → C3 ✓
- OWL widget FpExpressPartCell → C4 ✓
- OWL widget FpExpressBakePill → C5 ✓
- Express form view + menu + action + `action_switch_to_legacy`/`action_switch_to_express` → C6 ✓
- Drafts list dual-routing + badge → D1 ✓
- Phase 2 deprecation banner → D2 ✓
- Smoke test runbook → E1 ✓
- Phase 3 + 4 deferred tasks → F1, F2 ✓
**No placeholder check** — all steps have concrete code, exact file paths, and runnable commands.
**Type consistency** — method names verified consistent: `_fp_apply_express_overrides_to_job`, `_fp_all_nodes_with_kind`, `action_open_serial_bulk_add`, `action_upload_drawing`, `action_open_part`, `action_open_draft`, `action_switch_to_legacy`, `action_switch_to_express`, `action_quick_create_done`. Field names verified consistent across tasks: `x_fc_masking_enabled`, `x_fc_bake_instructions`, `x_fc_customer_line_ref`, `default_specification_text`, `default_bake_instructions`, `default_masking_enabled`, `material_process`, `pricelist_id`, `validity_date`, `internal_notes`, `terms_and_conditions`, `view_source`, `x_fc_material_process`, `x_fc_internal_notes`, `x_fc_print_terms`.
---
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-05-26-express-orders-plan.md`. Two execution options:
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?