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

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