Compare commits

...

5 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
gsinghpal
8217bb0ff6 fix(fusion_accounting_reports): expose dynamic OWL reports as menu items
User reported that after Enterprise uninstall, clicking 'Reports' opened
PDF statements instead of the dynamic Fusion report viewer. Root cause:
the OWL ReportViewer (registered as view_type='fusion_reports') was only
reachable via the period-picker WIZARD; no menu items used the OWL view
directly. Plus the JS service ignored report_code, so even within the
viewer, all PnL-typed reports rendered the canonical P&L line_specs.

Changes:

JS layer
- reports_service.js: runReport now accepts and forwards reportCode;
  state tracks currentReportCode so re-runs after period/comparison
  changes preserve the variant.
- report_viewer.js: reads default_report_code (and default_comparison)
  from the action context.
- period_filter.js: passes the cached reportCode on date changes;
  clears it when the user picks a different report_type.

Backend
- New fusion_accounting_reports/views/report_actions.xml with 11
  dedicated ir.actions.act_window records, one per built-in report
  (P&L, Balance Sheet, Trial Balance, GL, Cash Flow, Executive Summary,
  Annual Statements, Aged Receivable, Aged Payable, Partner Ledger,
  Tax Summary). Each opens view_mode='fusion_reports' with the
  appropriate default_report_type + default_report_code context.
- views/menu_views.xml: each report now gets its own menu item
  directly under Accounting > Reporting (sequence 10-40), matching
  Enterprise's flat structure. Custom Period wizard, XLSX export and
  Anomaly browser collected under a 'Tools' sub-group at the bottom.
- fusion_accounting_l10n_ca: adds menu items for 'Profit and Loss
  (Canada)' and 'Balance Sheet (Canada)' as siblings, plus a 'Tax
  Returns (CA)' configuration menu.

Verified live on westin-v19:
- pnl rendering 3 rows, cash_flow 9, executive_summary 7,
  annual_statements 5, ca_profit_loss 9 \u2014 each report now renders
  its own line_specs correctly.
- Reporting menu shows 14 Fusion report entries + Tools group.
- 136/136 reports + l10n_ca tests pass.

Version bumps: reports 19.0.1.1.1, l10n_ca 19.0.1.1.0.

Made-with: Cursor
2026-04-20 01:11:48 -04:00
gsinghpal
867b5f71a1 fix(fusion_accounting): unified Accounting menu under one root, hide migration when Enterprise gone
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
User reported two UX problems after the Enterprise uninstall:
1. Each Fusion sub-module showed up as its own standalone app in the
   launcher (Bank Reconciliation, Financial Reports, Asset Management,
   Customer Follow-ups, Fusion AI). Should look like ONE Accounting app.
2. Clicking the 'Fusion Accounting' app still opened the migration
   wizard even though Enterprise had been uninstalled and there was
   nothing to migrate.

Fix:
- Move all Fusion sub-module roots under the Community account.menu_finance
  hierarchy:
    * Bank Reconciliation \u2192 Accounting > Bank Reconciliation
    * Asset Management    \u2192 Accounting > Asset Management
    * Financial Reports   \u2192 Reporting > Financial Reports
    * Follow-ups          \u2192 Customers > Follow-ups
    * Fusion AI           \u2192 Configuration > Fusion AI
    * Migrate from Ent.   \u2192 Configuration > Migrate from Enterprise
- Rename Community's 'Invoicing' top-level menu to 'Accounting' (what
  Enterprise's accountant module did). Set the Fusion icon on it. This
  rename lives in the meta-module so it only fires when the full suite
  is installed.
- Add second computed group 'group_fusion_show_when_enterprise_present'
  (inverse of the existing 'absent' group). Migration menus are gated
  by this group, so they auto-hide once Enterprise is uninstalled.
- _fusion_recompute_coexistence_group now maintains both groups in lockstep.
- Meta-module now also depends on l10n_ca, hr_payroll, ocr, documents
  (the Phase 6/7 sub-modules) for one-click full-suite install.
- Fusion AI menu's old parent ('accountant.menu_accounting') was deleted
  with the Enterprise uninstall \u2014 reparented under Configuration.

