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:
@@ -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 |
|
||||
| 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.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 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 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`
|
||||
Reference in New Issue
Block a user