Mastery Threshold controls auto-promotion: when an
diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_production_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_production_views.xml
index 15c3aedf..4deb5228 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_production_views.xml
+++ b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_production_views.xml
@@ -57,9 +57,7 @@
-
-
-
+
diff --git a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
index 7cac6fc8..700db35c 100644
--- a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
+++ b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
@@ -145,9 +145,7 @@
-
-
-
+
diff --git a/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml b/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml
index 9cc011e7..56dd8797 100644
--- a/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml
+++ b/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml
@@ -39,10 +39,8 @@
company default, then a hardcoded AS9100/ISO 9001
statement.
-
-
-
diff --git a/fusion_plating/fusion_plating_compliance/data/fp_demo_compliance_data.xml b/fusion_plating/fusion_plating_compliance/data/fp_demo_compliance_data.xml
index 2fc4f885..27d9f9fc 100644
--- a/fusion_plating/fusion_plating_compliance/data/fp_demo_compliance_data.xml
+++ b/fusion_plating/fusion_plating_compliance/data/fp_demo_compliance_data.xml
@@ -50,7 +50,7 @@
Filter-press cake from hexavalent chrome waste treatment system.liquid45.0
- kg/day
+ kg_dayLicensed hazardous waste facility
@@ -62,7 +62,7 @@
Spent sulphuric and hydrochloric acid from pickling tanks.liquid120.0
- L/day
+ l_dayAcid reclamation
@@ -74,7 +74,7 @@
Sludge from black oxide line waste treatment.sludge10.0
- kg/day
+ kg_dayStabilisation and secure landfill
@@ -95,7 +95,7 @@
800.0
- L
+ ldraft
Pending carrier assignment for spent acid pickup.
@@ -107,7 +107,7 @@
Chromic Acid5.0
- L
+ lChrome line — tank overflow bermSpill contained within secondary containment berm. Absorbent pads deployed. Area neutralised with soda ash.
@@ -121,7 +121,7 @@
Nickel Sulphate Solution2.0
- L
+ lEast Annex — nickel line transfer pumpMinor drip from pump seal. Caught by drip tray, cleaned immediately.
diff --git a/fusion_plating/fusion_plating_compliance/models/fp_discharge_limit.py b/fusion_plating/fusion_plating_compliance/models/fp_discharge_limit.py
index 51bd4f65..5c9962c8 100644
--- a/fusion_plating/fusion_plating_compliance/models/fp_discharge_limit.py
+++ b/fusion_plating/fusion_plating_compliance/models/fp_discharge_limit.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
+from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
+
class FpDischargeLimit(models.Model):
_name = 'fusion.plating.discharge.limit'
@@ -18,8 +20,11 @@ class FpDischargeLimit(models.Model):
('combined', 'Combined Sewer'), ('air', 'Air Emission'), ('other', 'Other')],
string='Discharge Point', default='sanitary', required=True,
)
- limit_value = fields.Float(string='Limit', digits=(16, 4))
- uom = fields.Char(string='UoM')
+ limit_value = fields.Float(string='Limit', digits=(16, 4),
+ help='Numerical limit, expressed in the unit selected below.')
+ uom = fields.Selection(FP_UOM_SELECTION, string='UoM',
+ help='Unit the limit is enforced in (typical: mg/L for liquid '
+ 'discharge, mg/m³ for air emissions).')
limit_type = fields.Selection(
[('max', 'Maximum'), ('min', 'Minimum'), ('range', 'Range'), ('ceiling', 'Hard Ceiling')],
string='Limit Type', default='max', required=True,
diff --git a/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample_line.py b/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample_line.py
index cdb3332c..1a7a47ea 100644
--- a/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample_line.py
+++ b/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample_line.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
+from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
+
class FpDischargeSampleLine(models.Model):
_name = 'fusion.plating.discharge.sample.line'
@@ -12,8 +14,11 @@ class FpDischargeSampleLine(models.Model):
sample_id = fields.Many2one('fusion.plating.discharge.sample', string='Sample', required=True, ondelete='cascade')
limit_id = fields.Many2one('fusion.plating.discharge.limit', string='Limit', ondelete='restrict')
parameter = fields.Char(string='Parameter', related='limit_id.parameter', store=True, readonly=False)
- value = fields.Float(string='Result', digits=(16, 4))
- uom = fields.Char(string='UoM')
+ value = fields.Float(string='Result', digits=(16, 4),
+ help='Measured value, expressed in the unit selected below.')
+ uom = fields.Selection(FP_UOM_SELECTION, string='UoM',
+ help='Unit of the measured value. Defaults to the limit\'s unit; '
+ 'override only when the lab reported in a different unit.')
status = fields.Selection(
[('ok', 'OK'), ('warning', 'Warning'), ('out_of_spec', 'Out of Spec'), ('pending', 'Pending')],
string='Status', compute='_compute_status', store=True,
diff --git a/fusion_plating/fusion_plating_compliance/models/fp_spill_register.py b/fusion_plating/fusion_plating_compliance/models/fp_spill_register.py
index 7b9b229b..76611344 100644
--- a/fusion_plating/fusion_plating_compliance/models/fp_spill_register.py
+++ b/fusion_plating/fusion_plating_compliance/models/fp_spill_register.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
+from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
+
class FpSpillRegister(models.Model):
_name = 'fusion.plating.spill.register'
@@ -16,8 +18,10 @@ class FpSpillRegister(models.Model):
spill_date = fields.Datetime(string='Spill Date', required=True, default=fields.Datetime.now, tracking=True)
reported_by_id = fields.Many2one('res.users', string='Reported By', default=lambda s: s.env.user)
substance = fields.Char(string='Substance', tracking=True)
- quantity = fields.Float(string='Quantity', digits=(16, 3))
- uom = fields.Char(string='UoM', default='L')
+ quantity = fields.Float(string='Quantity', digits=(16, 3),
+ help='Quantity spilled, expressed in the unit selected below.')
+ uom = fields.Selection(FP_UOM_SELECTION, string='UoM', default='l',
+ help='Unit of the spill quantity (L for liquids, kg for solids).')
location = fields.Char(string='Location')
containment_action = fields.Text(string='Containment Action')
regulator_notified = fields.Boolean(string='Regulator Notified', tracking=True)
diff --git a/fusion_plating/fusion_plating_compliance/models/fp_waste_manifest.py b/fusion_plating/fusion_plating_compliance/models/fp_waste_manifest.py
index 11d721d5..4e8c56b6 100644
--- a/fusion_plating/fusion_plating_compliance/models/fp_waste_manifest.py
+++ b/fusion_plating/fusion_plating_compliance/models/fp_waste_manifest.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
+from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
+
class FpWasteManifest(models.Model):
_name = 'fusion.plating.waste.manifest'
@@ -15,8 +17,10 @@ class FpWasteManifest(models.Model):
facility_id = fields.Many2one('fusion.plating.facility', related='waste_stream_id.facility_id', store=True, readonly=True)
company_id = fields.Many2one('res.company', related='facility_id.company_id', store=True, readonly=True)
ship_date = fields.Date(string='Ship Date', default=fields.Date.context_today, tracking=True)
- quantity = fields.Float(string='Quantity', digits=(16, 3))
- uom = fields.Char(string='UoM', default='kg')
+ quantity = fields.Float(string='Quantity', digits=(16, 3),
+ help='Quantity shipped, expressed in the unit selected below.')
+ uom = fields.Selection(FP_UOM_SELECTION, string='UoM', default='kg',
+ help='Unit of the shipped quantity (kg, L, m³, etc.).')
carrier_id = fields.Many2one('res.partner', string='Carrier', domain=[('is_company', '=', True)], tracking=True)
receiver_id = fields.Many2one('res.partner', string='Receiver', domain=[('is_company', '=', True)], tracking=True)
manifest_number = fields.Char(string='Manifest #', tracking=True)
diff --git a/fusion_plating/fusion_plating_compliance/models/fp_waste_stream.py b/fusion_plating/fusion_plating_compliance/models/fp_waste_stream.py
index 1e151d60..15d3690c 100644
--- a/fusion_plating/fusion_plating_compliance/models/fp_waste_stream.py
+++ b/fusion_plating/fusion_plating_compliance/models/fp_waste_stream.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
+from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
+
class FpWasteStream(models.Model):
_name = 'fusion.plating.waste.stream'
@@ -19,8 +21,12 @@ class FpWasteStream(models.Model):
[('liquid', 'Liquid'), ('solid', 'Solid'), ('sludge', 'Sludge'), ('gas', 'Gas')],
string='Physical State', default='liquid',
)
- generation_rate = fields.Float(string='Generation Rate')
- generation_uom = fields.Char(string='Rate UoM', default='kg/day')
+ generation_rate = fields.Float(string='Generation Rate',
+ help='Average rate this stream is produced at, expressed in the '
+ 'rate unit below (typical: kg/day, L/day).')
+ generation_uom = fields.Selection(FP_UOM_SELECTION, string='Rate UoM',
+ default='kg_day',
+ help='Unit of the generation rate (kg/day, L/day, kg/month, etc.).')
disposal_method = fields.Char(string='Disposal Method')
approved_carrier_id = fields.Many2one('res.partner', string='Approved Carrier', domain=[('is_company', '=', True)])
approved_facility_id = fields.Many2one('res.partner', string='Approved Receiving Facility', domain=[('is_company', '=', True)])
diff --git a/fusion_plating/fusion_plating_compliance/views/fp_compliance_event_views.xml b/fusion_plating/fusion_plating_compliance/views/fp_compliance_event_views.xml
index e0dc104d..5915b9d1 100644
--- a/fusion_plating/fusion_plating_compliance/views/fp_compliance_event_views.xml
+++ b/fusion_plating/fusion_plating_compliance/views/fp_compliance_event_views.xml
@@ -43,7 +43,8 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_compliance/views/fp_discharge_limit_views.xml b/fusion_plating/fusion_plating_compliance/views/fp_discharge_limit_views.xml
index c332a87a..e3a6da13 100644
--- a/fusion_plating/fusion_plating_compliance/views/fp_discharge_limit_views.xml
+++ b/fusion_plating/fusion_plating_compliance/views/fp_discharge_limit_views.xml
@@ -45,7 +45,8 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_compliance/views/fp_facility_views.xml b/fusion_plating/fusion_plating_compliance/views/fp_facility_views.xml
index 98f53067..b631dff6 100644
--- a/fusion_plating/fusion_plating_compliance/views/fp_facility_views.xml
+++ b/fusion_plating/fusion_plating_compliance/views/fp_facility_views.xml
@@ -48,9 +48,8 @@
-
-
-
+
+
diff --git a/fusion_plating/fusion_plating_compliance/views/fp_permit_views.xml b/fusion_plating/fusion_plating_compliance/views/fp_permit_views.xml
index 298964e7..a65ef503 100644
--- a/fusion_plating/fusion_plating_compliance/views/fp_permit_views.xml
+++ b/fusion_plating/fusion_plating_compliance/views/fp_permit_views.xml
@@ -72,7 +72,8 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_compliance/views/fp_pollutant_inventory_views.xml b/fusion_plating/fusion_plating_compliance/views/fp_pollutant_inventory_views.xml
index dc5b4425..a7049a15 100644
--- a/fusion_plating/fusion_plating_compliance/views/fp_pollutant_inventory_views.xml
+++ b/fusion_plating/fusion_plating_compliance/views/fp_pollutant_inventory_views.xml
@@ -50,7 +50,8 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_compliance/views/fp_regulator_views.xml b/fusion_plating/fusion_plating_compliance/views/fp_regulator_views.xml
index 250c2bd0..f1563d27 100644
--- a/fusion_plating/fusion_plating_compliance/views/fp_regulator_views.xml
+++ b/fusion_plating/fusion_plating_compliance/views/fp_regulator_views.xml
@@ -35,7 +35,8 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py
index 4dbfaf2f..e283e511 100644
--- a/fusion_plating/fusion_plating_configurator/__manifest__.py
+++ b/fusion_plating/fusion_plating_configurator/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
- 'version': '19.0.17.16.0',
+ 'version': '19.0.18.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -50,11 +50,13 @@ Provides:
'views/sale_order_views.xml',
'views/res_partner_views.xml',
'views/fp_sale_description_template_views.xml',
+ 'views/fp_serial_views.xml',
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_wizard_views.xml',
'wizard/fp_quote_promote_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
+ 'wizard/fp_serial_bulk_add_wizard_views.xml',
'views/fp_configurator_menu.xml',
'data/fp_sale_description_template_data.xml',
],
diff --git a/fusion_plating/fusion_plating_configurator/migrations/19.0.18.0.0/post-migration.py b/fusion_plating/fusion_plating_configurator/migrations/19.0.18.0.0/post-migration.py
new file mode 100644
index 00000000..ee737edf
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/migrations/19.0.18.0.0/post-migration.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Phase 1 multi-serial — backfill the new M2M relations from the
+# pre-existing single-M2O column on sale.order.line and account.move.line.
+#
+# x_fc_serial_id was historically a stored Many2one. Phase 1 made it a
+# computed alias of `x_fc_serial_ids` (the new M2M). Existing rows have
+# the old FK column populated but no rows in the M2M relation table.
+# This migration walks the legacy column and inserts one M2M row per
+# (line, serial) pair so smart buttons / reverse links continue to find
+# the linked records.
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ """Backfill fp_sale_order_line_serial_rel + fp_account_move_line_serial_rel."""
+ backfill_table(cr, 'sale_order_line', 'fp_sale_order_line_serial_rel', 'line_id')
+ backfill_table(cr, 'account_move_line', 'fp_account_move_line_serial_rel', 'line_id')
+
+
+def backfill_table(cr, source_table, m2m_table, line_col):
+ cr.execute(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = %s AND column_name = 'x_fc_serial_id'",
+ (source_table,),
+ )
+ if not cr.fetchone():
+ _logger.info("Phase 1 multi-serial: %s has no x_fc_serial_id column, skip", source_table)
+ return
+
+ # Make sure the M2M table exists (Odoo creates it on registry load,
+ # but the migration runs BEFORE the registry comes up on upgrade —
+ # use IF NOT EXISTS to be safe).
+ cr.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS "{m2m_table}" (
+ "{line_col}" integer NOT NULL REFERENCES "{source_table}"(id) ON DELETE CASCADE,
+ "serial_id" integer NOT NULL REFERENCES "fp_serial"(id) ON DELETE CASCADE,
+ PRIMARY KEY ("{line_col}", "serial_id")
+ )
+ """
+ )
+
+ cr.execute(
+ f"""
+ INSERT INTO "{m2m_table}" ("{line_col}", "serial_id")
+ SELECT id, x_fc_serial_id FROM "{source_table}"
+ WHERE x_fc_serial_id IS NOT NULL
+ ON CONFLICT DO NOTHING
+ """
+ )
+ _logger.info(
+ "Phase 1 multi-serial: backfilled %s rows from %s.x_fc_serial_id into %s",
+ cr.rowcount, source_table, m2m_table,
+ )
diff --git a/fusion_plating/fusion_plating_configurator/models/account_move_line.py b/fusion_plating/fusion_plating_configurator/models/account_move_line.py
index 6c154128..2d40a996 100644
--- a/fusion_plating/fusion_plating_configurator/models/account_move_line.py
+++ b/fusion_plating/fusion_plating_configurator/models/account_move_line.py
@@ -19,12 +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 ---------------------------------------------------------------
+ # ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
+ x_fc_serial_ids = fields.Many2many(
+ 'fp.serial',
+ relation='fp_account_move_line_serial_rel',
+ column1='line_id',
+ column2='serial_id',
+ string='Serial Numbers',
+ help='Copied from sale.order.line for traceability. Multi-serial '
+ 'support added 2026-04-28.',
+ )
x_fc_serial_id = fields.Many2one(
'fp.serial',
string='Serial Number',
index=True,
- help='Copied from sale.order.line for traceability.',
+ help='Back-compat alias of the first serial in x_fc_serial_ids. '
+ 'Kept so legacy invoice templates that read the singular '
+ 'continue to render.',
)
x_fc_job_number = fields.Char(
string='Job #', index=True,
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py
index db8598e8..77bc1008 100644
--- a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py
+++ b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py
@@ -444,6 +444,33 @@ class FpPartCatalog(models.Model):
'target': 'current',
}
+ def action_open_default_simple_editor(self):
+ """Open the Simple Recipe Editor for this part's default variant.
+
+ One-click path that skips the Composer's variants list — useful
+ when the part only has one variant and the user wants to dive
+ straight into editing.
+ """
+ self.ensure_one()
+ if not self.default_process_id:
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'No default process variant for %s yet. Click Compose to '
+ 'create the first variant.'
+ ) % (self.display_name or self.part_number))
+ return self.default_process_id.action_open_simple_editor()
+
+ def action_open_default_tree_editor(self):
+ """Open the Tree Editor for this part's default variant."""
+ self.ensure_one()
+ if not self.default_process_id:
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'No default process variant for %s yet. Click Compose to '
+ 'create the first variant.'
+ ) % (self.display_name or self.part_number))
+ return self.default_process_id.action_open_tree_editor()
+
def action_set_default_variant(self, variant_id):
"""Flip the default variant for this part.
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_serial.py b/fusion_plating/fusion_plating_configurator/models/fp_serial.py
index 24476945..2c0596d6 100644
--- a/fusion_plating/fusion_plating_configurator/models/fp_serial.py
+++ b/fusion_plating/fusion_plating_configurator/models/fp_serial.py
@@ -58,6 +58,170 @@ class FpSerial(models.Model):
)
notes = fields.Text(string='Notes')
+ # ==================================================================
+ # Phase 2 (2026-04-28) — per-serial state machine
+ # ==================================================================
+ # Each physical part owns its own state independent of the parent
+ # job's qty roll-ups. When 30 parts arrive on one SO line, all 30
+ # serials are independently trackable through the shop. State
+ # auto-promotes from job-step transitions (see fp.job.button_*
+ # overrides in fusion_plating_jobs); operator can also flip a
+ # single serial manually (e.g. mark serial #5 scrapped after a
+ # plating defect).
+ state = fields.Selection(
+ [
+ ('received', 'Received'),
+ ('racked', 'Racked'),
+ ('in_process', 'In Process'),
+ ('inspected', 'Inspected'),
+ ('packed', 'Packed'),
+ ('shipped', 'Shipped'),
+ ('returned', 'Returned'),
+ ('scrapped', 'Scrapped'),
+ ('on_hold', 'On Hold'),
+ ],
+ string='Status',
+ default='received',
+ required=True,
+ tracking=True,
+ index=True,
+ help='Per-serial workflow state. Transitions auto-promote from '
+ 'parent job step events; supervisors can also flip a single '
+ 'serial manually (e.g. scrap one part out of a 30-part rack).',
+ )
+ state_color = fields.Integer(
+ string='Status Color',
+ compute='_compute_state_color',
+ help='Kanban / many2many_tags color index derived from state.',
+ )
+ last_state_change = fields.Datetime(
+ string='Last Status Change',
+ readonly=True,
+ help='Timestamp of the most recent state transition. Auto-stamped '
+ 'by every state-changing action.',
+ )
+ scrap_reason = fields.Text(
+ string='Scrap / Return Reason',
+ help='Captured when state transitions to scrapped or returned. '
+ 'Surfaces on per-serial CoC entries (Phase 4).',
+ )
+
+ # Reverse from move log — Phase 3 will populate this directly when
+ # operators record per-serial moves on the tablet. Defined here so
+ # views can already render the count column.
+ move_count = fields.Integer(
+ compute='_compute_move_count',
+ string='# Moves',
+ )
+
+ @api.depends('state')
+ def _compute_state_color(self):
+ # Odoo color-index mapping aligned with the standard kanban palette.
+ # 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
+ # 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
+ mapping = {
+ 'received': 8, # blue — fresh
+ 'racked': 7, # sky — staged
+ 'in_process': 3, # yellow — running
+ 'inspected': 11, # olive — passed QC, ready to ship
+ 'packed': 4, # green — boxed
+ 'shipped': 4, # green — out the door
+ 'returned': 2, # orange — back from customer
+ 'scrapped': 1, # red
+ 'on_hold': 1, # red — quality issue
+ }
+ for rec in self:
+ rec.state_color = mapping.get(rec.state, 0)
+
+ @api.depends_context('uid')
+ def _compute_move_count(self):
+ # Phase 3 will replace this with a real reverse link via
+ # fp.job.step.move.serial_ids (M2M added next phase).
+ # Defined here as 0-stub so views don't break on upgrade.
+ for rec in self:
+ rec.move_count = 0
+
+ # ------------------------------------------------------------------
+ # State transitions — log each one to chatter and stamp last_state_change
+ # ------------------------------------------------------------------
+ def _set_state(self, new_state, message=None):
+ """Internal helper. Validates the source state, flips, stamps,
+ chatters. Raises UserError on illegal transitions."""
+ labels = dict(self._fields['state'].selection)
+ for rec in self:
+ old = rec.state
+ if old == new_state:
+ continue
+ # Terminal states are write-protected (operator must explicitly
+ # un-set via action_reopen if they really need to).
+ if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'Serial %(name)s is %(old)s — cannot transition to '
+ '%(new)s. Use Reopen if this is a correction.'
+ ) % {
+ 'name': rec.name,
+ 'old': labels.get(old, old),
+ 'new': labels.get(new_state, new_state),
+ })
+ rec.state = new_state
+ rec.last_state_change = fields.Datetime.now()
+ body = message or _('Status %(old)s → %(new)s by %(user)s') % {
+ 'old': labels.get(old, old),
+ 'new': labels.get(new_state, new_state),
+ 'user': self.env.user.name,
+ }
+ rec.message_post(body=body)
+ return True
+
+ def action_mark_racked(self):
+ return self._set_state('racked')
+
+ def action_mark_in_process(self):
+ return self._set_state('in_process')
+
+ def action_mark_inspected(self):
+ return self._set_state('inspected')
+
+ def action_mark_packed(self):
+ return self._set_state('packed')
+
+ def action_mark_shipped(self):
+ return self._set_state('shipped')
+
+ def action_mark_returned(self):
+ return self._set_state('returned')
+
+ def action_mark_on_hold(self):
+ return self._set_state('on_hold')
+
+ def action_release_hold(self):
+ """Lift on_hold and return the serial to in_process. Used when a
+ hold is resolved without scrap (e.g. visual blemish was actually
+ within tolerance after re-inspection)."""
+ return self._set_state('in_process')
+
+ def action_mark_scrapped(self):
+ """Scrap a single serial. Operator should fill scrap_reason next
+ — view enforces it via a wizard form. Phase 3 hooks this into
+ the move log so the parent job's qty_scrapped auto-increments."""
+ return self._set_state('scrapped')
+
+ def action_reopen(self):
+ """Manager-only override — un-pin a terminal state when a
+ correction is needed (e.g. wrong serial marked shipped). Audit
+ trail preserved via chatter; never silently rewrites history."""
+ for rec in self:
+ if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'):
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'Only the Plating Manager group can reopen a terminal '
+ 'serial state. Contact your shop manager.'
+ ))
+ return self._set_state('in_process', message=_(
+ 'Serial reopened by %s — terminal state reverted for correction.'
+ ) % self.env.user.name)
+
# 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
diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py
index 4c42cd32..4498337a 100644
--- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py
+++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py
@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
-from odoo import api, fields, models
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
class SaleOrderLine(models.Model):
@@ -60,18 +61,29 @@ class SaleOrderLine(models.Model):
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
- # Sub 9 — process variant override per line. NULL means "use the
- # part's default variant". Domain restricts to root recipe nodes
- # owned by the chosen part.
+ # Sub 9 (polished 2026-04-28) — process variant per line. The picker
+ # now lets the estimator pick ANY root recipe in the system: the
+ # part's own variants, another customer's variants, or a template
+ # marked is_template. Cross-part picks auto-clone onto this part on
+ # save (see _onchange_process_variant_clone) so per-line edits never
+ # bleed across customers.
x_fc_process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
- domain="[('part_catalog_id', '=', x_fc_part_catalog_id), "
- "('parent_id', '=', False), ('node_type', '=', 'recipe')]",
+ domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
- help='Pick a specific process variant for this order. Leave blank '
- 'to use the part\'s default variant. Variants are managed via '
- 'the Process Composer on the part form.',
+ help='Pick any recipe — the part\'s own variant, another part\'s '
+ 'recipe, or a template from the library. If the chosen recipe '
+ 'doesn\'t belong to this part, it will be cloned onto the part '
+ 'when the order saves so per-line edits stay scoped. Use the '
+ 'Customize button on the line to open the Process Composer.',
+ )
+ x_fc_save_as_default_process = fields.Boolean(
+ string='Save as Default for Part',
+ default=False,
+ help='When ticked, the chosen process variant becomes this part\'s '
+ 'default on order save — future orders for the same part '
+ 'pre-fill with this variant.',
)
x_fc_archived = fields.Boolean(
string='Archived',
@@ -84,15 +96,61 @@ class SaleOrderLine(models.Model):
# 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.
+ #
+ # 2026-04-28 Phase 1 — multi-serial support. Customer can ship 30 parts
+ # with 30 distinct serials on a single line. The M2M is the source of
+ # truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
+ # serial so existing reports / smart buttons / downstream code that
+ # still reads the singular keep working unchanged.
+ x_fc_serial_ids = fields.Many2many(
+ 'fp.serial',
+ relation='fp_sale_order_line_serial_rel',
+ column1='line_id',
+ column2='serial_id',
+ string='Serial Numbers',
+ copy=False,
+ help='Customer-supplied serial numbers for the parts on this line. '
+ 'Use the Bulk Add Serials button to paste a list, range-fill '
+ '(SN-001..SN-030), or scan barcodes. Count must not exceed '
+ 'the line quantity.',
+ )
x_fc_serial_id = fields.Many2one(
'fp.serial',
- string='Serial Number',
- ondelete='set null',
+ string='Primary Serial',
+ compute='_compute_primary_serial',
+ inverse='_inverse_primary_serial',
+ search='_search_primary_serial',
+ store=False,
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.',
+ help='First of the line\'s serials — back-compat alias kept so '
+ 'pre-Phase-1 code (reports, smart buttons, downstream M2M '
+ 'reverse links) keeps working. Setting this prepends the '
+ 'serial to the M2M.',
)
+ x_fc_serial_count = fields.Integer(
+ string='# Serials',
+ compute='_compute_serial_count',
+ )
+
+ @api.depends('x_fc_serial_ids')
+ def _compute_primary_serial(self):
+ for line in self:
+ line.x_fc_serial_id = line.x_fc_serial_ids[:1]
+
+ def _inverse_primary_serial(self):
+ for line in self:
+ if not line.x_fc_serial_id:
+ continue
+ if line.x_fc_serial_id not in line.x_fc_serial_ids:
+ line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
+
+ def _search_primary_serial(self, operator, value):
+ return [('x_fc_serial_ids', operator, value)]
+
+ @api.depends('x_fc_serial_ids')
+ def _compute_serial_count(self):
+ for line in self:
+ line.x_fc_serial_count = len(line.x_fc_serial_ids)
x_fc_job_number = fields.Char(
string='Job #',
copy=False,
@@ -140,6 +198,27 @@ class SaleOrderLine(models.Model):
if line.x_fc_revision_pick_id:
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
+ def _fp_apply_recipe_polish(self):
+ """Post-write step: auto-clone any cross-part recipe pick and
+ honour the Save-as-Default toggle.
+
+ Called from create() and write() so the polish runs on every
+ save path — onchange alone doesn't cover programmatic creates
+ (the direct-order wizard, imports, the sale_mrp bridge, etc.).
+ """
+ for line in self:
+ if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
+ continue
+ recipe = line.x_fc_process_variant_id
+ if (not recipe.part_catalog_id
+ or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
+ clone = line._fp_clone_recipe_to_part()
+ if clone and clone.id != recipe.id:
+ line.x_fc_process_variant_id = clone.id
+ recipe = clone
+ if line.x_fc_save_as_default_process and recipe.part_catalog_id:
+ line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
+
@api.model_create_multi
def create(self, vals_list):
"""Default `x_fc_internal_description` from `name` when a caller
@@ -175,7 +254,9 @@ class SaleOrderLine(models.Model):
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)
+ lines = super().create(vals_list)
+ lines._fp_apply_recipe_polish()
+ return lines
def write(self, vals):
# Sub 5 — keep the revision snapshot in lockstep with the line's
@@ -190,7 +271,16 @@ class SaleOrderLine(models.Model):
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)
+ result = super().write(vals)
+ # Only run the polish when something relevant actually changed —
+ # avoids re-running on every unrelated write (e.g. price updates).
+ if any(k in vals for k in (
+ 'x_fc_process_variant_id',
+ 'x_fc_part_catalog_id',
+ 'x_fc_save_as_default_process',
+ )):
+ self._fp_apply_recipe_polish()
+ return result
@api.onchange('x_fc_description_template_id')
def _onchange_description_template(self):
@@ -229,7 +319,12 @@ class SaleOrderLine(models.Model):
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:
+ if self.x_fc_serial_ids:
+ # Carry the full M2M to the invoice line. Back-compat alias
+ # x_fc_serial_id will still resolve to the first one if any
+ # downstream code only reads the singular.
+ vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
+ elif 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
@@ -241,13 +336,95 @@ class SaleOrderLine(models.Model):
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_variant(self):
- """Clear process variant when the part changes — domain would
- otherwise leave a stale value pointing at the wrong part."""
+ """When the part changes, pre-fill the variant from the part's
+ default_process_id (if set) so the line carries a sensible
+ starting point. The estimator can override after.
+
+ Previously cleared the variant entirely when the part changed
+ (because the variant picker was scoped to the part). Now that
+ the picker is system-wide, we instead pre-fill from the part's
+ default — much more useful.
+ """
for line in self:
- if (line.x_fc_process_variant_id
- and line.x_fc_process_variant_id.part_catalog_id
- != line.x_fc_part_catalog_id):
- line.x_fc_process_variant_id = False
+ if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
+ line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
+
+ def _fp_clone_recipe_to_part(self):
+ """Deep-copy the picked recipe onto this line's part if it isn't
+ already scoped there. Returns the cloned (or unchanged) variant.
+
+ Edge cases handled:
+ * No recipe picked → no-op, return False.
+ * No part on the line → no-op (we need a part to scope the clone).
+ * Recipe already belongs to this part → no-op, return as-is.
+ * Recipe belongs to a different part / is a template / is unscoped
+ → deep-copy via Odoo's standard recursive copy(), reparent the
+ clone onto this part, name-stamp it for traceability.
+ """
+ self.ensure_one()
+ recipe = self.x_fc_process_variant_id
+ part = self.x_fc_part_catalog_id
+ if not recipe or not part:
+ return recipe
+ if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
+ return recipe # already scoped — nothing to do
+ # Clone — Odoo's default copy() recurses through child_ids when the
+ # field has copy=True. fp.process.node sets that on its tree, so
+ # one call gets us a full sub-tree clone.
+ clone_name = recipe.name or _('Untitled Recipe')
+ # If the source carried a part scope, preface the clone name with
+ # the customer's part number for quick identification on the
+ # variant dropdown later.
+ if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
+ clone_name = '%s — %s' % (clone_name, part.part_number or part.display_name)
+ clone = recipe.copy({
+ 'name': clone_name,
+ 'part_catalog_id': part.id,
+ 'is_template': False, # never propagate template flag
+ 'is_default_variant': False, # estimator opts in via toggle
+ })
+ return clone
+
+ def action_customize_process(self):
+ """Open the Process Composer for this line's process variant.
+
+ Auto-clones first if the variant isn't yet scoped to this part —
+ the operator should never edit a recipe that's shared across
+ customers (their edits would bleed). After cloning, the line
+ ends up pointing at the fresh per-part copy.
+ """
+ self.ensure_one()
+ if not self.x_fc_part_catalog_id:
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'Pick a part on this line before customizing the process — '
+ 'the recipe needs a part to scope the variant.'
+ ))
+ if not self.x_fc_process_variant_id:
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'Pick a process variant on this line first. To start from '
+ 'scratch, use the part\'s Compose button instead.'
+ ))
+ clone_or_existing = self._fp_clone_recipe_to_part()
+ if clone_or_existing.id != self.x_fc_process_variant_id.id:
+ self.x_fc_process_variant_id = clone_or_existing.id
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'fp_part_process_composer',
+ 'name': _('Customize Process — %s') % (
+ self.x_fc_part_catalog_id.display_name
+ or self.x_fc_part_catalog_id.part_number
+ or '?'
+ ),
+ 'params': {
+ 'part_id': self.x_fc_part_catalog_id.id,
+ 'part_display': self.x_fc_part_catalog_id.display_name
+ or self.x_fc_part_catalog_id.part_number,
+ 'focus_variant_id': clone_or_existing.id,
+ },
+ 'target': 'current',
+ }
@api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self):
@@ -263,19 +440,55 @@ class SaleOrderLine(models.Model):
line.x_fc_thickness_id = False
def action_generate_serial(self):
- """Create a fresh fp.serial for this line using the shop sequence."""
+ """Generate one new auto-sequenced serial and append it to the M2M.
+
+ Phase 1 polish: the legacy single-serial behaviour was "create one
+ serial and pin it to x_fc_serial_id". Now we append to the M2M so
+ repeated clicks add more serials (handy when the customer didn't
+ send any and the shop wants to assign N).
+ """
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
+ self.x_fc_serial_ids = [(4, serial.id)]
return False
+
+ def action_open_serial_bulk_add(self):
+ """Open the Bulk Add Serials wizard for this line."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.serial.bulk.add.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'name': _('Bulk Add Serials'),
+ 'context': {
+ 'default_target_model': 'sale.order.line',
+ 'default_target_id': self.id,
+ 'default_qty_expected': int(self.product_uom_qty or 0),
+ },
+ }
+
+ @api.constrains('x_fc_serial_ids', 'product_uom_qty')
+ def _check_serial_count_against_qty(self):
+ """Block save when the operator has attached more serials than
+ the line quantity. Under-count is allowed (some customers ship
+ with serials only on a subset of parts).
+ """
+ for line in self:
+ if line.x_fc_serial_ids and line.product_uom_qty:
+ n = len(line.x_fc_serial_ids)
+ if n > int(line.product_uom_qty):
+ raise ValidationError(_(
+ 'Line "%(part)s": %(n)s serials attached but only '
+ '%(qty)s parts ordered. Either reduce the serial '
+ 'list, increase the quantity, or split the line.'
+ ) % {
+ 'part': (line.x_fc_part_catalog_id.display_name
+ or line.product_id.display_name or ''),
+ 'n': n,
+ 'qty': int(line.product_uom_qty),
+ })
diff --git a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
index 09ca6079..fd6f829a 100644
--- a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
@@ -44,6 +44,8 @@ access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_
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_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
+access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,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
diff --git a/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js b/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js
index bbb4acae..7589e57d 100644
--- a/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js
+++ b/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js
@@ -188,6 +188,7 @@ export class FpPartProcessComposer extends Component {
}
openRecipeEditor(rootId) {
+ // Tree editor — the original drag-and-drop hierarchy view.
const id = rootId || this.state.rootId;
if (!id) return;
this.action.doAction({
@@ -199,6 +200,22 @@ export class FpPartProcessComposer extends Component {
});
}
+ openRecipeSimpleEditor(rootId) {
+ // Simple Recipe Editor (Sub 12a) — flat 2-pane drag-drop layout.
+ // Lives alongside the tree editor; the user picks per-variant
+ // which one to open. Both edit the same underlying tree, so
+ // changes flow back-and-forth without conflict.
+ const id = rootId || this.state.rootId;
+ if (!id) return;
+ this.action.doAction({
+ type: "ir.actions.client",
+ tag: "fp_simple_recipe_editor",
+ name: `Process Editor (Simple) — ${(this.state.part && this.state.part.display) || ""}`,
+ context: { recipe_id: id, part_id: this.partId },
+ target: "current",
+ });
+ }
+
backToPart() {
this.action.doAction({
type: "ir.actions.act_window",
diff --git a/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml b/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml
index e856a237..2969a5d8 100644
--- a/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml
+++ b/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml
@@ -83,8 +83,15 @@
+
-
-
-
+
+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml
index 2494f32d..7b9caf1d 100644
--- a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml
@@ -176,12 +176,26 @@
icon="fa-wrench"
class="btn-primary"
help="Open the Process Composer to manage this part's process variants."/>
+
+
The Compose button opens the Process Composer where you can add
multiple process variants for this part — for example "Standard ENP",
"Selective Masking", "Rework". One variant is flagged as default; estimators
- may pick a different variant on a per-order basis.
+ may pick a different variant on a per-order basis. Each variant can be edited
+ in either the Tree or Simple view — same data,
+ two layouts.
@@ -189,6 +203,12 @@
+
+
@@ -207,9 +227,7 @@
-
-
-
+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml
index 53e2d4b9..ebe365af 100644
--- a/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml
@@ -276,9 +276,7 @@
options="{'currency_field': 'currency_id'}"/>
-
-
-
+
@@ -299,10 +297,9 @@
-
-
+
-
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml
index 46325966..65c4f10d 100644
--- a/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml
@@ -58,10 +58,9 @@
-
-
+
-
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml
new file mode 100644
index 00000000..63439e78
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml
@@ -0,0 +1,191 @@
+
+
+
+
+
+ fp.serial.form
+ fp.serial
+
+
+
+
+
+
+ fp.serial.list
+ fp.serial
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.serial.search
+ fp.serial
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Serial Numbers
+ fp.serial
+ list,form
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml
index da91f435..859ed64f 100644
--- a/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml
@@ -52,9 +52,7 @@
options="{'currency_field': 'currency_id'}"/>
-
-
-
+
diff --git a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
index ae436d94..f068a557 100644
--- a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
@@ -188,10 +188,9 @@
-
-
+
-
@@ -214,17 +213,33 @@
optional="hide"/>
+
+
-
+
+
int(rec.quantity):
+ raise UserError(_(
+ 'Line "%(part)s": %(n)s serials attached but only '
+ '%(qty)s parts ordered. Reduce the serial list, '
+ 'increase the quantity, or split the line.'
+ ) % {
+ 'part': (rec.part_catalog_id.display_name or ''),
+ 'n': n,
+ 'qty': int(rec.quantity),
+ })
+
# ---- Onchange ----
@api.onchange('quote_id')
def _onchange_quote_id(self):
@@ -504,3 +593,100 @@ class FpDirectOrderLine(models.Model):
else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
return new_rev
+
+ # ==================================================================
+ # 2026-04-28 polish — recipe handling shared with sale.order.line
+ # ==================================================================
+ def _fp_clone_recipe_to_part(self):
+ """Deep-copy the picked recipe onto this line's part if it isn't
+ already scoped there. Returns the cloned (or unchanged) variant.
+
+ Mirrors `sale.order.line._fp_clone_recipe_to_part` — same
+ contract, same edge cases. The wizard runs this on every save
+ path (create/write) plus when Customize is clicked, so a
+ cross-part pick never leaks edits to the source recipe.
+ """
+ self.ensure_one()
+ recipe = self.process_variant_id
+ part = self.part_catalog_id
+ if not recipe or not part:
+ return recipe
+ if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
+ return recipe
+ clone_name = recipe.name or _('Untitled Recipe')
+ if part.part_number and part.part_number.lower() not in clone_name.lower():
+ clone_name = '%s — %s' % (clone_name, part.part_number or part.display_name)
+ clone = recipe.copy({
+ 'name': clone_name,
+ 'part_catalog_id': part.id,
+ 'is_template': False,
+ 'is_default_variant': False,
+ })
+ return clone
+
+ def _fp_apply_recipe_polish(self):
+ """Post-write step: auto-clone any cross-part recipe pick and
+ honour the Save-as-Default toggle. Called from create() and
+ write()."""
+ for line in self:
+ if not line.part_catalog_id or not line.process_variant_id:
+ continue
+ recipe = line.process_variant_id
+ if (not recipe.part_catalog_id
+ or recipe.part_catalog_id.id != line.part_catalog_id.id):
+ clone = line._fp_clone_recipe_to_part()
+ if clone and clone.id != recipe.id:
+ line.process_variant_id = clone.id
+ recipe = clone
+ if line.save_as_default_process and recipe.part_catalog_id:
+ line.part_catalog_id.action_set_default_variant(recipe.id)
+
+ def action_customize_process(self):
+ """Open the Process Composer for this line's variant — auto-clones
+ first if the variant isn't yet scoped to this part."""
+ self.ensure_one()
+ if not self.part_catalog_id:
+ raise UserError(_(
+ 'Pick a part on this line before customizing the process — '
+ 'the recipe needs a part to scope the variant.'
+ ))
+ if not self.process_variant_id:
+ raise UserError(_(
+ 'Pick a process variant on this line first. To start from '
+ 'scratch, use the part\'s Compose button instead.'
+ ))
+ clone_or_existing = self._fp_clone_recipe_to_part()
+ if clone_or_existing.id != self.process_variant_id.id:
+ self.process_variant_id = clone_or_existing.id
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'fp_part_process_composer',
+ 'name': _('Customize Process — %s') % (
+ self.part_catalog_id.display_name
+ or self.part_catalog_id.part_number
+ or '?'
+ ),
+ 'params': {
+ 'part_id': self.part_catalog_id.id,
+ 'part_display': self.part_catalog_id.display_name
+ or self.part_catalog_id.part_number,
+ 'focus_variant_id': clone_or_existing.id,
+ },
+ 'target': 'current',
+ }
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ lines = super().create(vals_list)
+ lines._fp_apply_recipe_polish()
+ return lines
+
+ def write(self, vals):
+ result = super().write(vals)
+ if any(k in vals for k in (
+ 'process_variant_id',
+ 'part_catalog_id',
+ 'save_as_default_process',
+ )):
+ self._fp_apply_recipe_polish()
+ return result
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
index a3487504..c4c78cdd 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
@@ -510,8 +510,13 @@ class FpDirectOrderWizard(models.Model):
'x_fc_is_one_off': line.is_one_off,
'x_fc_quote_id': line.quote_id.id or False,
'x_fc_process_variant_id': line.process_variant_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_save_as_default_process': line.save_as_default_process,
+ # Sub 5 / Phase 1 — carry serial M2M to the SO line.
+ # x_fc_serial_id is back-compat alias and auto-resolves
+ # from x_fc_serial_ids on SO-line read; passing both is
+ # safe (the alias setter just appends to the M2M).
+ 'x_fc_serial_ids': ([(6, 0, line.serial_ids.ids)]
+ if line.serial_ids else False),
'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,
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
index 65eb11b6..39fd022e 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
@@ -156,14 +156,23 @@
optional="hide"/>
+
+
+ optional="hide"/>
-
+
+
+
+
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard.py
new file mode 100644
index 00000000..464c0ee0
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard.py
@@ -0,0 +1,253 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+"""Bulk-add serial numbers to a sale.order.line or fp.direct.order.line.
+
+Three input modes — operator picks one:
+
+1. **Paste a list** — one per line, comma- or whitespace-separated.
+2. **Range fill** — prefix + start..end (e.g. SN- + 1..30 → SN-001..SN-030).
+3. **Scan barcodes** — repeated input (kept simple for Phase 1: the same
+ paste textarea works for a barcode reader that types-and-Enters).
+
+Existing serials with the same `name` are reused (the company-uniqueness
+SQL constraint on fp.serial would block dupes anyway). New ones are
+created and linked to the source line via sale_order_line_id when the
+target is a sale.order.line.
+
+Target abstracted via target_model + target_id so one wizard works for
+both the SO line and the Direct Order wizard line.
+"""
+
+import re
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+
+
+class FpSerialBulkAddWizard(models.TransientModel):
+ _name = 'fp.serial.bulk.add.wizard'
+ _description = 'Fusion Plating — Bulk Add Serials'
+
+ target_model = fields.Selection(
+ [
+ ('sale.order.line', 'Sale Order Line'),
+ ('fp.direct.order.line', 'Direct Order Line'),
+ ],
+ string='Target Model', required=True, readonly=True,
+ )
+ target_id = fields.Integer(string='Target Record ID', required=True, readonly=True)
+ qty_expected = fields.Integer(
+ string='Line Quantity', readonly=True,
+ help='How many parts the target line is ordered for. The wizard '
+ 'warns if you try to add more serials than this.',
+ )
+
+ mode = fields.Selection(
+ [
+ ('paste', 'Paste a List'),
+ ('range', 'Range Fill'),
+ ],
+ string='Mode', default='paste', required=True,
+ )
+
+ # --- Paste mode ---
+ paste_text = fields.Text(
+ string='Serial List',
+ help='One serial per line, or comma-separated. Whitespace and '
+ 'blank lines are ignored. Barcode scanners that emit one '
+ 'serial + Enter at a time also work — just leave the cursor '
+ 'in this box and scan.',
+ )
+
+ # --- Range mode ---
+ prefix = fields.Char(
+ string='Prefix',
+ default='SN-',
+ help='Text prefix prepended to each generated serial (e.g. "SN-", '
+ '"WO123-", or blank for pure numeric).',
+ )
+ start_number = fields.Integer(
+ string='Start',
+ default=1,
+ help='First number in the range (inclusive).',
+ )
+ end_number = fields.Integer(
+ string='End',
+ default=10,
+ help='Last number in the range (inclusive). Must be ≥ start.',
+ )
+ pad_width = fields.Integer(
+ string='Pad Width',
+ default=3,
+ help='Zero-pad numbers to this width (3 → 001, 002, ... 030). '
+ 'Set to 0 to disable padding.',
+ )
+ suffix = fields.Char(
+ string='Suffix',
+ help='Optional text appended after the number (e.g. "-A").',
+ )
+ range_preview = fields.Text(
+ string='Preview',
+ compute='_compute_range_preview',
+ readonly=True,
+ )
+
+ @api.depends('mode', 'prefix', 'start_number', 'end_number', 'pad_width', 'suffix')
+ def _compute_range_preview(self):
+ for wiz in self:
+ if wiz.mode != 'range':
+ wiz.range_preview = ''
+ continue
+ try:
+ names = wiz._build_range_names()
+ except (UserError, ValidationError) as e:
+ wiz.range_preview = '⚠ %s' % (e.args[0] if e.args else str(e))
+ continue
+ if len(names) <= 6:
+ wiz.range_preview = '\n'.join(names)
+ else:
+ wiz.range_preview = (
+ '\n'.join(names[:3])
+ + '\n ...\n'
+ + '\n'.join(names[-3:])
+ + '\n(%s total)' % len(names)
+ )
+
+ # ==================================================================
+ def _build_range_names(self):
+ """Resolve range_mode fields into a list of serial names."""
+ self.ensure_one()
+ if self.end_number < self.start_number:
+ raise ValidationError(_(
+ 'End (%s) is before Start (%s).'
+ ) % (self.end_number, self.start_number))
+ count = self.end_number - self.start_number + 1
+ if count > 1000:
+ raise ValidationError(_(
+ 'Range covers %s entries — too many. Cap at 1000 per call.'
+ ) % count)
+ names = []
+ prefix = self.prefix or ''
+ suffix = self.suffix or ''
+ pad = max(self.pad_width or 0, 0)
+ for n in range(self.start_number, self.end_number + 1):
+ num = str(n).zfill(pad) if pad else str(n)
+ names.append(f'{prefix}{num}{suffix}')
+ return names
+
+ def _parse_paste_text(self):
+ """Split paste_text into a clean ordered list of serial names.
+
+ Splits on newline or comma. Trims whitespace. Drops blanks.
+ Preserves first-occurrence order (paste duplicates collapse to
+ one, with the dupe count surfaced in the chatter audit).
+ """
+ self.ensure_one()
+ if not self.paste_text:
+ return []
+ tokens = re.split(r'[\s,;]+', self.paste_text.strip())
+ seen = set()
+ ordered = []
+ for tok in tokens:
+ tok = tok.strip()
+ if not tok or tok in seen:
+ continue
+ seen.add(tok)
+ ordered.append(tok)
+ return ordered
+
+ def _resolve_target(self):
+ """Return the browseable target record."""
+ self.ensure_one()
+ if self.target_model not in ('sale.order.line', 'fp.direct.order.line'):
+ raise UserError(_('Unsupported target model: %s') % self.target_model)
+ target = self.env[self.target_model].browse(self.target_id).exists()
+ if not target:
+ raise UserError(_('Target line not found.'))
+ return target
+
+ # ==================================================================
+ def action_apply(self):
+ """Materialise serials and append them to the target line's M2M."""
+ self.ensure_one()
+ target = self._resolve_target()
+
+ # 1. Resolve the list of names from the chosen mode.
+ if self.mode == 'paste':
+ names = self._parse_paste_text()
+ if not names:
+ raise UserError(_(
+ 'Paste at least one serial number, or switch to Range '
+ 'Fill mode.'
+ ))
+ elif self.mode == 'range':
+ names = self._build_range_names()
+ else:
+ raise UserError(_('Unsupported mode: %s') % self.mode)
+
+ # 2. Quantity sanity check — block if we'd exceed the line qty.
+ target_field = (
+ 'x_fc_serial_ids' if self.target_model == 'sale.order.line'
+ else 'serial_ids'
+ )
+ existing_count = len(target[target_field])
+ proposed_total = existing_count + len(names)
+ if self.qty_expected and proposed_total > self.qty_expected:
+ raise UserError(_(
+ '%(new)s new serials + %(existing)s already on the line '
+ '= %(total)s, but the line is only ordered for '
+ '%(qty)s parts. Reduce the list or increase the line '
+ 'quantity first.'
+ ) % {
+ 'new': len(names),
+ 'existing': existing_count,
+ 'total': proposed_total,
+ 'qty': self.qty_expected,
+ })
+
+ # 3. Reuse existing fp.serial records by name (uniqueness per
+ # company is SQL-constrained anyway), create the rest.
+ Serial = self.env['fp.serial']
+ existing = Serial.search([
+ ('name', 'in', names),
+ ('company_id', '=', self.env.company.id),
+ ])
+ existing_by_name = {s.name: s for s in existing}
+ to_create = []
+ link_kwargs = {}
+ if self.target_model == 'sale.order.line':
+ link_kwargs['sale_order_line_id'] = target.id
+ for name in names:
+ if name in existing_by_name:
+ continue
+ to_create.append(dict(name=name, **link_kwargs))
+ created = Serial.create(to_create) if to_create else Serial.browse([])
+
+ all_serials = existing + created
+ # Order-preserving: rebuild from the input order so paste/range
+ # ordering is preserved on the M2M (matters for paste_text — the
+ # operator typed them in physical-rack order).
+ serial_by_name = {s.name: s for s in all_serials}
+ ordered_ids = [serial_by_name[n].id for n in names if n in serial_by_name]
+ target[target_field] = [(4, sid) for sid in ordered_ids]
+
+ # 4. Audit on the target's chatter (SO line for sale.order.line;
+ # parent SO for the wizard line which has no chatter).
+ msg = _(
+ '+%(n)s serials added (%(reused)s reused, %(created)s new): '
+ '%(preview)s'
+ ) % {
+ 'n': len(names),
+ 'reused': len(existing),
+ 'created': len(created),
+ 'preview': ', '.join(names[:5]) + ('...' if len(names) > 5 else ''),
+ }
+ if hasattr(target, 'message_post'):
+ target.message_post(body=msg)
+ elif self.target_model == 'fp.direct.order.line' and target.wizard_id:
+ target.wizard_id.message_post(body=msg)
+
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard_views.xml
new file mode 100644
index 00000000..22d9abb7
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard_views.xml
@@ -0,0 +1,57 @@
+
+
+
+
+ fp.serial.bulk.add.wizard.form
+ fp.serial.bulk.add.wizard
+
+
+
+
+
+
+ Bulk Add Serials
+ fp.serial.bulk.add.wizard
+ form
+ new
+
+
+
diff --git a/fusion_plating/fusion_plating_invoicing/views/fp_invoice_strategy_views.xml b/fusion_plating/fusion_plating_invoicing/views/fp_invoice_strategy_views.xml
index e12f4390..9b9b68e6 100644
--- a/fusion_plating/fusion_plating_invoicing/views/fp_invoice_strategy_views.xml
+++ b/fusion_plating/fusion_plating_invoicing/views/fp_invoice_strategy_views.xml
@@ -38,9 +38,7 @@
-
-
-
+
diff --git a/fusion_plating/fusion_plating_jobs/__init__.py b/fusion_plating/fusion_plating_jobs/__init__.py
index 10666aa1..811c8e6c 100644
--- a/fusion_plating/fusion_plating_jobs/__init__.py
+++ b/fusion_plating/fusion_plating_jobs/__init__.py
@@ -2,3 +2,4 @@
from . import models
from . import report
from . import controllers
+from . import wizards
diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py
index 4ab9fa2e..c38bdb3e 100644
--- a/fusion_plating/fusion_plating_jobs/__manifest__.py
+++ b/fusion_plating/fusion_plating_jobs/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
- 'version': '19.0.7.0.0',
+ 'version': '19.0.8.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -62,8 +62,11 @@ full design rationale and §6.2 of the implementation plan for task list.
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
+ 'wizards/fp_job_step_move_wizard_views.xml',
+ 'wizards/fp_job_step_input_wizard_views.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
+ 'report/report_fp_job_wo_detail.xml',
'report/report_fp_job_margin.xml',
],
'assets': {
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py
index 327fac0d..15167cab 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py
@@ -155,6 +155,79 @@ class FpJob(models.Model):
'name': self.sale_order_id.name,
}
+ # All time logs across every step on this job — backs the Time Logs
+ # tab on the form so the manager sees the full labour audit without
+ # clicking into each step.
+ time_log_ids = fields.One2many(
+ 'fp.job.step.timelog',
+ 'job_id',
+ string='All Time Logs',
+ readonly=True,
+ )
+
+ # 2026-04-28 — link to the auto-created Sub 8 racking inspection so
+ # the job form can show a smart button + the manager can route into
+ # the inspection without leaving the job screen.
+ racking_inspection_ids = fields.One2many(
+ 'fp.racking.inspection',
+ 'x_fc_job_id',
+ string='Racking Inspections',
+ )
+ racking_inspection_id = fields.Many2one(
+ 'fp.racking.inspection',
+ string='Racking Inspection',
+ compute='_compute_racking_inspection',
+ store=False,
+ help='The single racking inspection scoped to this job (Sub 8 '
+ 'enforces uniqueness). Smart button on the form routes here.',
+ )
+ # Computed alongside racking_inspection_id so views can render the
+ # state badge without needing a related-on-non-stored field (which
+ # the ORM rejects). Selection mirrors fp.racking.inspection.state.
+ racking_inspection_state = fields.Selection(
+ [('draft', 'Draft'),
+ ('inspecting', 'Inspecting'),
+ ('done', 'Done'),
+ ('discrepancy_flagged', 'Discrepancy Flagged')],
+ string='Racking Inspection Status',
+ compute='_compute_racking_inspection',
+ store=False,
+ )
+
+ @api.depends('racking_inspection_ids', 'racking_inspection_ids.state')
+ def _compute_racking_inspection(self):
+ for job in self:
+ ri = job.racking_inspection_ids[:1]
+ job.racking_inspection_id = ri
+ job.racking_inspection_state = ri.state if ri else False
+
+ def action_view_racking_inspection(self):
+ """Open the racking inspection. Auto-create if missing (e.g. job
+ was created before Sub 8 shipped, or auto-create silently failed
+ at action_confirm time)."""
+ self.ensure_one()
+ if 'fp.racking.inspection' not in self.env:
+ from odoo.exceptions import UserError
+ raise UserError(_(
+ 'Sub 8 racking inspection module not installed. '
+ 'Install fusion_plating_receiving to enable.'
+ ))
+ if not self.racking_inspection_id:
+ self._fp_create_racking_inspection()
+ self.invalidate_recordset(['racking_inspection_ids'])
+ ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
+ if not ri:
+ from odoo.exceptions import UserError
+ raise UserError(_('Could not auto-create racking inspection.'))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.racking.inspection',
+ 'res_id': ri.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'name': _('Racking Inspection — %s') % self.name,
+ }
+
def action_view_steps(self):
self.ensure_one()
return {
@@ -166,6 +239,53 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id},
}
+ def action_open_move_wizard(self):
+ """Header button — opens the Move wizard pre-filled with the
+ currently in-progress (or most recently in-progress) step as the
+ from-step. Lets the manager move the job forward without first
+ clicking into a specific step row.
+ """
+ self.ensure_one()
+ active_step = self.step_ids.filtered(
+ lambda s: s.state == 'in_progress'
+ )[:1]
+ if not active_step:
+ active_step = self.step_ids.filtered(
+ lambda s: s.state in ('paused', 'ready')
+ ).sorted('sequence')[:1]
+ if not active_step:
+ raise UserError(_(
+ 'No in-progress, paused, or ready step found on this job. '
+ 'Either every step is done or the job is still in draft.'
+ ))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.job.step.move.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'name': _('Move Step — %s') % active_step.name,
+ 'context': {
+ 'default_from_step_id': active_step.id,
+ 'default_job_id': self.id,
+ },
+ }
+
+ def action_print_traveller(self):
+ self.ensure_one()
+ return self.env.ref(
+ 'fusion_plating_jobs.action_report_fp_job_traveller'
+ ).report_action(self)
+
+ def action_print_wo_detail(self):
+ """Print the Steelhead-style Work Order Detail PDF — chronological
+ chain-of-custody + per-step inputs + Certified By page. Use this
+ as the AS9100/Nadcap shippable audit document.
+ """
+ self.ensure_one()
+ return self.env.ref(
+ 'fusion_plating_jobs.action_report_fp_job_wo_detail'
+ ).report_action(self)
+
def action_view_deliveries(self):
self.ensure_one()
if not self.delivery_id:
@@ -497,6 +617,38 @@ class FpJob(models.Model):
instructions.append(line)
step_num += 1
+ # Map recipe_node.default_kind → step.kind so the
+ # downstream gates (Sub 8 racking soft-gate, Policy B
+ # contract-review gate) work even when the step gets
+ # renamed by the customer (e.g. "Hang on Bar" instead
+ # of "Racking"). Without this, gate detection falls
+ # back to fragile name matching.
+ _NODE_KIND_TO_STEP_KIND = {
+ 'cleaning': 'wet',
+ 'etch': 'wet',
+ 'rinse': 'wet',
+ 'plate': 'wet',
+ 'dry': 'wet',
+ 'wbf_test': 'wet',
+ 'bake': 'bake',
+ 'mask': 'mask',
+ 'demask': 'mask',
+ 'racking': 'rack',
+ 'derack': 'rack',
+ 'inspect': 'inspect',
+ 'final_inspect': 'inspect',
+ 'contract_review': 'other',
+ 'gating': 'other',
+ 'ship': 'other',
+ }
+ step_kind = 'other'
+ node_kind = (
+ node.default_kind
+ if 'default_kind' in node._fields else None
+ )
+ if node_kind and node_kind in _NODE_KIND_TO_STEP_KIND:
+ step_kind = _NODE_KIND_TO_STEP_KIND[node_kind]
+
vals = {
'job_id': job.id,
'name': node.name,
@@ -504,6 +656,7 @@ class FpJob(models.Model):
'duration_expected': node.estimated_duration or 0.0,
'sequence': seq_counter[0],
'recipe_node_id': node.id,
+ 'kind': step_kind,
}
if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration
@@ -636,12 +789,79 @@ class FpJob(models.Model):
)
if pending_steps:
pending_steps.write({'state': 'ready'})
+ # 2026-04-28 — auto-populate facility_id + manager_id so the
+ # job header surfaces them on the form. Page-1 audit found
+ # both empty on confirmed jobs.
+ job._fp_autofill_facility_and_manager()
job._fp_create_portal_job()
job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection()
job._fp_fire_notification('job_confirmed')
return result
+ def _fp_autofill_facility_and_manager(self):
+ """Populate facility_id + manager_id on confirm if empty.
+
+ Resolution order:
+ facility_id —
+ 1. Already set → leave alone.
+ 2. First step with a work_centre that has a facility → use it.
+ 3. Recipe's process_type → facility (if process_type carries one).
+ 4. Single-facility company → use that one.
+
+ manager_id —
+ 1. Already set → leave alone.
+ 2. Confirming user IS in the Plating Manager group → use them.
+ 3. Sale order user_id (the salesperson who confirmed the SO).
+ 4. The customer's account manager (partner.user_id).
+ 5. Leave blank — no sensible default.
+ """
+ self.ensure_one()
+ # ---- facility_id ----
+ if not self.facility_id:
+ facility = False
+ for s in self.step_ids:
+ if s.work_centre_id and 'facility_id' in s.work_centre_id._fields:
+ facility = s.work_centre_id.facility_id
+ if facility:
+ break
+ if not facility and self.recipe_id and 'process_type_id' in self.recipe_id._fields:
+ pt = self.recipe_id.process_type_id
+ if pt and 'facility_id' in pt._fields:
+ facility = pt.facility_id
+ if not facility:
+ Facility = self.env.get('fusion.plating.facility')
+ if Facility is not None:
+ facilities = Facility.search([
+ ('company_id', '=', self.company_id.id),
+ ])
+ if len(facilities) == 1:
+ facility = facilities
+ if facility:
+ self.facility_id = facility.id
+ self.message_post(body=_(
+ 'Facility auto-set on confirm: %s'
+ ) % facility.display_name)
+
+ # ---- manager_id ----
+ if not self.manager_id:
+ mgr = False
+ ManagerGroup = self.env.ref(
+ 'fusion_plating.group_fusion_plating_manager',
+ raise_if_not_found=False,
+ )
+ if ManagerGroup and self.env.user in ManagerGroup.user_ids:
+ mgr = self.env.user
+ elif self.sale_order_id and self.sale_order_id.user_id:
+ mgr = self.sale_order_id.user_id
+ elif self.partner_id and self.partner_id.user_id:
+ mgr = self.partner_id.user_id
+ if mgr:
+ self.manager_id = mgr.id
+ self.message_post(body=_(
+ 'Plating Manager auto-set on confirm: %s'
+ ) % mgr.name)
+
def _fp_create_racking_inspection(self):
"""Auto-create a draft racking inspection on job confirm.
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
index 97edec20..f01aa8e9 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
@@ -413,3 +413,370 @@ class FpJobStep(models.Model):
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
+
+ # ==================================================================
+ # Phase 2 multi-serial — auto-promote serials on step transitions
+ # ==================================================================
+ def _fp_promote_serials_on_start(self):
+ """When this step transitions to in_progress, lift any serial
+ attached to the parent SO line out of `received` / `racked` and
+ into `in_process`. Idempotent — already-promoted serials are
+ skipped.
+ """
+ for step in self:
+ job = step.job_id
+ if not job.sale_order_line_ids:
+ continue
+ serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
+ to_promote = serials.filtered(
+ lambda s: s.state in ('received', 'racked')
+ )
+ if to_promote:
+ # Use sudo on the helper so operator-tier users can promote
+ # serial state without needing direct write on fp.serial.
+ to_promote.sudo()._set_state('in_process', message=_(
+ 'Promoted to In Process on step "%s" start by %s.'
+ ) % (step.name, self.env.user.name))
+
+ def _fp_promote_serials_on_finish(self):
+ """When the LAST step of this step's job finishes (sequenced
+ terminal step OR an explicit inspect/final-inspect kind), bump
+ in-flight serials to `inspected` so the shipper sees them ready
+ for packing. Conservative — only promotes from `in_process`."""
+ for step in self:
+ job = step.job_id
+ if not job.sale_order_line_ids:
+ continue
+ # Is this the highest-sequence non-cancelled step on the job?
+ siblings = job.step_ids.filtered(
+ lambda s: s.state not in ('cancelled', 'skipped')
+ )
+ if not siblings:
+ continue
+ last_seq = max(siblings.mapped('sequence'))
+ is_terminal = (step.sequence == last_seq) or (
+ step.kind == 'inspect' or 'final' in (step.name or '').lower()
+ )
+ if not is_terminal:
+ continue
+ serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
+ to_promote = serials.filtered(lambda s: s.state == 'in_process')
+ if to_promote:
+ to_promote.sudo()._set_state('inspected', message=_(
+ 'Promoted to Inspected on step "%s" finish by %s.'
+ ) % (step.name, self.env.user.name))
+
+ # ==================================================================
+ # Policy B (2026-04-28) — Contract Review enforcement
+ # ==================================================================
+ # When a recipe author drops a "Contract Review" step into a recipe,
+ # button_start opens the QA-005 audit form for the linked part (auto-
+ # creates one if missing) and button_finish blocks completion until
+ # the form is `complete` AND the current user is on the recipe's
+ # contract_review_user_ids approver list (when configured).
+ #
+ # Detection — case-insensitive match on the step name OR
+ # recipe_node_id mapped from a step.template with default_kind ==
+ # 'contract_review' (the simple-editor library entry).
+ def _fp_is_contract_review_step(self):
+ self.ensure_one()
+ if (self.name or '').strip().lower() in ('contract review', 'qa-005'):
+ return True
+ node = self.recipe_node_id
+ if not node:
+ return False
+ # Source template kind (when authored via simple editor library)
+ if 'source_template_id' in node._fields and node.source_template_id:
+ if node.source_template_id.default_kind == 'contract_review':
+ return True
+ if 'default_kind' in node._fields and node.default_kind == 'contract_review':
+ return True
+ return False
+
+ def _fp_resolve_contract_review_part(self):
+ """Find the fp.part.catalog this step's job is for. Used by the
+ Contract Review hooks to auto-create / look up the QA-005 form.
+ Falls through to None when no part can be resolved (no SO line,
+ SO line without x_fc_part_catalog_id, etc.)."""
+ self.ensure_one()
+ for so_line in self.job_id.sale_order_line_ids:
+ if (so_line.x_fc_part_catalog_id
+ and 'fp.contract.review' in self.env):
+ return so_line.x_fc_part_catalog_id
+ return None
+
+ def _fp_open_contract_review(self):
+ """Auto-create the QA-005 form for this step's part if missing,
+ return the act_window pointing at it. Called from button_start
+ on Contract Review steps."""
+ self.ensure_one()
+ part = self._fp_resolve_contract_review_part()
+ if not part:
+ return None
+ Review = self.env.get('fp.contract.review')
+ if Review is None:
+ return None # quality module not installed — skip
+ review = part.x_fc_contract_review_id
+ if not review:
+ review = Review.sudo().create({
+ 'part_id': part.id,
+ 'state': 'assistant_review',
+ })
+ part.sudo().write({
+ 'x_fc_contract_review_id': review.id,
+ 'x_fc_contract_review_dismissed': False,
+ })
+ self.job_id.message_post(body=_(
+ 'Contract Review (QA-005) auto-created for %(part)s on '
+ 'Contract Review step start by %(user)s.'
+ ) % {
+ 'part': part.display_name or part.part_number or '',
+ 'user': self.env.user.name,
+ })
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.contract.review',
+ 'res_id': review.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'name': _('Contract Review — %s') % (
+ part.display_name or part.part_number or ''
+ ),
+ }
+
+ def _fp_check_contract_review_complete(self):
+ """Block button_finish on a Contract Review step until QA-005 is
+ signed off. Only enforced when the customer has
+ partner.x_fc_contract_review_required=True. Manager bypass via
+ context fp_skip_contract_review_gate=True."""
+ if self.env.context.get('fp_skip_contract_review_gate'):
+ return
+ for step in self:
+ if not step._fp_is_contract_review_step():
+ continue
+ part = step._fp_resolve_contract_review_part()
+ if not part or not part.partner_id.x_fc_contract_review_required:
+ continue
+ review = part.x_fc_contract_review_id
+ if not review or review.state != 'complete':
+ state_label = (
+ review.state if review else _('not started')
+ )
+ raise UserError(_(
+ 'Contract Review for %(part)s is %(state)s — must be '
+ '"complete" before this step can finish. Open the '
+ 'QA-005 form (smart button on the part), get both '
+ 'sections signed off, then retry. Manager bypass: '
+ 'fp_skip_contract_review_gate=True in context.'
+ ) % {
+ 'part': part.display_name or part.part_number or '',
+ 'state': state_label,
+ })
+ # Approver-list gate (restored from pre-Sub-11). When the
+ # recipe author named approvers on the recipe root, only those
+ # users can finish the Contract Review step.
+ recipe = step.recipe_node_id and step.recipe_node_id.recipe_root_id
+ approvers = (recipe.contract_review_user_ids
+ if (recipe and 'contract_review_user_ids' in recipe._fields)
+ else False)
+ if approvers and self.env.user not in approvers:
+ raise UserError(_(
+ 'Only authorised Contract Review approvers can finish '
+ 'this step. Approvers: %s.\n\nContact your Plating '
+ 'Manager to add yourself if this is wrong, or hand '
+ 'the step to one of the approvers.'
+ ) % ', '.join(approvers.mapped('name')))
+
+ # ==================================================================
+ # Sub 8 follow-up (2026-04-28) — Racking Inspection enforcement
+ # ==================================================================
+ # When the recipe-side "Racking" step starts, auto-promote the linked
+ # fp.racking.inspection from draft → inspecting and route the operator
+ # straight into the inspection form. When the same step finishes,
+ # block unless the inspection is in `done` or `discrepancy_flagged`
+ # (operator cleared every line). Manager bypass via context
+ # `fp_skip_racking_inspection_gate=True`.
+ def _fp_is_racking_step(self):
+ self.ensure_one()
+ if (self.name or '').strip().lower() in ('racking', 'rack'):
+ return True
+ node = self.recipe_node_id
+ if not node:
+ return False
+ if 'source_template_id' in node._fields and node.source_template_id:
+ if node.source_template_id.default_kind == 'racking':
+ return True
+ if 'default_kind' in node._fields and node.default_kind == 'racking':
+ return True
+ if self.kind == 'rack':
+ return True
+ return False
+
+ def _fp_open_racking_inspection(self):
+ """Auto-promote draft → inspecting + return act_window for the
+ linked racking inspection. Auto-creates one if missing."""
+ self.ensure_one()
+ if 'fp.racking.inspection' not in self.env:
+ return None
+ # Reach the job's existing inspection (auto-created on action_confirm)
+ # or trigger a fresh create if none exists.
+ ri = self.job_id.racking_inspection_id
+ if not ri:
+ self.job_id._fp_create_racking_inspection()
+ self.job_id.invalidate_recordset(['racking_inspection_ids'])
+ ri = self.job_id.racking_inspection_id
+ if not ri:
+ return None
+ # Promote draft → inspecting. action_start raises if state isn't
+ # draft, so guard.
+ if ri.state == 'draft':
+ ri.sudo().action_start()
+ self.job_id.message_post(body=_(
+ 'Racking inspection auto-promoted to "Inspecting" on '
+ '%(step)s start by %(user)s.'
+ ) % {'step': self.name, 'user': self.env.user.name})
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.racking.inspection',
+ 'res_id': ri.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'name': _('Racking Inspection — %s') % self.job_id.name,
+ }
+
+ def _fp_check_racking_inspection_complete(self):
+ """Soft gate — block button_finish on a Racking step until the
+ linked inspection is in a terminal state. discrepancy_flagged
+ counts as complete (the operator finished but flagged issues —
+ the discrepancy activity will route to the manager separately)."""
+ if self.env.context.get('fp_skip_racking_inspection_gate'):
+ return
+ for step in self:
+ if not step._fp_is_racking_step():
+ continue
+ ri = step.job_id.racking_inspection_id
+ if not ri:
+ # No inspection at all — still let it finish, but log a
+ # chatter warning so the manager sees the gap.
+ step.job_id.message_post(body=_(
+ '⚠️ Racking step "%s" finished without a racking '
+ 'inspection on file. Sub 8 expected one to be '
+ 'auto-created on job confirm.'
+ ) % step.name)
+ continue
+ if ri.state not in ('done', 'discrepancy_flagged'):
+ state_label = dict(ri._fields['state'].selection).get(
+ ri.state, ri.state)
+ raise UserError(_(
+ 'Racking inspection for %(job)s is "%(state)s" — must '
+ 'be Done or Discrepancy Flagged before this step can '
+ 'finish. Click the Racking Insp. smart button on the '
+ 'job, complete the line check-off, then retry. '
+ 'Manager bypass: fp_skip_racking_inspection_gate=True.'
+ ) % {
+ 'job': step.job_id.name,
+ 'state': state_label,
+ })
+
+ def button_start(self):
+ # Policy B — Contract Review takes priority (auto-opens QA-005).
+ for step in self:
+ if step._fp_is_contract_review_step():
+ action = step._fp_open_contract_review()
+ if action:
+ super(FpJobStep, step).button_start()
+ if step.state == 'in_progress':
+ step._fp_promote_serials_on_start()
+ return action
+ # Sub 8 — Racking step auto-opens the inspection form.
+ for step in self:
+ if step._fp_is_racking_step():
+ action = step._fp_open_racking_inspection()
+ if action:
+ super(FpJobStep, step).button_start()
+ if step.state == 'in_progress':
+ step._fp_promote_serials_on_start()
+ return action
+ result = super().button_start()
+ for step in self:
+ if step.state == 'in_progress':
+ step._fp_promote_serials_on_start()
+ return result
+
+ def button_finish(self):
+ # Policy B — block until QA-005 complete (when customer requires it).
+ self._fp_check_contract_review_complete()
+ # Sub 8 — block until racking inspection is Done / Flagged.
+ self._fp_check_racking_inspection_complete()
+ result = super().button_finish()
+ for step in self:
+ if step.state == 'done':
+ step._fp_promote_serials_on_finish()
+ return result
+
+ # ==================================================================
+ # Per-row shortcut actions used by the job form's inline action column
+ # ==================================================================
+ def action_open_move_wizard(self):
+ """Open the Move wizard with this step pre-filled as the from-step."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.job.step.move.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'name': _('Move from %s') % self.name,
+ 'context': {
+ 'default_from_step_id': self.id,
+ 'default_job_id': self.job_id.id,
+ },
+ }
+
+ def action_open_input_wizard(self):
+ """Open the Input Recording wizard for this step."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.job.step.input.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'name': _('Record Inputs — %s') % self.name,
+ 'context': {
+ 'default_step_id': self.id,
+ },
+ }
+
+ # ------------------------------------------------------------------
+ # Live duration helper — view binds to a non-stored compute that
+ # ticks each time the form re-reads. For a true live ticking clock
+ # we'd need an OWL widget; this gives "minutes since start" that's
+ # accurate at every record refresh, which is good enough for a
+ # backend manager's view.
+ # ------------------------------------------------------------------
+ duration_running_minutes = fields.Float(
+ string='Running Min',
+ compute='_compute_duration_running',
+ help='Minutes since the step\'s current open timelog started. '
+ 'Re-reads on every form refresh; equals duration_actual once '
+ 'the step is finished.',
+ )
+
+ @api.depends('state', 'date_started', 'time_log_ids',
+ 'time_log_ids.date_started', 'time_log_ids.date_finished',
+ 'duration_actual')
+ def _compute_duration_running(self):
+ now = fields.Datetime.now()
+ for step in self:
+ if step.state == 'in_progress':
+ # Sum closed intervals + (now - open interval start)
+ closed = sum(step.time_log_ids.mapped('duration_minutes'))
+ open_log = step.time_log_ids.filtered(
+ lambda l: not l.date_finished
+ )[:1]
+ running = 0.0
+ if open_log and open_log.date_started:
+ delta = (now - open_log.date_started).total_seconds() / 60.0
+ running = max(0.0, delta)
+ step.duration_running_minutes = closed + running
+ else:
+ step.duration_running_minutes = step.duration_actual or 0.0
diff --git a/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml b/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
index ad2fec74..b63c4f9c 100644
--- a/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
+++ b/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
@@ -202,6 +202,14 @@
+
+
+
+
+
+ No move log entries yet — this job hasn't progressed
+ through any steps. Operators move the job forward
+ via the tablet or the backend Move wizard.
+