Result: single 'Accounting' top-level menu containing the standard
V19 Community structure (Dashboard / Customers / Vendors / Accounting /
Reporting / Configuration), with all Fusion features slotted into the
appropriate sub-section. Verified live on westin-v19: 6 separate
Fusion top-level menus collapsed to 1; coexistence groups recomputed
(absent=10 users, present=0 users); 604/604 tests pass.

Version bump: all touched modules \u2192 19.0.1.1.0.

Made-with: Cursor
2026-04-20 01:04:49 -04:00
gsinghpal
bee5ba4d3f fix(plating): UAT-caught UX annoyances + lurking bugs
Five fixes from the end-to-end UAT debrief:

1. Menu discoverability (HIGH)
   Added a prominent "+ New Direct Order" button in the Sale Orders
   list header toolbar (class=btn-primary, display=always). The
   existing menuitem at Plating > Sales > New Direct Order was
   buried in a submenu that didn't always expand; the toolbar
   button is a guaranteed entry point from the most common screen.

2. Escape/X destroys wizard state (HIGH)
   Added a prominent info banner at the top of the wizard form:
   "Changes are not saved until you click Create & Confirm Order.
   Closing this window (Esc or X) discards your entries." The
   Cancel button now has confirm="Discard this order? All header
   data and line items will be lost." so the intentional-cancel
   path also prompts.

3. Shell/cron crash in _fp_auto_create_mo (MEDIUM)
   bridge_mrp/models/sale_order.py:232-264 used _() inside list
   comprehensions to format the internal chatter summary of newly
   created / adopted MOs. _() resolves language from env.context,
   which is empty in odoo-shell and cron contexts — triggering a
   translate.get_text_alias crash AFTER the MOs had been created.
   These strings are internal audit log text, not user-facing UI;
   dropped the _() wrappers so the message builds safely from any
   context. Same for the per-group error-message on savepoint
   rollback.

