This commit is contained in:
gsinghpal
2026-04-28 19:39:37 -04:00
parent 2d42b33d68
commit 13e300d90e
103 changed files with 4959 additions and 331 deletions

View File

@@ -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',
],

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
})

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_serial_user fp.serial.user model_fp_serial base.group_user 1 0 0 0
45 access_fp_serial_estimator fp.serial.estimator model_fp_serial fusion_plating_configurator.group_fp_estimator 1 1 1 0
46 access_fp_serial_manager fp.serial.manager model_fp_serial fusion_plating.group_fusion_plating_manager 1 1 1 1
47 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
48 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
49 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
50 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
51 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

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

View File

@@ -83,8 +83,15 @@
<td class="text-end">
<button class="btn btn-sm btn-primary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.openRecipeEditor(v.id)">
<i class="fa fa-pencil"/> Edit
t-on-click="() => this.openRecipeEditor(v.id)"
title="Open the tree editor (drag-and-drop hierarchy view)">
<i class="fa fa-pencil"/> Tree
</button>
<button class="btn btn-sm btn-info me-1"
t-att-disabled="state.busy"
t-on-click="() => this.openRecipeSimpleEditor(v.id)"
title="Open the Simple Recipe Editor (flat 2-pane drag-drop)">
<i class="fa fa-list-alt"/> Simple
</button>
<button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy"

View File

@@ -45,9 +45,8 @@
<field name="active" widget="boolean_toggle"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>

View File

@@ -176,12 +176,26 @@
icon="fa-wrench"
class="btn-primary"
help="Open the Process Composer to manage this part's process variants."/>
<button name="action_open_default_simple_editor" type="object"
string="Edit Default (Simple)"
icon="fa-list-alt"
class="btn-info ms-1"
invisible="not default_process_id"
help="Jump straight to the Simple Recipe Editor for the default variant — flat 2-pane drag-drop layout."/>
<button name="action_open_default_tree_editor" type="object"
string="Edit Default (Tree)"
icon="fa-sitemap"
class="btn-secondary ms-1"
invisible="not default_process_id"
help="Jump straight to the Tree Editor for the default variant."/>
</div>
<p class="text-muted mt-3">
The <strong>Compose</strong> button opens the Process Composer where you can add
multiple process <em>variants</em> 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 <strong>Tree</strong> or <strong>Simple</strong> view — same data,
two layouts.
</p>
<field name="process_variant_ids" readonly="1">
<list>
@@ -189,6 +203,12 @@
<field name="variant_label"/>
<field name="name"/>
<field name="estimated_duration" optional="hide"/>
<button name="action_open_simple_editor" type="object"
string="Simple" icon="fa-list-alt"
class="btn-link"/>
<button name="action_open_tree_editor" type="object"
string="Tree" icon="fa-sitemap"
class="btn-link"/>
</list>
</field>
</page>
@@ -207,9 +227,7 @@
<field name="has_threads"/>
</group>
</group>
<group>
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
</group>
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
</page>
<page string="Attachments" name="attachments">
<group>

View File

@@ -276,9 +276,7 @@
options="{'currency_field': 'currency_id'}"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
<field name="price_breakdown_html" readonly="1" colspan="2"/>
</div>
</div>
@@ -299,10 +297,9 @@
<field name="lost_date" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="lost_details" nolabel="1" colspan="2"
<separator string="Notes"/>
<field name="lost_details" colspan="2"
placeholder="What did we learn? (Price point competitor beat, spec we didn't meet, etc.)"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this quote..."/>

View File

@@ -58,10 +58,9 @@
<field name="internal_description" nolabel="1" colspan="2"
placeholder="What the shop floor sees on the WO / traveler…"/>
</group>
<group string="Customer-Facing Description">
<field name="customer_facing_description" nolabel="1" colspan="2"
<separator string="Customer-Facing Description"/>
<field name="customer_facing_description" colspan="2"
placeholder="Electroless nickel plating per AMS 2404, Class I, Type II…"/>
</group>
</sheet>
</form>
</field>

View File

