diff --git a/.superpowers/brainstorm/84408-1776602183/content/ai-badge-hybrid-v2.html b/.superpowers/brainstorm/84408-1776602183/content/ai-badge-hybrid-v2.html
new file mode 100644
index 00000000..9bebbbd9
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/content/ai-badge-hybrid-v2.html
@@ -0,0 +1,133 @@
+
Recommended Hybrid: A + B's escape hatch
+
Layout A's inline badge as default. Power users click "Show alternatives" on any line to reveal B's ranked panel for that line only.
+
+
+
Bank Reconciliation — Account: RBC Operating · 487 unreconciled
+
+
+
+
+
+
Apr 12 — RBC e-transfer
+
Cheque 4827 · Westin Plating Co · $1,847.50 CAD
+
+
+
92% MATCH
+
+
+
+
+ 💡 INV/2026/00123 — Westin Plating Co — $1,847.50
+
+
+
+
+
+
+
+
+
+
+
+
+
Apr 12 — RBC payment
+
Cheque 4828 · partner unknown · $1,800.00 CAD
+
+
68% MATCH
+
+
+
+ 💡 INV/2026/00098 — Westin Plating Co — $1,800.00 · amount matches but partner unconfirmed
+
+
+
+
+
+
+
+
+
+
+
+
+
Apr 11 — Visa adjustment
+
Ref VSA-201 · Royal Bank fees · $89.99 CAD
+
+
NO MATCH
+
+
+
+
+
+
+
+
+
+
+
+
Apr 11 — RBC bulk deposit
+
Ref 9921-D · Westin Plating Co · $3,200.00 CAD
+
+
98% MATCH (alternatives expanded)
+
+
+
AI suggestions, ranked
+
+
+
98% — INV/2026/00145 — $3,200.00 · Westin Plating Co
+
Exact amount + same partner + invoice date Apr 8 · 4 prior reconciles match this pattern
+
+
+
+
+
+
71% — INV/2026/00141 — $3,200.00 · Bramalea Lift Co
+
Amount matches, partner is a different client
+
+
+
+
+
+
62% — INV/2026/00139 + INV/2026/00140 (combined) — Westin Plating Co
+
Two invoices summing to $3,200.00
+
+
+
+
+
+
+
+
+
+ ·
+ 487 lines unreconciled · 47 ready to auto-accept · 134 need review · 306 no AI match
+
+
+
+
+
+
Each line: confidence badge top-right, single suggestion strip below (Accept / Reject / Show alternatives). High-confidence lines have a green border for instant scanning. Bottom bar offers batch-accept of all ≥95% matches at once. The 4th line shows what "Show alternatives" reveals when expanded — B's ranked panel inline.
+
+
+
+
✓
+
+
Looks right — proceed with this hybrid
+
I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.
+
+
+
+
✏
+
+
Mostly right but I want changes
+
Tell me in the terminal what to adjust (positions, colours, button labels, missing actions, etc.).
+
+
+
+
A
+
+
Just pure A, no alternatives panel
+
Keep it simple — single suggestion per line, no expand. If user disagrees with AI they go to the manual reconcile dialog.
+
+
+
diff --git a/.superpowers/brainstorm/84408-1776602183/content/ai-badge-placement.html b/.superpowers/brainstorm/84408-1776602183/content/ai-badge-placement.html
new file mode 100644
index 00000000..4072b0f7
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/content/ai-badge-placement.html
@@ -0,0 +1,101 @@
+
AI Suggestion Placement
+
You picked "AI assistive" — now: how does the AI suggestion appear on each unreconciled bank line? Three layouts:
Confidence badge top-right of each line, suggestion strip just below. One-click Accept/Reject. Familiar Enterprise-style line layout, AI feels like a layer added on top.
+
+
+
+
+
+
+
Layout B — Side Panel
+
+
+
+
Bank lines
+
Apr 12 RBC $1,847.50 ✓ selected
+
Apr 12 RBC $245.00
+
Apr 11 Visa $89.99
+
Apr 11 RBC $3,200.00
+
+
+
AI Suggestions
+
+
92% INV/2026/00123
+
Westin Plating $1,847.50
+
+
+
68% INV/2026/00098
+
Westin Plating $1,800.00
+
+
+
+
+
+
+
+
B — Dedicated Side Panel
+
Bank lines on the left, AI suggestions panel on the right, updates as you select a line. Multiple ranked suggestions visible. More screen real estate for AI; line list stays clean.
Just a confidence dot on each line. AI details appear on hover/click. Cleanest visual, most Enterprise-like density. Slowest discovery for new users.
+
+
+
+
+
Click your preferred option(s). I'll read your selection on the next turn. You can also describe in the terminal what you'd like changed.
diff --git a/.superpowers/brainstorm/84408-1776602183/content/intro.html b/.superpowers/brainstorm/84408-1776602183/content/intro.html
new file mode 100644
index 00000000..61b960f3
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/content/intro.html
@@ -0,0 +1,29 @@
+
Phase 1 — Bank Reconciliation
+
Brainstorming session for the next sub-module: fusion_accounting_bank_rec
+
+
+
What we're designing
+
A native bank-rec widget that replaces Odoo Enterprise's account_accountant bank reconciliation, using Odoo 19's frontend OWL architecture. It reads/writes the same account.partial.reconcile tables Community owns, so existing reconciliations are immune to Enterprise uninstall (verified empirically in Phase 0).
Enterprise V19 source at RePackaged-Odoo/accounting/account_accountant/ — 17 OWL components in static/src/components/bank_reconciliation/, 1 service file (140 lines), 3 inherits on community models, 2 wizards
+
Phase 0 BankRecAdapter — already present at fusion_accounting_ai/services/data_adapters/bank_rec.py with a stub list_unreconciled_via_fusion() waiting to be filled in
+
+
+
+
+
How this session works
+
+
I ask clarifying questions one at a time (terminal for scope/concept, this browser for layout/visual)
+
I propose 2-3 architectural approaches with tradeoffs
+
We work through the design section by section
+
I write the spec doc and you approve it
+
Then we transition to writing the implementation plan
+
+
+
+
Continuing in terminal for the first question...
diff --git a/.superpowers/brainstorm/84408-1776602183/content/waiting-1.html b/.superpowers/brainstorm/84408-1776602183/content/waiting-1.html
new file mode 100644
index 00000000..7eb6f85e
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/content/waiting-1.html
@@ -0,0 +1,4 @@
+
+
UI layout approved ✓
+
Continuing in terminal — next sections are about file structure, reconcile engine algorithms, and migration. Browser will return for any further visual decisions.
+
diff --git a/.superpowers/brainstorm/84408-1776602183/content/waiting-2.html b/.superpowers/brainstorm/84408-1776602183/content/waiting-2.html
new file mode 100644
index 00000000..5ddafedd
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/content/waiting-2.html
@@ -0,0 +1,4 @@
+
+
Spec approved ✓ — committed as 2d64f7e
+
Now writing the Phase 1 implementation plan in terminal. Browser session can be closed; the visual companion isn't needed for plan-writing.
+
diff --git a/.superpowers/brainstorm/84408-1776602183/state/server-stopped b/.superpowers/brainstorm/84408-1776602183/state/server-stopped
new file mode 100644
index 00000000..9481c539
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1776605003749}
diff --git a/.superpowers/brainstorm/84408-1776602183/state/server.log b/.superpowers/brainstorm/84408-1776602183/state/server.log
new file mode 100644
index 00000000..a7036903
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/state/server.log
@@ -0,0 +1,12 @@
+{"type":"server-started","port":50540,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:50540","screen_dir":"/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/content","state_dir":"/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/state"}
+{"type":"screen-added","file":"/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/content/intro.html"}
+{"type":"screen-added","file":"/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/content/ai-badge-placement.html"}
+{"type":"screen-added","file":"/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/content/ai-badge-hybrid-v2.html"}
+{"source":"user-event","type":"click","text":"✓\n \n Looks right — proceed with this hybrid\n I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.","choice":"approve","id":null,"timestamp":1776603091592}
+{"source":"user-event","type":"click","text":"✓\n \n Looks right — proceed with this hybrid\n I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.","choice":"approve","id":null,"timestamp":1776603096458}
+{"source":"user-event","type":"click","text":"✓\n \n Looks right — proceed with this hybrid\n I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.","choice":"approve","id":null,"timestamp":1776603097158}
+{"source":"user-event","type":"click","text":"✓\n \n Looks right — proceed with this hybrid\n I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.","choice":"approve","id":null,"timestamp":1776603097583}
+{"source":"user-event","type":"click","text":"✓\n \n Looks right — proceed with this hybrid\n I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.","choice":"approve","id":null,"timestamp":1776603097800}
+{"source":"user-event","type":"click","text":"✓\n \n Looks right — proceed with this hybrid\n I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.","choice":"approve","id":null,"timestamp":1776603098691}
+{"type":"screen-added","file":"/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/content/waiting-1.html"}
+{"type":"server-stopped","reason":"idle timeout"}
diff --git a/.superpowers/brainstorm/84408-1776602183/state/server.pid b/.superpowers/brainstorm/84408-1776602183/state/server.pid
new file mode 100644
index 00000000..7396bd6c
--- /dev/null
+++ b/.superpowers/brainstorm/84408-1776602183/state/server.pid
@@ -0,0 +1 @@
+84418
diff --git a/fusion_plating/docs/superpowers/plans/2026-04-19-direct-order-rewrite.md b/fusion_plating/docs/superpowers/plans/2026-04-19-direct-order-rewrite.md
new file mode 100644
index 00000000..0af381a1
--- /dev/null
+++ b/fusion_plating/docs/superpowers/plans/2026-04-19-direct-order-rewrite.md
@@ -0,0 +1,1235 @@
+# Direct Order Wizard Rewrite — Implementation Plan
+
+> **Status:** Draft v1 — value matrix + phase A detailed. Phases B/C outlined; to be detailed once Steelhead reference screenshots are collected.
+> **Last updated:** 2026-04-19
+
+**Goal:** Replace the current single-line `fp.direct.order.wizard` with a comprehensive multi-line order-entry wizard on par with (and beyond) Steelhead's Create Sales Order + Add Parts flow, while keeping it approachable for a non-power-user estimator.
+
+**Architecture:** Header + lines pattern. Existing `fp.direct.order.wizard` becomes the **header** carrying customer, PO, deadlines, addresses, fulfilment flags, and invoicing. A new transient `fp.direct.order.line` model carries per-part detail (part, treatments, qty, deadline, price, description, WO group). A single "Create & Confirm Order" submit creates the `sale.order` with one `sale.order.line` per wizard line and confirms it.
+
+**Tech Stack:** Odoo 19, Python 3.12, PostgreSQL, OWL (for any custom widgets), XML views.
+
+---
+
+## Why This Plan Exists
+
+Current `fp.direct.order.wizard` supports one part per order. Real POs routinely list 5–20 parts. Steelhead's Create Sales Order → Add Parts flow covers this elegantly; this plan pulls the high-value pieces over and adds a few things Steelhead doesn't have (live subtotals, description templates, price-list lookup).
+
+---
+
+## Feature Inventory — Value × Cost Matrix
+
+This plan covers two scopes: **(1) the order-entry wizard** (Phases A–C, in scope for this rewrite) and **(2) the SO detail view enhancements** that Steelhead shows after the order exists (Phase D — smaller, independent tasks that don't block the wizard work).
+
+Value: ★★★★★ = every estimator will use it daily. ★ = edge case.
+Cost: S = < 2 hr, M = half day, L = 1 day+.
+
+### Phase A — P0 Must-Have (cannot ship without these)
+
+| # | Feature | Value | Cost | Source |
+|---|---------|-------|------|--------|
+| A1 | Multi-row parts grid with add/duplicate/delete | ★★★★★ | M | Steelhead |
+| A2 | Multiple treatments per line (M2M) | ★★★★★ | M | Steelhead |
+| A3 | Per-row Part Deadline (falls back to SO deadline) | ★★★★ | S | Steelhead |
+| A4 | SO header dates: Planned Start + Internal + Customer Deadline | ★★★★★ | S | Steelhead |
+| A5 | Customer Job Number | ★★★★ | S | Steelhead |
+| A6 | Ship To / Bill To pickers (partner addresses) | ★★★★ | S | Steelhead |
+| A7 | Live line subtotal + order total | ★★★★ | S | ours extended |
+| A8 | Description template picker per line | ★★★★ | S | ours kept |
+
+### Phase B — P1 High-Value (ship soon after)
+
+| # | Feature | Value | Cost | Source |
+|---|---------|-------|------|--------|
+| B1 | Blanket Sales Order flag | ★★★ | S | Steelhead |
+| B2 | Block Partial Shipments flag | ★★★ | S | Steelhead |
+| B3 | Work Order grouping per line (New WO#1 / existing WO) | ★★★★ | L | Steelhead |
+| B4 | Add Row From Prior Sales Order (repeat-order shortcut) | ★★★★ | M | Steelhead |
+| B5 | Missing-info warning banner | ★★★ | S | Steelhead |
+| B6 | Rush Order per row (moves from header to lines) | ★★★ | S | ours refined |
+
+### Phase F — Quotes List View (related but separate workflow)
+
+Observed from the Steelhead "Quotes" list page. Quotes = `sale.order` in state `draft/sent` in Odoo; confirmed orders = state `sale`. Most of these are XML list-view additions.
+
+| # | Feature | Value | Cost | Source |
+|---|---------|-------|------|--------|
+| F1 | Follow-Up column (next action user + date) | ★★★★ | M | Steelhead — drives sales discipline |
+| F2 | Expires column + "No Expiration" default | ★★★★ | S | Steelhead — Odoo has `validity_date` |
+| F3 | Email status pills: Draft / Draft-Sent / Draft-Opened / Order Received | ★★★★ | M | Steelhead — needs mail tracking hook |
+| F4 | Signed column (e-signature status from bridge_sign) | ★★★ | S | Steelhead |
+| F5 | Part Numbers column with "See More..." expansion | ★★★ | S | Steelhead |
+| F6 | Process/Part Count summary column | ★★ | S | Steelhead |
+| F7 | "From RFQ" toggle filter | ★★★ | S | Steelhead — domain on `x_fc_rfq_id` |
+| F8 | Bulk select with checkboxes | ★★ | S | Odoo-native |
+| F9 | Per-row action icons (Download / PDF) | ★★ | S | Steelhead |
+| F10 | "New Quote" button in toolbar → opens configurator | ★★★★ | S | Steelhead |
+
+### Phase E — SO List View (independent, small, high visibility)
+
+Observed from the Steelhead "Sales Orders" list page. Mostly Odoo list-view XML; no models harmed.
+
+| # | Feature | Value | Cost | Source |
+|---|---------|-------|------|--------|
+| E1 | View toggles: Show Invoiced Amount / Show Margins / View Blanket / View Archived | ★★★★ | S | Steelhead |
+| E2 | Progress column "0/1 Complete" (WO completion ratio per SO) | ★★★★ | M | Steelhead |
+| E3 | Margin column (computed total − cost) | ★★★ | S | Steelhead |
+| E4 | Invoiced Amount column | ★★★ | S | Steelhead |
+| E5 | Inline action icons per row (PDF / Edit / Download Acknowledgement) | ★★★ | M | Steelhead |
+| E6 | Filter preset chips (Status=Open pre-applied) | ★★★ | S | Steelhead |
+| E7 | Creator avatar column with initials + colour | ★★ | S | Steelhead |
+| E8 | Customer column with partner image | ★★ | S | Odoo-native |
+| E9 | SO Internal Deadline column | ★★★★ | S | Phase A brings the field; E9 exposes it |
+| E10 | Quick "New Sales Order" button in toolbar → opens direct-order wizard | ★★★★★ | S | Steelhead |
+
+### Phase D — SO Detail View Enhancements (independent of the wizard)
+
+Observed from the Steelhead SO detail page. These live on the existing `sale.order` form, not the wizard, so they can land in parallel with or after Phase A.
+
+| # | Feature | Value | Cost | Source |
+|---|---------|-------|------|--------|
+| D1 | Live countdown display on deadlines ("in 2d 12h ago") | ★★ | S | Steelhead |
+| D2 | BOM Items section — parts list with WO progress | ★★★ | M | Steelhead |
+| D3 | Active Work Orders table embedded on SO page | ★★★★ | M | Steelhead |
+| D4 | Split Internal Notes vs External Notes (customer-visible) | ★★★★ | S | Steelhead |
+| D5 | Archive Line (soft delete) vs hard delete | ★★★ | S | Steelhead |
+| D6 | Add Quoted Lines button (pull lines from a prior quote) | ★★★ | M | Steelhead |
+| D7 | Sales Order Acknowledgement PDF generator | ★★★ | M | Steelhead |
+| D8 | Margin display on header | ★★★ | S | Steelhead |
+| D9 | Quick-nav link bar (Shipping / Invoices / NCRs / All Files / etc.) | ★★★ | S | Steelhead |
+| D10 | SO vs WO perspective toggle on line list | ★★ | M | Steelhead |
+| D11 | Assemblies section — hierarchical parts (sub-BOM on an SO line) | ★★ | L | Steelhead |
+| D12 | Customer contact + phone on SO header | ★★★ | S | Steelhead |
+| D13 | Ship Via (carrier) field | ★★ | S | Steelhead |
+| D14 | Uploaded Files section on SO | ★★ | S | Steelhead |
+
+### Phase C — P2 Polish (nice when there's time)
+
+| # | Feature | Value | Cost | Source |
+|---|---------|-------|------|--------|
+| C1 | "To Node" start-point picker (re-work jobs) | ★★ | M | Steelhead |
+| C2 | Split part description vs. WO-detail description | ★★ | S | Steelhead |
+| C3 | Quote/Price picker (link line to prior quote) | ★★★ | M | Steelhead |
+| C4 | Update Defaults toggle per line | ★★ | S | Steelhead |
+| C5 | One-Off Part # toggle (don't save to catalog) | ★★ | S | Steelhead |
+| C6 | Keyboard shortcut: Cmd/Ctrl-I = new line | ★★ | S | Steelhead |
+
+### Skipped — low value for our shop
+
+| Feature | Why skip |
+|---------|----------|
+| Sector / industry tag | Low information value; partner already has category. |
+| Display Image | Part record already carries drawings / STL. |
+| Assign Location per row | Plating parts live on racks, not bins. |
+| Update Customer Defaults button | Our onchange already pulls partner defaults. |
+| Order Type custom input | Overlaps with Coating field. |
+| Create Multiple Part Numbers wizard | Needs a spec-field system we don't have. Revisit after that foundation lands. |
+| Sector / Display Image | Low value. |
+
+---
+
+## Open Questions — pending screenshots / client input
+
+These will be filled in as the client shares more Steelhead flows or decides preferences:
+
+- [ ] **Q1: Exact field order and grouping** — defer to final screenshot pass.
+- [ ] **Q2: Work Order grouping semantics** — what happens when "New WO#1" is picked on 3 lines: do all 3 become one MO/WO in Odoo, or one MO with a shared x_fc_wo_group_tag? Leaning toward the second.
+- [ ] **Q3: "To Node" target** — does this drive the first WO's state on confirm, or a field on the MO that the bridge_mrp WO generator respects?
+- [ ] **Q4: Treatment selection UX** — two-level Process + Treatment (Steelhead) vs. flat multi-select of coating configs (ours today). Client preference TBD.
+- [ ] **Q5: Missing-info rules** — exactly which fields count toward "missing"? Starting assumption: part + coating + qty + price required; deadline recommended.
+- [ ] **Q6: Blanket-order release mechanics** — when someone calls a release against a blanket, is that a child SO or a separate DO? Not in scope for this wizard but decides whether we flag releases at line level.
+
+---
+
+## File Structure
+
+```
+fusion_plating_configurator/
+├── wizard/
+│ ├── fp_direct_order_wizard.py # [MODIFIED] header only + create logic
+│ ├── fp_direct_order_wizard_views.xml # [MODIFIED] new form with notebook + lines table
+│ ├── fp_direct_order_line.py # [NEW] transient line model
+│ └── fp_add_row_from_so_wizard.py # [NEW — Phase B] sub-wizard for repeat-order pick
+├── models/
+│ ├── sale_order.py # [MODIFIED] add Customer Job #, deadlines, blanket/block-partial, WO group tag
+│ └── sale_order_line.py # [NEW] x_fc_treatment_ids M2M, x_fc_part_deadline, x_fc_wo_group_tag
+├── security/
+│ └── ir.model.access.csv # [MODIFIED] add access for fp.direct.order.line
+└── views/
+ ├── fp_direct_order_wizard_views.xml # moved from wizard/ — keep in wizard/
+ └── sale_order_views.xml # [MODIFIED] surface new SO-header fields on SO form
+```
+
+**Existing files referenced:**
+- `fusion_plating_configurator/models/fp_part_catalog.py` — already has `partner_id`, `surface_area`, revisions
+- `fusion_plating_configurator/models/fp_coating_config.py` — already has UOM, price
+- `fusion_plating_configurator/models/fp_customer_price_list.py` — already supports `_find_price`
+- `fusion_plating_configurator/models/fp_sale_description_template.py` — per-part / per-customer templates
+
+---
+
+## Data Model Design
+
+### `fp.direct.order.wizard` (modified — becomes header)
+
+Fields to **keep** (already there):
+- `partner_id`, `po_number`, `po_attachment_file/filename`, `notes`, `invoice_strategy`, `deposit_percent`, `progress_initial_percent`, `currency_id`
+
+Fields to **add** (Phase A):
+- `partner_invoice_id` (Many2one `res.partner`) — Bill To. Domain: child of partner_id with type=invoice, or partner_id itself.
+- `partner_shipping_id` (Many2one `res.partner`) — Ship To. Similar domain with type=delivery.
+- `customer_job_number` (Char) — customer-internal ref, optional.
+- `planned_start_date` (Date) — when work should start. Default today.
+- `internal_deadline` (Date) — shop-floor target.
+- `customer_deadline` (Date) — contractual. Becomes `commitment_date` on SO.
+- `line_ids` (One2many `fp.direct.order.line`, `wizard_id`) — the grid.
+- `total_amount` (Monetary, compute) — sum of line subtotals.
+- `total_qty` (Integer, compute) — sum of line quantities.
+- `total_line_count` (Integer, compute) — for "Sales Order Total Part Count".
+- `missing_info_msg` (Char, compute) — banner text; empty = no banner.
+
+Fields to **add** (Phase B):
+- `is_blanket_order` (Boolean) — tags the SO.
+- `block_partial_shipments` (Boolean) — forbids partial deliveries.
+
+Fields to **remove** from wizard (move to line):
+- `part_catalog_id`, `coating_config_id`, `quantity`, `unit_price`, `line_subtotal`, `rush_order`, `delivery_method` (keep delivery_method at header), `create_new_revision`, `new_drawing_file/filename`, `revision_note`, `description_template_id`, `line_description`
+
+### `fp.direct.order.line` (NEW)
+
+```python
+class FpDirectOrderLine(models.TransientModel):
+ _name = 'fp.direct.order.line'
+ _description = 'Direct Order Line'
+ _order = 'sequence, id'
+
+ wizard_id = fields.Many2one('fp.direct.order.wizard', required=True, ondelete='cascade')
+ sequence = fields.Integer(default=10)
+
+ # Part
+ part_catalog_id = fields.Many2one(
+ 'fp.part.catalog',
+ domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]",
+ required=True,
+ )
+ part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
+ part_revision = fields.Char(related='part_catalog_id.revision', readonly=True)
+
+ # New revision (inline, Phase A keeps this from old wizard)
+ create_new_revision = fields.Boolean(string='New Revision')
+ new_drawing_file = fields.Binary()
+ new_drawing_filename = fields.Char()
+ revision_note = fields.Char()
+
+ # Treatments (M2M — multiple coatings/treatments per line)
+ coating_config_id = fields.Many2one(
+ 'fp.coating.config',
+ string='Primary Treatment',
+ required=True,
+ )
+ treatment_ids = fields.Many2many(
+ 'fp.treatment',
+ string='Additional Treatments',
+ help='Extra pre/post treatments applied to this line.',
+ )
+
+ # Qty / price
+ quantity = fields.Integer(default=1, required=True)
+ currency_id = fields.Many2one(related='wizard_id.currency_id')
+ unit_price = fields.Monetary(currency_field='currency_id')
+ line_subtotal = fields.Monetary(compute='_compute_line_subtotal', currency_field='currency_id')
+
+ # Scheduling
+ part_deadline = fields.Date(
+ help='Per-line deadline. Defaults to SO customer deadline if blank.',
+ )
+
+ # Fulfilment
+ rush_order = fields.Boolean()
+
+ # Description
+ description_template_id = fields.Many2one('fp.sale.description.template')
+ line_description = fields.Text()
+
+ # --- Phase B ---
+ wo_group_tag = fields.Char(
+ string='Work Order Group',
+ help='Lines sharing a tag (e.g. "WO#1") are batched into one MO on confirm.',
+ )
+
+ # --- Phase C ---
+ part_wo_description = fields.Text(
+ string='On Work Order',
+ help='Extra detail that appears on the work order travelling sheet.',
+ )
+ start_at_node_id = fields.Many2one(
+ 'fusion.plating.process.node',
+ string='Start at Node',
+ help='For re-work — pick the recipe node where this job should begin.',
+ )
+ quote_line_id = fields.Many2one(
+ 'fp.quote.configurator',
+ string='Linked Quote',
+ )
+ push_to_defaults = fields.Boolean(
+ string='Save as Default',
+ help='After submit, write this line\'s coating/treatments/desc back onto the part.',
+ )
+ is_one_off = fields.Boolean(
+ string='One-off Part',
+ help='Do not save this part in the catalog after the order is created.',
+ )
+```
+
+### `sale.order` (modified)
+
+New x_fc_* fields:
+- `x_fc_customer_job_number` (Char) — Phase A
+- `x_fc_planned_start_date` (Date) — Phase A
+- `x_fc_internal_deadline` (Date) — Phase A
+- (Customer deadline already maps to Odoo's `commitment_date`.)
+- `x_fc_is_blanket_order` (Boolean) — Phase B
+- `x_fc_block_partial_shipments` (Boolean) — Phase B
+
+### `sale.order.line` (modified)
+
+New x_fc_* fields:
+- `x_fc_treatment_ids` (M2M `fp.treatment`) — Phase A
+- `x_fc_part_deadline` (Date) — Phase A
+- `x_fc_wo_group_tag` (Char) — Phase B
+- `x_fc_part_wo_description` (Text) — Phase C
+- `x_fc_start_at_node_id` (Many2one `fusion.plating.process.node`) — Phase C
+
+---
+
+## UI Layout — wizard form
+
+```
+┌─ New Direct Order ────────────────────────────── ◩ ✕ ┐
+│ │
+│ [ Missing Info banner, amber, shows only if needed ] │
+│ │
+│ ┌─── CUSTOMER ──────────────┐ ┌─── PURCHASE ORDER ─┐│
+│ │ Customer [___▾] │ │ Customer PO # [_]││
+│ │ Customer Job # [_] │ │ PO Document [📎]││
+│ │ Bill To [___▾] │ │ Customer Deadline ││
+│ │ Ship To [___▾] │ │ [📅]││
+│ └────────────────────────────┘ └───────────────────┘│
+│ │
+│ ┌─── SCHEDULING ─────────────────────────────────────┐
+│ │ Planned Start [📅] Internal Deadline [📅] │
+│ │ Customer Deadline is in PO Box, shown there │
+│ └────────────────────────────────────────────────────┘
+│ │
+│ ┌─── LINES ──────────────────────────────────────────┐
+│ │ ┌──────────────────────────────────────────────┐ │
+│ │ │ Part │ Coat │ Tmnt │ Qty │ $ │ Dl │ Sub │ ✕ │ … │
+│ │ ├──────────────────────────────────────────────┤ │
+│ │ │ … rows … │ │
+│ │ └──────────────────────────────────────────────┘ │
+│ │ [+ Add Line] [+ From Prior SO] [Total: $ 1,200] │
+│ └────────────────────────────────────────────────────┘
+│ │
+│ ┌─── FULFILMENT & INVOICING ─────────────────────────┐
+│ │ Delivery Method [__▾] Invoice Strategy [__▾] │
+│ │ [☐] Is Blanket Order [☐] Block Partial Ships │
+│ └────────────────────────────────────────────────────┘
+│ │
+│ ┌─── NOTES ──────────────────────────────────────────┐
+│ │ [multiline textarea] │
+│ └────────────────────────────────────────────────────┘
+│ │
+│ [Create & Confirm Order] [Save Draft][✕] │
+└───────────────────────────────────────────────────────┘
+```
+
+Each expand-row on a line reveals: new-revision fields, description template + tweak, WO group, To Node, quote link.
+
+---
+
+## Phase A — Task Breakdown (detailed)
+
+### Task A1: Stub the line model + minimum scaffolding
+
+**Files:**
+- Create: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
+- Modify: `fusion_plating_configurator/wizard/__init__.py`
+- Modify: `fusion_plating_configurator/security/ir.model.access.csv`
+
+- [ ] **Step 1: Create `fp_direct_order_line.py` with minimum fields**
+
+```python
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+from odoo import api, fields, models
+
+
+class FpDirectOrderLine(models.TransientModel):
+ _name = 'fp.direct.order.line'
+ _description = 'Direct Order Line'
+ _order = 'sequence, id'
+
+ wizard_id = fields.Many2one(
+ 'fp.direct.order.wizard', required=True, ondelete='cascade',
+ )
+ sequence = fields.Integer(default=10)
+
+ part_catalog_id = fields.Many2one(
+ 'fp.part.catalog', string='Part', required=True,
+ )
+ coating_config_id = fields.Many2one(
+ 'fp.coating.config', string='Primary Treatment', required=True,
+ )
+ quantity = fields.Integer(string='Qty', default=1, required=True)
+ currency_id = fields.Many2one(related='wizard_id.currency_id')
+ unit_price = fields.Monetary(
+ string='Unit Price', currency_field='currency_id',
+ )
+ line_subtotal = fields.Monetary(
+ string='Subtotal', currency_field='currency_id',
+ compute='_compute_line_subtotal',
+ )
+
+ @api.depends('quantity', 'unit_price')
+ def _compute_line_subtotal(self):
+ for rec in self:
+ rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
+```
+
+- [ ] **Step 2: Register in wizard `__init__.py`**
+
+```python
+from . import fp_direct_order_wizard
+from . import fp_direct_order_line
+from . import fp_part_catalog_import_wizard
+```
+
+- [ ] **Step 3: Add ACL row**
+
+Append to `security/ir.model.access.csv`:
+
+```csv
+access_fp_direct_order_line_user,fp.direct.order.line.user,model_fp_direct_order_line,base.group_user,1,1,1,1
+```
+
+- [ ] **Step 4: Update module on local Docker dev and verify model registered**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | tail -5
+docker exec odoo-dev-app odoo shell -d fusion-dev --no-http 2>/dev/null <<'EOF'
+print(env['fp.direct.order.line']._fields.keys())
+EOF
+```
+
+Expected: field list includes `wizard_id`, `part_catalog_id`, `line_subtotal`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add fusion_plating_configurator/wizard/fp_direct_order_line.py \
+ fusion_plating_configurator/wizard/__init__.py \
+ fusion_plating_configurator/security/ir.model.access.csv
+git commit -m "feat(configurator): stub fp.direct.order.line model"
+```
+
+---
+
+### Task A2: Header-field additions + line O2M link on wizard
+
+**Files:**
+- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
+
+- [ ] **Step 1: Add new fields to wizard class**
+
+Open `fp_direct_order_wizard.py`. Locate the end of the field declarations (around line 116, before `@api.depends`). Add:
+
+```python
+ # --- Header: addresses ---
+ partner_invoice_id = fields.Many2one(
+ 'res.partner', string='Invoice Address',
+ domain="['|', ('id', '=', partner_id), "
+ "('parent_id', '=', partner_id), ('type', 'in', ['invoice', 'contact'])]",
+ )
+ partner_shipping_id = fields.Many2one(
+ 'res.partner', string='Delivery Address',
+ domain="['|', ('id', '=', partner_id), "
+ "('parent_id', '=', partner_id), ('type', 'in', ['delivery', 'contact'])]",
+ )
+
+ # --- Header: scheduling ---
+ customer_job_number = fields.Char(
+ string='Customer Job #',
+ help="Customer's internal job number. Appears on our work orders "
+ "and the customer's invoice for easy cross-referencing.",
+ )
+ planned_start_date = fields.Date(
+ string='Planned Start', default=fields.Date.context_today,
+ )
+ internal_deadline = fields.Date(string='Internal Deadline')
+ customer_deadline = fields.Date(string='Customer Deadline')
+
+ # --- Lines ---
+ line_ids = fields.One2many(
+ 'fp.direct.order.line', 'wizard_id', string='Order Lines',
+ )
+ total_amount = fields.Monetary(
+ compute='_compute_totals', currency_field='currency_id',
+ )
+ total_qty = fields.Integer(compute='_compute_totals')
+ total_line_count = fields.Integer(compute='_compute_totals')
+
+ # --- Missing info banner ---
+ missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
+```
+
+- [ ] **Step 2: Add the compute methods**
+
+After `_compute_line_subtotal` (existing around line 118), add:
+
+```python
+ @api.depends('line_ids.line_subtotal', 'line_ids.quantity')
+ def _compute_totals(self):
+ for rec in self:
+ rec.total_amount = sum(rec.line_ids.mapped('line_subtotal'))
+ rec.total_qty = sum(rec.line_ids.mapped('quantity'))
+ rec.total_line_count = len(rec.line_ids)
+
+ @api.depends('line_ids.part_catalog_id', 'line_ids.coating_config_id',
+ 'line_ids.unit_price', 'line_ids.quantity')
+ def _compute_missing_info_msg(self):
+ for rec in self:
+ missing = []
+ for line in rec.line_ids:
+ if not line.part_catalog_id:
+ missing.append('part')
+ if not line.coating_config_id:
+ missing.append('coating')
+ if not line.unit_price:
+ missing.append('price')
+ if missing:
+ rec.missing_info_msg = (
+ 'Some lines are missing quote information — verify '
+ 'before confirming.'
+ )
+ else:
+ rec.missing_info_msg = False
+```
+
+- [ ] **Step 3: Add onchange — reset addresses when partner changes**
+
+Append to `_onchange_partner_id`:
+
+```python
+ if self.partner_id:
+ self.partner_invoice_id = (
+ self.partner_id.address_get(['invoice']).get('invoice')
+ )
+ self.partner_shipping_id = (
+ self.partner_id.address_get(['delivery']).get('delivery')
+ )
+```
+
+- [ ] **Step 4: Deploy + quick shell check**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL" || echo "OK"
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py
+git commit -m "feat(configurator): add header fields + line O2M to direct order wizard"
+```
+
+---
+
+### Task A3: Remove single-line fields from wizard + migrate action_create_order to loop
+
+**Files:**
+- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
+
+- [ ] **Step 1: Delete the moved-to-line fields**
+
+Remove these field declarations (they now live on `fp.direct.order.line`):
+- `part_catalog_id`, `part_number`, `current_revision`, `surface_area`, `surface_area_uom`
+- `create_new_revision`, `new_drawing_file`, `new_drawing_filename`, `revision_note`
+- `coating_config_id`, `quantity`, `unit_price`, `line_subtotal`, `rush_order`
+- `description_template_id`, `line_description`
+
+Also delete the now-orphan onchange methods (`_onchange_description_template`, `_onchange_suggest_template`, `_onchange_lookup_price`) and the `_compute_line_subtotal` method — they move to the line model in Task A4.
+
+- [ ] **Step 2: Rewrite `action_create_order` to loop over lines**
+
+Replace the existing `action_create_order` method:
+
+```python
+ def action_create_order(self):
+ """Create and confirm the sale order with one SO line per wizard line."""
+ self.ensure_one()
+ if not self.line_ids:
+ raise UserError(_('Add at least one part line.'))
+
+ # 1. Save the PO attachment once
+ po_att = self.env['ir.attachment'].create({
+ 'name': self.po_attachment_filename or 'po.pdf',
+ 'datas': self.po_attachment_file,
+ 'mimetype': 'application/pdf',
+ })
+
+ # 2. Find or create the generic plating service product
+ product = self.env['product.product'].search(
+ [('default_code', '=', 'FP-SERVICE')], limit=1,
+ )
+ if not product:
+ product = self.env['product.product'].create({
+ 'name': 'Plating Service',
+ 'default_code': 'FP-SERVICE',
+ 'type': 'service',
+ 'list_price': 0,
+ 'sale_ok': True,
+ 'purchase_ok': False,
+ })
+
+ # 3. Build SO header
+ so_vals = {
+ 'partner_id': self.partner_id.id,
+ 'partner_invoice_id': (
+ self.partner_invoice_id.id or self.partner_id.id
+ ),
+ 'partner_shipping_id': (
+ self.partner_shipping_id.id or self.partner_id.id
+ ),
+ 'x_fc_po_number': self.po_number,
+ 'x_fc_po_attachment_id': po_att.id,
+ 'x_fc_po_received': True,
+ 'x_fc_customer_job_number': self.customer_job_number or False,
+ 'x_fc_planned_start_date': self.planned_start_date,
+ 'x_fc_internal_deadline': self.internal_deadline,
+ 'commitment_date': self.customer_deadline,
+ 'x_fc_invoice_strategy': self.invoice_strategy,
+ 'x_fc_deposit_percent': self.deposit_percent,
+ 'x_fc_progress_initial_percent': self.progress_initial_percent,
+ 'origin': 'Direct Order',
+ 'note': self.notes or False,
+ 'order_line': [],
+ }
+
+ # 4. One SO line per wizard line
+ for line in self.line_ids:
+ part = line._get_or_bump_revision() # revision handling moved to line
+ header = '%s - %s Rev %s (x%d)' % (
+ line.coating_config_id.name,
+ part.name,
+ part.revision or part.revision_number,
+ line.quantity,
+ )
+ extended = (line.line_description or '').strip()
+ line_desc = (header + '\n\n' + extended) if extended else header
+ if line.description_template_id:
+ line.description_template_id._register_usage()
+ so_vals['order_line'].append((0, 0, {
+ 'product_id': product.id,
+ 'name': line_desc,
+ 'product_uom_qty': line.quantity,
+ 'price_unit': line.unit_price or 0.0,
+ 'x_fc_part_catalog_id': part.id,
+ 'x_fc_coating_config_id': line.coating_config_id.id,
+ 'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
+ 'x_fc_part_deadline': line.part_deadline,
+ 'x_fc_rush_order': line.rush_order,
+ }))
+
+ so = self.env['sale.order'].create(so_vals)
+ so.action_confirm()
+ so.message_post(body=_(
+ 'Direct order created from PO %s with %d line(s). '
+ 'Quotation stage skipped.'
+ ) % (self.po_number, len(self.line_ids)))
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Sale Order'),
+ 'res_model': 'sale.order',
+ 'res_id': so.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+```
+
+- [ ] **Step 3: Deploy + verify no load errors**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py
+git commit -m "refactor(configurator): loop direct order wizard over line_ids"
+```
+
+---
+
+### Task A4: Fill out fp.direct.order.line with all per-line logic
+
+**Files:**
+- Modify: `fusion_plating_configurator/wizard/fp_direct_order_line.py`
+
+- [ ] **Step 1: Add remaining line fields**
+
+Extend the line model with:
+
+```python
+ # Part details
+ part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
+ part_revision = fields.Char(related='part_catalog_id.revision', readonly=True)
+ surface_area = fields.Float(
+ related='part_catalog_id.surface_area', readonly=True, digits=(12, 4),
+ )
+
+ # New revision
+ create_new_revision = fields.Boolean(string='This is a New Revision')
+ new_drawing_file = fields.Binary()
+ new_drawing_filename = fields.Char()
+ revision_note = fields.Char()
+
+ # Extra treatments
+ treatment_ids = fields.Many2many(
+ 'fp.treatment', string='Additional Treatments',
+ )
+
+ # Scheduling / fulfilment
+ part_deadline = fields.Date(string='Part Deadline')
+ rush_order = fields.Boolean(string='Rush')
+
+ # Description
+ description_template_id = fields.Many2one(
+ 'fp.sale.description.template', string='Description Template',
+ domain="[('active','=',True), '|', '|', '|', "
+ " ('part_catalog_id','=',part_catalog_id), "
+ " ('part_catalog_id','=',False), "
+ " ('partner_id','=',parent.partner_id), "
+ " ('coating_config_id','=',coating_config_id)]",
+ )
+ line_description = fields.Text()
+```
+
+- [ ] **Step 2: Move auto-price lookup and template suggestion onchange onto the line**
+
+```python
+ @api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
+ def _onchange_lookup_price(self):
+ if self.unit_price:
+ return
+ if not (self.wizard_id.partner_id and self.coating_config_id):
+ return
+ price = self.env['fp.customer.price.list']._find_price(
+ self.wizard_id.partner_id.id,
+ self.coating_config_id.id,
+ quantity=self.quantity or 1,
+ )
+ if price:
+ self.unit_price = price.unit_price
+
+ @api.onchange('description_template_id')
+ def _onchange_description_template(self):
+ if self.description_template_id:
+ self.line_description = self.description_template_id.description
+
+ @api.onchange('part_catalog_id', 'coating_config_id')
+ def _onchange_suggest_template(self):
+ if self.description_template_id or self.line_description:
+ return
+ Template = self.env['fp.sale.description.template']
+ if self.part_catalog_id:
+ match = Template.search([
+ ('active', '=', True),
+ ('part_catalog_id', '=', self.part_catalog_id.id),
+ ], order='sequence', limit=1)
+ if match:
+ self.description_template_id = match.id
+ self.line_description = match.description
+ return
+ if self.wizard_id.partner_id:
+ match = Template.search([
+ ('active', '=', True),
+ ('part_catalog_id', '=', False),
+ ('partner_id', '=', self.wizard_id.partner_id.id),
+ ], order='sequence', limit=1)
+ if match:
+ self.description_template_id = match.id
+ self.line_description = match.description
+ return
+ if self.coating_config_id:
+ match = Template.search([
+ ('active', '=', True),
+ ('part_catalog_id', '=', False),
+ ('partner_id', '=', False),
+ ('coating_config_id', '=', self.coating_config_id.id),
+ ], order='sequence', limit=1)
+ if match:
+ self.description_template_id = match.id
+ self.line_description = match.description
+```
+
+- [ ] **Step 3: Add `_get_or_bump_revision` helper**
+
+```python
+ def _get_or_bump_revision(self):
+ """Return the part to use for the SO line, optionally bumping revision."""
+ self.ensure_one()
+ part = self.part_catalog_id
+ if not self.create_new_revision:
+ return part
+ if not self.new_drawing_file:
+ raise UserError(_(
+ 'Upload the new drawing before confirming this line.'
+ ))
+ drawing_att = self.env['ir.attachment'].create({
+ 'name': self.new_drawing_filename or 'drawing.pdf',
+ 'datas': self.new_drawing_file,
+ 'res_model': 'fp.part.catalog',
+ 'res_id': part.id,
+ })
+ part.action_create_revision()
+ new_rev = self.env['fp.part.catalog'].search([
+ ('parent_part_id', '=', (part.parent_part_id or part).id),
+ ('is_latest_revision', '=', True),
+ ], limit=1, order='revision_number desc')
+ if new_rev:
+ new_rev.write({'revision_note': self.revision_note or False})
+ fname = (self.new_drawing_filename or '').lower()
+ if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')):
+ new_rev.model_attachment_id = drawing_att.id
+ else:
+ new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
+ return new_rev
+ return part
+```
+
+- [ ] **Step 4: Deploy**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add fusion_plating_configurator/wizard/fp_direct_order_line.py
+git commit -m "feat(configurator): full direct-order line with price lookup + rev bump"
+```
+
+---
+
+### Task A5: Add x_fc_* fields on sale.order and sale.order.line
+
+**Files:**
+- Modify: `fusion_plating_configurator/models/sale_order.py`
+- Create: `fusion_plating_configurator/models/sale_order_line.py`
+- Modify: `fusion_plating_configurator/models/__init__.py`
+
+- [ ] **Step 1: Add new header fields to `sale_order.py`**
+
+Open `sale_order.py` and add:
+
+```python
+ x_fc_customer_job_number = fields.Char(
+ string='Customer Job #',
+ help="Customer's internal job number for cross-referencing.",
+ tracking=True,
+ )
+ x_fc_planned_start_date = fields.Date(string='Planned Start Date')
+ x_fc_internal_deadline = fields.Date(string='Internal Deadline')
+```
+
+- [ ] **Step 2: Create `sale_order_line.py`**
+
+```python
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+from odoo import fields, models
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ x_fc_part_catalog_id = fields.Many2one(
+ 'fp.part.catalog', string='Part', tracking=True,
+ )
+ x_fc_coating_config_id = fields.Many2one(
+ 'fp.coating.config', string='Primary Treatment',
+ )
+ x_fc_treatment_ids = fields.Many2many(
+ 'fp.treatment', string='Additional Treatments',
+ )
+ x_fc_part_deadline = fields.Date(string='Part Deadline')
+ x_fc_rush_order = fields.Boolean(string='Rush')
+```
+
+- [ ] **Step 3: Register in `models/__init__.py`**
+
+Append:
+
+```python
+from . import sale_order_line
+```
+
+- [ ] **Step 4: Deploy**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add fusion_plating_configurator/models/
+git commit -m "feat(configurator): x_fc_* fields on sale.order and sale.order.line"
+```
+
+---
+
+### Task A6: Wizard form view with notebook + line tree
+
+**Files:**
+- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml`
+
+- [ ] **Step 1: Rewrite the form view**
+
+Replace the existing `view_fp_direct_order_wizard_form` with a notebook-style layout. Full markup (wrap in the existing ``):
+
+```xml
+
+ fp.direct.order.wizard.form
+ fp.direct.order.wizard
+
+
+
+
+```
+
+- [ ] **Step 2: Deploy + open the wizard in the UI**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
+```
+
+Open http://localhost:8069 → Plating → Sales → New Direct Order. Verify the header + lines table renders and rows can be added/removed.
+
+- [ ] **Step 3: Smoke test — create a 3-line order**
+
+Pick a customer, upload a PO, add 3 lines with different parts/coatings/quantities. Hit Create & Confirm. Verify the resulting SO has 3 lines with matching qty/price/deadline and is in state `sale`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
+git commit -m "feat(configurator): notebook wizard form with lines tree"
+```
+
+---
+
+### Task A7: Surface new header fields on sale.order form
+
+**Files:**
+- Modify: `fusion_plating_configurator/views/sale_order_views.xml`
+
+- [ ] **Step 1: Add fields to the existing SO form inheritance**
+
+Open the file, locate where `x_fc_po_number` is already rendered, and add (nearby):
+
+```xml
+
+
+
+```
+
+And for the line tree inside SO form, add:
+
+```xml
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Deploy + open an SO**
+
+```bash
+docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL" | tail -3
+```
+
+Open the SO created in Task A6 and verify all x_fc_* fields are visible.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add fusion_plating_configurator/views/sale_order_views.xml
+git commit -m "feat(configurator): surface new x_fc_* fields on sale order form"
+```
+
+---
+
+### Task A8: Bump module version + manifest update
+
+**Files:**
+- Modify: `fusion_plating_configurator/__manifest__.py`
+
+- [ ] **Step 1: Bump version and add new files**
+
+Bump `version` by one minor (e.g. `19.0.3.0.0`). Add the new files to `data`:
+
+```python
+'data': [
+ # ... existing ...
+ # already present: views/sale_order_views.xml, wizard/fp_direct_order_wizard_views.xml
+],
+```
+
+The line model is picked up via `__init__.py` — no extra data entry needed.
+
+- [ ] **Step 2: Deploy on odoo-entech for the first end-to-end UAT**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_configurator --stop-after-init\" 2>&1 | grep -E \"ERROR|CRITICAL|Registry loaded\" | tail -5 && systemctl start odoo'"
+```
+
+- [ ] **Step 3: Smoke test on staging — repeat Task A6 step 3 against real client data**
+
+- [ ] **Step 4: Commit + push**
+
+```bash
+git add fusion_plating_configurator/__manifest__.py
+git commit -m "chore(configurator): bump to 19.0.3.0.0 for multi-line direct order"
+git push origin main
+```
+
+---
+
+## Phase B — Task Outline (to be detailed in next pass)
+
+### B1–B2: Blanket + Block Partial flags
+
+- Add `x_fc_is_blanket_order`, `x_fc_block_partial_shipments` on `sale.order`.
+- Wizard header toggles.
+- `stock.picking` hook that refuses partial pick when `x_fc_block_partial_shipments=True`.
+
+### B3: Work Order grouping
+
+- `x_fc_wo_group_tag` on `sale.order.line`.
+- `fusion_plating_bridge_mrp` WO generator groups lines by tag into one MO.
+- Tag picker in the wizard line form: free-text, with suggestions from existing tags on the same wizard.
+
+### B4: Add Row From Prior Sales Order
+
+- New sub-wizard `fp.add.row.from.so.wizard`.
+- Shows prior SO lines for the same customer; user multi-selects; we create matching `fp.direct.order.line` rows.
+
+### B5: Missing-info warning banner
+
+- Already wired in Phase A with `missing_info_msg`; Phase B extends to more specific per-line chips in the line tree (e.g. amber dot in the row).
+
+### B6: Move Rush from header to line
+
+- Already a line field in Phase A. Phase B drops it from the header if it ever lived there.
+
+---
+
+## Phase D — SO Detail View Tasks (outline)
+
+- **D1 Live countdown**: computed Char on `sale.order` that formats `commitment_date - now` as "in 2d 12h" / "overdue by 3h". Use in widget or label.
+- **D2 BOM Items**: already partially there via order_line; add a grouped tree by part showing per-part completion % from linked MOs.
+- **D3 Active WOs**: `stat_button` or inline O2M of `mrp.workorder` filtered by `mrp.production.x_fc_so_line_id → order_id = this`.
+- **D4 Notes split**: `x_fc_internal_note` and `x_fc_external_note` on `sale.order` (Odoo's `note` goes to one of them; migrate default to `internal`).
+- **D5 Archive line**: `active=False` on `sale.order.line` — Odoo supports it natively; add an "Archive" button and a search filter on the SO line view.
+- **D6 Add Quoted Lines**: mini-wizard that shows the customer's prior quotes with sticky line selection.
+- **D7 SO Acknowledgement PDF**: `fusion_plating_reports` new report + template + "Generate Acknowledgement" smart button that emails customer.
+- **D8 Margin**: compute = total − cost rollup from coating configs.
+- **D9 Quick-nav**: inline view stub that shows chips linking to filtered lists (Receiving, NCRs, Invoices etc.) filtered by this SO.
+- **D10 SO/WO toggle**: view-mode switch in Odoo is already there; add a custom kanban of lines grouped by WO as an alternative view.
+- **D11 Assemblies**: new model `fp.sale.order.assembly` with children `fp.sale.order.assembly.line`; each assembly has a parent `sale.order.line`. Big scope — only if the client ships kits.
+- **D12 Contact + phone on SO**: Odoo has `partner_id.phone`; surface via related field `x_fc_contact_phone`.
+- **D13 Ship Via**: `x_fc_ship_via` Char or Many2one to `delivery.carrier`.
+- **D14 Uploaded Files**: already there via attachments; surface a filtered file widget in the SO form.
+
+## Phase C — Task Outline (polish)
+
+### C1: "To Node" start-point picker
+
+- Already a line field (`start_at_node_id`).
+- `fusion_plating_bridge_mrp` WO generator skips ancestor nodes when set.
+- UI: Many2one domain filtered by `coating_config_id.recipe_id`.
+
+### C2: Split part description vs. WO-detail description
+
+- `x_fc_part_wo_description` on sale.order.line.
+- Wizard line form exposes two textareas.
+- Report template includes both on the travelling sheet.
+
+### C3: Quote/Price picker
+
+- `quote_line_id` on line (Phase A declared, Phase C wires it).
+- Onchange copies unit price from picked quote.
+- `fp.quote.configurator` gains an `x_fc_from_so_count` computed field so popular quotes surface first.
+
+### C4: Update Defaults toggle
+
+- `push_to_defaults` on line (Phase A declared).
+- On `action_create_order` success, iterates lines and writes coating/treatments/desc onto `fp.part.catalog`.
+
+### C5: One-Off Part toggle
+
+- `is_one_off` on line (Phase A declared).
+- If true and `part_catalog_id` is set, we still reference the part but skip any revision bump and flag the part with `x_fc_one_off_use_count += 1`.
+
+### C6: Keyboard shortcut Cmd/Ctrl-I
+
+- OWL patch on `ListRenderer` adds the hotkey.
+- Low priority.
+
+---
+
+## Risks & Mitigations
+
+| Risk | Likelihood | Mitigation |
+|------|------------|------------|
+| Existing SOs break when we add new x_fc_* on sale.order.line | Medium | All new fields are optional (no `required=True`). |
+| Existing single-line wizard users mid-session when module upgrades | Low | Wizards are transient; no persistence. |
+| Treatment M2M triggers too many onchanges | Medium | `treatment_ids` is pure data; no onchange-heavy logic. Price calc stays on `coating_config_id`. |
+| bridge_mrp WO generator changes in B3 break existing single-line MOs | High | B3 touches MO-creation path; must add a regression test that confirms single-line still produces 1 MO / 1 WO set. |
+| Template picker domain is stringly-typed — easy to break on refactor | Low | Covered in Phase A — same pattern as existing wizard. |
+
+---
+
+## Verification — how "done" looks
+
+After Phase A:
+- An estimator can open New Direct Order.
+- Pick customer, PO#, customer job#, bill/ship addresses, three deadlines.
+- Add 5 lines with different parts, coatings, qty, price, deadline.
+- See running order total + per-line subtotal.
+- Hit Create & Confirm → lands on a `sale.order` in state `sale` with 5 matching lines.
+- The SO form shows the new x_fc_* fields and each line shows its coating / treatments / deadline.
+
+After Phase B:
+- The same flow plus: toggle Blanket + Block Partial; tag lines `WO#1` / `WO#2`; click "Add from Prior SO" and pick 3 lines from last month's PO.
+
+After Phase C:
+- Line detail form lets you resume from a specific recipe node, split part vs. WO description, link to the original quote, and push values back to the part catalog.
+
+---
+
+## Next Step
+
+Confirm Phase A scope, then begin with Task A1. Screenshots and clarifications land in the "Open Questions" section and get absorbed into the relevant task before it's executed.
diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index 187b4a60..b37c9437 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -96,6 +96,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/res_config_settings_views.xml',
'views/fp_menu.xml',
'data/fp_recipe_enp_alum_basic.xml',
+ 'data/fp_recipe_enp_steel_basic.xml',
+ 'data/fp_recipe_enp_sp.xml',
+ 'data/fp_recipe_general_processing.xml',
+ 'data/fp_recipe_anodize.xml',
+ 'data/fp_recipe_chem_conversion.xml',
],
'post_init_hook': 'post_init_hook',
'assets': {
diff --git a/fusion_plating/fusion_plating/data/fp_recipe_anodize.xml b/fusion_plating/fusion_plating/data/fp_recipe_anodize.xml
new file mode 100644
index 00000000..20c38da9
--- /dev/null
+++ b/fusion_plating/fusion_plating/data/fp_recipe_anodize.xml
@@ -0,0 +1,386 @@
+
+
+
+
+
+
+
+ Anodize
+ ANODIZE
+ recipe
+ fa-flask
+ 50
+ True
+ False
+
+
+
+
+ Ready for Solvent Clean (1)
+ sub_process
+
+ fa-shower
+ 10
+ True
+
+
+ Solvent Clean (1)
+ operation
+
+ fa-shower
+ 10
+ True
+
+
+
+
+ Blasting
+ sub_process
+
+ fa-bullseye
+ 20
+ True
+ True
+
+
+ Ready For Blast
+ operation
+
+ fa-clock-o
+ 10
+
+
+ Blast
+ operation
+
+ fa-bullseye
+ 20
+ True
+
+
+
+
+ Masking
+ sub_process
+
+ fa-paint-brush
+ 30
+ True
+ True
+
+
+ Ready For Masking
+ operation
+
+ fa-clock-o
+ 10
+
+
+ Masking
+ operation
+
+ fa-paint-brush
+ 20
+ True
+
+
+
+
+ Racking
+ sub_process
+
+ fa-th
+ 40
+ True
+
+
+ Ready for Racking
+ operation
+
+ fa-clock-o
+ 10
+
+
+ Racking
+ operation
+
+ fa-th
+ 20
+ True
+
+
+
+
+ Anodize Line
+ sub_process
+
+ fa-industry
+ 50
+ True
+
+
+
+
+ Ready For Anodize
+ operation
+
+ fa-clock-o
+ 10
+
+
+
+
+ Alkaline Clean (Tank A-1)
+ operation
+
+ fa-shower
+ 20
+ True
+
+
+ Primary Rinse (Tank A-2)
+ step
+
+ fa-tint
+ 10
+
+
+ Secondary Rinse (Tank A-4)
+ step
+
+ fa-tint
+ 20
+
+
+
+
+ Etch (Tank A-3)
+ operation
+
+ fa-flask
+ 30
+ True
+
+
+ Primary Rinse (Tank A-4)
+ step
+
+ fa-tint
+ 10
+
+
+ Secondary Rinse (Tank A-6)
+ step
+
+ fa-tint
+ 20
+
+
+
+
+ Deoxidize (Tank A-5)
+ operation
+
+ fa-flask
+ 40
+ True
+
+
+ Primary Rinse (Tank A-6)
+ step
+
+ fa-tint
+ 10
+
+
+ Secondary Rinse (Tank A-8)
+ step
+
+ fa-tint
+ 20
+
+
+
+
+ Sulfuric Anodize
+ operation
+
+ fa-bolt
+ 50
+ True
+
+
+ Sulfuric Anodize Ramp (Tank A-9)
+ step
+
+ fa-bolt
+ 10
+
+
+ Sulfuric Anodize (Tank A-9)
+ operation
+
+ fa-bolt
+ 20
+ True
+ True
+
+
+ Primary Rinse (Tank A-8)
+ step
+
+ fa-tint
+ 10
+
+
+ Secondary Rinse (Tank A-12)
+ step
+
+ fa-tint
+ 20
+
+
+ Hot Rinse (Tank A-17)
+ step
+
+ fa-thermometer-half
+ 30
+
+
+
+
+ Hot Water Seal (Tank A-16)
+ operation
+
+ fa-tint
+ 60
+ True
+
+
+ Primary Rinse (Tank A-12)
+ step
+
+ fa-tint
+ 10
+
+
+ Hot Rinse (Tank A-17)
+ step
+
+ fa-thermometer-half
+ 20
+ True
+
+
+
+
+ Anodize Dry
+ sub_process
+
+ fa-sun-o
+ 60
+
+
+
+
+ Unracking
+ sub_process
+
+ fa-th
+ 70
+ True
+
+
+ Ready for Unrack
+ operation
+
+ fa-clock-o
+ 10
+
+
+ Unracking
+ operation
+
+ fa-th
+ 20
+ True
+
+
+
+
+ De-Masking
+ sub_process
+
+ fa-eraser
+ 80
+ True
+ True
+
+
+ Ready for De-Masking
+ operation
+
+ fa-clock-o
+ 10
+
+
+ De-Masking
+ operation
+
+ fa-eraser
+ 20
+ True
+
+
+
+
diff --git a/fusion_plating/fusion_plating/data/fp_recipe_chem_conversion.xml b/fusion_plating/fusion_plating/data/fp_recipe_chem_conversion.xml
new file mode 100644
index 00000000..1bd59309
--- /dev/null
+++ b/fusion_plating/fusion_plating/data/fp_recipe_chem_conversion.xml
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+ Chemical Conversion Process
+ CHEM_CONVERSION
+ recipe
+ fa-flask
+ 60
+ True
+ False
+
+
+
+
+ Racking
+ sub_process
+
+ fa-th
+ 10
+ True
+
+
+ Ready for Racking
+ operation
+
+ fa-clock-o
+ 10
+
+
+ Racking
+ operation
+
+ fa-th
+ 20
+ True
+
+
+
+
+ Chemical Conversion
+ sub_process
+
+ fa-industry
+ 20
+ True
+
+
+ Ready For Chemical Conversion
+ operation
+
+ fa-clock-o
+ 10
+
+
+ Drying
+ operation
+
+ fa-sun-o
+ 20
+
+
+
+
+ Soak Clean (A-1)
+ operation
+
+ fa-shower
+ 30
+ True
+
+
+ Rinse (A-2)
+ step
+
+ fa-tint
+ 10
+
+
+
+
+ Etch (A-3)
+ operation
+
+ fa-flask
+ 40
+ True
+
+
+ Rinse (A-2)
+ step
+
+ fa-tint
+ 10
+
+
+ Rinse (A-4)
+ step
+
+ fa-tint
+ 20
+
+
+
+
+ Desmutter (A-5)
+ operation
+
+ fa-flask
+ 50
+ True
+
+
+ Rinse (A-4)
+ step
+
+ fa-tint
+ 10
+
+
+ Rinse (A-6)
+ step
+
+ fa-tint
+ 20
+
+
+
+
+ Trivalent Chromate Conversion (A-14 / A)
+ operation
+
+ fa-diamond
+ 60
+ True
+ True
+
+
+ Rinse (A-15)
+ step
+
+ fa-tint
+ 10
+
+
+
+
+ Plug The Threaded Holes
+ sub_process
+
+ fa-shield
+ 30
+ True
+
+
+
+
+ Nickel Strip - Aluminum Line
+ sub_process
+
+ fa-eraser
+ 40
+ True
+ True
+
+
+
+
+ Strip Process - AL
+ operation
+
+ fa-flask
+ 10
+ True
+
+
+ Nitric Acid (A-13 / SP-10)
+ step
+
+ fa-flask
+ 10
+
+
+ Final Rinse (A-14 / SP-11)
+ step
+
+ fa-tint
+ 20
+
+
+
+
+ Drying
+ operation
+
+ fa-sun-o
+ 20
+
+
+
+
+ Post Stripping Inspection
+ operation
+
+ fa-search
+ 30
+ True
+
+
+ Ready for Post Stripping Inspection
+ step
+
+ fa-clock-o
+ 10
+
+
+ Post Stripping Inspection
+ step
+
+ fa-eye
+ 20
+ True
+
+
+
+
diff --git a/fusion_plating/fusion_plating/data/fp_recipe_enp_sp.xml b/fusion_plating/fusion_plating/data/fp_recipe_enp_sp.xml
new file mode 100644
index 00000000..0369a466
--- /dev/null
+++ b/fusion_plating/fusion_plating/data/fp_recipe_enp_sp.xml
@@ -0,0 +1,577 @@
+
+
+
+
+
+
+
+ ENP-SP
+ ENP_SP_BASIC
+ recipe
+ fa-flask
+ 30
+ True
+ False
+
+
+
+
+ Pre-plate Bake (Stress Relief)
+ operation
+
+ fa-fire
+ 10
+ True
+ True
+ False
+
+
+ Ready for bake
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Bake
+ step
+
+ fa-fire
+ 20
+ True
+
+
+
+
+ Adhesion Test Coupon
+ operation
+
+ fa-cube
+ 20
+ opt_out
+ False
+
+
+
+
+ Blasting
+ operation
+
+ fa-bullseye
+ 30
+ True
+ True
+ True
+
+
+ Ready For Blast
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Blast
+ step
+
+ fa-bullseye
+ 20
+ True
+
+
+
+
+ Masking
+ operation
+
+ fa-paint-brush
+ 40
+ True
+ True
+ True
+
+
+ Ready For Masking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Masking
+ step
+
+ fa-paint-brush
+ 20
+ True
+
+
+
+
+ Racking
+ operation
+
+ fa-th
+ 50
+ True
+ True
+
+
+ Ready for racking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Racking
+ step
+
+ fa-th
+ 20
+ True
+
+
+
+
+ Ready For Plating
+ operation
+
+ fa-clock-o
+ 60
+ False
+
+
+
+
+ Electroless Nickel Plating
+ sub_process
+
+ fa-industry
+ 70
+ True
+
+
+
+
+ Soak Clean (SP-1)
+ operation
+
+ fa-shower
+ 10
+ True
+
+
+ ElectroClean (SP-1)
+ step
+
+ fa-bolt
+ 10
+ False
+
+
+ Rinse (SP-2)
+ step
+
+ fa-tint
+ 20
+ True
+
+
+
+
+ HCl Activation (SP-3)
+ operation
+
+ fa-flask
+ 20
+ True
+
+
+ Rinse (SP-6)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+
+
+ Woods Nickel Strike (SP-5)
+ operation
+
+ fa-bolt
+ 30
+ True
+ True
+
+
+ Rinse (SP-6)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+
+
+ E-Nickel Plate (Mid-Phos) (SP-7)
+ operation
+
+ fa-diamond
+ 40
+ True
+ True
+ False
+
+
+ Rinse (SP-11)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+
+
+ E-Nickel Plate (Hi-Phos) (SP-8)
+ operation
+
+ fa-diamond
+ 50
+ opt_out
+ True
+ False
+
+
+ Rinse (SP-11)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+
+
+ Drying
+ operation
+
+ fa-sun-o
+ 60
+ True
+
+
+
+
+ Post-plate Bake (H2 Embrittlement Relief)
+ operation
+
+ fa-fire
+ 80
+ True
+ True
+ False
+
+
+ Ready for bake
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Bake
+ step
+
+ fa-fire
+ 20
+ True
+
+
+
+
+ De-racking
+ operation
+
+ fa-th
+ 90
+ True
+ True
+
+
+ Ready For DeRacking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ DeRacking
+ step
+
+ fa-hand-paper-o
+ 20
+ True
+
+
+
+
+ De-Masking
+ operation
+
+ fa-eraser
+ 100
+ True
+ True
+ True
+
+
+ Ready for De-Masking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ De-Masking
+ step
+
+ fa-eraser
+ 20
+ False
+
+
+
+
+ Oven bake (Post de-rack)
+ operation
+
+ fa-fire
+ 110
+ True
+ opt_out
+ False
+
+
+ Ready for bake
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Bake
+ step
+
+ fa-fire
+ 20
+ True
+
+
+
+
+ Adhesion Testing
+ operation
+
+ fa-check-circle
+ 120
+ opt_out
+ True
+
+
+
+
+ Post Plate Inspection
+ operation
+
+ fa-search
+ 130
+ True
+
+
+ Ready For Post Plate Inspection
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Post Plate Inspection
+ step
+
+ fa-search
+ 20
+ True
+
+
+
+
+ Salt Spray Masking
+ operation
+
+ fa-paint-brush
+ 140
+ True
+ True
+ True
+
+
+ Ready for Salt Spray Masking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Salt Spray Masking
+ step
+
+ fa-paint-brush
+ 20
+ False
+
+
+
+
+ Corrosion Testing
+ operation
+
+ fa-search
+ 150
+ True
+ True
+
+
+ Corrosion Testing
+ step
+
+ fa-flask
+ 10
+ True
+
+
+ Corrosion Test Inspection
+ step
+
+ fa-search
+ 20
+ True
+
+
+
+
+ Lab Testing
+ operation
+
+ fa-flask
+ 160
+ True
+ True
+
+
+ Lab Testing
+ step
+
+ fa-flask
+ 10
+ True
+
+
+ Lab Testing Results
+ step
+
+ fa-search
+ 20
+ True
+
+
+
+
diff --git a/fusion_plating/fusion_plating/data/fp_recipe_enp_steel_basic.xml b/fusion_plating/fusion_plating/data/fp_recipe_enp_steel_basic.xml
new file mode 100644
index 00000000..ff3e75b5
--- /dev/null
+++ b/fusion_plating/fusion_plating/data/fp_recipe_enp_steel_basic.xml
@@ -0,0 +1,469 @@
+
+
+
+
+
+
+
+ ENP-STEEL-BASIC
+ ENP_STEEL_BASIC
+ recipe
+ fa-flask
+ 20
+ True
+ False
+
+
+
+
+ Blasting
+ operation
+
+ fa-bullseye
+ 10
+ True
+ True
+ True
+
+
+ Ready For Blast
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Blast
+ step
+
+ fa-bullseye
+ 20
+ True
+ True
+
+
+
+
+ Masking
+ operation
+
+ fa-paint-brush
+ 20
+ True
+ True
+ True
+
+
+ Ready For Masking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Masking
+ step
+
+ fa-paint-brush
+ 20
+ True
+
+
+
+
+ Racking
+ operation
+
+ fa-th
+ 30
+ True
+ True
+
+
+ Ready for Racking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Racking
+ step
+
+ fa-th
+ 20
+ True
+
+
+
+
+ Ready For Steel Line
+ operation
+
+ fa-clock-o
+ 40
+ False
+
+
+
+
+ Steel Line
+ sub_process
+
+ fa-industry
+ 50
+ True
+
+
+
+
+ Cleaner
+ operation
+
+ fa-shower
+ 10
+ True
+
+
+ Soak Clean (S-3)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+ Electroclean (S-3)
+ step
+
+ fa-bolt
+ 20
+ False
+
+
+ Primary Rinse (S-4)
+ step
+
+ fa-tint
+ 30
+ True
+
+
+
+
+ Acid Dip (S-5)
+ operation
+
+ fa-flask
+ 20
+ True
+
+
+ Primary Rinse (S-6)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+ Secondary Rinse (S-8)
+ step
+
+ fa-tint
+ 20
+ True
+
+
+
+
+ Nickel Strike (S-7 / SP-5)
+ operation
+
+ fa-bolt
+ 30
+ True
+ True
+
+
+ Rinse (S-8 / SP-6)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+
+
+ E-Nickel Plate (Mid Phos)(S-9)
+ operation
+
+ fa-diamond
+ 40
+ opt_out
+ True
+ False
+
+
+ Primary Rinse (S-11)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+ Hot Rinse (S-13)
+ step
+
+ fa-thermometer-half
+ 20
+ True
+
+
+
+
+ E-Nickel Plate (S-10)
+ operation
+
+ fa-diamond
+ 50
+ True
+ True
+ False
+
+
+ Primary Rinse (S-11)
+ step
+
+ fa-tint
+ 10
+ True
+
+
+ Hot Rinse (S-13)
+ step
+
+ fa-thermometer-half
+ 20
+ True
+
+
+
+
+ Hot Water Porosity (A-15)
+ operation
+
+ fa-tint
+ 60
+ True
+ True
+
+
+
+
+ Dry
+ operation
+
+ fa-sun-o
+ 70
+ True
+
+
+
+
+ Post-plate Bake (H2 Embrittlement Relief)
+ operation
+
+ fa-fire
+ 60
+ True
+ True
+ False
+
+
+ Ready for bake
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Bake
+ step
+
+ fa-fire
+ 20
+ True
+
+
+
+
+ De-racking
+ operation
+
+ fa-th
+ 70
+ True
+ True
+
+
+ Ready For DeRacking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ DeRacking
+ step
+
+ fa-hand-paper-o
+ 20
+ True
+
+
+
+
+ De-Masking
+ operation
+
+ fa-eraser
+ 80
+ True
+ True
+ True
+
+
+ Ready for De-Masking
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ De-Masking
+ step
+
+ fa-eraser
+ 20
+ False
+
+
+
+
+ Oven bake (Post de-rack)
+ operation
+
+ fa-fire
+ 90
+ True
+ opt_out
+ False
+
+
+ Ready for bake
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Bake
+ step
+
+ fa-fire
+ 20
+ True
+
+
+
+
+ Post Plate Inspection
+ operation
+
+ fa-search
+ 100
+ True
+
+
+ Ready For Post Plate Inspection
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Post Plate Inspection
+ step
+
+ fa-search
+ 20
+ True
+
+
+
+
diff --git a/fusion_plating/fusion_plating/data/fp_recipe_general_processing.xml b/fusion_plating/fusion_plating/data/fp_recipe_general_processing.xml
new file mode 100644
index 00000000..7784c3a6
--- /dev/null
+++ b/fusion_plating/fusion_plating/data/fp_recipe_general_processing.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+ General Processing
+ GENERAL_PROCESSING
+ recipe
+ fa-sitemap
+ 40
+ True
+ False
+
+
+
+
+
+ Contract Review
+ operation
+
+ fa-check-circle
+ 10
+ opt_out
+ True
+ True
+
+
+
+
+ Incoming Inspection
+ operation
+
+ fa-search
+ 20
+ True
+ True
+
+
+ Ready for Incoming Inspection
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Incoming Inspection
+ step
+
+ fa-eye
+ 20
+ True
+
+
+
+
+ Scheduling
+ operation
+
+ fa-clock-o
+ 30
+ False
+ True
+
+
+
+
+ Final Inspection / Packaging
+ operation
+
+ fa-cube
+ 40
+ True
+ True
+
+
+ Ready For Final Inspection / Packaging
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Final Inspection / Packaging
+ step
+
+ fa-cube
+ 20
+ True
+
+
+
+
+
+ Shipping
+ operation
+
+ fa-cube
+ 50
+ True
+ True
+
+
+ Ready For Shipping
+ step
+
+ fa-clock-o
+ 10
+ False
+
+
+ Packing Slip Created
+ step
+
+ fa-cube
+ 20
+ False
+
+
+ Shipped
+ step
+
+ fa-cube
+ 30
+ True
+
+
+
+
diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py
index f2799322..e5be8016 100644
--- a/fusion_plating/fusion_plating/models/fp_process_node.py
+++ b/fusion_plating/fusion_plating/models/fp_process_node.py
@@ -199,6 +199,41 @@ class FpProcessNode(models.Model):
tracking=True,
)
+ # ---- Recipe-only fields (apply when node_type='recipe') -----------------
+ # These migrate Steelhead's recipe-level metadata: lead time, the
+ # product/service tied to this recipe, the contract review approver
+ # roster, and the pricing builders to apply when this recipe is on
+ # a quote. They're loose-coupled to keep non-recipe nodes clean.
+
+ default_lead_time = fields.Float(
+ string='Default Lead Time (days)',
+ digits=(8, 2),
+ help='When an MO is created using this recipe, '
+ 'date_planned_finished is set to NOW + lead_time.',
+ tracking=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Service / Product',
+ ondelete='set null',
+ help='The plating service product this recipe sells. When the '
+ 'product appears on a sale order, the resulting MO can '
+ 'auto-pick this recipe.',
+ tracking=True,
+ )
+ contract_review_user_ids = fields.Many2many(
+ 'res.users',
+ relation='fp_process_node_contract_review_user_rel',
+ column1='node_id',
+ column2='user_id',
+ string='Contract Review Approvers',
+ help='Users authorised to sign off the Contract Review work order '
+ 'on jobs running this recipe. Anyone outside this list will '
+ 'be blocked from finishing the WO.',
+ )
+ # NB. `pricing_rule_ids` lives in fusion_plating_configurator
+ # (added there so this core module doesn't depend on the configurator).
+
# ---- Computed fields -----------------------------------------------------
display_name = fields.Char(
@@ -270,6 +305,73 @@ class FpProcessNode(models.Model):
raise ValidationError(
_('A process node cannot be its own ancestor.'))
+ # ---- Version auto-bump ---------------------------------------------------
+ # Any meaningful edit / add / delete inside a recipe bumps the recipe
+ # root's `version` field by one. Lets shop managers see at a glance
+ # how stable a recipe is and (later) lets a job pin to a specific
+ # recipe revision so already-running MOs don't see mid-flight changes.
+
+ # Fields that don't represent a "meaningful" change — adjusting these
+ # alone does not bump the version. `version` itself is in the list to
+ # avoid an infinite write loop.
+ _FP_NON_VERSIONED_FIELDS = {
+ 'version', 'write_date', 'write_uid',
+ 'create_date', 'create_uid',
+ 'parent_path', 'display_name', 'recipe_root_id', 'depth',
+ }
+
+ def _fp_bump_recipe_versions(self):
+ """Increment `version` by 1 on the distinct recipe roots covering
+ the current recordset."""
+ roots = self.mapped('recipe_root_id')
+ # _compute_recipe_root_id falls back to self for nodes whose
+ # parent_path isn't yet stored — pick those up too.
+ for rec in self:
+ if not rec.recipe_root_id and rec.node_type == 'recipe':
+ roots |= rec
+ if not roots:
+ return
+ # Use a direct SQL update so we (a) skip our own write override
+ # and (b) avoid touching write_date / write_uid on the root,
+ # which would itself be a no-op-but-noisy chatter event.
+ self.env.cr.execute(
+ 'UPDATE fusion_plating_process_node '
+ 'SET version = COALESCE(version, 0) + 1 '
+ 'WHERE id IN %s',
+ (tuple(roots.ids),),
+ )
+ roots.invalidate_recordset(['version'])
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ records = super().create(vals_list)
+ # Skip non-recipe roots — only count when the new node lives
+ # inside an existing recipe.
+ descendants = records.filtered(lambda r: r.node_type != 'recipe')
+ if descendants:
+ descendants._fp_bump_recipe_versions()
+ return records
+
+ def write(self, vals):
+ meaningful = bool(set(vals.keys()) - self._FP_NON_VERSIONED_FIELDS)
+ res = super().write(vals)
+ if meaningful and self:
+ self._fp_bump_recipe_versions()
+ return res
+
+ def unlink(self):
+ # Snapshot the affected recipe roots BEFORE delete, otherwise
+ # recipe_root_id becomes unreachable on the deleted records.
+ roots = self.mapped('recipe_root_id')
+ descendants = self.filtered(lambda r: r.node_type != 'recipe')
+ # Delete first so we don't bump the version of a recipe that's
+ # being removed entirely.
+ res = super().unlink()
+ survivors = roots.exists()
+ if descendants and survivors:
+ survivors._fp_bump_recipe_versions()
+ return res
+
# ---- Tree data for OWL component -----------------------------------------
def get_tree_data(self):
diff --git a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js
index a1ff0024..9574bd10 100644
--- a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js
+++ b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js
@@ -136,9 +136,16 @@ export class RecipeTreeEditor extends Component {
if (result && result.ok) {
this.state.recipe = result.recipe;
this.state.tree = result.tree;
- // Auto-expand root node
- if (result.tree) {
- this.state.expandedNodes[result.tree.id] = true;
+ // Auto-expand every node on first load so the full
+ // hierarchy is visible. The horizontal bracket layout
+ // works best when everything is open by default;
+ // operators can still collapse individual branches.
+ if (result.tree && Object.keys(this.state.expandedNodes).length === 0) {
+ const expandAll = (n) => {
+ this.state.expandedNodes[n.id] = true;
+ for (const c of (n.children || [])) expandAll(c);
+ };
+ expandAll(result.tree);
}
// Refresh selected node data if panel is open
if (this.state.selectedNodeId) {
diff --git a/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss b/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss
index 1c386735..bbd7b30c 100644
--- a/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss
+++ b/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss
@@ -1,433 +1,623 @@
// =============================================================================
-// Fusion Plating — Recipe Tree Editor
-// Copyright 2026 Nexa Systems Inc.
-// License OPL-1 (Odoo Proprietary License v1.0)
+// Fusion Plating — Recipe Tree Editor (horizontal bracket-tree, v2, 2026-04)
+// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
-// THEME AWARENESS
-// ---------------
-// All colours from CSS custom properties + SCSS $border-color.
-// Works in both light and dark mode.
+// Same Steelhead-style bracket layout as the read-only Process Tree, but
+// with editable cards: hover reveals Add / Delete buttons, click opens
+// the side panel for full editing, drag-drop reorders.
+//
+// Local tokens (this file lives in fusion_plating, not fusion_plating_shopfloor,
+// so we can't share the shopfloor tokens; defining the few we need inline).
// =============================================================================
-// ---- Root container ---------------------------------------------------------
+// ---- Local tokens -----------------------------------------------------------
+// Branch on dark mode at compile time (Odoo 19 compiles two bundles).
+$o-webclient-color-scheme: bright !default;
-.o_fp_recipe_editor {
+$_re-page-hex : #f3f4f6;
+$_re-card-hex : #ffffff;
+$_re-soft-hex : #f1f3f5;
+$_re-border-hex : #d8dadd;
+$_re-ink-hex : #1f2937;
+$_re-ink-mute : #6b7280;
+@if $o-webclient-color-scheme == dark {
+ $_re-page-hex : #1a1d21 !global;
+ $_re-card-hex : #22262d !global;
+ $_re-soft-hex : #1c2027 !global;
+ $_re-border-hex : #343942 !global;
+ $_re-ink-hex : #e5e7eb !global;
+ $_re-ink-mute : #8a909a !global;
+}
+$re-page : var(--fp-page-bg, $_re-page-hex);
+$re-card : var(--fp-card-bg, $_re-card-hex);
+$re-soft : var(--fp-card-soft-bg, $_re-soft-hex);
+$re-border : var(--fp-border-color, $_re-border-hex);
+$re-ink : var(--fp-ink, $_re-ink-hex);
+$re-mute : var(--fp-ink-mute, $_re-ink-mute);
+$re-accent : var(--o-action, #714B67);
+
+// Tree connector geometry
+$re-card-h : 56px;
+$re-row-gap : 14px;
+$re-indent : 36px;
+$re-stub : 28px;
+$re-line : #6b7280;
+$re-line-w : 2px;
+
+
+@media (hover: none) {
+ .o_fp_recipe_editor [class*="o_fp_re_"]:hover {
+ transform: none !important;
+ }
+}
+
+
+// =============================================================================
+// Editor shell
+// =============================================================================
+.o_fp_recipe_editor.o_fp_re_v2 {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
- background: var(--o-view-background-color, var(--bs-body-bg));
-}
+ background-color: $re-page;
+ color: $re-ink;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ "Inter", "Helvetica Neue", Arial, sans-serif;
-// ---- Header -----------------------------------------------------------------
-.o_fp_recipe_header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 12px;
- padding: 12px 20px;
- background: var(--bs-body-bg);
- border-bottom: 1px solid $border-color;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
-
- .o_fp_recipe_header_left {
+ // -------------------------------------------------------------------------
+ // Header
+ // -------------------------------------------------------------------------
+ .o_fp_re_header {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 16px;
+ padding: 12px 20px;
+ background-color: $re-card;
+ border-bottom: 1px solid #{$re-border};
+ flex-wrap: wrap;
}
-
- .o_fp_recipe_back_btn {
- text-decoration: none;
+ .o_fp_re_back {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background-color: $re-soft;
+ color: $re-ink;
font-weight: 500;
- }
+ font-size: 0.875rem;
+ border: 1px solid #{$re-border};
+ cursor: pointer;
+ transition: background-color 0.2s, border-color 0.2s;
- .o_fp_recipe_title {
- margin: 0;
- font-size: 1.2rem;
+ &:hover {
+ background-color: color-mix(in srgb, #{$re-accent} 8%, #{$re-card});
+ border-color: color-mix(in srgb, #{$re-accent} 45%, #{$re-border});
+ }
+ }
+ .o_fp_re_header_title { flex: 1 1 auto; min-width: 0; }
+ .o_fp_re_h2 {
+ font-size: 1.25rem;
font-weight: 700;
- color: var(--bs-body-color);
+ color: $re-ink;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ letter-spacing: -0.01em;
}
-
- .o_fp_recipe_version_badge {
- background: var(--bs-secondary-color);
- color: #fff;
+ .o_fp_re_ver {
+ display: inline-block;
+ background-color: $re-soft;
+ color: $re-mute;
font-size: 0.7rem;
- vertical-align: middle;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 999px;
+ margin-left: 6px;
+ border: 1px solid #{$re-border};
+ }
+ .o_fp_re_subtitle {
+ margin-top: 2px;
+ font-size: 0.8rem;
+ color: $re-mute;
+ }
+ .o_fp_re_header_actions {
+ display: flex;
+ gap: 6px;
+ }
+ .o_fp_re_btn_outline {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 12px;
+ border-radius: 6px;
+ background-color: transparent;
+ color: $re-ink;
+ border: 1px solid #{$re-border};
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s, border-color 0.2s;
+
+ &:hover {
+ background-color: $re-soft;
+ border-color: color-mix(in srgb, #{$re-accent} 45%, #{$re-border});
+ }
}
- .o_fp_recipe_header_right {
+
+ // -------------------------------------------------------------------------
+ // Body (canvas + side panel)
+ // -------------------------------------------------------------------------
+ .o_fp_re_body {
display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+ }
+ .o_fp_re_canvas {
+ flex: 1 1 auto;
+ overflow: auto;
+ padding: 24px 40px;
+ }
+ .o_fp_re_empty {
+ text-align: center;
+ padding: 48px 24px;
+ color: $re-mute;
+ font-size: 0.875rem;
+ > .fa { font-size: 2rem; margin-bottom: 12px; opacity: 0.6; }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Recursive node row (card + children column)
+ // -------------------------------------------------------------------------
+ .o_fp_re_node {
+ display: flex;
+ align-items: flex-start;
+ position: relative;
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Card — dark Steelhead-style
+ // -------------------------------------------------------------------------
+ .o_fp_re_card {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 240px;
+ max-width: 380px;
+ min-height: $re-card-h;
+ padding: 8px 12px;
+ background-color: #2b2f36;
+ color: #f1f3f5;
+ border-radius: 10px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06),
+ 0 1px 3px rgba(0, 0, 0, 0.08);
+ font-size: 0.875rem;
+ line-height: 1.25;
+ flex: 0 0 auto;
+ position: relative;
+ z-index: 1;
+ cursor: pointer;
+ transition: transform 0.12s, box-shadow 0.2s, background-color 0.2s;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06),
+ 0 6px 14px rgba(0, 0, 0, 0.12);
+ background-color: #353a42;
+ .o_fp_re_actions { opacity: 1; }
+ .o_fp_re_handle { opacity: 0.6; }
+ }
+
+ // ---- Type tints (subtle differences) -----------------------------
+ &.o_fp_re_type_recipe {
+ background-color: #1f2329;
+ font-weight: 700;
+ }
+ &.o_fp_re_type_sub_process {
+ background-color: #262a31;
+ font-weight: 600;
+ }
+ &.o_fp_re_type_step {
+ background-color: #353a42;
+ }
+
+ &.o_fp_re_selected {
+ box-shadow: 0 0 0 2px #{$re-accent},
+ 0 4px 12px rgba(113, 75, 103, 0.25);
+ }
+
+ // ---- Drag visuals ------------------------------------------------
+ &.o_fp_recipe_drag_ghost {
+ opacity: 0.4;
+ }
+ &.o_fp_recipe_drop_target {
+ box-shadow: 0 0 0 3px #{$re-accent};
+ }
+ }
+
+ .o_fp_re_handle {
+ opacity: 0;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 0.85em;
+ cursor: grab;
+ transition: opacity 0.15s;
+ }
+
+ .o_fp_re_icon {
+ flex: 0 0 auto;
+ width: 18px;
+ text-align: center;
+ opacity: 0.85;
+ font-size: 0.95em;
+ }
+
+ .o_fp_re_card_body {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .o_fp_re_title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+ }
+ .o_fp_re_meta {
+ font-size: 0.72rem;
+ opacity: 0.7;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px 6px;
+ .fa { opacity: 0.85; }
+ }
+
+ .o_fp_re_right {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Capability flags — small icon row inside the card
+ // -------------------------------------------------------------------------
+ .o_fp_re_flags {
+ display: inline-flex;
+ gap: 4px;
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.55);
+ i { opacity: 0.85; }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Type pill
+ // -------------------------------------------------------------------------
+ .o_fp_re_type_pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 8px;
+ border-radius: 999px;
+ font-size: 0.65rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ line-height: 1.4;
+ white-space: nowrap;
+
+ &.o_fp_re_type_pill_recipe { background-color: rgba(113, 75, 103, .35); color: #d9bfd1; }
+ &.o_fp_re_type_pill_sub_process { background-color: rgba(13, 110, 253, .25); color: #6ea8fe; }
+ &.o_fp_re_type_pill_operation { background-color: rgba(25, 135, 84, .28); color: #75d4a4; }
+ &.o_fp_re_type_pill_step { background-color: rgba(255, 255, 255, .12); color: #c8ccd2; }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Hover-action buttons (Add / Delete) inside the card
+ // -------------------------------------------------------------------------
+ .o_fp_re_actions {
+ display: inline-flex;
+ gap: 4px;
+ opacity: 0;
+ transition: opacity 0.15s;
+ }
+ .o_fp_re_btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 6px;
+ background-color: rgba(255, 255, 255, 0.08);
+ color: #f1f3f5;
+ border: none;
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: background-color 0.2s;
+
+ &:hover { background-color: rgba(255, 255, 255, 0.18); }
+
+ &.o_fp_re_btn_add:hover { background-color: rgba(40, 167, 69, 0.35); }
+ &.o_fp_re_btn_del:hover { background-color: rgba(220, 53, 69, 0.35); }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Children column (recursed nodes laid out vertically to the right)
+ //
+ // Connectors are drawn entirely from CSS pseudo-elements so the layout
+ // stays in pure flexbox. The horizontal stub bridging parent → bus
+ // column lives in .o_fp_re_children::before; per-child stubs and the
+ // vertical bus segments live on each child via ::before / ::after.
+ // -------------------------------------------------------------------------
+ .o_fp_re_children {
+ display: flex;
+ flex-direction: column;
+ gap: $re-row-gap;
+ margin-left: $re-indent;
+ position: relative;
+
+ // horizontal connector from parent card right edge → bus column
+ &::before {
+ content: "";
+ position: absolute;
+ left: -#{$re-indent};
+ top: calc(#{$re-card-h} / 2);
+ width: $re-indent;
+ height: $re-line-w;
+ background-color: $re-line;
+ z-index: 0;
+ }
+ }
+
+ .o_fp_re_children > .o_fp_re_node {
+ position: relative;
+ padding-left: $re-stub;
+
+ // horizontal stub — bus column → child card
+ &::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: calc(#{$re-card-h} / 2);
+ width: $re-stub;
+ height: $re-line-w;
+ background-color: $re-line;
+ z-index: 0;
+ }
+ // vertical bus segment (default: full row, top → bottom)
+ &::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: calc(-#{$re-row-gap} / 2);
+ bottom: calc(-#{$re-row-gap} / 2);
+ width: $re-line-w;
+ background-color: $re-line;
+ z-index: 0;
+ }
+ &:first-child::after { top: calc(#{$re-card-h} / 2); }
+ &:last-child::after { bottom: calc(100% - (#{$re-card-h} / 2)); }
+ &:first-child:last-child::after {
+ top: calc(#{$re-card-h} / 2);
+ bottom: calc(100% - (#{$re-card-h} / 2));
+ }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Inline add-form card (a "ghost" card with input + dropdown + buttons)
+ // -------------------------------------------------------------------------
+ .o_fp_re_add_form .o_fp_re_card_add {
+ background-color: rgba(40, 167, 69, 0.18);
+ border: 1px dashed rgba(40, 167, 69, 0.5);
+ cursor: default;
+ }
+ .o_fp_re_add_input {
+ width: 100%;
+ padding: 4px 8px;
+ background-color: rgba(0, 0, 0, 0.25);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ font-size: 0.85rem;
+
+ &::placeholder { color: rgba(255, 255, 255, 0.45); }
+ &:focus {
+ outline: none;
+ border-color: rgba(40, 167, 69, 0.7);
+ box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.2);
+ }
+ }
+ .o_fp_re_add_row {
+ display: flex;
+ gap: 6px;
+ margin-top: 6px;
align-items: center;
}
-}
+ .o_fp_re_add_select {
+ flex: 1 1 auto;
+ padding: 4px 8px;
+ background-color: rgba(0, 0, 0, 0.25);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ font-size: 0.85rem;
-// ---- Body (tree + panel) layout ---------------------------------------------
+ option { background-color: #2b2f36; color: #fff; }
+ }
+ .o_fp_re_btn_confirm { background-color: rgba(40, 167, 69, 0.45); }
+ .o_fp_re_btn_confirm:hover { background-color: rgba(40, 167, 69, 0.7); }
+ .o_fp_re_btn_cancel { background-color: rgba(255, 255, 255, 0.12); }
-.o_fp_recipe_body {
- display: flex;
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-.o_fp_recipe_tree_area {
- flex: 1;
- overflow-y: auto;
- padding: 24px 24px 24px 40px;
-}
+ // -------------------------------------------------------------------------
+ // Collapsed-children chip (shows when children are hidden)
+ // -------------------------------------------------------------------------
+ .o_fp_re_collapsed_chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 10px;
+ margin-left: $re-indent;
+ margin-top: 6px;
+ border-radius: 999px;
+ background-color: $re-soft;
+ color: $re-mute;
+ font-size: 0.72rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid #{$re-border};
+ position: relative;
-// ---- Side panel -------------------------------------------------------------
+ &::before {
+ content: "";
+ position: absolute;
+ left: -#{$re-indent};
+ top: 50%;
+ width: $re-indent;
+ height: $re-line-w;
+ background-color: $re-line;
+ opacity: 0.5;
+ }
-.o_fp_recipe_panel {
- width: 0;
- overflow: hidden;
- transition: width 0.2s ease;
- border-left: 1px solid $border-color;
- background: var(--bs-body-bg);
-
- &.o_fp_recipe_panel_open {
- width: 340px;
- overflow-y: auto;
+ &:hover {
+ background-color: color-mix(in srgb, #{$re-accent} 8%, #{$re-soft});
+ border-color: color-mix(in srgb, #{$re-accent} 45%, #{$re-border});
+ color: $re-ink;
+ }
}
- .o_fp_recipe_panel_header {
+
+ // -------------------------------------------------------------------------
+ // Side panel
+ // -------------------------------------------------------------------------
+ .o_fp_re_panel {
+ width: 0;
+ overflow: hidden;
+ transition: width 0.2s ease;
+ background-color: $re-card;
+ border-left: 1px solid #{$re-border};
+
+ &.o_fp_re_panel_open {
+ width: 360px;
+ overflow-y: auto;
+ }
+ }
+ .o_fp_re_panel_head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
- border-bottom: 1px solid $border-color;
+ border-bottom: 1px solid #{$re-border};
h5 {
margin: 0;
- font-size: 1rem;
+ font-size: 0.95rem;
font-weight: 600;
- color: var(--bs-body-color);
+ color: $re-ink;
}
}
-
- .o_fp_recipe_panel_body {
+ .o_fp_re_panel_body {
padding: 16px;
}
-}
+ .o_fp_re_field {
+ margin-bottom: 14px;
-// ---- Connector lines --------------------------------------------------------
-
-.o_fp_recipe_connector {
- width: 3px;
- height: 16px;
- background: $border-color;
- margin-left: 22px;
- border-radius: 2px;
-}
-
-// ---- Node card --------------------------------------------------------------
-
-.o_fp_recipe_node {
- position: relative;
- border-width: 1px;
- border-style: solid;
- border-color: $border-color;
- border-radius: 8px;
- padding: 10px 14px;
- max-width: 520px;
- cursor: pointer;
- background: var(--bs-body-bg);
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
- transition: box-shadow 0.15s, border-color 0.15s;
-
- &:hover {
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
- border-color: var(--o-action, var(--bs-primary));
+ > label {
+ display: block;
+ font-weight: 600;
+ font-size: 0.78rem;
+ color: $re-mute;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 4px;
+ }
}
-
- &.o_fp_recipe_node_selected {
- border-color: var(--o-action, var(--bs-primary));
- box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb, 13, 110, 253), 0.2);
+ .o_fp_re_icon_picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
}
-
- // Node type left accent
- &.o_fp_recipe_node_recipe {
- border-left: 5px solid var(--bs-primary);
- }
- &.o_fp_recipe_node_sub_process {
- border-left: 5px solid var(--bs-info);
- }
- &.o_fp_recipe_node_operation {
- border-left: 5px solid var(--bs-success);
- }
- &.o_fp_recipe_node_step {
- border-left: 5px solid var(--bs-secondary);
- }
-
- // Drag states
- &.o_fp_recipe_drag_ghost {
- opacity: 0.35;
- border-style: dashed;
- }
-
- &.o_fp_recipe_drop_target {
- border-color: var(--o-action, var(--bs-primary));
- background: color-mix(in srgb, var(--o-action, var(--bs-primary)) 6%, var(--bs-body-bg));
- }
-}
-
-// ---- Drag handle ------------------------------------------------------------
-
-.o_fp_recipe_drag_handle {
- position: absolute;
- left: -20px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--bs-secondary-color);
- cursor: grab;
- opacity: 0;
- transition: opacity 0.15s;
- font-size: 0.85rem;
-
- .o_fp_recipe_node:hover & {
- opacity: 0.6;
- }
-}
-
-// ---- Node header row --------------------------------------------------------
-
-.o_fp_recipe_node_header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 4px;
-}
-
-.o_fp_recipe_toggle_btn {
- background: none;
- border: none;
- color: var(--bs-secondary-color);
- cursor: pointer;
- width: 20px;
- text-align: center;
- padding: 0;
- font-size: 0.75rem;
-
- &:hover {
- color: var(--bs-body-color);
- }
-}
-
-.o_fp_recipe_toggle_spacer {
- width: 20px;
- flex-shrink: 0;
-}
-
-.o_fp_recipe_node_icon {
- color: var(--bs-secondary-color);
- font-size: 0.9rem;
- width: 18px;
- text-align: center;
- flex-shrink: 0;
-}
-
-.o_fp_recipe_node_name {
- font-weight: 600;
- font-size: 0.9rem;
- color: var(--bs-body-color);
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.o_fp_recipe_node_badge {
- font-size: 0.65rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.3px;
- padding: 2px 8px;
- border-radius: 4px;
- flex-shrink: 0;
-
- &.o_fp_recipe_badge_recipe {
- background: var(--bs-primary);
- color: #fff;
- }
- &.o_fp_recipe_badge_sub {
- background: var(--bs-info);
- color: #fff;
- }
- &.o_fp_recipe_badge_op {
- background: var(--bs-success);
- color: #fff;
- }
- &.o_fp_recipe_badge_step {
- background: var(--bs-secondary);
- color: #fff;
- }
-}
-
-// ---- Node meta row ----------------------------------------------------------
-
-.o_fp_recipe_node_meta {
- display: flex;
- align-items: center;
- gap: 12px;
- font-size: 0.78rem;
- color: var(--bs-secondary-color);
- padding-left: 28px;
- margin-bottom: 2px;
-}
-
-.o_fp_recipe_node_wc,
-.o_fp_recipe_node_duration {
- display: inline-flex;
- align-items: center;
-}
-
-.o_fp_recipe_node_icons {
- display: inline-flex;
- gap: 6px;
- font-size: 0.75rem;
- color: var(--bs-secondary-color);
-
- i {
- opacity: 0.7;
- }
-}
-
-// ---- Node action buttons ----------------------------------------------------
-
-.o_fp_recipe_node_actions {
- display: flex;
- gap: 4px;
- padding-left: 28px;
- margin-top: 4px;
- opacity: 0;
- transition: opacity 0.15s;
-
- .o_fp_recipe_node:hover & {
- opacity: 1;
- }
-
- .o_fp_recipe_add_btn {
- font-size: 0.72rem;
- color: var(--bs-success);
- border: 1px solid var(--bs-success);
- padding: 1px 8px;
- border-radius: 4px;
- background: transparent;
+ .o_fp_re_icon_btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #{$re-border};
+ border-radius: 6px;
+ background-color: transparent;
+ color: $re-mute;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: border-color 0.15s, background-color 0.15s, color 0.15s;
&:hover {
- background: var(--bs-success);
+ border-color: $re-accent;
+ color: $re-ink;
+ }
+ &.active {
+ background-color: $re-accent;
+ border-color: $re-accent;
color: #fff;
}
}
+ .o_fp_re_tracking {
+ border-top: 1px solid #{$re-border};
+ margin-top: 14px;
+ padding-top: 12px;
+ font-size: 0.78rem;
+ color: $re-mute;
+ > div { margin-bottom: 4px; }
+ }
+ .o_fp_re_panel_actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+ }
+ .o_fp_re_btn_save {
+ flex: 1 1 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 14px;
+ background-color: $re-accent;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-weight: 600;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: filter 0.15s;
- .o_fp_recipe_delete_btn {
- font-size: 0.72rem;
- color: var(--bs-danger);
- border: 1px solid transparent;
- padding: 1px 6px;
- border-radius: 4px;
- background: transparent;
+ &:hover { filter: brightness(1.1); }
+ &:disabled { opacity: 0.6; cursor: not-allowed; }
+ }
- &:hover {
- border-color: var(--bs-danger);
+
+ // -------------------------------------------------------------------------
+ // Responsive
+ // -------------------------------------------------------------------------
+ @media (max-width: 900px) {
+ .o_fp_re_canvas { padding: 16px 20px; }
+ .o_fp_re_panel.o_fp_re_panel_open { width: 300px; }
+ .o_fp_re_card { min-width: 200px; max-width: 280px; }
+ }
+ @media (max-width: 600px) {
+ .o_fp_re_panel.o_fp_re_panel_open {
+ position: absolute;
+ right: 0; top: 0; bottom: 0;
+ width: 100%;
+ z-index: 10;
+ box-shadow: -8px 0 20px rgba(0, 0, 0, 0.25);
}
}
}
-
-// ---- Add child form ---------------------------------------------------------
-
-.o_fp_recipe_add_form {
- padding-left: 28px;
-}
-
-.o_fp_recipe_add_card {
- border: 1px dashed var(--bs-success);
- border-radius: 8px;
- padding: 10px 14px;
- max-width: 520px;
- background: color-mix(in srgb, var(--bs-success) 4%, var(--bs-body-bg));
-}
-
-// ---- Children container (indentation) ---------------------------------------
-
-.o_fp_recipe_children {
- margin-left: 32px;
- padding-top: 0;
- position: relative;
-
- // Vertical guide line
- &::before {
- content: '';
- position: absolute;
- left: 22px;
- top: 0;
- bottom: 16px;
- width: 2px;
- background: $border-color;
- border-radius: 1px;
- opacity: 0.5;
- }
-}
-
-// ---- Tracking section -------------------------------------------------------
-
-.o_fp_recipe_tracking {
- border-top: 1px solid $border-color;
-}
-
-// ---- Icon picker ------------------------------------------------------------
-
-.o_fp_recipe_icon_picker {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-
-.o_fp_recipe_icon_btn {
- width: 34px;
- height: 34px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 1px solid $border-color;
- border-radius: 6px;
- background: transparent;
- color: var(--bs-secondary-color);
- font-size: 0.9rem;
- cursor: pointer;
- transition: border-color 0.12s, background-color 0.12s;
-
- &:hover {
- border-color: var(--o-action, var(--bs-primary));
- color: var(--bs-body-color);
- }
-
- &.active {
- background: var(--o-action, var(--bs-primary));
- border-color: var(--o-action, var(--bs-primary));
- color: #fff;
- }
-}
-
-// ---- Responsive -------------------------------------------------------------
-
-@media (max-width: 768px) {
- .o_fp_recipe_tree_area {
- padding: 16px 12px 16px 24px;
- }
-
- .o_fp_recipe_node {
- max-width: 100%;
- }
-
- .o_fp_recipe_panel.o_fp_recipe_panel_open {
- width: 280px;
- }
-
- .o_fp_recipe_children {
- margin-left: 20px;
- }
-}
diff --git a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml
index 065927ed..c6234964 100644
--- a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml
+++ b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml
@@ -3,41 +3,177 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
+
+ Recipe Tree Editor — horizontal hierarchical layout (Steelhead-style).
+ Recursive template renders recipe → sub-process → operation → step
+ cards left→right with bracket connectors. Each card carries hover-
+ revealed Add / Delete buttons; a side panel slides in for editing
+ when a node is clicked.
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ·
+
+
+ ·
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hidden
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
v
+
+
+
-
-
-
-
-
-
@@ -48,17 +184,17 @@
Loading recipe tree...
-
-
-
-
No recipe selected.
+
+
+
+
No recipe selected.
-
-
+
+
-
-
+
+
@@ -67,26 +203,29 @@
-
+
-
+
-
+
Edit Node
-
+
-
-
-
+
+
+
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
+
-
-
+
+
+
-
-
+
+
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
- operator input(s)
-
-
-
-
+
+
+
Created
by
-
+
Updated
@@ -187,15 +316,15 @@
-
-
-
+
Save
-
@@ -208,127 +337,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Add Step
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/fusion_plating/fusion_plating/views/fp_process_node_views.xml b/fusion_plating/fusion_plating/views/fp_process_node_views.xml
index 27744b2a..55370a34 100644
--- a/fusion_plating/fusion_plating/views/fp_process_node_views.xml
+++ b/fusion_plating/fusion_plating/views/fp_process_node_views.xml
@@ -79,6 +79,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_bridge_mrp/data/fp_cron_data.xml b/fusion_plating/fusion_plating_bridge_mrp/data/fp_cron_data.xml
index 97ef30ab..a2d32c80 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/data/fp_cron_data.xml
+++ b/fusion_plating/fusion_plating_bridge_mrp/data/fp_cron_data.xml
@@ -23,8 +23,6 @@
model._fp_cron_auto_finish_completed_wos()1minutes
- -1
-
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
index 232fa988..14d5d50f 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
+from markupsafe import Markup
+
from odoo import _, api, fields, models
@@ -191,6 +193,74 @@ class MrpWorkorder(models.Model):
help='Wall-clock time the timer was closed for the last time.',
)
+ # ------------------------------------------------------------------
+ # Recipe-node link + behaviour flags propagated from the recipe.
+ # _generate_workorders_from_recipe stores the link at WO creation;
+ # the related fields here let the start/finish gates and the
+ # auto-complete cron resolve flags in O(1) without joining by name.
+ # ------------------------------------------------------------------
+ x_fc_recipe_node_id = fields.Many2one(
+ 'fusion.plating.process.node',
+ string='Recipe Node',
+ readonly=True, copy=False, index=True,
+ help='The operation node in the recipe template that produced '
+ 'this work order. Drives auto-complete, sign-off and '
+ 'manual/automated behaviour.',
+ )
+ x_fc_requires_signoff = fields.Boolean(
+ related='x_fc_recipe_node_id.requires_signoff',
+ store=True, readonly=True,
+ help='Recipe says this is a quality hold point — finish is '
+ 'blocked until an operator records a sign-off.',
+ )
+ x_fc_is_manual = fields.Boolean(
+ related='x_fc_recipe_node_id.is_manual',
+ store=True, readonly=True,
+ help='If false, this is an automated step — the worker '
+ 'assignment gate is skipped on Start.',
+ )
+ x_fc_auto_complete = fields.Boolean(
+ related='x_fc_recipe_node_id.auto_complete',
+ store=True, readonly=True,
+ help='If true, the cron auto-finishes the WO once it has been '
+ 'in Progress for at least its expected duration.',
+ )
+ x_fc_signoff_user_id = fields.Many2one(
+ 'res.users', string='Signed Off By',
+ readonly=True, copy=False,
+ help='Operator who signed off on the quality hold point. '
+ 'Required to finish a WO whose recipe sets requires_signoff.',
+ )
+ x_fc_signoff_date = fields.Datetime(
+ string='Signed Off At',
+ readonly=True, copy=False,
+ )
+ # Contract-review approver list lifted from the recipe root via the
+ # node link. Computed on the fly — we tried a `related=` field but
+ # Odoo's M2M-through-M2O-through-M2O related chain didn't populate
+ # reliably in tests. A small compute is more predictable.
+ x_fc_contract_review_user_ids = fields.Many2many(
+ 'res.users',
+ relation='fp_wo_contract_review_user_rel',
+ column1='wo_id',
+ column2='user_id',
+ string='Contract Review Approvers',
+ compute='_compute_contract_review_approvers',
+ store=False,
+ )
+
+ @api.depends('x_fc_recipe_node_id')
+ def _compute_contract_review_approvers(self):
+ for wo in self:
+ recipe = (
+ wo.x_fc_recipe_node_id.recipe_root_id
+ if wo.x_fc_recipe_node_id else False
+ )
+ wo.x_fc_contract_review_user_ids = (
+ recipe.contract_review_user_ids
+ if recipe else self.env['res.users']
+ )
+
# ------------------------------------------------------------------
# Workflow step tracking
# ------------------------------------------------------------------
@@ -362,13 +432,22 @@ class MrpWorkorder(models.Model):
# Process tree action (opens OWL client action)
# ------------------------------------------------------------------
def action_view_process_tree(self):
- """Open the OWL process tree view for this MO's routing."""
+ """Open the OWL process tree view for this MO's routing.
+
+ Passes `back_workorder_id` so the tree's "Back" button returns to
+ the WO the user came from instead of always jumping to Plant
+ Overview.
+ """
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_process_tree',
'name': f'Process Tree — {self.production_id.name}',
- 'context': {'production_id': self.production_id.id},
+ 'context': {
+ 'production_id': self.production_id.id,
+ 'back_workorder_id': self.id,
+ 'back_workorder_name': self.display_name or self.name,
+ },
}
# ------------------------------------------------------------------
@@ -629,7 +708,7 @@ class MrpWorkorder(models.Model):
@api.depends('x_fc_assigned_user_id', 'x_fc_bath_id', 'x_fc_tank_id',
'x_fc_oven_id', 'x_fc_rack_id', 'x_fc_masking_material',
- 'x_fc_wo_kind')
+ 'x_fc_wo_kind', 'x_fc_is_manual')
def _compute_is_release_ready(self):
"""A WO is release-ready when the manager has set EVERY field
button_start would block on. Used by the Manager Desk to keep
@@ -638,7 +717,9 @@ class MrpWorkorder(models.Model):
"""
for wo in self:
missing = []
- if not wo.x_fc_assigned_user_id:
+ # Skip the operator requirement for automated steps so the
+ # Manager Desk doesn't park them in Setup Pending forever.
+ if not wo.x_fc_assigned_user_id and wo.x_fc_is_manual:
missing.append('Operator')
kind = wo.x_fc_wo_kind
if kind == 'wet':
@@ -771,7 +852,11 @@ class MrpWorkorder(models.Model):
from odoo.exceptions import UserError
for wo in self:
missing = []
- if not wo.x_fc_assigned_user_id:
+ # Automated steps (recipe.is_manual=False) don't need a
+ # human operator — the equipment runs unattended (timed
+ # immersion, automated rinse, etc.). The kind-specific
+ # equipment checks below still apply.
+ if not wo.x_fc_assigned_user_id and wo.x_fc_is_manual:
missing.append(_('Assigned Operator'))
kind = wo._fp_classify_kind()
if kind == 'wet':
@@ -847,17 +932,62 @@ class MrpWorkorder(models.Model):
) % (employee.name, process_type.name))
def _fp_check_required_fields_before_finish(self):
- """Block button_finish on bake WOs without the actual data
- Nadcap audits demand: setpoint temp, actual duration, and a
- chart-recorder reference on the oven (so the printed chart
- for this run can be retrieved).
+ """Block button_finish on:
+
+ - bake WOs without setpoint temp / actual duration / chart-recorder
+ ref (Nadcap requirement);
+ - any WO whose recipe node is `requires_signoff` and has no
+ sign-off recorded yet (quality hold point).
Run-time data (temp + duration) belongs at FINISH because
- you don't know it until the bake is done. Chart-recorder ref
- is on the oven config — checked here as a defensive backstop.
+ you don't know it until the bake is done.
"""
from odoo.exceptions import UserError
for wo in self:
+ # ---- Contract Review approver gate ---------------------------
+ # Only authorised users (per the recipe's
+ # contract_review_user_ids) can finish the Contract Review WO.
+ # Detected by the recipe-node name match — robust enough since
+ # this is a well-known operation in every recipe.
+ node = wo.x_fc_recipe_node_id
+ if (
+ node and (node.name or '').strip().lower() == 'contract review'
+ and wo.x_fc_contract_review_user_ids
+ and self.env.user not in wo.x_fc_contract_review_user_ids
+ and not self.env.user.has_group(
+ 'fusion_plating.group_fusion_plating_manager'
+ )
+ ):
+ allowed = ', '.join(
+ wo.x_fc_contract_review_user_ids.mapped('name')
+ ) or '(none configured)'
+ raise UserError(_(
+ 'Cannot finish Contract Review for "%(wo)s" — '
+ 'this approval is restricted to: %(allowed)s.\n\n'
+ 'You (%(user)s) are not on the approver list for '
+ 'recipe "%(recipe)s". Ask one of the approvers to '
+ 'sign off, or have a Plating Manager finish it on '
+ 'their behalf.'
+ ) % {
+ 'wo': wo.display_name or wo.name,
+ 'allowed': allowed,
+ 'user': self.env.user.name,
+ 'recipe': (node.recipe_root_id.name or '—'),
+ })
+
+ # ---- Quality hold point: requires sign-off -------------------
+ if wo.x_fc_requires_signoff and not wo.x_fc_signoff_user_id:
+ raise UserError(_(
+ 'Cannot finish work order "%(wo)s" — recipe step '
+ '"%(node)s" is a quality hold point and requires '
+ 'an operator sign-off first.\n\n'
+ 'On the WO form: tap "Sign Off" before clicking '
+ 'Finish. The sign-off captures who certified the '
+ 'work and is recorded in the audit trail.'
+ ) % {
+ 'wo': wo.display_name or wo.name,
+ 'node': (wo.x_fc_recipe_node_id.name or wo.name),
+ })
if wo._fp_classify_kind() != 'bake':
continue
missing = []
@@ -981,3 +1111,83 @@ class MrpWorkorder(models.Model):
'within %s hours of plate exit.'
) % (coating.bake_window_hours or 4.0)
)
+
+ # ------------------------------------------------------------------
+ # Sign-off (recipe quality hold point)
+ # ------------------------------------------------------------------
+ def action_signoff(self):
+ """Capture the current user as the sign-off operator + timestamp.
+
+ The button only makes sense for WOs whose recipe step is marked
+ `requires_signoff`. The view hides the button otherwise.
+ """
+ from odoo.exceptions import UserError
+ for wo in self:
+ if not wo.x_fc_requires_signoff:
+ raise UserError(_(
+ 'Work order "%s" is not a quality hold point — '
+ 'no sign-off required.'
+ ) % (wo.display_name or wo.name))
+ wo.write({
+ 'x_fc_signoff_user_id': self.env.user.id,
+ 'x_fc_signoff_date': fields.Datetime.now(),
+ })
+ wo.message_post(
+ body=Markup(_(
+ 'Quality hold point signed off by %s.'
+ )) % self.env.user.name,
+ )
+ return True
+
+ # ------------------------------------------------------------------
+ # Auto-complete cron
+ # ------------------------------------------------------------------
+ @api.model
+ def _fp_cron_auto_finish_completed_wos(self):
+ """Cron entry point — auto-finish WOs whose recipe step is marked
+ `auto_complete` once they've been in Progress for at least their
+ expected duration.
+
+ Used for fully-automated steps (timed immersion, automated rinse)
+ where the equipment runs unattended. Manual steps are unaffected.
+
+ Skips WOs that still have a sign-off requirement: those must be
+ finished by the operator after they've certified the work.
+ """
+ candidates = self.search([
+ ('state', '=', 'progress'),
+ ('x_fc_auto_complete', '=', True),
+ ('x_fc_started_at', '!=', False),
+ ('duration_expected', '>', 0),
+ ])
+ if not candidates:
+ return 0
+ now = fields.Datetime.now()
+ finished = 0
+ for wo in candidates:
+ if wo.x_fc_requires_signoff and not wo.x_fc_signoff_user_id:
+ # Quality hold trumps auto-complete — wait for the
+ # operator's sign-off before closing.
+ continue
+ elapsed_min = (now - wo.x_fc_started_at).total_seconds() / 60.0
+ if elapsed_min < (wo.duration_expected or 0):
+ continue
+ try:
+ wo.with_user(
+ wo.x_fc_assigned_user_id or self.env.user
+ ).button_finish()
+ wo.message_post(
+ body=Markup(_(
+ 'Auto-finished by recipe (auto_complete) after '
+ '%.1f min — expected %.1f min.'
+ )) % (elapsed_min, wo.duration_expected),
+ subtype_xmlid='mail.mt_note',
+ )
+ finished += 1
+ except Exception as exc: # noqa: BLE001
+ import logging
+ logging.getLogger(__name__).warning(
+ 'Auto-complete failed for WO %s (%s): %s',
+ wo.id, wo.display_name, exc,
+ )
+ return finished
diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
index 53a891df..d4b9e77a 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
+++ b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
@@ -102,6 +102,25 @@
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
+
+
+
+
+
+
+
+
+
+
+
@@ -118,6 +121,9 @@
+
+ Invoice_{{ (object.name or '').replace('/','_') }}
diff --git a/fusion_plating/fusion_plating_notifications/hooks.py b/fusion_plating/fusion_plating_notifications/hooks.py
new file mode 100644
index 00000000..54e4c105
--- /dev/null
+++ b/fusion_plating/fusion_plating_notifications/hooks.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def post_init_hook(env):
+ """Force-populate report_template_ids on the mail templates.
+
+ The mail-template XML records are tagged noupdate="1" so
+ customer-edited templates aren't overwritten on module update.
+ That means report_template_ids added to the XML AFTER the
+ templates were first installed won't propagate via the usual
+ -u reload. This hook wires the reports onto the branded
+ templates on install/upgrade and is safe to re-run.
+ """
+ _apply_report_template(
+ env,
+ 'fusion_plating_notifications.fp_mail_template_quote_sent',
+ 'fusion_plating_reports.action_report_fp_sale_portrait',
+ )
+ _apply_report_template(
+ env,
+ 'fusion_plating_notifications.fp_mail_template_so_confirmed',
+ 'fusion_plating_reports.action_report_fp_so_acknowledgement',
+ )
+ _apply_report_template(
+ env,
+ 'fusion_plating_notifications.fp_mail_template_invoice_posted',
+ 'fusion_plating_reports.action_report_fp_invoice_portrait',
+ )
+
+
+def _apply_report_template(env, mail_template_xmlid, report_xmlid):
+ mail_template = env.ref(mail_template_xmlid, raise_if_not_found=False)
+ report = env.ref(report_xmlid, raise_if_not_found=False)
+ if not mail_template or not report:
+ _logger.warning(
+ 'fusion_plating_notifications post_init: missing %s or %s',
+ mail_template_xmlid, report_xmlid,
+ )
+ return
+ if report.id not in mail_template.report_template_ids.ids:
+ mail_template.write({
+ 'report_template_ids': [(4, report.id)],
+ })
+ _logger.info(
+ 'fusion_plating_notifications: attached report %s to template %s',
+ report_xmlid, mail_template_xmlid,
+ )
diff --git a/fusion_plating/fusion_plating_notifications/models/account_move.py b/fusion_plating/fusion_plating_notifications/models/account_move.py
index 7721a459..9f1ce933 100644
--- a/fusion_plating/fusion_plating_notifications/models/account_move.py
+++ b/fusion_plating/fusion_plating_notifications/models/account_move.py
@@ -9,6 +9,24 @@ from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
+ def _get_mail_template(self):
+ """Prefer the Fusion Plating-branded invoice template over Odoo default.
+
+ Called by the account.move.send wizard when the user clicks
+ "Send" on an invoice. Override returns our
+ fp_mail_template_invoice_posted (which attaches the branded
+ Invoice PDF) for out_invoice moves. Credit notes / self-billing
+ fall back to Odoo's built-in templates.
+ """
+ if all(m.move_type == 'out_invoice' for m in self):
+ tpl = self.env.ref(
+ 'fusion_plating_notifications.fp_mail_template_invoice_posted',
+ raise_if_not_found=False,
+ )
+ if tpl:
+ return tpl
+ return super()._get_mail_template()
+
def action_post(self):
res = super().action_post()
Dispatch = self.env['fp.notification.template']
diff --git a/fusion_plating/fusion_plating_notifications/models/sale_order.py b/fusion_plating/fusion_plating_notifications/models/sale_order.py
index 75b3b9d0..e6943eb9 100644
--- a/fusion_plating/fusion_plating_notifications/models/sale_order.py
+++ b/fusion_plating/fusion_plating_notifications/models/sale_order.py
@@ -9,6 +9,40 @@ from odoo import models
class SaleOrder(models.Model):
_inherit = 'sale.order'
+ def _find_mail_template(self):
+ """Prefer Fusion Plating-branded templates over Odoo defaults.
+
+ Called by sale.order.action_quotation_send (the "Send" button on
+ both quotations and confirmed orders) to resolve the template
+ the composer pre-selects.
+
+ Override returns:
+ - state in ('draft', 'sent') -> fp_mail_template_quote_sent
+ (attaches Quotation PDF)
+ - state == 'sale' or 'done' -> fp_mail_template_so_confirmed
+ (attaches Acknowledgement PDF)
+
+ Falls back to Odoo's default template if ours can't be resolved
+ (e.g. fusion_plating_reports not installed, or template record
+ missing).
+ """
+ self.ensure_one()
+ ref = self.env.ref
+ if self.env.context.get('proforma'):
+ return super()._find_mail_template()
+ fp_tpl = False
+ if self.state in ('draft', 'sent'):
+ fp_tpl = ref(
+ 'fusion_plating_notifications.fp_mail_template_quote_sent',
+ raise_if_not_found=False,
+ )
+ elif self.state in ('sale', 'done'):
+ fp_tpl = ref(
+ 'fusion_plating_notifications.fp_mail_template_so_confirmed',
+ raise_if_not_found=False,
+ )
+ return fp_tpl or super()._find_mail_template()
+
def action_quotation_send(self):
"""Fire the quote_sent trigger when a quotation is emailed."""
res = super().action_quotation_send()
diff --git a/fusion_plating/fusion_plating_portal/models/fp_portal_job.py b/fusion_plating/fusion_plating_portal/models/fp_portal_job.py
index 4de03f45..d6496e58 100644
--- a/fusion_plating/fusion_plating_portal/models/fp_portal_job.py
+++ b/fusion_plating/fusion_plating_portal/models/fp_portal_job.py
@@ -125,3 +125,42 @@ class FpPortalJob(models.Model):
def _progress_percent(self):
self.ensure_one()
return self._state_progress_map().get(self.state, 0)
+
+ # ------------------------------------------------------------------
+ # Customer-visible process steps
+ #
+ # Walks the linked production's recipe tree and returns only the
+ # nodes the recipe author marked `customer_visible=True`. Used by
+ # the portal job page so internal QC / setup / handling steps stay
+ # hidden from the customer while the substantive process steps are
+ # surfaced.
+ # ------------------------------------------------------------------
+ def get_customer_visible_steps(self):
+ """Return [{'name': str, 'icon': str, 'depth': int}] for portal display."""
+ self.ensure_one()
+ Production = self.env.get('mrp.production')
+ if Production is None:
+ return []
+ mo = Production.sudo().search(
+ [('x_fc_portal_job_id', '=', self.id)], limit=1,
+ )
+ if not mo or not mo.x_fc_recipe_id:
+ return []
+
+ result = []
+ def walk(node, depth):
+ for child in node.child_ids.sorted('sequence'):
+ if not child.customer_visible:
+ # Hidden node — and its sub-tree is also hidden,
+ # because if you're skipping the parent the kids
+ # never make sense in isolation.
+ continue
+ result.append({
+ 'name': child.name,
+ 'icon': child.icon or 'fa-cog',
+ 'depth': depth,
+ 'node_type': child.node_type,
+ })
+ walk(child, depth + 1)
+ walk(mo.x_fc_recipe_id, 0)
+ return result
diff --git a/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml b/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml
index 7cf3cc22..1e64f36f 100644
--- a/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml
+++ b/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml
@@ -588,6 +588,21 @@
+
+
+
+
Process Steps
+
+
+
+
+
+
+
+
Documents
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
index 6e534755..2750058e 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
@@ -1021,87 +1021,286 @@ class FpShopfloorController(http.Controller):
def process_tree(self, production_id):
"""Return routing tree for a manufacturing order.
- Each node is an operation/work-order step. Children represent
- sub-states (ready vs active) within that step.
- """
- MrpWO = request.env.get('mrp.workorder')
- if MrpWO is None:
- return {
- 'production_name': '',
- 'product_name': '',
- 'state': '',
- 'nodes': [],
- }
+ Walks the MO's recipe tree (fusion.plating.process.node) and returns
+ a recursive nested structure:
+ recipe → sub_process → operation → step
+ For each `operation` node we look up the matching mrp.workorder by
+ name within this MO, then attach the WO state, qty progress, kind,
+ equipment, and a synthetic state-child ("Ready for X" or "In X")
+ so the operator sees the live position in the flow.
- MrpProduction = request.env['mrp.production']
+ If the MO has no recipe assigned we fall back to a flat list of
+ WOs as a single tier of operation nodes under a synthetic root.
+ """
+ env = request.env
+ MrpWO = env.get('mrp.workorder')
+ MrpProduction = env['mrp.production']
production = MrpProduction.browse(int(production_id))
if not production.exists():
raise UserError(f"Manufacturing order {production_id} not found")
- work_orders = MrpWO.search(
- [('production_id', '=', production.id)],
- order='sequence, id',
+ # Customer
+ customer = ''
+ so_name = production.origin or ''
+ if production.x_fc_portal_job_id and production.x_fc_portal_job_id.partner_id:
+ customer = production.x_fc_portal_job_id.partner_id.name or ''
+ elif so_name:
+ so = env['sale.order'].search([('name', '=', so_name)], limit=1)
+ if so:
+ customer = so.partner_id.name or ''
+
+ product_qty = int(production.product_qty or 0)
+ recipe = production.x_fc_recipe_id
+
+ # Build a lookup so each operation node finds its matching WO by name.
+ # The bridge's _generate_workorders_from_recipe() copies node.name →
+ # wo.name, so this is a stable join key within one MO.
+ wos_by_name = {}
+ all_wos = MrpWO.browse([]) if MrpWO is not None else []
+ if MrpWO is not None:
+ all_wos = MrpWO.search(
+ [('production_id', '=', production.id)],
+ order='sequence, id',
+ )
+ for wo in all_wos:
+ key = (wo.name or '').strip()
+ if key and key not in wos_by_name:
+ wos_by_name[key] = wo
+
+ wo_kind_selection = (
+ dict(MrpWO._fields['x_fc_wo_kind'].selection)
+ if MrpWO is not None and 'x_fc_wo_kind' in MrpWO._fields else {}
+ )
+ masking_selection = (
+ dict(MrpWO._fields['x_fc_masking_material'].selection)
+ if MrpWO is not None and 'x_fc_masking_material' in MrpWO._fields else {}
)
- nodes = []
- for wo in work_orders:
+ def _f(wo, name):
+ return wo[name] if wo and name in wo._fields else False
+
+ def _dur_disp(mins):
+ if mins >= 60:
+ return f'{mins / 60:.1f}h'
+ if mins > 0:
+ return f'{int(mins)}m'
+ return ''
+
+ def _wo_payload(wo):
+ """Manager-Desk style fields for one WO."""
qty_done = int(wo.qty_produced or 0)
- qty_total = int(wo.qty_production or production.product_qty or 0)
-
- # Duration display
- duration_mins = wo.duration or 0
- if duration_mins >= 60:
- duration_display = f'{duration_mins / 60:.1f}h'
- elif duration_mins > 0:
- duration_display = f'{int(duration_mins)}m'
- else:
- duration_display = ''
-
- # Build children — sub-state nodes
- children = []
- if wo.state in ('ready', 'waiting'):
- children.append({
- 'id': f'{wo.id}_ready',
- 'name': f'Ready for {wo.workcenter_id.name or wo.name}',
- 'state': 'ready',
- 'qty_done': 0,
- 'qty_total': qty_total,
- })
- elif wo.state == 'progress':
- children.append({
- 'id': f'{wo.id}_active',
- 'name': f'{wo.workcenter_id.name or wo.name}-ing',
- 'state': 'progress',
- 'qty_done': qty_done,
- 'qty_total': qty_total,
- })
- # Also show "remaining" child if partial
- remaining = qty_total - qty_done
- if remaining > 0:
- children.append({
- 'id': f'{wo.id}_remaining',
- 'name': f'Ready for {wo.workcenter_id.name or wo.name}',
- 'state': 'ready',
- 'qty_done': 0,
- 'qty_total': remaining,
- })
-
- nodes.append({
- 'id': wo.id,
+ qty_total = int(wo.qty_production or product_qty or 0)
+ wo_kind = _f(wo, 'x_fc_wo_kind') or 'other'
+ assigned = _f(wo, 'x_fc_assigned_user_id')
+ bath = _f(wo, 'x_fc_bath_id')
+ tank = _f(wo, 'x_fc_tank_id')
+ oven = _f(wo, 'x_fc_oven_id')
+ rack = _f(wo, 'x_fc_rack_id')
+ masking = _f(wo, 'x_fc_masking_material')
+ return {
'workorder_id': wo.id,
- 'sequence': wo.sequence or 0,
- 'name': wo.display_name or wo.name,
- 'work_center_name': wo.workcenter_id.name if wo.workcenter_id else '',
- 'state': wo.state or '',
+ 'wo_state': wo.state or '',
'qty_done': qty_done,
'qty_total': qty_total,
- 'duration_display': duration_display,
- 'children': children,
- })
+ 'wo_kind': wo_kind,
+ 'wo_kind_label': wo_kind_selection.get(wo_kind, ''),
+ 'assigned_user_name': assigned.name if assigned else '',
+ 'bath': bath.name if bath else '',
+ 'tank': tank.name if tank else '',
+ 'oven': oven.name if oven else '',
+ 'rack': rack.name if rack else '',
+ 'masking_material': (
+ masking_selection.get(masking, '') if masking else ''
+ ),
+ 'duration_display': _dur_disp(wo.duration or 0),
+ 'duration_expected_display': _dur_disp(wo.duration_expected or 0),
+ 'missing_for_release': _f(wo, 'x_fc_missing_for_release') or '',
+ }
+
+ def _step_state_for(step_node, wo):
+ """Map a recipe step's state from the parent operation's WO.
+
+ The step nodes are templates ("Ready For Blast", "Blast",
+ "Bake", etc.). We push the operation's WO state down so the
+ step that represents the live position renders highlighted.
+
+ Convention: a step whose name contains "ready" represents the
+ queued/waiting phase; the other step represents the action
+ phase.
+ """
+ if not wo:
+ return ''
+ step_name = (step_node.name or '').lower()
+ is_ready_step = 'ready' in step_name
+ wo_state = wo.state or ''
+ if wo_state == 'done':
+ return 'done'
+ if wo_state in ('ready', 'waiting'):
+ return 'ready' if is_ready_step else 'pending'
+ if wo_state == 'progress':
+ return 'progress' if not is_ready_step else 'done'
+ return ''
+
+ def _step_qty_for(step_node, wo):
+ """Live qty for a step — fed from the parent WO."""
+ if not wo or not wo.qty_production:
+ return (0, 0)
+ qty_done = int(wo.qty_produced or 0)
+ qty_total = int(wo.qty_production or 0)
+ step_name = (step_node.name or '').lower()
+ wo_state = wo.state or ''
+ if wo_state == 'done':
+ return (qty_total, qty_total)
+ if wo_state in ('ready', 'waiting'):
+ # Everything is queued
+ return (qty_total, qty_total) if 'ready' in step_name else (0, 0)
+ if wo_state == 'progress':
+ if 'ready' in step_name:
+ remaining = qty_total - qty_done
+ return (remaining, remaining) if remaining > 0 else (0, 0)
+ return (qty_done, qty_total)
+ return (0, 0)
+
+ # Track which WOs were attached to a recipe node — leftovers get
+ # pushed under the recipe root as orphan operations.
+ attached_wo_ids = set()
+
+ def _walk(node, parent_wo=None):
+ wo = wos_by_name.get((node.name or '').strip())
+ wo_data = {}
+ if node.node_type == 'operation' and wo:
+ attached_wo_ids.add(wo.id)
+ wo_data = _wo_payload(wo)
+ # If this node is a `step` whose parent operation has a WO,
+ # mirror the WO's state onto the step so the live phase
+ # ("Ready for X" or "X") renders highlighted.
+ step_state = ''
+ step_qty_done, step_qty_total = 0, 0
+ if node.node_type == 'step' and parent_wo:
+ step_state = _step_state_for(node, parent_wo)
+ step_qty_done, step_qty_total = _step_qty_for(node, parent_wo)
+
+ # Recurse — pass this operation's WO down so step children inherit
+ inherited_wo = wo if (node.node_type == 'operation' and wo) else parent_wo
+ children_payload = []
+ for child in node.child_ids.sorted('sequence'):
+ children_payload.append(_walk(child, inherited_wo))
+
+ return {
+ 'id': f'n_{node.id}',
+ 'name': node.name or '',
+ 'node_type': node.node_type,
+ 'icon': node.icon or '',
+ 'sequence': node.sequence or 0,
+ 'workorder_id': wo_data.get('workorder_id'),
+ 'wo_state': wo_data.get('wo_state', ''),
+ 'state': wo_data.get('wo_state') or step_state or '',
+ 'qty_done': wo_data.get('qty_done') or step_qty_done or 0,
+ 'qty_total': wo_data.get('qty_total') or step_qty_total or 0,
+ 'wo_kind': wo_data.get('wo_kind', ''),
+ 'wo_kind_label': wo_data.get('wo_kind_label', ''),
+ 'assigned_user_name': wo_data.get('assigned_user_name', ''),
+ 'bath': wo_data.get('bath', ''),
+ 'tank': wo_data.get('tank', ''),
+ 'oven': wo_data.get('oven', ''),
+ 'rack': wo_data.get('rack', ''),
+ 'masking_material': wo_data.get('masking_material', ''),
+ 'duration_display': wo_data.get('duration_display', ''),
+ 'duration_expected_display': wo_data.get(
+ 'duration_expected_display', ''),
+ 'missing_for_release': wo_data.get('missing_for_release', ''),
+ 'children': children_payload,
+ }
+
+ if recipe:
+ root = _walk(recipe)
+ # Append orphan WOs (those not matched to any recipe node by name)
+ # so we don't lose them — these usually appear when the user
+ # adds ad-hoc WOs after generation.
+ for wo in all_wos:
+ if wo.id in attached_wo_ids:
+ continue
+ wo_data = _wo_payload(wo)
+ orphan = {
+ 'id': f'wo_{wo.id}',
+ 'name': wo.name or '',
+ 'node_type': 'operation',
+ 'icon': '',
+ 'sequence': wo.sequence or 0,
+ 'workorder_id': wo.id,
+ 'wo_state': wo.state or '',
+ 'state': wo.state or '',
+ 'qty_done': wo_data['qty_done'],
+ 'qty_total': wo_data['qty_total'],
+ 'wo_kind': wo_data['wo_kind'],
+ 'wo_kind_label': wo_data['wo_kind_label'],
+ 'assigned_user_name': wo_data['assigned_user_name'],
+ 'bath': wo_data['bath'],
+ 'tank': wo_data['tank'],
+ 'oven': wo_data['oven'],
+ 'rack': wo_data['rack'],
+ 'masking_material': wo_data['masking_material'],
+ 'duration_display': wo_data['duration_display'],
+ 'duration_expected_display': wo_data['duration_expected_display'],
+ 'missing_for_release': wo_data['missing_for_release'],
+ 'children': [],
+ }
+ root['children'].append(orphan)
+ else:
+ # No recipe — synth a root with WOs as direct operation children.
+ child_nodes = []
+ for wo in all_wos:
+ wo_data = _wo_payload(wo)
+ child_nodes.append({
+ 'id': f'wo_{wo.id}',
+ 'name': wo.name or '',
+ 'node_type': 'operation',
+ 'icon': '',
+ 'sequence': wo.sequence or 0,
+ 'workorder_id': wo.id,
+ 'wo_state': wo.state or '',
+ 'state': wo.state or '',
+ 'qty_done': wo_data['qty_done'],
+ 'qty_total': wo_data['qty_total'],
+ 'wo_kind': wo_data['wo_kind'],
+ 'wo_kind_label': wo_data['wo_kind_label'],
+ 'assigned_user_name': wo_data['assigned_user_name'],
+ 'bath': wo_data['bath'],
+ 'tank': wo_data['tank'],
+ 'oven': wo_data['oven'],
+ 'rack': wo_data['rack'],
+ 'masking_material': wo_data['masking_material'],
+ 'duration_display': wo_data['duration_display'],
+ 'duration_expected_display': wo_data['duration_expected_display'],
+ 'missing_for_release': wo_data['missing_for_release'],
+ 'children': [],
+ })
+ root = {
+ 'id': 'root',
+ 'name': production.product_id.display_name if production.product_id
+ else (production.name or 'Process'),
+ 'node_type': 'recipe',
+ 'icon': 'fa-sitemap',
+ 'sequence': 0,
+ 'children': child_nodes,
+ 'workorder_id': None,
+ 'state': production.state or '',
+ 'wo_state': '',
+ 'qty_done': 0, 'qty_total': 0,
+ 'wo_kind': '', 'wo_kind_label': '',
+ 'assigned_user_name': '', 'bath': '', 'tank': '', 'oven': '',
+ 'rack': '', 'masking_material': '',
+ 'duration_display': '', 'duration_expected_display': '',
+ 'missing_for_release': '',
+ }
return {
'production_name': production.name or '',
'product_name': production.product_id.display_name if production.product_id else '',
'state': production.state or '',
- 'nodes': nodes,
+ 'customer': customer,
+ 'so_name': so_name,
+ 'product_qty': product_qty,
+ 'recipe': recipe.name if recipe else '',
+ 'root': root,
}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js
index 73f2874a..5c9ea205 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js
@@ -1,16 +1,17 @@
/** @odoo-module **/
// =============================================================================
-// Fusion Plating — Process Tree View (OWL backend client action)
-// Copyright 2026 Nexa Systems Inc.
-// License OPL-1 (Odoo Proprietary License v1.0)
+// Fusion Plating — Process Tree (horizontal hierarchical view)
+// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
-// Visual routing-step tree for a single manufacturing order showing progress
-// bars per work order.
+// Renders the MO's recipe (recipe → sub_process → operation → state) as a
+// horizontal bracket tree. Cards render dark, identical card style across
+// all depths; connector lines are drawn from CSS so the layout stays in
+// pure flexbox.
//
-// Odoo 19 conventions:
-// * Backend OWL component: `static template` + `static props = ["*"]`
-// * RPC via standalone `rpc()` from @web/core/network/rpc
-// * Registered under registry.category("actions") → "fp_process_tree"
+// Action context:
+// production_id — required; the MO whose recipe to render
+// back_workorder_id — optional; if set, the back button returns to
+// that WO instead of Plant Overview
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
@@ -30,9 +31,12 @@ export class ProcessTree extends Component {
productionName: "",
productName: "",
moState: "",
- nodes: [],
+ customer: "",
+ soName: "",
+ productQty: 0,
+ recipe: "",
+ root: null,
loading: false,
- collapsed: {}, // node id → boolean
});
onMounted(async () => {
@@ -40,20 +44,19 @@ export class ProcessTree extends Component {
});
}
- // ----- Data loading ------------------------------------------------------
+ // ---- Action context -----------------------------------------------------
- get productionId() {
- // Client action may receive production_id via action context or params
- const ctx = this.props.action && this.props.action.context;
- if (ctx && ctx.production_id) {
- return ctx.production_id;
- }
- const params = this.props.action && this.props.action.params;
- if (params && params.production_id) {
- return params.production_id;
- }
- return null;
+ get _ctx() {
+ const a = this.props.action || {};
+ return { ...(a.context || {}), ...(a.params || {}) };
}
+ get productionId() { return this._ctx.production_id || null; }
+ get backWorkorderId() { return this._ctx.back_workorder_id || null; }
+ get backLabel() {
+ return this.backWorkorderId ? "Back to Work Order" : "Plant Overview";
+ }
+
+ // ---- Data ---------------------------------------------------------------
async loadTree() {
const prodId = this.productionId;
@@ -66,14 +69,18 @@ export class ProcessTree extends Component {
}
this.state.loading = true;
try {
- const result = await rpc("/fp/shopfloor/process_tree", {
+ const r = await rpc("/fp/shopfloor/process_tree", {
production_id: prodId,
});
- if (result) {
- this.state.productionName = result.production_name || "";
- this.state.productName = result.product_name || "";
- this.state.moState = result.state || "";
- this.state.nodes = result.nodes || [];
+ if (r) {
+ this.state.productionName = r.production_name || "";
+ this.state.productName = r.product_name || "";
+ this.state.moState = r.state || "";
+ this.state.customer = r.customer || "";
+ this.state.soName = r.so_name || "";
+ this.state.productQty = r.product_qty || 0;
+ this.state.recipe = r.recipe || "";
+ this.state.root = r.root || null;
}
} catch (err) {
this.notification.add(
@@ -85,20 +92,10 @@ export class ProcessTree extends Component {
}
}
- // ----- Collapse / expand -------------------------------------------------
-
- toggleNode(nodeId) {
- this.state.collapsed[nodeId] = !this.state.collapsed[nodeId];
- }
-
- isCollapsed(nodeId) {
- return !!this.state.collapsed[nodeId];
- }
-
- // ----- Navigation --------------------------------------------------------
+ // ---- Navigation ---------------------------------------------------------
onNodeClick(node) {
- if (!node.workorder_id) {
+ if (!node || !node.workorder_id) {
return;
}
this.action.doAction({
@@ -110,54 +107,68 @@ export class ProcessTree extends Component {
});
}
- onBackToOverview() {
+ onBack() {
+ const woId = this.backWorkorderId;
+ if (woId) {
+ this.action.doAction({
+ type: "ir.actions.act_window",
+ res_model: "mrp.workorder",
+ res_id: parseInt(woId, 10),
+ views: [[false, "form"]],
+ target: "current",
+ });
+ return;
+ }
this.action.doAction("fusion_plating_shopfloor.action_fp_plant_overview");
}
- // ----- Helpers -----------------------------------------------------------
+ // ---- Helpers ------------------------------------------------------------
- getProgressPct(node) {
- if (!node.qty_total || node.qty_total === 0) {
- return 0;
+ /** Return the css class chain for a node card (state + node_type). */
+ getCardClass(node) {
+ const parts = ["o_fp_pt_card"];
+ parts.push(`o_fp_pt_type_${node.node_type || "unknown"}`);
+ if (node.state) {
+ parts.push(`o_fp_pt_state_${node.state}`);
}
- return Math.round((node.qty_done / node.qty_total) * 100);
+ if (node.workorder_id) {
+ parts.push("o_fp_pt_clickable");
+ }
+ if (this.isHighlight(node)) {
+ parts.push("o_fp_pt_highlight");
+ }
+ return parts.join(" ");
}
- getProgressClass(node) {
- const pct = this.getProgressPct(node);
- if (pct >= 100) {
- return "o_fp_tree_progress_done";
- }
- if (pct > 0) {
- return "o_fp_tree_progress_active";
- }
- return "o_fp_tree_progress_empty";
+ /** A node should pulse-highlight if it is the live position of the MO. */
+ isHighlight(node) {
+ return node.state === "ready"
+ || node.state === "progress"
+ || node.state === "waiting";
}
- getNodeStateLabel(state) {
- const map = {
- pending: "Pending",
- waiting: "Waiting",
- ready: "Ready",
- progress: "In Progress",
- done: "Done",
- cancel: "Cancelled",
+ getKindBadge(node) {
+ if (!node.wo_kind) return null;
+ return {
+ cls: `o_fp_pt_kind o_fp_pt_kind_${node.wo_kind}`,
+ label: node.wo_kind_label || node.wo_kind,
};
- return map[state] || state || "—";
}
- getNodeStateClass(state) {
- switch (state) {
- case "done":
- return "o_fp_tree_state_done";
- case "progress":
- return "o_fp_tree_state_progress";
- case "ready":
- return "o_fp_tree_state_ready";
- case "cancel":
- return "o_fp_tree_state_cancel";
- default:
- return "o_fp_tree_state_pending";
+ qtyLabel(node) {
+ if (!node.qty_total) return "";
+ return `${node.qty_done}/${node.qty_total}`;
+ }
+
+ nodeIcon(node) {
+ if (node.icon) return node.icon;
+ switch (node.node_type) {
+ case "recipe": return "fa-cubes";
+ case "sub_process": return "fa-folder";
+ case "operation": return "fa-cog";
+ case "step": return "fa-circle-o";
+ case "state": return "fa-circle";
+ default: return "fa-square";
}
}
}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss
index a890b4a5..c6a9a2bc 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss
@@ -1,298 +1,398 @@
// =============================================================================
-// Fusion Plating — Process Tree View
-// Copyright 2026 Nexa Systems Inc.
-// License OPL-1 (Odoo Proprietary License v1.0)
+// Fusion Plating — Process Tree (horizontal hierarchical, v3, 2026-04)
+// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
-// THEME AWARENESS
-// ---------------
-// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
-// the tree view renders correctly in BOTH light and dark mode.
+// Hierarchical bracket tree:
//
-// background: var(--bs-body-bg)
-// surface: var(--o-view-background-color)
-// foreground: var(--bs-body-color)
-// muted text: var(--bs-secondary-color)
-// border: var(--bs-border-color)
-// primary: var(--o-action)
+// [Recipe]──┬──[Sub-Process]──┬──[Operation]──┬──[Ready for X]
+// │ │ └──[X]
+// │ └──[Operation]
+// ├──[Operation]
+// └──[Operation]
+//
+// Each .o_fp_pt_node is `display: flex` with:
+// - the card on the left
+// - .o_fp_pt_children on the right (column of recursed children)
+// Connectors are drawn entirely from CSS pseudo-elements:
+// - vertical bus column on each child via ::after
+// - horizontal stub from bus column to card via ::before
+// - first/last children trim the vertical line so it stops at the card
+// centre.
// =============================================================================
-.o_fp_process_tree {
+
+@media (hover: none) {
+ .o_fp_process_tree [class*="o_fp_pt_"]:hover {
+ transform: none !important;
+ box-shadow: inherit !important;
+ }
+}
+
+
+// --- Connector geometry -------------------------------------------------------
+// Tweaking these recalculates the whole bracket-tree layout.
+$pt-card-h : 44px; // nominal card height (cards may be taller
+ // when meta line wraps; centre stays at h/2)
+$pt-row-gap : 12px; // vertical gap between sibling children
+$pt-indent : 36px; // horizontal gap from parent → children
+$pt-stub : 28px; // horizontal connector segment length
+$pt-line-color : #6b7280; // connector colour
+$pt-line-width : 2px;
+
+
+.o_fp_process_tree.o_fp_pt_v3 {
+ font-family: $fp-font-stack;
+ background-color: $fp-page;
+ color: $fp-ink;
+ height: 100%;
+ overflow: auto; // both axes — wide trees scroll horizontally
+ -webkit-overflow-scrolling: touch;
+ padding: $fp-space-4 $fp-space-5;
display: flex;
flex-direction: column;
- height: 100%;
- min-height: 0;
- background: var(--o-view-background-color, var(--bs-body-bg));
- padding: 0;
-}
+ gap: $fp-space-3;
-// ---- Header -----------------------------------------------------------------
+ @media (max-width: 600px) { padding: $fp-space-3; gap: $fp-space-3; }
-.o_fp_pt_header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 12px;
- padding: 16px 24px;
- background: var(--bs-body-bg);
- border-bottom: 1px solid var(--bs-border-color);
- box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
- .o_fp_pt_header_left {
+ // -------------------------------------------------------------------------
+ // Header (compact strip)
+ // -------------------------------------------------------------------------
+ .o_fp_pt_header {
display: flex;
align-items: center;
+ gap: $fp-space-3;
+ flex-wrap: wrap;
+ padding: $fp-space-3 $fp-space-4;
+ background-color: $fp-card;
+ border-radius: $fp-radius-md;
+ box-shadow: $fp-elev-1;
+ position: sticky;
+ top: 0;
+ z-index: 5;
}
-
+ .o_fp_pt_back {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 12px;
+ border-radius: $fp-radius-pill;
+ background-color: $fp-card-soft;
+ color: $fp-ink-soft;
+ font-weight: $fp-weight-medium;
+ font-size: $fp-text-sm;
+ border: 1px solid #{$fp-border};
+ cursor: pointer;
+ transition: background-color $fp-dur $fp-ease,
+ border-color $fp-dur $fp-ease,
+ color $fp-dur $fp-ease;
+ @include fp-hover-only {
+ &:hover {
+ background-color: color-mix(in srgb, #{$fp-accent} 8%, $fp-card);
+ border-color: color-mix(in srgb, #{$fp-accent} 45%, #{$fp-border});
+ color: $fp-ink;
+ }
+ }
+ }
+ .o_fp_pt_title_block { flex: 1 1 auto; min-width: 0; }
.o_fp_pt_title {
- font-size: 1.2rem;
- font-weight: 700;
- color: var(--bs-body-color);
+ font-size: $fp-text-md;
+ font-weight: $fp-weight-bold;
+ margin: 0;
+ color: $fp-ink;
+ display: inline-flex; align-items: center; gap: 4px;
+ .o_fp_pt_mo_name { color: $fp-ink-soft; font-weight: $fp-weight-semibold; }
}
-
.o_fp_pt_subtitle {
- font-size: 0.85rem;
- display: block;
- }
-}
-
-// ---- Tree container ---------------------------------------------------------
-
-.o_fp_pt_tree {
- padding: 24px;
- padding-left: 48px;
- overflow-y: auto;
- flex: 1;
- min-height: 0;
-}
-
-// ---- Node wrapper -----------------------------------------------------------
-
-.o_fp_pt_node_wrapper {
- position: relative;
- margin-bottom: 0;
-}
-
-// ---- Connector line (vertical line between nodes) ---------------------------
-
-.o_fp_pt_connector {
- width: 3px;
- height: 20px;
- background: var(--bs-border-color);
- margin-left: 28px;
-}
-
-// ---- Node box ---------------------------------------------------------------
-
-.o_fp_pt_node {
- background: var(--bs-secondary-bg);
- color: var(--bs-body-color);
- border-radius: 10px;
- padding: 14px 18px;
- max-width: 440px;
- cursor: pointer;
- transition: box-shadow 0.15s, transform 0.1s;
- position: relative;
-
- &:hover {
- box-shadow: 0 3px 12px color-mix(in srgb, var(--bs-body-color) 15%, transparent);
- transform: translateX(2px);
+ margin-top: 2px;
+ font-size: $fp-text-xs;
+ color: $fp-ink-mute;
+ display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
+ .fa { margin-right: 2px; opacity: 0.7; }
}
- // State colour accents (left border)
- &.o_fp_tree_state_done {
- border-left: 5px solid var(--bs-success);
- }
- &.o_fp_tree_state_progress {
- border-left: 5px solid var(--bs-warning);
- }
- &.o_fp_tree_state_ready {
- border-left: 5px solid var(--bs-primary);
- }
- &.o_fp_tree_state_cancel {
- border-left: 5px solid var(--bs-secondary);
- opacity: 0.6;
- }
- &.o_fp_tree_state_pending {
- border-left: 5px solid var(--bs-border-color);
- }
-}
-.o_fp_pt_node_header {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.o_fp_pt_node_name {
- font-size: 1rem;
-}
-
-.o_fp_pt_node_seq {
- color: var(--bs-secondary-color);
- font-weight: 400;
- margin-right: 4px;
-}
-
-.o_fp_pt_toggle_btn {
- background: none;
- border: none;
- color: var(--bs-secondary-color);
- cursor: pointer;
- padding: 2px 6px;
- font-size: 0.85rem;
-
- &:hover {
- color: var(--bs-body-color);
- }
-}
-
-.o_fp_pt_node_wc {
- font-size: 0.8rem;
- color: var(--bs-secondary-color) !important;
- margin-top: 2px;
-}
-
-// ---- State badges inside tree -----------------------------------------------
-
-.o_fp_pt_node_state {
- .badge {
- font-size: 0.7rem;
- font-weight: 600;
- padding: 3px 8px;
+ // -------------------------------------------------------------------------
+ // Empty / loading
+ // -------------------------------------------------------------------------
+ .o_fp_pt_empty {
+ text-align: center;
+ padding: $fp-space-7 $fp-space-5;
+ color: $fp-ink-mute;
+ background-color: $fp-card;
+ border-radius: $fp-radius-md;
+ box-shadow: $fp-elev-1;
+ font-size: $fp-text-sm;
+ max-width: 520px;
+ > .fa { font-size: 1.75rem; margin-bottom: $fp-space-2; opacity: 0.6; }
}
- .o_fp_tree_state_done {
- background: var(--bs-success) !important;
- color: #fff !important;
- }
- .o_fp_tree_state_progress {
- background: var(--bs-warning) !important;
- color: #fff !important;
- }
- .o_fp_tree_state_ready {
- background: var(--bs-primary) !important;
- color: #fff !important;
- }
- .o_fp_tree_state_cancel {
- background: var(--bs-secondary) !important;
- color: #fff !important;
- }
- .o_fp_tree_state_pending {
- background: var(--bs-tertiary-bg) !important;
- color: var(--bs-secondary-color) !important;
- }
-}
-// ---- Progress bar -----------------------------------------------------------
-
-.o_fp_pt_bar {
- height: 8px;
- background: var(--bs-tertiary-bg);
- border-radius: 4px;
- overflow: hidden;
-
- &.o_fp_pt_bar_sm {
- height: 6px;
+ // -------------------------------------------------------------------------
+ // Tree canvas — horizontally scrollable
+ // -------------------------------------------------------------------------
+ .o_fp_pt_canvas {
+ padding: $fp-space-3 0;
+ min-width: max-content; // let cards push the canvas wider for scroll
}
- .o_fp_pt_bar_fill {
- height: 100%;
- border-radius: 4px;
- transition: width 0.3s ease;
- }
-
- &.o_fp_tree_progress_active .o_fp_pt_bar_fill {
- background: var(--bs-warning);
- }
- &.o_fp_tree_progress_done .o_fp_pt_bar_fill {
- background: var(--bs-success);
- }
- &.o_fp_tree_progress_empty .o_fp_pt_bar_fill {
- background: var(--bs-secondary);
- }
-}
-
-.o_fp_pt_bar_label {
- font-size: 0.75rem;
- color: var(--bs-secondary-color);
- margin-top: 2px;
- display: inline-block;
-}
-
-.o_fp_pt_node_duration {
- font-size: 0.75rem;
- color: var(--bs-secondary-color) !important;
-}
-
-// ---- Children (sub-state nodes) ---------------------------------------------
-
-.o_fp_pt_children {
- margin-left: 48px;
- padding-top: 4px;
-}
-
-.o_fp_pt_child_connector {
- width: 3px;
- height: 12px;
- background: var(--bs-border-color);
- margin-left: 20px;
-}
-
-.o_fp_pt_child_node {
- background: var(--bs-tertiary-bg);
- color: var(--bs-body-color);
- border-radius: 8px;
- padding: 10px 14px;
- max-width: 360px;
- margin-bottom: 0;
-
- &.o_fp_tree_state_progress {
- border-left: 4px solid var(--bs-warning);
- }
- &.o_fp_tree_state_ready {
- border-left: 4px solid var(--bs-primary);
- }
- &.o_fp_tree_state_done {
- border-left: 4px solid var(--bs-success);
- }
- &.o_fp_tree_state_pending {
- border-left: 4px solid var(--bs-secondary);
- }
-}
-
-.o_fp_pt_child_name {
- font-size: 0.85rem;
- font-weight: 600;
- margin-bottom: 4px;
-}
-
-.o_fp_pt_child_progress {
- display: flex;
- align-items: center;
- gap: 8px;
-
- .o_fp_pt_bar {
- flex: 1;
- }
-}
-
-// ---- Responsive -------------------------------------------------------------
-
-@media (max-width: 768px) {
- .o_fp_pt_tree {
- padding: 16px;
- padding-left: 24px;
- }
+ // -------------------------------------------------------------------------
+ // Recursive node — flex row of [card | children-column]
+ // -------------------------------------------------------------------------
.o_fp_pt_node {
- max-width: 100%;
+ display: flex;
+ align-items: flex-start;
+ position: relative;
}
- .o_fp_pt_child_node {
- max-width: 100%;
+
+ // -------------------------------------------------------------------------
+ // Card (Steelhead-style: dark fill, rounded, fixed-ish width per row)
+ // -------------------------------------------------------------------------
+ .o_fp_pt_card {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 200px;
+ max-width: 320px;
+ min-height: $pt-card-h;
+ padding: 8px 12px;
+ background-color: #2b2f36; // dark slate, matches Steelhead look
+ color: #f1f3f5;
+ border-radius: $fp-radius-sm;
+ box-shadow: $fp-elev-1;
+ font-size: $fp-text-sm;
+ line-height: 1.25;
+ flex: 0 0 auto;
+ position: relative;
+ z-index: 1; // sit above connector lines
+ transition: transform $fp-dur-fast $fp-ease,
+ box-shadow $fp-dur $fp-ease,
+ background-color $fp-dur $fp-ease;
+
+ &.o_fp_pt_clickable {
+ cursor: pointer;
+ @include fp-hover-only {
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: $fp-elev-2;
+ background-color: #34394221;
+ background-color: #353a42;
+ }
+ }
+ }
+
+ // ---- Card type tints (subtle) -------------------------------------
+ &.o_fp_pt_type_recipe {
+ background-color: #1f2329;
+ font-weight: $fp-weight-bold;
+ }
+ &.o_fp_pt_type_sub_process {
+ background-color: #262a31;
+ font-weight: $fp-weight-semibold;
+ }
+ &.o_fp_pt_type_state {
+ background-color: #3a3f47;
+ font-size: $fp-text-xs;
+ min-height: 36px;
+ min-width: 160px;
+ }
+ &.o_fp_pt_type_step {
+ background-color: #353a42;
+ font-size: $fp-text-xs;
+ min-height: 36px;
+ }
+
+ // ---- Live state highlight ----------------------------------------
+ &.o_fp_pt_state_progress,
+ &.o_fp_pt_highlight.o_fp_pt_state_progress {
+ background-color: #c0392b; // warm red — active step
+ color: #fff;
+ box-shadow: 0 0 0 1px rgba(192, 57, 43, .6),
+ 0 4px 14px rgba(192, 57, 43, .35);
+ }
+ &.o_fp_pt_highlight.o_fp_pt_state_ready,
+ &.o_fp_pt_state_ready.o_fp_pt_type_state {
+ background-color: #c0392b; // ready-to-pickup also red
+ color: #fff;
+ box-shadow: 0 0 0 1px rgba(192, 57, 43, .6),
+ 0 4px 14px rgba(192, 57, 43, .35);
+ }
+ &.o_fp_pt_state_done.o_fp_pt_type_state {
+ background-color: #1e8449; // green for completed slice
+ color: #fff;
+ }
+ &.o_fp_pt_state_cancel { opacity: 0.55; }
}
+ .o_fp_pt_card_icon {
+ flex: 0 0 auto;
+ width: 18px;
+ text-align: center;
+ opacity: 0.85;
+ font-size: 0.95em;
+ }
+
+ .o_fp_pt_card_body {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .o_fp_pt_card_title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .o_fp_pt_card_meta {
+ font-size: 0.72rem;
+ opacity: 0.75;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px 6px;
+
+ .fa { opacity: 0.8; }
+ }
+
+ .o_fp_pt_card_right {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .o_fp_pt_qty {
+ font-size: 0.72rem;
+ font-weight: $fp-weight-bold;
+ padding: 1px 8px;
+ border-radius: $fp-radius-pill;
+ background-color: rgba(255, 255, 255, 0.18);
+ color: #fff;
+ }
+
+ .o_fp_pt_card_open {
+ opacity: 0.55;
+ font-size: 0.85em;
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Kind badge inside cards
+ // -------------------------------------------------------------------------
+ .o_fp_pt_kind {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 7px;
+ border-radius: $fp-radius-pill;
+ font-size: 0.65rem;
+ font-weight: $fp-weight-bold;
+ line-height: 1.3;
+ white-space: nowrap;
+
+ &.o_fp_pt_kind_wet { background-color: rgba(13, 110, 253, .25); color: #6ea8fe; }
+ &.o_fp_pt_kind_bake { background-color: rgba(220, 53, 69, .25); color: #f1aeb5; }
+ &.o_fp_pt_kind_mask { background-color: rgba(255, 193, 7, .25); color: #ffd866; }
+ &.o_fp_pt_kind_rack { background-color: rgba(108, 117, 125, .35); color: #d0d4d9; }
+ &.o_fp_pt_kind_inspect { background-color: rgba(25, 135, 84, .28); color: #75d4a4; }
+ &.o_fp_pt_kind_other { background-color: rgba(255, 255, 255, .12); color: #c8ccd2; }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Children column (recursed nodes laid out vertically to the right)
+ //
+ // The ::before pseudo draws the horizontal connector that bridges the
+ // parent card's right edge → the bus column at left: 0 of this
+ // container. Without it the children look orphaned even though the
+ // bus column + per-child stubs are present.
+ // -------------------------------------------------------------------------
.o_fp_pt_children {
- margin-left: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: $pt-row-gap;
+ margin-left: $pt-indent;
+ position: relative;
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: -#{$pt-indent};
+ top: calc(#{$pt-card-h} / 2); // parent-card vertical centre
+ width: $pt-indent;
+ height: $pt-line-width;
+ background-color: $pt-line-color;
+ z-index: 0;
+ }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Connector lines (bracket style, drawn from CSS only)
+ //
+ // Each child .o_fp_pt_node owns its own connector segments:
+ // ::before → horizontal stub from the bus column → card centre
+ // ::after → vertical bus segment for this row
+ //
+ // First/last/single children trim the vertical so the bracket stops
+ // exactly at the card centre.
+ // -------------------------------------------------------------------------
+ .o_fp_pt_children > .o_fp_pt_node {
+ position: relative;
+ padding-left: $pt-stub; // room for the horizontal stub
+
+ // -- horizontal stub from bus column → card --------------------------
+ &::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: calc(#{$pt-card-h} / 2); // align with card vertical centre
+ width: $pt-stub;
+ height: $pt-line-width;
+ background-color: $pt-line-color;
+ z-index: 0;
+ }
+
+ // -- vertical bus segment (default: full row, top → bottom) ----------
+ &::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: calc(-#{$pt-row-gap} / 2); // bridge gap to sibling above
+ bottom: calc(-#{$pt-row-gap} / 2); // bridge gap to sibling below
+ width: $pt-line-width;
+ background-color: $pt-line-color;
+ z-index: 0;
+ }
+
+ // First child — vertical only from card centre → bottom of row
+ &:first-child::after {
+ top: calc(#{$pt-card-h} / 2);
+ }
+ // Last child — vertical only from top of row → card centre
+ &:last-child::after {
+ bottom: calc(100% - (#{$pt-card-h} / 2));
+ }
+ // Only child — vertical only at the card centre point (just enough
+ // to render the elbow connecting to the parent stub)
+ &:first-child:last-child::after {
+ top: calc(#{$pt-card-h} / 2);
+ bottom: calc(100% - (#{$pt-card-h} / 2));
+ }
+ }
+
+
+ // -------------------------------------------------------------------------
+ // Pulse on live (in-progress / ready) cards
+ // -------------------------------------------------------------------------
+ @keyframes o_fp_pt_pulse {
+ 0%, 100% { box-shadow: 0 0 0 1px rgba(192, 57, 43, .55),
+ 0 4px 14px rgba(192, 57, 43, .35); }
+ 50% { box-shadow: 0 0 0 4px rgba(192, 57, 43, .25),
+ 0 4px 18px rgba(192, 57, 43, .45); }
+ }
+ .o_fp_pt_card.o_fp_pt_state_progress,
+ .o_fp_pt_card.o_fp_pt_highlight.o_fp_pt_state_ready {
+ animation: o_fp_pt_pulse 2.4s ease-in-out infinite;
}
}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml
index 4bd0ef0c..c9e46c43 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml
@@ -3,148 +3,133 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
+
+ Process Tree — horizontal hierarchical view.
+ Recursive template renders the recipe → sub-process → operation → step
+ hierarchy with bracket connectors between cards. Active step pulses.
-->
-
-