diff --git a/fusion_plating/docs/superpowers/plans/2026-05-26-express-orders-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-26-express-orders-plan.md new file mode 100644 index 00000000..1cbe903d --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-26-express-orders-plan.md @@ -0,0 +1,2757 @@ +# 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 + + + + + + + + +
+