Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-26-express-orders-design.md
gsinghpal 7da51b4ec8 docs(specs): Express Orders design spec
Consolidates the brainstorming session into a single design spec. Covers:
header layout + field-to-model mapping, line widget (multi-row Part cell,
masking + bake pills, serial bulk-add trigger), masking/baking override
flow at SO confirm, currency/pricelist picker mechanics, inline part
create + drawing upload + open-part buttons, and phase-out path for the
legacy Direct Order view.

Reuses fp.direct.order.wizard model end-to-end (Q1=D). New fields:
material_process, customer_line_ref, masking_enabled, bake_instructions,
default_specification_text, default_bake_instructions,
default_masking_enabled, x_fc_internal_notes, x_fc_print_terms.
Rename: wizard.notes → terms_and_conditions. Retire: wizard.currency_id.

Mockup at .claude/mockups/express_orders.html (interactive, light + dark).
2026-05-26 19:50:01 -04:00

39 KiB
Raw Blame History

Express Orders — Design Spec

Date: 2026-05-26 Status: Approved (brainstorming complete, ready for implementation planning) Modules touched: fusion_plating_configurator (primary), fusion_plating_jobs (job-creation hook), fusion_plating (recipe walker helper) Backing model: fp.direct.order.wizard (shared with legacy Direct Order view) Visual reference: .claude/mockups/express_orders.html (light + dark mode, three tabs — Screen / Data Flow / Comparison) Inspiration: Customer-supplied Excel mockup showing spreadsheet-style flat order entry — header grid on top, line table in the middle, footer with totals + T&C + notes

1. Goal

Replace the slow, form-per-part Direct Order wizard with a spreadsheet-style entry surface that supports fast batch entry for repeat customers. All lines on one screen, every column inline, type-once-and-remember per-part defaults, no navigation to the part record for routine work.

The Express view writes to the same fp.direct.order.wizard model as the legacy view — there is no parallel model, no parallel storage, no migration concerns. Drafts created in one view can be re-opened in the other.

2. Locked decisions (from clarifying-question pass)

# Question Decision Source
1 Model strategy D — new view on existing fp.direct.order.wizard Q1
2 Specification storage Free-text on the part — new default_specification_text Text on fp.part.catalog Q2
3 Per-line customer sub-job ref New x_fc_customer_line_ref Char on sale.order.line Q3
4 Masking checkbox scope Both masking AND de-masking together (paired opt-out) Q4
5 Baking field shape Free-text per line, auto-fill from part.default_bake_instructions; empty = exclude bake nodes; non-empty = include + push to step.instructions Q5
6 Currency mechanic Pricelist-per-currency picker on the wizard; selector lists currencies the company has pricelists for Q6
714 8 default-interpretation items PO Pending = keep existing flag + chase mechanism; Material Process = new informational Char; Upload Part Drawing = per-line button writing to part's drawing M2M; Create Part = per-line modal opening fp.part.catalog form; Lead Time = reuse existing min/max; Blanket SO = reuse existing Boolean; Delivery Method = reuse existing Selection; phase-out path = both views visible initially, retire old view after Express is stable Q714

3. Architecture — single helper at SO confirm

The flags typed on the Express line (x_fc_masking_enabled, x_fc_bake_instructions, x_fc_customer_line_ref) persist as plain fields on sale.order.line. No staging models, no pending-override tables. At SO confirm time, the existing _fp_auto_create_job() chain clones the recipe into fp.job + fp.job.step rows; a NEW helper method sale.order.line._fp_apply_express_overrides_to_job(job) runs immediately after, reads the three flags, and writes:

  • fp.job.node.override rows (included=False) for masking/de_masking/baking nodes when applicable
  • fp.job.step.instructions text on the bake step when bake instructions are non-empty
SO confirm
   ↓
sale_order.action_confirm()  (existing)
   ↓
_fp_auto_create_job()        (existing — clones recipe → fp.job + fp.job.step rows)
   ↓
[NEW] for each contributing line:
   line._fp_apply_express_overrides_to_job(job)
   ↓
job ready for shop floor

Single execution point. Idempotent (re-running pre-deletes prior overrides this helper wrote). Helper lives on sale.order.line so it's unit-testable in isolation and reusable from any future job-creation path. ~40 lines of Python.

4. Header layout & field-to-model mapping

