feat(plating): Sub 5 — order-line fields (serial, job#, thickness, revision)

Four new fields on every sale.order.line, propagated through to MO,
Delivery, and Invoice for end-to-end traceability:

- fp.serial registry (new model in configurator) with smart-button
  traceability to Sale Order, MO, Delivery, Invoice, Part. M2O on SO
  line; optional; user types a customer serial or clicks Generate
  Serial for a sequence-backed one. Reverse O2M links split across
  configurator (invoice) / bridge_mrp (MO) / logistics (delivery) so
  module load order is respected.
- x_fc_job_number on SO line, auto-sequenced FP-JOB-NNNNN on SO
  confirm. Editable — shops can override for customer/legacy schemes.
- fp.coating.thickness (new child of fp.coating.config) with per-
  config discrete thickness options; x_fc_thickness_id on SO line
  domain-filtered to the line's coating. Auto-clears when coating
  changes.
- x_fc_revision_snapshot Char on SO line, frozen from
  x_fc_part_catalog_id.revision at save. Protects historical SOs from
  later catalog edits. Secondary "Revision" picker on the tree view
  lets users switch between prior revisions of the same part number;
  the Part M2O still surfaces only is_latest_revision rows.

Reports (CoC, packing slip, invoice, BoL) pick up all four via the
Sub 2 customer_line_header macro — one macro edit, four reports.

Smoke on entech: 11 assertions pass including revision snapshot,
generate-serial button, typed-serial create-on-fly, coating→thickness
domain reset, SO confirm auto job#, and MO traceability carry.

Module version bumps:
  fusion_plating_configurator  → 19.0.12.0.0
  fusion_plating_bridge_mrp    → 19.0.11.0.0
  fusion_plating_logistics     → 19.0.2.0.0 (+depends configurator)
  fusion_plating_reports       → 19.0.5.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 23:04:44 -04:00
parent bb9bcf45f8
commit 25c3f6f8d1
29 changed files with 934 additions and 21 deletions

View File

@@ -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 1530 min, seed 610 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 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
<?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.
Sub 5 — Serial Number registry views.
-->
<odoo>
<record id="view_fp_serial_form" model="ir.ui.view">
<field name="name">fp.serial.form</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<form string="Serial Number">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_productions" type="object"
class="oe_stat_button" icon="fa-cogs"
invisible="production_count == 0">
<field name="production_count" widget="statinfo" string="MOs"/>
</button>
<button name="action_view_deliveries" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="delivery_count == 0">
<field name="delivery_count" widget="statinfo" string="Deliveries"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-money"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
<button name="action_view_part" type="object"
class="oe_stat_button" icon="fa-cube"
invisible="not part_id">
<div class="o_stat_info">
<span class="o_stat_text">Part</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name" string="Serial #"/>
<h1><field name="name" placeholder="e.g. SN-12345"/></h1>
</div>
<group>
<group>
<field name="customer_id" readonly="1"/>
<field name="part_id" readonly="1"/>
<field name="sale_order_id" readonly="1"/>
</group>
<group>
<field name="sale_order_line_id" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_serial_list" model="ir.ui.view">
<field name="name">fp.serial.list</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<list string="Serial Numbers">
<field name="name"/>
<field name="customer_id"/>
<field name="part_id"/>
<field name="sale_order_id"/>
<field name="production_count" string="MOs"/>
<field name="delivery_count" string="Deliveries"/>
<field name="invoice_count" string="Invoices"/>
<field name="create_date"/>
</list>
</field>
</record>
<record id="view_fp_serial_search" model="ir.ui.view">
<field name="name">fp.serial.search</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="customer_id"/>
<field name="part_id"/>
<field name="sale_order_id"/>
<group>
<filter name="group_customer" string="Customer"
context="{'group_by': 'customer_id'}"/>
<filter name="group_part" string="Part"
context="{'group_by': 'part_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_serial" model="ir.actions.act_window">
<field name="name">Serial Numbers</field>
<field name="res_model">fp.serial</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_serial_search"/>
</record>
<menuitem id="menu_fp_serial"
name="Serial Numbers"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_serial"
sequence="60"/>
</odoo>

View File

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

View File

@@ -0,0 +1,26 @@
<?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.
Sub 5 — sequences for serial numbers and job numbers on SO lines.
-->
<odoo noupdate="1">
<record id="seq_fp_serial" model="ir.sequence">
<field name="name">Fusion Plating: Serial Number</field>
<field name="code">fp.serial</field>
<field name="prefix">FP-SN-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_fp_job_number" model="ir.sequence">
<field name="name">Fusion Plating: Job Number</field>
<field name="code">fp.job.number</field>
<field name="prefix">FP-JOB-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

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

View File

@@ -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.',
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
39 access_fp_sale_desc_template_user fp.sale.description.template.user model_fp_sale_description_template base.group_user 1 0 0 0
40 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
41 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
42 access_fp_serial_user fp.serial.user model_fp_serial base.group_user 1 0 0 0
43 access_fp_serial_estimator fp.serial.estimator model_fp_serial fusion_plating_configurator.group_fp_estimator 1 1 1 0
44 access_fp_serial_manager fp.serial.manager model_fp_serial fusion_plating.group_fusion_plating_manager 1 1 1 1
45 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
46 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
47 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -66,6 +66,24 @@
<page string="Description" name="description">
<field name="description" placeholder="Detailed description of this coating configuration..."/>
</page>
<page string="Thickness Options" name="thickness_options">
<p class="text-muted">
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.
</p>
<field name="thickness_option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="value"/>
<field name="uom"/>
<field name="display_name" string="Display" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</page>
</notebook>
<group>
<field name="active" widget="boolean_toggle"/>

View File

@@ -188,6 +188,22 @@
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_thickness_id"
options="{'no_create': True}"
invisible="not x_fc_coating_config_id"
optional="show"/>
<field name="x_fc_serial_id"
options="{'no_create_edit': False}"
optional="show"/>
<field name="x_fc_job_number" optional="show"/>
<field name="x_fc_revision_pick_id"
string="Revision"
options="{'no_create': True}"
invisible="not x_fc_part_catalog_id"
optional="hide"/>
<field name="x_fc_revision_snapshot"
readonly="1"
optional="hide"/>
<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"/>

View File

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

View File

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

View File

@@ -106,6 +106,14 @@
<field name="internal_description"
optional="hide"/>
<field name="coating_config_id"/>
<field name="thickness_id"
options="{'no_create': True}"
invisible="not coating_config_id"
optional="show"/>
<field name="serial_id"
options="{'no_create_edit': False}"
optional="show"/>
<field name="job_number" optional="hide"/>
<field name="treatment_ids"
widget="many2many_tags"
optional="hide"/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,12 +24,35 @@
<t t-if="line.x_fc_part_catalog_id">
<strong>
<span t-esc="line.x_fc_part_catalog_id.part_number"/>
<t t-if="line.x_fc_part_catalog_id.revision">
<span> (Rev <span t-esc="line.x_fc_part_catalog_id.revision"/>)</span>
<!-- Sub 5 — prefer the revision snapshot captured on the line
at save time; fall back to the catalog's current revision
if no snapshot was frozen (old lines from before Sub 5). -->
<t t-set="_rev" t-value="(
line.x_fc_revision_snapshot
if 'x_fc_revision_snapshot' in line._fields
and line.x_fc_revision_snapshot
else line.x_fc_part_catalog_id.revision)"/>
<t t-if="_rev">
<span> (Rev <span t-esc="_rev"/>)</span>
</t>
</strong>
<br/>
<span t-esc="line.name"/>
<!-- Sub 5 — print serial / job# / thickness under the line
description when populated. Each on its own row so blank
fields disappear cleanly. -->
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<br/>
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
</t>
<t t-if="'x_fc_job_number' in line._fields and line.x_fc_job_number">
<br/>
<small>Job #: <span t-esc="line.x_fc_job_number"/></small>
</t>
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
<br/>
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>
</t>
</t>
<t t-else="">
<!-- Fee / freight / non-part line: standard Odoo rendering -->