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

103 KiB
Raw Blame History

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.419.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 142145 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 to data)

  • 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 &amp; Use" type="object"
                            name="action_quick_create_done"
                            class="btn-primary"/>
                    <button string="Cancel" class="btn-secondary"
                            special="cancel"/>
                </footer>
            </form>
        </field>
    </record>

    <record id="action_fp_part_catalog_quick_create" model="ir.actions.act_window">
        <field name="name">Create Part</field>
        <field name="res_model">fp.part.catalog</field>
        <field name="view_mode">form</field>
        <field name="view_id" ref="view_fp_part_catalog_quick_create_form"/>
        <field name="target">new</field>
    </record>

</odoo>
  • Step 2: Add action_quick_create_done method on fp.part.catalog

Edit fusion_plating_configurator/models/fp_part_catalog.py — add:

    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 (add assets)

  • 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)"

This is the big view file. Single task, but the body is substantial. The view extends view_fp_direct_order_wizard_form is not the right approach — Express is a fresh view that targets the same model with a different layout. So this is a new <record> not an inheritance.

Files:

  • Create: fusion_plating_configurator/views/fp_express_order_views.xml

  • Modify: __manifest__.py (add to data)

  • Step 1: Create the Express form view

fusion_plating_configurator/views/fp_express_order_views.xml (full file — paste verbatim):

