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

636 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
### Footer — Order-Level Notes + Terms & Conditions + Totals
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.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_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):
```python
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
```python
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
```python
# 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`:
```python
@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:
```xml
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 '<filename>' uploaded by <user> from <order> 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:
```python
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.notes` → `fp.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 `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:
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 cleanup** — `notes` rename, `currency_id` → `pricelist_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 widgets** — `fp_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 trigger** — `action_open_serial_bulk_add` helper + UI integration.
11. **Drafts list dual-routing** — `view_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`