feat(jobs): order-level ship-readiness helpers

_fp_order_ship_state + _fp_mark_order_shipped enforce spec D4 ship-together:
the order ships only when every active job on it is awaiting_ship/done.
Shared by the tablet shipping endpoints and /fp/workspace/load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-29 09:14:36 -04:00
parent be7256ce4c
commit bb814a46ff
4 changed files with 142 additions and 1 deletions

View File

@@ -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.',

View File

@@ -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)
#

View File

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

View File

@@ -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()