4. Misleading "100%" margin (MEDIUM)
   x_fc_margin_percent displayed 100% on every SO because the cost
   rollup from fp.coating.config.unit_cost isn't populated yet.
   Added x_fc_margin_available Boolean (True only when at least
   one line's coating has a non-zero unit_cost). The SO Plating
   tab now hides the margin numbers when margin_available=False
   and shows an inline muted note: "Margin n/a — coating cost
   rollup not yet populated on any line's treatment."

5. Account Hold banner too loud (LOW)
   fusion_plating_invoicing was injecting a full-height danger
   alert above every SO header. Slimmed it to a one-line compact
   alert with icon: "Account Hold — SO confirmation, invoicing
   and shipping are blocked for non-managers." Half the vertical
   footprint, less visual competition with the Plating chip bar.

Verified via UAT on S00071.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:03:26 -04:00
70 changed files with 6181 additions and 1500 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

View File

@@ -1,26 +1,30 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.4',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
'summary': 'Meta-module that installs the full Fusion Accounting suite as a Community-edition replacement for Odoo Enterprise accounting.',
'description': """
Fusion Accounting (Meta-Module)
===============================
One-click install of the entire Fusion Accounting suite.
One-click install of the entire Fusion Accounting suite \u2014 a Community-edition
replacement for Odoo Enterprise's accounting modules.
Currently installs:
- fusion_accounting_core Shared schema, security, runtime helpers
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
- fusion_accounting_assets AI-augmented asset management (Phase 3)
- fusion_accounting_followup AI-augmented customer follow-ups (Phase 4)
Sub-modules installed:
- fusion_accounting_core Shared schema, security, runtime helpers
- fusion_accounting_ai AI Co-Pilot (Claude/GPT/local LLM)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation
- fusion_accounting_reports AI-augmented financial reports
- fusion_accounting_assets AI-augmented asset management
- fusion_accounting_followup AI-augmented customer follow-ups
- fusion_accounting_l10n_ca Canadian reports + tax return tracking
- fusion_accounting_hr_payroll Payroll \u2192 GL bridge (replaces hr_payroll_account)
- fusion_accounting_ocr Tesseract + LLM invoice OCR
- fusion_accounting_documents Documents app \u2194 invoice bridge
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_dashboard (Phase 5)
- fusion_accounting_budget (Phase 6)
Renames the Community "Invoicing" top-level menu to "Accounting" and slots
all Fusion sub-features as sub-menus, mirroring the Odoo Enterprise UX.
Built by Nexa Systems Inc.
""",
@@ -37,8 +41,14 @@ Built by Nexa Systems Inc.
'fusion_accounting_reports',
'fusion_accounting_assets',
'fusion_accounting_followup',
'fusion_accounting_l10n_ca',
'fusion_accounting_hr_payroll',
'fusion_accounting_ocr',
'fusion_accounting_documents',
],
'data': [
'data/menu_overrides.xml',
],
'data': [],
'installable': True,
'application': True,
'license': 'OPL-1',

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="0">
<!--
Top-level "Invoicing" menu rename + visual rebrand.
V19 Community ships this menu as "Invoicing" with the standard
accounting icon. Odoo Enterprise's `accountant` module renames it
to "Accounting" and swaps the icon. We do the same here so that
once Enterprise is uninstalled, the unified menu still presents
as "Accounting" (not "Invoicing") to users.
This file lives in the meta-module so the rename only takes effect
when the full Fusion suite is installed; sub-modules installed
a-la-carte don't change the menu's name.
-->
<record id="account.menu_finance" model="ir.ui.menu">
<field name="name">Accounting</field>
<field name="web_icon">fusion_accounting,static/description/icon.png</field>
<field name="sequence">25</field>
</record>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting AI',
'version': '19.0.1.0.1',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 26,
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root menu under Accounting (account_accountant uses accountant.menu_accounting) -->
<!-- Lives under Community Accounting "Configuration" sub-menu so the
AI co-pilot is reachable regardless of whether Enterprise is
installed. (Was previously parented to `accountant.menu_accounting`
which doesn't exist after the Enterprise uninstall.) -->
<menuitem id="menu_fusion_accounting_root"
name="Fusion AI"
parent="accountant.menu_accounting"
sequence="8"
parent="account.menu_finance_configuration"
sequence="55"
groups="fusion_accounting_core.group_fusion_accounting_user"/>
<!-- Dashboard -->

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.36',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_asset Enterprise NOT installed) -->
<!-- Lives under Community Accounting "Accounting" sub-menu. Only visible
when Enterprise's account_asset is absent. -->
<menuitem id="menu_fusion_assets_root"
name="Asset Management"
sequence="60"
web_icon="fusion_accounting_assets,static/description/icon.png"
parent="account.menu_finance_entries"
sequence="25"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Asset list/form -->

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.26',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',

View File

@@ -20,11 +20,14 @@
</field>
</record>
<!-- Top-level menu — only visible when Enterprise's account_accountant is absent -->
<!-- Container menu lives under the Community Accounting "Accounting"
sub-menu (account.menu_finance_entries). Only visible when
Enterprise's account_accountant is absent (Enterprise's reconcile
widget owns the same UI surface). -->
<menuitem id="menu_fusion_bank_rec_root"
name="Bank Reconciliation"
sequence="40"
web_icon="fusion_accounting_bank_rec,static/description/icon.png"
parent="account.menu_finance_entries"
sequence="15"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_bank_rec_main"
@@ -34,9 +37,8 @@
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Sub-menu for the auto-reconcile wizard -->
<menuitem id="menu_fusion_auto_reconcile_wizard"
name="Auto-Reconcile"
name="Auto-Reconcile\u2026"
parent="menu_fusion_bank_rec_root"
action="action_fusion_auto_reconcile_wizard"
sequence="20"

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Core',
'version': '19.0.1.0.2',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 24,
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',

View File

