diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 6216518b..dbb31df6 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.11.4.0', + 'version': '19.0.11.5.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/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 71e834a3..b0e5ede5 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -2184,6 +2184,72 @@ class FpJob(models.Model): ) % self.env.user.name) return True + # ------------------------------------------------------------------ + # Order-level ship readiness (tablet receiving+shipping, 2026-05-29) + # + # An order can split into several jobs (one per part/recipe) but has + # ONE outbound shipment (the physical boxes). Spec D4 = "ship + # together": the order can ship only when EVERY active job on it is + # awaiting_ship or done, with at least one awaiting_ship to act on. + # Both the tablet endpoints and /fp/workspace/load read this. + # ------------------------------------------------------------------ + def _fp_order_ship_state(self): + """Return ship-readiness for the whole order this job belongs to. + + {ready, not_ready:[{wo_name, state_label}], awaiting_ship_jobs, + order_jobs, order_receiving} + Runs in the caller's env: call on a sudo job for display, on a + user job (the tablet tech) when you want real write attribution. + """ + self.ensure_one() + empty_job = self.browse() + empty_rcv = (self.env['fp.receiving'].browse() + if 'fp.receiving' in self.env else empty_job) + so = self.sale_order_id + if not so: + return {'ready': False, 'not_ready': [], + 'awaiting_ship_jobs': empty_job, 'order_jobs': self, + 'order_receiving': empty_rcv} + jobs = self.search([ + ('sale_order_id', '=', so.id), + ('state', '!=', 'cancelled'), + ]) + not_ready = jobs.filtered(lambda j: j.state not in ('awaiting_ship', 'done')) + awaiting = jobs.filtered(lambda j: j.state == 'awaiting_ship') + ready = bool(jobs) and not not_ready and bool(awaiting) + state_sel = dict(self._fields['state'].selection) + rcv = empty_rcv + if 'fp.receiving' in self.env: + rcv = self.env['fp.receiving'].search( + [('sale_order_id', '=', so.id)], order='id desc', limit=1) + return { + 'ready': ready, + 'not_ready': [{'wo_name': j.display_wo_name, + 'state_label': state_sel.get(j.state, j.state)} + for j in not_ready], + 'awaiting_ship_jobs': awaiting, + 'order_jobs': jobs, + 'order_receiving': rcv, + } + + def _fp_mark_order_shipped(self): + """Mark every awaiting_ship job on the order as shipped (done). + + Gated on _fp_order_ship_state['ready']; raises UserError naming + the unfinished jobs otherwise. Returns the recordset marked. + """ + self.ensure_one() + info = self._fp_order_ship_state() + if not info['ready']: + names = ', '.join(n['wo_name'] for n in info['not_ready']) or _('none') + raise UserError(_( + 'Cannot ship yet — these jobs on the order are not ' + 'finished: %s' + ) % names) + awaiting = info['awaiting_ship_jobs'] + awaiting.button_mark_shipped() + return awaiting + # ------------------------------------------------------------------ # Notifications dispatch (Phase 4) # diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index b1987d68..970f2ee2 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_active_step_id from . import test_autopause_cron from . import test_post_shop_states from . import test_recipe_cert_suppression +from . import test_order_ship_state diff --git a/fusion_plating/fusion_plating_jobs/tests/test_order_ship_state.py b/fusion_plating/fusion_plating_jobs/tests/test_order_ship_state.py new file mode 100644 index 00000000..0a16afc5 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_order_ship_state.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Order-level ship-readiness gate (spec D4 — ship together). + +Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md +""" +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError + + +class TestOrderShipState(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'}) + cls.product = cls.env['product.product'].create({'name': 'ShipWidget'}) + + def _make_so(self): + return self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'order_line': [(0, 0, { + 'product_id': self.product.id, + 'product_uom_qty': 1, + })], + }) + + def _make_job(self, so, state): + return self.env['fp.job'].create({ + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1.0, + 'state': state, + 'sale_order_id': so.id, + }) + + def test_ready_single_awaiting_ship_job(self): + so = self._make_so() + job = self._make_job(so, 'awaiting_ship') + info = job._fp_order_ship_state() + self.assertTrue(info['ready']) + self.assertEqual(info['awaiting_ship_jobs'], job) + self.assertEqual(info['not_ready'], []) + + def test_not_ready_with_unfinished_sibling(self): + so = self._make_so() + j1 = self._make_job(so, 'awaiting_ship') + self._make_job(so, 'in_progress') + info = j1._fp_order_ship_state() + self.assertFalse(info['ready']) + self.assertEqual(len(info['not_ready']), 1) + + def test_done_sibling_does_not_block(self): + so = self._make_so() + j1 = self._make_job(so, 'awaiting_ship') + self._make_job(so, 'done') + info = j1._fp_order_ship_state() + self.assertTrue(info['ready']) + + def test_mark_order_shipped_marks_all_awaiting(self): + so = self._make_so() + j1 = self._make_job(so, 'awaiting_ship') + j2 = self._make_job(so, 'awaiting_ship') + j1._fp_mark_order_shipped() + self.assertEqual(j1.state, 'done') + self.assertEqual(j2.state, 'done') + + def test_mark_order_shipped_blocks_on_unfinished_sibling(self): + so = self._make_so() + j1 = self._make_job(so, 'awaiting_ship') + self._make_job(so, 'in_progress') + with self.assertRaises(UserError): + j1._fp_mark_order_shipped()