2 Commits

Author SHA1 Message Date
gsinghpal
f09bef9083 refactor(reports): consolidate SO Acknowledgement back into the Sales Order PDF
Earlier I built report_fp_so_acknowledgement.xml as a separate
customer-facing document. On review there was no good reason — our
existing report_fp_sale.xml already flips its title between
"Quotation" and "Sales Order" based on state, and carried ~90% of
the same content. Two documents would have meant the shop had to
remember which to send when, and the customer would get two
near-identical PDFs in their inbox.

Consolidation:

1. Merged the four unique blocks from the acknowledgement into
   report_fp_sale.xml (both portrait AND landscape variants):
   - CUSTOMER JOB # / PLANNED START / CUSTOMER DEADLINE / SHIP VIA
     info row (shown only when any of those fields is populated)
   - Blanket / block-partial highlight-box callout (shown only
     when the flags are set)
   - External notes (x_fc_external_note) block above Terms and
     Conditions

2. Deleted fusion_plating_reports/report/report_fp_so_acknowledgement.xml
   and removed it from the module manifest. Also purged the orphan
   ir.actions.report and ir.ui.view DB rows + the stale
   ir.model.data entries.

3. Re-pointed the fp_mail_template_so_confirmed mail template's
   report_template_ids from the now-gone acknowledgement report to
   action_report_fp_sale_portrait. Updated hooks.py accordingly; the
   hook now uses "set" semantics (replace all) instead of "add" so
   re-running it cleans up stale attachments from prior refactors.

4. UAT on S00071: the Send button pre-selects the FP: Order
   Confirmation template with SalesOrder_S00071.pdf attached. The
   PDF renders with the new plating rows populated — Customer Job #
   AMPH-2026-0420-01, Customer Deadline 05/14/2026 08:00:00 PM,
   "Partial shipments blocked" callout, all lines + totals.

One PDF, one Send button behaviour, matching what Odoo and most
ERP systems do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:30:06 -04:00
gsinghpal
54e56ed0e6 changes 2026-04-20 01:16:12 -04:00
42 changed files with 5696 additions and 1405 deletions

View File

@@ -0,0 +1,133 @@
<h2>Recommended Hybrid: A + B's escape hatch</h2>
<p class="subtitle">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.</p>
<div class="mockup">
<div class="mockup-header">Bank Reconciliation — Account: RBC Operating · 487 unreconciled</div>
<div class="mockup-body" style="padding:14px;font-family:-apple-system,sans-serif;font-size:13px;background:#f3f4f6">
<div style="background:#fff;border:1px solid #d8dadd;border-radius:8px;margin-bottom:10px;overflow:hidden">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px">
<div>
<div style="font-weight:600">Apr 12 — RBC e-transfer</div>
<div style="color:#666;font-size:12px;margin-top:2px">Cheque 4827 · Westin Plating Co · <strong>$1,847.50 CAD</strong></div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<div style="background:#22c55e;color:#fff;padding:4px 10px;border-radius:14px;font-size:11px;font-weight:700;letter-spacing:0.3px">92% MATCH</div>
</div>
</div>
<div style="padding:10px 14px;background:#f0fdf4;border-top:1px solid #d1fae5;display:flex;justify-content:space-between;align-items:center">
<div style="font-size:12px;color:#166534">
💡 <strong>INV/2026/00123</strong> — Westin Plating Co — $1,847.50
</div>
<div style="display:flex;gap:6px">
<button style="background:#22c55e;color:#fff;border:none;padding:5px 12px;border-radius:5px;font-size:11px;font-weight:600;cursor:pointer">Accept</button>
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 10px;border-radius:5px;font-size:11px;cursor:pointer">Reject</button>
<button style="background:transparent;color:#666;border:none;padding:5px 8px;font-size:11px;cursor:pointer;text-decoration:underline">Show 2 alternatives</button>
</div>
</div>
</div>
<div style="background:#fff;border:1px solid #fde68a;border-radius:8px;margin-bottom:10px;overflow:hidden">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px">
<div>
<div style="font-weight:600">Apr 12 — RBC payment</div>
<div style="color:#666;font-size:12px;margin-top:2px">Cheque 4828 · partner unknown · <strong>$1,800.00 CAD</strong></div>
</div>
<div style="background:#f59e0b;color:#fff;padding:4px 10px;border-radius:14px;font-size:11px;font-weight:700">68% MATCH</div>
</div>
<div style="padding:10px 14px;background:#fffbeb;border-top:1px solid #fde68a;display:flex;justify-content:space-between;align-items:center">
<div style="font-size:12px;color:#92400e">
💡 <strong>INV/2026/00098</strong> — Westin Plating Co — $1,800.00 · <em style="color:#a16207">amount matches but partner unconfirmed</em>
</div>
<div style="display:flex;gap:6px">
<button style="background:#f59e0b;color:#fff;border:none;padding:5px 12px;border-radius:5px;font-size:11px;font-weight:600;cursor:pointer">Accept</button>
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 10px;border-radius:5px;font-size:11px;cursor:pointer">Reject</button>
<button style="background:transparent;color:#666;border:none;padding:5px 8px;font-size:11px;cursor:pointer;text-decoration:underline">Show 4 alternatives</button>
</div>
</div>
</div>
<div style="background:#fff;border:1px solid #d8dadd;border-radius:8px;margin-bottom:10px;overflow:hidden">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px">
<div>
<div style="font-weight:600">Apr 11 — Visa adjustment</div>
<div style="color:#666;font-size:12px;margin-top:2px">Ref VSA-201 · Royal Bank fees · <strong>$89.99 CAD</strong></div>
</div>
<div style="background:#94a3b8;color:#fff;padding:4px 10px;border-radius:14px;font-size:11px;font-weight:700">NO MATCH</div>
</div>
<div style="padding:8px 14px;background:#f8fafc;border-top:1px solid #e2e8f0;display:flex;gap:6px;justify-content:flex-end">
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 10px;border-radius:5px;font-size:11px;cursor:pointer">Reconcile manually</button>
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 10px;border-radius:5px;font-size:11px;cursor:pointer">Apply rule</button>
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 10px;border-radius:5px;font-size:11px;cursor:pointer">Write off</button>
</div>
</div>
<div style="background:#fff;border:2px solid #22c55e;border-radius:8px;margin-bottom:10px;overflow:hidden">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:#f0fdf4">
<div>
<div style="font-weight:600">Apr 11 — RBC bulk deposit</div>
<div style="color:#666;font-size:12px;margin-top:2px">Ref 9921-D · Westin Plating Co · <strong>$3,200.00 CAD</strong></div>
</div>
<div style="background:#22c55e;color:#fff;padding:4px 10px;border-radius:14px;font-size:11px;font-weight:700">98% MATCH (alternatives expanded)</div>
</div>
<div style="background:#f8fafc;padding:10px 14px;border-top:1px solid #d1fae5">
<div style="font-size:11px;color:#666;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.4px;font-weight:600">AI suggestions, ranked</div>
<div style="background:#fff;border:1px solid #22c55e;border-radius:6px;padding:8px 10px;margin-bottom:5px;display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-size:12px;font-weight:600;color:#166534">98% — INV/2026/00145 — $3,200.00 · Westin Plating Co</div>
<div style="font-size:11px;color:#666;margin-top:2px">Exact amount + same partner + invoice date Apr 8 · 4 prior reconciles match this pattern</div>
</div>
<button style="background:#22c55e;color:#fff;border:none;padding:5px 14px;border-radius:5px;font-size:11px;font-weight:600;cursor:pointer">Accept</button>
</div>
<div style="background:#fff;border:1px solid #fde68a;border-radius:6px;padding:8px 10px;margin-bottom:5px;display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-size:12px;font-weight:600;color:#92400e">71% — INV/2026/00141 — $3,200.00 · Bramalea Lift Co</div>
<div style="font-size:11px;color:#666;margin-top:2px">Amount matches, partner is a different client</div>
</div>
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 12px;border-radius:5px;font-size:11px;cursor:pointer">Use this</button>
</div>
<div style="background:#fff;border:1px solid #d8dadd;border-radius:6px;padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-size:12px;font-weight:600;color:#666">62% — INV/2026/00139 + INV/2026/00140 (combined) — Westin Plating Co</div>
<div style="font-size:11px;color:#666;margin-top:2px">Two invoices summing to $3,200.00</div>
</div>
<button style="background:#fff;color:#666;border:1px solid #d8dadd;padding:5px 12px;border-radius:5px;font-size:11px;cursor:pointer">Use this</button>
</div>
<div style="margin-top:6px"><button style="background:transparent;color:#666;border:none;padding:4px;font-size:11px;cursor:pointer;text-decoration:underline">Hide alternatives</button></div>
</div>
</div>
<div style="background:#fff;border:1px solid #d8dadd;border-radius:8px;padding:8px 14px;text-align:center">
<button style="background:#22c55e;color:#fff;border:none;padding:8px 22px;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer">Accept all 47 high-confidence (≥95%)</button>
<span style="color:#666;font-size:11px;margin-left:10px">·</span>
<span style="color:#666;font-size:11px;margin-left:8px">487 lines unreconciled · 47 ready to auto-accept · 134 need review · 306 no AI match</span>
</div>
</div>
</div>
<p class="subtitle">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.</p>
<div class="options">
<div class="option" data-choice="approve" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>Looks right — proceed with this hybrid</h3>
<p>I'll capture this as the default UI design in the spec. Specific colour choices and exact pixel spacing get refined during implementation.</p>
</div>
</div>
<div class="option" data-choice="adjust" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>Mostly right but I want changes</h3>
<p>Tell me in the terminal what to adjust (positions, colours, button labels, missing actions, etc.).</p>
</div>
</div>
<div class="option" data-choice="back_to_pure_a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Just pure A, no alternatives panel</h3>
<p>Keep it simple — single suggestion per line, no expand. If user disagrees with AI they go to the manual reconcile dialog.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,101 @@
<h2>AI Suggestion Placement</h2>
<p class="subtitle">You picked "AI assistive" — now: how does the AI suggestion appear on each unreconciled bank line? Three layouts:</p>
<div class="cards" data-multiselect>
<div class="card" data-choice="badge_inline" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">Layout A — Inline Badge</div>
<div class="mockup-body" style="padding:12px;font-family:monospace;font-size:13px;line-height:1.7">
<div style="border:1px solid #d8dadd;padding:10px;border-radius:6px;background:#fff">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:600">Apr 12 — RBC ETF deposit</div>
<div style="color:#666;font-size:12px">Cheque ref 4827 · $1,847.50 CAD</div>
</div>
<div style="background:#22c55e;color:#fff;padding:3px 8px;border-radius:12px;font-size:11px;font-weight:600">92% MATCH</div>
</div>
<div style="margin-top:8px;padding:6px;background:#f0fdf4;border-left:3px solid #22c55e;font-size:12px;color:#166534">
💡 Invoice <strong>INV/2026/00123</strong> — Westin Plating Co — $1,847.50 · <a href="#" style="color:#22c55e">Accept</a> · <a href="#" style="color:#666">Reject</a>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A — Inline Badge + Suggestion Strip</h3>
<p>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.</p>
</div>
</div>
<div class="card" data-choice="side_panel" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">Layout B — Side Panel</div>
<div class="mockup-body" style="padding:12px;font-family:monospace;font-size:12px">
<div style="display:flex;gap:8px;height:200px">
<div style="flex:1;border:1px solid #d8dadd;border-radius:6px;background:#fff;padding:8px">
<div style="font-weight:600;margin-bottom:6px">Bank lines</div>
<div style="background:#dbeafe;padding:6px;border-radius:4px;margin-bottom:4px;font-size:11px">Apr 12 RBC $1,847.50 ✓ selected</div>
<div style="padding:6px;font-size:11px;color:#666">Apr 12 RBC $245.00</div>
<div style="padding:6px;font-size:11px;color:#666">Apr 11 Visa $89.99</div>
<div style="padding:6px;font-size:11px;color:#666">Apr 11 RBC $3,200.00</div>
</div>
<div style="width:200px;border:1px solid #d8dadd;border-radius:6px;background:#f8fafc;padding:8px">
<div style="font-weight:600;font-size:11px;margin-bottom:6px">AI Suggestions</div>
<div style="padding:6px;background:#fff;border-radius:4px;margin-bottom:4px;font-size:10px">
<div style="color:#22c55e;font-weight:600">92% INV/2026/00123</div>
<div style="color:#666">Westin Plating $1,847.50</div>
</div>
<div style="padding:6px;background:#fff;border-radius:4px;font-size:10px">
<div style="color:#f59e0b;font-weight:600">68% INV/2026/00098</div>
<div style="color:#666">Westin Plating $1,800.00</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B — Dedicated Side Panel</h3>
<p>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.</p>
</div>
</div>
<div class="card" data-choice="hover_only" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">Layout C — Hover Reveal</div>
<div class="mockup-body" style="padding:12px;font-family:monospace;font-size:13px;line-height:1.7">
<div style="border:1px solid #d8dadd;padding:10px;border-radius:6px;background:#fff;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:600">Apr 12 — RBC ETF deposit</div>
<div style="color:#666;font-size:12px">Cheque ref 4827 · $1,847.50 CAD</div>
</div>
<div style="display:flex;align-items:center;gap:6px">
<div style="width:8px;height:8px;background:#22c55e;border-radius:50%"></div>
<div style="color:#666;font-size:11px;font-style:italic">hover for AI</div>
</div>
</div>
</div>
<div style="border:1px solid #22c55e;padding:10px;border-radius:6px;background:#f0fdf4;box-shadow:0 4px 12px rgba(0,0,0,0.08)">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:600">Apr 12 — RBC e-transfer</div>
<div style="color:#166534;font-size:12px">💡 92% match: INV/2026/00123 — $1,847.50 · <a href="#" style="color:#22c55e">Accept</a></div>
</div>
<div style="background:#22c55e;color:#fff;padding:3px 8px;border-radius:12px;font-size:11px">92%</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C — Hover-to-Reveal</h3>
<p>Just a confidence dot on each line. AI details appear on hover/click. Cleanest visual, most Enterprise-like density. Slowest discovery for new users.</p>
</div>
</div>
</div>
<p class="subtitle">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.</p>