@@ -0,0 +1,191 @@
<?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.
Phase 2 (2026-04-28) — relocates the fp.serial views from
fusion_plating_bridge_mrp (uninstalled in Sub 11) into configurator
where the model lives. Adds the new state machine to the form +
list with workflow buttons + status badge.
-->
<odoo>
<record id="view_fp_serial_form" model="ir.ui.view">
<field name="name">fp.serial.form</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<form string="Serial Number">
<header>
<button name="action_mark_racked" type="object"
string="Mark Racked" class="btn-primary"
invisible="state != 'received'"/>
<button name="action_mark_in_process" type="object"
string="Start Processing" class="btn-primary"
invisible="state != 'racked'"/>
<button name="action_mark_inspected" type="object"
string="Pass Inspection" class="btn-success"
invisible="state != 'in_process'"/>
<button name="action_mark_packed" type="object"
string="Mark Packed" class="btn-primary"
invisible="state != 'inspected'"/>
<button name="action_mark_shipped" type="object"
string="Mark Shipped" class="btn-success"
invisible="state != 'packed'"/>
<button name="action_mark_on_hold" type="object"
string="Hold" class="btn-warning"
invisible="state in ('on_hold', 'shipped', 'scrapped')"/>
<button name="action_release_hold" type="object"
string="Release Hold" class="btn-secondary"
invisible="state != 'on_hold'"/>
<button name="action_mark_scrapped" type="object"
string="Scrap" class="btn-danger"
invisible="state in ('shipped', 'scrapped')"
confirm="Mark this serial as scrapped? This is reversible only via Reopen."/>
<button name="action_mark_returned" type="object"
string="Returned by Customer" class="btn-secondary"
invisible="state != 'shipped'"/>
<button name="action_reopen" type="object"
string="Reopen" class="btn-secondary"
groups="fusion_plating.group_fusion_plating_manager"
invisible="state not in ('shipped', 'scrapped')"
confirm="Reopen a terminal-state serial? Manager-only override; the chatter audit will record who, when, why."/>
<field name="state" widget="statusbar"
statusbar_visible="received,racked,in_process,inspected,packed,shipped"/>
</header>
<sheet>
<widget name="web_ribbon" title="Scrapped" bg_color="text-bg-danger"
invisible="state != 'scrapped'"/>
<widget name="web_ribbon" title="On Hold" bg_color="text-bg-warning"
invisible="state != 'on_hold'"/>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-money"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
<button name="action_view_part" type="object"
class="oe_stat_button" icon="fa-cube"
invisible="not part_id">
<div class="o_stat_info">
<span class="o_stat_text">Part</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name" string="Serial #"/>
<h1><field name="name" placeholder="e.g. SN-12345"/></h1>
</div>
<group>
<group>
<field name="customer_id" readonly="1"/>
<field name="part_id" readonly="1"/>
<field name="sale_order_id" readonly="1"/>
<field name="sale_order_line_id" readonly="1"/>
</group>
<group>
<field name="last_state_change" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<separator string="Scrap / Return Reason"
invisible="state not in ('scrapped', 'returned', 'on_hold')"/>
<field name="scrap_reason"
invisible="state not in ('scrapped', 'returned', 'on_hold')"
placeholder="What happened? Cause / disposition / who decided..."/>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_serial_list" model="ir.ui.view">
<field name="name">fp.serial.list</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<list string="Serial Numbers"
decoration-success="state in ('inspected', 'packed', 'shipped')"
decoration-info="state in ('received', 'racked', 'in_process')"
decoration-warning="state in ('on_hold', 'returned')"
decoration-danger="state == 'scrapped'">
<field name="name"/>
<field name="state" widget="badge"
decoration-success="state in ('inspected', 'packed', 'shipped')"
decoration-info="state in ('received', 'racked', 'in_process')"
decoration-warning="state in ('on_hold', 'returned')"
decoration-danger="state == 'scrapped'"/>
<field name="customer_id"/>
<field name="part_id"/>
<field name="sale_order_id"/>
<field name="last_state_change" optional="show"/>
<field name="invoice_count" string="Invoices" optional="hide"/>
<field name="create_date" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_serial_search" model="ir.ui.view">
<field name="name">fp.serial.search</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="customer_id"/>
<field name="part_id"/>
<field name="sale_order_id"/>
<separator/>
<filter name="state_received" string="Received"
domain="[('state','=','received')]"/>
<filter name="state_in_process" string="In Process"
domain="[('state','=','in_process')]"/>
<filter name="state_inspected" string="Inspected"
domain="[('state','=','inspected')]"/>
<filter name="state_packed" string="Packed"
domain="[('state','=','packed')]"/>
<filter name="state_shipped" string="Shipped"
domain="[('state','=','shipped')]"/>
<separator/>
<filter name="state_on_hold" string="On Hold"
domain="[('state','=','on_hold')]"/>
<filter name="state_scrapped" string="Scrapped"
domain="[('state','=','scrapped')]"/>
<filter name="state_returned" string="Returned"
domain="[('state','=','returned')]"/>
<separator/>
<filter name="active_only" string="In Flight (not shipped/scrapped)"
domain="[('state','not in',('shipped','scrapped'))]"/>
<group>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_customer" string="Customer"
context="{'group_by': 'customer_id'}"/>
<filter name="group_part" string="Part"
context="{'group_by': 'part_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_serial" model="ir.actions.act_window">
<field name="name">Serial Numbers</field>
<field name="res_model">fp.serial</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_serial_search"/>
</record>
<menuitem id="menu_fp_serial"
name="Serial Numbers"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_serial"
sequence="60"/>
</odoo>

