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

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