@@ -8,20 +8,46 @@ class ResUsers(models.Model):
@api.model
def _fusion_recompute_coexistence_group(self):
"""Set group membership = all internal users iff Enterprise absent.
"""Maintain the two coexistence groups based on Enterprise presence.
- ``group_fusion_show_when_enterprise_absent``: members = all internal
users when NO Enterprise accounting module is installed. Used to
unhide Fusion menus that would conflict with Enterprise UIs.
- ``group_fusion_show_when_enterprise_present``: members = all internal
users when AT LEAST ONE Enterprise accounting module IS installed.
Used to hide migration/transitional UIs once Enterprise has been
uninstalled (so the user doesn't see "Migrate from Enterprise" with
nothing to migrate).
The two groups are mutually exclusive at any moment in time, but a
user can transition between them as Enterprise modules are installed
or uninstalled. Idempotent; safe to call multiple times.
Called from ir.module.module.button_immediate_install / uninstall
overrides. Idempotent; safe to call multiple times.
overrides.
"""
group = self.env.ref(
absent_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
if not group:
present_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_present',
raise_if_not_found=False,
)
if not absent_group and not present_group:
return
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
all_internal = self.sudo().search([('share', '=', False)])
if enterprise_installed:
group.sudo().write({'user_ids': [(5, 0, 0)]})
if absent_group:
absent_group.sudo().write({'user_ids': [(5, 0, 0)]})
if present_group:
present_group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
else:
all_internal = self.sudo().search([('share', '=', False)])
group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
if absent_group:
absent_group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
if present_group:
present_group.sudo().write({'user_ids': [(5, 0, 0)]})

View File

@@ -49,4 +49,12 @@
<field name="name">Fusion: Show menus when Enterprise absent</field>
<field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
</record>
<!-- Phase 8: inverse coexistence group \u2014 visible only when Enterprise IS present.
Used to hide migration/transitional UIs once the migration is complete and
Enterprise has been uninstalled. -->
<record id="group_fusion_show_when_enterprise_present" model="res.groups">
<field name="name">Fusion: Show menus when Enterprise present</field>
<field name="comment">Computed group. Membership: all internal users WHEN at least one Enterprise accounting module is installed. Used to hide migration/transitional UIs that are irrelevant once Enterprise has been uninstalled.</field>
</record>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.30',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
'description': """

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_followup Enterprise NOT installed) -->
<!-- Lives under Community Accounting "Customers" sub-menu. Only visible
when Enterprise's account_followup is absent. -->
<menuitem id="menu_fusion_followup_root"
name="Customer Follow-ups"
sequence="70"
web_icon="fusion_accounting_followup,static/description/icon.png"
name="Follow-ups"
parent="account.menu_finance_receivables"
sequence="50"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Partners list (gated to overdue) -->

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Canadian Reports',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Accounting/Localizations/Reporting',
'summary': 'Canadian-specific report definitions and tax return templates for Fusion Accounting.',
'description': """
@@ -21,6 +21,7 @@ Auto-installs when l10n_ca + fusion_accounting_reports are both present.
'data/fusion_tax_return_data.xml',
'data/report_ca_balance_sheet.xml',
'data/report_ca_profit_loss.xml',
'views/menu_views.xml',
],
'auto_install': ['l10n_ca', 'fusion_accounting_reports'],
'installable': True,

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Canadian-flavored P&L + BS menus. Live alongside the rest of the
Fusion reports under Accounting > Reporting. Open the OWL
ReportViewer with the Canadian report_code so the line_specs
come from the Canadian definitions seeded in this module.
-->
<record id="action_fusion_report_ca_pnl" model="ir.actions.act_window">
<field name="name">Profit and Loss (Canada)</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'ca_profit_loss', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_ca_bs" model="ir.actions.act_window">
<field name="name">Balance Sheet (Canada)</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'balance_sheet', 'default_report_code': 'ca_balance_sheet', 'default_comparison': 'previous_period'}</field>
</record>
<menuitem id="menu_fusion_report_ca_pnl"
name="Profit and Loss (Canada)"
parent="account.menu_finance_reports"
action="action_fusion_report_ca_pnl"
sequence="15"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_report_ca_bs"
name="Balance Sheet (Canada)"
parent="account.menu_finance_reports"
action="action_fusion_report_ca_bs"
sequence="16"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Tax-return tracking list -->
<record id="action_fusion_tax_return" model="ir.actions.act_window">
<field name="name">Tax Returns</field>
<field name="res_model">fusion.tax.return</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No tax returns recorded yet</p>
<p>Track GST/HST/PST/T4/T5018/payroll-remittance filings.
Each return covers a (date_from, date_to) window and moves
from draft \u2192 to-file \u2192 filed as you submit it.</p>
</field>
</record>
<menuitem id="menu_fusion_tax_return"
name="Tax Returns (CA)"
parent="account.menu_finance_configuration"
action="action_fusion_tax_return"
sequence="100"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Migration',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 27,
'summary': 'Transitional module: migrates Odoo Enterprise accounting data to Fusion Accounting tables before Enterprise uninstall.',

View File

@@ -27,21 +27,29 @@
</record>
<!--
Top-level "Fusion Accounting" menu so the UserError guidance
("Fusion Accounting -> Migrate from Enterprise") is actually reachable.
Placed at top level (no parent) because the migration is a one-time
admin task; making it visible during switchover is the point.
`groups` hides the menu from non-admins (mirroring the ACL on the wizard).
Migration wizard lives under Accounting > Configuration, and is
ONLY visible while at least one Enterprise accounting module is
still installed. Once the operator has uninstalled Enterprise, the
wizard is hidden \u2014 there's nothing left to migrate.
Visibility is gated by the intersection of:
- group_fusion_accounting_admin (admin-only feature)
- group_fusion_show_when_enterprise_present (computed: members
iff at least one Enterprise accounting module is installed)
-->
<!-- Note: gating uses ONLY group_fusion_show_when_enterprise_present.
Admin-restriction is enforced via the model ACL
(ir.model.access.csv only grants access to group_fusion_accounting_admin).
Odoo `groups=` on menuitems uses OR semantics, so listing both groups
would let any admin see the menu even after Enterprise is uninstalled. -->
<menuitem id="menu_fusion_migration_root"
name="Fusion Accounting"
sequence="95"
web_icon="fusion_accounting_migration,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
<menuitem id="menu_fusion_migration_wizard"
name="Migrate from Enterprise"
parent="account.menu_finance_configuration"
sequence="95"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_present"/>
<menuitem id="menu_fusion_migration_wizard"
name="Run Migration Wizard"
parent="menu_fusion_migration_root"
action="action_fusion_migration_wizard"
sequence="10"
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
groups="fusion_accounting_core.group_fusion_show_when_enterprise_present"/>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.38',
'version': '19.0.1.1.1',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """
@@ -47,6 +47,7 @@ menu hides; the engine and AI tools remain available for the chat.
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',
'wizards/period_picker_wizard_views.xml',
'views/report_actions.xml',
'views/menu_views.xml',
],
'external_dependencies': {

View File

@@ -15,9 +15,11 @@ export class PeriodFilter extends Component {
async onReportTypeChange(ev) {
const reportType = ev.target.value;
if (reportType && this.state.dateFrom && this.state.dateTo) {
// Switching report type clears the report_code (user is picking
// a different category, not a variant).
await this.reports.runReport(
reportType, this.state.dateFrom, this.state.dateTo,
this.state.comparison);
this.state.comparison, null);
}
}
@@ -27,7 +29,8 @@ export class PeriodFilter extends Component {
await this.reports.runReport(
this.state.currentReportType,
this.state.dateFrom, this.state.dateTo,
this.state.comparison);
this.state.comparison,
this.state.currentReportCode);
}
}