View File

@@ -52,9 +52,7 @@
options="{'currency_field': 'currency_id'}"/>
</group>
</group>
<group>
<field name="description" placeholder="Description of this treatment step..."/>
</group>
<field name="description" placeholder="Description of this treatment step..."/>
<group>
<field name="active" widget="boolean_toggle"/>
</group>

View File

@@ -188,10 +188,9 @@
<field name="x_fc_internal_note" nolabel="1"
placeholder="Internal notes for estimator / planner / shop floor..."/>
</group>
<group string="External Notes (customer-visible)">
<field name="x_fc_external_note" nolabel="1"
<separator string="External Notes (customer-visible)"/>
<field name="x_fc_external_note"
placeholder="Notes that appear on the acknowledgement and portal..."/>
</group>
</group>
</page>
</xpath>
@@ -214,17 +213,33 @@
optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id"
string="Variant"
options="{'no_create': True}"
string="Process / Recipe"
options="{'no_quick_create': True}"
invisible="not x_fc_part_catalog_id"
optional="show"/>
<field name="x_fc_save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not x_fc_process_variant_id"
optional="hide"/>
<button name="action_customize_process" type="object"
string="Customize" icon="fa-pencil-square-o"
class="btn-link"
invisible="not x_fc_process_variant_id"/>
<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}"
<field name="x_fc_serial_ids"
widget="many2many_tags"
options="{'no_quick_create': False, 'color_field': 'state_color'}"
optional="show"/>
<field name="x_fc_serial_count"
string="# SN"
optional="hide"/>
<button name="action_open_serial_bulk_add" type="object"
string="Bulk Add Serials" icon="fa-list-ol"
class="btn-link"/>
<field name="x_fc_job_number" optional="show"/>
<field name="x_fc_revision_pick_id"
string="Revision"

View File

@@ -8,3 +8,4 @@ from . import fp_add_from_so_wizard
from . import fp_add_from_quote_wizard
from . import fp_quote_promote_wizard
from . import fp_part_catalog_import_wizard
from . import fp_serial_bulk_add_wizard

View File