Layout — 4×4 grid, 14 fields

┌────────────────────────────────┬────────────────────────────────┐
│ Customer *           (span 2)  │ Shipping Address    (span 2)   │
├────────────────────────────────┼──────────────┬─────────────────┤
│ PO Block *           (span 2)  │ Customer     │ Job Sorting     │
│   PO #                          │ Job #        │                  │
│   PDF attachment + Replace      │              │                  │
│   ☐ PO Pending → expected date │              │                  │
├──────────────┬──────────────┬──┴────┬─────────┴─────────────────┤
│ Material/    │ Lead Time    │ Pmt   │ Delivery Method            │
│ Process Tag  │ (X to Y)     │ Terms │                            │
├──────────────┼──────────────┼───────┼────────────────────────────┤
│ Blanket SO   │ Currency /   │ Quote │ Invoice Strategy           │
│              │ Pricelist    │ Valid │                            │
└──────────────┴──────────────┴───────┴────────────────────────────┘

Field mapping

# Display Wizard field SO field on confirm Status
1 Customer * partner_id partner_id Existing
2 Shipping Address partner_shipping_id partner_shipping_id Existing wizard, NEW on view
3 PO # * po_number x_fc_po_number Existing
3a PO Attachment po_attachment_file + po_attachment_filename x_fc_po_attachment_id + x_fc_po_received Existing
3b PO Pending toggle po_pending x_fc_po_pending Existing
3c PO Expected By po_expected_date x_fc_po_expected_date Existing
4 Customer Job # customer_job_number x_fc_customer_job_number Existing
5 Job Sorting job_sort_id x_fc_job_sort_id Existing
6 Material/Process Tag material_process x_fc_material_process NEW Char
7 Lead Time (X to Y) lead_time_min_days + lead_time_max_days x_fc_lead_time_min_days + x_fc_lead_time_max_days Existing, rendered inline as range
8 Payment Terms payment_term_id payment_term_id Existing
9 Delivery Method delivery_method x_fc_delivery_method Existing
10 Blanket SO is_blanket_order x_fc_is_blanket_order Existing
11 Currency / Pricelist pricelist_id (replacing existing currency_id) pricelist_id NEW field on wizard, existing on SO
12 Quote Validity validity_date validity_date Existing on SO, NEW on wizard
13 Invoice Strategy invoice_strategy x_fc_invoice_strategy Existing

Left column (stacked cards):

  • Order-Level Notes — internal, never prints. New wizard.internal_notes → new sale.order.x_fc_internal_notes.
  • Terms & Conditions — customer-facing, prints on quote / SO / invoice. Re-purpose existing wizard.notes → write to existing sale.order.note (Odoo native Html). Default-seeded from company.invoice_terms_html, with partner-level override via res.partner.invoice_terms. New x_fc_print_terms Boolean default True controls whether T&C prints.
  • Reset to company default action — re-reads company.invoice_terms_html and writes it back to sale.order.note.

Right column (totals card):

  • Subtotal · Tooling Charge · Tax · Grand Total + currency pill — all standard Odoo monetary fields with currency_field='currency_id'.

Cleanup decision on the existing notes field

The existing fp.direct.order.wizard.notes field's placeholder says "Internal notes for the estimator / planner - not shown to the customer" but the action method writes that value to sale.order.note, which DOES print on customer PDFs. Fix as part of Section 4: rename wizard.noteswizard.terms_and_conditions (still writes to sale.order.note), and add fresh wizard.internal_notes → new sale.order.x_fc_internal_notes. Matches the mockup's two-block footer.

5. Line widget design

Column layout

┌──┬─────────────────────┬───────────────┬────────┬──────────┬────┬───────────┬───────────┬─────┬─────┬───────┬──────────┬────────────┐
│# │ Part Number         │ Specification │ Line   │ Thickness│Mask│ Bake      │ Internal  │ Qty │ UOM │ Price │ Subtotal │ Actions    │
│  │ (3-row stacked cell)│ (customer-    │ Job #  │          │ ✓  │ pill      │ Notes     │     │     │       │          │ DWG / OPEN │
│  │                     │  facing text) │ ABC    │          │    │           │           │     │     │       │          │            │
└──┴─────────────────────┴───────────────┴────────┴──────────┴────┴───────────┴───────────┴─────┴─────┴───────┴──────────┴────────────┘

