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)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.22.10',
|
'version': '19.0.8.23.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -412,15 +412,13 @@ class SaleOrder(models.Model):
|
|||||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Group by (recipe, part, coating). Lines that share ALL THREE
|
# Group by (recipe, part, coating, thickness, serial). Lines that
|
||||||
# collapse into one WO. Sharing only the recipe is not enough —
|
# share ALL FIVE collapse into one WO. Same compliance reasoning
|
||||||
# the WO header captures part_id and coating_config_id from
|
# as part_id + coating_id: bundling lines with different thicknesses
|
||||||
# first_line, and downstream the CoC prints the WO header's
|
# or different serials under one WO would carry the first line's
|
||||||
# part_number on the customer-facing cert. Bundling Part A +
|
# values onto the cert + sticker — silent mis-attestation. Sub 5
|
||||||
# Part B under one WO because they happen to share a recipe
|
# added thickness_id + serial_id; this extends the grouping logic
|
||||||
# would put Part A's number on a cert covering both, which is
|
# to honour them. No-recipe lines still get their own group each.
|
||||||
# a compliance bug (silent mis-attestation).
|
|
||||||
# No-recipe lines get their own group each.
|
|
||||||
groups = {}
|
groups = {}
|
||||||
unrecipe_idx = 0
|
unrecipe_idx = 0
|
||||||
for line in plating_lines:
|
for line in plating_lines:
|
||||||
@@ -433,8 +431,16 @@ class SaleOrder(models.Model):
|
|||||||
'x_fc_coating_config_id' in line._fields
|
'x_fc_coating_config_id' in line._fields
|
||||||
and line.x_fc_coating_config_id.id
|
and line.x_fc_coating_config_id.id
|
||||||
) or False
|
) 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:
|
if recipe:
|
||||||
key = (recipe.id, part_id, coating_id)
|
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
|
||||||
else:
|
else:
|
||||||
unrecipe_idx += 1
|
unrecipe_idx += 1
|
||||||
key = ('no_recipe', unrecipe_idx)
|
key = ('no_recipe', unrecipe_idx)
|
||||||
|
|||||||
@@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase):
|
|||||||
else:
|
else:
|
||||||
self.skipTest('x_fc_part_catalog_id field not present')
|
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):
|
class TestJobLifecycleHooks(TransactionCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user