View File

@@ -16,6 +16,7 @@ export class ReportsService {
this.state = reactive({
availableReports: [],
currentReportType: null,
currentReportCode: null,
currentResult: null,
currentAnomalies: [],
currentCommentary: null,
@@ -41,15 +42,17 @@ export class ReportsService {
}
}
async runReport(reportType, dateFrom, dateTo, comparison = 'none') {
async runReport(reportType, dateFrom, dateTo, comparison = 'none', reportCode = null) {
this.state.isLoading = true;
this.state.currentReportType = reportType;
this.state.currentReportCode = reportCode;
this.state.dateFrom = dateFrom;
this.state.dateTo = dateTo;
this.state.comparison = comparison;
try {
this.state.currentResult = await this.rpc(`${ENDPOINT_BASE}/run`, {
report_type: reportType,
report_code: reportCode,
date_from: dateFrom,
date_to: dateTo,
comparison: comparison,
@@ -136,7 +139,8 @@ export class ReportsService {
this.state.comparison = mode;
if (this.state.currentReportType) {
return this.runReport(this.state.currentReportType,
this.state.dateFrom, this.state.dateTo, mode);
this.state.dateFrom, this.state.dateTo, mode,
this.state.currentReportCode);
}
}
}

View File

@@ -22,6 +22,11 @@ export class ReportViewer extends Component {
const ctx = this.props.action?.context || {};
const reportType = ctx.default_report_type || 'pnl';
// default_report_code lets multiple reports of the same type
// (e.g. pnl, cash_flow, executive_summary, annual_statements all
// type='pnl') resolve to their own line_specs.
const reportCode = ctx.default_report_code || null;
const comparison = ctx.default_comparison || 'none';
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
@@ -29,7 +34,8 @@ export class ReportViewer extends Component {
const today = new Date();
const year = today.getFullYear();
await this.reports.runReport(
reportType, `${year}-01-01`, `${year}-12-31`, 'none');
reportType, `${year}-01-01`, `${year}-12-31`,
comparison, reportCode);
});
}

View File

@@ -1,21 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fusion_reports_root"
name="Financial Reports"
sequence="50"
web_icon="fusion_accounting_reports,static/description/icon.png"
<!--
Fusion dynamic financial reports live as direct children of the
Community "Reporting" sub-menu (account.menu_finance_reports),
sitting alongside Community's PDF-based "Statement Reports" /
"Partner Reports" / "Taxes & Fiscal" / "Management" wrappers.
Each menu opens an act_window with view_mode='fusion_reports'
(the OWL ReportViewer). report_actions.xml defines the actions.
All gated to the coexistence group so they only appear when
Enterprise's account_reports is uninstalled.
-->
<!-- Top of the list \u2014 daily-driver statements -->
<menuitem id="menu_fusion_reports_pnl"
name="Profit and Loss"
parent="account.menu_finance_reports"
action="action_fusion_report_pnl"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_bs"
name="Balance Sheet"
parent="account.menu_finance_reports"
action="action_fusion_report_balance_sheet"
sequence="11"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_executive_summary"
name="Executive Summary"
parent="account.menu_finance_reports"
action="action_fusion_report_executive_summary"
sequence="12"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_cash_flow"
name="Cash Flow Statement"
parent="account.menu_finance_reports"
action="action_fusion_report_cash_flow"
sequence="13"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_annual_statements"
name="Annual Statements"
parent="account.menu_finance_reports"
action="action_fusion_report_annual_statements"
sequence="14"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Audit / drill-down statements -->
<menuitem id="menu_fusion_reports_tb"
name="Trial Balance"
parent="account.menu_finance_reports"
action="action_fusion_report_trial_balance"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_gl"
name="General Ledger"
parent="account.menu_finance_reports"
action="action_fusion_report_general_ledger"
sequence="21"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Partner-grouped -->
<menuitem id="menu_fusion_reports_aged_receivable"
name="Aged Receivable"
parent="account.menu_finance_reports"
action="action_fusion_report_aged_receivable"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_aged_payable"
name="Aged Payable"
parent="account.menu_finance_reports"
action="action_fusion_report_aged_payable"
sequence="31"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_partner_ledger"
name="Partner Ledger"
parent="account.menu_finance_reports"
action="action_fusion_report_partner_ledger"
sequence="32"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Tax -->
<menuitem id="menu_fusion_reports_tax"
name="Tax Summary"
parent="account.menu_finance_reports"
action="action_fusion_report_tax_summary"
sequence="40"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!--
Tools group at the bottom: custom-period picker, XLSX export wizard,
anomaly browser. Less-frequently used; bundled together so they
don't clutter the report list.
-->
<menuitem id="menu_fusion_reports_tools_group"
name="Tools"
parent="account.menu_finance_reports"
sequence="90"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_open"
name="Open Report..."
parent="menu_fusion_reports_root"
name="Custom Period..."
parent="menu_fusion_reports_tools_group"
action="action_fusion_period_picker_wizard"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_xlsx"
name="Export to XLSX..."
parent="menu_fusion_reports_root"
parent="menu_fusion_reports_tools_group"
action="action_fusion_xlsx_export_wizard"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
@@ -28,7 +118,7 @@
<menuitem id="menu_fusion_reports_anomalies"
name="Anomalies"
parent="menu_fusion_reports_root"
parent="menu_fusion_reports_tools_group"
action="action_fusion_report_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
One ir.actions.act_window per built-in report, each opens the OWL
ReportViewer (view_mode='fusion_reports'). The viewer reads
``default_report_type`` and ``default_report_code`` from the action
context to pick which fusion.report to render.
New menus per-report live below; users no longer need to go through
the period-picker wizard for the standard reports.
-->
<!-- ============================================================
CORE REPORTS (one per Fusion engine method)
============================================================ -->
<record id="action_fusion_report_pnl" model="ir.actions.act_window">
<field name="name">Profit and Loss</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'pnl'}</field>
</record>
<record id="action_fusion_report_balance_sheet" model="ir.actions.act_window">
<field name="name">Balance Sheet</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'balance_sheet', 'default_report_code': 'balance_sheet'}</field>
</record>
<record id="action_fusion_report_trial_balance" model="ir.actions.act_window">
<field name="name">Trial Balance</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'trial_balance', 'default_report_code': 'trial_balance'}</field>
</record>
<record id="action_fusion_report_general_ledger" model="ir.actions.act_window">
<field name="name">General Ledger</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'general_ledger', 'default_report_code': 'general_ledger'}</field>
</record>
<!-- ============================================================
SECONDARY PnL VARIANTS (engine.compute_pnl with code)
============================================================ -->
<record id="action_fusion_report_cash_flow" model="ir.actions.act_window">
<field name="name">Cash Flow Statement</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'cash_flow', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_executive_summary" model="ir.actions.act_window">
<field name="name">Executive Summary</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'executive_summary', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_annual_statements" model="ir.actions.act_window">
<field name="name">Annual Statements</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'annual_statements', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_tax_summary" model="ir.actions.act_window">
<field name="name">Tax Summary</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'trial_balance', 'default_report_code': 'tax_summary'}</field>
</record>
<!-- ============================================================
PARTNER-GROUPED REPORTS
============================================================ -->
<record id="action_fusion_report_aged_receivable" model="ir.actions.act_window">
<field name="name">Aged Receivable</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'aged_receivable', 'default_report_code': 'aged_receivable'}</field>
</record>
<record id="action_fusion_report_aged_payable" model="ir.actions.act_window">
<field name="name">Aged Payable</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'aged_payable', 'default_report_code': 'aged_payable'}</field>
</record>
<record id="action_fusion_report_partner_ledger" model="ir.actions.act_window">
<field name="name">Partner Ledger</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'partner_ledger', 'default_report_code': 'partner_ledger'}</field>
</record>
</odoo>

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

