34 Commits

Author SHA1 Message Date
gsinghpal
d36933d7f4 fix(configurator): wrap t-field widgets in <span> inside table cells
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
Acknowledgement PDF rendering failed with "QWeb widgets do not work
correctly on 'td' elements" — Odoo's qweb compiler rejects
t-field/t-options directly on <td>. Wrap the monetary / qty widgets
in an inner <span> for every cell that uses them (body rows + footer
total).

Caught during browser UAT on S00066 — shell _render_qweb_pdf smoke
test passed earlier because it bypasses the full compile path, but
the production /report/pdf/ endpoint fails the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:59:02 -04:00
gsinghpal
1817f63c67 fix(fusion_accounting_reports): engine accepts report_code to disambiguate
When multiple fusion.report rows share a report_type (e.g. 4 PnL-typed
reports: pnl, cash_flow, executive_summary, annual_statements), the
engine's _get_report previously returned whichever matched the type
filter first \u2014 so all four reports rendered the canonical P&L
line_specs regardless of which report the user selected.

Adds report_code kwarg to compute_pnl, compute_balance_sheet,
compute_trial_balance, compute_gl. Controller /fusion/reports/run now
accepts and forwards report_code. _get_report has a 3-tier resolution:
1. Exact code match (validates type)
2. Canonical (code == report_type)
3. First by sequence

Two new tests assert distinct line_specs render for distinct codes and
that wrong-type code raises ValidationError.

Verified live on westin-v19: pnl/cash_flow/executive_summary/
annual_statements now return 3/9/7/5 rows respectively (was all
3 before).

Made-with: Cursor
2026-04-19 23:58:29 -04:00
gsinghpal
1ebff01d35 feat(fusion_accounting_reports): seed 3 partner-grouped reports
Adds Aged Receivable, Aged Payable, and Partner Ledger as fusion.report
records using the new compute_partner_grouped engine method.

REPORT_TYPES is extended with aged_receivable / aged_payable /
partner_ledger so each report has a unique report_type. The HTTP
controller dispatches these to engine.compute_partner_grouped with
the appropriate account_type via PARTNER_GROUPED_ACCOUNT_TYPE.

Output includes per-partner aging buckets: current, 1-30, 31-60,
61-90, 90+ days.

Westin total: 4 + 4 + 3 = 11 of Enterprise's 22 standard reports.

Made-with: Cursor
2026-04-19 23:55:45 -04:00
gsinghpal
ff6d21a561 feat(fusion_accounting_reports): partner-grouped engine method
Adds engine.compute_partner_grouped(period, account_type=...) that
returns per-partner aggregations with aging buckets (current/1-30/
31-60/61-90/90+). SQL-direct for performance — single GROUP BY query
with conditional sum per bucket.

Foundation for the 3 partner-grouped reports landing in commit 3:
Aged Receivable, Aged Payable, Partner Ledger.

Made-with: Cursor
2026-04-19 23:54:32 -04:00
gsinghpal
6896c71b79 feat(fusion_accounting_reports): seed 4 more standard reports
Adds Cash Flow Statement, Executive Summary, Tax Summary, and Annual
Statements as fusion.report records with line_specs. All work with the
existing engine's bucket-sum pattern — no engine changes needed.

Westin total: 4 + 4 = 8 of Enterprise's 22 standard reports now in
fusion_accounting_reports. Partner-grouped reports (Aged AR/AP,
Partner Ledger) need an engine extension — in commit 2.

Made-with: Cursor
2026-04-19 23:53:16 -04:00
gsinghpal
111792599c fix(configurator): margin % stored as fraction so widget='percentage' formats right
Phase D8 compute was returning x_fc_margin_percent already-multiplied
by 100, but the 'percentage' widget in the SO form multiplies again
for display. Result was 10000% instead of 100%.

Store as 0.0-1.0 fraction; widget handles the multiplier. Caught
during UAT on S00066.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:49:00 -04:00
gsinghpal
679dbaa979 feat(fusion_accounting_followup): per-partner state migration from Enterprise
Migrates Enterprise account_followup per-partner state to Fusion fields:
- res.partner.followup_status -> fusion_followup_status (action_due/no_action)
- res.partner.payment_next_action_date -> fusion_followup_paused_until
  (when future-dated; sets status to 'paused')
- res.partner.followup_line_id -> fusion_followup_last_level_id
  (resolved by name match against migrated levels)

Wired into fusion.migration.wizard.action_run_migration after the
existing _followup_bootstrap_step. Idempotent: skips partners whose
Fusion state is already non-default. Defensive against missing
Enterprise fields (each field probed individually before use).

Closes the per-partner state migration gap that was blocking
Enterprise account_followup uninstall.

Made-with: Cursor
2026-04-19 23:48:22 -04:00
gsinghpal
b15bf2293e fix(configurator/bridge_mrp): address all bugs from code review
Two critical, one important, four polish fixes found by the
pr-review-toolkit code-reviewer.

C1 (CRITICAL) Start-at-node filter dropped later siblings
  fusion_plating_bridge_mrp/models/mrp_production.py:448
  The allowed_ids set was {descendants} ∪ {ancestors}, which wrongly
  excluded nodes that should run AFTER the start node — including
  later siblings of the start node and all operations in subsequent
  sub-processes. Rewrote the upward walk to ALSO include each
  ancestor's later-sequence siblings and their descendants. Smoke on
  ENP-ALUM-BASIC: full=9 WOs, partial from mid-tree 'De-Masking'=5
  WOs (previously was 1).

C2 (CRITICAL) Duplicate MO on re-confirm of pre-PR SOs
  fusion_plating_bridge_mrp/models/sale_order.py:96
  Legacy untagged MOs (created before this PR had line-linkage m2m)
  were not recognized by the untagged idempotency check, so
  re-confirming an already-processed SO would create one additional
  MO per untagged plating line. Fix: pre-scan for a single legacy
  untagged MO and adopt it by linking ALL untagged plating lines
  onto it. Those lines are then treated as covered and no per-line
  MOs are created on top. Smoke: S00066 before=1 MO, after
  re-run=1 MO.

I5 (IMPORTANT) push_to_defaults wrote to pre-bump revision
  fusion_plating_configurator/wizard/fp_direct_order_wizard.py:236
  When create_new_revision=True, _get_or_bump_revision() returned a
  new part record that got written to the SO line, but the
  post-confirm push_to_defaults loop re-read line.part_catalog_id
  (still the OLD rev) and wrote defaults there, defeating the whole
  point of "save as default". Fix: cache resolved parts in a dict
  keyed by wizard-line ID during the build loop, and use that cache
  in the push_to_defaults pass.

I3/I4/I6 (PERF) Computes lacked @api.depends and did per-record
  search_count / search queries
  fusion_plating_configurator/models/sale_order.py
  _compute_nav_counts, _compute_workorder_count, _compute_wo_completion
  now:
  - declare @api.depends
  - batch via read_group across the whole self recordset
  - rebuild {origin: counts} dicts and assign per record

M7 (MEDIUM) No savepoint around per-group MO creation
  fusion_plating_bridge_mrp/models/sale_order.py:_fp_auto_create_mo
  A mid-loop exception left group 1's MO persisted and aborted
  groups 2..N. Wrapped each group's create in SAVEPOINT/RELEASE/
  ROLLBACK TO SAVEPOINT so one bad group no longer corrupts state.

M8 (MEDIUM) Email 'opened' status false-positived on internal CC
  fusion_plating_configurator/models/sale_order.py:_compute_email_status
  Switched from 'any notification is_read' to 'customer partner has
  a read email notification on this SO'.

M9 (LOW) start_at_node_id domain silently empty when coating unset
  fusion_plating_configurator/wizard/fp_direct_order_line.py:94
  Changed `('parent_id', 'child_of', ...)` to
  `('id', 'child_of', ..., or 0)` and clarified the help text.

Regression smoke passed all checks on odoo-entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:35:03 -04:00
gsinghpal
9d8db0f9b1 fix(bank_rec): don't shadow Odoo's _() translation function in action_run_migration
Line 77 was `_ = super().action_run_migration()`, using `_` as a
throwaway variable name. That rebinds the module-level `_` (Odoo's
translation function imported at the top) to whatever super() returns
\u2014 in our case the parent's notification dict.

Lines 84/85 then call `_('Bank-Rec Migration Complete')` which is
now `some_dict('Bank-Rec Migration Complete')` \u2192
TypeError: 'dict' object is not callable.

User hit this when running the migration wizard from the menu.

Fix: drop the assignment; we don't actually use super()'s return value.
Made-with: Cursor
2026-04-19 23:34:45 -04:00
gsinghpal
ef2ccb89cf fix(services): V19 removed 'rpc' service \u2014 import standalone rpc() function
V19 removed the 'rpc' service from the registry. All 4 fusion services
(bank_reconciliation, reports, assets, followup) declared dependencies:
['rpc', ...] and accessed services.rpc in their constructor. At runtime
this caused:

  Error: Some services could not be started: fusion_bank_reconciliation,
  fusion_reports, fusion_assets, fusion_followup. Missing dependencies: rpc

\u2014 which prevented the entire OWL backend from booting (blank screen).

Fix per V19 docs:
- Add 'import { rpc } from "@web/core/network/rpc";'
- Set 'this.rpc = rpc;' in constructor (instead of services.rpc)
- Remove 'rpc' from dependencies list

This is the workspace CLAUDE.md guidance Phase 4's subagent flagged
but didn't act on for backward consistency. V19 actually removed the
service entirely, so the consistency choice was wrong \u2014 fixing now.

All call sites still use this.rpc(...) so no per-method changes needed.
Bundle rebuilt clean; backend boots correctly.

Made-with: Cursor
2026-04-19 23:25:52 -04:00
gsinghpal
51d8ce494d fix(scss): remove forbidden @import "variables" lines breaking V19 asset bundle
Phases 1-3's SCSS files used '@import "variables";' to pull in tokens
from _variables.scss. V19's odoo.addons.base.models.assetsbundle
forbids cross-file SCSS imports for security ('Local import forbidden')
and the asset bundle warning was firing on every web request.

Phase 4 caught + fixed this for fusion_accounting_followup; Phases 1-3
were never updated. Today's deployment surfaced the CSS error reported
by the user.

Resolution:
- Removed @import lines from 7 SCSS files across bank_rec, reports, assets
- Variables come from _variables.scss via manifest concatenation order
  (bundle order is _variables.scss first, then dependent files)
- Replaced documentation comments to NOT contain the literal string
  '@import "variables"' \u2014 Odoo's check is regex-based and was
  matching even SCSS comments

Verified clean: bundle rebuilds with zero 'Local import forbidden'
warnings; all 534 fusion-module tests still pass.

Made-with: Cursor
2026-04-19 21:57:22 -04:00
gsinghpal
190c296240 fix(fusion_accounting_ai): align legacy assets-adapter test with Phase 3 return shape
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
Phase 3 (fusion_accounting_assets) changed list_assets() to return
{count, total, assets} dict instead of a flat list — consistent with
bank_rec.list_unreconciled, reports.run_report, followup.list_overdue.

The pre-existing test in fusion_accounting_ai still asserted isinstance(rows, list)
and was failing on every run since Phase 3 merge. Updated to assert dict shape.

Made-with: Cursor
2026-04-19 21:50:47 -04:00
gsinghpal
12fa20c4f1 Merge Phase 4: AI-augmented customer follow-ups
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
37 tasks shipped on fusion_accounting/phase-4-followup:
- fusion.followup.engine (7-method API: get_overdue, compute_level, send, escalate, pause, reset, snapshot_history)
- 6 aging buckets + 3-level dunning + tone selector
- 5 persisted models (level, run, text_cache, partner inherit, move_line inherit)
- AI: payment risk scoring + LLM follow-up text + templated fallback
- 6 JSON-RPC controller endpoints + reactive frontend service
- 5 OWL components + SCSS + dark mode
- Batch wizard + 2 cron jobs (daily scan + weekly risk refresh)
- 3 default mail templates + 3 default levels
- Migration wizard backfill from account_followup
- Coexistence with Enterprise
- 106 tests passing
- All P95 perf metrics within 1x of budget

ALL 4 PHASES COMPLETE — replaces account_accountant + account_reports + account_asset + account_followup.
2026-04-19 21:48:10 -04:00
gsinghpal
b834ae3117 feat(configurator): complete all deferred Phase D/E/F tasks
Ships the remaining items from the Sales UX Uplift plan:

D2 BOM Items kanban
  New view_sale_order_line_bom_kanban grouped by x_fc_part_catalog_id.
  Smart button 'BOM Items' on SO form opens it.

D5 Archive line
  x_fc_archived Boolean on sale.order.line plus action_archive_line /
  action_unarchive_line. Acknowledgement report filters out archived
  lines.

D6 Add Quoted Lines sub-wizard
  New fp.add.from.quote.wizard parallel to fp.add.from.so.wizard. Pick
  quotes for this customer and clone them into direct-order lines
  carrying part, coating, qty, unit price (from calculated or
  override), and notes. Button '+ Add From Quotes' on wizard Lines tab.

D7 SO Acknowledgement PDF
  New ir.actions.report + QWeb template in configurator/report/.
  Header shows customer / contact / PO / Customer Job #, Bill-To,
  Ship-To, planned start + customer deadline + ship-via. Line table
  skips archived lines. Includes external notes, blanket-order
  callout, and customer-signature + vendor-signature blocks.
  Binding added to sale.order so it shows up under Print menu.

D9 Quick-nav chip bar
  New smart buttons on SO form: Invoices / Pickings / NCRs / Files
  with counts and icons. Each opens a filtered list. NCR button
  appears only when fusion_plating_quality is installed.

D10 SO/WO perspective toggle
  view_sale_order_line_wo_kanban grouped by x_fc_wo_group_tag. Smart
  button 'By WO' on SO form.

D11 Assemblies minimal model
  fp.sale.assembly + fp.sale.assembly.line with name, ship_to, count,
  procured_count, completed_at. UX (forms / kanbans / integration
  into receiving) deferred — model only for now.

D14 Uploaded Files
  Files smart button on SO form opens ir.attachment kanban filtered
  to this SO. Count appears in the chip bar.

F4 Signed tracking
  x_fc_signed_at / x_fc_signed_by / x_fc_is_signed on sale.order +
  action_mark_signed helper. Signed column on quotes list view.

F10 New Quote
  Kept on existing action_fp_quotations (already surfaces the
  default New button).

E5/F9 Action icons per row
  Deferred — requires a custom widget; the native PDF action via the
  Print menu covers 80% of the use case.

Bumped to 19.0.8.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:45:17 -04:00
gsinghpal
b85e208856 chore(bridge_mrp): bump to 19.0.7.0.0 — WO group + start-at-node wiring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:35:59 -04:00
gsinghpal
e3001b5297 feat(bridge_mrp): honour x_fc_wo_group_tag + x_fc_start_at_node_id
Two features from Phases B/C that were previously only data now do work:

1. WO GROUPING (x_fc_wo_group_tag)
   _fp_auto_create_mo rewritten to iterate order_lines and group by
   x_fc_wo_group_tag. Lines sharing a tag collapse into ONE MO with
   product = first line's part.product_id, qty = Σ line qty,
   recipe = first line's coating_config.recipe_id. Untagged lines
   each get their own MO. Legacy path preserved for service-line SOs
   with no plating data.

   Idempotency is per (origin, tag): re-confirming an SO doesn't
   create duplicate MOs for already-grouped lines.

   New on mrp.production:
   - x_fc_wo_group_tag (Char, tracking)
   - x_fc_sale_order_line_ids (M2M back to sale.order.line)
   - x_fc_start_at_node_id (Many2one fusion.plating.process.node)

2. START-AT-NODE (x_fc_start_at_node_id)
   _generate_workorders_from_recipe pre-computes allowed_ids as the
   set of {descendants of start_node} ∪ {ancestors of start_node}.
   _is_node_included rejects any node outside that set. This skips
   sibling branches earlier in the recipe while keeping the
   container hierarchy so WO sequence numbers still make sense.

