Compare commits

...

22 Commits

Author SHA1 Message Date
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
21 changed files with 2060 additions and 312 deletions

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,26 @@ 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, build the set of node IDs that are
# "at or descended from" the start node OR on its ancestor
# path (so we keep the containing recipe / sub-processes
# visible but skip sibling branches that come before the
# start point).
start_node = production.x_fc_start_at_node_id
allowed_ids = None # None = include everything
if start_node:
# Descendants (inclusive)
descendants = self.env['fusion.plating.process.node'].search([
('id', 'child_of', start_node.id),
])
# Ancestors (excluding self — already in descendants)
ancestors = self.env['fusion.plating.process.node']
cur = start_node.parent_id
while cur:
ancestors |= cur
cur = cur.parent_id
allowed_ids = set(descendants.ids) | set(ancestors.ids)
# 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 +475,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 +539,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 +630,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,132 @@ 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_tags = set(Production.search([
('origin', '=', self.name),
]).mapped('x_fc_wo_group_tag'))
# 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()
groups = {} # {tag_or_line_key: [lines]}
for line in plating_lines:
key = line.x_fc_wo_group_tag or ('__line__%d' % line.id)
groups.setdefault(key, []).append(line)
created = []
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.
# Untagged keys are 1:1 with lines; use the line ID in sudo
# check via existing MOs' line links.
if tag and tag in existing_tags:
continue
if not tag:
# Untagged idempotency — check if any existing MO points
# at this line via x_fc_sale_order_line_ids.
if Production.search_count([
('origin', '=', self.name),
('x_fc_sale_order_line_ids', 'in', [lines[0].id]),
]):
continue
# 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.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)))
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
])
self.message_post(body=Markup(_(
'%d draft manufacturing order(s) auto-created:<br/>%s'
)) % (len(created), lines_html))
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 +241,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 +257,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,385 @@ 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',
)
def _compute_wo_completion(self):
WO = self.env['mrp.workorder'].sudo()
for rec in self:
if not rec.name:
rec.x_fc_wo_completion = '0/0'
continue
total = WO.search_count([('production_id.origin', '=', rec.name)])
done = WO.search_count([
('production_id.origin', '=', rec.name),
('state', '=', 'done'),
])
rec.x_fc_wo_completion = '%d/%d' % (done, total) if total 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.
- draft SO with no tracked email sent => draft
- sent (Odoo state) => sent
- sent + mail opened => opened (detected via mail.message)
- state=sale/done => won
"""
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:
msgs = self.env['mail.message'].sudo().search([
('model', '=', 'sale.order'),
('res_id', '=', rec.id),
('message_type', '=', 'email'),
], limit=10)
# mail.notification tracks read timestamps
for m in msgs:
if m.notification_ids.filtered(
lambda n: n.is_read
):
opened = True
break
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'))
)
def _compute_workorder_count(self):
WO = self.env['mrp.workorder'].sudo()
for rec in self:
if not rec.name:
rec.x_fc_workorder_count = 0
continue
rec.x_fc_workorder_count = WO.search_count([
('production_id.origin', '=', rec.name),
('state', 'not in', ('done', 'cancel')),
])
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',
)
def _compute_nav_counts(self):
NCR = self.env.get('fusion.plating.ncr')
for rec in self:
rec.x_fc_invoice_count = len(rec.invoice_ids)
rec.x_fc_picking_count = len(rec.picking_ids)
rec.x_fc_attachment_count = self.env['ir.attachment'].sudo().search_count([
('res_model', '=', 'sale.order'),
('res_id', '=', rec.id),
])
if NCR and 'sale_order_id' in NCR._fields:
rec.x_fc_ncr_count = NCR.sudo().search_count([
('sale_order_id', '=', rec.id),
])
else:
rec.x_fc_ncr_count = 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."""
for rec in self:
cost = 0.0
for line in rec.order_line:
if line.x_fc_coating_config_id:
# If coating_config has a cost field, use it; otherwise 0.
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 * 100.0)
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,162 @@
<?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 t-field="line.x_fc_coating_config_id"/>
<td class="text-end"
t-field="line.product_uom_qty"/>
<td class="text-end"
t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<td class="text-end"
t-field="line.price_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-end">
<strong>Total</strong>
</td>
<td class="text-end">
<strong t-field="doc.amount_total"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</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,206 @@ 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="[('parent_id', 'child_of', coating_config_id and coating_config_id.recipe_id.id)]",
help='For re-work jobs: pick the recipe step where this job should '
'begin. Skips ancestor steps in the generated work order.',
)
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,85 @@ 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
for line in self.line_ids:
part = line._get_or_bump_revision()
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) — after the part has been resolved /
# revision-bumped, write coating + treatments back onto the part
# catalog entry so the next order inherits the same defaults.
for line in self.line_ids:
if not line.push_to_defaults:
continue
part = line.part_catalog_id
if not part or line.is_one_off:
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>