@@ -229,35 +229,38 @@ class SaleOrder(models.Model):
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
except Exception as exc:
self.env.cr.execute('ROLLBACK TO SAVEPOINT %s' % savepoint_name)
self.message_post(body=_(
self.message_post(body=(
'Auto-MO group %s failed: %s'
) % (tag or 'single-line', exc))
continue
if created or adopted:
# _() needs a lang in env.context; in shell/cron this may be
# unset. Compose the message with plain format strings — this
# text is an internal chatter log, not user-facing UI.
msg_parts = []
if created:
lines_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)') % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
'MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)' % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
for mo, tag, n in created
])
msg_parts.append(
_('%d draft MO(s) auto-created:<br/>%s') % (
'%d draft MO(s) auto-created:<br/>%s' % (
len(created), lines_html,
)
)
if adopted:
adopted_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(legacy, now line-linked)') % (mo.id, mo.name)
'MO <a href="/odoo/manufacturing/%s">%s</a> '
'(legacy, now line-linked)' % (mo.id, mo.name)
for mo in adopted
])
msg_parts.append(
_('%d legacy MO(s) adopted:<br/>%s') % (
'%d legacy MO(s) adopted:<br/>%s' % (
len(adopted), adopted_html,
)
)

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

@@ -113,6 +113,12 @@ class SaleOrder(models.Model):
string='Margin %',
compute='_compute_margin',
)
x_fc_margin_available = fields.Boolean(
string='Margin Available',
compute='_compute_margin',
help='False when no order line has a costed coating — the '
'margin fields should render "n/a" in the UI.',
)
x_fc_workorder_count = fields.Integer(
string='Active WOs',
@@ -486,24 +492,34 @@ class SaleOrder(models.Model):
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Simple margin: untaxed total minus rolled-up cost from coating configs.
"""Margin = untaxed total rolled-up cost from coating configs.
x_fc_margin_percent is stored as a fraction (0.0 - 1.0) so the
widget='percentage' formats it correctly (a 100% margin reads
as 100%, not 10000%).
widget='percentage' formats 100% as 100%, not 10000%.
x_fc_margin_available is False when NO line has a costed coating
(i.e. fp.coating.config.unit_cost isn't populated anywhere). The
UI should render margin fields as "n/a" in that case rather than
showing a misleading 100%.
"""
for rec in self:
has_cost_data = False
cost = 0.0
for line in rec.order_line:
if line.x_fc_coating_config_id:
cost_per_unit = getattr(
line.x_fc_coating_config_id, 'unit_cost', 0.0,
) or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
cc = line.x_fc_coating_config_id
if not cc:
continue
if 'unit_cost' not in cc._fields:
continue
if cc.unit_cost:
has_cost_data = True
cost_per_unit = cc.unit_cost or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
rec.x_fc_margin_available = has_cost_data
rec.x_fc_margin_amount = (rec.amount_untaxed or 0) - cost
rec.x_fc_margin_percent = (
(rec.x_fc_margin_amount / rec.amount_untaxed)
if rec.amount_untaxed else 0.0
if (rec.amount_untaxed and has_cost_data) else 0.0
)
@api.onchange('upload_rfq_file')

View File

@@ -148,11 +148,21 @@
</group>
<group>
<group string="Margin">
<div colspan="2"
invisible="x_fc_margin_available"
class="text-muted">
<i class="fa fa-info-circle me-1"/>
Margin n/a — coating cost rollup not yet
populated on any line's treatment.
</div>
<field name="x_fc_margin_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
options="{'currency_field': 'currency_id'}"
invisible="not x_fc_margin_available"/>
<field name="x_fc_margin_percent"
widget="percentage"/>
widget="percentage"
invisible="not x_fc_margin_available"/>
<field name="x_fc_margin_available" invisible="1"/>
</group>
</group>
<group>
@@ -188,6 +198,13 @@
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="+ New Direct Order"
class="btn-primary"
display="always"/>
</header>
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>

View File

@@ -6,6 +6,13 @@
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<form string="Direct Order Entry">
<div class="alert alert-info py-2 mb-0 small"
role="alert">
<i class="fa fa-info-circle me-1"/>
Changes are not saved until you click
<strong>Create &amp; Confirm Order</strong>. Closing this
window (Esc or X) discards your entries.
</div>
<div class="alert alert-warning mb-0"
role="alert"
invisible="not missing_info_msg">
@@ -194,7 +201,10 @@
type="object"
string="Create &amp; Confirm Order"
class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
<button string="Cancel"
special="cancel"
class="btn-secondary"
confirm="Discard this order? All header data and line items will be lost."/>
</footer>
</form>
</field>

View File

@@ -13,10 +13,12 @@
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//form/header" position="before">
<div class="alert alert-danger" role="alert"
<div class="alert alert-danger py-1 px-2 mb-0 small"
role="alert"
invisible="not partner_id or not partner_id.x_fc_account_hold">
<strong>Account Hold</strong> — This customer is on account hold.
SO confirmation, invoicing, and shipping are blocked for non-managers.
<i class="fa fa-ban me-1"/>
<strong>Account Hold</strong> — SO confirmation, invoicing
and shipping are blocked for non-managers.
</div>
</xpath>
</field>

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