Part Number cell — 3 rows stacked in one cell

Row Content Source
1 (bold) Part # / Revision part_catalog_id.part_number / part_catalog_id.revision
2 (italic) Part description part_catalog_id.name
3 (muted) Serial #s, comma-separated, with + bulk button serial_ids joined; + bulk opens existing fp.serial.bulk.add.wizard

Multi-row rendered via a small custom OWL widget fp_express_part_cell (~80 lines). Other columns are stock Odoo list cells.

Line # column

The narrow column carrying the row handle does double duty: default state shows the line number (small, bold, muted); on row hover the number swaps to a ⋮⋮ drag grip. Pure CSS hover swap, no JS.

Column → field mapping

Col Display Wizard line field (fp.direct.order.line) SO line field on confirm (sale.order.line) Status
# Line # / drag sequence sequence Existing
Part — row 1 Part # / Rev M2O traversal via part_catalog_id x_fc_part_catalog_id.part_number / x_fc_revision_snapshot Existing
Part — row 2 Description part_catalog_id.name derived Existing
Part — row 3 Serial #s + + bulk serial_ids (M2M) x_fc_serial_ids Existing
Specification customer-facing text line_description name (Odoo native) Existing
Line Job # customer sub-ref customer_line_ref x_fc_customer_line_ref NEW (Char)
Thickness range thickness_range x_fc_thickness_range Existing
Mask ✓ masking toggle masking_enabled Boolean default True x_fc_masking_enabled NEW (Boolean)
Bake pill bake free-text bake_instructions Text x_fc_bake_instructions NEW (Text)
Internal Notes shop-floor notes internal_description x_fc_internal_description Existing
Qty quantity quantity product_uom_qty Existing
UOM unit derived from product product_uom Existing — read-only
Price unit price unit_price price_unit Existing
Subtotal extended line_subtotal (computed) price_subtotal (computed) Existing
Action: DWG upload drawing → part NEW button method writes to part_catalog_id.drawing_attachment_ids (M2M) NEW UI
Action: OPEN open part form NEW button method navigates to fp.part.catalog form, target='new' NEW UI

Optional-show columns (Odoo optional="hide" — operator adds via column-toggle menu): Shop Job # (job_number), Process / Recipe (process_variant_id), Effective Process, Tax IDs, Part Deadline override + offset, WO Group Tag, Rush Order.

Widget behaviours

