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

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

View File

@@ -412,15 +412,13 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by (recipe, part, coating). Lines that share ALL THREE
# collapse into one WO. Sharing only the recipe is not enough —
# the WO header captures part_id and coating_config_id from
# first_line, and downstream the CoC prints the WO header's
# part_number on the customer-facing cert. Bundling Part A +
# Part B under one WO because they happen to share a recipe
# would put Part A's number on a cert covering both, which is
# a compliance bug (silent mis-attestation).
# No-recipe lines get their own group each.
# Group by (recipe, part, coating, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning
# as part_id + coating_id: bundling lines with different thicknesses
# or different serials under one WO would carry the first line's
# values onto the cert + sticker — silent mis-attestation. Sub 5
# added thickness_id + serial_id; this extends the grouping logic
# to honour them. No-recipe lines still get their own group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
@@ -433,8 +431,16 @@ class SaleOrder(models.Model):
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
) or False
thickness_id = (
'x_fc_thickness_id' in line._fields
and line.x_fc_thickness_id.id
) 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, coating_id)
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)

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):