fix(receiving): propagate qty_received to fp.job + drop duplicate carrier field

Bug surfaced on WO-30043 (2026-05-20): operator walked every step
including a fully closed receiving record, then hit
"Quantity Received is blank — close the receiving record for
SO SO-30043 before completing this job." Receiving WAS closed.

Root cause: the 2026-05-18 cert-creation gate
(fp.job.button_mark_done) blocks on job.qty_received but nothing
populated it. fp.receiving carried the qty on its line records,
fp.job stayed at 0 indefinitely. Two disconnected records on the
same SO.

Fix: when fp.receiving._update_so_receiving_status runs (i.e. on
every state transition — counted / staged / closed / accepted /
resolved), also mirror each line's received_qty onto the matching
fp.job by (sale_order_id + part_catalog_id). Single-part SOs map
1-to-1; multi-part SOs spawn one job per line so the same join
still works.

Two defensive guards in the hook:
- Skip silently when fusion_plating_jobs not installed
  (Job = env.get('fp.job') returns None).
- Skip silently when fp.job doesn't yet carry part_catalog_id /
  qty_received (test scope, unusual install topology).

Drive-by during cleanup:
- fp_parent_numbered_mixin._fp_assign_parent_name: guard
  so.x_fc_parent_number access with field-existence check. The
  column lives in fusion_plating_jobs; downstream modules that
  inherit the mixin (receiving) but don't depend on jobs were
  hitting AttributeError on every fp.receiving.create at test
  time. Falls through to the legacy sequence when the column
  isn't there.

- fp_receiving_views.xml: legacy carrier_name Char field rendered
  as a second carrier row labeled "Legacy Carrier" alongside the
  proper x_fc_carrier_id M2O — operators saw two carrier fields
  and got confused. Hide the legacy display (data stays in DB for
  audit; migration 19.0.3.10.0 already matched it to a real
  delivery.carrier).

Migration 19.0.3.19.0/post-migrate.py backfills qty_received from
closed receiving lines for any job stuck at 0 — fixes WO-30043
and two sibling jobs on entech.

Modules: fusion_plating 19.0.20.2.0, fusion_plating_receiving
19.0.3.19.0, fusion_plating_jobs 19.0.10.15.0.

All 19 tests green (TestCarrierFields 6, TestQtyReceivedPropagation 5
new, TestReceivingGate 8). Direct verification on entech: WO-30043
qty_received = 1, mark_done succeeds, delivery + cert auto-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-19 22:15:46 -04:00
parent 60eb2adef3
commit 2645db40a2
10 changed files with 286 additions and 8 deletions

View File

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

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade
from . import test_qty_received_propagation

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Closes the bug surfaced by WO-30043 on 2026-05-20: closing a receiving
# did not propagate received_qty to fp.job.qty_received, so the
# button_mark_done gate stayed red after the operator had completed
# every step of the workflow.
from odoo.tests.common import TransactionCase
class TestQtyReceivedPropagation(TransactionCase):
"""fp.receiving close → fp.job.qty_received mirrored per part."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
cls.product = cls.env['product.product'].create({'name': 'TestPart'})
cls.part_catalog = cls.env['fp.part.catalog'].create({
'name': 'Test Part Catalog',
'part_number': 'TPC-001',
'partner_id': cls.partner.id,
})
def _make_so_with_job(self):
so = self.env['sale.order'].create({'partner_id': self.partner.id})
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'part_catalog_id': self.part_catalog.id,
'qty': 5.0,
'sale_order_id': so.id,
})
return so, job
def _make_receiving(self, so, received_qty=5):
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'partner_id': self.partner.id,
'expected_qty': received_qty,
'received_qty': received_qty,
# box_count_in is required by action_mark_counted's gate.
'box_count_in': 1,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id,
'part_catalog_id': self.part_catalog.id,
'expected_qty': received_qty,
'received_qty': received_qty,
})
return recv
# ---- propagation on state transitions -----------------------------
def test_close_propagates_received_qty_to_job(self):
"""The bug: WO-30043 had qty_received=0 after receiving closed."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=5)
# Walk the state machine to closed.
recv.action_mark_counted()
recv.action_mark_staged()
recv.action_close()
# Reload — the hook fires inside _update_so_receiving_status.
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 5)
def test_counted_propagates_partial_qty(self):
"""Even a not-yet-closed receiving should mirror what's counted."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=3)
recv.action_mark_counted()
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 3)
def test_no_job_match_is_silent(self):
"""If the receiving line's part doesn't match any job, skip
without raising — common for receivings without spawned jobs."""
# Build a receiving with a part that no job uses.
other_part = self.env['fp.part.catalog'].create({
'name': 'Orphan',
'part_number': 'ORP-001',
'partner_id': self.partner.id,
})
so = self.env['sale.order'].create({'partner_id': self.partner.id})
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'partner_id': self.partner.id,
'expected_qty': 1,
'received_qty': 1,
'box_count_in': 1,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id,
'part_catalog_id': other_part.id,
'expected_qty': 1,
'received_qty': 1,
})
# Should NOT raise.
recv.action_mark_counted()
recv.action_mark_staged()
recv.action_close()
def test_multi_part_so_matches_per_part(self):
"""Two jobs on the same SO, each for a different part. Closing
a receiving with two lines must mirror to BOTH jobs by part."""
so = self.env['sale.order'].create({'partner_id': self.partner.id})
part_a = self.env['fp.part.catalog'].create({
'name': 'A', 'part_number': 'A-1', 'partner_id': self.partner.id,
})
part_b = self.env['fp.part.catalog'].create({
'name': 'B', 'part_number': 'B-1', 'partner_id': self.partner.id,
})
job_a = self.env['fp.job'].create({
'partner_id': self.partner.id, 'product_id': self.product.id,
'part_catalog_id': part_a.id, 'qty': 3.0,
'sale_order_id': so.id,
})
job_b = self.env['fp.job'].create({
'partner_id': self.partner.id, 'product_id': self.product.id,
'part_catalog_id': part_b.id, 'qty': 7.0,
'sale_order_id': so.id,
})
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'partner_id': self.partner.id,
'expected_qty': 10,
'received_qty': 10,
'box_count_in': 2,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id, 'part_catalog_id': part_a.id,
'expected_qty': 3, 'received_qty': 3,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id, 'part_catalog_id': part_b.id,
'expected_qty': 7, 'received_qty': 7,
})
recv.action_mark_counted()
recv.action_mark_staged()
recv.action_close()
job_a.invalidate_recordset(['qty_received'])
job_b.invalidate_recordset(['qty_received'])
self.assertEqual(job_a.qty_received, 3)
self.assertEqual(job_b.qty_received, 7)
def test_idempotent_under_repeated_writes(self):
"""Hook is safe to call multiple times — value just settles."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=5)
recv.action_mark_counted()
# Manually nudge the same state transition again (legitimate
# in real life: manager re-opens then re-closes).
recv._update_so_receiving_status()
recv._update_so_receiving_status()
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 5)