A. Part picker (Many2one autocomplete)

  • Domain: [('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]
  • _rec_names_search = ['part_number', 'name', 'default_code'] (audit existing model)
  • NEW: name_search override that bumps parts frequently ordered by parent.partner_id to the top
  • Quick-create: + Create new part "<typed>" opens the inline create modal (Section 8)

B. Auto-fill cascade on part pick (existing _onchange_part_catalog_id, extended)

When the line's part_catalog_id is set:

Line field Filled from
description (line.name) part.default_specification_text (NEW) → fallback: last SO line's name for this part
thickness_range part.x_fc_default_thickness_range
process_variant_id part.default_process_variant_id or last-used variant
masking_enabled part.default_masking_enabled (NEW Boolean on part, default True)
bake_instructions part.default_bake_instructions (NEW) → fallback: last SO line's bake_instructions for this part
tax_ids from last SO line for this part + fiscal position
unit_price from last SO line + customer pricelist

C. Masking toggle — plain Boolean cell with widget="boolean_toggle". Default True. Unchecked → at job creation, helper spawns fp.job.node.override(included=False) for every masking + de_masking node (Section 6).

D. Bake pill — small custom OWL widget fp_bake_pill (~50 lines) wrapping a Text field. Empty → italic muted "no bake" pill. Non-empty → coloured pill showing the text. Click pill → inline popover with textarea + Save / Clear. At job creation, empty pill opts out of bake nodes; non-empty keeps them AND writes the text to step.instructions (Section 6).

E. Bulk-add serials+ bulk button next to the serial input opens existing fp.serial.bulk.add.wizard. NEW thin helper action_open_serial_bulk_add on fp.direct.order.line (mirror on sale.order.line so the button works post-confirm too):

def action_open_serial_bulk_add(self):
    return self.env.ref('fusion_plating_configurator.action_fp_serial_bulk_add_wizard')\
        .read()[0] | {'context': {
            'default_target_model': 'fp.direct.order.line',
            'default_target_id': self.id,
            'default_qty_expected': self.quantity,
        }}

F. DWG button — opens HTML file picker, uploads file, creates ir.attachment with res_model='fp.part.catalog', res_id=part.id, links to part.drawing_attachment_ids via (4, att.id). Drawing lives on the PART (not the line) so future orders reuse it. Audit posted to part's chatter.

G. OPEN button — returns window action to fp.part.catalog form, res_id=part.id, target='new'. Closes modal returns to Express view with line still in place.

Row management

  • Add: standard + Add a line button (no auto-add — out of scope for v1)
  • Delete: small × on row hover
  • Reorder: drag grip (replaces line # on hover)
  • Validation: existing is_missing_info boolean compute drives decoration-warning amber row tint. Extended to flag missing part_catalog_id, quantity <= 0. unit_price=0 warns but doesn't block (some shops quote $0 for samples).

Rendering implementation

  • Line list = Odoo native <list editable="bottom"> (same as legacy wizard's list)
  • Multi-row Part cell = single custom OWL widget (~80 lines)
  • Bake pill = custom OWL widget (~50 lines)
  • Masking = stock boolean_toggle
  • DWG / OPEN = standard <button> in list arch + new Python methods

6. Masking + baking override flow at job creation

Per-line resolution algorithm

def _fp_apply_express_overrides_to_job(self, job):
    """NEW helper on sale.order.line (mirrored on fp.direct.order.line).

    Reads the three Express flags off self and writes overrides + step
    instructions to the job. Called from sale_order._fp_auto_create_job()
    immediately after the job + steps are built.
    """
    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 rows this helper wrote (handles SO un-confirm + re-confirm)
    Override.search([
        ('job_id', '=', job.id),
        ('node_id.default_kind', 'in', ('masking', 'de_masking', 'baking')),
    ]).unlink()

    # 1. Masking — opt out of masking + de_masking AS A PAIR (per handoff Q4)
    if not self.x_fc_masking_enabled:
        for node in recipe._fp_all_nodes_with_kind(('masking', 'de_masking')):
            Override.create({'job_id': job.id, 'node_id': node.id, 'included': False})

    # 2. Bake — empty text = opt out; non-empty = keep + push to step.instructions (per Q5)
    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})
    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})

    # 3. Audit
    msgs = []
    if not self.x_fc_masking_enabled:
        msgs.append('Masking + de-masking steps opted out (per SO line)')
    if not bake_text and bake_nodes:
        msgs.append('Baking steps opted out (per SO line)')
    if bake_text:
        msgs.append('Bake step instructions set to: %s' % bake_text)
    if msgs:
        job.message_post(body='\n'.join('• ' + m for m in msgs))

Recipe-walker helper

# NEW on fusion.plating.process.node
def _fp_all_nodes_with_kind(self, kinds):
    """All descendants (and self) where default_kind ∈ kinds."""
    self.ensure_one()
    if not kinds:
        return self.browse([])
    return self.search([
        ('id', 'child_of', self.id),
        ('default_kind', 'in', list(kinds)),
    ])

Uses existing _parent_store = True on the recipe model — child_of resolves through parent_path in a single SQL hit.

Edge cases

Case Behaviour
Recipe has no masking nodes Helper creates 0 override rows. No-op.
Recipe has multiple bake nodes (pre-bake + post-bake) All bake steps get the same instruction text. Operator can edit per-step on the job form post-create.
Two SO lines share a recipe and _fp_auto_create_job consolidates them Implementation must verify whether existing logic consolidates. If yes, conservative rule: any line wanting opt-out wins (AND semantics).
Operator flips masking on SO line AFTER confirm Override rows don't auto-update. Manual fix via the job's existing Overrides smart button. (Matches existing behaviour — jobs are authoritative once created.)
Helper re-runs (SO un-confirm + re-confirm) Pre-delete handles it.
bake_instructions = " " whitespace only Treated as empty (.strip() call).

Override row shape — schema unchanged

fp.job.node.override stays 3 fields: job_id, node_id, included. No new fields, no migration. Mechanism already wired post-Sub-11 via fp.recipe.config.wizard.

Audit trail

Single chatter post on the job summarising what changed:

• Masking + de-masking steps opted out (per SO line)
• Bake step instructions set to: 350°F × 4 hr

Operator on the shop floor sees WHY their step list differs from the recipe template.

Manual override after confirm

Already exists — every fp.job has an "Overrides" smart button (post-Sub-11) opening the fp.recipe.config.wizard filtered to that job. Operator/supervisor can untick masking, re-tick, edit step instructions directly. No new UI needed.

7. Currency mechanic

How Odoo handles multi-currency (for context)

Your client is in Canada (books in CAD), some customers are in the US (orders in USD). Odoo stores both currencies on every journal-entry line — the customer is invoiced in USD, but the GL keeps the CAD equivalent at the day's rate.

Worked example: USD $4,200 invoice on a 1.36 CAD/USD day:

Dr  AR (USD customer)   USD $4,746.00 ⇄ CAD $6,454.56
    Cr  Revenue                        CAD $5,712.00
    Cr  HST Payable                    CAD $   742.56

Customer pays one month later at 1.38 CAD/USD:

Dr  USD Bank            USD $4,746.00 ⇄ CAD $6,549.48
    Cr  AR                              CAD $6,454.56
    Cr  Realized FX Gain                CAD $    94.92  (auto-posted)

Books stay in CAD. AR statements stay in USD. FX gain/loss auto-posts to a configured account.

What the picker does

The "Currency / Pricelist" cell in row 4 is a Many2one to product.pricelist. Each dropdown row is one of the company's active pricelists, rendered as [CURRENCY] — Pricelist Name. Estimator picks one; both currency_id and pricelist_id resolve from the pick.

Field mapping

Mockup element Backing field Notes
Picker sale.order.pricelist_id (Odoo native) M2O product.pricelist
Currency code (totals pill) sale.order.currency_id Related read-only, flows from pricelist_id.currency_id
Wizard equivalent Replace existing fp.direct.order.wizard.currency_id with NEW wizard.pricelist_id One-line post-migration: pricelist_id = currency_id.property_product_pricelist per row

Picker rendering

Two small enhancements on top of standard Many2one widget:

  1. Display-name override on product.pricelist, scoped via context flag fp_express_currency_picker:

    @api.depends('currency_id', 'name')
    def _compute_display_name(self):
        for pl in self:
            if self.env.context.get('fp_express_currency_picker'):
                pl.display_name = f"{pl.currency_id.name}{pl.name}"
            else:
                super(...)._compute_display_name()
    
  2. Domain filters to active pricelists for the company:

    domain="[('company_id', 'in', (False, allowed_company_ids[0])), ('active', '=', True)]"
    
  3. "+ Set up a new currency pricelist…" link next to the picker, manager-gated, opens Settings → Pricelists.

Mid-order currency change

Odoo native _onchange_pricelist_id prompts the user via a confirmation dialog asking "Update Prices?". If confirmed, every line's price_unit is recomputed via the new pricelist's rules. Tax/fiscal-position rebinding via _compute_tax_id happens automatically. No custom code needed.

Per-partner default pricelist

Odoo native: res.partner.property_product_pricelist auto-applies when an estimator picks the customer. Express Orders inherits this — customer change re-seeds wizard.pricelist_id from the customer's default. Estimator can override per-order.

Admin setup (one-time per shop)

  1. Settings → Multi-Currencies → activate USD
  2. Same screen → "Live Currency Rates" → pick Bank of Canada (or ECB), daily refresh
  3. Sales → Configuration → Pricelists → create one pricelist per currency the shop sells in

For plating specifically, recommend explicit per-part USD pricing on fp.part.catalog, not rate-based conversion of CAD list prices (US prices end up with random pennies that way).

8. Inline part create + drawing upload + open part

A. Inline part create

Triggered from the autocomplete dropdown's + Create new part "<typed>" row. Opens a small modal with 4 fields:

┌─ Create Part ──────────────────────────────┐
│ Part Number *    [ ENG-1042            ]   │  <- pre-filled from typed text
│ Revision *       [ A                   ]   │  <- default "A"
│ Part Description [ Cylinder Head Cover ]   │
│ Customer *       [ WESTIN HEALTHCARE ▾]   │  <- pre-filled from order.partner_id
│                                              │
│        [ Cancel ]   [ Create & Use ]        │
└────────────────────────────────────────────┘

