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