<?xml version="1.0" encoding="UTF-8"?>
<odoo>

    <record id="view_fp_express_order_form" model="ir.ui.view">
        <field name="name">fp.express.order.form</field>
        <field name="model">fp.direct.order.wizard</field>
        <field name="priority">10</field>
        <field name="arch" type="xml">
            <form string="Express Order Entry" class="o_fp_express_order">
                <header>
                    <button name="action_create_order" type="object"
                            string="Confirm Order"
                            class="btn-primary"
                            invisible="state != 'draft'"/>
                    <button name="action_view_sale_order" type="object"
                            string="Open Sale Order"
                            class="btn-primary"
                            invisible="state != 'confirmed' or not sale_order_id"/>
                    <button name="action_switch_to_legacy" type="object"
                            string="Switch to Legacy View"
                            class="btn-secondary"
                            invisible="state != 'draft'"/>
                    <field name="state" widget="statusbar"
                           statusbar_visible="draft,confirmed"/>
                </header>
                <sheet>
                    <div class="oe_title">
                        <h1><field name="name" readonly="1"/></h1>
                    </div>

                    <!-- Header grid: Customer + Shipping (row 1), PO block + Job # + Sort (row 2), etc. -->
                    <group>
                        <group string="Customer">
                            <field name="partner_id" options="{'no_create_edit': True}"/>
                            <field name="partner_shipping_id"
                                   options="{'no_create_edit': False}"
                                   invisible="not partner_id"/>
                            <field name="customer_job_number"/>
                            <field name="job_sort_id"
                                   options="{'no_create_edit': False, 'no_open': True}"
                                   placeholder="Type to create a new bucket..."/>
                            <field name="material_process" placeholder="ENP-STEEL-HP-ADVANCED"/>
                        </group>
                        <group string="Purchase Order">
                            <field name="po_number"
                                   placeholder="Enter the customer PO number"/>
                            <field name="po_attachment_file"
                                   filename="po_attachment_filename"/>
                            <field name="po_attachment_filename" invisible="1"/>
                            <field name="po_pending" widget="boolean_toggle"/>
                            <field name="po_expected_date"
                                   invisible="not po_pending"/>
                        </group>
                    </group>

                    <group>
                        <group string="Scheduling &amp; Lead Time">
                            <label for="lead_time_min_days" string="Lead Time (days)"/>
                            <div class="o_row">
                                <field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
                                <span> to </span>
                                <field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
                            </div>
                            <field name="customer_deadline" string="Delivery Date"/>
                            <field name="validity_date" string="Quote Validity"/>
                            <field name="is_blanket_order"/>
                        </group>
                        <group string="Pricing &amp; Fulfilment">
                            <field name="pricelist_id"
                                   string="Currency / Pricelist"
                                   context="{'fp_express_currency_picker': True}"
                                   options="{'no_create_edit': True}"/>
                            <field name="payment_term_id"
                                   options="{'no_create': True}"/>
                            <field name="delivery_method"/>
                            <field name="invoice_strategy"/>
                        </group>
                    </group>

                    <notebook>
                        <page string="Lines" name="lines">
                            <field name="line_ids">
                                <list editable="bottom"
                                      decoration-warning="is_missing_info">
                                    <field name="is_missing_info" column_invisible="1"/>
                                    <field name="sequence" widget="handle"/>
                                    <field name="part_catalog_id"
                                           widget="fp_express_part_cell"
                                           context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
                                           domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"/>
                                    <field name="line_description" string="Specification"/>
                                    <field name="customer_line_ref" string="Line Job #"/>
                                    <field name="thickness_range" placeholder="e.g. .0005-.0010"/>
                                    <field name="masking_enabled" widget="boolean_toggle"/>
                                    <field name="bake_instructions" widget="fp_express_bake_pill"/>
                                    <field name="internal_description" string="Internal Notes"/>
                                    <field name="quantity"/>
                                    <field name="unit_price"
                                           widget="monetary"
                                           options="{'currency_field': 'currency_id'}"/>
                                    <field name="line_subtotal"
                                           widget="monetary"
                                           options="{'currency_field': 'currency_id'}"
                                           sum="Total"/>
                                    <button name="action_upload_drawing" type="object"
                                            string="DWG" class="btn-link"
                                            invisible="not part_catalog_id"/>
                                    <button name="action_open_part" type="object"
                                            string="OPEN" class="btn-link"
                                            invisible="not part_catalog_id"/>
                                    <field name="currency_id" column_invisible="1"/>
                                </list>
                            </field>
                            <group class="mt-3">
                                <group>
                                    <field name="total_line_count" readonly="1"/>
                                    <field name="total_qty" readonly="1"/>
                                </group>
                                <group>
                                    <field name="total_amount"
                                           widget="monetary"
                                           options="{'currency_field': 'currency_id'}"
                                           readonly="1"/>
                                </group>
                            </group>
                        </page>
                        <page string="Notes &amp; Terms" name="notes">
                            <group>
                                <group string="Order-Level Internal Notes (never prints)">
                                    <field name="internal_notes" nolabel="1"
                                           placeholder="Visible only to estimator / planner / shop."/>
                                </group>
                                <group string="Terms &amp; Conditions (prints on customer docs)">
                                    <field name="terms_and_conditions" nolabel="1"/>
                                </group>
                            </group>
                        </page>
                    </notebook>

                </sheet>
                <chatter/>
            </form>
        </field>
    </record>

    <record id="action_fp_express_order" model="ir.actions.act_window">
        <field name="name">+ New Express Order</field>
        <field name="res_model">fp.direct.order.wizard</field>
        <field name="view_mode">form</field>
        <field name="view_id" ref="view_fp_express_order_form"/>
        <field name="target">current</field>
        <field name="context">{'default_view_source': 'express'}</field>
    </record>

    <menuitem id="menu_fp_express_order"
              name="+ New Express Order"
              parent="fusion_plating_configurator.menu_fp_sales"
              action="action_fp_express_order"
              sequence="2"/>

</odoo>
  • Step 2: Add action_switch_to_legacy method

Edit fusion_plating_configurator/wizard/fp_direct_order_wizard.py — add:

    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 (add action_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_source badge column to the drafts list

Edit fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml — find the view_fp_direct_order_wizard_list record and add a new field inside <list>:

                <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 AD tasks pass on local dev.

Files:

  • Create: fusion_plating_configurator/docs/express_orders_smoke_test.md (in-repo runbook)

  • Step 1: Create the runbook

fusion_plating_configurator/docs/express_orders_smoke_test.md:

# 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 45+ after launch). Captured here so the plan is complete.

Task F1 (Phase 3, Week 4): Hide legacy menu + auto-redirect

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard.py

Steps:

  1. Add groups="base.group_no_one" (debug-mode-only) to the + New Direct Order menuitem so it's hidden in normal mode.
  2. Patch action_open_draft to ALWAYS return the Express view:
    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',
        }
  1. Bump module version (e.g. 19.0.23.0.0).
  2. Commit: chore(configurator): Phase 3 — hide legacy menu, auto-redirect drafts