View File

@@ -0,0 +1,29 @@
<h2>Phase 1 — Bank Reconciliation</h2>
<p class="subtitle">Brainstorming session for the next sub-module: <code>fusion_accounting_bank_rec</code></p>
<div class="section">
<h3>What we're designing</h3>
<p>A native bank-rec widget that replaces Odoo Enterprise's <code>account_accountant</code> bank reconciliation, using Odoo 19's frontend OWL architecture. It reads/writes the same <code>account.partial.reconcile</code> tables Community owns, so existing reconciliations are immune to Enterprise uninstall (verified empirically in Phase 0).</p>
</div>
<div class="section">
<h3>Reference material I've already scanned</h3>
<ul>
<li><strong>Roadmap design</strong> Section 4.3 — Phase 1 scope, exit criteria</li>
<li><strong>Enterprise V19 source</strong> at <code>RePackaged-Odoo/accounting/account_accountant/</code> — 17 OWL components in <code>static/src/components/bank_reconciliation/</code>, 1 service file (140 lines), 3 inherits on community models, 2 wizards</li>
<li><strong>Phase 0 BankRecAdapter</strong> — already present at <code>fusion_accounting_ai/services/data_adapters/bank_rec.py</code> with a stub <code>list_unreconciled_via_fusion()</code> waiting to be filled in</li>
</ul>
</div>
<div class="section">
<h3>How this session works</h3>
<ol>
<li>I ask clarifying questions one at a time (terminal for scope/concept, this browser for layout/visual)</li>
<li>I propose 2-3 architectural approaches with tradeoffs</li>
<li>We work through the design section by section</li>
<li>I write the spec doc and you approve it</li>
<li>Then we transition to writing the implementation plan</li>
</ol>
</div>
<p class="subtitle">Continuing in terminal for the first question...</p>

View File

@@ -0,0 +1,4 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;flex-direction:column;gap:16px">
<p class="subtitle">UI layout approved ✓</p>
<p class="subtitle">Continuing in terminal — next sections are about file structure, reconcile engine algorithms, and migration. Browser will return for any further visual decisions.</p>
</div>

View File

@@ -0,0 +1,4 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;flex-direction:column;gap:16px">
<p class="subtitle">Spec approved ✓ — committed as <code>2d64f7e</code></p>
<p class="subtitle">Now writing the Phase 1 implementation plan in terminal. Browser session can be closed; the visual companion isn't needed for plan-writing.</p>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776605003749}

View File

@@ -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"}

View File

@@ -0,0 +1 @@
84418

File diff suppressed because it is too large Load Diff

View File

@@ -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': {

View File

