Compare commits
5 Commits
b98ee8a6fb
...
f75e082e67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f75e082e67 | ||
|
|
f1273798cd | ||
|
|
bb814a46ff | ||
|
|
be7256ce4c | ||
|
|
d37f10f1c3 |
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.11.4.0',
|
'version': '19.0.11.5.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -2184,6 +2184,72 @@ class FpJob(models.Model):
|
|||||||
) % self.env.user.name)
|
) % self.env.user.name)
|
||||||
return True
|
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)
|
# Notifications dispatch (Phase 4)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ from . import test_active_step_id
|
|||||||
from . import test_autopause_cron
|
from . import test_autopause_cron
|
||||||
from . import test_post_shop_states
|
from . import test_post_shop_states
|
||||||
from . import test_recipe_cert_suppression
|
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()
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Logistics',
|
'name': 'Fusion Plating — Logistics',
|
||||||
'version': '19.0.3.11.1',
|
'version': '19.0.3.11.2',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': (
|
'summary': (
|
||||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ access_fp_vehicle_manager,fp.vehicle.manager,model_fusion_plating_vehicle,fusion
|
|||||||
access_fp_pickup_request_operator,fp.pickup.request.operator,model_fusion_plating_pickup_request,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_pickup_request_operator,fp.pickup.request.operator,model_fusion_plating_pickup_request,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_pickup_request_supervisor,fp.pickup.request.supervisor,model_fusion_plating_pickup_request,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_pickup_request_supervisor,fp.pickup.request.supervisor,model_fusion_plating_pickup_request,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_pickup_request_manager,fp.pickup.request.manager,model_fusion_plating_pickup_request,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_pickup_request_manager,fp.pickup.request.manager,model_fusion_plating_pickup_request,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_delivery_operator,fp.delivery.operator,model_fusion_plating_delivery,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_delivery_operator,fp.delivery.operator,model_fusion_plating_delivery,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_delivery_supervisor,fp.delivery.supervisor,model_fusion_plating_delivery,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_delivery_supervisor,fp.delivery.supervisor,model_fusion_plating_delivery,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_delivery_manager,fp.delivery.manager,model_fusion_plating_delivery,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_delivery_manager,fp.delivery.manager,model_fusion_plating_delivery,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_route_operator,fp.route.operator,model_fusion_plating_route,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_route_operator,fp.route.operator,model_fusion_plating_route,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
@@ -14,9 +14,9 @@ access_fp_route_manager,fp.route.manager,model_fusion_plating_route,fusion_plati
|
|||||||
access_fp_route_stop_operator,fp.route.stop.operator,model_fusion_plating_route_stop,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_route_stop_operator,fp.route.stop.operator,model_fusion_plating_route_stop,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_route_stop_supervisor,fp.route.stop.supervisor,model_fusion_plating_route_stop,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_route_stop_supervisor,fp.route.stop.supervisor,model_fusion_plating_route_stop,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_route_stop_manager,fp.route.stop.manager,model_fusion_plating_route_stop,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_route_stop_manager,fp.route.stop.manager,model_fusion_plating_route_stop,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_chain_of_custody_operator,fp.chain.of.custody.operator,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_chain_of_custody_operator,fp.chain.of.custody.operator,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_chain_of_custody_supervisor,fp.chain.of.custody.supervisor,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_chain_of_custody_supervisor,fp.chain.of.custody.supervisor,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_chain_of_custody_manager,fp.chain.of.custody.manager,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_chain_of_custody_manager,fp.chain.of.custody.manager,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_proof_of_delivery_operator,fp.proof.of.delivery.operator,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_proof_of_delivery_operator,fp.proof.of.delivery.operator,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_proof_of_delivery_supervisor,fp.proof.of.delivery.supervisor,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_proof_of_delivery_supervisor,fp.proof.of.delivery.supervisor,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_proof_of_delivery_manager,fp.proof.of.delivery.manager,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_proof_of_delivery_manager,fp.proof.of.delivery.manager,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_delivery_shipping_fields
|
from . import test_delivery_shipping_fields
|
||||||
|
from . import test_technician_delivery_acl
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Technician can create/edit delivery-completion records (spec D5).
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
|
||||||
|
"""
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
|
||||||
|
|
||||||
|
class TestTechnicianDeliveryAcl(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.partner = cls.env['res.partner'].create({'name': 'DelCust'})
|
||||||
|
cls.tech = cls.env['res.users'].create({
|
||||||
|
'name': 'Tech Del',
|
||||||
|
'login': 'tech_acl_del',
|
||||||
|
'group_ids': [(6, 0, [
|
||||||
|
cls.env.ref('fusion_plating.group_fp_technician').id,
|
||||||
|
])],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_technician_can_create_delivery(self):
|
||||||
|
try:
|
||||||
|
delivery = self.env['fusion.plating.delivery'].with_user(
|
||||||
|
self.tech,
|
||||||
|
).create({'partner_id': self.partner.id})
|
||||||
|
except AccessError as e:
|
||||||
|
self.fail("Technician blocked creating delivery: %s" % e)
|
||||||
|
self.assertTrue(delivery.id)
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Receiving & Inspection',
|
'name': 'Fusion Plating — Receiving & Inspection',
|
||||||
'version': '19.0.3.28.2',
|
'version': '19.0.3.28.3',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -1358,17 +1358,21 @@ class FpReceiving(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.sale_order_id:
|
if not rec.sale_order_id:
|
||||||
continue
|
continue
|
||||||
|
# Internal denormalized status field — elevate the write so a
|
||||||
|
# non-privileged technician (tablet receiving) isn't blocked by
|
||||||
|
# sale.order ACL inside action_mark_counted / action_close.
|
||||||
|
so = rec.sale_order_id.sudo()
|
||||||
if rec.state == 'closed':
|
if rec.state == 'closed':
|
||||||
rec.sale_order_id.x_fc_receiving_status = 'received'
|
so.x_fc_receiving_status = 'received'
|
||||||
elif rec.state in ('counted', 'staged'):
|
elif rec.state in ('counted', 'staged'):
|
||||||
rec.sale_order_id.x_fc_receiving_status = 'partial'
|
so.x_fc_receiving_status = 'partial'
|
||||||
# Legacy states preserved.
|
# Legacy states preserved.
|
||||||
elif rec.state in ('accepted', 'resolved'):
|
elif rec.state in ('accepted', 'resolved'):
|
||||||
rec.sale_order_id.x_fc_receiving_status = 'received'
|
so.x_fc_receiving_status = 'received'
|
||||||
elif rec.state in ('discrepancy', 'inspecting'):
|
elif rec.state in ('discrepancy', 'inspecting'):
|
||||||
rec.sale_order_id.x_fc_receiving_status = 'partial'
|
so.x_fc_receiving_status = 'partial'
|
||||||
elif rec.state == 'draft':
|
elif rec.state == 'draft':
|
||||||
rec.sale_order_id.x_fc_receiving_status = 'not_received'
|
so.x_fc_receiving_status = 'not_received'
|
||||||
# Propagate the per-part qty onto the matching fp.job records
|
# Propagate the per-part qty onto the matching fp.job records
|
||||||
# so the 2026-05-18 mark_done gate can see what was received.
|
# so the 2026-05-18 mark_done gate can see what was received.
|
||||||
rec._update_job_qty_received()
|
rec._update_job_qty_received()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_fp_receiving_operator,fp.receiving.operator,model_fp_receiving,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_receiving_operator,fp.receiving.operator,model_fp_receiving,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_receiving_receiver,fp.receiving.receiver,model_fp_receiving,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_receiving_receiver,fp.receiving.receiver,model_fp_receiving,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_receiving_manager,fp.receiving.manager,model_fp_receiving,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_receiving_manager,fp.receiving.manager,model_fp_receiving,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_receiving_line_operator,fp.receiving.line.operator,model_fp_receiving_line,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_receiving_line_operator,fp.receiving.line.operator,model_fp_receiving_line,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_receiving_line_receiver,fp.receiving.line.receiver,model_fp_receiving_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_receiving_line_receiver,fp.receiving.line.receiver,model_fp_receiving_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_receiving_line_manager,fp.receiving.line.manager,model_fp_receiving_line,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_receiving_line_manager,fp.receiving.line.manager,model_fp_receiving_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_receiving_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fp_technician,1,0,0,0
|
access_fp_receiving_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_receiving_damage_receiver,fp.receiving.damage.receiver,model_fp_receiving_damage,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
access_fp_receiving_damage_receiver,fp.receiving.damage.receiver,model_fp_receiving_damage,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_receiving_damage_manager,fp.receiving.damage.manager,model_fp_receiving_damage,fusion_plating.group_fp_manager,1,1,1,1
|
access_fp_receiving_damage_manager,fp.receiving.damage.manager,model_fp_receiving_damage,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_racking_inspection_operator,fp.racking.inspection.operator,model_fp_racking_inspection,fusion_plating.group_fp_technician,1,1,1,0
|
access_fp_racking_inspection_operator,fp.racking.inspection.operator,model_fp_racking_inspection,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
|
|||||||
|
@@ -1,3 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_carrier_fields
|
from . import test_carrier_fields
|
||||||
|
from . import test_technician_receiving_acl
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Technician can receive (count + close) from the tablet.
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
|
||||||
|
"""
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
|
||||||
|
|
||||||
|
class TestTechnicianReceivingAcl(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.partner = cls.env['res.partner'].create({'name': 'AclCust'})
|
||||||
|
cls.product = cls.env['product.product'].create({'name': 'AclWidget'})
|
||||||
|
cls.so = cls.env['sale.order'].create({
|
||||||
|
'partner_id': cls.partner.id,
|
||||||
|
'order_line': [(0, 0, {
|
||||||
|
'product_id': cls.product.id,
|
||||||
|
'product_uom_qty': 1,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
cls.tech = cls.env['res.users'].create({
|
||||||
|
'name': 'Tech ACL',
|
||||||
|
'login': 'tech_acl_recv',
|
||||||
|
# Odoo 19: group_ids (NOT groups_id) — CLAUDE.md rule 13c.
|
||||||
|
'group_ids': [(6, 0, [
|
||||||
|
cls.env.ref('fusion_plating.group_fp_technician').id,
|
||||||
|
])],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_technician_can_count_and_close_receiving(self):
|
||||||
|
# Created as admin; the technician must be able to count + close.
|
||||||
|
rec = self.env['fp.receiving'].create({
|
||||||
|
'sale_order_id': self.so.id,
|
||||||
|
'box_count_in': 3,
|
||||||
|
})
|
||||||
|
rec_as_tech = rec.with_user(self.tech)
|
||||||
|
try:
|
||||||
|
rec_as_tech.action_mark_counted()
|
||||||
|
except AccessError as e:
|
||||||
|
self.fail("Technician blocked marking counted: %s" % e)
|
||||||
|
self.assertEqual(rec.state, 'counted')
|
||||||
|
rec_as_tech.action_close()
|
||||||
|
self.assertEqual(rec.state, 'closed')
|
||||||
|
# The SO status write inside _update_so_receiving_status must have
|
||||||
|
# gone through (it is sudo'd) — proves no AccessError on sale.order.
|
||||||
|
self.assertEqual(self.so.x_fc_receiving_status, 'received')
|
||||||
|
|
||||||
|
def test_technician_can_create_damage(self):
|
||||||
|
rec = self.env['fp.receiving'].create({'sale_order_id': self.so.id})
|
||||||
|
dmg = self.env['fp.receiving.damage'].with_user(self.tech).create({
|
||||||
|
'receiving_id': rec.id,
|
||||||
|
'description': 'scratch on flange',
|
||||||
|
})
|
||||||
|
self.assertTrue(dmg.id)
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.36.0.3',
|
'version': '19.0.36.1.1',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -189,6 +189,35 @@ class FpWorkspaceController(http.Controller):
|
|||||||
} for dmg in rec.damage_ids],
|
} for dmg in rec.damage_ids],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---- Shipping (awaiting_ship jobs) ------------------------------
|
||||||
|
# Spec 2026-05-29. Surfaces the order-level ship-readiness gate +
|
||||||
|
# the carrier/service/weight inputs + any label already on the
|
||||||
|
# order's single outbound shipment. job is already sudo() here, so
|
||||||
|
# the helper's reads are fine for display. ship_rec (not rec) to
|
||||||
|
# avoid shadowing the receivings loop variable above.
|
||||||
|
shipping_payload = None
|
||||||
|
if job.state == 'awaiting_ship':
|
||||||
|
info = job._fp_order_ship_state()
|
||||||
|
ship_rec = info['order_receiving']
|
||||||
|
shipment = ship_rec.x_fc_outbound_shipment_id if ship_rec else False
|
||||||
|
carriers = env['delivery.carrier'].sudo().search([], limit=50)
|
||||||
|
shipping_payload = {
|
||||||
|
'ready': info['ready'],
|
||||||
|
'not_ready': info['not_ready'],
|
||||||
|
'receiving_id': ship_rec.id if ship_rec else False,
|
||||||
|
'carrier_id': (ship_rec.x_fc_carrier_id.id
|
||||||
|
if ship_rec and ship_rec.x_fc_carrier_id else False),
|
||||||
|
'carrier_options': [{'id': c.id, 'name': c.name} for c in carriers],
|
||||||
|
'service_type': (ship_rec.x_fc_outbound_service_type or '') if ship_rec else '',
|
||||||
|
'service_options': env['fp.receiving']._fp_get_service_type_selection(),
|
||||||
|
'weight': (ship_rec.x_fc_weight or 0.0) if ship_rec else 0.0,
|
||||||
|
'has_label': bool(ship_rec.x_fc_has_label) if ship_rec else False,
|
||||||
|
'tracking_number': (shipment.tracking_number or '') if shipment else '',
|
||||||
|
'label_attachment_id': (shipment.label_attachment_id.id
|
||||||
|
if shipment and shipment.label_attachment_id
|
||||||
|
else False),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'job': {
|
'job': {
|
||||||
@@ -246,6 +275,7 @@ class FpWorkspaceController(http.Controller):
|
|||||||
],
|
],
|
||||||
'required_certs': required_certs,
|
'required_certs': required_certs,
|
||||||
'receivings': receivings_payload,
|
'receivings': receivings_payload,
|
||||||
|
'shipping': shipping_payload,
|
||||||
# 2026-05-24 — is_manager surfaces to the JS so it can offer
|
# 2026-05-24 — is_manager surfaces to the JS so it can offer
|
||||||
# the manager-bypass affordance (e.g. on the required-inputs
|
# the manager-bypass affordance (e.g. on the required-inputs
|
||||||
# gate dialog). Server-side endpoints re-check the group
|
# gate dialog). Server-side endpoints re-check the group
|
||||||
@@ -622,3 +652,78 @@ class FpWorkspaceController(http.Controller):
|
|||||||
_logger.exception("workspace/damage_delete failed")
|
_logger.exception("workspace/damage_delete failed")
|
||||||
return {'ok': False, 'error': str(exc)}
|
return {'ok': False, 'error': str(exc)}
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Shipping — generate outbound label + mark shipped (2026-05-29)
|
||||||
|
# ======================================================================
|
||||||
|
# Spec D3/D4. mark_shipped runs as the technician (real chatter
|
||||||
|
# attribution; the method has no group gate). generate_label sudo's
|
||||||
|
# the carrier/stock/shipment machinery — technicians intentionally
|
||||||
|
# don't hold those ACLs. Both re-check the order-level "ship together"
|
||||||
|
# gate server-side via fp.job._fp_order_ship_state.
|
||||||
|
|
||||||
|
@http.route('/fp/workspace/mark_shipped', type='jsonrpc', auth='user')
|
||||||
|
def mark_shipped(self, job_id):
|
||||||
|
env = request.env
|
||||||
|
job = env['fp.job'].browse(int(job_id)) # as the tech
|
||||||
|
if not job.exists():
|
||||||
|
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||||
|
try:
|
||||||
|
shipped = job._fp_mark_order_shipped()
|
||||||
|
except UserError as e:
|
||||||
|
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("workspace/mark_shipped failed")
|
||||||
|
return {'ok': False, 'error': str(exc)}
|
||||||
|
return {'ok': True, 'shipped': shipped.mapped('display_wo_name')}
|
||||||
|
|
||||||
|
@http.route('/fp/workspace/generate_label', type='jsonrpc', auth='user')
|
||||||
|
def generate_label(self, job_id, weight, service_type='', carrier_id=False):
|
||||||
|
env = request.env
|
||||||
|
job = env['fp.job'].browse(int(job_id))
|
||||||
|
if not job.exists():
|
||||||
|
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||||
|
info = job._fp_order_ship_state()
|
||||||
|
if not info['ready']:
|
||||||
|
names = ', '.join(n['wo_name'] for n in info['not_ready']) or 'none'
|
||||||
|
return {'ok': False,
|
||||||
|
'error': 'Not all jobs on the order are finished: %s' % names,
|
||||||
|
'not_ready': info['not_ready']}
|
||||||
|
rec = info['order_receiving']
|
||||||
|
if not rec:
|
||||||
|
return {'ok': False,
|
||||||
|
'error': 'No receiving record on this order to ship from.'}
|
||||||
|
try:
|
||||||
|
w = float(weight or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
w = 0.0
|
||||||
|
if w <= 0:
|
||||||
|
return {'ok': False,
|
||||||
|
'error': 'Enter a non-zero weight before generating the label.'}
|
||||||
|
# sudo: carrier write triggers delivery.carrier read; the actual
|
||||||
|
# generate synthesizes a stock.picking + fusion.shipment + label
|
||||||
|
# attachment — all privileged. The carrier choice came from the
|
||||||
|
# sudo'd options list in /load, so this is safe.
|
||||||
|
rec = rec.sudo()
|
||||||
|
vals = {'x_fc_weight': w,
|
||||||
|
'x_fc_outbound_service_type': service_type or False}
|
||||||
|
if carrier_id:
|
||||||
|
vals['x_fc_carrier_id'] = int(carrier_id)
|
||||||
|
try:
|
||||||
|
rec.write(vals)
|
||||||
|
rec._fp_actually_generate_outbound_label()
|
||||||
|
except UserError as e:
|
||||||
|
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("workspace/generate_label failed")
|
||||||
|
return {'ok': False, 'error': 'Label generation failed: %s' % exc}
|
||||||
|
# The model returns a manual-wizard action (no raise) on API
|
||||||
|
# failure — so success is "a label landed on the shipment".
|
||||||
|
shipment = rec.x_fc_outbound_shipment_id
|
||||||
|
if not (shipment and shipment.label_attachment_id):
|
||||||
|
return {'ok': False, 'error': (
|
||||||
|
'The carrier did not return a label. Ask the office to '
|
||||||
|
'generate it from the receiving record.')}
|
||||||
|
return {'ok': True,
|
||||||
|
'tracking_number': shipment.tracking_number or '',
|
||||||
|
'label_attachment_id': shipment.label_attachment_id.id}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class FpJobWorkspace extends Component {
|
|||||||
// the active-step timer re-renders without an RPC. The template
|
// the active-step timer re-renders without an RPC. The template
|
||||||
// reads tickNow and re-runs formatActiveStepElapsed each second.
|
// reads tickNow and re-runs formatActiveStepElapsed each second.
|
||||||
tickNow: Date.now(),
|
tickNow: Date.now(),
|
||||||
|
// Shipping panel input buffer (carrier/service/weight). Seeded
|
||||||
|
// lazily from the payload defaults inside the handlers below.
|
||||||
|
shipForm: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -611,6 +614,85 @@ export class FpJobWorkspace extends Component {
|
|||||||
this.notification.add(err.message, { type: "danger" });
|
this.notification.add(err.message, { type: "danger" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Shipping handlers (tablet receiving+shipping 2026-05-29) ----------
|
||||||
|
// All coercion is JS-side (CLAUDE.md Rule 20 — templates only expose Math).
|
||||||
|
onShipInput(field, ev) {
|
||||||
|
const raw = ev.target.value;
|
||||||
|
this.state.shipForm[field] =
|
||||||
|
field === "weight" ? (parseFloat(raw) || 0) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onGenerateLabel() {
|
||||||
|
const sh = (this.state.data && this.state.data.shipping) || {};
|
||||||
|
const f = this.state.shipForm || {};
|
||||||
|
const weight = f.weight != null ? f.weight : (sh.weight || 0);
|
||||||
|
const serviceType = f.service_type != null ? f.service_type : (sh.service_type || "");
|
||||||
|
const carrierRaw = f.carrier_id != null ? f.carrier_id : (sh.carrier_id || false);
|
||||||
|
const carrierId = carrierRaw ? parseInt(carrierRaw, 10) : false;
|
||||||
|
if (!weight || weight <= 0) {
|
||||||
|
this.notification.add("Enter a non-zero weight before generating the label.", { type: "danger" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!carrierId) {
|
||||||
|
this.notification.add("Pick an outbound carrier first.", { type: "danger" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/workspace/generate_label", {
|
||||||
|
job_id: this.state.jobId,
|
||||||
|
weight: weight,
|
||||||
|
service_type: serviceType,
|
||||||
|
carrier_id: carrierId,
|
||||||
|
});
|
||||||
|
if (res && res.ok) {
|
||||||
|
this.notification.add(
|
||||||
|
"Label generated — tracking " + (res.tracking_number || "n/a"),
|
||||||
|
{ type: "success" },
|
||||||
|
);
|
||||||
|
await this.refresh();
|
||||||
|
} else {
|
||||||
|
this.notification.add((res && res.error) || "Label generation failed", { type: "danger" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message || String(err), { type: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onViewLabel() {
|
||||||
|
const sh = (this.state.data && this.state.data.shipping) || {};
|
||||||
|
if (!sh.label_attachment_id) return;
|
||||||
|
try {
|
||||||
|
// Route through fusion_pdf_preview (CLAUDE.md PDF-preview rule).
|
||||||
|
const action = await rpc("/web/dataset/call_kw", {
|
||||||
|
model: "ir.attachment",
|
||||||
|
method: "action_fusion_preview",
|
||||||
|
args: [[sh.label_attachment_id]],
|
||||||
|
kwargs: { title: "Shipping Label" },
|
||||||
|
});
|
||||||
|
if (action) await this.action.doAction(action);
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback: plain content open if the preview helper is absent.
|
||||||
|
window.open("/web/content/" + sh.label_attachment_id, "_blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMarkShipped() {
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/workspace/mark_shipped", { job_id: this.state.jobId });
|
||||||
|
if (res && res.ok) {
|
||||||
|
this.notification.add(
|
||||||
|
"Marked shipped: " + ((res.shipped || []).join(", ") || "done"),
|
||||||
|
{ type: "success" },
|
||||||
|
);
|
||||||
|
await this.refresh();
|
||||||
|
} else {
|
||||||
|
this.notification.add((res && res.error) || "Mark shipped failed", { type: "danger" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message || String(err), { type: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
|
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
|
||||||
|
|||||||
@@ -385,6 +385,69 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
|
||||||
|
// =============================================================================
|
||||||
|
// Reuses the workspace card tokens ($_ws-card-hex/$_ws-border-hex are
|
||||||
|
// already dark-aware via the @if branch near the top of this file), so the
|
||||||
|
// surface adapts to dark mode automatically. Only the blue left-accent is a
|
||||||
|
// raw hex, and it gets its own dark override below (CLAUDE.md dark-mode rule).
|
||||||
|
|
||||||
|
.o_fp_ws_ship {
|
||||||
|
background: $_ws-card-hex;
|
||||||
|
border: 1px solid $_ws-border-hex;
|
||||||
|
border-left: 4px solid #0071e3;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
|
||||||
|
.o_fp_ws_ship_head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.o_fp_ws_ship_icon { font-size: 1.2rem; }
|
||||||
|
.o_fp_ws_ship_title { font-size: 1rem; }
|
||||||
|
|
||||||
|
.o_fp_ws_ship_waiting {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_ws_ship_fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 150px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.form-select, .form-control { margin-top: 0.2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_ws_ship_actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
.o_fp_ws_ship { border-left-color: #6cb6ff; }
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// PRE-RECIPE RECEIVING CARD (Spec C1+C2 2026-05-24)
|
// PRE-RECIPE RECEIVING CARD (Spec C1+C2 2026-05-24)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -213,6 +213,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
|
<!-- SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
|
||||||
|
Shown when the job is awaiting_ship. Actions are
|
||||||
|
enabled only when ALL of the order's jobs are
|
||||||
|
awaiting_ship (spec D4 ship-together). -->
|
||||||
|
<div t-if="state.data.shipping" class="o_fp_ws_ship">
|
||||||
|
<div class="o_fp_ws_ship_head">
|
||||||
|
<span class="o_fp_ws_ship_icon">🚚</span>
|
||||||
|
<span class="o_fp_ws_ship_title">Ship Order</span>
|
||||||
|
<span t-if="state.data.shipping.has_label"
|
||||||
|
class="o_fp_chip o_fp_chip_info">
|
||||||
|
Label ready · <t t-esc="state.data.shipping.tracking_number or 'no tracking'"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not-ready: read-only, list the blocking jobs -->
|
||||||
|
<div t-if="!state.data.shipping.ready"
|
||||||
|
class="o_fp_ws_ship_waiting">
|
||||||
|
<i class="fa fa-hourglass-half"/> Waiting on:
|
||||||
|
<t t-foreach="state.data.shipping.not_ready"
|
||||||
|
t-as="nr" t-key="nr.wo_name">
|
||||||
|
<span class="o_fp_chip o_fp_chip_warning">
|
||||||
|
<t t-esc="nr.wo_name"/> — <t t-esc="nr.state_label"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ready: inputs + actions -->
|
||||||
|
<t t-if="state.data.shipping.ready">
|
||||||
|
<div class="o_fp_ws_ship_fields">
|
||||||
|
<label>Carrier
|
||||||
|
<select class="form-select"
|
||||||
|
t-on-change="(ev) => this.onShipInput('carrier_id', ev)">
|
||||||
|
<option value="">— pick carrier —</option>
|
||||||
|
<t t-foreach="state.data.shipping.carrier_options"
|
||||||
|
t-as="c" t-key="c.id">
|
||||||
|
<option t-att-value="c.id"
|
||||||
|
t-att-selected="c.id === state.data.shipping.carrier_id">
|
||||||
|
<t t-esc="c.name"/>
|
||||||
|
</option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Service
|
||||||
|
<select class="form-select"
|
||||||
|
t-on-change="(ev) => this.onShipInput('service_type', ev)">
|
||||||
|
<option value="">Carrier default</option>
|
||||||
|
<t t-foreach="state.data.shipping.service_options"
|
||||||
|
t-as="s" t-key="s[0]">
|
||||||
|
<option t-att-value="s[0]"
|
||||||
|
t-att-selected="s[0] === state.data.shipping.service_type">
|
||||||
|
<t t-esc="s[1]"/>
|
||||||
|
</option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Weight
|
||||||
|
<input type="number" step="0.001" inputmode="decimal"
|
||||||
|
class="form-control"
|
||||||
|
t-att-value="state.data.shipping.weight || ''"
|
||||||
|
t-on-blur="(ev) => this.onShipInput('weight', ev)"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_ws_ship_actions">
|
||||||
|
<button class="btn btn-primary me-2" t-on-click="onGenerateLabel">
|
||||||
|
<i class="fa fa-tag"/> Generate Label
|
||||||
|
</button>
|
||||||
|
<button t-if="state.data.shipping.has_label"
|
||||||
|
class="btn btn-light me-2" t-on-click="onViewLabel">
|
||||||
|
<i class="fa fa-print"/> View / Print Label
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" t-on-click="onMarkShipped">
|
||||||
|
<i class="fa fa-check-circle"/> Mark Shipped
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
|
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
|
||||||
<i class="fa fa-exclamation-circle fa-2x"/>
|
<i class="fa fa-exclamation-circle fa-2x"/>
|
||||||
<div>Recipe not generated for this WO.</div>
|
<div>Recipe not generated for this WO.</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user