feat(numbering): WO grouping by recipe + parent-derived bulk naming

Replaces x_fc_wo_group_tag grouping with resolved-recipe grouping.
Bare WO-<parent> when 1 recipe, WO-<parent>-NN zero-padded for N>1
ordered by min line sequence. fp.job inherits parent-numbered mixin
for the manual-add path; bulk SO-confirm sets names explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 13:18:10 -04:00
parent 1b1bebdcd8
commit 6c6fb8d2a4
4 changed files with 135 additions and 59 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.15.12', 'version': '19.0.18.15.13',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -51,7 +51,7 @@ class FpJob(models.Model):
dt = pytz.UTC.localize(dt) dt = pytz.UTC.localize(dt)
return dt.astimezone(tz).strftime(fmt) return dt.astimezone(tz).strftime(fmt)
_description = 'Work Order' _description = 'Work Order'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
# Sub 12d — state-aware sort. Active work bubbles to the top # Sub 12d — state-aware sort. Active work bubbles to the top
# (in_progress → confirmed/draft → on_hold → done → cancelled), # (in_progress → confirmed/draft → on_hold → done → cancelled),
# then high-priority first within each state, then nearest deadline. # then high-priority first within each state, then nearest deadline.
@@ -389,12 +389,50 @@ class FpJob(models.Model):
continue continue
job.current_step_id = False job.current_step_id = False
# ------------------------------------------------------------------
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'WO'
def _fp_parent_counter_field(self):
return 'x_fc_wo_count'
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
"""fp.job naming priority:
1. Caller-provided name (bulk SO-confirm path sets these explicitly).
2. Mixin parent-derived name (manual WO add to an existing SO).
3. Legacy fp.job sequence (standalone job, no SO link).
"""
# Pass A: fall back to legacy 'New' sentinel for records that
# don't get a parent-derived name. The mixin's post-create
# _fp_assign_parent_name() will override 'New' once the record
# exists if a parent SO is reachable.
for vals in vals_list: for vals in vals_list:
if vals.get('name', _('New')) == _('New'): if not vals.get('name'):
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New') vals['name'] = _('New')
return super().create(vals_list) records = super().create(vals_list)
# Pass B: any record that came through with 'New' (no explicit
# name from the bulk SO path) tries the parent-derived path,
# falling back to the legacy sequence if there's no parent SO.
for rec in records:
if rec.name and rec.name != _('New') and rec.name != 'New':
continue # caller set an explicit name (e.g. bulk SO confirm)
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
# Raw SQL — fp.job has no immutability guard yet in this
# task, but Task 11 will add one. Using SQL here keeps the
# fallback path consistent across all child models.
self.env.cr.execute(
"UPDATE fp_job SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# State machine — actions # State machine — actions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.22.2', 'version': '19.0.8.22.3',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -296,12 +296,55 @@ class SaleOrder(models.Model):
) % {'job': job.name, 'err': exc}) ) % {'job': job.name, 'err': exc})
return result return result
def _fp_resolve_recipe_for_line(self, line):
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
recipe-driven WO splits) AND for the per-job vals construction.
Priority (most-specific first):
1. line.x_fc_process_variant_id — Sarah explicitly picked a
part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe; must beat any
generic coating template.
3. coating.recipe_id — coating-config recipe
(generic template fallback).
4. part.recipe_id — legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
part = (
'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id
) or False
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
coating = (
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
) or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
picked = (
'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id
) or False
if picked:
return picked
if part and 'default_process_id' in part._fields and part.default_process_id:
return part.default_process_id
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
return coating.recipe_id
if part and 'recipe_id' in part._fields and part.recipe_id:
return part.recipe_id
return Node
def _fp_auto_create_job(self): def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines. """Create fp.job(s) from the SO's plating lines.
Lines that share a `x_fc_wo_group_tag` collapse into one job; 2026-05-12 parent-number rewrite: lines are grouped by resolved
untagged lines get one job per line. Mirrors bridge_mrp's recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
_fp_auto_create_mo grouping logic. WO-<parent> (bare). If N>1 groups → N WOs named WO-<parent>-01,
WO-<parent>-02, ..., ordered by min line sequence so suffixes
mirror SO display order. WO names are then immutable; later
manual additions to the SO get the next index via the mixin.
""" """
self.ensure_one() self.ensure_one()
Job = self.env['fp.job'].sudo() Job = self.env['fp.job'].sudo()
@@ -334,20 +377,32 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name) _logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return return
# Group by x_fc_wo_group_tag (untagged → distinct group per line) # Group by resolved recipe id (lines sharing a recipe → one WO).
groups = {} # tag → recordset of lines # No-recipe lines get their own group each (preserves the legacy
untagged_idx = 0 # "one job per line" behaviour for unrecipe'd SOs).
groups = {}
unrecipe_idx = 0
for line in plating_lines: for line in plating_lines:
tag = ( recipe = self._fp_resolve_recipe_for_line(line)
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag if recipe:
) or False key = recipe.id
if not tag: else:
untagged_idx += 1 unrecipe_idx += 1
tag = '__untagged_%d' % untagged_idx key = ('no_recipe', unrecipe_idx)
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO
# display order. Deterministic regardless of dict iteration order.
ordered_keys = sorted(
groups.keys(),
key=lambda k: min(groups[k].mapped('sequence') or [0]),
)
n_groups = len(ordered_keys)
parent = self.x_fc_parent_number # set by action_confirm earlier
# Create a job per group # Create a job per group
for tag, lines in groups.items(): for idx, key in enumerate(ordered_keys, start=1):
lines = groups[key]
first_line = lines[0] first_line = lines[0]
qty = sum(lines.mapped('product_uom_qty')) qty = sum(lines.mapped('product_uom_qty'))
part = ( part = (
@@ -360,49 +415,11 @@ class SaleOrder(models.Model):
and first_line.x_fc_coating_config_id and first_line.x_fc_coating_config_id
or False or False
) )
# Header fallback for legacy/configurator SOs that put part +
# coating on the SO header instead of the line.
if not part and 'x_fc_part_catalog_id' in self._fields: if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields: if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False coating = self.x_fc_coating_config_id or False
# Recipe lookup priority (specific → generic): recipe = self._fp_resolve_recipe_for_line(first_line)
# 1. line.x_fc_process_variant_id — Sarah explicitly picked
# a part-scoped variant on this order line. Always wins.
# 2. part.default_process_id — part's flagged default
# variant. This is the customer-and-part-tuned recipe
# (months of process refinement) and must beat any
# generic template attached to the coating config.
# 3. coating.recipe_id — coating-config recipe
# (generic template fallback when no part variant exists).
# 4. part.recipe_id — legacy fallback.
#
# Order swap 2026-05-12: before this, step (3) ran before (2),
# so jobs with both a part variant AND a coating-attached
# template silently picked the template. WO #01373 (S00063)
# is the documented case — part 9876699373 Rev A had its own
# variant but the job linked to ENP-ALUM-BASIC instead.
#
# If multiple lines in the same WO group have different
# variants we use the FIRST line's variant (consistent with
# everything else in this loop using `first_line`).
recipe = False
picked_variant = (
'x_fc_process_variant_id' in first_line._fields
and first_line.x_fc_process_variant_id
or False
)
if picked_variant:
recipe = picked_variant
if not recipe and part and 'default_process_id' in part._fields \
and part.default_process_id:
recipe = part.default_process_id
if not recipe and coating and 'recipe_id' in coating._fields \
and coating.recipe_id:
recipe = coating.recipe_id
if not recipe and part and 'recipe_id' in part._fields \
and part.recipe_id:
recipe = part.recipe_id
vals = { vals = {
'partner_id': self.partner_id.id, 'partner_id': self.partner_id.id,
@@ -457,11 +474,32 @@ class SaleOrder(models.Model):
# Quoted revenue: sum line totals # Quoted revenue: sum line totals
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal')) vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
# Parent-number naming (2026-05-12). Bare for the single-group
# case; zero-padded -NN suffix when multiple recipes split the
# SO into multiple WOs. Set explicitly so fp.job.create() skips
# its own naming fallback.
if parent:
if n_groups == 1:
vals['name'] = f'WO-{parent}'
vals['x_fc_doc_index'] = 1
else:
vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}'
vals['x_fc_doc_index'] = idx
job = Job.create(vals) job = Job.create(vals)
_logger.info( _logger.info(
'SO %s: created fp.job %s (qty=%s, recipe=%s)', 'SO %s: created fp.job %s (qty=%s, recipe=%s)',
self.name, job.name, qty, (recipe.name if recipe else '-'), self.name, job.name, qty, (recipe.name if recipe else '-'),
) )
# Bump SO counter to reflect the bulk creation. Future manual
# WO additions pick up from here via the mixin standard path.
if parent and n_groups:
self.env.cr.execute(
"UPDATE sale_order SET x_fc_wo_count = %s WHERE id = %s",
(n_groups, self.id),
)
self.invalidate_recordset(['x_fc_wo_count'])
return True return True
# ------------------------------------------------------------------ # ------------------------------------------------------------------