@@ -0,0 +1,386 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Recipe: ANODIZE (Sulfuric Anodize — Type II)
Source: Client's Steelhead export (April 2026 transcription).
Anodize is an umbrella workflow covering pre-treatment, the wet
anodize line (alkaline clean → etch → deoxidize → sulfuric anodize
→ hot water seal), and post-treatment (dry, unrack, de-mask).
Tree:
ANODIZE (recipe)
Ready for Solvent Clean (1) (sub-process, auto)
Solvent Clean (1) (operation, customer-visible)
Blasting (sub-process, signoff, auto)
Ready For Blast (operation)
Blast (operation, customer-visible)
Masking (sub-process, signoff, auto)
Ready For Masking (operation)
Masking (operation, customer-visible)
Racking (sub-process, auto)
Ready for Racking (operation)
Racking (operation, customer-visible)
Anodize Line (sub-process, auto)
Ready For Anodize (operation)
Alkaline Clean (Tank A1) (operation, customer-visible)
Primary Rinse (Tank A2)
Secondary Rinse (Tank A4)
Etch (Tank A3) (operation, customer-visible)
Primary Rinse (Tank A4)
Secondary Rinse (Tank A6)
Deoxidize (Tank A5) (operation, customer-visible)
Primary Rinse (Tank A6)
Secondary Rinse (Tank A8)
Sulfuric Anodize (operation, auto)
Sulfuric Anodize Ramp (Tank A9)
Sulfuric Anodize (Tank A9) (operation, signoff, customer-visible)
Primary Rinse (Tank A8)
Secondary Rinse (Tank A12)
Hot Rinse (Tank A17)
Hot Water Seal (Tank A16) (operation, customer-visible)
Primary Rinse (Tank A12)
Hot Rinse (Tank A17) (customer-visible)
Anodize Dry (sub-process)
Unracking (sub-process, auto)
Ready for Unrack (operation)
Unracking (operation, customer-visible)
De-Masking (sub-process, signoff, auto)
Ready for De-Masking (operation)
De-Masking (operation, customer-visible)
-->
<odoo>
<data noupdate="0">
<!-- ========================= ROOT ========================= -->
<record id="recipe_anodize" model="fusion.plating.process.node">
<field name="name">Anodize</field>
<field name="code">ANODIZE</field>
<field name="node_type">recipe</field>
<field name="icon">fa-flask</field>
<field name="sequence">50</field>
<field name="auto_complete">True</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 1. Ready for Solvent Clean (1) ========================= -->
<record id="anodize_ready_solvent_clean" model="fusion.plating.process.node">
<field name="name">Ready for Solvent Clean (1)</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-shower</field>
<field name="sequence">10</field>
<field name="auto_complete">True</field>
</record>
<record id="anodize_solvent_clean" model="fusion.plating.process.node">
<field name="name">Solvent Clean (1)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_ready_solvent_clean"/>
<field name="icon">fa-shower</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 2. Blasting ========================= -->
<record id="anodize_blasting" model="fusion.plating.process.node">
<field name="name">Blasting</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-bullseye</field>
<field name="sequence">20</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
</record>
<record id="anodize_blasting_ready" model="fusion.plating.process.node">
<field name="name">Ready For Blast</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_blasting"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="anodize_blasting_do" model="fusion.plating.process.node">
<field name="name">Blast</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_blasting"/>
<field name="icon">fa-bullseye</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 3. Masking ========================= -->
<record id="anodize_masking" model="fusion.plating.process.node">
<field name="name">Masking</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">30</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
</record>
<record id="anodize_masking_ready" model="fusion.plating.process.node">
<field name="name">Ready For Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_masking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="anodize_masking_do" model="fusion.plating.process.node">
<field name="name">Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_masking"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 4. Racking ========================= -->
<record id="anodize_racking" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-th</field>
<field name="sequence">40</field>
<field name="auto_complete">True</field>
</record>
<record id="anodize_racking_ready" model="fusion.plating.process.node">
<field name="name">Ready for Racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_racking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="anodize_racking_do" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_racking"/>
<field name="icon">fa-th</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 5. Anodize Line (sub-process) ========================= -->
<record id="anodize_line" model="fusion.plating.process.node">
<field name="name">Anodize Line</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-industry</field>
<field name="sequence">50</field>
<field name="auto_complete">True</field>
</record>
<!-- 5a. Ready For Anodize -->
<record id="anodize_line_ready" model="fusion.plating.process.node">
<field name="name">Ready For Anodize</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_line"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<!-- 5b. Alkaline Clean (Tank A-1) -->
<record id="anodize_alkaline_clean" model="fusion.plating.process.node">
<field name="name">Alkaline Clean (Tank A-1)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_line"/>
<field name="icon">fa-shower</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<record id="anodize_alkaline_primary_rinse" model="fusion.plating.process.node">
<field name="name">Primary Rinse (Tank A-2)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_alkaline_clean"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="anodize_alkaline_secondary_rinse" model="fusion.plating.process.node">
<field name="name">Secondary Rinse (Tank A-4)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_alkaline_clean"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<!-- 5c. Etch (Tank A-3) -->
<record id="anodize_etch" model="fusion.plating.process.node">
<field name="name">Etch (Tank A-3)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_line"/>
<field name="icon">fa-flask</field>
<field name="sequence">30</field>
<field name="customer_visible">True</field>
</record>
<record id="anodize_etch_primary_rinse" model="fusion.plating.process.node">
<field name="name">Primary Rinse (Tank A-4)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_etch"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="anodize_etch_secondary_rinse" model="fusion.plating.process.node">
<field name="name">Secondary Rinse (Tank A-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_etch"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<!-- 5d. Deoxidize (Tank A-5) -->
<record id="anodize_deoxidize" model="fusion.plating.process.node">
<field name="name">Deoxidize (Tank A-5)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_line"/>
<field name="icon">fa-flask</field>
<field name="sequence">40</field>
<field name="customer_visible">True</field>
</record>
<record id="anodize_deoxidize_primary_rinse" model="fusion.plating.process.node">
<field name="name">Primary Rinse (Tank A-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_deoxidize"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="anodize_deoxidize_secondary_rinse" model="fusion.plating.process.node">
<field name="name">Secondary Rinse (Tank A-8)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_deoxidize"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<!-- 5e. Sulfuric Anodize (operation container) -->
<record id="anodize_sulfuric" model="fusion.plating.process.node">
<field name="name">Sulfuric Anodize</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_line"/>
<field name="icon">fa-bolt</field>
<field name="sequence">50</field>
<field name="auto_complete">True</field>
</record>
<record id="anodize_sulfuric_ramp" model="fusion.plating.process.node">
<field name="name">Sulfuric Anodize Ramp (Tank A-9)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_sulfuric"/>
<field name="icon">fa-bolt</field>
<field name="sequence">10</field>
</record>
<record id="anodize_sulfuric_do" model="fusion.plating.process.node">
<field name="name">Sulfuric Anodize (Tank A-9)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_sulfuric"/>
<field name="icon">fa-bolt</field>
<field name="sequence">20</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
</record>
<record id="anodize_sulfuric_do_primary_rinse" model="fusion.plating.process.node">
<field name="name">Primary Rinse (Tank A-8)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_sulfuric_do"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="anodize_sulfuric_do_secondary_rinse" model="fusion.plating.process.node">
<field name="name">Secondary Rinse (Tank A-12)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_sulfuric_do"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<record id="anodize_sulfuric_do_hot_rinse" model="fusion.plating.process.node">
<field name="name">Hot Rinse (Tank A-17)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_sulfuric_do"/>
<field name="icon">fa-thermometer-half</field>
<field name="sequence">30</field>
</record>
<!-- 5f. Hot Water Seal (Tank A-16) -->
<record id="anodize_hot_water_seal" model="fusion.plating.process.node">
<field name="name">Hot Water Seal (Tank A-16)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_line"/>
<field name="icon">fa-tint</field>
<field name="sequence">60</field>
<field name="customer_visible">True</field>
</record>
<record id="anodize_hot_water_seal_primary_rinse" model="fusion.plating.process.node">
<field name="name">Primary Rinse (Tank A-12)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_hot_water_seal"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="anodize_hot_water_seal_hot_rinse" model="fusion.plating.process.node">
<field name="name">Hot Rinse (Tank A-17)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="anodize_hot_water_seal"/>
<field name="icon">fa-thermometer-half</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 6. Anodize Dry ========================= -->
<record id="anodize_dry" model="fusion.plating.process.node">
<field name="name">Anodize Dry</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-sun-o</field>
<field name="sequence">60</field>
</record>
<!-- ========================= 7. Unracking ========================= -->
<record id="anodize_unracking" model="fusion.plating.process.node">
<field name="name">Unracking</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-th</field>
<field name="sequence">70</field>
<field name="auto_complete">True</field>
</record>
<record id="anodize_unracking_ready" model="fusion.plating.process.node">
<field name="name">Ready for Unrack</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_unracking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="anodize_unracking_do" model="fusion.plating.process.node">
<field name="name">Unracking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_unracking"/>
<field name="icon">fa-th</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 8. De-Masking ========================= -->
<record id="anodize_demasking" model="fusion.plating.process.node">
<field name="name">De-Masking</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_anodize"/>
<field name="icon">fa-eraser</field>
<field name="sequence">80</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
</record>
<record id="anodize_demasking_ready" model="fusion.plating.process.node">
<field name="name">Ready for De-Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_demasking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="anodize_demasking_do" model="fusion.plating.process.node">
<field name="name">De-Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="anodize_demasking"/>
<field name="icon">fa-eraser</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,266 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Recipe: CHEM_CONVERSION (Chemical Conversion Process - Trivalent Chromate)
Source: Client's Steelhead export (April 2026 transcription).
Chemical conversion coating for aluminium (MIL-DTL-5541 Type II /
Trivalent chromate). Optional Nickel Strip lane handles stripping
re-work parts that had previous EN plating.
Tree:
CHEM_CONVERSION (recipe)
Racking (sub-process, auto)
Ready for Racking (operation)
Racking (operation, customer-visible)
Chemical Conversion (sub-process, auto)
Ready For Chemical Conversion (operation)
Drying (operation)
Soak Clean (A-1) (operation, customer-visible)
Rinse (A-2)
Etch (A-3) (operation, customer-visible)
Rinse (A-2)
Rinse (A-4)
Desmutter (A-5) (operation, customer-visible)
Rinse (A-4)
Rinse (A-6)
Trivalent Chromate Conversion (A-14 / A) (operation, signoff, customer-visible)
Rinse (A-15)
Plug The Threaded Holes (sub-process, signoff)
Nickel Strip - Aluminum Line (sub-process, signoff, auto)
Strip Process - AL (operation, auto)
Nitric Acid (A-13 / SP-10)
Final Rinse (A-14 / SP-11)
Drying (operation)
Post Stripping Inspection (operation, auto)
Ready for Post Stripping Inspection
Post Stripping Inspection (customer-visible)
-->
<odoo>
<data noupdate="0">
<!-- ========================= ROOT ========================= -->
<record id="recipe_chem_conversion" model="fusion.plating.process.node">
<field name="name">Chemical Conversion Process</field>
<field name="code">CHEM_CONVERSION</field>
<field name="node_type">recipe</field>
<field name="icon">fa-flask</field>
<field name="sequence">60</field>
<field name="auto_complete">True</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 1. Racking ========================= -->
<record id="cc_racking" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_chem_conversion"/>
<field name="icon">fa-th</field>
<field name="sequence">10</field>
<field name="auto_complete">True</field>
</record>
<record id="cc_racking_ready" model="fusion.plating.process.node">
<field name="name">Ready for Racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_racking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="cc_racking_do" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_racking"/>
<field name="icon">fa-th</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 2. Chemical Conversion (sub-process) ========================= -->
<record id="cc_chem_conversion" model="fusion.plating.process.node">
<field name="name">Chemical Conversion</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_chem_conversion"/>
<field name="icon">fa-industry</field>
<field name="sequence">20</field>
<field name="auto_complete">True</field>
</record>
<record id="cc_chem_ready" model="fusion.plating.process.node">
<field name="name">Ready For Chemical Conversion</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_chem_conversion"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="cc_chem_drying" model="fusion.plating.process.node">
<field name="name">Drying</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_chem_conversion"/>
<field name="icon">fa-sun-o</field>
<field name="sequence">20</field>
</record>
<!-- 2a. Soak Clean (A-1) -->
<record id="cc_soak_clean" model="fusion.plating.process.node">
<field name="name">Soak Clean (A-1)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_chem_conversion"/>
<field name="icon">fa-shower</field>
<field name="sequence">30</field>
<field name="customer_visible">True</field>
</record>
<record id="cc_soak_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (A-2)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_soak_clean"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<!-- 2b. Etch (A-3) -->
<record id="cc_etch" model="fusion.plating.process.node">
<field name="name">Etch (A-3)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_chem_conversion"/>
<field name="icon">fa-flask</field>
<field name="sequence">40</field>
<field name="customer_visible">True</field>
</record>
<record id="cc_etch_rinse1" model="fusion.plating.process.node">
<field name="name">Rinse (A-2)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_etch"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="cc_etch_rinse2" model="fusion.plating.process.node">
<field name="name">Rinse (A-4)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_etch"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<!-- 2c. Desmutter (A-5) -->
<record id="cc_desmutter" model="fusion.plating.process.node">
<field name="name">Desmutter (A-5)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_chem_conversion"/>
<field name="icon">fa-flask</field>
<field name="sequence">50</field>
<field name="customer_visible">True</field>
</record>
<record id="cc_desmutter_rinse1" model="fusion.plating.process.node">
<field name="name">Rinse (A-4)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_desmutter"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<record id="cc_desmutter_rinse2" model="fusion.plating.process.node">
<field name="name">Rinse (A-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_desmutter"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<!-- 2d. Trivalent Chromate Conversion (A-14 / A) -->
<record id="cc_trivalent" model="fusion.plating.process.node">
<field name="name">Trivalent Chromate Conversion (A-14 / A)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_chem_conversion"/>
<field name="icon">fa-diamond</field>
<field name="sequence">60</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
</record>
<record id="cc_trivalent_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (A-15)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_trivalent"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
</record>
<!-- ========================= 3. Plug The Threaded Holes ========================= -->
<record id="cc_plug_threaded" model="fusion.plating.process.node">
<field name="name">Plug The Threaded Holes</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_chem_conversion"/>
<field name="icon">fa-shield</field>
<field name="sequence">30</field>
<field name="requires_signoff">True</field>
</record>
<!-- ========================= 4. Nickel Strip - Aluminum Line ========================= -->
<record id="cc_ni_strip" model="fusion.plating.process.node">
<field name="name">Nickel Strip - Aluminum Line</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_chem_conversion"/>
<field name="icon">fa-eraser</field>
<field name="sequence">40</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
</record>
<!-- 4a. Strip Process - AL -->
<record id="cc_ni_strip_process" model="fusion.plating.process.node">
<field name="name">Strip Process - AL</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_ni_strip"/>
<field name="icon">fa-flask</field>
<field name="sequence">10</field>
<field name="auto_complete">True</field>
</record>
<record id="cc_ni_strip_nitric" model="fusion.plating.process.node">
<field name="name">Nitric Acid (A-13 / SP-10)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_ni_strip_process"/>
<field name="icon">fa-flask</field>
<field name="sequence">10</field>
</record>
<record id="cc_ni_strip_final_rinse" model="fusion.plating.process.node">
<field name="name">Final Rinse (A-14 / SP-11)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_ni_strip_process"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
</record>
<!-- 4b. Drying -->
<record id="cc_ni_strip_drying" model="fusion.plating.process.node">
<field name="name">Drying</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_ni_strip"/>
<field name="icon">fa-sun-o</field>
<field name="sequence">20</field>
</record>
<!-- 4c. Post Stripping Inspection -->
<record id="cc_ni_strip_inspect" model="fusion.plating.process.node">
<field name="name">Post Stripping Inspection</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="cc_ni_strip"/>
<field name="icon">fa-search</field>
<field name="sequence">30</field>
<field name="auto_complete">True</field>
</record>
<record id="cc_ni_strip_inspect_ready" model="fusion.plating.process.node">
<field name="name">Ready for Post Stripping Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_ni_strip_inspect"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
</record>
<record id="cc_ni_strip_inspect_do" model="fusion.plating.process.node">
<field name="name">Post Stripping Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="cc_ni_strip_inspect"/>
<field name="icon">fa-eye</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,577 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Recipe: ENP-SP (Electroless Nickel Plating - Special Process)
Source: Client's Steelhead export (April 2026 transcription).
Notes:
- Steelhead allows a node to appear at multiple positions in the
same recipe ("occurrence #2"). Our parent_id model is strict
single-parent so we duplicate the node — see "Oven baking"
appearing first as #1 and again later in the flow.
- The Electroless Nickel Plating sub-process holds the wet line;
everything inside it is a separate plating step (cleaner →
activation → strike → plate → drying).
Tree:
ENP-SP (recipe)
├── Oven baking (op, signoff, auto) — first oven cycle
│ ├── Ready for bake
│ └── Bake (customer-visible)
├── Adhesion Test Coupon (op, opt-out)
├── Blasting (op, signoff, auto)
│ ├── Ready For Blast
│ └── Blast (customer-visible)
├── Masking (op, signoff, auto)
│ ├── Ready For Masking
│ └── Masking (customer-visible)
├── Racking (op, auto)
│ ├── Ready for racking
│ └── Racking (customer-visible)
├── Ready For Plating (op)
├── Electroless Nickel Plating (sub-process, auto)
│ ├── Soak Clean (SP-1)
│ │ ├── ElectroClean (SP-1)
│ │ └── Rinse (SP-2)
│ ├── HCl Activation (SP-3)
│ │ └── Rinse (SP-6)
│ ├── Woods Nickel Strike (SP-5) (signoff)
│ │ └── Rinse (SP-6)
│ ├── E-Nickel Plate (Mid-Phos) (SP-7) (signoff)
│ │ └── Rinse (SP-11)
│ ├── E-Nickel Plate (Hi-Phos) (SP-8) (opt-out)
│ │ └── Rinse (SP-11)
│ └── Drying
├── Oven baking (op, signoff, auto) — second oven cycle (#2)
│ ├── Ready for bake
│ └── Bake (customer-visible)
├── De-racking (op, auto)
│ ├── Ready For DeRacking
│ └── DeRacking (customer-visible)
├── De-Masking (op, signoff, auto)
│ ├── Ready for De-Masking
│ └── De-Masking
├── Oven bake (Post de-rack) (op, opt-out, auto)
│ ├── Ready for bake
│ └── Bake (customer-visible)
├── Adhesion Testing (op, opt-out)
├── Post Plate Inspection (op, auto)
│ ├── Ready For Post Plate Inspection
│ └── Post Plate Inspection (customer-visible)
├── Salt Spray Masking (op, signoff, auto)
│ ├── Ready for Salt Spray Masking
│ └── Salt Spray Masking
├── Corrosion Testing (op, signoff, auto)
│ ├── Corrosion Testing
│ └── Corrosion Test Inspection
└── Lab Testing (op, signoff, auto)
├── Lab Testing
└── Lab Testing Results
-->
<odoo>
<data noupdate="0">
<!-- ========================= ROOT ========================= -->
<record id="recipe_enp_sp" model="fusion.plating.process.node">
<field name="name">ENP-SP</field>
<field name="code">ENP_SP_BASIC</field>
<field name="node_type">recipe</field>
<field name="icon">fa-flask</field>
<field name="sequence">30</field>
<field name="auto_complete">True</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 1. Pre-plate Bake (Stress Relief) =========================
Done before plating on high-strength steel to relieve residual
machining/forming stresses (ASTM B850 / AMS 2759). -->
<record id="enp_sp_oven_baking_1" model="fusion.plating.process.node">
<field name="name">Pre-plate Bake (Stress Relief)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-fire</field>
<field name="sequence">10</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sp_oven_baking_1_ready" model="fusion.plating.process.node">
<field name="name">Ready for bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_oven_baking_1"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_oven_baking_1_do" model="fusion.plating.process.node">
<field name="name">Bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_oven_baking_1"/>
<field name="icon">fa-fire</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 2. Adhesion Test Coupon ========================= -->
<record id="enp_sp_adhesion_coupon" model="fusion.plating.process.node">
<field name="name">Adhesion Test Coupon</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-cube</field>
<field name="sequence">20</field>
<field name="opt_in_out">opt_out</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 3. Blasting ========================= -->
<record id="enp_sp_blasting" model="fusion.plating.process.node">
<field name="name">Blasting</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-bullseye</field>
<field name="sequence">30</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sp_blasting_ready" model="fusion.plating.process.node">
<field name="name">Ready For Blast</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_blasting"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_blasting_do" model="fusion.plating.process.node">
<field name="name">Blast</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_blasting"/>
<field name="icon">fa-bullseye</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 4. Masking ========================= -->
<record id="enp_sp_masking" model="fusion.plating.process.node">
<field name="name">Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">40</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sp_masking_ready" model="fusion.plating.process.node">
<field name="name">Ready For Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_masking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_masking_do" model="fusion.plating.process.node">
<field name="name">Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_masking"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 5. Racking ========================= -->
<record id="enp_sp_racking" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-th</field>
<field name="sequence">50</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sp_racking_ready" model="fusion.plating.process.node">
<field name="name">Ready for racking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_racking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_racking_do" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_racking"/>
<field name="icon">fa-th</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 6. Ready For Plating ========================= -->
<record id="enp_sp_ready_plating" model="fusion.plating.process.node">
<field name="name">Ready For Plating</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">60</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 7. Electroless Nickel Plating (sub-process) ========================= -->
<record id="enp_sp_enp_line" model="fusion.plating.process.node">
<field name="name">Electroless Nickel Plating</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-industry</field>
<field name="sequence">70</field>
<field name="auto_complete">True</field>
</record>
<!-- 7a. Soak Clean (SP-1) -->
<record id="enp_sp_soak_clean" model="fusion.plating.process.node">
<field name="name">Soak Clean (SP-1)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sp_enp_line"/>
<field name="icon">fa-shower</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sp_electroclean" model="fusion.plating.process.node">
<field name="name">ElectroClean (SP-1)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_soak_clean"/>
<field name="icon">fa-bolt</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_soak_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (SP-2)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_soak_clean"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- 7b. HCl Activation (SP-3) -->
<record id="enp_sp_hcl_activation" model="fusion.plating.process.node">
<field name="name">HCl Activation (SP-3)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sp_enp_line"/>
<field name="icon">fa-flask</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sp_hcl_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (SP-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_hcl_activation"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<!-- 7c. Woods Nickel Strike (SP-5) -->
<record id="enp_sp_woods_strike" model="fusion.plating.process.node">
<field name="name">Woods Nickel Strike (SP-5)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sp_enp_line"/>
<field name="icon">fa-bolt</field>
<field name="sequence">30</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sp_woods_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (SP-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_woods_strike"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<!-- 7d. E-Nickel Plate (Mid-Phos) (SP-7) -->
<record id="enp_sp_enp_mid_phos" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (Mid-Phos) (SP-7)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sp_enp_line"/>
<field name="icon">fa-diamond</field>
<field name="sequence">40</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sp_enp_mp_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (SP-11)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_enp_mid_phos"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<!-- 7e. E-Nickel Plate (Hi-Phos) (SP-8) — opt-out -->
<record id="enp_sp_enp_hi_phos" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (Hi-Phos) (SP-8)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sp_enp_line"/>
<field name="icon">fa-diamond</field>
<field name="sequence">50</field>
<field name="opt_in_out">opt_out</field>
<field name="customer_visible">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sp_enp_hp_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (SP-11)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_enp_hi_phos"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<!-- 7f. Drying -->
<record id="enp_sp_drying" model="fusion.plating.process.node">
<field name="name">Drying</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sp_enp_line"/>
<field name="icon">fa-sun-o</field>
<field name="sequence">60</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 8. Post-plate Bake (H2 Embrittlement Relief) =========================
Drives out hydrogen absorbed during plating. Must START within
~4 hours of plate exit (ASTM B850 / AMS 2759). Parts still on
rack — de-rack happens after this bake. -->
<record id="enp_sp_oven_baking_2" model="fusion.plating.process.node">
<field name="name">Post-plate Bake (H2 Embrittlement Relief)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-fire</field>
<field name="sequence">80</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sp_oven_baking_2_ready" model="fusion.plating.process.node">
<field name="name">Ready for bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_oven_baking_2"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_oven_baking_2_do" model="fusion.plating.process.node">
<field name="name">Bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_oven_baking_2"/>
<field name="icon">fa-fire</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 9. De-racking ========================= -->
<record id="enp_sp_deracking" model="fusion.plating.process.node">
<field name="name">De-racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-th</field>
<field name="sequence">90</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sp_deracking_ready" model="fusion.plating.process.node">
<field name="name">Ready For DeRacking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_deracking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_deracking_do" model="fusion.plating.process.node">
<field name="name">DeRacking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_deracking"/>
<field name="icon">fa-hand-paper-o</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 10. De-Masking ========================= -->
<record id="enp_sp_demasking" model="fusion.plating.process.node">
<field name="name">De-Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-eraser</field>
<field name="sequence">100</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sp_demasking_ready" model="fusion.plating.process.node">
<field name="name">Ready for De-Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_demasking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_demasking_do" model="fusion.plating.process.node">
<field name="name">De-Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_demasking"/>
<field name="icon">fa-eraser</field>
<field name="sequence">20</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 11. Oven bake (Post de-rack) ========================= -->
<record id="enp_sp_oven_post" model="fusion.plating.process.node">
<field name="name">Oven bake (Post de-rack)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-fire</field>
<field name="sequence">110</field>
<field name="auto_complete">True</field>
<field name="opt_in_out">opt_out</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sp_oven_post_ready" model="fusion.plating.process.node">
<field name="name">Ready for bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_oven_post"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_oven_post_do" model="fusion.plating.process.node">
<field name="name">Bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_oven_post"/>
<field name="icon">fa-fire</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 12. Adhesion Testing ========================= -->
<record id="enp_sp_adhesion_testing" model="fusion.plating.process.node">
<field name="name">Adhesion Testing</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-check-circle</field>
<field name="sequence">120</field>
<field name="opt_in_out">opt_out</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 13. Post Plate Inspection ========================= -->
<record id="enp_sp_post_inspection" model="fusion.plating.process.node">
<field name="name">Post Plate Inspection</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-search</field>
<field name="sequence">130</field>
<field name="auto_complete">True</field>
</record>
<record id="enp_sp_post_inspection_ready" model="fusion.plating.process.node">
<field name="name">Ready For Post Plate Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_post_inspection"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_post_inspection_do" model="fusion.plating.process.node">
<field name="name">Post Plate Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_post_inspection"/>
<field name="icon">fa-search</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 14. Salt Spray Masking ========================= -->
<record id="enp_sp_salt_masking" model="fusion.plating.process.node">
<field name="name">Salt Spray Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">140</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sp_salt_masking_ready" model="fusion.plating.process.node">
<field name="name">Ready for Salt Spray Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_salt_masking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sp_salt_masking_do" model="fusion.plating.process.node">
<field name="name">Salt Spray Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_salt_masking"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">20</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 15. Corrosion Testing ========================= -->
<record id="enp_sp_corrosion_testing" model="fusion.plating.process.node">
<field name="name">Corrosion Testing</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-search</field>
<field name="sequence">150</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
</record>
<record id="enp_sp_corrosion_test_do" model="fusion.plating.process.node">
<field name="name">Corrosion Testing</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_corrosion_testing"/>
<field name="icon">fa-flask</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sp_corrosion_test_inspect" model="fusion.plating.process.node">
<field name="name">Corrosion Test Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_corrosion_testing"/>
<field name="icon">fa-search</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 16. Lab Testing ========================= -->
<record id="enp_sp_lab_testing" model="fusion.plating.process.node">
<field name="name">Lab Testing</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_sp"/>
<field name="icon">fa-flask</field>
<field name="sequence">160</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
</record>
<record id="enp_sp_lab_test_do" model="fusion.plating.process.node">
<field name="name">Lab Testing</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_lab_testing"/>
<field name="icon">fa-flask</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sp_lab_test_results" model="fusion.plating.process.node">
<field name="name">Lab Testing Results</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sp_lab_testing"/>
<field name="icon">fa-search</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,469 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Recipe: ENP-STEEL-BASIC (Electroless Nickel Plating - Steel Line)
Source: Client's Steelhead export (April 2026 transcription).
Tree:
ENP-STEEL-BASIC (recipe)
├── Blasting (op, signoff, auto)
│ ├── Ready For Blast
│ └── Blast (signoff, customer-visible)
├── Masking (op, signoff, auto)
│ ├── Ready For Masking
│ └── Masking (customer-visible)
├── Racking (op, auto)
│ ├── Ready for Racking
│ └── Racking (customer-visible)
├── Ready For Steel Line (op)
├── Steel Line (sub-process)
│ ├── Cleaner (op, auto)
│ │ ├── Soak Clean (S-3) (customer-visible)
│ │ ├── Electroclean (S-3)
│ │ └── Primary Rinse (S-4) (customer-visible)
│ ├── Acid Dip (S-5) (op, customer-visible)
│ │ ├── Primary Rinse (S-6) (customer-visible)
│ │ └── Secondary Rinse (S-8) (customer-visible)
│ ├── Nickel Strike (S-7 / SP-5) (op, signoff, customer-visible)
│ │ └── Rinse (S-8 / SP-6) (customer-visible)
│ ├── E-Nickel Plate (Mid Phos)(S-9) (op, opt-out, customer-visible)
│ │ ├── Primary Rinse (S-11) (customer-visible)
│ │ └── Hot Rinse (S-13) (customer-visible)
│ ├── E-Nickel Plate (S-10) (op, signoff, customer-visible)
│ │ ├── Primary Rinse (S-11) (customer-visible)
│ │ └── Hot Rinse (S-13) (customer-visible)
│ ├── Hot Water Porosity (A-15) (op, signoff, customer-visible)
│ └── Dry (op, customer-visible)
├── Oven baking (op, signoff, auto)
│ ├── Ready for bake
│ └── Bake (customer-visible)
├── De-racking (op, auto)
│ ├── Ready For DeRacking
│ └── DeRacking (customer-visible)
├── De-Masking (op, signoff, auto)
│ ├── Ready for De-Masking
│ └── De-Masking
├── Oven bake (Post de-rack) (op, opt-out, auto)
│ ├── Ready for bake
│ └── Bake (customer-visible)
└── Post Plate Inspection (op, auto)
├── Ready For Post Plate Inspection
└── Post Plate Inspection (customer-visible)
-->
<odoo>
<data noupdate="0">
<!-- ========================= ROOT ========================= -->
<record id="recipe_enp_steel_basic" model="fusion.plating.process.node">
<field name="name">ENP-STEEL-BASIC</field>
<field name="code">ENP_STEEL_BASIC</field>
<field name="node_type">recipe</field>
<field name="icon">fa-flask</field>
<field name="sequence">20</field>
<field name="auto_complete">True</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 1. Blasting ========================= -->
<record id="enp_sb_blasting" model="fusion.plating.process.node">
<field name="name">Blasting</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-bullseye</field>
<field name="sequence">10</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sb_blasting_ready" model="fusion.plating.process.node">
<field name="name">Ready For Blast</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_blasting"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_blasting_do" model="fusion.plating.process.node">
<field name="name">Blast</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_blasting"/>
<field name="icon">fa-bullseye</field>
<field name="sequence">20</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 2. Masking ========================= -->
<record id="enp_sb_masking" model="fusion.plating.process.node">
<field name="name">Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">20</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sb_masking_ready" model="fusion.plating.process.node">
<field name="name">Ready For Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_masking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_masking_do" model="fusion.plating.process.node">
<field name="name">Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_masking"/>
<field name="icon">fa-paint-brush</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 3. Racking ========================= -->
<record id="enp_sb_racking" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-th</field>
<field name="sequence">30</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sb_racking_ready" model="fusion.plating.process.node">
<field name="name">Ready for Racking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_racking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_racking_do" model="fusion.plating.process.node">
<field name="name">Racking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_racking"/>
<field name="icon">fa-th</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 4. Ready For Steel Line ========================= -->
<record id="enp_sb_ready_steel_line" model="fusion.plating.process.node">
<field name="name">Ready For Steel Line</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">40</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 5. Steel Line (sub-process) ========================= -->
<record id="enp_sb_steel_line" model="fusion.plating.process.node">
<field name="name">Steel Line</field>
<field name="node_type">sub_process</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-industry</field>
<field name="sequence">50</field>
<field name="auto_complete">True</field>
</record>
<!-- 5a. Cleaner -->
<record id="enp_sb_sl_cleaner" model="fusion.plating.process.node">
<field name="name">Cleaner</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-shower</field>
<field name="sequence">10</field>
<field name="auto_complete">True</field>
</record>
<record id="enp_sb_sl_soak_clean" model="fusion.plating.process.node">
<field name="name">Soak Clean (S-3)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_cleaner"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sb_sl_electroclean" model="fusion.plating.process.node">
<field name="name">Electroclean (S-3)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_cleaner"/>
<field name="icon">fa-bolt</field>
<field name="sequence">20</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_sl_primary_rinse_s4" model="fusion.plating.process.node">
<field name="name">Primary Rinse (S-4)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_cleaner"/>
<field name="icon">fa-tint</field>
<field name="sequence">30</field>
<field name="customer_visible">True</field>
</record>
<!-- 5b. Acid Dip (S-5) -->
<record id="enp_sb_sl_acid_dip" model="fusion.plating.process.node">
<field name="name">Acid Dip (S-5)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-flask</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sb_sl_acid_rinse_s6" model="fusion.plating.process.node">
<field name="name">Primary Rinse (S-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_acid_dip"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sb_sl_acid_rinse_s8" model="fusion.plating.process.node">
<field name="name">Secondary Rinse (S-8)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_acid_dip"/>
<field name="icon">fa-tint</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- 5c. Nickel Strike (S-7 / SP-5) -->
<record id="enp_sb_sl_nickel_strike" model="fusion.plating.process.node">
<field name="name">Nickel Strike (S-7 / SP-5)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-bolt</field>
<field name="sequence">30</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sb_sl_strike_rinse" model="fusion.plating.process.node">
<field name="name">Rinse (S-8 / SP-6)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_nickel_strike"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<!-- 5d. E-Nickel Plate (Mid Phos)(S-9) — opt-out (chosen per job) -->
<record id="enp_sb_sl_enp_mid_phos" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (Mid Phos)(S-9)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-diamond</field>
<field name="sequence">40</field>
<field name="opt_in_out">opt_out</field>
<field name="customer_visible">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sb_sl_enp_mp_rinse_s11" model="fusion.plating.process.node">
<field name="name">Primary Rinse (S-11)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_enp_mid_phos"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sb_sl_enp_mp_hot_rinse" model="fusion.plating.process.node">
<field name="name">Hot Rinse (S-13)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_enp_mid_phos"/>
<field name="icon">fa-thermometer-half</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- 5e. E-Nickel Plate (S-10) — primary plating -->
<record id="enp_sb_sl_enp_s10" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (S-10)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-diamond</field>
<field name="sequence">50</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sb_sl_enp_s10_rinse_s11" model="fusion.plating.process.node">
<field name="name">Primary Rinse (S-11)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_enp_s10"/>
<field name="icon">fa-tint</field>
<field name="sequence">10</field>
<field name="customer_visible">True</field>
</record>
<record id="enp_sb_sl_enp_s10_hot_rinse" model="fusion.plating.process.node">
<field name="name">Hot Rinse (S-13)</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_sl_enp_s10"/>
<field name="icon">fa-thermometer-half</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- 5f. Hot Water Porosity (A-15) -->
<record id="enp_sb_sl_hot_water_porosity" model="fusion.plating.process.node">
<field name="name">Hot Water Porosity (A-15)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-tint</field>
<field name="sequence">60</field>
<field name="requires_signoff">True</field>
<field name="customer_visible">True</field>
</record>
<!-- 5g. Dry -->
<record id="enp_sb_sl_dry" model="fusion.plating.process.node">
<field name="name">Dry</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="enp_sb_steel_line"/>
<field name="icon">fa-sun-o</field>
<field name="sequence">70</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 6. Post-plate Bake (H2 Embrittlement Relief) =========================
Drives out hydrogen absorbed during plating. Must START within
~4 hours of plate exit (ASTM B850 / AMS 2759). Parts still on
rack — de-rack happens after this bake. -->
<record id="enp_sb_oven_baking" model="fusion.plating.process.node">
<field name="name">Post-plate Bake (H2 Embrittlement Relief)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-fire</field>
<field name="sequence">60</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sb_oven_baking_ready" model="fusion.plating.process.node">
<field name="name">Ready for bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_oven_baking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_oven_baking_do" model="fusion.plating.process.node">
<field name="name">Bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_oven_baking"/>
<field name="icon">fa-fire</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 7. De-racking ========================= -->
<record id="enp_sb_deracking" model="fusion.plating.process.node">
<field name="name">De-racking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-th</field>
<field name="sequence">70</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sb_deracking_ready" model="fusion.plating.process.node">
<field name="name">Ready For DeRacking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_deracking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_deracking_do" model="fusion.plating.process.node">
<field name="name">DeRacking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_deracking"/>
<field name="icon">fa-hand-paper-o</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 8. De-Masking ========================= -->
<record id="enp_sb_demasking" model="fusion.plating.process.node">
<field name="name">De-Masking</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-eraser</field>
<field name="sequence">80</field>
<field name="auto_complete">True</field>
<field name="requires_signoff">True</field>
<field name="is_manual">True</field>
</record>
<record id="enp_sb_demasking_ready" model="fusion.plating.process.node">
<field name="name">Ready for De-Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_demasking"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_demasking_do" model="fusion.plating.process.node">
<field name="name">De-Masking</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_demasking"/>
<field name="icon">fa-eraser</field>
<field name="sequence">20</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 9. Oven bake (Post de-rack) ========================= -->
<record id="enp_sb_oven_post" model="fusion.plating.process.node">
<field name="name">Oven bake (Post de-rack)</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-fire</field>
<field name="sequence">90</field>
<field name="auto_complete">True</field>
<field name="opt_in_out">opt_out</field>
<field name="is_manual">False</field>
</record>
<record id="enp_sb_oven_post_ready" model="fusion.plating.process.node">
<field name="name">Ready for bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_oven_post"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_oven_post_do" model="fusion.plating.process.node">
<field name="name">Bake</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_oven_post"/>
<field name="icon">fa-fire</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 10. Post Plate Inspection ========================= -->
<record id="enp_sb_post_inspection" model="fusion.plating.process.node">
<field name="name">Post Plate Inspection</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_enp_steel_basic"/>
<field name="icon">fa-search</field>
<field name="sequence">100</field>
<field name="auto_complete">True</field>
</record>
<record id="enp_sb_post_inspection_ready" model="fusion.plating.process.node">
<field name="name">Ready For Post Plate Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_post_inspection"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="enp_sb_post_inspection_do" model="fusion.plating.process.node">
<field name="name">Post Plate Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="enp_sb_post_inspection"/>
<field name="icon">fa-search</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Recipe: GENERAL_PROCESSING (General Processing — common workflow umbrella)
Source: Client's Steelhead export (April 2026 transcription).
This is the "envelope" workflow that sits AROUND every job: contract
review (sales/quoting checkpoint), incoming inspection, scheduling,
final inspection, and shipping. Specific plating recipes (ENP-STEEL,
ENP-SP, ENP-ALUM, etc.) handle the technical processing in between.
Steelhead-only features that did NOT migrate (we don't have these
wired up yet — let me know if you want them):
- "Default Lead Time" on the recipe
- "Product" linkage from the recipe to a product/service record
- "Contract Review Users" — list of users who must approve
contract review before the job can advance
- Treatment Groups / "Use Price Builders" hook into pricing
Tree:
GENERAL_PROCESSING (recipe)
├── Contract Review (op, opt-out, customer-visible)
├── Incoming Inspection (op, auto)
│ ├── Ready for Incoming Inspection
│ └── Incoming Inspection (customer-visible)
├── Scheduling (op)
├── Final Inspection / Packaging (op, auto)
│ ├── Ready For Final Inspection / Packaging
│ └── Final Inspection / Packaging (customer-visible)
└── Shipping (op, auto)
├── Ready For Shipping
├── Packing Slip Created
└── Shipped (customer-visible)
-->
<odoo>
<data noupdate="0">
<!-- ========================= ROOT ========================= -->
<record id="recipe_general_processing" model="fusion.plating.process.node">
<field name="name">General Processing</field>
<field name="code">GENERAL_PROCESSING</field>
<field name="node_type">recipe</field>
<field name="icon">fa-sitemap</field>
<field name="sequence">40</field>
<field name="auto_complete">True</field>
<field name="customer_visible">False</field>
</record>
<!-- ========================= 1. Contract Review ========================= -->
<!-- Sales / quoting checkpoint. Marked opt-out so it can be
skipped on repeat orders where contract terms haven't
changed (per Steelhead's setup). -->
<record id="gp_contract_review" model="fusion.plating.process.node">
<field name="name">Contract Review</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_general_processing"/>
<field name="icon">fa-check-circle</field>
<field name="sequence">10</field>
<field name="opt_in_out">opt_out</field>
<field name="customer_visible">True</field>
<field name="is_manual">True</field>
</record>
<!-- ========================= 2. Incoming Inspection ========================= -->
<record id="gp_incoming_inspection" model="fusion.plating.process.node">
<field name="name">Incoming Inspection</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_general_processing"/>
<field name="icon">fa-search</field>
<field name="sequence">20</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="gp_incoming_ready" model="fusion.plating.process.node">
<field name="name">Ready for Incoming Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_incoming_inspection"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="gp_incoming_do" model="fusion.plating.process.node">
<field name="name">Incoming Inspection</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_incoming_inspection"/>
<field name="icon">fa-eye</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 3. Scheduling ========================= -->
<record id="gp_scheduling" model="fusion.plating.process.node">
<field name="name">Scheduling</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_general_processing"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">30</field>
<field name="customer_visible">False</field>
<field name="is_manual">True</field>
</record>
<!-- ========================= 4. Final Inspection / Packaging ========================= -->
<record id="gp_final_inspection" model="fusion.plating.process.node">
<field name="name">Final Inspection / Packaging</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_general_processing"/>
<field name="icon">fa-cube</field>
<field name="sequence">40</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="gp_final_ready" model="fusion.plating.process.node">
<field name="name">Ready For Final Inspection / Packaging</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_final_inspection"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="gp_final_do" model="fusion.plating.process.node">
<field name="name">Final Inspection / Packaging</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_final_inspection"/>
<field name="icon">fa-cube</field>
<field name="sequence">20</field>
<field name="customer_visible">True</field>
</record>
<!-- ========================= 5. Shipping ========================= -->
<!-- Steelhead shows a truck icon here; our icon list doesn't
include fa-truck so we fall back to fa-cube (the parts
leaving the door). -->
<record id="gp_shipping" model="fusion.plating.process.node">
<field name="name">Shipping</field>
<field name="node_type">operation</field>
<field name="parent_id" ref="recipe_general_processing"/>
<field name="icon">fa-cube</field>
<field name="sequence">50</field>
<field name="auto_complete">True</field>
<field name="is_manual">True</field>
</record>
<record id="gp_shipping_ready" model="fusion.plating.process.node">
<field name="name">Ready For Shipping</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_shipping"/>
<field name="icon">fa-clock-o</field>
<field name="sequence">10</field>
<field name="customer_visible">False</field>
</record>
<record id="gp_shipping_packing_slip" model="fusion.plating.process.node">
<field name="name">Packing Slip Created</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_shipping"/>
<field name="icon">fa-cube</field>
<field name="sequence">20</field>
<field name="customer_visible">False</field>
</record>
<record id="gp_shipping_shipped" model="fusion.plating.process.node">
<field name="name">Shipped</field>
<field name="node_type">step</field>
<field name="parent_id" ref="gp_shipping"/>
<field name="icon">fa-cube</field>
<field name="sequence">30</field>
<field name="customer_visible">True</field>
</record>
</data>
</odoo>

View File

@@ -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):

View File

@@ -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) {

View File

@@ -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.
-->
<templates xml:space="preserve">
<!-- ====================================================================
RECURSIVE NODE TEMPLATE
Expects: node, parentNode (or null), isFirst (bool)
==================================================================== -->
<t t-name="fusion_plating.RecipeTreeNode">
<div class="o_fp_re_node">
<!-- Card -->
<div t-att-class="'o_fp_re_card o_fp_re_type_' + node.node_type
+ (state.selectedNodeId === node.id ? ' o_fp_re_selected' : '')"
t-att-draggable="node.node_type !== 'recipe' ? 'true' : 'false'"
t-on-dragstart="(ev) => this.onNodeDragStart(node, parentNode, ev)"
t-on-dragend="(ev) => this.onNodeDragEnd(ev)"
t-on-dragover="(ev) => this.onNodeDragOver(node, ev)"
t-on-dragleave="(ev) => this.onNodeDragLeave(ev)"
t-on-drop="(ev) => this.onNodeDrop(node, parentNode, ev)"
t-on-click.stop="() => this.selectNode(node)">
<!-- Drag handle (left grip) -->
<i class="o_fp_re_handle fa fa-bars"
t-if="node.node_type !== 'recipe'"/>
<!-- Icon + name -->
<i t-attf-class="o_fp_re_icon fa #{ node.icon || 'fa-cog' }"/>
<div class="o_fp_re_card_body">
<div class="o_fp_re_title" t-esc="node.name"/>
<div class="o_fp_re_meta"
t-if="node.work_center or node.estimated_duration or node.input_count">
<span t-if="node.work_center">
<i class="fa fa-building me-1"/><t t-esc="node.work_center"/>
</span>
<span t-if="node.estimated_duration">
· <i class="fa fa-clock-o me-1"/><t t-esc="formatDuration(node.estimated_duration)"/>
</span>
<span t-if="node.input_count">
· <i class="fa fa-keyboard-o me-1"/><t t-esc="node.input_count"/>
</span>
</div>
</div>
<!-- Right side: type pill + capability icons + actions -->
<div class="o_fp_re_right">
<!-- Capability flags as small icons -->
<span class="o_fp_re_flags">
<i class="fa fa-check-square" t-if="node.requires_signoff" title="Requires sign-off"/>
<i class="fa fa-eye" t-if="node.customer_visible" title="Customer visible"/>
<i class="fa fa-magic" t-if="node.auto_complete" title="Auto-complete"/>
<i class="fa fa-bolt" t-if="!node.is_manual" title="Automated"/>
</span>
<!-- Type pill -->
<span t-attf-class="o_fp_re_type_pill o_fp_re_type_pill_#{ node.node_type }"
t-esc="getNodeTypeMeta(node.node_type).label"/>
<!-- Hover-revealed actions -->
<span class="o_fp_re_actions">
<button class="o_fp_re_btn o_fp_re_btn_add"
t-on-click.stop="() => this.startAddChild(node.id)"
title="Add child step">
<i class="fa fa-plus"/>
</button>
<button class="o_fp_re_btn o_fp_re_btn_del"
t-if="node.node_type !== 'recipe'"
t-on-click.stop="() => this.deleteNode(node.id)"
title="Delete">
<i class="fa fa-trash"/>
</button>
</span>
</div>
</div>
<!-- Children + inline-add form -->
<div class="o_fp_re_children"
t-if="(node.children and node.children.length and isExpanded(node.id)) or state.addingTo === node.id">
<t t-foreach="node.children || []" t-as="child" t-key="child.id">
<t t-call="fusion_plating.RecipeTreeNode">
<t t-set="node" t-value="child"/>
<t t-set="parentNode" t-value="node"/>
<t t-set="isFirst" t-value="false"/>
</t>
</t>
<!-- Inline add form (sits as the last child of `node`) -->
<div class="o_fp_re_node o_fp_re_add_form"
t-if="state.addingTo === node.id">
<div class="o_fp_re_card o_fp_re_card_add">
<i class="o_fp_re_icon fa fa-plus-circle"/>
<div class="o_fp_re_card_body">
<input type="text" class="o_fp_re_add_input"
placeholder="New step name..."
autofocus="autofocus"
t-att-value="state.newNodeName"
t-on-input="(ev) => { state.newNodeName = ev.target.value; }"
t-on-keydown="onAddNameKey"
t-on-click.stop=""/>
<div class="o_fp_re_add_row">
<select class="o_fp_re_add_select"
t-on-change="(ev) => { state.newNodeType = ev.target.value; }"
t-on-click.stop="">
<t t-foreach="getNodeTypeOptions()" t-as="opt" t-key="opt.value">
<option t-att-value="opt.value"
t-att-selected="state.newNodeType === opt.value"
t-esc="opt.label"/>
</t>
</select>
<button class="o_fp_re_btn o_fp_re_btn_confirm"
t-on-click.stop="confirmAdd">
<i class="fa fa-check"/>
</button>
<button class="o_fp_re_btn o_fp_re_btn_cancel"
t-on-click.stop="cancelAdd">
<i class="fa fa-times"/>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Collapsed indicator: small chip showing # of hidden children -->
<div class="o_fp_re_collapsed_chip"
t-if="node.children and node.children.length and !isExpanded(node.id)"
t-on-click.stop="() => this.toggleExpand(node.id)">
<i class="fa fa-plus-square-o me-1"/>
<t t-esc="node.children.length"/> hidden
</div>
</div>
</t>
<!-- ====================================================================
ROOT TEMPLATE
==================================================================== -->
<t t-name="fusion_plating.RecipeTreeEditor">
<div class="o_fp_recipe_editor">
<div class="o_fp_recipe_editor o_fp_re_v2">
<!-- ========== HEADER ========== -->
<div class="o_fp_recipe_header">
<div class="o_fp_recipe_header_left">
<button class="btn btn-link o_fp_recipe_back_btn"
t-on-click="onBackToList" title="Back to list">
<i class="fa fa-arrow-left me-1"/> Recipes
</button>
<h2 class="o_fp_recipe_title" t-if="state.recipe">
<i class="fa fa-flask me-2"/>
<t t-esc="state.recipe.name"/>
<span class="badge rounded-pill o_fp_recipe_version_badge ms-2"
t-if="state.recipe.version">
<div class="o_fp_re_header">
<button class="o_fp_re_back"
t-on-click="onBackToList"
title="Back to recipes">
<i class="fa fa-arrow-left me-2"/>Recipes
</button>
<div class="o_fp_re_header_title" t-if="state.recipe">
<h2 class="o_fp_re_h2 mb-0">
<i class="fa fa-flask me-2"/><t t-esc="state.recipe.name"/>
<span t-if="state.recipe.version" class="o_fp_re_ver">
v<t t-esc="state.recipe.version"/>
</span>
</h2>
<div class="o_fp_re_subtitle" t-if="state.recipe.process_type">
<i class="fa fa-tag me-1"/><t t-esc="state.recipe.process_type"/>
</div>
</div>
<div class="o_fp_recipe_header_right" t-if="state.recipe">
<span class="text-muted small me-3" t-if="state.recipe.process_type">
<i class="fa fa-tag me-1"/>
<t t-esc="state.recipe.process_type"/>
</span>
<button class="btn btn-sm btn-outline-secondary me-1"
t-on-click="onDuplicate" title="Duplicate recipe">
<i class="fa fa-copy me-1"/> Duplicate
<div class="o_fp_re_header_actions" t-if="state.recipe">
<button class="o_fp_re_btn_outline"
t-on-click="onDuplicate"
title="Duplicate recipe">
<i class="fa fa-copy me-1"/>Duplicate
</button>
<button class="btn btn-sm btn-outline-primary"
<button class="o_fp_re_btn_outline"
t-on-click="() => this.onOpenForm(state.recipe.id)"
title="Edit in form view">
<i class="fa fa-pencil me-1"/> Form View
<i class="fa fa-pencil me-1"/>Form
</button>
</div>
</div>
@@ -48,17 +184,17 @@
<p class="mt-2 text-muted">Loading recipe tree...</p>
</div>
<!-- ========== NO RECIPE ========== -->
<div class="text-center py-5" t-if="!state.loading and !_recipeId">
<i class="fa fa-exclamation-triangle fa-3x text-muted"/>
<p class="mt-3 text-muted">No recipe selected.</p>
<!-- ========== EMPTY ========== -->
<div class="o_fp_re_empty" t-if="!state.loading and !_recipeId">
<i class="fa fa-exclamation-triangle"/>
<div>No recipe selected.</div>
</div>
<!-- ========== TREE + PANEL LAYOUT ========== -->
<div class="o_fp_recipe_body" t-if="state.tree">
<!-- ========== BODY (canvas + side panel) ========== -->
<div class="o_fp_re_body" t-if="state.tree">
<!-- Tree area -->
<div class="o_fp_recipe_tree_area">
<!-- Tree canvas -->
<div class="o_fp_re_canvas">
<t t-call="fusion_plating.RecipeTreeNode">
<t t-set="node" t-value="state.tree"/>
<t t-set="parentNode" t-value="null"/>
@@ -67,26 +203,29 @@
</div>
<!-- Side panel -->
<div t-att-class="'o_fp_recipe_panel' + (state.showPanel ? ' o_fp_recipe_panel_open' : '')">
<div t-att-class="'o_fp_re_panel' + (state.showPanel ? ' o_fp_re_panel_open' : '')">
<t t-if="state.showPanel and state.selectedNode">
<div class="o_fp_recipe_panel_header">
<div class="o_fp_re_panel_head">
<h5>
<i t-att-class="'fa ' + (state.selectedNode.icon || 'fa-cog') + ' me-2'"/>
<i t-attf-class="fa #{ state.selectedNode.icon || 'fa-cog' } me-2"/>
Edit Node
</h5>
<button class="btn btn-sm btn-link" t-on-click="closePanel">
<button class="o_fp_re_btn o_fp_re_btn_cancel"
t-on-click="closePanel">
<i class="fa fa-times"/>
</button>
</div>
<div class="o_fp_recipe_panel_body">
<div class="mb-3">
<label class="form-label fw-bold">Name</label>
<div class="o_fp_re_panel_body">
<div class="o_fp_re_field">
<label>Name</label>
<input type="text" class="form-control"
t-att-value="state.selectedNode.name"
t-on-change="(ev) => { state.selectedNode.name = ev.target.value; }"/>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Type</label>
<div class="o_fp_re_field">
<label>Type</label>
<select class="form-select"
t-on-change="(ev) => { state.selectedNode.node_type = ev.target.value; }">
<option value="recipe"
@@ -99,53 +238,57 @@
t-att-selected="state.selectedNode.node_type === 'step'">Step</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Icon</label>
<div class="o_fp_recipe_icon_picker">
<div class="o_fp_re_field">
<label>Icon</label>
<div class="o_fp_re_icon_picker">
<t t-foreach="getIconOptions()" t-as="ic" t-key="ic.value">
<button t-att-class="'o_fp_recipe_icon_btn' + (state.selectedNode.icon === ic.value ? ' active' : '')"
<button t-att-class="'o_fp_re_icon_btn' + (state.selectedNode.icon === ic.value ? ' active' : '')"
t-on-click.stop="() => { state.selectedNode.icon = ic.value; }"
t-att-title="ic.label">
<i t-att-class="'fa ' + ic.value"/>
<i t-attf-class="fa #{ ic.value }"/>
</button>
</t>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Duration (min)</label>
<div class="o_fp_re_field">
<label>Estimated Duration (min)</label>
<input type="number" class="form-control" min="0" step="1"
t-att-value="state.selectedNode.estimated_duration || 0"
t-on-change="(ev) => { state.selectedNode.estimated_duration = parseFloat(ev.target.value) || 0; }"/>
</div>
<div class="mb-3">
<label class="form-label fw-bold d-block">Flags</label>
<div class="o_fp_re_field">
<label>Flags</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_manual"
<input type="checkbox" class="form-check-input" id="fp_re_chk_manual"
t-att-checked="state.selectedNode.is_manual"
t-on-change="(ev) => { state.selectedNode.is_manual = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_manual">Manual operation</label>
<label class="form-check-label" for="fp_re_chk_manual">Manual operation</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_auto"
<input type="checkbox" class="form-check-input" id="fp_re_chk_auto"
t-att-checked="state.selectedNode.auto_complete"
t-on-change="(ev) => { state.selectedNode.auto_complete = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_auto">Auto-complete</label>
<label class="form-check-label" for="fp_re_chk_auto">Auto-complete</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_signoff"
<input type="checkbox" class="form-check-input" id="fp_re_chk_signoff"
t-att-checked="state.selectedNode.requires_signoff"
t-on-change="(ev) => { state.selectedNode.requires_signoff = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_signoff">Requires sign-off</label>
<label class="form-check-label" for="fp_re_chk_signoff">Requires sign-off</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_visible"
<input type="checkbox" class="form-check-input" id="fp_re_chk_visible"
t-att-checked="state.selectedNode.customer_visible"
t-on-change="(ev) => { state.selectedNode.customer_visible = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_visible">Customer visible</label>
<label class="form-check-label" for="fp_re_chk_visible">Customer visible</label>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Opt In/Out</label>
<div class="o_fp_re_field">
<label>Opt In/Out</label>
<select class="form-select"
t-on-change="(ev) => { state.selectedNode.opt_in_out = ev.target.value; }">
<option value="disabled"
@@ -156,30 +299,16 @@
t-att-selected="state.selectedNode.opt_in_out === 'opt_out'">Opt-Out</option>
</select>
</div>
<!-- Info -->
<div class="text-muted small mb-2" t-if="state.selectedNode.work_center">
<i class="fa fa-building me-1"/>
<t t-esc="state.selectedNode.work_center"/>
</div>
<div class="text-muted small mb-2" t-if="state.selectedNode.process_type">
<i class="fa fa-tag me-1"/>
<t t-esc="state.selectedNode.process_type"/>
</div>
<div class="text-muted small mb-2"
t-if="state.selectedNode.input_count">
<i class="fa fa-keyboard-o me-1"/>
<t t-esc="state.selectedNode.input_count"/> operator input(s)
</div>
<!-- Tracking -->
<div class="o_fp_recipe_tracking mt-3 pt-3" t-if="state.selectedNode.create_date">
<div class="text-muted small mb-1">
<div class="o_fp_re_tracking" t-if="state.selectedNode.create_date">
<div>
<i class="fa fa-calendar-plus-o me-1"/>
Created <t t-esc="formatTimeAgo(state.selectedNode.create_date)"/>
<t t-if="state.selectedNode.create_uid_name">
by <strong t-esc="state.selectedNode.create_uid_name"/>
</t>
</div>
<div class="text-muted small" t-if="state.selectedNode.write_date">
<div t-if="state.selectedNode.write_date">
<i class="fa fa-pencil me-1"/>
Updated <t t-esc="formatTimeAgo(state.selectedNode.write_date)"/>
<t t-if="state.selectedNode.write_uid_name">
@@ -187,15 +316,15 @@
</t>
</div>
</div>
<!-- Actions -->
<div class="d-flex gap-2 mt-4">
<button class="btn btn-primary flex-fill"
<div class="o_fp_re_panel_actions">
<button class="o_fp_re_btn_save"
t-on-click="saveNode"
t-att-disabled="state.saving">
<i t-att-class="state.saving ? 'fa fa-spinner fa-spin me-1' : 'fa fa-check me-1'"/>
Save
</button>
<button class="btn btn-outline-secondary"
<button class="o_fp_re_btn_outline"
t-on-click="() => this.onOpenForm(state.selectedNode.id)"
title="Open full form">
<i class="fa fa-external-link"/>
@@ -208,127 +337,4 @@
</div>
</t>
<!-- ========== RECURSIVE NODE TEMPLATE ========== -->
<t t-name="fusion_plating.RecipeTreeNode">
<!-- Connector line (skip for root) -->
<div class="o_fp_recipe_connector" t-if="!isFirst"/>
<!-- Node card -->
<div t-att-class="'o_fp_recipe_node'
+ (state.selectedNodeId === node.id ? ' o_fp_recipe_node_selected' : '')
+ ' o_fp_recipe_node_' + node.node_type"
t-att-draggable="node.node_type !== 'recipe' ? 'true' : 'false'"
t-on-dragstart="(ev) => this.onNodeDragStart(node, parentNode, ev)"
t-on-dragend="(ev) => this.onNodeDragEnd(ev)"
t-on-dragover="(ev) => this.onNodeDragOver(node, ev)"
t-on-dragleave="(ev) => this.onNodeDragLeave(ev)"
t-on-drop="(ev) => this.onNodeDrop(node, parentNode, ev)"
t-on-click.stop="() => this.selectNode(node)">
<!-- Drag handle (non-root only) -->
<span class="o_fp_recipe_drag_handle" t-if="node.node_type !== 'recipe'">
<i class="fa fa-grip-vertical"/>
</span>
<!-- Node header row -->
<div class="o_fp_recipe_node_header">
<!-- Expand/collapse toggle -->
<button class="o_fp_recipe_toggle_btn"
t-if="node.children and node.children.length"
t-on-click.stop="() => this.toggleExpand(node.id)">
<i t-att-class="isExpanded(node.id) ? 'fa fa-chevron-down' : 'fa fa-chevron-right'"/>
</button>
<span class="o_fp_recipe_toggle_spacer" t-else=""/>
<!-- Icon -->
<i t-att-class="'o_fp_recipe_node_icon fa ' + (node.icon || 'fa-cog')"/>
<!-- Name -->
<span class="o_fp_recipe_node_name">
<t t-esc="node.name"/>
</span>
<!-- Type badge -->
<span t-att-class="'badge o_fp_recipe_node_badge ' + getNodeTypeMeta(node.node_type).badgeClass">
<t t-esc="getNodeTypeMeta(node.node_type).label"/>
</span>
</div>
<!-- Meta row: work centre, duration, capability icons -->
<div class="o_fp_recipe_node_meta">
<span class="o_fp_recipe_node_wc" t-if="node.work_center">
<i class="fa fa-building me-1"/>
<t t-esc="node.work_center"/>
</span>
<span class="o_fp_recipe_node_duration" t-if="node.estimated_duration">
<i class="fa fa-clock-o me-1"/>
<t t-esc="formatDuration(node.estimated_duration)"/>
</span>
<!-- Capability icons -->
<span class="o_fp_recipe_node_icons">
<i class="fa fa-hand-paper-o" t-if="node.is_manual" title="Manual"/>
<i class="fa fa-bolt" t-if="!node.is_manual" title="Automated"/>
<i class="fa fa-check-square" t-if="node.requires_signoff" title="Requires sign-off"/>
<i class="fa fa-eye" t-if="node.customer_visible" title="Customer visible"/>
<i class="fa fa-magic" t-if="node.auto_complete" title="Auto-complete"/>
</span>
</div>
<!-- Action buttons row -->
<div class="o_fp_recipe_node_actions">
<button class="btn btn-sm o_fp_recipe_add_btn"
t-on-click.stop="() => this.startAddChild(node.id)"
title="Add child step">
<i class="fa fa-plus me-1"/> Add Step
</button>
<button class="btn btn-sm o_fp_recipe_delete_btn"
t-if="node.node_type !== 'recipe'"
t-on-click.stop="() => this.deleteNode(node.id)"
title="Delete">
<i class="fa fa-trash"/>
</button>
</div>
</div>
<!-- Add child inline form -->
<div class="o_fp_recipe_add_form" t-if="state.addingTo === node.id">
<div class="o_fp_recipe_connector"/>
<div class="o_fp_recipe_add_card">
<input type="text" class="form-control form-control-sm mb-2"
placeholder="New step name..."
t-att-value="state.newNodeName"
t-on-input="(ev) => { state.newNodeName = ev.target.value; }"
t-on-keydown="onAddNameKey"/>
<div class="d-flex gap-2">
<select class="form-select form-select-sm flex-shrink-1"
style="max-width: 140px;"
t-on-change="(ev) => { state.newNodeType = ev.target.value; }">
<t t-foreach="getNodeTypeOptions()" t-as="opt" t-key="opt.value">
<option t-att-value="opt.value"
t-att-selected="state.newNodeType === opt.value"
t-esc="opt.label"/>
</t>
</select>
<button class="btn btn-sm btn-primary" t-on-click="confirmAdd">
<i class="fa fa-check"/>
</button>
<button class="btn btn-sm btn-outline-secondary" t-on-click="cancelAdd">
<i class="fa fa-times"/>
</button>
</div>
</div>
</div>
<!-- Children (recursive) -->
<div class="o_fp_recipe_children" t-if="node.children and node.children.length and isExpanded(node.id)">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<t t-call="fusion_plating.RecipeTreeNode">
<t t-set="node" t-value="child"/>
<t t-set="parentNode" t-value="node"/>
<t t-set="isFirst" t-value="false"/>
</t>
</t>
</div>
</t>
</templates>

View File

@@ -79,6 +79,24 @@
<field name="active" invisible="True"/>
</group>
</group>
<!-- Recipe-only metadata (lead time, product link,
contract review approvers). Hidden on
operation/step nodes since those values are
only meaningful at the recipe root. -->
<group string="Recipe Settings"
invisible="node_type != 'recipe'">
<group>
<field name="default_lead_time"
widget="float_time"/>
<field name="product_id"
options="{'no_create': True}"/>
</group>
<group>
<field name="contract_review_user_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
</group>
</group>
<group>
<group string="Tracking">
<field name="create_date" string="Created"/>

View File

@@ -23,8 +23,6 @@
<field name="code">model._fp_cron_auto_finish_completed_wos()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
</data>

View File

@@ -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 <b>%s</b>.'
)) % 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

View File

@@ -102,6 +102,25 @@
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
<field name="x_fc_requires_bath" invisible="1"/>
<field name="x_fc_requires_oven" invisible="1"/>
<field name="x_fc_recipe_node_id" invisible="1"/>
<field name="x_fc_requires_signoff" invisible="1"/>
<field name="x_fc_is_manual" invisible="1"/>
<field name="x_fc_auto_complete" invisible="1"/>
<field name="x_fc_signoff_user_id" readonly="1"
invisible="not x_fc_requires_signoff"/>
<field name="x_fc_signoff_date" readonly="1"
invisible="not x_fc_requires_signoff"/>
</xpath>
<!-- ============================================================
SIGN OFF BUTTON — only visible when the recipe step
requires a sign-off and the WO is in progress.
============================================================ -->
<xpath expr="//header" position="inside">
<button name="action_signoff" type="object"
string="Sign Off" class="oe_highlight"
icon="fa-check-square-o"
invisible="not x_fc_requires_signoff or x_fc_signoff_user_id or state in ('done', 'cancel')"/>
</xpath>
<!-- ============================================================

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Add the configurator-side `pricing_rule_ids` field to process nodes.
Lives here (not in core fusion_plating) so the core module doesn't have
to depend on the configurator.
"""
from odoo import fields, models
class FpProcessNode(models.Model):
_inherit = 'fusion.plating.process.node'
pricing_rule_ids = fields.Many2many(
'fp.pricing.rule',
relation='fp_process_node_pricing_rule_rel',
column1='node_id',
column2='rule_id',
string='Price Builders',
help='Pricing rules to apply when this recipe is selected on a '
'quotation (mirrors Steelhead "Use Price Builders").',
)

View File

@@ -439,10 +439,28 @@ class FpQuoteConfigurator(models.Model):
Scores rules by specificity -- most specific match wins.
If no rule matches filters, returns None.
When the chosen coating config points at a recipe and that recipe
has `pricing_rule_ids` configured, the search is constrained to
those rules ("Use Price Builders" semantics). Otherwise the
whole active rule set is considered as before.
"""
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
recipe = (
self.coating_config_id.recipe_id
if self.coating_config_id and self.coating_config_id.recipe_id
else False
)
builder_rules = (
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
)
if builder_rules:
rules = builder_rules.filtered('active').sorted(
lambda r: (r.sequence, r.id)
)
else:
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.4.0.0',
'version': '19.0.4.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',
@@ -35,6 +35,7 @@
'views/fp_notification_log_views.xml',
'views/fp_notifications_menu.xml',
],
'post_init_hook': 'post_init_hook',
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -61,6 +61,9 @@
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
<field name="report_name">Quotation_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -118,6 +121,9 @@
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
<field name="report_name">SalesOrder_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -342,6 +348,9 @@
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->

View File

@@ -0,0 +1,62 @@
# -*- 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_sale_portrait',
)
_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):
"""Replace the template's report_template_ids with exactly [report].
We use `set` semantics (replace all) rather than `add` so that old
attachments from previous refactors get cleaned up — e.g. when the
Acknowledgement report was consolidated into the Sales Order report,
the now-stale Acknowledgement reference gets removed here.
"""
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
current_ids = set(mail_template.report_template_ids.ids)
if current_ids != {report.id}:
mail_template.write({
'report_template_ids': [(6, 0, [report.id])],
})
_logger.info(
'fusion_plating_notifications: set report %s on template %s',
report_xmlid, mail_template_xmlid,
)

View File

@@ -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']

View File

@@ -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()

View File

@@ -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

View File

@@ -588,6 +588,21 @@
</div>
</div>
<!-- Process Steps — only the customer-visible recipe nodes
(recipe author marked customer_visible=True). -->
<t t-set="visible_steps" t-value="job.sudo().get_customer_visible_steps()"/>
<div class="mb-4" t-if="visible_steps">
<h6 class="text-muted small text-uppercase">Process Steps</h6>
<ol class="list-group list-group-numbered">
<li t-foreach="visible_steps" t-as="step"
class="list-group-item d-flex align-items-center"
t-attf-style="padding-left: #{ 12 + (step['depth'] * 18) }px;">
<i t-attf-class="fa #{ step['icon'] } me-2 text-muted"/>
<span t-out="step['name']"/>
</li>
</ol>
</div>
<div class="mb-4">
<h6 class="text-muted small text-uppercase">Documents</h6>
<div class="list-group">

View File

@@ -40,7 +40,6 @@
'report/report_wo_margin.xml',
# Quote-to-cash reports (portrait + landscape)
'report/report_fp_sale.xml',
'report/report_fp_so_acknowledgement.xml',
'report/report_fp_work_order.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_packing_slip.xml',

View File

@@ -97,6 +97,42 @@
</table>
</t>
<!-- Scheduling + customer job reference -->
<t t-if="doc.x_fc_customer_job_number or doc.x_fc_planned_start_date or doc.commitment_date or doc.x_fc_ship_via">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">CUSTOMER JOB #</th>
<th class="info-header" style="width: 25%;">PLANNED START</th>
<th class="info-header" style="width: 25%;">CUSTOMER DEADLINE</th>
<th class="info-header" style="width: 25%;">SHIP VIA</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="doc.x_fc_customer_job_number or '-'"/></td>
<td class="text-center"><span t-field="doc.x_fc_planned_start_date"/></td>
<td class="text-center"><span t-field="doc.commitment_date"/></td>
<td class="text-center"><span t-esc="doc.x_fc_ship_via or '-'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Blanket / block-partial callout (confirmed-order shipping flags) -->
<t t-if="doc.x_fc_is_blanket_order or doc.x_fc_block_partial_shipments">
<div class="highlight-box">
<t t-if="doc.x_fc_is_blanket_order">
<strong>Blanket Order.</strong>
Parts will be released in quantities over time.
</t>
<t t-if="doc.x_fc_block_partial_shipments">
<strong>Partial shipments blocked.</strong>
The order ships as one complete batch.
</t>
</div>
</t>
<!-- Order lines -->
<table class="bordered">
<thead>
@@ -189,6 +225,14 @@
</div>
</div>
<!-- External (customer-visible) notes -->
<t t-if="doc.x_fc_external_note">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<div t-field="doc.x_fc_external_note"/>
</div>
</t>
<!-- Terms and Conditions -->
<t t-if="doc.note">
<div style="margin-top: 15px;">
@@ -327,6 +371,42 @@
</table>
</t>
<!-- Scheduling + customer job reference -->
<t t-if="doc.x_fc_customer_job_number or doc.x_fc_planned_start_date or doc.commitment_date or doc.x_fc_ship_via">
<table class="bordered info-table">
<thead>
<tr>
<th>CUSTOMER JOB #</th>
<th>PLANNED START</th>
<th>CUSTOMER DEADLINE</th>
<th>SHIP VIA</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="doc.x_fc_customer_job_number or '-'"/></td>
<td class="text-center"><span t-field="doc.x_fc_planned_start_date"/></td>
<td class="text-center"><span t-field="doc.commitment_date"/></td>
<td class="text-center"><span t-esc="doc.x_fc_ship_via or '-'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Blanket / block-partial callout -->
<t t-if="doc.x_fc_is_blanket_order or doc.x_fc_block_partial_shipments">
<div class="highlight-box">
<t t-if="doc.x_fc_is_blanket_order">
<strong>Blanket Order.</strong>
Parts will be released in quantities over time.
</t>
<t t-if="doc.x_fc_block_partial_shipments">
<strong>Partial shipments blocked.</strong>
The order ships as one complete batch.
</t>
</div>
</t>
<!-- Order lines — hide discount column unless at least one line has a discount -->
<t t-set="has_discount" t-value="any(l.discount for l in doc.order_line)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/>
@@ -426,6 +506,14 @@
</div>
</div>
<!-- External (customer-visible) notes -->
<t t-if="doc.x_fc_external_note">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<div t-field="doc.x_fc_external_note"/>
</div>
</t>
<!-- Terms and Conditions -->
<t t-if="doc.note">
<div style="margin-top: 15px;">

View File

@@ -1,273 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sales Order Acknowledgement (Phase D7) — customer-facing
confirmation sent shortly after action_confirm. Styled to match
the rest of the Fusion Plating report family (portrait; bordered
tables; company primary-colour header; totals-table footer;
sig-box signature pair).
-->
<odoo>
<record id="action_report_fp_so_acknowledgement" model="ir.actions.report">
<field name="name">Sales Order Acknowledgement</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_acknowledgement_doc</field>
<field name="report_file">fusion_plating_reports.report_fp_so_acknowledgement_doc</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="print_report_name">'Acknowledgement - %s' % object.name</field>
</record>
<template id="report_fp_so_acknowledgement_doc">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<!-- Title -->
<h4>
<span>Sales Order Acknowledgement </span>
<span t-field="doc.name"/>
</h4>
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">SHIPPING ADDRESS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px;">
<div t-field="doc.partner_invoice_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 70px;">
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- References -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">CUSTOMER PO #</th>
<th class="info-header" style="width: 25%;">CUSTOMER JOB #</th>
<th class="info-header" style="width: 25%;">ORDER DATE</th>
<th class="info-header" style="width: 25%;">SALESPERSON</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">
<span t-esc="doc.x_fc_po_number or '-'"/>
</td>
<td class="text-center">
<span t-esc="doc.x_fc_customer_job_number or '-'"/>
</td>
<td class="text-center">
<span t-field="doc.date_order"
t-options="{'widget': 'date'}"/>
</td>
<td class="text-center">
<span t-field="doc.user_id"/>
</td>
</tr>
</tbody>
</table>
<!-- Scheduling -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">PLANNED START</th>
<th class="info-header" style="width: 25%;">INTERNAL DEADLINE</th>
<th class="info-header" style="width: 25%;">CUSTOMER DEADLINE</th>
<th class="info-header" style="width: 25%;">SHIP VIA</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">
<span t-field="doc.x_fc_planned_start_date"/>
</td>
<td class="text-center">
<span t-field="doc.x_fc_internal_deadline"/>
</td>
<td class="text-center">
<span t-field="doc.commitment_date"/>
</td>
<td class="text-center">
<span t-esc="doc.x_fc_ship_via or '-'"/>
</td>
</tr>
</tbody>
</table>
<!-- Blanket / Block Partial callout -->
<t t-if="doc.x_fc_is_blanket_order or doc.x_fc_block_partial_shipments">
<div class="highlight-box">
<t t-if="doc.x_fc_is_blanket_order">
<strong>Blanket Order.</strong>
Parts will be released in quantities over time.
</t>
<t t-if="doc.x_fc_block_partial_shipments">
<strong>Partial shipments blocked.</strong>
The order ships as one complete batch.
</t>
</div>
</t>
<!-- Order lines -->
<table class="bordered">
<thead>
<tr>
<th style="width: 14%;">PART</th>
<th class="text-start" style="width: 36%;">DESCRIPTION</th>
<th style="width: 18%;">TREATMENT</th>
<th style="width: 8%;">QTY</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 12%;">SUBTOTAL</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line.filtered(lambda l: not l.x_fc_archived and (not l.display_type or l.display_type in ('line_section', 'line_note', 'product')))"
t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row">
<td colspan="6"><strong t-field="line.name"/></td>
</tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row">
<td colspan="6"><span t-field="line.name"/></td>
</tr>
</t>
<t t-else="">
<tr>
<td class="text-center">
<span t-esc="line.x_fc_part_catalog_id.part_number or '-'"/>
</td>
<td>
<t t-set="clean_name" t-value="line.name"/>
<t t-if="line.name and '] ' in line.name">
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
</t>
<span t-esc="clean_name"/>
</td>
<td class="text-center">
<span t-field="line.x_fc_coating_config_id"/>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-end">
<span t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-6">
<t t-if="doc.x_fc_invoice_strategy">
<strong>Invoice Strategy: </strong>
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/>
<t t-if="doc.x_fc_invoice_strategy == 'deposit' and doc.x_fc_deposit_percent">
(<span t-esc="doc.x_fc_deposit_percent"/>%)
</t>
<br/>
</t>
<t t-if="doc.payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.payment_term_id.note"/>
</t>
</div>
<div class="col-6" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 150px;">Subtotal</td>
<td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span t-field="doc.amount_tax"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</table>
</div>
</div>
<!-- External (customer-visible) notes -->
<t t-if="doc.x_fc_external_note">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<div t-field="doc.x_fc_external_note"/>
</div>
</t>
<!-- Signature block -->
<div class="row" style="margin-top: 25px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Customer Acceptance (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<t t-if="doc.signature">
<img t-att-src="image_data_uri(doc.signature)"
style="max-height: 3cm; max-width: 8cm;"/><br/>
<span t-field="doc.signed_by"/>
</t>
<t t-else="">
<div class="sig-line"/>
</t>
<div class="small-muted">Authorized Representative</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -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,
}

View File

@@ -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";
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ProcessTree">
<div class="o_fp_process_tree">
<!-- =====================================================================
RECURSIVE NODE TEMPLATE
Expects a `node` set in the t-call context.
===================================================================== -->
<t t-name="fusion_plating_shopfloor.ProcessNode">
<div class="o_fp_pt_node">
<!-- ========== HEADER ========== -->
<div class="o_fp_pt_header">
<div class="o_fp_pt_header_left">
<button class="btn btn-outline-secondary btn-sm me-3"
t-on-click="onBackToOverview"
title="Back to Plant Overview">
<i class="fa fa-arrow-left me-1"/> Overview
</button>
<div class="o_fp_pt_title_block">
<h3 class="o_fp_pt_title mb-0">
<i class="fa fa-sitemap me-2"/>
Process Tree
</h3>
<span class="o_fp_pt_subtitle text-muted" t-if="state.productionName">
<t t-esc="state.productionName"/>
<t t-if="state.productName">
<t t-esc="state.productName"/>
</t>
<!-- The card itself -->
<div t-att-class="getCardClass(node)"
t-on-click="() => this.onNodeClick(node)">
<i t-attf-class="o_fp_pt_card_icon fa #{ nodeIcon(node) }"/>
<div class="o_fp_pt_card_body">
<div class="o_fp_pt_card_title" t-esc="node.name"/>
<div class="o_fp_pt_card_meta"
t-if="node.assigned_user_name or node.bath or node.tank or node.oven or node.rack or node.masking_material or node.duration_display or node.duration_expected_display">
<span t-if="node.assigned_user_name">
<i class="fa fa-user me-1"/><t t-esc="node.assigned_user_name"/>
</span>
<span t-if="node.bath">
· <i class="fa fa-flask me-1"/><t t-esc="node.bath"/>
</span>
<span t-if="node.tank">
· <i class="fa fa-tint me-1"/><t t-esc="node.tank"/>
</span>
<span t-if="node.oven">
· <i class="fa fa-fire me-1"/><t t-esc="node.oven"/>
</span>
<span t-if="node.rack">
· <i class="fa fa-th me-1"/><t t-esc="node.rack"/>
</span>
<span t-if="node.masking_material">
· <i class="fa fa-tag me-1"/><t t-esc="node.masking_material"/>
</span>
<span t-if="node.duration_display">
· <i class="fa fa-clock-o me-1"/><t t-esc="node.duration_display"/>
</span>
<span t-elif="node.duration_expected_display">
· <i class="fa fa-hourglass-half me-1"/><t t-esc="node.duration_expected_display"/>
</span>
</div>
</div>
<div class="o_fp_pt_header_right" t-if="state.moState">
<span class="badge bg-secondary">
MO: <t t-esc="state.moState"/>
</span>
<!-- Right-side: kind badge / qty / open icon -->
<div class="o_fp_pt_card_right">
<span t-if="node.wo_kind"
t-attf-class="o_fp_pt_kind o_fp_pt_kind_#{ node.wo_kind }"
t-esc="node.wo_kind_label || node.wo_kind"/>
<span class="o_fp_pt_qty"
t-if="node.qty_total"
t-esc="qtyLabel(node)"/>
<i class="o_fp_pt_card_open fa fa-external-link"
t-if="node.workorder_id"/>
</div>
</div>
<!-- Children — recurse -->
<div class="o_fp_pt_children" t-if="node.children and node.children.length">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<t t-call="fusion_plating_shopfloor.ProcessNode">
<t t-set="node" t-value="child"/>
</t>
</t>
</div>
</div>
</t>
<!-- =====================================================================
ROOT TEMPLATE
===================================================================== -->
<t t-name="fusion_plating_shopfloor.ProcessTree">
<div class="o_fp_process_tree o_fp_pt_v3">
<!-- ========== HEADER ========== -->
<div class="o_fp_pt_header">
<button class="o_fp_pt_back"
t-on-click="onBack"
t-att-title="backLabel">
<i class="fa fa-arrow-left me-2"/>
<t t-esc="backLabel"/>
</button>
<div class="o_fp_pt_title_block">
<h2 class="o_fp_pt_title mb-0">
<i class="fa fa-sitemap me-2"/>Process
<span t-if="state.productionName" class="o_fp_pt_mo_name">
· <t t-esc="state.productionName"/>
</span>
</h2>
<div class="o_fp_pt_subtitle">
<span t-if="state.soName"><t t-esc="state.soName"/></span>
<span t-if="state.customer"> · <i class="fa fa-user me-1"/><t t-esc="state.customer"/></span>
<span t-if="state.productName"> · <t t-esc="state.productName"/></span>
<span t-if="state.productQty"> · Qty <t t-esc="state.productQty"/></span>
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
</div>
</div>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_pt_loading text-center py-5" t-if="state.loading">
<div class="o_fp_pt_loading text-center py-4" t-if="state.loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted">Loading process tree...</p>
<p class="mt-2 text-muted small">Loading process...</p>
</div>
<!-- ========== NO PRODUCTION ID ========== -->
<div class="o_fp_pt_empty text-center py-5"
<!-- ========== EMPTY ========== -->
<div class="o_fp_pt_empty"
t-if="!state.loading and !productionId">
<i class="fa fa-exclamation-triangle fa-3x text-warning"/>
<p class="mt-3">No manufacturing order selected.
Open this view from a production order to see its routing tree.</p>
<i class="fa fa-exclamation-triangle"/>
<div>No manufacturing order selected.</div>
</div>
<!-- ========== EMPTY TREE ========== -->
<div class="o_fp_pt_empty text-center py-5"
t-if="!state.loading and productionId and !state.nodes.length">
<i class="fa fa-sitemap fa-3x text-muted"/>
<p class="mt-3 text-muted">No routing steps found for this order.</p>
<div class="o_fp_pt_empty"
t-if="!state.loading and productionId and !state.root">
<i class="fa fa-sitemap"/>
<div>No process steps for this order.</div>
</div>
<!-- ========== TREE ========== -->
<div class="o_fp_pt_tree" t-if="state.nodes.length">
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<div class="o_fp_pt_node_wrapper">
<!-- Connecting line (not on first node) -->
<div class="o_fp_pt_connector" t-if="!node_first"/>
<!-- Node box -->
<div t-att-class="'o_fp_pt_node ' + getNodeStateClass(node.state)"
t-on-click="() => this.onNodeClick(node)">
<div class="o_fp_pt_node_header">
<div class="o_fp_pt_node_name">
<span class="o_fp_pt_node_seq"
t-if="node.sequence">
<t t-esc="node.sequence"/>.
</span>
<strong t-esc="node.name"/>
</div>
<button class="o_fp_pt_toggle_btn"
t-if="node.children and node.children.length"
t-on-click.stop="() => this.toggleNode(node.id)"
title="Expand / collapse">
<i t-att-class="isCollapsed(node.id) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
</button>
</div>
<!-- Work centre -->
<div class="o_fp_pt_node_wc text-muted"
t-if="node.work_center_name">
<i class="fa fa-cog me-1"/>
<t t-esc="node.work_center_name"/>
</div>
<!-- State badge -->
<div class="o_fp_pt_node_state mt-1">
<span t-att-class="'badge ' + getNodeStateClass(node.state)">
<t t-esc="getNodeStateLabel(node.state)"/>
</span>
</div>
<!-- Progress bar -->
<div class="o_fp_pt_node_progress mt-2"
t-if="node.qty_total">
<div t-att-class="'o_fp_pt_bar ' + getProgressClass(node)">
<div class="o_fp_pt_bar_fill"
t-att-style="'width:' + getProgressPct(node) + '%'"/>
</div>
<span class="o_fp_pt_bar_label">
<t t-esc="node.qty_done"/>/<t t-esc="node.qty_total"/>
(<t t-esc="getProgressPct(node)"/>%)
</span>
</div>
<!-- Duration -->
<div class="o_fp_pt_node_duration text-muted mt-1"
t-if="node.duration_display">
<i class="fa fa-clock-o me-1"/>
<t t-esc="node.duration_display"/>
</div>
</div>
<!-- Child nodes (sub-states: Ready for X, X-ing) -->
<div class="o_fp_pt_children"
t-if="node.children and node.children.length and !isCollapsed(node.id)">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<div class="o_fp_pt_child_connector"/>
<div t-att-class="'o_fp_pt_child_node ' + getNodeStateClass(child.state)">
<div class="o_fp_pt_child_name">
<t t-esc="child.name"/>
</div>
<div class="o_fp_pt_child_progress"
t-if="child.qty_total">
<div t-att-class="'o_fp_pt_bar o_fp_pt_bar_sm ' + getProgressClass(child)">
<div class="o_fp_pt_bar_fill"
t-att-style="'width:' + getProgressPct(child) + '%'"/>
</div>
<span class="o_fp_pt_bar_label">
<t t-esc="child.qty_done"/>/<t t-esc="child.qty_total"/>
</span>
</div>
</div>
</t>
</div>
</div>
<div class="o_fp_pt_canvas" t-if="state.root">
<t t-call="fusion_plating_shopfloor.ProcessNode">
<t t-set="node" t-value="state.root"/>
</t>
</div>

View File

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

View File

@@ -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})')

BIN
fusion_plating/so_list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB