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:

+ +
+
+
+
+
Layout A — Inline Badge
+
+
+
+
+
Apr 12 — RBC ETF deposit
+
Cheque ref 4827 · $1,847.50 CAD
+
+
92% MATCH
+
+
+ 💡 Invoice INV/2026/00123 — Westin Plating Co — $1,847.50 · Accept · Reject +
+
+
+
+
+
+

A — Inline Badge + Suggestion Strip

+

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.

+
+
+ +
+
+
+
Layout C — Hover Reveal
+
+
+
+
+
Apr 12 — RBC ETF deposit
+
Cheque ref 4827 · $1,847.50 CAD
+
+
+
+
hover for AI
+
+
+
+
+
+
+
Apr 12 — RBC e-transfer
+
💡 92% match: INV/2026/00123 — $1,847.50 · Accept
+
+
92%
+
+
+
+
+
+
+

C — Hover-to-Reveal

+

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).

+
+ +
+

Reference material I've already scanned

+ +
+ +
+

How this session works

+
    +
  1. I ask clarifying questions one at a time (terminal for scope/concept, this browser for layout/visual)
  2. +
  3. I propose 2-3 architectural approaches with tradeoffs
  4. +
  5. We work through the design section by section
  6. +
  7. I write the spec doc and you approve it
  8. +
  9. Then we transition to writing the implementation plan
  10. +
+
+ +

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 @@
- -
- -
- - - -
- - -
- - - - - - - -
- - - - - - - - - - - - - - - - -
- - -
- - - - - - - - - - - - - - - - -
- - -
- - -
-
- - -
-
-
- -
- - - -
-
-
- - -
- - - - - - - -
- - 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() 1 minutes - -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')"/> + + + + + + + + + + +
+ + Acknowledgement_{{ (object.name or '').replace('/','_') }} @@ -342,6 +348,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
+
    +
  1. + + +
  2. +
+
+
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. --> - -
+ + +
- -
-
- -
-

- - Process Tree -

- - - - — - + +
+ +
+
+
+ + + + + · + + + · + + + · + + + · + + + · + + + · + + + ·
-
- - MO: - + + +
+ + + +
+
+ + +
+ + + + + +
+
+ + + + + +
+ + +
+ +
+

+ Process + + · + +

+
+ + · + · + · Qty + · +
-
+
-

Loading process tree...

+

Loading process...

- -
+
- -

No manufacturing order selected. - Open this view from a production order to see its routing tree.

+ +
No manufacturing order selected.
- - -
- -

No routing steps found for this order.

+
+ +
No process steps for this order.
-
- -
- - -
- - -
- -
-
- - . - - -
- -
- - -
- - -
- - -
- - - -
- - -
-
-
-
- - / - (%) - -
- - -
- - -
-
- - -
- -
-
-
- -
-
-
-
-
- - / - -
-
- -
- -
+
+ +
diff --git a/fusion_plating/scripts/fp_debug_mo.py b/fusion_plating/scripts/fp_debug_mo.py new file mode 100644 index 00000000..76a4363b --- /dev/null +++ b/fusion_plating/scripts/fp_debug_mo.py @@ -0,0 +1,7 @@ +env = env # noqa +mo49 = env['mrp.production'].browse(49) +print('id=49:', mo49.name, 'state=', mo49.state, 'company=', mo49.company_id.id) +mo47 = env['mrp.production'].browse(47) +print('id=47:', mo47.name, 'state=', mo47.state, 'company=', mo47.company_id.id) +res = env['mrp.production'].search([('state', '=', 'done')], order='id desc', limit=3) +for m in res: print('got:', m.id, m.name, m.state) diff --git a/fusion_plating/scripts/fp_isolate.py b/fusion_plating/scripts/fp_isolate.py new file mode 100644 index 00000000..f9e9d266 --- /dev/null +++ b/fusion_plating/scripts/fp_isolate.py @@ -0,0 +1,13 @@ +env = env # noqa +# Same exact query the audit uses +print('attempt 1 (no sudo):') +mo = env['mrp.production'].search([('state', '=', 'done')], order='id desc', limit=1) +print(f' → {mo.name} (id {mo.id})') + +print('attempt 2 (.sudo()):') +mo2 = env['mrp.production'].sudo().search([('state', '=', 'done')], order='id desc', limit=1) +print(f' → {mo2.name} (id {mo2.id})') + +print('attempt 3 (read 5):') +mos = env['mrp.production'].sudo().search([('state', '=', 'done')], order='id desc', limit=5) +for m in mos: print(f' → {m.name} (id {m.id})') diff --git a/fusion_plating/so_list.png b/fusion_plating/so_list.png new file mode 100644 index 00000000..093ea097 Binary files /dev/null and b/fusion_plating/so_list.png differ