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:
@@ -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.',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user