103 KiB
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
Visual reference: .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):
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:
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):
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:
'version': '19.0.22.0.0',
- Step 2: Create migration directory
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
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:
# -*- 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:
from . import test_express_part_defaults
- Step 2: Run test to verify it fails
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):
# ---- 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
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:
# -*- 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
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):
# ---- 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
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:
# -*- 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
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:
# ---- 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
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
# 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
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:
# ---- 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
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
# 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
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:
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):
# ---- 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:
'note': self.notes or False,
with:
'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:
# -*- 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
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
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
# 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
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:
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
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
# 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
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:
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
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:
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
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:
# 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
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
# 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
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:
# 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
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:
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:
# 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
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
# 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:
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):
# 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
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
# 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:
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:
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
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):
@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:
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
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:
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:
# -*- 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:
from . import product_pricelist
- Step 4: Run test to verify it passes
Same command.
Expected: OK.
- Step 5: Commit
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 todata) -
Step 1: Create the view file
fusion_plating_configurator/views/fp_part_catalog_quick_create_views.xml:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_part_catalog_quick_create_form" model="ir.ui.view">
<field name="name">fp.part.catalog.quick.create.form</field>
<field name="model">fp.part.catalog</field>
<field name="priority">99</field>
<field name="arch" type="xml">
<form string="Create Part">
<sheet>
<group>
<field name="part_number" required="1"
placeholder="ENG-1042"/>
<field name="revision" required="1"
placeholder="A"/>
<field name="name" placeholder="Part description"/>
<field name="partner_id" required="1"
options="{'no_create_edit': True}"
placeholder="Customer"/>
</group>
</sheet>
<footer>
<button string="Create & Use" type="object"
name="action_quick_create_done"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_part_catalog_quick_create" model="ir.actions.act_window">
<field name="name">Create Part</field>
<field name="res_model">fp.part.catalog</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fp_part_catalog_quick_create_form"/>
<field name="target">new</field>
</record>
</odoo>
- Step 2: Add
action_quick_create_donemethod onfp.part.catalog
Edit fusion_plating_configurator/models/fp_part_catalog.py — add:
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):
'views/fp_part_catalog_quick_create_views.xml',
- Step 4: Verify view parses + module reloads
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
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(addassets) -
Step 1: Create token file (loads FIRST in bundle)
fusion_plating_configurator/static/src/scss/_express_tokens.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:
// 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:
'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
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
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:
/** @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 version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.FpExpressPartCell">
<div class="o_fp_part_cell">
<div class="o_fp_part_id">
<Many2OneField
record="props.record"
name="'part_catalog_id'"
readonly="false"/>
<span class="o_fp_sep">/</span>
<span><t t-esc="revision"/></span>
</div>
<div class="o_fp_part_name">
<span t-esc="partName"/>
</div>
<div class="o_fp_part_serial">
<span t-esc="serialDisplay or 'no serials yet'"/>
<button class="btn btn-sm btn-link"
t-on-click="onBulkAddClick"
title="Bulk add serials">+ bulk</button>
</div>
</div>
</t>
</templates>
- Step 3: Register assets in the manifest
Extend the assets block in __manifest__.py:
'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
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
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:
/** @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 version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.FpExpressBakePill">
<t t-if="!state.editing">
<span class="o_fp_bake_pill"
t-att-class="{ 'has-bake': hasBake, 'no-bake': !hasBake }"
t-on-click="openEditor"
t-esc="pillLabel"/>
</t>
<t t-else="">
<div class="o_fp_bake_pill_editor">
<textarea t-model="state.draft" rows="2"
placeholder="e.g. 350F x 4hr"/>
<div>
<button class="btn btn-sm btn-primary" t-on-click="save">Save</button>
<button class="btn btn-sm btn-secondary" t-on-click="clear">Clear</button>
<button class="btn btn-sm btn-link" t-on-click="cancel">Cancel</button>
</div>
</div>
</t>
</t>
</templates>
- Step 3: Register in manifest
Add to assets.web.assets_backend:
'fusion_plating_configurator/static/src/js/express_bake_pill.js',
'fusion_plating_configurator/static/src/xml/express_bake_pill.xml',
- Step 4: Verify reload
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
Expected: clean.
- Step 5: Commit
git add fusion_plating_configurator/static/src/js/express_bake_pill.js fusion_plating_configurator/static/src/xml/express_bake_pill.xml fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): OWL widget FpExpressBakePill (click-to-edit bake pill)"
Task C6: Express form view — header + lines + footer
This is the big view file. Single task, but the body is substantial. The view extends view_fp_direct_order_wizard_form is not the right approach — Express is a fresh view that targets the same model with a different layout. So this is a new <record> not an inheritance.
Files:
-
Create:
fusion_plating_configurator/views/fp_express_order_views.xml -
Modify:
__manifest__.py(add todata) -
Step 1: Create the Express form view
fusion_plating_configurator/views/fp_express_order_views.xml (full file — paste verbatim):
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_express_order_form" model="ir.ui.view">
<field name="name">fp.express.order.form</field>
<field name="model">fp.direct.order.wizard</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<form string="Express Order Entry" class="o_fp_express_order">
<header>
<button name="action_create_order" type="object"
string="Confirm Order"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_view_sale_order" type="object"
string="Open Sale Order"
class="btn-primary"
invisible="state != 'confirmed' or not sale_order_id"/>
<button name="action_switch_to_legacy" type="object"
string="Switch to Legacy View"
class="btn-secondary"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<!-- Header grid: Customer + Shipping (row 1), PO block + Job # + Sort (row 2), etc. -->
<group>
<group string="Customer">
<field name="partner_id" options="{'no_create_edit': True}"/>
<field name="partner_shipping_id"
options="{'no_create_edit': False}"
invisible="not partner_id"/>
<field name="customer_job_number"/>
<field name="job_sort_id"
options="{'no_create_edit': False, 'no_open': True}"
placeholder="Type to create a new bucket..."/>
<field name="material_process" placeholder="ENP-STEEL-HP-ADVANCED"/>
</group>
<group string="Purchase Order">
<field name="po_number"
placeholder="Enter the customer PO number"/>
<field name="po_attachment_file"
filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
<field name="po_pending" widget="boolean_toggle"/>
<field name="po_expected_date"
invisible="not po_pending"/>
</group>
</group>
<group>
<group string="Scheduling & Lead Time">
<label for="lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="customer_deadline" string="Delivery Date"/>
<field name="validity_date" string="Quote Validity"/>
<field name="is_blanket_order"/>
</group>
<group string="Pricing & Fulfilment">
<field name="pricelist_id"
string="Currency / Pricelist"
context="{'fp_express_currency_picker': True}"
options="{'no_create_edit': True}"/>
<field name="payment_term_id"
options="{'no_create': True}"/>
<field name="delivery_method"/>
<field name="invoice_strategy"/>
</group>
</group>
<notebook>
<page string="Lines" name="lines">
<field name="line_ids">
<list editable="bottom"
decoration-warning="is_missing_info">
<field name="is_missing_info" column_invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="part_catalog_id"
widget="fp_express_part_cell"
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"/>
<field name="line_description" string="Specification"/>
<field name="customer_line_ref" string="Line Job #"/>
<field name="thickness_range" placeholder="e.g. .0005-.0010"/>
<field name="masking_enabled" widget="boolean_toggle"/>
<field name="bake_instructions" widget="fp_express_bake_pill"/>
<field name="internal_description" string="Internal Notes"/>
<field name="quantity"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<button name="action_upload_drawing" type="object"
string="DWG" class="btn-link"
invisible="not part_catalog_id"/>
<button name="action_open_part" type="object"
string="OPEN" class="btn-link"
invisible="not part_catalog_id"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
<group class="mt-3">
<group>
<field name="total_line_count" readonly="1"/>
<field name="total_qty" readonly="1"/>
</group>
<group>
<field name="total_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1"/>
</group>
</group>
</page>
<page string="Notes & Terms" name="notes">
<group>
<group string="Order-Level Internal Notes (never prints)">
<field name="internal_notes" nolabel="1"
placeholder="Visible only to estimator / planner / shop."/>
</group>
<group string="Terms & Conditions (prints on customer docs)">
<field name="terms_and_conditions" nolabel="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_express_order" model="ir.actions.act_window">
<field name="name">+ New Express Order</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fp_express_order_form"/>
<field name="target">current</field>
<field name="context">{'default_view_source': 'express'}</field>
</record>
<menuitem id="menu_fp_express_order"
name="+ New Express Order"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_express_order"
sequence="2"/>
</odoo>
- Step 2: Add
action_switch_to_legacymethod
Edit fusion_plating_configurator/wizard/fp_direct_order_wizard.py — add:
def action_switch_to_legacy(self):
"""Re-open this draft in the legacy view."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_plating_configurator.view_fp_direct_order_wizard_form'
).id,
'target': 'current',
}
Also add the mirror action_switch_to_express (for the legacy view to call):
def action_switch_to_express(self):
"""Re-open this draft in the Express view."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_plating_configurator.view_fp_express_order_form'
).id,
'target': 'current',
}
- Step 3: Register the view in the manifest
Add to __manifest__.py data (after the quick-create view registered in Task C2):
'views/fp_express_order_views.xml',
Confirm the menu parent fusion_plating_configurator.menu_fp_sales exists by greping. If the menu xmlid is different, adjust to match the existing parent.
- Step 4: Verify view loads + menu appears
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error|warning" | head -20
Expected: clean. Open http://localhost:8069/web → Plating → Sales & Quoting → "+ New Express Order" should appear.
- Step 5: Commit
git add fusion_plating_configurator/views/fp_express_order_views.xml fusion_plating_configurator/wizard/fp_direct_order_wizard.py fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): Express Orders form view + action + menu item"
Phase D — Drafts list + view switching
Task D1: Drafts list view_source badge column + dual-routing
Files:
-
Modify:
fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml -
Modify:
fusion_plating_configurator/wizard/fp_direct_order_wizard.py(addaction_open_draft) -
Create:
fusion_plating_configurator/tests/test_express_drafts_routing.py -
Step 1: Write the failing test
# tests/test_express_drafts_routing.py
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_express')
class TestExpressDraftsRouting(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
def test_express_draft_routes_to_express_view(self):
wiz = self.env['fp.direct.order.wizard'].create({
'partner_id': self.partner.id, 'view_source': 'express',
})
action = wiz.action_open_draft()
expected = self.env.ref('fusion_plating_configurator.view_fp_express_order_form').id
self.assertEqual(action['view_id'], expected)
def test_legacy_draft_routes_to_legacy_view(self):
wiz = self.env['fp.direct.order.wizard'].create({
'partner_id': self.partner.id, 'view_source': 'legacy',
})
action = wiz.action_open_draft()
expected = self.env.ref('fusion_plating_configurator.view_fp_direct_order_wizard_form').id
self.assertEqual(action['view_id'], expected)
Wire up.
- Step 2: Run test to verify it fails
Same command.
Expected: AttributeError: action_open_draft.
- Step 3: Implement
action_open_draft
Add to fusion_plating_configurator/wizard/fp_direct_order_wizard.py:
def action_open_draft(self):
"""Route the drafts-list row-click to the form view matching view_source."""
self.ensure_one()
view_xmlid = (
'fusion_plating_configurator.view_fp_express_order_form'
if self.view_source == 'express'
else 'fusion_plating_configurator.view_fp_direct_order_wizard_form'
)
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(view_xmlid).id,
'target': 'current',
}
- Step 4: Run test to verify it passes
Same command.
Expected: OK.
- Step 5: Add
view_sourcebadge column to the drafts list
Edit fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml — find the view_fp_direct_order_wizard_list record and add a new field inside <list>:
<field name="view_source" widget="badge"
decoration-info="view_source == 'express'"
decoration-muted="view_source == 'legacy'"
optional="show"/>
Also change the list's row-click to route via the method:
<list ...
default_order="create_date desc"
action="action_open_draft"
type="object">
(Standard Odoo list view supports action="..." + type="object" to override the row-click target.)
- Step 6: Verify view loads
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
Expected: clean.
- Step 7: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml fusion_plating_configurator/tests/test_express_drafts_routing.py fusion_plating_configurator/tests/__init__.py
git commit -m "feat(configurator): drafts list view_source badge + dual-routing click action"
Task D2: Phase 2 deprecation banner on legacy view
Files:
- Modify:
fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
This task is shipped LATER (Phase 2, ~Week 3 after launch). Tracking here for completeness so the writing-plans output captures the full timeline.
- Step 1: Add the banner
Find the legacy form's <sheet> opening tag in view_fp_direct_order_wizard_form. Just before it, add:
<div class="alert alert-warning py-2 mb-0 small"
role="alert">
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Legacy view.</strong> This form is being retired
in favour of the new Express Orders view, which is faster
for batch entry.
<button name="action_switch_to_express" type="object"
string="Switch to Express ➜"
class="btn-link btn-sm py-0"/>
</div>
- Step 2: Verify view loads + banner appears
docker exec odoo-dev-app odoo -c /etc/odoo/odoo.conf -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -iE "error" | head -10
Expected: clean. Manually verify banner appears on the legacy form.
- Step 3: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
git commit -m "feat(configurator): Phase 2 deprecation banner on legacy direct-order view"
Phase E — Smoke test runbook
Task E1: End-to-end smoke test
This is a manual checklist, not an automated test. Run after all Phase A–D tasks pass on local dev.
Files:
-
Create:
fusion_plating_configurator/docs/express_orders_smoke_test.md(in-repo runbook) -
Step 1: Create the runbook
fusion_plating_configurator/docs/express_orders_smoke_test.md:
# Express Orders — Smoke Test Runbook
Run after deploy to confirm end-to-end flow. ~10 minutes.
## Setup
1. Open http://localhost:8069/web → Plating → Sales & Quoting → **+ New Express Order**
2. Confirm the form opens with the Express layout (NOT the legacy form)
## Test 1 — Happy path with mask + bake
1. Pick customer "WESTIN HEALTHCARE INC."
2. Confirm `partner_shipping_id` defaults to the customer's first address
3. Confirm `pricelist_id` defaults to the customer's default pricelist
4. Type PO# "PO-SMOKE-01"
5. Upload any PDF as the PO attachment
6. Add a line — pick part "ENG-1042 Rev B" (or create one via the inline + Create modal)
7. Confirm auto-fill: description, thickness, masking checkbox, bake pill all populate from part defaults
8. Set quantity 5, price 42.00
9. Click "Confirm Order"
10. Expected:
- SO is created
- `fp.job` row exists for the SO
- Job's recipe is the one linked to the part
- No `fp.job.node.override` rows (masking on, bake non-empty)
- Bake step's `instructions` field shows the bake text
- Job chatter has the audit post: "Bake step instructions set to: ..."
## Test 2 — Masking opt-out
1. Repeat Test 1 but uncheck the masking checkbox before confirming
2. Expected:
- `fp.job.node.override` rows exist with `included=False` for masking AND de_masking nodes
- Job chatter audit: "Masking + de-masking steps opted out (per SO line)"
## Test 3 — Bake opt-out
1. Repeat Test 1 but clear the bake pill (set to "no bake") before confirming
2. Expected:
- `fp.job.node.override` row exists with `included=False` for baking node
- Job chatter audit: "Baking steps opted out (per SO line)"
## Test 4 — Bulk-add serials
1. On a line, click the `+ bulk` button on the serial row
2. Bulk-add wizard opens
3. Switch to Range Fill mode, prefix SN-, start 1, end 5
4. Click Add Serials
5. Expected: line's `serial_ids` populated with SN-001 … SN-005
## Test 5 — DWG / OPEN buttons
1. On a line with a part selected, click DWG → upload a PDF
2. Expected: file attaches to `part.drawing_attachment_ids`, button shows updated count
3. Click OPEN
4. Expected: part form opens in a modal
## Test 6 — Currency switch
1. On a draft, change the pricelist from CAD to a USD pricelist
2. Confirm prompt: "Update Prices?" → click Yes
3. Expected: line `unit_price` values are recomputed in USD; currency pill on Grand Total flips to USD
## Test 7 — Round-trip with legacy view
1. Create a draft in Express view, save (no confirm)
2. Go to "Direct Order Drafts" list
3. Confirm the draft shows badge "Express"
4. Open the draft → confirms Express view loads
5. Click "Switch to Legacy View" header button
6. Confirm same data renders in the legacy form
7. Go back to drafts list, click the row → still routes to Express (because view_source='express')
- Step 2: Commit
git add fusion_plating_configurator/docs/
git commit -m "docs(configurator): Express Orders smoke test runbook"
Phase F — Deferred: Phase 3 & 4 (post-launch)
These two tasks ship LATER (Weeks 4–5+ after launch). Captured here so the plan is complete.
Task F1 (Phase 3, Week 4): Hide legacy menu + auto-redirect
Files:
- Modify:
fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml - Modify:
fusion_plating_configurator/wizard/fp_direct_order_wizard.py
Steps:
- Add
groups="base.group_no_one"(debug-mode-only) to the+ New Direct Ordermenuitem so it's hidden in normal mode. - Patch
action_open_draftto ALWAYS return the Express view:
def action_open_draft(self):
self.ensure_one()
# Phase 3 (2026-XX-XX): all drafts auto-route to Express
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_plating_configurator.view_fp_express_order_form'
).id,
'target': 'current',
}
- Bump module version (e.g. 19.0.23.0.0).
- Commit:
chore(configurator): Phase 3 — hide legacy menu, auto-redirect drafts
Task F2 (Phase 4, Week 5+): Full removal
Files:
- Delete:
view_fp_direct_order_wizard_formrecord - Delete:
action_fp_direct_order_wizardrecord - Delete:
+ New Direct Ordermenuitem - Create:
fusion_plating_configurator/migrations/19.0.24.0.0/post-migrate.py
The post-migration:
def migrate(cr, version):
# 1. Drop the view_source column (no longer used)
cr.execute("ALTER TABLE fp_direct_order_wizard DROP COLUMN IF EXISTS view_source")
# 2. Sweep orphan landing actions — any user whose landing pointed at the
# retired legacy action gets re-pointed at the Express action.
cr.execute("""
UPDATE res_users
SET x_fc_plating_landing_action_id = (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'action_fp_express_order'
AND model = 'ir.actions.act_window'
LIMIT 1
)
WHERE x_fc_plating_landing_action_id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'action_fp_direct_order_wizard'
AND model = 'ir.actions.act_window'
)
""")
Also remove view_source field from the model definition. Commit: chore(configurator): Phase 4 — remove legacy direct-order view + view_source column
Self-Review
Spec coverage check — every NEW field + helper + view + button from the spec has a task above:
fp.part.catalog.default_specification_text/default_bake_instructions/default_masking_enabled→ A1 ✓fp.direct.order.line.customer_line_ref/masking_enabled/bake_instructions→ A2 ✓sale.order.line.x_fc_*(3 fields) → A3 ✓sale.order.x_fc_material_process/x_fc_internal_notes/x_fc_print_terms→ A4 ✓fp.direct.order.wizard.material_process/pricelist_id/validity_date/internal_notes/terms_and_conditions/view_source+ rename notes + retire currency_id → A5 ✓_fp_all_nodes_with_kindhelper → B1 ✓_fp_apply_express_overrides_to_jobhelper + 4 quadrants + audit + idempotency → B2 ✓- Hook into
_fp_auto_create_job→ B3 ✓ - Onchange auto-fill cascade → B4 ✓
_prepare_order_line_valscarry-through → B5 ✓- Part-default write-back on confirm → B6 ✓
action_open_serial_bulk_add→ B7 ✓action_upload_drawing+action_open_part→ B8 ✓- Currency picker context-aware display_name → C1 ✓
- Quick-create part view → C2 ✓
- SCSS tokens + light/dark → C3 ✓
- OWL widget FpExpressPartCell → C4 ✓
- OWL widget FpExpressBakePill → C5 ✓
- Express form view + menu + action +
action_switch_to_legacy/action_switch_to_express→ C6 ✓ - Drafts list dual-routing + badge → D1 ✓
- Phase 2 deprecation banner → D2 ✓
- Smoke test runbook → E1 ✓
- Phase 3 + 4 deferred tasks → F1, F2 ✓
No placeholder check — all steps have concrete code, exact file paths, and runnable commands.
Type consistency — method names verified consistent: _fp_apply_express_overrides_to_job, _fp_all_nodes_with_kind, action_open_serial_bulk_add, action_upload_drawing, action_open_part, action_open_draft, action_switch_to_legacy, action_switch_to_express, action_quick_create_done. Field names verified consistent across tasks: x_fc_masking_enabled, x_fc_bake_instructions, x_fc_customer_line_ref, default_specification_text, default_bake_instructions, default_masking_enabled, material_process, pricelist_id, validity_date, internal_notes, terms_and_conditions, view_source, x_fc_material_process, x_fc_internal_notes, x_fc_print_terms.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-26-express-orders-plan.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?