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:
@@ -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.',
|
||||
|
||||
@@ -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)
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user