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:
@@ -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 |
|
||||
|
||||
149
fusion_plating/docs/superpowers/tests/2026-04-22-sub5-smoke.py
Normal file
149
fusion_plating/docs/superpowers/tests/2026-04-22-sub5-smoke.py
Normal 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 ===')
|
||||
@@ -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': [
|
||||
|
||||
@@ -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
|
||||
|
||||
26
fusion_plating/fusion_plating_bridge_mrp/models/fp_serial.py
Normal file
26
fusion_plating/fusion_plating_bridge_mrp/models/fp_serial.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
151
fusion_plating/fusion_plating_configurator/models/fp_serial.py
Normal file
151
fusion_plating/fusion_plating_configurator/models/fp_serial.py
Normal 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',
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
fusion_plating/fusion_plating_logistics/models/fp_serial.py
Normal file
25
fusion_plating/fusion_plating_logistics/models/fp_serial.py
Normal 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)
|
||||
@@ -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': [
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user