Smoke-tested S00070 (4 lines, 2 tagged groups + 1 untagged) -> 3 MOs:
WO#A qty=15 (2 lines batched), WO#B qty=50 (1 line), untagged qty=7
(1 line). Each got the ENP-ALUM-BASIC recipe.

Start-at-node smoke on the same recipe: full generation = 9 WOs,
partial with start_at='Ready for processing' = 1 WO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:34:48 -04:00
gsinghpal
97c733b7c3 feat(configurator): Phase F — quotations list uplift
F1 follow-up: x_fc_follow_up_date + x_fc_follow_up_user_id fields on
sale.order, surfaced in the quotations list + a 'Needs Follow-Up'
preset filter.

F2 expires: native validity_date exposed as togglable column on the
quotes list + an 'Expired' preset filter.

F3 email status pills: x_fc_email_status computed (draft / sent /
opened / won). 'Opened' detects via mail.notification.is_read on any
email-type mail.message attached to this SO.

F5 part numbers summary: x_fc_part_numbers_summary ("PN1, PN2 (+3
more)") across order_line parts, togglable column.

F7 from-RFQ filter reuses existing x_fc_rfq_attachment_id.

Views:
- view_sale_order_list_fp_quotes (new list dedicated to quotes).
- view_sale_order_search_fp_quotes with filters Draft / Sent / Won /
  From RFQ / Needs Follow-Up / Expired + group-bys.
- action_fp_quotations rewired to both of the above.

Bumped to 19.0.7.2.0. Closes all six phases originally planned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:23:41 -04:00
gsinghpal
94eb7ef415 feat(configurator): Phase E — SO list view uplift
E1/E2/E3/E4: list view gets new togglable columns for
- x_fc_wo_completion (e.g. '3/5'): count of completed vs total WOs
- x_fc_invoiced_amount (Monetary): sum of posted customer invoices
  minus credit notes
- x_fc_margin_amount + x_fc_margin_percent: reuses Phase D8 computes
- x_fc_is_blanket_order toggle

New sale.order.search view (sale.order.search.fp) with preset
filters: My Orders / Open / Confirmed / Done / Blanket / Has Rush /
Overdue, plus group-bys for Customer / Status / Customer Deadline.

Bumped to 19.0.7.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:18:52 -04:00
gsinghpal
3f807d0152 chore(configurator): bump to 19.0.7.0.0 — Phase D first pass landed
Phase D scope landed so far:
- D1 deadline countdown
- D4 internal/external notes split
- D8 margin amount + percent
- D12 contact phone on SO header
- D13 ship via Char
- D3 active WOs stat button

Deferred to later Phase D pass:
- D2 BOM Items grouped list (overlaps with order_line)
- D5 archive line (native Odoo, just needs UI exposure)
- D6 Add Quoted Lines sub-wizard
- D7 SO Acknowledgement PDF report
- D9 Quick-nav link bar
- D10 SO/WO perspective toggle
- D11 Assemblies section (hierarchical BOM)
- D14 Uploaded Files surface (native Odoo attachments)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:13:40 -04:00
gsinghpal
842efd828c feat(configurator): Phase D batch 2 — active WOs stat button on SO form
D3 first half: x_fc_workorder_count computes live count of active MRP
work orders linked to this SO (via mo.origin = so.name). Adds a
'Active WOs' smart button next to the existing PO / RFQ buttons on
the sale.order form. Clicking opens a filtered mrp.workorder list
grouped by MO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:12:34 -04:00
gsinghpal
2476961f50 feat(configurator): Phase D batch 1 — countdown, notes split, margin, contact
Phase D first landing covers the quick-win Steelhead-parity fields on
the SO form / list:

- D1: x_fc_deadline_countdown ("in 2d 3h", "overdue 1d 4h") computed
  from commitment_date. Surfaced in SO form scheduling group and as
  togglable column on the SO list.
- D4: x_fc_internal_note + x_fc_external_note split (html). Existing
  'note' field is left untouched for back-compat. External note is
  intended for the SO acknowledgement + portal; internal note is
  shop-floor only.
- D8: x_fc_margin_amount + x_fc_margin_percent, currently computed
  against fp.coating.config.unit_cost if defined (else 0 -> 100%
  margin). When cost rollup lands on fp.coating.config, margin will
  reflect reality automatically.
- D12: x_fc_contact_phone related to partner.phone (readonly) on SO
  header.
- D13: x_fc_ship_via Char on SO header (carrier name).

Smoke: S00066 shows 'in 9d 22h' countdown + \$3025 margin; S00069
shows 'in 24d 22h' + \$750. Contact phone pulls from partner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:11:18 -04:00
gsinghpal
6b4b0c9eb7 chore(configurator): bump to 19.0.6.2.0 — Phase C direct order polish
Phase C complete on odoo-entech. Smoke-tested S00069:
- C1 x_fc_start_at_node_id = Ready for De-Masking (resume-rework)
- C2 x_fc_part_wo_description = internal rework note
- C5 x_fc_is_one_off = False
- C3 x_fc_quote_id slot wired (no quote picked in this smoke)
- C4 push-to-defaults wrote EN High-Phos back onto part catalog

Phase D (SO detail view), Phase E (SO list view), and Phase F
(Quotes list) are independent tracks — outlined in the plan doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:09:00 -04:00
gsinghpal
31bd8d1e56 feat(configurator): C3 — link direct-order line to a prior quote
Adds quote_id (Many2one fp.quote.configurator) on the wizard line
with a domain scoped to the wizard's customer + quote states (sent /
accepted / won). Onchange auto-fills part, coating, and unit price
(final = estimator_override_price or calculated_price, per-part).

Mirrors x_fc_quote_id on sale.order.line for the audit trail. Surfaced
as a togglable column on the SO line tree and under "Qty & Price" on
the wizard line drill-in form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:07:48 -04:00
gsinghpal
d437d1d959 feat(configurator): C4 — push coating + treatments back to part catalog defaults
Adds x_fc_default_coating_config_id and x_fc_default_treatment_ids
fields on fp.part.catalog. Wizard line gets a push_to_defaults
toggle. After action_create_order confirms the SO, any line with
push_to_defaults=True writes its coating + treatments back onto the
part catalog entry as the new defaults.

Reverse direction too: onchange on part_catalog_id in the wizard
line seeds coating + treatments from the part's defaults (if set and
the line doesn't already have them).

Part catalog form gets a new "Defaults" tab showing the stored
defaults. Smoke-tested: pushing default on order 1 populates the
catalog entry; new wizard line for that part auto-seeds the coating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:04:30 -04:00
gsinghpal
43a26b6849 feat(configurator): Phase C polish — to-node picker, WO description, one-off flag
C1: start_at_node_id per wizard line, mirrors to x_fc_start_at_node_id
on sale.order.line. Domain filters to nodes descending from the
coating_config's recipe so the estimator only picks valid resume
points. bridge_mrp will use this in a follow-up to skip ancestor
steps in the generated work order.

C2: part_wo_description (separate from customer-facing line_description)
lets the planner add internal-only notes that appear on the travelling
sheet only. Mirrors to x_fc_part_wo_description on sale.order.line.

C5: is_one_off flag for prototype / non-catalog parts. Mirrors to
x_fc_is_one_off. Actual skip-catalog behaviour will be wired in a
later pass.

All three fields appear in the wizard line drill-in form (under a new
"Work Order (internal)" group) and as togglable columns on the
sale.order.line tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:01:25 -04:00
gsinghpal
059276886d chore(configurator): bump to 19.0.6.1.0 — Phase B direct order wizard
Phase B complete on odoo-entech:
- B1/B2: Blanket + Block Partial flags on wizard header + sale.order
- B3: x_fc_wo_group_tag per SO line (bridge_mrp will use this to
  batch MOs in a follow-up)
- B4: 'Add From Prior SO' sub-wizard for repeat orders
- B5: Per-line is_missing_info compute + amber row decoration
- B6: Rush already on line (added in Phase A)

Smoke-tested: wizard accepts 4 lines (1 with missing price, 3 WO-tagged
across 2 groups), banner shows correctly, missing row highlighted in
amber, after fix SO creates cleanly with all flags + tags persisted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:50:49 -04:00
gsinghpal
9642a07306 feat(configurator): 'Add From Prior SO' sub-wizard for repeat orders
Task B4. New fp.add.from.so.wizard transient model: given the current
direct-order wizard + customer, lists the customer's prior confirmed
sale orders, lets the estimator tick source lines, and clones them
into fp.direct.order.line rows (part, coating, treatments, qty,
price, deadline, rush, WO group, description).

Button "+ Add From Prior SO" lives on the Lines tab of the main
wizard, visible once the customer is picked. Sub-wizard rejects
source lines that predate the new plating fields (no x_fc_part_catalog_id).

Smoke-tested on odoo-entech: copying all 3 lines of S00066 onto a
fresh wizard reproduces part/coating/qty/price correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:48:52 -04:00
gsinghpal
f55022c3d6 feat(configurator): blanket/block-partial flags + WO group + per-line missing indicator
Phase B partial landing (B1, B2, B3, B5):

- B1/B2: x_fc_is_blanket_order and x_fc_block_partial_shipments on
  sale.order; matching booleans on the wizard header.
- B3: x_fc_wo_group_tag Char on sale.order.line and wo_group_tag on
  wizard line. Free-text tag; bridge_mrp will batch lines sharing a
  tag into one MO in a follow-up.
- B5: is_missing_info computed Boolean on fp.direct.order.line;
  tree uses decoration-warning to highlight incomplete rows in amber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:45:27 -04:00
gsinghpal
f0c3661277 chore(configurator): bump to 19.0.6.0.0 for multi-line direct order wizard
Task A8. Closes Phase A of the direct-order rewrite.

Smoke-tested on odoo-entech: wizard accepts 3 lines (qty 65, total
\$3,025 + tax -> \$3,418.25), creates SO S00066 in state=sale with all
header fields (customer job #, three deadlines, bill/ship addresses)
and per-line fields (part, coating, qty, price) populated correctly.

Phase A complete. Phase B (blanket flag, block partial, WO grouping,
add-from-SO, missing-info banner polish) and Phase C (to-node picker,
quote link, push-defaults) outlined in the plan doc; Phases D/E/F
(SO detail, SO list, quotes list) are separate tracks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:40:44 -04:00
gsinghpal
6fa4140d11 feat(configurator): surface new direct-order fields on sale order form + list
Task A7. SO form Plating tab gets a new "Customer Reference /
Scheduling" block showing customer_job_number, planned_start_date,
internal_deadline, commitment_date (as Customer Deadline). Order line
tree in SO form now shows per-line part / coating / treatments /
deadline / rush. SO list view exposes customer job # and both
deadlines as togglable columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:38:39 -04:00
gsinghpal
e34c1bcc8d refactor(configurator): multi-line direct order wizard with notebook form
Tasks A3 + A6. Wizard rewritten as header + lines architecture:

- Header carries customer/addresses/PO/deadlines/invoicing/notes.
- One SO line created per fp.direct.order.line, carrying part,
  coating, treatments M2M, qty, price, per-line deadline, rush flag,
  and description.
- action_create_order loops wizard lines, invokes revision-bump
  helper, and builds order_line tuples with x_fc_* fields.
- Form view uses notebook (Lines tab with editable tree + drill-in
  form, Notes tab), amber missing-info banner at top, running totals
  at bottom. Customer deadline maps to Odoo commitment_date on SO.

Single-line fields and their computes/onchanges removed from wizard;
moved to fp.direct.order.line in task A4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:37:11 -04:00
gsinghpal
95db3aff0f feat(configurator): x_fc_* fields on sale.order + new sale.order.line extensions
Task A5. Adds customer_job_number, planned_start_date, and
internal_deadline on sale.order. Customer deadline maps to Odoo's
native commitment_date. Creates sale_order_line.py with per-line
plating fields: part_catalog_id, coating_config_id, treatment_ids
M2M, part_deadline, rush_order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:33:50 -04:00
gsinghpal
9423a93961 feat(configurator): fill per-line logic (price lookup, desc template, rev bump)
Task A4. Expands fp.direct.order.line with: part related fields,
optional new-revision block, additional treatment M2M, per-line
deadline + rush flag, description template + free-text, onchange
auto-price-lookup from customer price list, onchange template
suggestion (part > customer > coating), and _get_or_bump_revision
helper that will be called by the SO-creation loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:32:34 -04:00
gsinghpal
057157587d feat(configurator): add header fields + line O2M to direct order wizard
Task A2 of the direct-order-wizard rewrite. Adds SO-header fields for
customer job #, three deadlines (planned start / internal / customer),
bill-to / ship-to address pickers, the line_ids O2M linking to
fp.direct.order.line, computed order totals, and a missing-info
warning banner. Partner onchange now also seeds default addresses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:31:14 -04:00
48 changed files with 2796 additions and 356 deletions

View File

@@ -140,7 +140,11 @@ class TestFollowupAdapter(TransactionCase):
@tagged('post_install', '-at_install')
class TestAssetsAdapter(TransactionCase):
def test_list_assets_returns_list(self):
def test_list_assets_returns_dict_with_assets(self):
# Phase 3 (fusion_accounting_assets) wired list_assets to return
# {count, total, assets} — consistent with bank_rec.list_unreconciled etc.
adapter = get_adapter(self.env, 'assets')
rows = adapter.list_assets()
self.assertIsInstance(rows, list)
self.assertIsInstance(rows, dict)
self.assertIn('assets', rows)
self.assertIsInstance(rows['assets'], list)

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
.o_fusion_assets {
background: $asset-bg-secondary;

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_assets {
background: #1f2937; color: #f9fafb;

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/assets";
export class AssetsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -142,7 +144,7 @@ export class AssetsService {
}
export const assetsService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new AssetsService(env, services); },
};

View File

@@ -74,7 +74,9 @@ class FusionMigrationWizard(models.TransientModel):
Phase 0) and then runs the bank-rec bootstrap. Returns a
notification summarizing both.
"""
_ = super().action_run_migration()
# Don't bind super()'s return value to `_` \u2014 that shadows the
# imported translation function and breaks the _("...") calls below.
super().action_run_migration()
result = self._bank_rec_bootstrap_step()
return {
'type': 'ir.actions.client',

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// ============================================================
// AI Suggestion strip (inline, on each statement line card)

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
// ============================================================
// Bank reconciliation kanban container

View File

@@ -1,5 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// Activated via [data-color-scheme="dark"] on body or any ancestor.
// Mirrors Odoo's standard dark-mode trigger pattern.

View File

@@ -14,13 +14,15 @@ import { registry } from "@web/core/registry";
import { reactive, useState, EventBus } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { browser } from "@web/core/browser/browser";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/bank_rec";
export class BankReconciliationService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is no longer a service — imported as a standalone function above.
this.rpc = rpc;
this.notification = services.notification;
this.orm = services.orm;
@@ -400,7 +402,7 @@ export class BankReconciliationService {
}
export const bankReconciliationService = {
dependencies: ["rpc", "notification", "orm"],
dependencies: ["notification", "orm"],
start(env, services) {
return new BankReconciliationService(env, services);
},

View File

@@ -78,10 +78,120 @@ class FusionMigrationWizard(models.TransientModel):
result['created'], result['skipped'], len(result['errors']))
return result
def _followup_partner_state_bootstrap_step(self):
"""Migration step: copy Enterprise account_followup per-partner state
onto Fusion's fields on res.partner.
Idempotent: only updates partners whose Fusion field is at default
(no_action) and whose Enterprise field has a non-default value.
"""
self.ensure_one()
_logger.info("fusion_accounting_followup partner-state migration starting")
Partner = self.env['res.partner'].sudo()
has_status = 'followup_status' in Partner._fields
has_next_date = 'payment_next_action_date' in Partner._fields
has_line = 'followup_line_id' in Partner._fields
if not (has_status or has_next_date or has_line):
_logger.info(
"Enterprise account_followup partner fields not present \u2014 skipping")
return {
'step': 'followup_partner_state',
'enterprise_module_present': False,
'updated': 0, 'skipped': 0, 'errors': [],
}
result = {
'step': 'followup_partner_state',
'enterprise_module_present': True,
'updated': 0, 'skipped': 0, 'errors': [],
}
domain_terms = []
if has_status:
domain_terms.append(('followup_status', '!=', 'no_action_needed'))
if has_next_date:
domain_terms.append(('payment_next_action_date', '!=', False))
if not domain_terms:
_logger.info("No usable Enterprise follow-up fields \u2014 skipping")
return result
if len(domain_terms) > 1:
domain = ['|'] * (len(domain_terms) - 1) + domain_terms
else:
domain = domain_terms
candidates = Partner.search(domain)
_logger.info(
"Found %d partners with non-default Enterprise follow-up state",
len(candidates))
Level = self.env['fusion.followup.level'].sudo()
today = fields.Date.today()
status_map = {
'in_need_of_action': 'action_due',
'with_overdue_invoices': 'action_due',
'no_action_needed': 'no_action',
}
for partner in candidates:
try:
if partner.fusion_followup_status not in (False, 'no_action'):
result['skipped'] += 1
continue
vals = {}
ent_status = (
getattr(partner, 'followup_status', None)
if has_status else None)
if ent_status and ent_status in status_map:
vals['fusion_followup_status'] = status_map[ent_status]
next_date = (
getattr(partner, 'payment_next_action_date', False)
if has_next_date else False)
if next_date and next_date > today:
vals['fusion_followup_paused_until'] = next_date
vals['fusion_followup_status'] = 'paused'
ent_line = (
getattr(partner, 'followup_line_id', None)
if has_line else None)
if ent_line:
fusion_level = Level.search([
('name', '=', ent_line.name),
], limit=1)
if fusion_level:
vals['fusion_followup_last_level_id'] = fusion_level.id
if vals:
partner.write(vals)
result['updated'] += 1
_logger.debug(
"Migrated partner %s: %s", partner.name, vals)
else:
result['skipped'] += 1
except Exception as e:
result['errors'].append(
f"Partner {partner.id} ({partner.name}): {e}")
_logger.warning(
"Migration failed for partner %s: %s", partner.id, e)
_logger.info(
"fusion_accounting_followup partner-state migration: "
"updated=%d skipped=%d errors=%d",
result['updated'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._followup_bootstrap_step()
except Exception as e:
_logger.warning("followup_bootstrap_step failed: %s", e)
try:
self._followup_partner_state_bootstrap_step()
except Exception as e:
_logger.warning("followup_partner_state_bootstrap_step failed: %s", e)
return result

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/followup";
export class FollowupService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -138,7 +140,7 @@ export class FollowupService {
}
export const followupService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new FollowupService(env, services); },
};

View File

@@ -19,3 +19,12 @@ class TestFollowupMigrationRoundTrip(TransactionCase):
# Second run skips what first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])
def test_partner_state_bootstrap_step(self):
"""Verify the partner-state migration step runs without error."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._followup_partner_state_bootstrap_step()
self.assertEqual(result['step'], 'followup_partner_state')
self.assertIn(result['enterprise_module_present'], [True, False])
self.assertGreaterEqual(result['updated'], 0)
self.assertGreaterEqual(result['skipped'], 0)

