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).
39 KiB
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 |
| 7–14 | 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 |
Q7–14 |
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.overriderows (included=False) for masking/de_masking/baking nodes when applicablefp.job.step.instructionstext 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 |
Footer — Order-Level Notes + Terms & Conditions + Totals
Left column (stacked cards):
- Order-Level Notes — internal, never prints. New
wizard.internal_notes→ newsale.order.x_fc_internal_notes. - Terms & Conditions — customer-facing, prints on quote / SO / invoice. Re-purpose existing
wizard.notes→ write to existingsale.order.note(Odoo native Html). Default-seeded fromcompany.invoice_terms_html, with partner-level override viares.partner.invoice_terms. Newx_fc_print_termsBoolean default True controls whether T&C prints. - Reset to company default action — re-reads
company.invoice_terms_htmland writes it back tosale.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.notes → wizard.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_searchoverride that bumps parts frequently ordered byparent.partner_idto 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 linebutton (no auto-add — out of scope for v1) - Delete: small × on row hover
- Reorder: drag grip (replaces line # on hover)
- Validation: existing
is_missing_infoboolean compute drivesdecoration-warningamber row tint. Extended to flag missingpart_catalog_id,quantity <= 0.unit_price=0warns 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:
-
Display-name override on
product.pricelist, scoped via context flagfp_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() -
Domain filters to active pricelists for the company:
domain="[('company_id', 'in', (False, allowed_company_ids[0])), ('active', '=', True)]" -
"+ 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)
- Settings → Multi-Currencies → activate USD
- Same screen → "Live Currency Rates" → pick Bank of Canada (or ECB), daily refresh
- 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_idset 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:
- Click DWG → HTML file picker
- File uploads → creates
ir.attachment(res_model='fp.part.catalog',res_id=part.id) - Attachment added to
part.drawing_attachment_idsvia(4, att.id) - Button re-renders showing count:
DWG (N) - 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 1–2 | 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 1–3: 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.notes→fp.direct.order.wizard.terms_and_conditions. Still writes tosale.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(M2Ores.currency) → replaced bypricelist_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 notes → terms_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 notes → terms_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:
- Schema layer first — all new fields on wizard / line / part / sale.order / sale.order.line, with post-migration backfill for
pricelist_idand thenotesrename. Get the model layer green before any view work. - Recipe-walker helper (
_fp_all_nodes_with_kind) + unit test. - Override-application helper (
_fp_apply_express_overrides_to_job) onsale.order.lineandfp.direct.order.line+ unit test for all 4 paths (mask on/off × bake empty/non-empty). - Job-creation hook — extend
_fp_auto_create_jobto call the helper per line + integration test. - Wizard cleanup —
notesrename,currency_id→pricelist_idswap,material_processadd,view_sourceadd,validity_dateadd,internal_notesadd. - Quick-create part view — new XML view + action + onChange to assign new part to line.
- Custom OWL widgets —
fp_express_part_cell+fp_express_bake_pill. - Express form view XML — header grid + line list + footer (notes + T&C + totals).
- Drawing upload + open part — new line buttons + methods.
- Bulk-serial trigger —
action_open_serial_bulk_addhelper + UI integration. - Drafts list dual-routing —
view_sourcecolumn + click action. - Phase 2 deprecation banner — legacy view header banner.
- 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.
- Phase 3 (~Week 4) — hide legacy menu, auto-reroute legacy drafts.
- Phase 4 (~Week 5+) — remove legacy view XML, drop
view_sourcecolumn, 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