On Create & Use: creates the fp.part.catalog record, assigns to the line, triggers _onchange_part_catalog_id to auto-fill remaining cells (most empty for a brand-new part — estimator types them inline).

NEW pieces:

  • NEW form view view_fp_part_catalog_quick_create_form (4 fields, no notebook)
  • NEW act_window action with view_id set to the quick-create view
  • Context pre-fill: default_part_number, default_revision, default_partner_id (~30 lines OWL hook to wire the result back to the calling line)

B. Drawing upload (DWG button)

Per-line button. Drawing lives on the PART (fp.part.catalog.drawing_attachment_ids M2M), so future orders for the same part reuse the drawing set.

Flow:

  1. Click DWG → HTML file picker
  2. File uploads → creates ir.attachment (res_model='fp.part.catalog', res_id=part.id)
  3. Attachment added to part.drawing_attachment_ids via (4, att.id)
  4. Button re-renders showing count: DWG (N)
  5. Audit posted on the part's chatter: "Drawing '' uploaded by from line <#>"

States:

Line state Button state Click
part_catalog_id empty Disabled, tooltip "Pick or create a part first" (no-op)
part_catalog_id set, no drawings Enabled, label DWG Open file picker → upload
part_catalog_id set, ≥1 drawing Enabled, label DWG (N) Popover: existing drawings + + Upload another

Deferred to v2: inline PDF preview via fusion_pdf_preview. v1 popover just lists filenames with download links.

C. OPEN button

Per-line button. Returns window action to fp.part.catalog form, res_id=part.id, target='new'. Estimator can edit defaults, upload 3D model, manage description templates, etc. Closing the modal returns to Express view; line's compute fields refresh (small JS hook).

D. Part-default write-back on confirm

When the estimator types values into spec / bake / thickness / masking cells for a part that has NO existing defaults set, on confirm the line writes back to the part:

Line value Writes to part on confirm Condition
line_description (Specification) part.default_specification_text Always (per Q2 — "type once, saves to part")
bake_instructions part.default_bake_instructions Always (per Q5)
thickness_range part.x_fc_default_thickness_range Always
masking_enabled part.default_masking_enabled (NEW Boolean on part, default True) Always
process_variant_id part.default_process_variant_id Only if save_as_default_process ticked (existing toggle)

Single direction: line → part on confirm. Editing the part's defaults later doesn't retroactively update existing SO lines (they're frozen). Matches existing description-template + thickness logic.

9. Phase-out path for the old Direct Order view

Architecture: it's just a view swap

Both views share fp.direct.order.wizard + fp.direct.order.line + all business logic. Phase-out is delete one view XML when ready. Nothing under the view layer touches.

Menu structure on launch

🏭 Plating
└── 💰 Sales & Quoting
    ├── Quotations
    ├── Sale Orders
    ├── ⭐ + New Express Order        ← NEW (default, top of list)
    ├── + New Direct Order            ← KEEP (legacy, available)
    ├── Direct Order Drafts           ← SHARED list, both views can re-open
    └── Quote Requests

Both + New menu entries route to the same model with different view_id in their ir.actions.act_window.

Shared drafts list

NEW field on fp.direct.order.wizard: view_source Selection 'express' / 'legacy', default 'express'. Populated by action context (default_view_source: 'express' on the Express action, 'legacy' on the old one).

Drafts-list row click routes to the correct form view:

def action_open_draft(self):
    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',
    }

Manual switcher: header button on each form lets estimator A/B-compare on a real draft.

Phase timeline (development-stage, entech-only — no external users to migrate)

Phase Duration What
1 — Co-existence Week 12 Both views visible. Express is default + New. New drafts default to Express.
2 — Soft deprecation Week 3 Legacy view gets banner: "This view is being retired. Switch to Express Orders." Menu entry gets (Legacy) suffix.
3 — Hidden by default Week 4 Legacy menu hidden in non-debug mode. action_open_draft is patched to always return the Express view regardless of view_source. Legacy form still accessible via ?debug=1 directly on the action xmlid for emergencies.
4 — Removal Week 5+ Delete legacy view XML, action, menu item. Drop view_source column in post-migration.

What stays vs. what gets removed at Phase 4

Stays (untouched): fp.direct.order.wizard model, fp.direct.order.line model, all business logic, all wizards, all SO/job/cert integrations, drafts list view + search view.

