feat(fusion_plating_jobs): group WOs by recipe step structure

Replace the old 5-tuple (recipe.id, part, spec, thickness, serial) grouping
key with a structural signature so multiple parts that share the same recipe
step tree (ENP clones) collapse onto one combined work order. Add three
helpers: _fp_recipe_signature, _fp_line_express_signature, _fp_line_group_key.
Add TransactionCase test covering merge, non-merge, and masking-split cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-03 22:43:47 -04:00
parent c71c60350b
commit 28e5e7f9de
3 changed files with 168 additions and 29 deletions

View File

@@ -395,6 +395,66 @@ class SaleOrder(models.Model):
return part.recipe_id
return Node
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit).
When the Express fields are absent on a line's module, masking
defaults to True and bake to False, so a non-Express line groups
as masking-on / no-bake.
"""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line, sig_cache=None):
"""WO grouping key. Lines with the same key ride one work order.
`sig_cache` (optional) memoises recipe-id -> signature so a
multi-line SO doesn't re-search the same recipe tree per line.
"""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
if sig_cache is None:
sig = self._fp_recipe_signature(recipe)
else:
if recipe.id not in sig_cache:
sig_cache[recipe.id] = self._fp_recipe_signature(recipe)
sig = sig_cache[recipe.id]
if not sig:
# A recipe with no step nodes has no structure to share —
# don't let empty-tree shells silently merge into one WO.
return ('no_recipe', line.id)
return ('recipe', sig, self._fp_line_express_signature(line))
def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines.
@@ -436,37 +496,14 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker —
# silent mis-attestation. No-recipe lines still get their own
# group each.
# Group by recipe structural signature (+ per-line masking/bake
# toggles). Lines whose recipes have identical steps collapse onto
# one WO; no-recipe lines stay separate. See spec
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
groups = {}
unrecipe_idx = 0
_sig_cache = {}
for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_key = (
'x_fc_thickness_range' in line._fields
and (line.x_fc_thickness_range or '').strip()
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
key = self._fp_line_group_key(line, sig_cache=_sig_cache)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO