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

@@ -296,12 +296,55 @@ class SaleOrder(models.Model):
) % {'job': job.name, 'err': exc})
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):
"""Create fp.job(s) from the SO's plating lines.
Lines that share a `x_fc_wo_group_tag` collapse into one job;
untagged lines get one job per line. Mirrors bridge_mrp's
_fp_auto_create_mo grouping logic.
2026-05-12 parent-number rewrite: lines are grouped by resolved
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
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()
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)
return
# Group by x_fc_wo_group_tag (untagged → distinct group per line)
groups = {} # tag → recordset of lines
untagged_idx = 0
# Group by resolved recipe id (lines sharing a recipe → one WO).
# No-recipe lines get their own group each (preserves the legacy
# "one job per line" behaviour for unrecipe'd SOs).
groups = {}
unrecipe_idx = 0
for line in plating_lines:
tag = (
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag
) or False
if not tag:
untagged_idx += 1
tag = '__untagged_%d' % untagged_idx
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
recipe = self._fp_resolve_recipe_for_line(line)
if recipe:
key = recipe.id
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
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
for tag, lines in groups.items():
for idx, key in enumerate(ordered_keys, start=1):
lines = groups[key]
first_line = lines[0]
qty = sum(lines.mapped('product_uom_qty'))
part = (
@@ -360,49 +415,11 @@ class SaleOrder(models.Model):
and first_line.x_fc_coating_config_id
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:
part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
# Recipe lookup priority (specific → generic):
# 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
recipe = self._fp_resolve_recipe_for_line(first_line)
vals = {
'partner_id': self.partner_id.id,
@@ -457,11 +474,32 @@ class SaleOrder(models.Model):
# Quoted revenue: sum line totals
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)
_logger.info(
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
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
# ------------------------------------------------------------------