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 — 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"/>