Removed at Phase 4: view_fp_direct_order_wizard_form, action_fp_direct_order_wizard, + New Direct Order menu item, view_source field. Plus a one-liner SQL sweep to re-point any user whose x_fc_plating_landing_action_id was the legacy direct-order action to the Express action.

Bookmark handling

Phase 13: legacy action stays valid, bookmarks work. Phase 4: bookmark errors out — acceptable in development-stage. No external users to disrupt.

10. New fields & model changes (consolidated)

NEW fields

Model Field Type Default Notes
fp.direct.order.wizard material_process Char Order-level free-text shop tag
fp.direct.order.wizard pricelist_id Many2one(product.pricelist) from partner Replaces existing currency_id
fp.direct.order.wizard validity_date Date Mirrors existing sale.order.validity_date
fp.direct.order.wizard internal_notes Text Internal-only notes (new)
fp.direct.order.wizard terms_and_conditions Html from company RENAME of existing notes
fp.direct.order.wizard view_source Selection 'express' Phase-out tracking (dropped at Phase 4)
fp.direct.order.line customer_line_ref Char Per-line customer sub-ref
fp.direct.order.line masking_enabled Boolean True Express masking toggle
fp.direct.order.line bake_instructions Text Express bake free-text
sale.order x_fc_material_process Char Receives wizard material_process
sale.order x_fc_internal_notes Text Receives wizard internal_notes
sale.order x_fc_print_terms Boolean True Controls T&C print on customer docs
sale.order.line x_fc_customer_line_ref Char Customer per-line ref
sale.order.line x_fc_masking_enabled Boolean True Masking flag read at job creation
sale.order.line x_fc_bake_instructions Text Bake text read at job creation
fp.part.catalog default_specification_text Text Per-part spec default
fp.part.catalog default_bake_instructions Text Per-part bake default
fp.part.catalog default_masking_enabled Boolean True Per-part masking default

Existing field rename

  • fp.direct.order.wizard.notesfp.direct.order.wizard.terms_and_conditions. Still writes to sale.order.note (Odoo native). One-line post-migration: ALTER COLUMN ... RENAME TO ... plus updating the placeholder text.

Existing field retire

  • fp.direct.order.wizard.currency_id (M2O res.currency) → replaced by pricelist_id. Post-migration: pricelist_id = currency_id.property_product_pricelist. Drop column after one deploy cycle.

NEW methods

Model Method Purpose
sale.order.line _fp_apply_express_overrides_to_job(job) Per-line override + step-instruction application at confirm
fp.direct.order.line same (mirror) Available pre-confirm
fusion.plating.process.node _fp_all_nodes_with_kind(kinds) Recipe walker via child_of
sale.order.line action_upload_drawing DWG button handler
sale.order.line action_open_part OPEN button handler
sale.order.line action_open_serial_bulk_add + bulk button handler
fp.direct.order.line (same three) Available pre-confirm
product.pricelist _compute_display_name override Context-gated to currency picker

NEW Python hook

  • sale_order._fp_auto_create_job() extended to loop over contributing lines and call _fp_apply_express_overrides_to_job(job) after the job is built (~3 lines).

11. File touch list (consolidated)

File Change
fusion_plating_configurator/wizard/fp_direct_order_wizard.py Add material_process, pricelist_id, validity_date, internal_notes, view_source. Rename notesterms_and_conditions. Retire currency_id. Extend _prepare_order_vals to carry new fields to SO.
fusion_plating_configurator/wizard/fp_direct_order_line.py Add customer_line_ref, masking_enabled, bake_instructions. Mirror new methods. Extend _prepare_order_line_vals to carry new fields to SO line.
fusion_plating_configurator/models/sale_order.py Add x_fc_material_process, x_fc_internal_notes, x_fc_print_terms.
fusion_plating_configurator/models/sale_order_line.py Add x_fc_customer_line_ref, x_fc_masking_enabled, x_fc_bake_instructions. Add _fp_apply_express_overrides_to_job, action_upload_drawing, action_open_part, action_open_serial_bulk_add.
fusion_plating_configurator/models/fp_part_catalog.py Add default_specification_text, default_bake_instructions, default_masking_enabled.
fusion_plating_configurator/models/product_pricelist.py (NEW small file) _compute_display_name context-aware override.
fusion_plating/models/fp_process_node.py Add _fp_all_nodes_with_kind helper.
fusion_plating_jobs/models/sale_order.py Extend _fp_auto_create_job to call new helper per line.
fusion_plating_configurator/views/fp_express_order_views.xml (NEW) The Express form view + action + menu item.
fusion_plating_configurator/views/fp_part_catalog_views.xml Add view_fp_part_catalog_quick_create_form.
fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml Add Phase 2 deprecation banner; reroute drafts list click action.
fusion_plating_configurator/static/src/js/express_part_cell.js (NEW) Custom OWL widget — multi-row Part cell.
fusion_plating_configurator/static/src/js/express_bake_pill.js (NEW) Custom OWL widget — bake pill.
fusion_plating_configurator/static/src/scss/express_order.scss (NEW) Express-specific styles (light + dark via SCSS compile-time branch per project rule).
fusion_plating_configurator/migrations/<version>/post-migrate.py Backfill pricelist_id from old currency_id. Rename notesterms_and_conditions.

