diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 03c1719b..59bdc5f8 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -370,7 +370,7 @@ rewrite code as new requirements surface. Each sub-project has its own design do | 2 | Part Data Model Overhaul (part#/rev required, dual descriptions, per-part cert requirement, SKU→Part Number on customer docs) | **Shipped 2026-04-22** (commits 868b418..afd8bae) | 2b, 2c, 2d, 4 | | 3 | Default Process + Composer per part (reuse recipe tree) | **Shipped 2026-04-22** (commits ce07daa..f059348) | 2e, 2f | | 4 | Contract Review (optional, per-part, settings-driven QA roster, QA-005 1:1 PDF) | **Shipped 2026-04-22** | 2i | -| 5 | Order-line fields (serial, job#, thickness dropdown, revision picker) | Pending | 5, 6, Q2 | +| 5 | Order-line fields (fp.serial registry, auto job#, coating-scoped thickness dropdown, revision picker) | **Shipped 2026-04-22** | 5, 6, Q2 | | 6 | Contact Profiles & Communication Routing (sub-contacts + per-location notification lists + global contacts) | Pending | client transcript A/B/C | | 7 | IoT tuning (configurable polling interval 15–30 min, seed 6–10 tank sensors) | Pending | client transcript D | | 8 | Receiving / Inspection / QC flow restructure (split receiving vs inspection; racking crew inspects, not receiver) | Pending | client transcript E | diff --git a/fusion_plating/docs/superpowers/tests/2026-04-22-sub5-smoke.py b/fusion_plating/docs/superpowers/tests/2026-04-22-sub5-smoke.py new file mode 100644 index 00000000..339c49c4 --- /dev/null +++ b/fusion_plating/docs/superpowers/tests/2026-04-22-sub5-smoke.py @@ -0,0 +1,149 @@ +"""Sub 5 smoke test — runs inside odoo-shell on entech.""" +env = env + +Partner = env['res.partner'] +Part = env['fp.part.catalog'] +Coating = env['fp.coating.config'] +Thickness = env['fp.coating.thickness'] +Serial = env['fp.serial'] +SO = env['sale.order'] +admin = env.ref('base.user_admin') + +# ---- Seed customer + part + coating + thickness options --------------- +cust = Partner.create({ + 'name': 'Sub5 Smoke Customer', + 'is_company': True, + 'customer_rank': 1, +}) +part_a = Part.create({ + 'partner_id': cust.id, + 'part_number': 'SUB5-001', + 'revision': 'A', +}) +# Bump to a newer revision so we have A + B in the chain +part_a.action_create_revision() +part_b = Part.search([ + ('parent_part_id', '=', (part_a.parent_part_id or part_a).id), + ('is_latest_revision', '=', True), +], limit=1, order='revision_number desc') +assert part_b.revision != part_a.revision, 'revision chain failed' +print(f'[OK] Part revisions: A={part_a.revision} / latest={part_b.revision}') + +coating = Coating.search([], limit=1) or Coating.create({ + 'name': 'Sub5 Coating Test', + 'process_type_id': env['fusion.plating.process.type'].search([], limit=1).id, +}) +t1 = Thickness.create({ + 'coating_config_id': coating.id, + 'value': 0.001, 'uom': 'inches', 'sequence': 10, +}) +t2 = Thickness.create({ + 'coating_config_id': coating.id, + 'value': 0.0015, 'uom': 'inches', 'sequence': 20, +}) +print(f'[OK] Thickness options: {t1.display_name} / {t2.display_name}') + +# ---- Create SO with latest revision, assign serial via create-on-fly -- +product = env['product.product'].search([('sale_ok', '=', True)], limit=1) +so = SO.create({ + 'partner_id': cust.id, + 'x_fc_po_number': 'PO-SUB5-SMOKE', + 'x_fc_po_received': True, + 'order_line': [(0, 0, { + 'product_id': product.id, + 'product_uom_qty': 10, + 'name': 'Sub5 smoke line', + 'x_fc_part_catalog_id': part_b.id, + 'x_fc_coating_config_id': coating.id, + 'x_fc_thickness_id': t1.id, + 'x_fc_internal_description': 'smoke', + })], +}) +line = so.order_line[0] +line.invalidate_recordset() +assert line.x_fc_revision_snapshot == part_b.revision, ( + f'snapshot mismatch: {line.x_fc_revision_snapshot} vs {part_b.revision}' +) +print(f'[OK] Revision snapshot captured on create: {line.x_fc_revision_snapshot}') + +# ---- Generate serial button ----------------------------------------- +line.action_generate_serial() +line.invalidate_recordset() +assert line.x_fc_serial_id, 'serial should be assigned' +assert line.x_fc_serial_id.name.startswith('FP-SN-'), ( + f'unexpected serial name: {line.x_fc_serial_id.name}' +) +print(f'[OK] Generate serial: {line.x_fc_serial_id.name}') + +# ---- Customer-typed serial -------------------------------------------- +so2 = SO.create({ + 'partner_id': cust.id, + 'x_fc_po_number': 'PO-SUB5-SMOKE-2', + 'x_fc_po_received': True, + 'order_line': [(0, 0, { + 'product_id': product.id, + 'product_uom_qty': 5, + 'name': 'Sub5 smoke line 2', + 'x_fc_part_catalog_id': part_b.id, + 'x_fc_coating_config_id': coating.id, + 'x_fc_internal_description': 'smoke2', + })], +}) +line2 = so2.order_line[0] +typed_serial = Serial.create({'name': 'CUST-999'}) +line2.x_fc_serial_id = typed_serial.id +line2.invalidate_recordset() +assert line2.x_fc_serial_id.name == 'CUST-999' +print('[OK] Customer-typed serial attached') + +# ---- Revision picker: switch to Rev A -------------------------------- +line.x_fc_revision_pick_id = part_a.id +line.invalidate_recordset() +assert line.x_fc_part_catalog_id == part_a, ( + f'picker did not re-point part: {line.x_fc_part_catalog_id.revision}' +) +assert line.x_fc_revision_snapshot == part_a.revision +print(f'[OK] Revision picker: switched to {part_a.revision}') + +# ---- Confirm SO → job number auto-assigned --------------------------- +so.action_confirm() +line.invalidate_recordset() +assert line.x_fc_job_number, 'job number should be auto-assigned on confirm' +assert line.x_fc_job_number.startswith('FP-JOB-'), ( + f'unexpected job number: {line.x_fc_job_number}' +) +print(f'[OK] SO confirmed → job number: {line.x_fc_job_number}') + +# ---- MO carry-over (if bridge_mrp auto-created an MO) ----------------- +MO = env['mrp.production'] +mos = MO.search([('origin', '=', so.name)]) +if mos: + mo = mos[0] + print(f'[OK] MO created ({mo.name}) → serial={mo.x_fc_serial_id.name if mo.x_fc_serial_id else "—"} / job={mo.x_fc_job_number or "—"} / thickness={mo.x_fc_thickness_id.display_name if mo.x_fc_thickness_id else "—"} / rev={mo.x_fc_revision_snapshot or "—"}') + if mo.x_fc_serial_id: + assert mo.x_fc_serial_id == line.x_fc_serial_id, 'MO serial mismatch' + print('[OK] Serial carried MO ← SO line') +else: + print('[SKIP] No MO auto-created for this SO') + +# ---- fp.serial smart-button counts reflect reality ------------------- +serial = line.x_fc_serial_id +serial.invalidate_recordset() +prod_cnt = serial.production_count +del_cnt = serial.delivery_count +print(f'[OK] Serial {serial.name}: MOs={prod_cnt}, Deliveries={del_cnt}, Invoices={serial.invoice_count}') + +# ---- Thickness dropdown domain ---------------------------------------- +another_coating = Coating.create({ + 'name': 'Sub5 Other Coating', + 'process_type_id': coating.process_type_id.id, +}) +line.x_fc_coating_config_id = another_coating.id +line._onchange_coating_clears_thickness() +line.invalidate_recordset() +assert not line.x_fc_thickness_id, 'thickness should clear when coating changes' +print('[OK] Thickness clears when coating switches') + +# ---- Cleanup --------------------------------------------------------- +env.cr.rollback() +print('\n=== SUB 5 SMOKE PASS — all assertions held ===') diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index a7bcf878..907dc1f7 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.10.0.0', + 'version': '19.0.11.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ @@ -74,6 +74,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_job_consumption_views.xml', 'views/fp_work_role_views.xml', 'views/res_partner_views.xml', + 'views/fp_serial_views.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py index e535b3f1..ee70955b 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py @@ -23,3 +23,4 @@ from . import fp_qc_template from . import fp_quality_check from . import fp_thickness_reading from . import res_partner +from . import fp_serial diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_serial.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_serial.py new file mode 100644 index 00000000..737c17f1 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_serial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +# +# Sub 5 — attach MO reverse links to fp.serial. Defined here rather than +# in fusion_plating_configurator because configurator loads before +# bridge_mrp; declaring the O2M at configurator setup time would fail +# because mrp.production.x_fc_serial_id wouldn't exist yet. + +from odoo import api, fields, models + + +class FpSerial(models.Model): + _inherit = 'fp.serial' + + production_ids = fields.One2many( + 'mrp.production', 'x_fc_serial_id', + string='Manufacturing Orders', + ) + production_count = fields.Integer(compute='_compute_production_count') + + @api.depends('production_ids') + def _compute_production_count(self): + for rec in self: + rec.production_count = len(rec.production_ids) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index 0c9c13ae..1a5ab2ce 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -111,6 +111,29 @@ class MrpProduction(models.Model): 'before this one. Copied from the first SO line that set it.', ) + # ---- Sub 5 — traceability fields copied from the source SO line -------- + # Populated by bridge_mrp's _prepare_mo_vals override, which pulls these + # from the first linked SO line. Lets the fp.serial registry show every + # MO it spawned via a direct FK rather than heuristic origin matching. + x_fc_serial_id = fields.Many2one( + 'fp.serial', string='Serial Number', + ondelete='set null', index=True, + help='Serial number from the source SO line.', + ) + x_fc_job_number = fields.Char( + string='Job #', index=True, + help='Shop-floor job number from the source SO line.', + ) + x_fc_thickness_id = fields.Many2one( + 'fp.coating.thickness', string='Thickness', + ondelete='set null', + help='Target coating thickness from the source SO line. Prints on the traveller.', + ) + x_fc_revision_snapshot = fields.Char( + string='Revision (snapshot)', + help='Revision letter captured on the source SO line at save time.', + ) + # ------------------------------------------------------------------ # T1.4 — Rework / strip-and-replate # ------------------------------------------------------------------ @@ -1287,7 +1310,7 @@ class MrpProduction(models.Model): ('active', '=', True), ], order='id', limit=1) - return { + vals = { 'company_id': mo.company_id.id or self.env.company.id, 'partner_id': job.partner_id.id, 'job_ref': job.name, @@ -1298,6 +1321,20 @@ class MrpProduction(models.Model): 'assigned_driver_id': driver.id if driver else False, 'state': 'draft', } + # Sub 5 — carry serial / job# / thickness / revision from the MO + # onto the draft delivery for end-to-end traceability. + Delivery = self.env.get('fusion.plating.delivery') + if Delivery is not None: + for fname in ('x_fc_serial_id', 'x_fc_job_number', + 'x_fc_thickness_id', 'x_fc_revision_snapshot'): + if fname in Delivery._fields and fname in mo._fields: + value = mo[fname] + if value: + if hasattr(value, 'id'): + vals[fname] = value.id + else: + vals[fname] = value + return vals # ------------------------------------------------------------------ # #3 — Render the cert PDF + cross-link it everywhere it's needed diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py index b1844bad..294143b2 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py @@ -212,6 +212,13 @@ class SaleOrder(models.Model): start_node = ln.x_fc_start_at_node_id break + # Sub 5 — carry serial / job# / thickness / revision from + # the first line of the group. Single-line groups pick it up + # cleanly; multi-line groups inherit the primary line's + # tracking data. These fields are metadata only (reports and + # smart buttons), so a "first line wins" rule is safe. + primary = lines[0] + mo_vals = { 'product_id': product.id, 'product_qty': qty, @@ -224,6 +231,14 @@ class SaleOrder(models.Model): mo_vals['x_fc_recipe_id'] = recipe.id if start_node: mo_vals['x_fc_start_at_node_id'] = start_node.id + if 'x_fc_serial_id' in Production._fields and primary.x_fc_serial_id: + mo_vals['x_fc_serial_id'] = primary.x_fc_serial_id.id + if 'x_fc_job_number' in Production._fields and primary.x_fc_job_number: + mo_vals['x_fc_job_number'] = primary.x_fc_job_number + if 'x_fc_thickness_id' in Production._fields and primary.x_fc_thickness_id: + mo_vals['x_fc_thickness_id'] = primary.x_fc_thickness_id.id + if 'x_fc_revision_snapshot' in Production._fields and primary.x_fc_revision_snapshot: + mo_vals['x_fc_revision_snapshot'] = primary.x_fc_revision_snapshot mo = Production.create(mo_vals) created.append((mo, tag, len(lines))) self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name) diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/fp_serial_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/fp_serial_views.xml new file mode 100644 index 00000000..af4bc5cb --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/views/fp_serial_views.xml @@ -0,0 +1,122 @@ + + + + + + fp.serial.form + fp.serial + +
+ +
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + + fp.serial.list + fp.serial + + + + + + + + + + + + + + + + fp.serial.search + fp.serial + + + + + + + + + + + + + + + + Serial Numbers + fp.serial + list,form + + + + + +
diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index e3dba900..6054895e 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Configurator', - 'version': '19.0.11.0.0', + 'version': '19.0.12.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'description': """ @@ -38,6 +38,7 @@ Provides: 'security/fp_configurator_security.xml', 'security/ir.model.access.csv', 'data/fp_configurator_sequence_data.xml', + 'data/fp_sub5_sequence_data.xml', 'data/fp_treatment_data.xml', 'views/fp_treatment_views.xml', 'views/fp_part_catalog_views.xml', diff --git a/fusion_plating/fusion_plating_configurator/data/fp_sub5_sequence_data.xml b/fusion_plating/fusion_plating_configurator/data/fp_sub5_sequence_data.xml new file mode 100644 index 00000000..5aee6f20 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/data/fp_sub5_sequence_data.xml @@ -0,0 +1,26 @@ + + + + + + Fusion Plating: Serial Number + fp.serial + FP-SN- + 5 + + + + + Fusion Plating: Job Number + fp.job.number + FP-JOB- + 5 + + + + diff --git a/fusion_plating/fusion_plating_configurator/models/__init__.py b/fusion_plating/fusion_plating_configurator/models/__init__.py index 25c2ab94..5e21cc87 100644 --- a/fusion_plating/fusion_plating_configurator/models/__init__.py +++ b/fusion_plating/fusion_plating_configurator/models/__init__.py @@ -5,12 +5,14 @@ from . import fp_treatment from . import fp_part_catalog +from . import fp_coating_thickness from . import fp_coating_config from . import fp_pricing_complexity_surcharge from . import fp_pricing_rule from . import fp_customer_price_list from . import fp_sale_description_template from . import fp_quote_configurator +from . import fp_serial from . import sale_order from . import sale_order_line from . import account_move_line diff --git a/fusion_plating/fusion_plating_configurator/models/account_move_line.py b/fusion_plating/fusion_plating_configurator/models/account_move_line.py index 6f4ebda0..6c154128 100644 --- a/fusion_plating/fusion_plating_configurator/models/account_move_line.py +++ b/fusion_plating/fusion_plating_configurator/models/account_move_line.py @@ -19,3 +19,23 @@ class AccountMoveLine(models.Model): help="Copied from sale.order.line on invoice creation so customer-" "facing invoice PDFs can render the customer's part number.", ) + # ---- Sub 5 --------------------------------------------------------------- + x_fc_serial_id = fields.Many2one( + 'fp.serial', + string='Serial Number', + index=True, + help='Copied from sale.order.line for traceability.', + ) + x_fc_job_number = fields.Char( + string='Job #', index=True, + help='Copied from sale.order.line.', + ) + x_fc_thickness_id = fields.Many2one( + 'fp.coating.thickness', + string='Thickness', + help='Copied from sale.order.line for customer-facing invoice PDFs.', + ) + x_fc_revision_snapshot = fields.Char( + string='Revision (snapshot)', + help='Revision letter from the source SO line.', + ) diff --git a/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py b/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py index d5b287f4..ef798622 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py @@ -37,6 +37,14 @@ class FpCoatingConfig(models.Model): [('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')], string='Thickness UoM', default='mils', ) + thickness_option_ids = fields.One2many( + 'fp.coating.thickness', + 'coating_config_id', + string='Thickness Options', + help='Discrete thickness values the estimator can pick from when ' + 'this coating appears on a sale order line. Each value is ' + 'driven by the spec the coating is built against. Sub 5.', + ) spec_reference = fields.Char(string='Spec Reference', help='e.g. "AMS 2404", "E499-303-00-005"') certification_level = fields.Selection( [('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'), diff --git a/fusion_plating/fusion_plating_configurator/models/fp_coating_thickness.py b/fusion_plating/fusion_plating_configurator/models/fp_coating_thickness.py new file mode 100644 index 00000000..94a762bf --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/models/fp_coating_thickness.py @@ -0,0 +1,58 @@ +# -*- 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 FpCoatingThickness(models.Model): + """Allowed thickness option for a coating configuration. + + Each plating process (ENP Class 4, hard chrome 0.001", Type III + anodize, etc.) has its own set of valid thicknesses driven by the + spec it's built from. This child of `fp.coating.config` holds the + discrete options so the SO-line thickness dropdown can filter to + only what's actually achievable for the line's coating. + """ + _name = 'fp.coating.thickness' + _description = 'Coating Thickness Option' + _order = 'coating_config_id, sequence, value' + + coating_config_id = fields.Many2one( + 'fp.coating.config', + required=True, + ondelete='cascade', + ) + value = fields.Float( + digits=(10, 4), + required=True, + help='Target thickness value (magnitude only; UoM in the next field).', + ) + uom = fields.Selection( + [('mils', 'mils (0.001 in)'), + ('microns', 'microns (µm)'), + ('inches', 'inches'), + ('mm', 'mm')], + required=True, + default='mils', + ) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + @api.depends('value', 'uom') + def _compute_display_name(self): + uom_labels = dict(self._fields['uom'].selection) + for rec in self: + label = uom_labels.get(rec.uom, rec.uom or '') + # Strip the bracketed clarification for a tighter dropdown row. + if ' (' in label: + label = label.split(' (')[0] + if rec.value: + rec.display_name = f'{rec.value:g} {label}'.strip() + else: + rec.display_name = label diff --git a/fusion_plating/fusion_plating_configurator/models/fp_serial.py b/fusion_plating/fusion_plating_configurator/models/fp_serial.py new file mode 100644 index 00000000..24476945 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/models/fp_serial.py @@ -0,0 +1,151 @@ +# -*- 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 FpSerial(models.Model): + """Serial number registry. + + One record per "occurrence of a part on an order line". The same part + ordered six months later gets a different serial. The serial is the + common thread linking the SO line to the MO, Delivery, and Invoice + records it spawns downstream. + + Most serials are customer-supplied (pass-through from the customer's + own end-user); a smaller share are shop-generated via the sequence. + The registry is optional — SO lines can carry no serial at all. + """ + _name = 'fp.serial' + _description = 'Fusion Plating — Serial Number' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'create_date desc, id desc' + _rec_name = 'name' + + name = fields.Char( + required=True, + tracking=True, + help='Customer-supplied serial (most common) or shop-generated ' + 'sequence value. Typed-in values are accepted as-is.', + ) + company_id = fields.Many2one( + 'res.company', required=True, + default=lambda s: s.env.company, + ) + sale_order_line_id = fields.Many2one( + 'sale.order.line', + string='Source Sale Order Line', + ondelete='set null', + copy=False, + tracking=True, + ) + sale_order_id = fields.Many2one( + related='sale_order_line_id.order_id', + store=True, + string='Sale Order', + ) + customer_id = fields.Many2one( + related='sale_order_line_id.order_id.partner_id', + store=True, + string='Customer', + ) + part_id = fields.Many2one( + related='sale_order_line_id.x_fc_part_catalog_id', + store=True, + string='Part', + ) + notes = fields.Text(string='Notes') + + # Reverse link to invoice lines — safe here because account.move.line + # lives in this same module. Production (mrp) and delivery (logistics) + # reverse links are defined in their own modules' fp_serial inherits + # to keep module load order consistent. + invoice_line_ids = fields.One2many( + 'account.move.line', 'x_fc_serial_id', + string='Invoice Lines', + ) + invoice_ids = fields.Many2many( + 'account.move', + compute='_compute_invoice_ids', + string='Invoices', + ) + + invoice_count = fields.Integer(compute='_compute_counts') + # production_count / delivery_count are declared in the inheriting + # modules (bridge_mrp / logistics) so the O2Ms exist alongside them. + + _sql_constraints = [ + ('fp_serial_name_company_uniq', + 'unique(company_id, name)', + 'Serial number must be unique within the company.'), + ] + + # ---- Computes ------------------------------------------------------------ + + @api.depends('invoice_line_ids.move_id') + def _compute_counts(self): + # Base compute sets invoice_count only. bridge_mrp + logistics + # override this to also populate production_count / delivery_count. + for rec in self: + rec.invoice_count = len(rec.invoice_line_ids.mapped('move_id')) + + @api.depends('invoice_line_ids.move_id') + def _compute_invoice_ids(self): + for rec in self: + rec.invoice_ids = rec.invoice_line_ids.mapped('move_id') + + # ---- Actions ------------------------------------------------------------- + + def action_view_sale_order(self): + self.ensure_one() + if not self.sale_order_id: + return False + return { + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'res_id': self.sale_order_id.id, + 'view_mode': 'form', + } + + def action_view_productions(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Manufacturing Orders'), + 'res_model': 'mrp.production', + 'domain': [('id', 'in', self.production_ids.ids)], + 'view_mode': 'list,form', + } + + def action_view_deliveries(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Deliveries'), + 'res_model': 'fusion.plating.delivery', + 'domain': [('id', 'in', self.delivery_ids.ids)], + 'view_mode': 'list,form', + } + + def action_view_invoices(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Invoices'), + 'res_model': 'account.move', + 'domain': [('id', 'in', self.invoice_ids.ids)], + 'view_mode': 'list,form', + } + + def action_view_part(self): + self.ensure_one() + if not self.part_id: + return False + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.part.catalog', + 'res_id': self.part_id.id, + 'view_mode': 'form', + } diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order.py b/fusion_plating/fusion_plating_configurator/models/sale_order.py index 1f620ef9..012b9e18 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order.py @@ -579,3 +579,20 @@ class SaleOrder(models.Model): 'title': 'PO — %s' % (self.x_fc_po_attachment_id.name or ''), }, } + + # ---- Sub 5 — auto-assign Job # on confirm ------------------------------- + # Job # is the shop-floor reference that prints on travellers and WOs. + # Auto-assigned once at confirm so every confirmed line has one; still + # editable afterwards (clearable, overridable to match a customer scheme). + def action_confirm(self): + res = super().action_confirm() + Sequence = self.env['ir.sequence'] + for so in self: + for line in so.order_line: + if line.display_type: + continue + if not line.x_fc_job_number: + line.x_fc_job_number = Sequence.next_by_code( + 'fp.job.number' + ) or False + return res diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py index 121d8b40..8e8dad98 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py @@ -67,6 +67,66 @@ class SaleOrderLine(models.Model): 'preserved for audit. Useful when a part is cancelled mid-order.', ) + # ---- Sub 5 — Order-line fields (serial / job# / thickness / revision) --- + # NB: sale.order.line in Odoo 19 does not support `tracking=True` on + # inherited fields — Odoo emits a warning and ignores it. Audit trail + # for these values lives on fp.serial.mail.thread instead. + x_fc_serial_id = fields.Many2one( + 'fp.serial', + string='Serial Number', + ondelete='set null', + copy=False, + help='Customer-supplied serial number for this line. Optional. ' + 'Typing a value offers to create a new fp.serial record on ' + 'the fly; use the Generate Serial button to auto-sequence.', + ) + x_fc_job_number = fields.Char( + string='Job #', + copy=False, + index=True, + help='Shop-floor reference for this line. Auto-sequenced on sale ' + 'order confirmation; editable. Blank is allowed.', + ) + x_fc_thickness_id = fields.Many2one( + 'fp.coating.thickness', + string='Thickness', + ondelete='set null', + domain="[('coating_config_id', '=', x_fc_coating_config_id)]", + help="Target coating thickness. Options come from the line's " + 'coating configuration.', + ) + x_fc_revision_snapshot = fields.Char( + string='Revision (snapshot)', + copy=False, + readonly=True, + help='Revision letter / number at the moment the line was saved. ' + 'Preserved even if the part catalog revision is later edited ' + 'or the catalog row is removed.', + ) + + # Revision picker — non-stored compute that re-points x_fc_part_catalog_id + # to any revision of the same part number. The Part M2O itself is domain- + # filtered to latest revisions only, so the picker is what surfaces + # earlier revisions when the estimator needs one. + x_fc_revision_pick_id = fields.Many2one( + 'fp.part.catalog', + string='Revision', + compute='_compute_revision_pick_id', + inverse='_inverse_revision_pick_id', + store=False, + help='Switch to a different revision of the same part number.', + ) + + @api.depends('x_fc_part_catalog_id') + def _compute_revision_pick_id(self): + for line in self: + line.x_fc_revision_pick_id = line.x_fc_part_catalog_id + + def _inverse_revision_pick_id(self): + for line in self: + if line.x_fc_revision_pick_id: + line.x_fc_part_catalog_id = line.x_fc_revision_pick_id + @api.model_create_multi def create(self, vals_list): """Default `x_fc_internal_description` from `name` when a caller @@ -82,21 +142,43 @@ class SaleOrderLine(models.Model): back-filled historical lines. """ Product = self.env['product.product'] + Part = self.env['fp.part.catalog'] for vals in vals_list: - if vals.get('x_fc_internal_description'): - continue - # Try the explicit `name` first. If the caller didn't pass - # one (sale_mrp + some Odoo internals don't — they let the - # name compute from product_id later), fall back to the - # product's display_name so we have SOMETHING non-empty. - fallback = vals.get('name') - if not fallback and vals.get('product_id'): - prod = Product.browse(vals['product_id']).exists() - if prod: - fallback = prod.display_name or prod.name - vals['x_fc_internal_description'] = fallback or '—' + if not vals.get('x_fc_internal_description'): + # Try the explicit `name` first. If the caller didn't pass + # one (sale_mrp + some Odoo internals don't — they let the + # name compute from product_id later), fall back to the + # product's display_name so we have SOMETHING non-empty. + fallback = vals.get('name') + if not fallback and vals.get('product_id'): + prod = Product.browse(vals['product_id']).exists() + if prod: + fallback = prod.display_name or prod.name + vals['x_fc_internal_description'] = fallback or '—' + + # Sub 5 — freeze the revision letter on the line at save time. + # Protects historical SOs from later edits to the catalog row. + if not vals.get('x_fc_revision_snapshot') and vals.get('x_fc_part_catalog_id'): + part = Part.browse(vals['x_fc_part_catalog_id']).exists() + if part and part.revision: + vals['x_fc_revision_snapshot'] = part.revision return super().create(vals_list) + def write(self, vals): + # Sub 5 — keep the revision snapshot in lockstep with the line's + # part catalog pointer. Only refresh when the part changes; never + # overwrite a snapshot that's already been set on a historical line. + if 'x_fc_part_catalog_id' in vals: + Part = self.env['fp.part.catalog'] + new_part = Part.browse(vals['x_fc_part_catalog_id']).exists() + if new_part and new_part.revision: + vals.setdefault('x_fc_revision_snapshot', new_part.revision) + # Blank overrides keep their snapshot; explicit changes do too. + for line in self: + if line.x_fc_part_catalog_id.id != new_part.id: + line.x_fc_revision_snapshot = new_part.revision + return super().write(vals) + @api.onchange('x_fc_description_template_id') def _onchange_description_template(self): """When estimator picks a template, auto-fill both descriptions. @@ -122,13 +204,55 @@ class SaleOrderLine(models.Model): return True def _prepare_invoice_line(self, **optional_values): - """Carry x_fc_part_catalog_id from SO line to invoice line. + """Carry x_fc_part_catalog_id + Sub 5 fields from SO line to invoice line. Sub 2 Task 19 — lets the customer-facing invoice PDF render the customer's part number via the shared customer_line_header macro instead of the internal service SKU. + + Sub 5 — also carry serial / job# / thickness / revision snapshot so + the same macro can print them unchanged on invoices. """ vals = super()._prepare_invoice_line(**optional_values) if self.x_fc_part_catalog_id: vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id + if self.x_fc_serial_id: + vals['x_fc_serial_id'] = self.x_fc_serial_id.id + if self.x_fc_job_number: + vals['x_fc_job_number'] = self.x_fc_job_number + if self.x_fc_thickness_id: + vals['x_fc_thickness_id'] = self.x_fc_thickness_id.id + if self.x_fc_revision_snapshot: + vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot return vals + + @api.onchange('x_fc_coating_config_id') + def _onchange_coating_clears_thickness(self): + """Clear the thickness picker when coating config changes. + + The thickness options are scoped to the coating config; a value + carried over from a previous coating would fail its domain. + """ + for line in self: + if (line.x_fc_thickness_id + and line.x_fc_thickness_id.coating_config_id + != line.x_fc_coating_config_id): + line.x_fc_thickness_id = False + + def action_generate_serial(self): + """Create a fresh fp.serial for this line using the shop sequence.""" + self.ensure_one() + if self.x_fc_serial_id: + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.serial', + 'res_id': self.x_fc_serial_id.id, + 'view_mode': 'form', + } + seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000' + serial = self.env['fp.serial'].create({ + 'name': seq, + 'sale_order_line_id': self.id, + }) + self.x_fc_serial_id = serial.id + return False diff --git a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv index a49ee5f5..262997aa 100644 --- a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv @@ -39,3 +39,9 @@ access_fp_customer_price_list_manager,fp.customer.price.list.manager,model_fp_cu access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0 access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0 +access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_configurator.group_fp_estimator,1,1,1,0 +access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0 +access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0 +access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_configurator/views/fp_coating_config_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_coating_config_views.xml index 83b44714..763bc8ff 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_coating_config_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_coating_config_views.xml @@ -66,6 +66,24 @@ + +

+ Discrete thickness values the estimator can pick when + this coating appears on a sale order line. Each value + is driven by the spec this coating is built against + (e.g. AMS-2404 Class 4 → 0.0005″ / 0.001″ / 0.0015″). + Leave empty if no dropdown is needed for this coating. +

+ + + + + + + + + +
diff --git a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml index d22540a4..f45f228b 100644 --- a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml @@ -188,6 +188,22 @@ placeholder="Shop-floor workflow instructions (prints on WO / traveler)" optional="hide"/> + + + + + diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py index 3b637ead..2876e019 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py @@ -141,6 +141,25 @@ class FpDirectOrderLine(models.TransientModel): compute='_compute_is_missing_info', ) + # ---- Sub 5 — Serial / Job# / Thickness ------------------------------- + # These mirror the SO-line fields and are carried over when the wizard + # creates the sale order. Serial stays optional; Job# is left blank + # here and gets auto-assigned by action_confirm on the SO. + serial_id = fields.Many2one( + 'fp.serial', + string='Serial Number', + ondelete='set null', + help='Optional. Typing a value offers to create a new serial on ' + 'the fly, or hit "Generate Serial" to auto-sequence.', + ) + job_number = fields.Char(string='Job #') + thickness_id = fields.Many2one( + 'fp.coating.thickness', + string='Thickness', + domain="[('coating_config_id', '=', coating_config_id)]", + ondelete='set null', + ) + # ---- Computes ---- @api.depends('quantity', 'unit_price') def _compute_line_subtotal(self): @@ -157,6 +176,22 @@ class FpDirectOrderLine(models.TransientModel): and rec.quantity ) + @api.onchange('coating_config_id') + def _onchange_coating_clears_thickness(self): + for rec in self: + if (rec.thickness_id + and rec.thickness_id.coating_config_id != rec.coating_config_id): + rec.thickness_id = False + + def action_generate_serial(self): + """Create an auto-sequenced fp.serial and assign it to this line.""" + self.ensure_one() + if self.serial_id: + return False + seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000' + self.serial_id = self.env['fp.serial'].create({'name': seq}).id + return False + # ---- Onchange ---- @api.onchange('quote_id') def _onchange_quote_id(self): diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py index 8df36270..4406ac2f 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py @@ -279,6 +279,11 @@ class FpDirectOrderWizard(models.TransientModel): '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, + # Sub 5 — carry serial / job# / thickness onto the SO line. + # Revision snapshot auto-fills on SO-line create from the part. + 'x_fc_serial_id': line.serial_id.id or False, + 'x_fc_job_number': line.job_number or False, + 'x_fc_thickness_id': line.thickness_id.id or False, })) # 5. Create — stays in draft / quotation. Sub 1: user reviews diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml index 52ab676c..b9884089 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml @@ -106,6 +106,14 @@ + + + diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index 8368ac04..752baaab 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Logistics', - 'version': '19.0.1.2.0', + 'version': '19.0.2.0.0', 'category': 'Manufacturing/Plating', 'summary': ( 'Pickup & delivery for plating shops: vehicle master, driver ' @@ -41,6 +41,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'currency': 'CAD', 'depends': [ 'fusion_plating', + 'fusion_plating_configurator', 'hr', 'mail', ], diff --git a/fusion_plating/fusion_plating_logistics/models/__init__.py b/fusion_plating/fusion_plating_logistics/models/__init__.py index dabd8896..e16c51c4 100644 --- a/fusion_plating/fusion_plating_logistics/models/__init__.py +++ b/fusion_plating/fusion_plating_logistics/models/__init__.py @@ -11,3 +11,4 @@ from . import fp_route from . import fp_route_stop from . import fp_chain_of_custody from . import fp_proof_of_delivery +from . import fp_serial diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index aedba767..b99f2067 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -57,6 +57,23 @@ class FpDelivery(models.Model): 'Will become a Many2one once the job module ships.', tracking=True, ) + # ---- Sub 5 — traceability fields from the source MO -------------------- + x_fc_serial_id = fields.Many2one( + 'fp.serial', string='Serial Number', + ondelete='set null', index=True, + help='Serial copied from the MO when bridge_mrp drafts this delivery.', + ) + x_fc_job_number = fields.Char( + string='Job #', index=True, + help='Shop-floor job number from the MO. Prints on packing slip.', + ) + x_fc_thickness_id = fields.Many2one( + 'fp.coating.thickness', string='Thickness', + ondelete='set null', + ) + x_fc_revision_snapshot = fields.Char( + string='Revision (snapshot)', + ) scheduled_date = fields.Datetime( string='Scheduled Date', tracking=True, diff --git a/fusion_plating/fusion_plating_logistics/models/fp_serial.py b/fusion_plating/fusion_plating_logistics/models/fp_serial.py new file mode 100644 index 00000000..d37b7e68 --- /dev/null +++ b/fusion_plating/fusion_plating_logistics/models/fp_serial.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +# +# Sub 5 — attach delivery reverse link to fp.serial. Lives here rather +# than in fusion_plating_configurator because fusion.plating.delivery +# is defined in this module, which loads after configurator. + +from odoo import api, fields, models + + +class FpSerial(models.Model): + _inherit = 'fp.serial' + + delivery_ids = fields.One2many( + 'fusion.plating.delivery', 'x_fc_serial_id', + string='Deliveries', + ) + delivery_count = fields.Integer(compute='_compute_delivery_count') + + @api.depends('delivery_ids') + def _compute_delivery_count(self): + for rec in self: + rec.delivery_count = len(rec.delivery_ids) diff --git a/fusion_plating/fusion_plating_reports/__manifest__.py b/fusion_plating/fusion_plating_reports/__manifest__.py index cd430f6d..1b90f70d 100644 --- a/fusion_plating/fusion_plating_reports/__manifest__.py +++ b/fusion_plating/fusion_plating_reports/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Reports', - 'version': '19.0.5.0.0', + 'version': '19.0.5.1.0', 'category': 'Manufacturing/Plating', 'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.', 'depends': [ diff --git a/fusion_plating/fusion_plating_reports/report/customer_line_header.xml b/fusion_plating/fusion_plating_reports/report/customer_line_header.xml index 8f4eb2f7..13c20c32 100644 --- a/fusion_plating/fusion_plating_reports/report/customer_line_header.xml +++ b/fusion_plating/fusion_plating_reports/report/customer_line_header.xml @@ -24,12 +24,35 @@ - - (Rev ) + + + + (Rev )
+ + +
+ Serial: +
+ +
+ Job #: +
+ +
+ Thickness: +