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 @@ - + +