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).
This commit is contained in:
gsinghpal
2026-05-26 19:50:01 -04:00
parent 5764d439c3
commit 7da51b4ec8

View File

@@ -0,0 +1,635 @@
# 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`