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:
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user