@@ -60,17 +60,27 @@ class FpDirectOrderLine(models.Model):
string='Additional Treatments',
help='Extra pre/post treatments applied to this line.',
)
# Sub 9 — explicit per-line process variant override. NULL means
# "use the part's default variant".
# 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 _fp_apply_recipe_polish) so per-line edits never bleed.
process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('part_catalog_id', '=', 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 line. Leave blank '
'to use the part\'s default variant. Manage variants 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. Cross-part picks '
'are cloned onto this part on save so per-line edits stay '
'scoped. Use the Customize button to open the Process '
'Composer for the chosen variant.',
)
save_as_default_process = fields.Boolean(
string='Set as Part Default',
help='When ticked, the chosen process variant becomes this part\'s '
'default on order submit — future orders for the same part '
'pre-fill with this variant.',
)
# Read-only preview of the process tree that WILL drive WO generation
# for this line. Resolution priority:
@@ -116,26 +126,38 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped).
"""Pre-fill the line from the part's saved defaults when the part
changes.
2026-04-28 polish: variant is no longer cleared — instead it
pre-fills from the part's `default_process_id` so the estimator
gets a sensible starting point. (Domain is system-wide now, so
a stale value would still load fine; we just upgrade the UX.)
Pre-fill coating + treatments from the part's saved defaults so
Sarah doesn't re-pick the same coating every repeat customer.
Defaults only apply when the line currently has no coating set
— editing an existing line with a chosen coating doesn't get
clobbered.
the estimator doesn't re-pick the same coating every repeat
customer. Defaults only apply when the line currently has no
coating set — editing an existing line with a chosen coating
doesn't get clobbered.
For BRAND-NEW parts (no defaults saved yet) auto-tick
`push_to_defaults` so Sarah's first coating pick gets persisted
back to the part. Without this Sarah has to remember to tick the
toggle herself, and the second order doesn't pre-fill.
`push_to_defaults` so the first coating pick gets persisted
back to the part. Without this, the estimator has to remember
to tick the toggle and the second order doesn't pre-fill.
Returns a warning popup explaining what's happening.
"""
warning = None
for rec in self:
# Variant clear (original behaviour).
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
# Pre-fill variant from the part's default (was: blanket clear).
if rec.part_catalog_id and rec.part_catalog_id.default_process_id:
# Only overwrite when blank or pointing at a different part —
# don't clobber a deliberate cross-part pick the estimator
# made before changing the part.
if (not rec.process_variant_id
or (rec.process_variant_id.part_catalog_id
and rec.process_variant_id.part_catalog_id.id
!= rec.part_catalog_id.id)):
rec.process_variant_id = rec.part_catalog_id.default_process_id
if not rec.part_catalog_id:
continue
part = rec.part_catalog_id
@@ -266,17 +288,50 @@ class FpDirectOrderLine(models.Model):
compute='_compute_is_missing_info',
)
# ---- Sub 5 — Serial / Job# / Thickness -------------------------------
# ---- Sub 5 / Phase 1 — Serials / 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.
#
# 2026-04-28 Phase 1 — multi-serial. M2M is the source of truth;
# serial_id stays as a computed alias so existing flows that read
# the singular continue to work.
serial_ids = fields.Many2many(
'fp.serial',
relation='fp_direct_order_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
help='Customer-supplied serial numbers. Use Bulk Add to paste a '
'list, range-fill (SN-001..SN-030), or scan barcodes.',
)
serial_count = fields.Integer(
compute='_compute_serial_count',
string='# Serials',
)
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.',
string='Primary Serial',
compute='_compute_primary_serial',
inverse='_inverse_primary_serial',
store=False,
help='First of the line\'s serials — back-compat alias.',
)
@api.depends('serial_ids')
def _compute_serial_count(self):
for rec in self:
rec.serial_count = len(rec.serial_ids)
@api.depends('serial_ids')
def _compute_primary_serial(self):
for rec in self:
rec.serial_id = rec.serial_ids[:1]
def _inverse_primary_serial(self):
for rec in self:
if rec.serial_id and rec.serial_id not in rec.serial_ids:
rec.serial_ids = [(4, rec.serial_id.id)]
job_number = fields.Char(string='Job #')
thickness_id = fields.Many2one(
'fp.coating.thickness',
@@ -309,14 +364,48 @@ class FpDirectOrderLine(models.Model):
rec.thickness_id = False
def action_generate_serial(self):
"""Create an auto-sequenced fp.serial and assign it to this line."""
"""Generate one auto-sequenced fp.serial and append to the M2M.
Phase 1: appends instead of replacing — repeated clicks add more.
"""
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
new_serial = self.env['fp.serial'].create({'name': seq})
self.serial_ids = [(4, new_serial.id)]
return False
def action_open_serial_bulk_add(self):
"""Open the Bulk Add Serials wizard for this Direct Order 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': 'fp.direct.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.quantity or 0),
},
}
@api.constrains('serial_ids', 'quantity')
def _check_serial_count_against_qty(self):
for rec in self:
if rec.serial_ids and rec.quantity:
n = len(rec.serial_ids)
if n > 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

View File

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

View File

@@ -156,14 +156,23 @@
optional="hide"/>
<field name="coating_config_id"/>
<field name="process_variant_id"
string="Variant"
options="{'no_create': True}"
string="Process / Recipe"
options="{'no_quick_create': True}"
invisible="not part_catalog_id"
optional="show"/>
<field name="save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not process_variant_id"
optional="hide"/>
<button name="action_customize_process" type="object"
string="Customize" icon="fa-pencil-square-o"
class="btn-link"
invisible="not process_variant_id"/>
<field name="effective_process_id"
string="Process"
string="Effective Process"
readonly="1"
optional="show"/>
optional="hide"/>
<field name="effective_process_source"
string="Process Source"
readonly="1"
@@ -172,9 +181,16 @@
options="{'no_create': True}"
invisible="not coating_config_id"
optional="show"/>
<field name="serial_id"
options="{'no_create_edit': False}"
<field name="serial_ids"
widget="many2many_tags"
options="{'no_quick_create': False, 'color_field': 'state_color'}"
optional="show"/>
<field name="serial_count"
string="# SN"
optional="hide"/>
<button name="action_open_serial_bulk_add" type="object"
string="Bulk Add Serials" icon="fa-list-ol"
class="btn-link"/>
<field name="job_number" optional="hide"/>
<field name="treatment_ids"
widget="many2many_tags"
@@ -210,9 +226,17 @@
<field name="treatment_ids"
widget="many2many_tags"/>
<field name="process_variant_id"
string="Process Variant"
options="{'no_create': True}"
string="Process / Recipe"
options="{'no_quick_create': True}"
invisible="not part_catalog_id"/>
<field name="save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not process_variant_id"/>
<button name="action_customize_process" type="object"
string="Customize Process" icon="fa-pencil-square-o"
class="btn-link"
invisible="not process_variant_id"/>
<field name="effective_process_id"
string="Effective Process"
readonly="1"/>

View File

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

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_serial_bulk_add_wizard_form" model="ir.ui.view">
<field name="name">fp.serial.bulk.add.wizard.form</field>
<field name="model">fp.serial.bulk.add.wizard</field>
<field name="arch" type="xml">
<form string="Bulk Add Serials">
<sheet>
<group>
<field name="target_model" invisible="1"/>
<field name="target_id" invisible="1"/>
<field name="qty_expected"/>
<field name="mode" widget="radio"/>
</group>
<!-- Paste mode -->
<group invisible="mode != 'paste'">
<separator string="Paste a List"/>
<field name="paste_text" nolabel="1"
placeholder="One per line, or comma-separated.&#10;Example:&#10;SN-001&#10;SN-002&#10;CUST-12345&#10;or scan barcodes — one per Enter."/>
</group>
<!-- Range mode -->
<group invisible="mode != 'range'">
<separator string="Range Fill"/>
<group>
<field name="prefix"/>
<field name="suffix"/>
<field name="pad_width"/>
</group>
<group>
<field name="start_number"/>
<field name="end_number"/>
</group>
<separator string="Preview"/>
<field name="range_preview" nolabel="1" readonly="1"/>
</group>
</sheet>
<footer>
<button name="action_apply" type="object"
string="Add Serials" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_serial_bulk_add_wizard" model="ir.actions.act_window">
<field name="name">Bulk Add Serials</field>
<field name="res_model">fp.serial.bulk.add.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>