12. Open items / deferrals

Item Status When
Inline PDF preview for drawings via fusion_pdf_preview Deferred to v2 After v1 stable
Drawing tags / categorisation (as-built / rev B / customer-supplied) Deferred Add later if requested
Auto-add empty line when last line gets data Skipped for v1 Use stock + Add a line button
Manager-controlled default order entry view Skipped Phase-out is fast enough
Server action redirect for orphan legacy bookmarks Skipped Acceptable to error in dev-stage
Job consolidation behaviour (same-recipe lines → one job?) Verify at implementation time Quick read of _fp_auto_create_job first thing in plan
Silent recompute on currency switch (no Odoo prompt) Deferred Defer to v2 if estimators report friction

13. Suggested implementation order

Sequenced for the writing-plans skill to break into tasks:

  1. Schema layer first — all new fields on wizard / line / part / sale.order / sale.order.line, with post-migration backfill for pricelist_id and the notes rename. Get the model layer green before any view work.
  2. Recipe-walker helper (_fp_all_nodes_with_kind) + unit test.
  3. Override-application helper (_fp_apply_express_overrides_to_job) on sale.order.line and fp.direct.order.line + unit test for all 4 paths (mask on/off × bake empty/non-empty).
  4. Job-creation hook — extend _fp_auto_create_job to call the helper per line + integration test.
  5. Wizard cleanupnotes rename, currency_idpricelist_id swap, material_process add, view_source add, validity_date add, internal_notes add.
  6. Quick-create part view — new XML view + action + onChange to assign new part to line.
  7. Custom OWL widgetsfp_express_part_cell + fp_express_bake_pill.
  8. Express form view XML — header grid + line list + footer (notes + T&C + totals).
  9. Drawing upload + open part — new line buttons + methods.
  10. Bulk-serial triggeraction_open_serial_bulk_add helper + UI integration.
  11. Drafts list dual-routingview_source column + click action.
  12. Phase 2 deprecation banner — legacy view header banner.
  13. Manual smoke test on entech dev DB — full SO lifecycle, both views, serial bulk-add, drawing upload, masking off, bake off, currency switch, USD invoice.
  14. Phase 3 (~Week 4) — hide legacy menu, auto-reroute legacy drafts.
  15. Phase 4 (~Week 5+) — remove legacy view XML, drop view_source column, sweep orphan landing actions.

14. Reference artifacts

  • Brainstorming handoff: docs/superpowers/handoffs/2026-05-25-express-orders-brainstorm-handoff.md
  • Visual mockup (light + dark, interactive): .claude/mockups/express_orders.html
  • Existing wizard model: fusion_plating_configurator/wizard/fp_direct_order_wizard.py
  • Existing wizard view: fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
  • Existing line model: fusion_plating_configurator/wizard/fp_direct_order_line.py
  • Existing bulk-serial wizard: fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard.py
  • Job-creation hook: fusion_plating_jobs/models/sale_order.py (_fp_auto_create_job)
  • Override model: fusion_plating_jobs/models/fp_job_node_override.py (3 fields: job_id, node_id, included)
  • Job-sort model: fusion_plating_configurator/models/fp_so_job_sort.py
  • PO chase activity: fusion_plating_invoicing/models/sale_order.py:159