fix(jobs): split fp.jobs by thickness + serial on SO confirm

The _fp_auto_create_job grouping key was (recipe, part, coating).
Lines that shared all three but differed in thickness (or serial)
silently collapsed into one fp.job — the second line's thickness/SN
was lost, and any downstream cert printed the first line's values
across both batches. Silent mis-attestation = compliance hole.

Extended the key tuple to (recipe, part, coating, thickness, serial).
Single-line SOs and same-(thickness, SN) multi-line SOs collapse
identically to before. Only lines that previously merged when they
shouldn't have now split into their own fp.jobs.

TDD via test_so_confirm_splits_by_thickness:
  - seeds the part with default_process_id so both lines hit the
    `if recipe:` branch (where the bug lived — the no_recipe branch
    already split correctly per line)
  - confirms 2 jobs after action_confirm with each carrying its
    own thickness via the linked SO line

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-13 07:57:56 -04:00
parent 135cbd3a5c
commit e4681a58c6
3 changed files with 93 additions and 11 deletions

View File

@@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase):
else:
self.skipTest('x_fc_part_catalog_id field not present')
def test_so_confirm_splits_by_thickness(self):
"""Two lines with same recipe+part+coating but DIFFERENT thicknesses
must produce TWO fp.jobs — silent merge was a compliance bug (the
second thickness's CoC would carry the first thickness).
The bug only manifests when lines hit the `if recipe:` branch in
_fp_auto_create_job — without a resolved recipe, the no_recipe
branch already splits per line. We seed a recipe via
part.default_process_id so both lines resolve to the same recipe
and reach the buggy grouping path.
"""
SOL = self.env['sale.order.line']
Part = self.env['fp.part.catalog']
Node = self.env['fusion.plating.process.node']
Thick = self.env['fp.coating.thickness']
if 'x_fc_part_catalog_id' not in SOL._fields \
or 'x_fc_thickness_id' not in SOL._fields \
or 'default_process_id' not in Part._fields:
self.skipTest('Sub 5 + recipe-on-part fields not present')
# Two distinct existing thicknesses. Creating them from scratch
# requires a coating_config → process_type chain that's too noisy
# for a unit test; reuse what's seeded.
thicknesses = Thick.search([], limit=2)
if len(thicknesses) < 2:
self.skipTest('need >= 2 fp.coating.thickness records seeded')
thick_a, thick_b = thicknesses[0], thicknesses[1]
# Any existing top-level recipe works — the test only needs both
# lines to resolve to the SAME recipe so they collide on the key.
recipe = Node.search([('parent_id', '=', False)], limit=1)
if not recipe:
self.skipTest('no fusion.plating.process.node records to anchor a recipe')
partner_for_part = self.env['res.partner'].create({'name': 'SplitPartner'})
part = Part.create({
'name': 'SplitPart', 'part_number': 'SP-1',
'partner_id': partner_for_part.id,
'default_process_id': recipe.id,
})
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'client_order_ref': 'TEST-PO-SPLIT',
})
SOL.create({
'order_id': so.id, 'product_id': self.product.id,
'product_uom_qty': 2.0, 'price_unit': 10.0,
'x_fc_part_catalog_id': part.id,
'x_fc_thickness_id': thick_a.id,
})
SOL.create({
'order_id': so.id, 'product_id': self.product.id,
'product_uom_qty': 1.0, 'price_unit': 10.0,
'x_fc_part_catalog_id': part.id,
'x_fc_thickness_id': thick_b.id,
})
so.action_confirm()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(
len(jobs), 2,
'Lines with different thicknesses must spawn separate fp.jobs '
'(both lines share recipe+part+coating, only thickness differs)',
)
# Each job's linked SO line should carry its own thickness
thicknesses_on_jobs = set()
for job in jobs:
for line in job.sale_order_line_ids:
if line.x_fc_thickness_id:
thicknesses_on_jobs.add(line.x_fc_thickness_id.id)
self.assertEqual(
thicknesses_on_jobs, {thick_a.id, thick_b.id},
'The two distinct thicknesses must each appear on its own job',
)
class TestJobLifecycleHooks(TransactionCase):
def setUp(self):