View File

@@ -36,6 +36,13 @@ menu hides; the engine and AI tools remain available for the chat.
'data/report_balance_sheet.xml',
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/report_cash_flow.xml',
'data/report_executive_summary.xml',
'data/report_tax_report.xml',
'data/report_annual_statements.xml',
'data/report_aged_receivable.xml',
'data/report_aged_payable.xml',
'data/report_partner_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',

View File

@@ -18,7 +18,16 @@ from ..services.date_periods import Period
_logger = logging.getLogger(__name__)
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
REPORT_TYPES = {
'pnl', 'balance_sheet', 'trial_balance', 'general_ledger',
'aged_receivable', 'aged_payable', 'partner_ledger',
}
PARTNER_GROUPED_ACCOUNT_TYPE = {
'aged_receivable': 'asset_receivable',
'aged_payable': 'liability_payable',
'partner_ledger': 'asset_receivable',
}
def _parse_date(value):
@@ -56,7 +65,7 @@ class FusionReportsController(http.Controller):
@http.route('/fusion/reports/run', type='jsonrpc', auth='user')
def run(self, report_type, date_from=None, date_to=None,
comparison='none', company_id=None):
comparison='none', company_id=None, report_code=None):
if report_type not in REPORT_TYPES:
raise ValidationError(_("Unknown report type: %s") % report_type)
company_id = int(company_id) if company_id else request.env.company.id
@@ -66,19 +75,33 @@ class FusionReportsController(http.Controller):
period = _build_period(date_from, date_to)
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
report_code=report_code,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
_parse_date(date_to),
comparison=comparison,
company_id=company_id,
report_code=report_code,
)
if report_type == 'trial_balance':
period = _build_period(date_from, date_to)
return engine.compute_trial_balance(period, company_id=company_id)
return engine.compute_trial_balance(
period, company_id=company_id, report_code=report_code,
)
if report_type in PARTNER_GROUPED_ACCOUNT_TYPE:
period = _build_period(date_from, date_to)
return engine.compute_partner_grouped(
period,
account_type=PARTNER_GROUPED_ACCOUNT_TYPE[report_type],
comparison=comparison,
company_id=company_id,
)
# general_ledger
period = _build_period(date_from, date_to)
return engine.compute_gl(period, company_id=company_id)
return engine.compute_gl(
period, company_id=company_id, report_code=report_code,
)
@http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user')
def drill_down(self, account_id, date_from, date_to, company_id=None):

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_payable" model="fusion.report">
<field name="name">Aged Payable</field>
<field name="code">aged_payable</field>
<field name="report_type">aged_payable</field>
<field name="sequence">36</field>
<field name="description">Per-vendor outstanding payables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Payable', 'account_type_for_grouping': 'liability_payable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_receivable" model="fusion.report">
<field name="name">Aged Receivable</field>
<field name="code">aged_receivable</field>
<field name="report_type">aged_receivable</field>
<field name="sequence">35</field>
<field name="description">Per-customer outstanding receivables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Receivable', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_annual_statements" model="fusion.report">
<field name="name">Annual Statements</field>
<field name="code">annual_statements</field>
<field name="report_type">pnl</field>
<field name="sequence">11</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Year-over-year P&amp;L comparison for annual reporting.</field>
<field name="line_specs" eval="[
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
{'label': 'Cost of Goods Sold', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
{'label': 'Gross Profit', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_cash_flow" model="fusion.report">
<field name="name">Cash Flow Statement</field>
<field name="code">cash_flow</field>
<field name="report_type">pnl</field>
<field name="sequence">15</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Cash flow by activity (operating, investing, financing).</field>
<field name="line_specs" eval="[
{'label': 'Operating Activities', 'level': 0},
{'label': 'Net Income (from operations)', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Depreciation Add-back', 'account_type_prefix': 'expense_depreciation', 'sign': 1, 'level': 1},
{'label': 'Operating Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Investing Activities', 'level': 0},
{'label': 'Fixed Asset Purchases', 'account_type_prefix': 'asset_fixed', 'sign': -1, 'level': 1},
{'label': 'Investing Cash Flow', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
{'label': 'Financing Activities', 'level': 0},
{'label': 'Liabilities (long-term)', 'account_type_prefix': 'liability_non_current', 'sign': 1, 'level': 1},
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': 1, 'level': 1},
{'label': 'Financing Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'NET CHANGE IN CASH', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_executive_summary" model="fusion.report">
<field name="name">Executive Summary</field>
<field name="code">executive_summary</field>
<field name="report_type">pnl</field>
<field name="sequence">5</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Top-level KPI summary: revenue, expenses, net income, key balance positions.</field>
<field name="line_specs" eval="[
{'label': 'PROFIT &amp; LOSS', 'level': 0},
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'BALANCE POSITIONS', 'level': 0},
{'label': 'Cash &amp; Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
{'label': 'Net Working Position', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_partner_ledger" model="fusion.report">
<field name="name">Partner Ledger</field>
<field name="code">partner_ledger</field>
<field name="report_type">partner_ledger</field>
<field name="sequence">40</field>
<field name="description">Per-partner ledger combining receivable and payable activity.</field>
<field name="line_specs" eval="[
{'label': 'Partner Ledger', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_tax_summary" model="fusion.report">
<field name="name">Tax Summary</field>
<field name="code">tax_summary</field>
<field name="report_type">trial_balance</field>
<field name="sequence">25</field>
<field name="description">Tax liability + asset positions. v1: aggregate-level only; per-tax-code breakdown is Phase 2.5.</field>
<field name="line_specs" eval="[
{'label': 'Tax Asset (recoverable)', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 0},
{'label': 'Tax Liability (collected)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 0},
{'label': 'NET TAX POSITION', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -13,6 +13,9 @@ REPORT_TYPES = [
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
('aged_receivable', 'Aged Receivable'),
('aged_payable', 'Aged Payable'),
('partner_ledger', 'Partner Ledger'),
]

View File

@@ -14,7 +14,7 @@ Internal pipeline (per report run):
"""
import logging
from datetime import date
from datetime import date, timedelta
from odoo import _, api, models
from odoo.exceptions import ValidationError
@@ -39,10 +39,17 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_pnl(
self, period: Period, *, comparison: str = 'none',
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""Income statement (P&L) for the given period."""
report = self._get_report('pnl', company_id=company_id)
"""Income statement (P&L) for the given period.
``report_code`` selects between multiple PnL-typed report definitions
(``pnl``, ``cash_flow``, ``executive_summary``, ``annual_statements``).
When omitted, falls back to the canonical ``pnl`` definition.
"""
report = self._get_report(
'pnl', company_id=company_id, code=report_code,
)
return self._compute(
report, period, comparison=comparison, company_id=company_id,
)
@@ -50,11 +57,13 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_balance_sheet(
self, date_to: date, *, comparison: str = 'none',
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""Balance sheet AS OF date_to. Period.date_from is set to a
far-past date so balances are cumulative-since-inception."""
report = self._get_report('balance_sheet', company_id=company_id)
report = self._get_report(
'balance_sheet', company_id=company_id, code=report_code,
)
period = Period(
date_from=date(1970, 1, 1),
date_to=date_to,
@@ -67,10 +76,17 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_trial_balance(
self, period: Period, *, company_id: int | None = None,
report_code: str | None = None,
) -> dict:
"""Trial balance for the given period - every account with
non-zero balance."""
report = self._get_report('trial_balance', company_id=company_id)
non-zero balance.
``report_code`` selects between multiple TB-typed reports (e.g.
``trial_balance``, ``tax_summary``).
"""
report = self._get_report(
'trial_balance', company_id=company_id, code=report_code,
)
return self._compute(
report, period, comparison='none', company_id=company_id,
)
@@ -78,12 +94,14 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_gl(
self, period: Period, *, account_ids: list | None = None,
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""General ledger for the given period.
Returns per-account move-line listings rather than aggregated rows."""
report = self._get_report('general_ledger', company_id=company_id)
report = self._get_report(
'general_ledger', company_id=company_id, code=report_code,
)
company_id = company_id or self.env.company.id
result = self._compute(
report, period, comparison='none', company_id=company_id,
@@ -118,27 +136,188 @@ class FusionReportEngine(models.AbstractModel):
limit=500,
)
@api.model
def compute_partner_grouped(
self, period: Period, *, account_type: str = 'asset_receivable',
comparison: str = 'none', company_id: int | None = None,
) -> dict:
"""Per-partner aggregation report (Aged Receivable, Aged Payable,
Partner Ledger).
Returns a dict with ``rows`` = list of partner-level aggregates.
Each row has the partner_id, partner_name, total residual, and
aging buckets: current / 1-30 / 31-60 / 61-90 / 90+ days past
``period.date_to``.
SQL-direct for performance: a single GROUP BY query with conditional
sum per bucket. Only un-reconciled, posted lines with non-zero
residual at the as-of date are included.
"""
company_id = company_id or self.env.company.id
accounts = self.env['account.account'].sudo().search([
('account_type', '=', account_type),
('company_ids', 'in', company_id),
])
if not accounts:
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'rows': [],
'total': 0.0,
'partner_count': 0,
}
as_of = period.date_to
d30 = as_of - timedelta(days=30)
d60 = as_of - timedelta(days=60)
d90 = as_of - timedelta(days=90)
self.env.cr.execute(
"""
SELECT
COALESCE(p.id, 0) AS partner_id,
COALESCE(p.name, '(no partner)') AS partner_name,
SUM(aml.amount_residual) AS total_residual,
SUM(CASE
WHEN aml.date_maturity >= %s
OR aml.date_maturity IS NULL
THEN aml.amount_residual ELSE 0
END) AS bucket_current,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_1_30,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_31_60,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_61_90,
SUM(CASE
WHEN aml.date_maturity < %s
THEN aml.amount_residual ELSE 0
END) AS bucket_90_plus,
COUNT(*) AS line_count
FROM account_move_line aml
LEFT JOIN res_partner p ON p.id = aml.partner_id
WHERE aml.account_id = ANY(%s)
AND aml.parent_state = 'posted'
AND aml.reconciled = false
AND aml.amount_residual != 0
AND aml.company_id = %s
AND aml.date <= %s
GROUP BY p.id, p.name
HAVING SUM(aml.amount_residual) != 0
ORDER BY total_residual DESC
""",
(
as_of,
as_of, d30,
d30, d60,
d60, d90,
d90,
list(accounts.ids), company_id, as_of,
),
)
rows = []
for r in self.env.cr.dictfetchall():
rows.append({
'partner_id': r['partner_id'] or False,
'partner_name': r['partner_name'] or '(no partner)',
'total': float(r['total_residual'] or 0),
'bucket_current': float(r['bucket_current'] or 0),
'bucket_1_30': float(r['bucket_1_30'] or 0),
'bucket_31_60': float(r['bucket_31_60'] or 0),
'bucket_61_90': float(r['bucket_61_90'] or 0),
'bucket_90_plus': float(r['bucket_90_plus'] or 0),
'line_count': r['line_count'],
})
total = sum(r['total'] for r in rows)
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'company_id': company_id,
'rows': rows,
'total': total,
'partner_count': len(rows),
}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _get_report(self, report_type: str, *, company_id: int | None = None):
"""Look up the active fusion.report definition for a given
type+company. If no per-company override, falls back to global
(company_id=False)."""
def _get_report(
self, report_type: str, *, company_id: int | None = None,
code: str | None = None,
):
"""Look up the active fusion.report definition.
When ``code`` is provided, prefer the report with that exact code
(validating its ``report_type`` matches). Otherwise fall back to
the canonical-by-type lookup: prefer code == report_type, then any
report of that type. Per-company overrides win over global.
"""
Report = self.env['fusion.report'].sudo()
company_id = company_id or self.env.company.id
company_domain = [
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
]
if code:
report = Report.search(
[('code', '=', code)] + company_domain,
order='company_id desc nulls last',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition with code '%s'") % code
)
if report.report_type != report_type:
raise ValidationError(
_("Report '%(code)s' has type '%(actual)s' but '%(expected)s' was expected.")
% {
'code': code,
'actual': report.report_type,
'expected': report_type,
}
)
return report
# No code: prefer the canonical (code == report_type), then any
# other report of that type.
report = Report.search(
[
('report_type', '=', report_type),
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
],
[('code', '=', report_type), ('report_type', '=', report_type)] + company_domain,
order='company_id desc nulls last',
limit=1,
)
if report:
return report
report = Report.search(
[('report_type', '=', report_type)] + company_domain,
order='company_id desc nulls last, sequence',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition for type '%s'") % report_type

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_reports {
background: #1f2937;

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
.o_fusion_reports {
background: $report-bg-secondary;

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/reports";
export class ReportsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -140,7 +142,7 @@ export class ReportsService {
}
export const reportsService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new ReportsService(env, services); },
};

View File

@@ -90,6 +90,75 @@ class TestFusionReportEngine(TransactionCase):
)
self.assertIsInstance(rows, list)
def test_compute_partner_grouped_receivable(self):
period = Period(date(2025, 1, 1), date(2025, 12, 31), 'Test')
result = self.env['fusion.report.engine'].compute_partner_grouped(
period, account_type='asset_receivable',
)
self.assertEqual(result['report_type'], 'partner_grouped')
self.assertEqual(result['account_type'], 'asset_receivable')
self.assertIn('rows', result)
self.assertIn('total', result)
self.assertIn('partner_count', result)
if result['rows']:
for key in (
'partner_name', 'total', 'bucket_current', 'bucket_1_30',
'bucket_31_60', 'bucket_61_90', 'bucket_90_plus',
):
self.assertIn(key, result['rows'][0])
def test_report_code_disambiguates_same_report_type(self):
"""Multiple reports of report_type='pnl' must each be addressable
by code so the engine returns the requested definition's line_specs
(not whichever was first by company_id)."""
spec_one = [
{'label': 'A', 'account_type_prefix': 'income_', 'sign': 1},
]
spec_two = [
{'label': 'X', 'account_type_prefix': 'income_', 'sign': 1},
{'label': 'Y', 'account_type_prefix': 'expense_', 'sign': -1},
{'label': 'Z', 'account_type_prefix': 'asset_', 'sign': 1},
]
self.env['fusion.report'].create({
'name': 'Variant One', 'code': 'variant_one',
'report_type': 'pnl', 'line_specs': spec_one,
'company_id': self.env.company.id,
})
self.env['fusion.report'].create({
'name': 'Variant Two', 'code': 'variant_two',
'report_type': 'pnl', 'line_specs': spec_two,
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
engine = self.env['fusion.report.engine']
r1 = engine.compute_pnl(
period, company_id=self.env.company.id,
report_code='variant_one',
)
r2 = engine.compute_pnl(
period, company_id=self.env.company.id,
report_code='variant_two',
)
self.assertEqual(r1['report_name'], 'Variant One')
self.assertEqual(r2['report_name'], 'Variant Two')
self.assertEqual(len(r1['rows']), 1)
self.assertEqual(len(r2['rows']), 3)
def test_report_code_validates_type_match(self):
"""Asking for a 'pnl' computation but giving a balance_sheet code
should raise ValidationError, not silently mis-render."""
self.env['fusion.report'].create({
'name': 'Wrong Type', 'code': 'wrong_type_test',
'report_type': 'balance_sheet', 'line_specs': [],
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
with self.assertRaises(ValidationError):
self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id,
report_code='wrong_type_test',
)
def test_no_report_raises_validation_error(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
# Inactivate any pre-existing GL definitions so the lookup

View File

@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.10.0',
'version': '19.0.7.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
@@ -58,6 +58,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'data': [
'security/ir.model.access.csv',
'data/fp_work_role_data.xml',
'data/fp_cron_data.xml',
'wizard/fp_recipe_config_wizard_views.xml',
'views/mrp_workcenter_views.xml',
'views/mrp_workorder_views.xml',

View File

@@ -58,6 +58,28 @@ class MrpProduction(models.Model):
compute='_compute_override_count',
)
# ---- WO grouping + start-at-node (from direct-order wizard Phases B/C) ----
x_fc_wo_group_tag = fields.Char(
string='WO Group Tag',
help='Free-text tag shared by all SO lines batched into this MO. '
'Blank means the MO is for a single untagged line.',
tracking=True,
)
x_fc_sale_order_line_ids = fields.Many2many(
'sale.order.line',
'fp_mrp_production_sale_order_line_rel',
'production_id', 'sale_order_line_id',
string='Source SO Lines',
help='The sale.order.line rows that feed this MO. Populated when '
'bridge_mrp batches multiple lines into one MO by WO group tag.',
)
x_fc_start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='For rework: WO generation skips recipe nodes that come '
'before this one. Copied from the first SO line that set it.',
)
# ------------------------------------------------------------------
# T1.4 — Rework / strip-and-replate
# ------------------------------------------------------------------
@@ -418,6 +440,37 @@ class MrpProduction(models.Model):
for override in production.x_fc_override_ids:
override_map[override.node_id.id] = override.included
# Start-at-node: if set, the allowed set is the union of:
# 1. start_node and all its descendants (we run these)
# 2. each ancestor of start_node (to preserve the container
# hierarchy the recipe walker uses to reach start_node)
# 3. at each ancestor level, any LATER-sequence sibling and
# all of its descendants (these come after start_node
# in the flow and must still run)
# Earlier siblings at each level are implicitly skipped.
start_node = production.x_fc_start_at_node_id
allowed_ids = None # None = include everything
if start_node:
Node = self.env['fusion.plating.process.node']
# 1. Descendants of start_node (inclusive)
descendants = Node.search([('id', 'child_of', start_node.id)])
allowed_ids = set(descendants.ids)
# 2+3. Walk up; at each level add the parent and the
# later-sibling subtrees.
cur = start_node
while cur.parent_id:
parent = cur.parent_id
allowed_ids.add(parent.id)
later_sibs = parent.child_ids.filtered(
lambda n: n.sequence > cur.sequence
)
for sib in later_sibs:
sib_descendants = Node.search([
('id', 'child_of', sib.id),
])
allowed_ids |= set(sib_descendants.ids)
cur = parent
# Bind the source SO once per production so walk_node closure
# can read coating config / spec without an extra search per WO.
so = False
@@ -433,13 +486,18 @@ class MrpProduction(models.Model):
def _is_node_included(node):
"""Determine if a node should be included based on opt-in/out
logic and per-job overrides.
logic, per-job overrides, and start-at-node filter.
- disabled: always included (not configurable)
- opt_in: excluded by default, included only with override
- opt_out: included by default, excluded only with override
- If start_at_node is set, nodes outside the allowed
subtree (at-or-below start_node, plus its ancestors)
are always excluded.
"""
nid = node.id
if allowed_ids is not None and nid not in allowed_ids:
return False
opt = node.opt_in_out or 'disabled'
if opt == 'disabled':
return True
@@ -492,6 +550,11 @@ class MrpProduction(models.Model):
'workcenter_id': mrp_wc,
'duration_expected': node.estimated_duration or 0,
'sequence': seq_counter[0],
# Persist the link back to the recipe node so
# downstream behaviour (auto-complete, sign-off,
# automated-vs-manual gating, customer-visibility)
# can resolve in O(1) instead of joining by name.
'x_fc_recipe_node_id': node.id,
}
# Recipe estimated_duration also fills the WO's
# x_fc_dwell_time_minutes — operators see the recipe-
@@ -578,27 +641,69 @@ class MrpProduction(models.Model):
# Recipe auto-assignment from SO coating config
# ------------------------------------------------------------------
def _auto_assign_recipe_from_so(self):
"""If no recipe is set, pull the default recipe from the SO's
coating config (fp.coating.config.recipe_id).
"""Pull the default recipe for this MO when none is set.
Resolution order:
1. SO coating config (fp.coating.config.recipe_id)
2. Recipe whose product_id matches the MO's product
(Steelhead "Product" link on the recipe)
Then, regardless of how the recipe was picked, apply its
`default_lead_time` to MO.date_planned_finished if the planner
hasn't already overridden the date.
"""
from datetime import timedelta
ProcessNode = self.env['fusion.plating.process.node']
for mo in self:
if mo.x_fc_recipe_id:
continue # Already set — respect planner's choice
if not mo.origin:
continue
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if not so or 'x_fc_coating_config_id' not in so._fields:
continue
coating = so.x_fc_coating_config_id
if coating and coating.recipe_id:
mo.x_fc_recipe_id = coating.recipe_id
mo.message_post(
body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
coating.recipe_id.name, coating.name,
),
if not mo.x_fc_recipe_id:
# 1. SO coating config (legacy path)
so = False
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so and 'x_fc_coating_config_id' in so._fields:
coating = so.x_fc_coating_config_id
if coating and coating.recipe_id:
mo.x_fc_recipe_id = coating.recipe_id
mo.message_post(
body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
coating.recipe_id.name, coating.name,
),
)
# 2. Recipe.product_id == MO product
if not mo.x_fc_recipe_id and mo.product_id:
by_product = ProcessNode.sudo().search([
('node_type', '=', 'recipe'),
('product_id', '=', mo.product_id.id),
], limit=1)
if by_product:
mo.x_fc_recipe_id = by_product
mo.message_post(
body=_('Recipe "%s" auto-assigned from product "%s".') % (
by_product.name, mo.product_id.display_name,
),
)
# Lead-time application — recipe lead time wins only if the
# MO's planned finish was at the model default (i.e. operator
# hasn't deliberately scheduled a date).
recipe = mo.x_fc_recipe_id
if recipe and recipe.default_lead_time and not mo.date_finished:
target = fields.Datetime.now() + timedelta(
days=recipe.default_lead_time,
)
# Don't overwrite if the planner already set a tighter
# (earlier) commit date — only push it later if no commit.
if not mo.date_finished or mo.date_finished < target:
mo.date_finished = target
mo.message_post(
body=_(
'Planned finish set to %s '
'(recipe "%s" default lead time = %.1f days).'
) % (target.strftime('%Y-%m-%d %H:%M'),
recipe.name, recipe.default_lead_time),
)
# ------------------------------------------------------------------
# GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs

View File

@@ -94,23 +94,185 @@ class SaleOrder(models.Model):
return res
def _fp_auto_create_mo(self):
"""Create one draft MO per SO that doesn't already have one.
"""Create draft MO(s) for this SO, grouping by x_fc_wo_group_tag.
Resolution order for the manufactured product:
1. The configurator's part catalog → linked product (if any).
2. The configurator's coating config → linked product (if any).
3. The shop's fallback FP-WIDGET (used for service-line orders).
Grouping rules (new in v19.0.7.x):
- Lines sharing a non-empty x_fc_wo_group_tag collapse into ONE MO
with product = first line's part product, qty = sum of line
qtys, recipe = first line's coating_config.recipe_id.
- Lines with blank tag each get their own MO (one-to-one with
the line).
- If the SO has no plating lines at all, fall back to the legacy
one-MO-per-SO path using configurator data.
Resolution for the recipe:
1. configurator.coating_config_id.recipe_id (if the field exists)
2. configurator.part_catalog_id.recipe_id (if the field exists)
3. The first installed fp.process.node of node_type='recipe'.
Idempotent: skips any group for which an MO with matching
(origin, x_fc_wo_group_tag) already exists.
"""
self.ensure_one()
Production = self.env['mrp.production']
existing = Production.search_count([('origin', '=', self.name)])
if existing:
return # idempotent
existing_mos = Production.search([('origin', '=', self.name)])
existing_tags = set(existing_mos.mapped('x_fc_wo_group_tag'))
# Legacy MOs = untagged MOs created before this PR that never
# had x_fc_sale_order_line_ids populated. We adopt them 1-for-1
# onto the first N untagged groups so re-confirm doesn't
# double-book.
legacy_untagged = existing_mos.filtered(
lambda m: not m.x_fc_wo_group_tag and not m.x_fc_sale_order_line_ids
)
# Build groups from SO lines that carry plating data
plating_lines = self.order_line.filtered(
lambda l: l.x_fc_part_catalog_id or l.x_fc_coating_config_id
)
if not plating_lines:
return self._fp_auto_create_mo_legacy()
created = []
adopted = []
# If a legacy untagged MO already exists for this SO, it
# represents the pre-PR "one MO for the whole order" work.
# Adopt it by linking EVERY untagged plating line to it, and
# treat those lines as covered — don't create per-line MOs on
# top of the legacy MO.
untagged_lines = plating_lines.filtered(lambda l: not l.x_fc_wo_group_tag)
tagged_lines = plating_lines - untagged_lines
covered_untagged_ids = set()
if legacy_untagged and untagged_lines:
legacy = legacy_untagged[0]
legacy.write({
'x_fc_sale_order_line_ids': [(4, ln.id) for ln in untagged_lines],
})
adopted.append(legacy)
covered_untagged_ids = set(untagged_lines.ids)
groups = {} # {tag_or_line_key: [lines]}
for line in tagged_lines:
groups.setdefault(line.x_fc_wo_group_tag, []).append(line)
for line in untagged_lines:
if line.id in covered_untagged_ids:
continue # already adopted onto legacy MO
groups['__line__%d' % line.id] = [line]
for key, lines in groups.items():
tag = lines[0].x_fc_wo_group_tag or False
# Skip if we already have an MO for this (origin, tag) pair.
if tag and tag in existing_tags:
continue
if not tag:
# Untagged link-based idempotency (rerun protection)
if Production.search_count([
('origin', '=', self.name),
('x_fc_sale_order_line_ids', 'in', [lines[0].id]),
]):
continue
# Per-group savepoint so one broken group can't block later
# ones AND can't leave partial state committed.
savepoint_name = 'fp_mo_group_%s' % abs(hash(key))
self.env.cr.execute('SAVEPOINT %s' % savepoint_name)
try:
# Resolve product: part catalog's linked product if any,
# else FP-WIDGET fallback.
product = False
for ln in lines:
pc = ln.x_fc_part_catalog_id
if pc and 'product_id' in pc._fields and pc.product_id:
product = pc.product_id
break
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'FP-WIDGET')], limit=1,
)
if not product:
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
self.message_post(body=_(
'Auto-MO skipped (group %s) — no manufacturable '
'product available.'
) % (tag or 'single-line'))
continue
# Recipe: first line's coating -> recipe_id.
recipe = False
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
break
if not recipe:
recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,
)
qty = sum(ln.product_uom_qty for ln in lines) or 1
# Start-at-node: first non-blank wins
start_node = False
for ln in lines:
if ln.x_fc_start_at_node_id:
start_node = ln.x_fc_start_at_node_id
break
mo_vals = {
'product_id': product.id,
'product_qty': qty,
'product_uom_id': product.uom_id.id,
'origin': self.name,
'x_fc_wo_group_tag': tag or False,
'x_fc_sale_order_line_ids': [(6, 0, [ln.id for ln in lines])],
}
if recipe and 'x_fc_recipe_id' in Production._fields:
mo_vals['x_fc_recipe_id'] = recipe.id
if start_node:
mo_vals['x_fc_start_at_node_id'] = start_node.id
mo = Production.create(mo_vals)
created.append((mo, tag, len(lines)))
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=_(
'Auto-MO group %s failed: %s'
) % (tag or 'single-line', exc))
continue
if created or adopted:
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 ''
)
for mo, tag, n in created
])
msg_parts.append(
_('%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)
for mo in adopted
])
msg_parts.append(
_('%d legacy MO(s) adopted:<br/>%s') % (
len(adopted), adopted_html,
)
)
self.message_post(body=Markup('<br/><br/>'.join(msg_parts)))
def _fp_auto_create_mo_legacy(self):
"""Fallback for SOs with no plating order_line data (service lines).
Preserves the pre-v19.0.7 behaviour: one MO per SO using the
configurator's part / coating / recipe references.
"""
self.ensure_one()
Production = self.env['mrp.production']
if Production.search_count([('origin', '=', self.name)]):
return
cfg = self.x_fc_configurator_id if 'x_fc_configurator_id' in self._fields else False
product = False
@@ -132,8 +294,7 @@ class SaleOrder(models.Model):
)
if not product:
self.message_post(body=_(
'Auto-MO skipped — no manufacturable product available '
'(neither part catalog nor FP-WIDGET fallback resolved).'
'Auto-MO skipped — no manufacturable product available.'
))
return
@@ -149,8 +310,7 @@ class SaleOrder(models.Model):
mo = Production.create(mo_vals)
self.message_post(body=Markup(_(
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
'auto-created. Accept the parts and click <b>Assign to Me</b> to '
'release it to the floor.'
'auto-created (legacy path).'
)) % (mo.id, mo.name))
@api.depends(

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.5.2.0',
'version': '19.0.8.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -50,6 +50,9 @@ Provides:
'views/fp_configurator_menu.xml',
'views/fp_sale_description_template_views.xml',
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_wizard_views.xml',
'report/report_so_acknowledgement.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'data/fp_sale_description_template_data.xml',
],

View File

@@ -12,4 +12,7 @@ from . import fp_customer_price_list
from . import fp_sale_description_template
from . import fp_quote_configurator
from . import sale_order
from . import sale_order_line
from . import fp_sale_assembly
from . import res_partner
from . import fp_process_node

View File

@@ -131,6 +131,21 @@ class FpPartCatalog(models.Model):
notes = fields.Html(string='Notes')
active = fields.Boolean(string='Active', default=True)
# ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one(
'fp.coating.config',
string='Default Treatment',
help='Default coating applied when this part is dropped onto a '
'direct order line. Updated when "Save as Default" is ticked.',
)
x_fc_default_treatment_ids = fields.Many2many(
'fp.treatment',
relation='fp_part_catalog_default_treatment_rel',
string='Default Additional Treatments',
help='Default additional treatments. Seeded when "Save as Default" '
'is ticked on a direct order line.',
)
# Substrate density mapping (g/cm³) for material weight calculation
_SUBSTRATE_DENSITY = {
'aluminium': 2.70,

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpSaleAssembly(models.Model):
"""Hierarchical kit / assembly on a sale order line.
A sale.order.line can carry child parts that make up an assembly.
Useful when the customer sends a kit (e.g. housing + cover + two
bolts) and each sub-part needs its own receive count + processing
status but they all bill as one kit.
Phase D11 shipped minimal: just the data model. Full UX (hierarchy
kanban, procurement tracking) is a follow-on.
"""
_name = 'fp.sale.assembly'
_description = 'Fusion Plating - Sales Order Assembly'
_order = 'sequence, id'
name = fields.Char(string='Assembly Name', required=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Parent SO Line',
required=True, ondelete='cascade',
)
order_id = fields.Many2one(
'sale.order', related='sale_order_line_id.order_id',
store=True, readonly=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id', store=True, readonly=True,
)
line_ids = fields.One2many(
'fp.sale.assembly.line', 'assembly_id',
string='Assembly Lines',
)
ship_to = fields.Char(string='Ship To')
count = fields.Integer(string='Count', default=1)
procured_count = fields.Integer(
string='Procured Count',
compute='_compute_procured_count',
)
completed_at = fields.Datetime(string='Completed At')
@api.depends('line_ids.procured_qty')
def _compute_procured_count(self):
for rec in self:
rec.procured_count = sum(rec.line_ids.mapped('procured_qty'))
class FpSaleAssemblyLine(models.Model):
_name = 'fp.sale.assembly.line'
_description = 'Fusion Plating - Assembly Line'
_order = 'sequence, id'
name = fields.Char(string='Part Number', required=True)
sequence = fields.Integer(default=10)
assembly_id = fields.Many2one(
'fp.sale.assembly', required=True, ondelete='cascade',
)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)
qty_per_assembly = fields.Float(string='Qty / Assembly', default=1.0)
procured_qty = fields.Float(string='Procured Qty', default=0.0)

View File

@@ -58,6 +58,454 @@ class SaleOrder(models.Model):
string='Receiving Status', default='not_received', tracking=True,
)
# ---- Direct Order rewrite (Phase A) ----
x_fc_customer_job_number = fields.Char(
string='Customer Job #',
help="Customer's internal job number for cross-referencing.",
tracking=True,
)
x_fc_planned_start_date = fields.Date(
string='Planned Start Date', tracking=True,
)
x_fc_internal_deadline = fields.Date(
string='Internal Deadline', tracking=True,
)
x_fc_is_blanket_order = fields.Boolean(
string='Is Blanket Sales Order',
help='Blanket orders release parts in quantities over time, '
'often with a negotiated price and a fixed expiry.',
tracking=True,
)
x_fc_block_partial_shipments = fields.Boolean(
string='Block Partial Shipments',
help='If set, the order must ship all-or-nothing. '
'Partial pickings are blocked.',
tracking=True,
)
# ---- Phase D: SO detail view polish ----
x_fc_external_note = fields.Html(
string='External Notes',
help='Customer-visible notes. Appear on the SO acknowledgement '
'and customer portal.',
)
x_fc_internal_note = fields.Html(
string='Internal Notes',
help='Internal-only notes for the estimator / planner / shop floor.',
)
x_fc_ship_via = fields.Char(
string='Ship Via',
help='Carrier or delivery method name (UPS, FedEx, customer pickup, etc.).',
tracking=True,
)
x_fc_contact_phone = fields.Char(
related='partner_id.phone', string='Contact Phone', readonly=True,
)
x_fc_deadline_countdown = fields.Char(
string='Deadline',
compute='_compute_deadline_countdown',
)
x_fc_margin_amount = fields.Monetary(
string='Margin',
compute='_compute_margin', currency_field='currency_id',
)
x_fc_margin_percent = fields.Float(
string='Margin %',
compute='_compute_margin',
)
x_fc_workorder_count = fields.Integer(
string='Active WOs',
compute='_compute_workorder_count',
)
# ---- Phase E: list view helpers ----
x_fc_wo_completion = fields.Char(
string='WO Progress',
compute='_compute_wo_completion',
help='Ratio of completed work orders, shown as "3/5 done".',
)
x_fc_invoiced_amount = fields.Monetary(
string='Invoiced',
compute='_compute_invoiced_amount',
currency_field='currency_id',
)
@api.depends('name')
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in self."""
for rec in self:
rec.x_fc_wo_completion = '0/0'
names = [so.name for so in self if so.name]
if not names:
return
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names)],
['production_id.origin', 'state'],
['production_id', 'state'],
lazy=False,
)
# Build {origin: {'done': n, 'total': n}}
# read_group returns production_id as (id, name) tuples; we need
# to translate back to origin. Do a small lookup.
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {} # {origin: [total, done]}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
if not origin:
continue
cnt = r['__count']
bucket = totals.setdefault(origin, [0, 0])
bucket[0] += cnt
if r['state'] == 'done':
bucket[1] += cnt
for rec in self:
if not rec.name:
continue
tot, done = totals.get(rec.name, [0, 0])
rec.x_fc_wo_completion = '%d/%d' % (done, tot) if tot else '0/0'
# ---- Phase F: quotes list view polish ----
x_fc_follow_up_date = fields.Date(
string='Follow-Up Date',
help='Date to chase the customer for a decision on this quote.',
tracking=True,
)
x_fc_follow_up_user_id = fields.Many2one(
'res.users', string='Follow-Up Owner',
help='Who should chase the customer on the follow-up date.',
)
x_fc_email_status = fields.Selection(
[('draft', 'Draft'),
('sent', 'Sent'),
('opened', 'Opened'),
('won', 'Order Received')],
string='Email Status',
compute='_compute_email_status',
store=True,
)
x_fc_part_numbers_summary = fields.Char(
string='Part Numbers',
compute='_compute_part_numbers_summary',
)
x_fc_signed_at = fields.Datetime(
string='Signed On', tracking=True,
help='When the customer signed / accepted this quote.',
)
x_fc_signed_by = fields.Char(
string='Signed By', tracking=True,
help='Name of the customer signatory.',
)
x_fc_is_signed = fields.Boolean(
string='Signed', compute='_compute_is_signed', store=True,
)
@api.depends('x_fc_signed_at')
def _compute_is_signed(self):
for rec in self:
rec.x_fc_is_signed = bool(rec.x_fc_signed_at)
def action_mark_signed(self):
self.ensure_one()
self.write({
'x_fc_signed_at': fields.Datetime.now(),
'x_fc_signed_by': self.partner_id.name,
})
@api.depends('state')
def _compute_email_status(self):
"""Map state + mail tracking to a single visible pill.
- state draft => draft
- state sent => sent (or 'opened' if the customer partner has
a read notification for any email message on this SO)
- state sale / done => won
'Opened' is scoped to the CUSTOMER partner's notifications —
not internal CCs — to avoid false positives from sales-ops
viewing the thread.
"""
for rec in self:
if rec.state in ('sale', 'done'):
rec.x_fc_email_status = 'won'
continue
if rec.state == 'draft':
rec.x_fc_email_status = 'draft'
continue
# state == 'sent'
opened = False
if rec.id and rec.partner_id:
# Look for any read notification on any email message
# of this SO that targeted the customer.
notif_count = self.env['mail.notification'].sudo().search_count([
('mail_message_id.model', '=', 'sale.order'),
('mail_message_id.res_id', '=', rec.id),
('mail_message_id.message_type', '=', 'email'),
('res_partner_id', '=', rec.partner_id.id),
('is_read', '=', True),
])
opened = notif_count > 0
rec.x_fc_email_status = 'opened' if opened else 'sent'
@api.depends('order_line.x_fc_part_catalog_id.part_number')
def _compute_part_numbers_summary(self):
for rec in self:
parts = rec.order_line.mapped('x_fc_part_catalog_id.part_number')
parts = [p for p in parts if p]
if not parts:
rec.x_fc_part_numbers_summary = False
continue
if len(parts) <= 2:
rec.x_fc_part_numbers_summary = ', '.join(parts)
else:
rec.x_fc_part_numbers_summary = '%s, %s (+%d more)' % (
parts[0], parts[1], len(parts) - 2,
)
@api.depends('invoice_ids.amount_total', 'invoice_ids.state',
'invoice_ids.move_type')
def _compute_invoiced_amount(self):
for rec in self:
posted = rec.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'out_invoice'
)
refunds = rec.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'out_refund'
)
rec.x_fc_invoiced_amount = (
sum(posted.mapped('amount_total'))
- sum(refunds.mapped('amount_total'))
)
@api.depends('name')
def _compute_workorder_count(self):
for rec in self:
rec.x_fc_workorder_count = 0
names = [so.name for so in self if so.name]
if not names:
return
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names),
('state', 'not in', ('done', 'cancel'))],
['production_id'],
['production_id'],
lazy=False,
)
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
if origin:
totals[origin] = totals.get(origin, 0) + r['__count']
for rec in self:
rec.x_fc_workorder_count = totals.get(rec.name, 0)
def action_view_workorders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Work Orders',
'res_model': 'mrp.workorder',
'view_mode': 'list,form',
'domain': [('production_id.origin', '=', self.name)],
'context': {'search_default_group_production_id': 1},
}
# ---- Quick-nav counts for smart buttons (Phase D9 / D14) ----
x_fc_invoice_count = fields.Integer(
string='Invoices', compute='_compute_nav_counts',
)
x_fc_ncr_count = fields.Integer(
string='NCRs', compute='_compute_nav_counts',
)
x_fc_picking_count = fields.Integer(
string='Pickings', compute='_compute_nav_counts',
)
x_fc_attachment_count = fields.Integer(
string='Files', compute='_compute_nav_counts',
)
@api.depends('invoice_ids', 'picking_ids')
def _compute_nav_counts(self):
# Invoice + picking counts are cheap (related collections).
for rec in self:
rec.x_fc_invoice_count = len(rec.invoice_ids)
rec.x_fc_picking_count = len(rec.picking_ids)
# Attachment counts — batched read_group.
ids = self.ids
att_counts = {}
if ids:
rows = self.env['ir.attachment'].sudo().read_group(
[('res_model', '=', 'sale.order'),
('res_id', 'in', ids)],
['res_id'], ['res_id'], lazy=False,
)
att_counts = {r['res_id']: r['__count'] for r in rows}
for rec in self:
rec.x_fc_attachment_count = att_counts.get(rec.id, 0)
# NCR counts — only if the module is installed.
NCR = self.env.get('fusion.plating.ncr')
ncr_counts = {}
if ids and NCR is not None and 'sale_order_id' in NCR._fields:
rows = NCR.sudo().read_group(
[('sale_order_id', 'in', ids)],
['sale_order_id'], ['sale_order_id'], lazy=False,
)
ncr_counts = {
(r['sale_order_id'][0] if r['sale_order_id'] else False):
r['__count']
for r in rows
}
for rec in self:
rec.x_fc_ncr_count = ncr_counts.get(rec.id, 0)
def action_view_invoices(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Invoices',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', self.invoice_ids.ids)],
}
def action_view_pickings(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Deliveries / Pickings',
'res_model': 'stock.picking',
'view_mode': 'list,form',
'domain': [('id', 'in', self.picking_ids.ids)],
}
def action_view_ncrs(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'NCRs',
'res_model': 'fusion.plating.ncr',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
}
def action_view_files(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Files',
'res_model': 'ir.attachment',
'view_mode': 'kanban,list,form',
'domain': [
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
],
'context': {
'default_res_model': 'sale.order',
'default_res_id': self.id,
},
}
def action_view_bom_items(self):
"""Open SO lines grouped by part catalog (Phase D2)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'BOM Items - %s' % self.name,
'res_model': 'sale.order.line',
'view_mode': 'kanban,list,form',
'views': [
(self.env.ref('fusion_plating_configurator.view_sale_order_line_bom_kanban').id, 'kanban'),
(False, 'list'),
(False, 'form'),
],
'domain': [('order_id', '=', self.id)],
}
def action_view_wo_perspective(self):
"""Open SO lines grouped by WO tag (Phase D10)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Lines by WO - %s' % self.name,
'res_model': 'sale.order.line',
'view_mode': 'kanban,list',
'views': [
(self.env.ref('fusion_plating_configurator.view_sale_order_line_wo_kanban').id, 'kanban'),
(False, 'list'),
],
'domain': [('order_id', '=', self.id)],
}
@api.depends('commitment_date')
def _compute_deadline_countdown(self):
from datetime import datetime
now = fields.Datetime.now()
for rec in self:
if not rec.commitment_date:
rec.x_fc_deadline_countdown = False
continue
target = rec.commitment_date
if isinstance(target, datetime):
delta = target - now
else:
from datetime import datetime as _dt
delta = _dt.combine(target, _dt.min.time()) - now
secs = int(delta.total_seconds())
if secs == 0:
rec.x_fc_deadline_countdown = 'due now'
continue
past = secs < 0
secs = abs(secs)
days = secs // 86400
hours = (secs % 86400) // 3600
mins = (secs % 3600) // 60
bits = []
if days:
bits.append('%dd' % days)
if hours:
bits.append('%dh' % hours)
if mins and not days:
bits.append('%dm' % mins)
phrase = ' '.join(bits) or '<1m'
rec.x_fc_deadline_countdown = (
'overdue %s' % phrase if past else 'in %s' % phrase
)
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Simple margin: untaxed total minus 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%).
"""
for rec in self:
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)
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
)
@api.onchange('upload_rfq_file')
def _onchange_upload_rfq_file(self):
"""Create attachment from uploaded binary and link it."""

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.
from odoo import fields, models
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)
x_fc_coating_config_id = fields.Many2one(
'fp.coating.config', string='Primary Treatment',
)
x_fc_treatment_ids = fields.Many2many(
'fp.treatment', string='Additional Treatments',
)
x_fc_part_deadline = fields.Date(string='Part Deadline')
x_fc_rush_order = fields.Boolean(string='Rush')
x_fc_wo_group_tag = fields.Char(
string='Work Order Group',
help='Lines sharing a tag (e.g. "WO#1") will be batched into one '
'manufacturing order when bridge_mrp generates MOs.',
)
x_fc_part_wo_description = fields.Text(
string='On Work Order',
help='Extra detail printed on the work order travelling sheet. '
'Separate from the customer-facing line description.',
)
x_fc_start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='For re-work jobs: pick the recipe step where this job '
'should begin. bridge_mrp skips ancestor steps.',
)
x_fc_is_one_off = fields.Boolean(
string='One-off Part',
help='Flag for prototype / non-catalog parts that should not be '
'reused after this order.',
)
x_fc_quote_id = fields.Many2one(
'fp.quote.configurator',
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
x_fc_archived = fields.Boolean(
string='Archived',
default=False,
help='Archived lines are hidden from the default list view but '
'preserved for audit. Useful when a part is cancelled mid-order.',
)
def action_archive_line(self):
self.write({'x_fc_archived': True})
return True
def action_unarchive_line(self):
self.write({'x_fc_archived': False})
return True

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)
Part of the Fusion Plating product family.
Sales Order Acknowledgement PDF (Phase D7) — a customer-facing
confirmation sent shortly after action_confirm. Includes external
notes, deadlines, and a signature block.
-->
<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_configurator.report_fp_so_acknowledgement_doc</field>
<field name="report_file">fusion_plating_configurator.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">
<div class="page">
<h2 class="mb-4">
<span>Sales Order Acknowledgement - </span>
<span t-field="doc.name"/>
</h2>
<div class="row mb-4">
<div class="col-6">
<strong>Customer</strong><br/>
<span t-field="doc.partner_id"/><br/>
<span t-if="doc.x_fc_contact_phone"
t-field="doc.x_fc_contact_phone"/>
</div>
<div class="col-6">
<strong>References</strong><br/>
<span>Customer PO: </span>
<span t-field="doc.x_fc_po_number"/><br/>
<t t-if="doc.x_fc_customer_job_number">
<span>Customer Job #: </span>
<span t-field="doc.x_fc_customer_job_number"/><br/>
</t>
</div>
</div>
<div class="row mb-4">
<div class="col-6">
<strong>Bill To</strong><br/>
<div t-field="doc.partner_invoice_id"
t-options='{"widget": "contact", "fields": ["address"], "no_marker": true}'/>
</div>
<div class="col-6">
<strong>Ship To</strong><br/>
<div t-field="doc.partner_shipping_id"
t-options='{"widget": "contact", "fields": ["address"], "no_marker": true}'/>
</div>
</div>
<div class="row mb-4">
<div class="col-4">
<strong>Planned Start:</strong>
<span t-field="doc.x_fc_planned_start_date"/>
</div>
<div class="col-4">
<strong>Customer Deadline:</strong>
<span t-field="doc.commitment_date"/>
</div>
<div class="col-4">
<strong>Ship Via:</strong>
<span t-field="doc.x_fc_ship_via"/>
</div>
</div>
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Part</th>
<th>Treatment</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="doc.order_line.filtered(lambda l: not l.x_fc_archived)"
t-as="line">
<td>
<span t-field="line.x_fc_part_catalog_id.part_number"/>
<br/>
<small t-field="line.name"/>
</td>
<td>
<span t-field="line.x_fc_coating_config_id"/>
</td>
<td class="text-end">
<span t-field="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>
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-end">
<strong>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>
</tfoot>
</table>
<div t-if="doc.x_fc_external_note" class="mt-4">
<strong>Notes</strong>
<div t-field="doc.x_fc_external_note"/>
</div>
<div t-if="doc.x_fc_is_blanket_order" class="alert alert-info mt-3">
<strong>Blanket Order.</strong>
Parts will be released in quantities over time.
<span t-if="doc.x_fc_block_partial_shipments">
Partial shipments are blocked; the order ships
as one complete batch.
</span>
</div>
<div class="mt-5">
<table class="table table-borderless">
<tr>
<td style="width: 50%;">
<strong>Customer Signature</strong><br/>
<div style="border-bottom: 1px solid #333; height: 40px;"/>
<small>Signed name / date</small>
</td>
<td style="width: 50%;">
<strong>Nexa Systems / EN Technologies</strong><br/>
<div style="border-bottom: 1px solid #333; height: 40px;"/>
<small>
<span t-field="doc.user_id"/>
</small>
</td>
</tr>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -21,6 +21,16 @@ access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_f
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_direct_order_line_estimator,fp.direct.order.line.estimator,model_fp_direct_order_line,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_direct_order_line_manager,fp.direct.order.line.manager,model_fp_direct_order_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_add_from_so_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sale_assembly_line_user,fp.sale.assembly.line.user,model_fp_sale_assembly_line,base.group_user,1,0,0,0
access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_sale_assembly_line,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_customer_price_list_operator,fp.customer.price.list.operator,model_fp_customer_price_list,fusion_plating.group_fusion_plating_operator,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
21 access_fp_direct_order_wizard_manager fp.direct.order.wizard.manager model_fp_direct_order_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
22 access_fp_direct_order_line_estimator fp.direct.order.line.estimator model_fp_direct_order_line fusion_plating_configurator.group_fp_estimator 1 1 1 1
23 access_fp_direct_order_line_manager fp.direct.order.line.manager model_fp_direct_order_line fusion_plating.group_fusion_plating_manager 1 1 1 1
24 access_fp_add_from_so_wizard_estimator fp.add.from.so.wizard.estimator model_fp_add_from_so_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
25 access_fp_add_from_so_wizard_manager fp.add.from.so.wizard.manager model_fp_add_from_so_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
26 access_fp_add_from_quote_wizard_estimator fp.add.from.quote.wizard.estimator model_fp_add_from_quote_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
27 access_fp_add_from_quote_wizard_manager fp.add.from.quote.wizard.manager model_fp_add_from_quote_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
28 access_fp_sale_assembly_user fp.sale.assembly.user model_fp_sale_assembly base.group_user 1 0 0 0
29 access_fp_sale_assembly_estimator fp.sale.assembly.estimator model_fp_sale_assembly fusion_plating_configurator.group_fp_estimator 1 1 1 1
30 access_fp_sale_assembly_manager fp.sale.assembly.manager model_fp_sale_assembly fusion_plating.group_fusion_plating_manager 1 1 1 1
31 access_fp_sale_assembly_line_user fp.sale.assembly.line.user model_fp_sale_assembly_line base.group_user 1 0 0 0
32 access_fp_sale_assembly_line_estimator fp.sale.assembly.line.estimator model_fp_sale_assembly_line fusion_plating_configurator.group_fp_estimator 1 1 1 1
33 access_fp_sale_assembly_line_manager fp.sale.assembly.line.manager model_fp_sale_assembly_line fusion_plating.group_fusion_plating_manager 1 1 1 1
34 access_fp_part_import_wizard_estimator fp.part.catalog.import.wizard.estimator model_fp_part_catalog_import_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
35 access_fp_part_import_wizard_manager fp.part.catalog.import.wizard.manager model_fp_part_catalog_import_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
36 access_fp_customer_price_list_operator fp.customer.price.list.operator model_fp_customer_price_list fusion_plating.group_fusion_plating_operator 1 0 0 0

View File

@@ -224,6 +224,20 @@
</list>
</field>
</page>
<page string="Defaults" name="direct_order_defaults">
<group>
<field name="x_fc_default_coating_config_id"
options="{'no_create_edit': True}"/>
<field name="x_fc_default_treatment_ids"
widget="many2many_tags"
options="{'no_create_edit': True}"/>
</group>
<p class="text-muted">
Seeds the treatment fields on new direct-order
lines. Updated whenever "Save as Default" is
ticked while placing an order.
</p>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</page>

View File

@@ -33,6 +33,56 @@
<span class="o_stat_text">PO</span>
</div>
</button>
<button name="action_view_workorders"
type="object"
class="oe_stat_button"
icon="fa-cogs"
invisible="x_fc_workorder_count == 0">
<field name="x_fc_workorder_count" widget="statinfo"
string="Active WOs"/>
</button>
<button name="action_view_invoices"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="x_fc_invoice_count == 0">
<field name="x_fc_invoice_count" widget="statinfo"
string="Invoices"/>
</button>
<button name="action_view_pickings"
type="object"
class="oe_stat_button"
icon="fa-truck"
invisible="x_fc_picking_count == 0">
<field name="x_fc_picking_count" widget="statinfo"
string="Pickings"/>
</button>
<button name="action_view_ncrs"
type="object"
class="oe_stat_button"
icon="fa-exclamation-triangle"
invisible="x_fc_ncr_count == 0">
<field name="x_fc_ncr_count" widget="statinfo"
string="NCRs"/>
</button>
<button name="action_view_files"
type="object"
class="oe_stat_button"
icon="fa-paperclip"
invisible="x_fc_attachment_count == 0">
<field name="x_fc_attachment_count" widget="statinfo"
string="Files"/>
</button>
<button name="action_view_bom_items"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
string="BOM Items"/>
<button name="action_view_wo_perspective"
type="object"
class="oe_stat_button"
icon="fa-th-large"
string="By WO"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
@@ -81,8 +131,53 @@
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
<group>
<group string="Customer Reference">
<field name="x_fc_customer_job_number"/>
<field name="x_fc_contact_phone"/>
<field name="x_fc_ship_via"/>
</group>
<group string="Scheduling">
<field name="x_fc_planned_start_date"/>
<field name="x_fc_internal_deadline"/>
<field name="commitment_date" string="Customer Deadline"/>
<field name="x_fc_deadline_countdown" readonly="1"/>
<field name="x_fc_is_blanket_order"/>
<field name="x_fc_block_partial_shipments"/>
</group>
</group>
<group>
<group string="Margin">
<field name="x_fc_margin_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="x_fc_margin_percent"
widget="percentage"/>
</group>
</group>
<group>
<group string="Internal Notes">
<field name="x_fc_internal_note" nolabel="1"
placeholder="Internal notes for estimator / planner / shop floor..."/>
</group>
<group string="External Notes (customer-visible)">
<field name="x_fc_external_note" nolabel="1"
placeholder="Notes that appear on the acknowledgement and portal..."/>
</group>
</group>
</page>
</xpath>
<xpath expr="//field[@name='order_line']/list/field[@name='product_uom_qty']" position="before">
<field name="x_fc_part_catalog_id" optional="show"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
<field name="x_fc_part_deadline" optional="hide"/>
<field name="x_fc_wo_group_tag" optional="hide"/>
<field name="x_fc_start_at_node_id" optional="hide"/>
<field name="x_fc_is_one_off" optional="hide"/>
<field name="x_fc_quote_id" optional="hide"/>
<field name="x_fc_rush_order" optional="hide"/>
</xpath>
</field>
</record>
@@ -96,18 +191,201 @@
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>
<field name="x_fc_part_catalog_id" optional="show"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_customer_job_number" optional="show"/>
<field name="x_fc_internal_deadline" optional="show"/>
<field name="commitment_date" string="Customer Deadline" optional="show"/>
<field name="x_fc_deadline_countdown" optional="show"/>
<field name="x_fc_wo_completion" optional="show"/>
<field name="x_fc_planned_start_date" optional="hide"/>
<field name="x_fc_part_catalog_id" optional="hide"/>
<field name="x_fc_coating_config_id" optional="hide"/>
<field name="amount_total" sum="Total"/>
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="x_fc_margin_amount" sum="Margin" optional="hide"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="x_fc_margin_percent" optional="hide"
widget="percentage"/>
<field name="x_fc_is_blanket_order" optional="hide"/>
<field name="x_fc_receiving_status" widget="badge"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
<field name="x_fc_delivery_method" optional="show"/>
<field name="x_fc_delivery_method" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ===== BOM Items view (lines grouped by part) — Phase D2 ===== -->
<record id="view_sale_order_line_bom_kanban" model="ir.ui.view">
<field name="name">sale.order.line.bom.kanban</field>
<field name="model">sale.order.line</field>
<field name="arch" type="xml">
<kanban default_group_by="x_fc_part_catalog_id" records_draggable="0">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<field name="qty_delivered"/>
<field name="x_fc_wo_group_tag"/>
<field name="x_fc_archived"/>
<field name="currency_id"/>
<templates>
<t t-name="card">
<div class="o_kanban_card_content">
<div class="o_kanban_record_title">
<strong><field name="x_fc_coating_config_id"/></strong>
</div>
<div class="text-muted">
Qty: <field name="product_uom_qty"/>
/ Delivered: <field name="qty_delivered"/>
</div>
<div t-if="record.x_fc_wo_group_tag.raw_value">
<span class="badge bg-info">
<field name="x_fc_wo_group_tag"/>
</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== WO-perspective view: lines grouped by WO tag — Phase D10 ===== -->
<record id="view_sale_order_line_wo_kanban" model="ir.ui.view">
<field name="name">sale.order.line.wo.kanban</field>
<field name="model">sale.order.line</field>
<field name="arch" type="xml">
<kanban default_group_by="x_fc_wo_group_tag" records_draggable="0">
<field name="x_fc_wo_group_tag"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<templates>
<t t-name="card">
<div class="o_kanban_card_content">
<div>
<strong><field name="x_fc_part_catalog_id"/></strong>
</div>
<div class="text-muted">
<field name="x_fc_coating_config_id"/>
</div>
<div>
Qty: <field name="product_uom_qty"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== Quotes list view (state in draft/sent) ===== -->
<record id="view_sale_order_list_fp_quotes" model="ir.ui.view">
<field name="name">sale.order.list.fp.quotes</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Quotations" decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_part_numbers_summary" optional="show"/>
<field name="x_fc_po_number" optional="hide"/>
<field name="x_fc_customer_job_number" optional="hide"/>
<field name="create_date" string="Created" optional="show"/>
<field name="validity_date" string="Expires" optional="show"/>
<field name="x_fc_follow_up_date" optional="show"/>
<field name="x_fc_follow_up_user_id" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="x_fc_is_signed" widget="boolean_toggle"
string="Signed" optional="show"/>
<field name="x_fc_email_status" widget="badge"
decoration-info="x_fc_email_status == 'sent'"
decoration-warning="x_fc_email_status == 'opened'"
decoration-success="x_fc_email_status == 'won'"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ===== Quotes search view ===== -->
<record id="view_sale_order_search_fp_quotes" model="ir.ui.view">
<field name="name">sale.order.search.fp.quotes</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<search string="Quotations">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_part_numbers_summary" string="Part Number"/>
<filter name="my_quotes" string="My Quotes"
domain="[('user_id', '=', uid)]"/>
<separator/>
<filter name="draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="sent" string="Sent"
domain="[('state', '=', 'sent')]"/>
<filter name="won" string="Won"
domain="[('state', 'in', ('sale', 'done'))]"/>
<separator/>
<filter name="from_rfq" string="From RFQ"
domain="[('x_fc_rfq_attachment_id', '!=', False)]"/>
<filter name="needs_followup" string="Needs Follow-Up"
domain="[('x_fc_follow_up_date', '&lt;=', context_today()), ('state', 'in', ('draft', 'sent'))]"/>
<filter name="expired" string="Expired"
domain="[('validity_date', '&lt;', context_today()), ('state', 'in', ('draft', 'sent'))]"/>
<group>
<filter string="Customer" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Follow-Up Owner" name="group_followup"
context="{'group_by': 'x_fc_follow_up_user_id'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Search view for Fusion Plating SO list ===== -->
<record id="view_sale_order_search_fp" model="ir.ui.view">
<field name="name">sale.order.search.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<search string="Sales Orders">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number" string="Customer PO #"/>
<field name="x_fc_customer_job_number" string="Customer Job #"/>
<filter name="my_orders" string="My Orders"
domain="[('user_id', '=', uid)]"/>
<separator/>
<filter name="open_orders" string="Open"
domain="[('state', 'in', ('draft', 'sent', 'sale'))]"/>
<filter name="confirmed" string="Confirmed"
domain="[('state', '=', 'sale')]"/>
<filter name="done" string="Done"
domain="[('state', '=', 'done')]"/>
<separator/>
<filter name="blanket_orders" string="Blanket Orders"
domain="[('x_fc_is_blanket_order', '=', True)]"/>
<filter name="rush_lines" string="Has Rush Line"
domain="[('order_line.x_fc_rush_order', '=', True)]"/>
<filter name="overdue" string="Overdue"
domain="[('commitment_date', '&lt;', context_today()), ('state', 'in', ('sale',))]"/>
<group>
<filter string="Customer" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Customer Deadline" name="group_deadline"
context="{'group_by': 'commitment_date'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action — Quotations (for Fusion Plating menu) ===== -->
<record id="action_fp_quotations" model="ir.actions.act_window">
<field name="name">Quotations</field>
@@ -115,7 +393,8 @@
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('draft', 'sent'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp_quotes')})]"/>
<field name="search_view_id" ref="view_sale_order_search_fp_quotes"/>
<field name="context">{'default_x_fc_delivery_method': 'shipping_partner'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">

View File

@@ -4,4 +4,6 @@
from . import fp_direct_order_wizard
from . import fp_direct_order_line
from . import fp_add_from_so_wizard
from . import fp_add_from_quote_wizard
from . import fp_part_catalog_import_wizard

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpAddFromQuoteWizard(models.TransientModel):
"""Pick fp.quote.configurator rows and clone them onto the direct-order wizard.
Parallels fp.add.from.so.wizard but sources from the quote library
instead of prior sale orders. Each selected quote becomes one
fp.direct.order.line with part, coating, qty and unit price
carried over.
"""
_name = 'fp.add.from.quote.wizard'
_description = 'Fusion Plating - Add Lines From Quotes'
direct_order_wizard_id = fields.Many2one(
'fp.direct.order.wizard',
required=True,
ondelete='cascade',
)
partner_id = fields.Many2one(
related='direct_order_wizard_id.partner_id', readonly=True,
)
quote_ids = fields.Many2many(
'fp.quote.configurator',
string='Quotes to Copy',
domain="[('partner_id', '=', partner_id), ('state', 'in', ['sent', 'accepted', 'won'])]",
help='Select one or more quotes for this customer. Each quote '
'becomes a new line on the direct order.',
)
def action_copy_quotes(self):
self.ensure_one()
if not self.quote_ids:
raise UserError(_('Pick at least one quote to copy.'))
Line = self.env['fp.direct.order.line']
wizard = self.direct_order_wizard_id
copied = 0
for q in self.quote_ids:
if not q.part_catalog_id or not q.coating_config_id:
continue
final = q.estimator_override_price or q.calculated_price
unit = (final / q.quantity) if (final and q.quantity) else 0.0
Line.create({
'wizard_id': wizard.id,
'part_catalog_id': q.part_catalog_id.id,
'coating_config_id': q.coating_config_id.id,
'quantity': int(q.quantity) or 1,
'unit_price': unit,
'quote_id': q.id,
'line_description': q.notes or False,
})
copied += 1
if not copied:
raise UserError(_(
'The selected quotes do not have both part and coating set, '
'so nothing could be copied.'
))
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': wizard.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_add_from_quote_wizard_form" model="ir.ui.view">
<field name="name">fp.add.from.quote.wizard.form</field>
<field name="model">fp.add.from.quote.wizard</field>
<field name="arch" type="xml">
<form string="Add Lines From Quotes">
<sheet>
<div class="oe_title">
<h1>Copy Lines From Quotes</h1>
<p class="text-muted">
Select quotes for this customer. Each becomes a
new line on the direct order with part, coating,
quantity and unit price pre-filled.
</p>
</div>
<group>
<field name="direct_order_wizard_id" invisible="1"/>
<field name="partner_id" readonly="1"/>
</group>
<field name="quote_ids">
<list>
<field name="name"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="calculated_price" widget="monetary"/>
<field name="estimator_override_price" widget="monetary"/>
<field name="currency_id" column_invisible="1"/>
<field name="state"/>
</list>
</field>
</sheet>
<footer>
<button name="action_copy_quotes"
type="object"
string="Copy Selected Quotes"
class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpAddFromSoWizard(models.TransientModel):
"""Pick lines from a prior sale.order and clone them onto the direct-order wizard.
Entry: a button on the direct-order wizard. The source SO list is
filtered to the wizard's current customer. Selected SO lines are
mapped back to fp.direct.order.line rows (part, coating, qty, price,
treatments, description).
"""
_name = 'fp.add.from.so.wizard'
_description = 'Fusion Plating - Add Lines From Prior SO'
direct_order_wizard_id = fields.Many2one(
'fp.direct.order.wizard',
required=True,
ondelete='cascade',
)
partner_id = fields.Many2one(
related='direct_order_wizard_id.partner_id', readonly=True,
)
source_order_id = fields.Many2one(
'sale.order', string='Source Sales Order',
domain="[('partner_id', '=', partner_id), ('state', 'in', ('sale', 'done'))]",
help='Pick a prior confirmed order for this customer.',
)
source_line_ids = fields.Many2many(
'sale.order.line',
string='Lines to Copy',
domain="[('order_id', '=', source_order_id)]",
help='Tick the lines you want to replicate on the new direct order.',
)
@api.onchange('source_order_id')
def _onchange_source_order_id(self):
self.source_line_ids = False
def action_copy_lines(self):
self.ensure_one()
if not self.source_order_id:
raise UserError(_('Pick a source sales order.'))
if not self.source_line_ids:
raise UserError(_('Pick at least one line to copy.'))
Line = self.env['fp.direct.order.line']
wizard = self.direct_order_wizard_id
copied = 0
for src in self.source_line_ids:
if not src.x_fc_part_catalog_id or not src.x_fc_coating_config_id:
# Skip SO lines that predate the plating fields
continue
Line.create({
'wizard_id': wizard.id,
'part_catalog_id': src.x_fc_part_catalog_id.id,
'coating_config_id': src.x_fc_coating_config_id.id,
'treatment_ids': [(6, 0, src.x_fc_treatment_ids.ids)],
'quantity': int(src.product_uom_qty) or 1,
'unit_price': src.price_unit or 0.0,
'part_deadline': src.x_fc_part_deadline,
'rush_order': src.x_fc_rush_order,
'wo_group_tag': src.x_fc_wo_group_tag or False,
'line_description': src.name,
})
copied += 1
if not copied:
raise UserError(_(
'None of the selected lines carry plating part / coating '
'fields, so there was nothing to copy. Pick lines from a '
'direct order created with the new wizard.'
))
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': wizard.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_add_from_so_wizard_form" model="ir.ui.view">
<field name="name">fp.add.from.so.wizard.form</field>
<field name="model">fp.add.from.so.wizard</field>
<field name="arch" type="xml">
<form string="Add Lines From Prior SO">
<sheet>
<div class="oe_title">
<h1>Copy Lines From Prior Order</h1>
<p class="text-muted">
Pick one of this customer's previous confirmed orders,
then tick the lines you want replicated on the new
direct order. Each copied row can still be edited
before confirming.
</p>
</div>
<group>
<field name="direct_order_wizard_id" invisible="1"/>
<field name="partner_id" readonly="1"/>
<field name="source_order_id"
options="{'no_create': True, 'no_open': True}"/>
</group>
<separator string="Lines on Source Order"/>
<field name="source_line_ids"
invisible="not source_order_id">
<list>
<field name="name"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<field name="price_unit"/>
<field name="x_fc_part_deadline"/>
</list>
</field>
</sheet>
<footer>
<button name="action_copy_lines"
type="object"
string="Copy Selected Lines"
class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpDirectOrderLine(models.TransientModel):
@@ -18,16 +19,48 @@ class FpDirectOrderLine(models.TransientModel):
)
sequence = fields.Integer(default=10)
# ---- Part ----
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
required=True,
)
part_number = fields.Char(
related='part_catalog_id.part_number', readonly=True,
)
part_revision = fields.Char(
related='part_catalog_id.revision', readonly=True,
)
surface_area = fields.Float(
related='part_catalog_id.surface_area', readonly=True, digits=(12, 4),
)
surface_area_uom = fields.Selection(
related='part_catalog_id.surface_area_uom', readonly=True,
)
# ---- New revision (optional) ----
create_new_revision = fields.Boolean(
string='This is a New Revision',
help='Check if the customer sent an updated drawing or 3D model. '
'A new part revision will be created and linked to this line.',
)
new_drawing_file = fields.Binary(string='New Drawing / 3D Model')
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(string='Revision Note')
# ---- Treatments ----
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Primary Treatment',
required=True,
)
treatment_ids = fields.Many2many(
'fp.treatment',
string='Additional Treatments',
help='Extra pre/post treatments applied to this line.',
)
# ---- Qty / price ----
quantity = fields.Integer(string='Qty', default=1, required=True)
currency_id = fields.Many2one(related='wizard_id.currency_id')
unit_price = fields.Monetary(
@@ -40,7 +73,208 @@ class FpDirectOrderLine(models.TransientModel):
compute='_compute_line_subtotal',
)
# ---- Scheduling / fulfilment ----
part_deadline = fields.Date(
string='Part Deadline',
help='Per-line deadline. Defaults to SO customer deadline if blank.',
)
rush_order = fields.Boolean(string='Rush')
wo_group_tag = fields.Char(
string='WO Group',
help='Free-text tag. Lines sharing a tag (e.g. "WO#1", "WO#2") '
'will be batched into one manufacturing order.',
)
# ---- Phase C: polish ----
part_wo_description = fields.Text(
string='On Work Order',
help='Extra detail printed on the work order travelling sheet. '
'Kept separate from the customer-facing description.',
)
start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
domain="[('id', 'child_of', coating_config_id and coating_config_id.recipe_id.id or 0)]",
help='For re-work jobs: pick the recipe step where this job should '
'begin. Pick a coating first — nodes are scoped to its '
'recipe tree. Skips earlier steps in the generated WO but '
'keeps later siblings and sub-processes.',
)
is_one_off = fields.Boolean(
string='One-off Part',
help='Do not save this as a reusable part in the catalog after the '
'order is created. Useful for quote-only or prototype parts.',
)
push_to_defaults = fields.Boolean(
string='Save as Default',
help='After submit, write this line\'s coating + additional '
'treatments back onto the part catalog as its new defaults.',
)
quote_id = fields.Many2one(
'fp.quote.configurator',
string='Linked Quote',
domain="[('partner_id', '=', parent.partner_id), ('state', 'in', ['sent','accepted','won'])]",
help='Optional: link this line to a prior quote. The unit price '
'auto-fills from the quote\'s final price (or override).',
)
# ---- Description ----
description_template_id = fields.Many2one(
'fp.sale.description.template',
string='Description Template',
)
line_description = fields.Text(
string='Line Description',
help='This text becomes the description of the sale order line. '
'Edit freely — your changes override the template.',
)
# ---- Missing info per line ----
is_missing_info = fields.Boolean(
string='Missing Info',
compute='_compute_is_missing_info',
)
# ---- Computes ----
@api.depends('quantity', 'unit_price')
def _compute_line_subtotal(self):
for rec in self:
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
@api.depends('part_catalog_id', 'coating_config_id', 'unit_price', 'quantity')
def _compute_is_missing_info(self):
for rec in self:
rec.is_missing_info = not (
rec.part_catalog_id
and rec.coating_config_id
and rec.unit_price
and rec.quantity
)
# ---- Onchange ----
@api.onchange('quote_id')
def _onchange_quote_id(self):
"""Auto-fill part, coating, and unit price from the linked quote."""
if not self.quote_id:
return
q = self.quote_id
if q.part_catalog_id and not self.part_catalog_id:
self.part_catalog_id = q.part_catalog_id
if q.coating_config_id and not self.coating_config_id:
self.coating_config_id = q.coating_config_id
if not self.unit_price:
final = q.estimator_override_price or q.calculated_price
if final and q.quantity:
self.unit_price = final / q.quantity
@api.onchange('part_catalog_id')
def _onchange_part_defaults(self):
"""When a part is picked, seed coating + treatments from its catalog defaults."""
if not self.part_catalog_id:
return
if not self.coating_config_id and self.part_catalog_id.x_fc_default_coating_config_id:
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
def _onchange_lookup_price(self):
"""Auto-fill unit_price from customer price list when available."""
if self.unit_price:
return
partner = self.wizard_id.partner_id
if not (partner and self.coating_config_id):
return
price = self.env['fp.customer.price.list']._find_price(
partner.id,
self.coating_config_id.id,
quantity=self.quantity or 1,
)
if price:
self.unit_price = price.unit_price
@api.onchange('description_template_id')
def _onchange_description_template(self):
if self.description_template_id:
self.line_description = self.description_template_id.description
@api.onchange('part_catalog_id', 'coating_config_id')
def _onchange_suggest_template(self):
"""Offer a sensible default template — part-specific wins.
Priority (first non-empty result wins):
1. This part's lowest-sequence active template
2. This customer's templates (no part)
3. This coating's templates (no part)
4. Don't auto-pick — user has to choose
"""
if self.description_template_id or self.line_description:
return
Template = self.env['fp.sale.description.template']
partner = self.wizard_id.partner_id
if self.part_catalog_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', self.part_catalog_id.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
return
if partner:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', partner.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
return
if self.coating_config_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', False),
('coating_config_id', '=', self.coating_config_id.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
# ---- Helpers ----
def _get_or_bump_revision(self):
"""Return the part to use for the SO line, optionally bumping revision."""
self.ensure_one()
part = self.part_catalog_id
if not self.create_new_revision:
return part
if not self.new_drawing_file:
raise UserError(_(
'Line %s: upload the new drawing before confirming.'
) % (part.name or part.part_number or '?'))
drawing_att = self.env['ir.attachment'].create({
'name': self.new_drawing_filename or 'drawing.pdf',
'datas': self.new_drawing_file,
'res_model': 'fp.part.catalog',
'res_id': part.id,
})
part.action_create_revision()
new_rev = self.env['fp.part.catalog'].search([
('parent_part_id', '=', (part.parent_part_id or part).id),
('is_latest_revision', '=', True),
], limit=1, order='revision_number desc')
if not new_rev:
return part
new_rev.write({'revision_note': self.revision_note or False})
fname = (self.new_drawing_filename or '').lower()
if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')):
new_rev.model_attachment_id = drawing_att.id
else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
return new_rev

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -11,64 +11,56 @@ class FpDirectOrderWizard(models.TransientModel):
"""Direct order entry for repeat customers.
Skips the quotation stage when the customer has already sent a PO.
Creates a sale.order and calls action_confirm() in one step.
Optionally bumps the part catalog revision when a new drawing is uploaded.
Creates a sale.order with one sale.order.line per wizard line and
calls action_confirm() in one step.
"""
_name = 'fp.direct.order.wizard'
_description = 'Fusion Plating Direct Order Entry'
_description = 'Fusion Plating - Direct Order Entry'
# ---- Customer ----
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
)
# Part selection
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part', required=True,
domain="[('partner_id', '=', partner_id), ('is_latest_revision', '=', True)]",
partner_invoice_id = fields.Many2one(
'res.partner', string='Invoice Address',
domain="['|', ('id', '=', partner_id), "
"('parent_id', '=', partner_id)]",
)
part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
current_revision = fields.Char(related='part_catalog_id.revision', readonly=True)
surface_area = fields.Float(
related='part_catalog_id.surface_area', readonly=True, digits=(12, 4),
partner_shipping_id = fields.Many2one(
'res.partner', string='Delivery Address',
domain="['|', ('id', '=', partner_id), "
"('parent_id', '=', partner_id)]",
)
surface_area_uom = fields.Selection(
related='part_catalog_id.surface_area_uom', readonly=True,
customer_job_number = fields.Char(
string='Customer Job #',
help="Customer's internal job number for cross-referencing. "
"Appears on work orders and invoices.",
)
# Revision upload (optional — creates a new revision of the part)
create_new_revision = fields.Boolean(
string='This is a New Revision',
help='Check if the customer sent an updated drawing or 3D model. '
'A new part revision will be created and linked to this order.',
# ---- Scheduling ----
planned_start_date = fields.Date(
string='Planned Start', default=fields.Date.context_today,
)
new_drawing_file = fields.Binary(
string='New Drawing / 3D Model',
help='STEP, STL, IGES, or PDF. Used when creating a new revision.',
internal_deadline = fields.Date(string='Internal Deadline')
customer_deadline = fields.Date(string='Customer Deadline')
# ---- Order flags (Phase B) ----
is_blanket_order = fields.Boolean(
string='Blanket Sales Order',
help='Blanket orders release parts in quantities over time.',
)
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(
string='Revision Note', help='What changed in this revision?',
block_partial_shipments = fields.Boolean(
string='Block Partial Shipments',
help='Ship all-or-nothing; partial pickings are blocked.',
)
# Order details
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating', required=True,
)
quantity = fields.Integer(string='Quantity', required=True, default=1)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit_price = fields.Monetary(
string='Unit Price', currency_field='currency_id',
help='Negotiated price per part. Leave blank to set later.',
)
line_subtotal = fields.Monetary(
string='Line Subtotal', currency_field='currency_id',
compute='_compute_line_subtotal',
)
rush_order = fields.Boolean(string='Rush Order')
# ---- PO (required — that's what makes this a "direct" order) ----
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
po_attachment_filename = fields.Char(string='PO Filename')
# ---- Fulfilment (order-level) ----
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
@@ -76,12 +68,11 @@ class FpDirectOrderWizard(models.TransientModel):
string='Delivery Method',
)
# PO (required — that's what makes this a "direct" order)
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
po_attachment_filename = fields.Char(string='PO Filename')
# Invoice strategy (pulled from partner default if set)
# ---- Currency + invoicing ----
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
@@ -89,163 +80,121 @@ class FpDirectOrderWizard(models.TransientModel):
)
deposit_percent = fields.Float(string='Deposit %')
progress_initial_percent = fields.Float(
string='Progress Initial %', default=50.0,
string='Progress - Initial %', default=50.0,
)
# ---- Notes ----
notes = fields.Text(string='Internal Notes')
# Description template picker — the domain is dynamically narrowed to
# this part's canned descriptions first. When no part is chosen it
# falls through to generic templates.
description_template_id = fields.Many2one(
'fp.sale.description.template',
string='Description Template',
domain="[('active','=',True), "
" '|', '|', '|', "
" ('part_catalog_id','=',part_catalog_id), "
" ('part_catalog_id','=',False), "
" ('partner_id','=',partner_id), "
" ('coating_config_id','=',coating_config_id)]",
help='Pick a saved description and tweak it below. Part-specific '
'descriptions appear first, then customer / coating / generic.',
# ---- Lines ----
line_ids = fields.One2many(
'fp.direct.order.line', 'wizard_id', string='Order Lines',
)
line_description = fields.Text(
string='Line Description',
help='This text becomes the description of the sale order line. '
'Edit freely — your changes override the template.',
total_amount = fields.Monetary(
string='Order Total',
compute='_compute_totals', currency_field='currency_id',
)
total_qty = fields.Integer(string='Total Qty', compute='_compute_totals')
total_line_count = fields.Integer(
string='Line Count', compute='_compute_totals',
)
@api.depends('quantity', 'unit_price')
def _compute_line_subtotal(self):
# ---- Missing info banner ----
missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
# ---- Computes ----
@api.depends('line_ids.line_subtotal', 'line_ids.quantity')
def _compute_totals(self):
for rec in self:
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
rec.total_amount = sum(rec.line_ids.mapped('line_subtotal'))
rec.total_qty = sum(rec.line_ids.mapped('quantity'))
rec.total_line_count = len(rec.line_ids)
@api.depends('line_ids.part_catalog_id', 'line_ids.coating_config_id',
'line_ids.unit_price', 'line_ids.quantity')
def _compute_missing_info_msg(self):
for rec in self:
has_missing = False
for line in rec.line_ids:
if (not line.part_catalog_id
or not line.coating_config_id
or not line.unit_price
or not line.quantity):
has_missing = True
break
rec.missing_info_msg = (
'Some lines are missing quote information '
'(part / treatment / price / qty). '
'Verify before confirming the order.'
if has_missing else False
)
# ---- Onchange ----
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Reset part selection when customer changes + pull invoice defaults."""
self.part_catalog_id = False
"""Seed invoice defaults + default addresses when customer changes."""
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
@api.onchange('description_template_id')
def _onchange_description_template(self):
"""Copy the template's text into the editable paragraph — user tweaks from there."""
if self.description_template_id:
self.line_description = self.description_template_id.description
@api.onchange('part_catalog_id', 'coating_config_id', 'partner_id')
def _onchange_suggest_template(self):
"""Offer a sensible default template — part-specific wins.
Priority (first non-empty result wins):
1. This part's lowest-sequence active template
2. This customer's templates (no part)
3. This coating's templates (no part)
4. Don't auto-pick — user has to choose
"""
if self.description_template_id or self.line_description:
return # respect user's choice
Template = self.env['fp.sale.description.template']
# 1. Part-specific
if self.part_catalog_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', self.part_catalog_id.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
return
# 2. Customer (no part)
if self.partner_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', self.partner_id.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
return
addrs = self.partner_id.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
else:
self.partner_invoice_id = False
self.partner_shipping_id = False
# 3. Coating (no part, no customer restriction)
if self.coating_config_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', False),
('coating_config_id', '=', self.coating_config_id.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
return
# ---- Actions ----
def action_add_from_prior_so(self):
"""Open a sub-wizard to copy lines from a prior sale.order."""
self.ensure_one()
if not self.partner_id:
raise UserError(_('Pick a customer first.'))
sub = self.env['fp.add.from.so.wizard'].create({
'direct_order_wizard_id': self.id,
})
return {
'type': 'ir.actions.act_window',
'name': _('Add Lines From Prior SO'),
'res_model': 'fp.add.from.so.wizard',
'res_id': sub.id,
'view_mode': 'form',
'target': 'new',
}
@api.onchange('coating_config_id', 'quantity', 'partner_id')
def _onchange_lookup_price(self):
"""Auto-fill unit_price from customer price list when available."""
if not (self.partner_id and self.coating_config_id):
return
# Don't overwrite a manually-entered price
if self.unit_price:
return
price = self.env['fp.customer.price.list']._find_price(
self.partner_id.id, self.coating_config_id.id,
quantity=self.quantity or 1,
)
if price:
self.unit_price = price.unit_price
def action_add_from_quotes(self):
"""Open a sub-wizard to copy lines from prior quotes."""
self.ensure_one()
if not self.partner_id:
raise UserError(_('Pick a customer first.'))
sub = self.env['fp.add.from.quote.wizard'].create({
'direct_order_wizard_id': self.id,
})
return {
'type': 'ir.actions.act_window',
'name': _('Add Lines From Quotes'),
'res_model': 'fp.add.from.quote.wizard',
'res_id': sub.id,
'view_mode': 'form',
'target': 'new',
}
def action_create_order(self):
"""Create and confirm the sale order, optionally bumping part revision."""
"""Create and confirm the sale order with one SO line per wizard line."""
self.ensure_one()
if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.'))
if not self.po_attachment_file:
raise UserError(_('Upload the customer PO document.'))
if self.create_new_revision and not self.new_drawing_file:
raise UserError(_(
'Please upload the new drawing when creating a new revision.'
))
if self.quantity <= 0:
raise UserError(_('Quantity must be positive.'))
# 1. Optional: create a new part revision from the uploaded drawing
part = self.part_catalog_id
if self.create_new_revision:
drawing_att = self.env['ir.attachment'].create({
'name': self.new_drawing_filename or 'drawing.pdf',
'datas': self.new_drawing_file,
'res_model': 'fp.part.catalog',
'res_id': part.id,
})
# action_create_revision returns an action dict; we keep the part
part.action_create_revision()
new_rev = self.env['fp.part.catalog'].search(
[('parent_part_id', '=', (part.parent_part_id or part).id),
('is_latest_revision', '=', True)],
limit=1, order='revision_number desc',
)
if new_rev:
new_rev.write({
'revision_note': self.revision_note or False,
})
# Attach drawing/model based on extension
fname = (self.new_drawing_filename or '').lower()
if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')):
new_rev.model_attachment_id = drawing_att.id
else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
part = new_rev
# 2. Save the PO attachment
# 1. Save the PO attachment once
po_att = self.env['ir.attachment'].create({
'name': self.po_attachment_filename or 'po.pdf',
'datas': self.po_attachment_file,
'mimetype': 'application/pdf',
})
# 3. Find or create the generic plating service product (same as configurator)
# 2. Find or create the generic plating service product
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
@@ -259,53 +208,89 @@ class FpDirectOrderWizard(models.TransientModel):
'purchase_ok': False,
})
# Canonical line label (always present)
header = '%s%s Rev %s (x%d)' % (
self.coating_config_id.name,
part.name,
part.revision or part.revision_number,
self.quantity,
)
# Optional extended description from template / user tweak
extended = (self.line_description or '').strip()
if extended:
line_desc = '%s\n\n%s' % (header, extended)
else:
line_desc = header
# Bump template usage counter so popular ones float to the top over time
if self.description_template_id:
self.description_template_id._register_usage()
# 3. Build SO header
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
'partner_invoice_id': (
self.partner_invoice_id.id or self.partner_id.id
),
'partner_shipping_id': (
self.partner_shipping_id.id or self.partner_id.id
),
'x_fc_po_number': self.po_number,
'x_fc_po_attachment_id': po_att.id,
'x_fc_po_received': True,
'x_fc_customer_job_number': self.customer_job_number or False,
'x_fc_planned_start_date': self.planned_start_date,
'x_fc_internal_deadline': self.internal_deadline,
'commitment_date': self.customer_deadline,
'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent,
'x_fc_progress_initial_percent': self.progress_initial_percent,
'x_fc_delivery_method': self.delivery_method,
'x_fc_is_blanket_order': self.is_blanket_order,
'x_fc_block_partial_shipments': self.block_partial_shipments,
'origin': 'Direct Order',
'note': self.notes or False,
'order_line': [(0, 0, {
'order_line': [],
}
# 4. One SO line per wizard line. Cache resolved parts (post
# rev-bump) so the push-to-defaults pass writes to the right
# catalog entry.
resolved_parts = {} # {wizard_line_id: resolved part record}
for line in self.line_ids:
part = line._get_or_bump_revision()
resolved_parts[line.id] = part
header = '%s - %s Rev %s (x%d)' % (
line.coating_config_id.name,
part.name,
part.revision or part.revision_number,
line.quantity,
)
extended = (line.line_description or '').strip()
line_desc = (header + '\n\n' + extended) if extended else header
if line.description_template_id:
line.description_template_id._register_usage()
so_vals['order_line'].append((0, 0, {
'product_id': product.id,
'name': line_desc,
'product_uom_qty': self.quantity,
'price_unit': self.unit_price or 0.0,
})],
}
'product_uom_qty': line.quantity,
'price_unit': line.unit_price or 0.0,
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': line.coating_config_id.id,
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
'x_fc_part_deadline': line.part_deadline,
'x_fc_rush_order': line.rush_order,
'x_fc_wo_group_tag': line.wo_group_tag or False,
'x_fc_part_wo_description': line.part_wo_description or False,
'x_fc_start_at_node_id': line.start_at_node_id.id or False,
'x_fc_is_one_off': line.is_one_off,
'x_fc_quote_id': line.quote_id.id or False,
}))
# 5. Create + confirm
so = self.env['sale.order'].create(so_vals)
# Immediately confirm — skips quote/send step entirely
so.action_confirm()
so.message_post(
body=_(
'Direct order created from PO %s. Quotation stage skipped.'
) % self.po_number,
)
# 6. Push-to-defaults (C4) — uses the resolved part cached
# during the build loop so rev-bumped lines write defaults to
# the NEW revision, not the pre-bump one.
for line in self.line_ids:
if not line.push_to_defaults or line.is_one_off:
continue
part = resolved_parts.get(line.id) or line.part_catalog_id
if not part:
continue
part.write({
'x_fc_default_coating_config_id': line.coating_config_id.id or False,
'x_fc_default_treatment_ids': [(6, 0, line.treatment_ids.ids)],
})
so.message_post(body=_(
'Direct order created from PO %s with %d line(s). '
'Quotation stage skipped.'
) % (self.po_number, len(self.line_ids)))
return {
'type': 'ir.actions.act_window',

View File

@@ -6,11 +6,17 @@
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<form string="Direct Order Entry">
<div class="alert alert-warning mb-0"
role="alert"
invisible="not missing_info_msg">
<i class="fa fa-exclamation-triangle me-2"/>
<field name="missing_info_msg" readonly="1" nolabel="1"/>
</div>
<sheet>
<div class="oe_title">
<h1>New Direct Order</h1>
<p class="text-muted">
Skip the quotation stage create a confirmed order
Skip the quotation stage - create a confirmed order
when the customer has already sent a PO.
</p>
</div>
@@ -18,59 +24,32 @@
<group>
<group string="Customer">
<field name="partner_id" options="{'no_create_edit': True}"/>
<field name="partner_invoice_id"
options="{'no_create_edit': True}"
invisible="not partner_id"/>
<field name="partner_shipping_id"
options="{'no_create_edit': True}"
invisible="not partner_id"/>
<field name="customer_job_number"/>
</group>
<group string="Purchase Order">
<field name="po_number"/>
<field name="po_attachment_file" filename="po_attachment_filename"/>
<field name="po_attachment_file"
filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
</group>
</group>
<group string="Part">
<group>
<field name="part_catalog_id"
options="{'no_create_edit': True}"
context="{'default_partner_id': partner_id}"/>
<field name="part_number" invisible="not part_catalog_id"/>
<field name="current_revision" invisible="not part_catalog_id"/>
</group>
<group>
<label for="surface_area" invisible="not part_catalog_id"/>
<div class="o_row" invisible="not part_catalog_id">
<field name="surface_area" nolabel="1" class="oe_inline"/>
<field name="surface_area_uom" nolabel="1" class="oe_inline"/>
</div>
</group>
</group>
<group string="New Revision (optional)">
<field name="create_new_revision"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
invisible="not create_new_revision"
required="create_new_revision"/>
<field name="new_drawing_filename" invisible="1"/>
<field name="revision_note" invisible="not create_new_revision"/>
</group>
<group>
<group string="Order">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="currency_id" invisible="1"/>
<field name="unit_price" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<group string="Scheduling">
<field name="planned_start_date"/>
<field name="internal_deadline"/>
<field name="customer_deadline"/>
<field name="is_blanket_order"/>
<field name="block_partial_shipments"/>
</group>
<group string="Fulfilment">
<field name="rush_order"/>
<group string="Fulfilment &amp; Invoicing">
<field name="delivery_method"/>
</group>
</group>
<group string="Invoicing">
<group>
<field name="invoice_strategy"/>
<label for="deposit_percent"
invisible="invoice_strategy != 'deposit'"/>
@@ -89,19 +68,125 @@
</group>
</group>
<!-- ===== Line description — template picker + editable paragraph ===== -->
<group string="Line Description">
<field name="description_template_id"
options="{'no_create': True, 'no_open': True}"
placeholder="Start typing to search saved descriptions..."/>
<field name="line_description" nolabel="1" colspan="2"
placeholder="Pick a template above, then tweak the text here. Whatever you leave in this box lands on the sale order line."/>
</group>
<group string="Internal Notes">
<field name="notes" nolabel="1" colspan="2"
placeholder="Notes for the estimator / planner — not shown to the customer."/>
</group>
<notebook>
<page string="Lines" name="lines">
<div class="mb-2">
<button name="action_add_from_prior_so"
type="object"
string="+ Add From Prior SO"
class="btn-secondary"
invisible="not partner_id"/>
<button name="action_add_from_quotes"
type="object"
string="+ Add From Quotes"
class="btn-secondary"
invisible="not partner_id"/>
</div>
<field name="line_ids">
<list editable="bottom"
decoration-warning="is_missing_info">
<field name="is_missing_info" column_invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="part_catalog_id"
context="{'default_partner_id': parent.partner_id}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
options="{'no_create_edit': True}"/>
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"
optional="hide"/>
<field name="quantity"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<field name="part_deadline"/>
<field name="wo_group_tag" optional="show"/>
<field name="rush_order" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
</list>
<form string="Order Line">
<group>
<group string="Part &amp; Treatment">
<field name="part_catalog_id"
context="{'default_partner_id': parent.partner_id}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"/>
<field name="part_number"
invisible="not part_catalog_id"/>
<field name="part_revision"
invisible="not part_catalog_id"/>
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"/>
</group>
<group string="Qty &amp; Price">
<field name="quote_id"
options="{'no_create': True, 'no_open': True}"/>
<field name="quantity"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="part_deadline"/>
<field name="rush_order"/>
<field name="wo_group_tag"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<group string="New Revision (optional)">
<field name="create_new_revision"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
invisible="not create_new_revision"
required="create_new_revision"/>
<field name="new_drawing_filename" invisible="1"/>
<field name="revision_note"
invisible="not create_new_revision"/>
</group>
<group string="Line Description">
<field name="description_template_id"
options="{'no_create': True, 'no_open': True}"
placeholder="Start typing to search saved descriptions..."/>
<field name="line_description"
nolabel="1" colspan="2"
placeholder="Pick a template above, then tweak the text here."/>
</group>
<group string="Work Order (internal)">
<field name="part_wo_description"
nolabel="1" colspan="2"
placeholder="Extra detail for the travelling sheet. Not shown to the customer."/>
<field name="start_at_node_id"
options="{'no_create': True}"/>
<field name="is_one_off"/>
<field name="push_to_defaults"
invisible="is_one_off"/>
</group>
</form>
</field>
<group class="mt-3">
<group>
<field name="total_line_count" readonly="1"/>
<field name="total_qty" readonly="1"/>
</group>
<group>
<field name="total_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"
placeholder="Internal notes for the estimator / planner - not shown to the customer."/>
</page>
</notebook>
</sheet>
<footer>