From 2645db40a2e3fa4b823c5fb36708883aab4fbbdc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 19 May 2026 22:15:46 -0400 Subject: [PATCH] fix(receiving): propagate qty_received to fp.job + drop duplicate carrier field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../models/fp_parent_numbered_mixin.py | 10 +- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/tests/__init__.py | 1 + .../tests/test_qty_received_propagation.py | 157 ++++++++++++++++++ .../fusion_plating_receiving/__manifest__.py | 2 +- .../migrations/19.0.3.19.0/post-migrate.py | 44 +++++ .../models/fp_receiving.py | 58 +++++++ .../tests/__init__.py | 1 + .../views/fp_receiving_views.xml | 17 +- 10 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 fusion_plating/fusion_plating_jobs/tests/test_qty_received_propagation.py create mode 100644 fusion_plating/fusion_plating_receiving/migrations/19.0.3.19.0/post-migrate.py diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index d5c1064a..2eef8637 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.20.1.0', + 'version': '19.0.20.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py index 0d6af25c..d3e5a75e 100644 --- a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py +++ b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py @@ -80,7 +80,15 @@ class FpParentNumberedMixin(models.AbstractModel): """ self.ensure_one() so = self._fp_parent_sale_order() - if not so or not so.x_fc_parent_number: + # Defensive: the parent-number column lives in fusion_plating_jobs; + # downstream modules (e.g. fusion_plating_receiving) inherit the + # mixin but don't depend on jobs, so so.x_fc_parent_number can + # raise AttributeError at test time. hasattr keeps the mixin safe + # in either install topology — falls through to the legacy + # sequence when the column isn't there. + if not so or 'x_fc_parent_number' not in so._fields: + return False + if not so.x_fc_parent_number: return False counter_field = self._fp_parent_counter_field() # Whitelist check — the field name is interpolated directly into diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 953faad7..1600b5e2 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 56d0ea0d..86247ad2 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/tests/test_qty_received_propagation.py b/fusion_plating/fusion_plating_jobs/tests/test_qty_received_propagation.py new file mode 100644 index 00000000..4c2ea41e --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_qty_received_propagation.py @@ -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) diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py index c32c57e1..032be7ce 100644 --- a/fusion_plating/fusion_plating_receiving/__manifest__.py +++ b/fusion_plating/fusion_plating_receiving/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Receiving & Inspection', - 'version': '19.0.3.18.0', + 'version': '19.0.3.19.0', 'category': 'Manufacturing/Plating', 'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.', 'description': """ diff --git a/fusion_plating/fusion_plating_receiving/migrations/19.0.3.19.0/post-migrate.py b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.19.0/post-migrate.py new file mode 100644 index 00000000..5625524a --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.19.0/post-migrate.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Backfill fp.job.qty_received from closed fp.receiving lines. +# +# Triggering issue (2026-05-20): WO-30043 — and any other job created +# before the new _update_job_qty_received hook shipped — has +# qty_received=0 even though its receiving is closed. The +# button_mark_done gate then blocks the operator with no obvious next +# step ("Quantity Received is blank — close the receiving record..."). +# Receiving IS closed. The propagation was missing. +# +# This migration walks every (closed / accepted / resolved) receiving, +# matches lines to their corresponding fp.job by (sale_order_id + +# part_catalog_id), and writes received_qty onto the job. Idempotent. + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + """Backfill fp.job.qty_received from already-closed receivings.""" + # Match by SO + part_catalog. Same logic as the new runtime hook. + cr.execute(""" + UPDATE fp_job j + SET qty_received = rl.received_qty + FROM fp_receiving r + JOIN fp_receiving_line rl ON rl.receiving_id = r.id + WHERE r.state IN ('closed', 'accepted', 'resolved') + AND r.sale_order_id = j.sale_order_id + AND rl.part_catalog_id = j.part_catalog_id + AND (j.qty_received IS NULL OR j.qty_received = 0) + AND rl.received_qty IS NOT NULL + AND rl.received_qty > 0 + """) + updated = cr.rowcount + if updated: + _logger.info( + 'fp.job.qty_received backfilled from receiving lines on ' + '%d job(s) — fixes WO-30043 and any sibling stuck jobs.', + updated, + ) diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index b9a143d6..c9295f3e 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -882,3 +882,61 @@ class FpReceiving(models.Model): rec.sale_order_id.x_fc_receiving_status = 'partial' elif rec.state == 'draft': rec.sale_order_id.x_fc_receiving_status = 'not_received' + # Propagate the per-part qty onto the matching fp.job records + # so the 2026-05-18 mark_done gate can see what was received. + rec._update_job_qty_received() + + def _update_job_qty_received(self): + """Push received qty from this receiving's lines onto fp.job. + + The 2026-05-18 cert-creation gate (fp.job.button_mark_done) + blocks completion until ``job.qty_received`` is non-zero, but + nothing was writing the field — receiving and job were two + disconnected records on the same SO. Operators completed + receiving, then hit "Quantity Received is blank" with no + obvious next step. + + Match rule: one fp.job ←→ one fp.receiving.line within the same + sale order, joined by ``part_catalog_id``. Multi-part SOs spawn + one job per line + one receiving line per part, so this gives a + 1-to-1 mapping. Single-line SOs work too because the only line + matches the only job. + + Best-effort: if no job matches (e.g. receiving without a + spawned job, or part-catalog mismatch), skip silently — the + receiving record itself still has the qty for audit. + """ + Job = self.env.get('fp.job') + if Job is None: + return # fusion_plating_jobs not installed + # Match criteria depend on fields owned by fusion_plating_jobs. + # Bail out cleanly if the registry doesn't have them — the same + # hook then becomes a no-op in any install topology that + # doesn't ship the jobs module (and in test scope where the + # field may not be materialised on fp.job yet). + if 'sale_order_id' not in Job._fields \ + or 'part_catalog_id' not in Job._fields \ + or 'qty_received' not in Job._fields: + return + for rec in self: + so = rec.sale_order_id + if not so: + continue + for line in rec.line_ids: + if not line.part_catalog_id: + continue + domain = [ + ('sale_order_id', '=', so.id), + ('part_catalog_id', '=', line.part_catalog_id.id), + ] + jobs = Job.sudo().search(domain) + if not jobs: + continue + # Only sync the integer qty, don't touch state. Skip writes + # when the value already matches so we don't churn chatter. + qty = int(line.received_qty or 0) + jobs_to_update = jobs.filtered( + lambda j: (j.qty_received or 0) != qty + ) + if jobs_to_update: + jobs_to_update.sudo().write({'qty_received': qty}) diff --git a/fusion_plating/fusion_plating_receiving/tests/__init__.py b/fusion_plating/fusion_plating_receiving/tests/__init__.py index ffad1d47..c49da65d 100644 --- a/fusion_plating/fusion_plating_receiving/tests/__init__.py +++ b/fusion_plating/fusion_plating_receiving/tests/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import test_carrier_fields + diff --git a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml index 312e78d3..584b9ea2 100644 --- a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml +++ b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml @@ -140,10 +140,19 @@ - + +