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:
@@ -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
|
||||
|
||||
@@ -11,3 +11,4 @@ from . import test_post_shop_states
|
||||
from . import test_recipe_cert_suppression
|
||||
from . import test_order_ship_state
|
||||
from . import test_combined_cert_creation
|
||||
from . import test_wo_recipe_grouping
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestWoRecipeGrouping(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.SO = self.env['sale.order']
|
||||
self.Node = self.env['fusion.plating.process.node']
|
||||
# kind_id is required on process.node; reuse any seeded kind so
|
||||
# node creation doesn't depend on the default lookup resolving.
|
||||
self.kind = self.env['fp.step.kind'].search([], limit=1)
|
||||
|
||||
def _node_vals(self, name, node_type):
|
||||
v = {'name': name, 'node_type': node_type}
|
||||
if self.kind:
|
||||
v['kind_id'] = self.kind.id
|
||||
return v
|
||||
|
||||
def _recipe(self, name, step_names):
|
||||
root = self.Node.create(self._node_vals(name, 'recipe'))
|
||||
seq = 10
|
||||
for sn in step_names:
|
||||
v = self._node_vals(sn, 'step')
|
||||
v.update({'parent_id': root.id, 'sequence': seq})
|
||||
self.Node.create(v)
|
||||
seq += 10
|
||||
return root
|
||||
|
||||
def test_identical_structure_same_signature(self):
|
||||
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
self.assertEqual(
|
||||
self.SO._fp_recipe_signature(r1),
|
||||
self.SO._fp_recipe_signature(r2),
|
||||
'clones with identical steps share a signature')
|
||||
|
||||
def test_different_structure_different_signature(self):
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
|
||||
self.assertNotEqual(
|
||||
self.SO._fp_recipe_signature(r1),
|
||||
self.SO._fp_recipe_signature(r2))
|
||||
|
||||
def test_so_groups_same_structure_into_one_wo(self):
|
||||
partner = self.env['res.partner'].create({'name': 'G'})
|
||||
product = self.env['product.product'].create({'name': 'P'})
|
||||
pa = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
|
||||
pb = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
|
||||
pc = self.env['fp.part.catalog'].create({
|
||||
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
|
||||
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
|
||||
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pa.id,
|
||||
'x_fc_process_variant_id': r1.id}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pb.id,
|
||||
'x_fc_process_variant_id': r2.id}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pc.id,
|
||||
'x_fc_process_variant_id': r3.id}),
|
||||
],
|
||||
})
|
||||
so._fp_auto_create_job()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
|
||||
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
|
||||
self.assertEqual(sizes, [1, 2])
|
||||
|
||||
def test_masking_toggle_splits_same_structure(self):
|
||||
partner = self.env['res.partner'].create({'name': 'M'})
|
||||
product = self.env['product.product'].create({'name': 'P'})
|
||||
pa = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
|
||||
pb = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
|
||||
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pa.id,
|
||||
'x_fc_process_variant_id': r1.id,
|
||||
'x_fc_masking_enabled': True}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pb.id,
|
||||
'x_fc_process_variant_id': r2.id,
|
||||
'x_fc_masking_enabled': False}),
|
||||
],
|
||||
})
|
||||
so._fp_auto_create_job()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
|
||||
Reference in New Issue
Block a user