# 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 142–145 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 fp.part.catalog.quick.create.form fp.part.catalog 99
Create Part fp.part.catalog form new
``` - [ ] **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
/
``` - [ ] **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