Task F2 (Phase 4, Week 5+): Full removal

Files:

  • Delete: view_fp_direct_order_wizard_form record
  • Delete: action_fp_direct_order_wizard record
  • Delete: + New Direct Order menuitem
  • Create: fusion_plating_configurator/migrations/19.0.24.0.0/post-migrate.py

The post-migration:

def migrate(cr, version):
    # 1. Drop the view_source column (no longer used)
    cr.execute("ALTER TABLE fp_direct_order_wizard DROP COLUMN IF EXISTS view_source")

    # 2. Sweep orphan landing actions — any user whose landing pointed at the
    # retired legacy action gets re-pointed at the Express action.
    cr.execute("""
        UPDATE res_users
        SET x_fc_plating_landing_action_id = (
            SELECT res_id FROM ir_model_data
            WHERE module = 'fusion_plating_configurator'
              AND name = 'action_fp_express_order'
              AND model = 'ir.actions.act_window'
            LIMIT 1
        )
        WHERE x_fc_plating_landing_action_id IN (
            SELECT res_id FROM ir_model_data
            WHERE module = 'fusion_plating_configurator'
              AND name = 'action_fp_direct_order_wizard'
              AND model = 'ir.actions.act_window'
        )
    """)

Also remove view_source field from the model definition. Commit: chore(configurator): Phase 4 — remove legacy direct-order view + view_source column


Self-Review

Spec coverage check — every NEW field + helper + view + button from the spec has a task above:

  • fp.part.catalog.default_specification_text/default_bake_instructions/default_masking_enabled → A1 ✓
  • fp.direct.order.line.customer_line_ref/masking_enabled/bake_instructions → A2 ✓
  • sale.order.line.x_fc_* (3 fields) → A3 ✓
  • sale.order.x_fc_material_process/x_fc_internal_notes/x_fc_print_terms → A4 ✓
  • fp.direct.order.wizard.material_process/pricelist_id/validity_date/internal_notes/terms_and_conditions/view_source + rename notes + retire currency_id → A5 ✓
  • _fp_all_nodes_with_kind helper → B1 ✓
  • _fp_apply_express_overrides_to_job helper + 4 quadrants + audit + idempotency → B2 ✓
  • Hook into _fp_auto_create_job → B3 ✓
  • Onchange auto-fill cascade → B4 ✓
  • _prepare_order_line_vals carry-through → B5 ✓
  • Part-default write-back on confirm → B6 ✓
  • action_open_serial_bulk_add → B7 ✓
  • action_upload_drawing + action_open_part → B8 ✓
  • Currency picker context-aware display_name → C1 ✓
  • Quick-create part view → C2 ✓
  • SCSS tokens + light/dark → C3 ✓
  • OWL widget FpExpressPartCell → C4 ✓
  • OWL widget FpExpressBakePill → C5 ✓
  • Express form view + menu + action + action_switch_to_legacy/action_switch_to_express → C6 ✓
  • Drafts list dual-routing + badge → D1 ✓
  • Phase 2 deprecation banner → D2 ✓
  • Smoke test runbook → E1 ✓
  • Phase 3 + 4 deferred tasks → F1, F2 ✓

No placeholder check — all steps have concrete code, exact file paths, and runnable commands.

Type consistency — method names verified consistent: _fp_apply_express_overrides_to_job, _fp_all_nodes_with_kind, action_open_serial_bulk_add, action_upload_drawing, action_open_part, action_open_draft, action_switch_to_legacy, action_switch_to_express, action_quick_create_done. Field names verified consistent across tasks: x_fc_masking_enabled, x_fc_bake_instructions, x_fc_customer_line_ref, default_specification_text, default_bake_instructions, default_masking_enabled, material_process, pricelist_id, validity_date, internal_notes, terms_and_conditions, view_source, x_fc_material_process, x_fc_internal_notes, x_fc_print_terms.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-26-express-orders-plan.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?