Bite-sized TDD plan across receiving ACL+sudo, delivery ACL, fp.job ship-readiness helpers, shipping endpoints, and the workspace shipping panel. Also patches the spec to record the sale.order status-write sudo fix found during planning. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
45 KiB
Technician Receiving + Shipping from the Workstation — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let Technicians receive a confirmed order and ship a finished order directly from the fp_job_workspace tablet surface.
Architecture: Receiving is already built on the tablet (panel + endpoints in fusion_plating_shopfloor); it only needs an ACL flip on the three fp.receiving* models plus one sudo() on the internal sale-order status write inside _update_so_receiving_status (the write that a read-only technician would otherwise be blocked on). Shipping is net-new: two model helpers on fp.job (_fp_order_ship_state, _fp_mark_order_shipped) enforce the "ship-together" gate, two thin JSON-RPC endpoints (generate_label, mark_shipped) wrap them with the FedEx machinery sudo()'d, and a new OWL Shipping panel in the workspace drives it.
Tech Stack: Odoo 19 (Python ORM, JSON-RPC http.route, OWL 2), SCSS bundles, FedEx via fusion_shipping. Tests are Odoo TransactionCase; OWL + the live FedEx call are verified manually on entech.
Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
Conventions used below:
- Local test runner (db
modsdev, containerodoo-modsdev-app):docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /MODULE \ -u MODULE --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 - After editing any controller, run
docker exec odoo-modsdev-app python3 -m pyflakes <file>(catches undefined names — CLAUDE.md lesson). - Bump the
versionstring in each touched module's__manifest__.py(increment the last segment) so ACL rows + data reload on-u. - OWL rule: never call
String()/Number()/parseInt()/parseFloat()inside a.xmltemplate expression — do all coercion in the.jshandler (CLAUDE.md Critical Rule 20).
Phase 1 — Receiving for Technicians (ACL flip + sudo fix)
After this phase, a technician can count + close a receiving from the tablet. This phase alone is shippable.
Task 1: Technician can count + close a receiving
Files:
-
Create:
fusion_plating_receiving/tests/test_technician_receiving_acl.py -
Modify:
fusion_plating_receiving/security/ir.model.access.csv(rows 2, 5, 8) -
Modify:
fusion_plating_receiving/models/fp_receiving.py(_update_so_receiving_status, ~line 1358-1371) -
Modify:
fusion_plating_receiving/__init__.py? No. Modify:fusion_plating_receiving/tests/__init__.py -
Modify:
fusion_plating_receiving/__manifest__.py(version bump) -
Step 1: Write the failing test
Create fusion_plating_receiving/tests/test_technician_receiving_acl.py:
# -*- 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)
Then register it in fusion_plating_receiving/tests/__init__.py — add:
from . import test_technician_receiving_acl
- Step 2: Run the test, verify it FAILS
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
--test-tags /fusion_plating_receiving -u fusion_plating_receiving \
--stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
Expected: FAIL — AccessError on fp.receiving write (technician is read-only) and/or on sale.order write inside _update_so_receiving_status.
- Step 3: Flip the three Technician ACL rows
In fusion_plating_receiving/security/ir.model.access.csv, change the three *_operator rows (currently ...group_fp_technician,1,0,0,0) to grant write + create (1,1,1,0). Final rows:
access_fp_receiving_operator,fp.receiving.operator,model_fp_receiving,fusion_plating.group_fp_technician,1,1,1,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_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fp_technician,1,1,1,0
(Leave perm_unlink = 0 — technicians count/correct but don't delete receivings. Shop Manager / Manager rows unchanged.)
- Step 4:
sudo()the internal SO status write
In fusion_plating_receiving/models/fp_receiving.py, _update_so_receiving_status writes rec.sale_order_id.x_fc_receiving_status directly — a technician lacks sale.order write. The field is an internal denormalized status (never user-entered), so elevate just that write. Replace the body's per-state writes to go through sudo():
for rec in self:
if not rec.sale_order_id:
continue
so = rec.sale_order_id.sudo() # internal status field — safe to elevate
if rec.state == 'closed':
so.x_fc_receiving_status = 'received'
elif rec.state in ('counted', 'staged'):
so.x_fc_receiving_status = 'partial'
# Legacy states preserved.
elif rec.state in ('accepted', 'resolved'):
so.x_fc_receiving_status = 'received'
elif rec.state in ('discrepancy', 'inspecting'):
so.x_fc_receiving_status = 'partial'
elif rec.state == 'draft':
so.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()
Then in _update_job_qty_received, the writes to fp.job also run as the technician; technicians hold fp.job write, so leave them as-is. (If a future audit shows a tech can't write a matched field, elevate Job reads/writes there too — not needed today.)
- Step 5: Bump the version
In fusion_plating_receiving/__manifest__.py, increment the last segment of version.
- Step 6: Run the test, verify it PASSES
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
--test-tags /fusion_plating_receiving -u fusion_plating_receiving \
--stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
Expected: PASS (2 tests).
- Step 7: Commit
git add fusion_plating_receiving/security/ir.model.access.csv \
fusion_plating_receiving/models/fp_receiving.py \
fusion_plating_receiving/tests/test_technician_receiving_acl.py \
fusion_plating_receiving/tests/__init__.py \
fusion_plating_receiving/__manifest__.py
git commit -m "feat(receiving): technicians can count+close receivings from the tablet
ACL: grant group_fp_technician write+create on fp.receiving / line / damage.
sudo the internal sale.order x_fc_receiving_status write so a non-privileged
technician isn't blocked inside action_mark_counted / action_close."
Phase 2 — Delivery ACL for Technicians
Grants technicians write/create on the delivery-completion set (per spec D5). Dispatch records (route / vehicle / pickup) stay read-only.
Task 2: Technician can create a delivery record
Files:
-
Create:
fusion_plating_logistics/tests/test_technician_delivery_acl.py -
Modify:
fusion_plating_logistics/security/ir.model.access.csv(rows 8, 17, 20) -
Modify:
fusion_plating_logistics/tests/__init__.py -
Modify:
fusion_plating_logistics/__manifest__.py(version bump) -
Step 1: Write the failing test
Create fusion_plating_logistics/tests/test_technician_delivery_acl.py:
# -*- 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)."""
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)
Register in fusion_plating_logistics/tests/__init__.py:
from . import test_technician_delivery_acl
- Step 2: Run the test, verify it FAILS
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
--test-tags /fusion_plating_logistics -u fusion_plating_logistics \
--stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
Expected: FAIL — AccessError creating fusion.plating.delivery (technician read-only).
- Step 3: Flip the three Technician ACL rows
In fusion_plating_logistics/security/ir.model.access.csv, change the *_operator rows for delivery, chain-of-custody, and proof-of-delivery from 1,0,0,0 to 1,1,1,0. Final rows:
access_fp_delivery_operator,fp.delivery.operator,model_fusion_plating_delivery,fusion_plating.group_fp_technician,1,1,1,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_proof_of_delivery_operator,fp.proof.of.delivery.operator,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_technician,1,1,1,0
(Leave fp.vehicle, fp.pickup.request, fp.route, fp.route.stop technician rows at 1,0,0,0 — dispatch/planning stays Shop-Manager+.)
- Step 4: Bump the version
In fusion_plating_logistics/__manifest__.py, increment the last segment of version.
- Step 5: Run the test, verify it PASSES
Same command as Step 2. Expected: PASS (1 test).
- Step 6: Commit
git add fusion_plating_logistics/security/ir.model.access.csv \
fusion_plating_logistics/tests/test_technician_delivery_acl.py \
fusion_plating_logistics/tests/__init__.py \
fusion_plating_logistics/__manifest__.py
git commit -m "feat(logistics): technicians can create/edit delivery, POD, chain-of-custody
Dispatch records (route/vehicle/pickup) stay read-only for technicians."
Phase 3 — Order ship-readiness helpers on fp.job
The "ship-together" gate (spec D4) lives in two reusable model methods so both endpoints and /load share one source of truth.
Task 3: _fp_order_ship_state + _fp_mark_order_shipped
Files:
-
Create:
fusion_plating_jobs/tests/test_order_ship_state.py -
Modify:
fusion_plating_jobs/models/fp_job.py(add two methods nearbutton_mark_shipped, ~line 2186) -
Modify:
fusion_plating_jobs/tests/__init__.py -
Modify:
fusion_plating_jobs/__manifest__.py(version bump) -
Step 1: Write the failing tests
Create fusion_plating_jobs/tests/test_order_ship_state.py:
# -*- 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()
Register in fusion_plating_jobs/tests/__init__.py:
from . import test_order_ship_state
- Step 2: Run the tests, verify they FAIL
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
--test-tags /fusion_plating_jobs -u fusion_plating_jobs \
--stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
Expected: FAIL — AttributeError: 'fp.job' object has no attribute '_fp_order_ship_state'.
- Step 3: Implement the two helpers
In fusion_plating_jobs/models/fp_job.py, add immediately after button_mark_shipped (the method ends ~line 2185). Ensure from odoo import _ and from odoo.exceptions import UserError are already imported at the top of the file (they are — button_mark_shipped uses both):
# ------------------------------------------------------------------
# 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
- Step 4: Bump the version
In fusion_plating_jobs/__manifest__.py, increment the last segment of version.
- Step 5: Run the tests, verify they PASS
Same command as Step 2. Expected: PASS (5 tests).
- Step 6: Commit
git add fusion_plating_jobs/models/fp_job.py \
fusion_plating_jobs/tests/test_order_ship_state.py \
fusion_plating_jobs/tests/__init__.py \
fusion_plating_jobs/__manifest__.py
git commit -m "feat(jobs): order-level ship-readiness helpers (_fp_order_ship_state, _fp_mark_order_shipped)
Spec D4 ship-together gate: the order ships only when every active job
on it is awaiting_ship/done. Shared by the tablet shipping endpoints."
Phase 4 — Shipping endpoints + /load payload (shopfloor controller)
Task 4: Extend /fp/workspace/load with a shipping block
Files:
-
Modify:
fusion_plating_shopfloor/controllers/workspace_controller.py(inload, before the finalreturn, ~line 192; and add'shipping': shipping_payloadto the returned dict) -
Step 1: Build the shipping payload in
load
In workspace_controller.py, inside load, just before the big return {...} (after the receivings_payload block ends ~line 190), insert:
# ---- 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.
shipping_payload = None
if job.state == 'awaiting_ship':
info = job._fp_order_ship_state()
rec = info['order_receiving']
shipment = rec.x_fc_outbound_shipment_id if rec else False
carriers = env['delivery.carrier'].sudo().search([], limit=50)
shipping_payload = {
'ready': info['ready'],
'not_ready': info['not_ready'],
'receiving_id': rec.id if rec else False,
'carrier_id': (rec.x_fc_carrier_id.id
if rec and rec.x_fc_carrier_id else False),
'carrier_options': [{'id': c.id, 'name': c.name} for c in carriers],
'service_type': (rec.x_fc_outbound_service_type or '') if rec else '',
'service_options': env['fp.receiving']._fp_get_service_type_selection(),
'weight': (rec.x_fc_weight or 0.0) if rec else 0.0,
'has_label': bool(rec.x_fc_has_label) if 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),
}
Then add one key to the returned dict (next to 'receivings': receivings_payload,):
'receivings': receivings_payload,
'shipping': shipping_payload,
- Step 2: Pyflakes the controller
docker exec odoo-modsdev-app python3 -m pyflakes \
/mnt/odoo-modules/fusion_plating_shopfloor/controllers/workspace_controller.py
Expected: no output (clean).
Task 5: /fp/workspace/mark_shipped + /fp/workspace/generate_label
Files:
-
Modify:
fusion_plating_shopfloor/controllers/workspace_controller.py(add two routes afterdamage_delete, ~line 624) -
Step 1: Add the two endpoints
Append to the FpWorkspaceController class in workspace_controller.py:
# ======================================================================
# 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}
- Step 2: Pyflakes the controller
docker exec odoo-modsdev-app python3 -m pyflakes \
/mnt/odoo-modules/fusion_plating_shopfloor/controllers/workspace_controller.py
Expected: clean (no undefined names — UserError, http, request, _logger are already imported in this file).
- Step 3: Smoke the endpoints' gate via odoo shell (no FedEx needed)
The FedEx call needs live carrier config (entech), but the gate + plumbing can be smoke-tested locally. Optional but recommended:
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http <<'PY'
# Find any awaiting_ship job; assert the helper returns a dict shape.
job = env['fp.job'].search([('state','=','awaiting_ship')], limit=1)
print('job:', job)
if job:
print(job._fp_order_ship_state().keys())
PY
Expected: prints the dict keys (ready, not_ready, ...). No traceback.
- Step 4: Bump shopfloor version + commit
Bump the last segment of version in fusion_plating_shopfloor/__manifest__.py.
git add fusion_plating_shopfloor/controllers/workspace_controller.py \
fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(shopfloor): tablet shipping endpoints (generate_label, mark_shipped) + /load payload
mark_shipped runs as the technician (attribution); generate_label sudo's the
FedEx/stock machinery. Both enforce the order-level ship-together gate."
Phase 5 — Shipping OWL panel (shopfloor static)
No automated test (OWL isn't unit-tested in this project); verified manually on entech in Phase 6.
Task 6: Shipping panel handlers in job_workspace.js
Files:
-
Modify:
fusion_plating_shopfloor/static/src/js/job_workspace.js(add state init + 4 handlers) -
Step 1: Add
shipFormto component state
In setup(), extend the useState object (after tickNow: Date.now(),):
tickNow: Date.now(),
// Shipping panel input buffer (carrier/service/weight). Seeded
// lazily from the payload defaults in the handlers below.
shipForm: {},
- Step 2: Add the shipping handlers
Add these methods to the FpJobWorkspace class (e.g. after onAdvanceMilestone, before the closing brace ~line 613). All coercion is JS-side per CLAUDE.md Rule 20:
// ---- Shipping handlers (tablet receiving+shipping 2026-05-29) ----------
onShipInput(field, ev) {
const raw = ev.target.value;
// carrier_id + service_type stay strings; weight is numeric.
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" });
}
}
- Step 3: Commit (with the XML + SCSS in Task 7/8 — or commit JS now and the rest after)
Defer commit to end of Phase 5 (Task 8) so the panel lands as one coherent change.
Task 7: Shipping panel markup in job_workspace.xml
Files:
-
Modify:
fusion_plating_shopfloor/static/src/xml/job_workspace.xml(insert after the receivingst-foreachblock, before thestate.data.steps.lengthempty check, ~line 215) -
Step 1: Add the panel markup
Insert immediately after the closing </t> of the receivings t-foreach (the </t> on ~line 214), before <div t-if="!state.data.steps.length" ...>:
<!-- 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>
Task 8: Shipping panel SCSS + commit
Files:
-
Modify:
fusion_plating_shopfloor/static/src/scss/job_workspace.scss(append panel styles with a dark-mode branch) -
Step 1: Add styles
Append to job_workspace.scss. Match the receiving-card token approach already in this file (reuse the existing o_fp_ws_rcv* colour vars if present; otherwise these explicit hex values with a dark branch per CLAUDE.md dark-mode rule):
// ---- Shipping panel (tablet receiving+shipping 2026-05-29) ---------------
$o-webclient-color-scheme: bright !default;
$_fp-ship-bg-hex: #f0f7ff;
$_fp-ship-border-hex: #b6d4fe;
$_fp-ship-text-hex: #1d1f1e;
@if $o-webclient-color-scheme == dark {
$_fp-ship-bg-hex: #18222e !global;
$_fp-ship-border-hex: #2f4256 !global;
$_fp-ship-text-hex: #e6e6e6 !global;
}
.o_fp_ws_ship {
background-color: var(--fp-ship-bg, $_fp-ship-bg-hex);
border: 1px solid var(--fp-ship-border, $_fp-ship-border-hex);
color: var(--fp-ship-text, $_fp-ship-text-hex);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 12px;
.o_fp_ws_ship_head {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 8px;
}
.o_fp_ws_ship_icon { font-size: 1.25rem; }
.o_fp_ws_ship_title { font-size: 1.05rem; }
.o_fp_ws_ship_waiting {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: 0.95rem;
}
.o_fp_ws_ship_fields {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 10px;
label {
display: flex;
flex-direction: column;
font-size: 0.85rem;
font-weight: 500;
min-width: 160px;
flex: 1;
}
.form-select, .form-control { margin-top: 4px; }
}
.o_fp_ws_ship_actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
- Step 2: Bump shopfloor version (if not already bumped in Task 5)
If Phase 4 already bumped fusion_plating_shopfloor, bump it again here so the asset bundle re-hashes for the JS/XML/SCSS change.
- Step 3: Commit the panel
git add fusion_plating_shopfloor/static/src/js/job_workspace.js \
fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
fusion_plating_shopfloor/static/src/scss/job_workspace.scss \
fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(shopfloor): tablet Shipping panel on the Job Workspace
Carrier/service/weight inputs + Generate Label + Mark Shipped, gated
read-only until all of the order's jobs are awaiting_ship (ship-together)."
Phase 6 — Deploy to entech + manual verification
The OWL panel + the live FedEx label call can only be exercised on entech (carrier configured there; local is Community without the FedEx sandbox wired). The user runs from a Mac — verify on the iPad/entech browser, not a Windows-side localhost preview.
Task 9: Deploy the four modules to entech
Files: none (deployment)
- Step 1: Push commits
git push origin main
- Step 2: Sync the changed files to entech (LXC 111 / pve-worker5)
For each changed file, copy to /mnt/extra-addons/custom/<module>/... per the CLAUDE.md entech file-copy pattern:
cat <LOCAL_FILE> | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<REMOTE_PATH>'"
(Or git pull on entech if the repo is checked out there — confirm with the user which sync method they use for entech.)
- Step 3: Upgrade the four modules (single stop/start)
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
-u fusion_plating_receiving,fusion_plating_logistics,fusion_plating_jobs,fusion_plating_shopfloor \
--stop-after-init\" && systemctl start odoo'"
- Step 4: Bust the asset cache (SCSS/JS changed)
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
ssh pve-worker5 "pct exec 111 -- systemctl restart odoo"
Task 10: Manual walkthrough on the tablet
Files: none (verification)
- Step 1: Receiving as a technician
On the iPad (or entech browser), PIN-unlock as a Technician, open the plant kanban → tap a card in the Receiving column → in the workspace, set Boxes Received, adjust a line qty, tap Mark Counted, then Close Receiving. Confirm: no error, the card leaves Receiving, and (back office) the order's receiving status shows received. Previously this AccessError'd for a technician.
- Step 2: Shipping a single-job order
Find a job in awaiting_ship, open the workspace, confirm the Ship Order panel shows. Pick carrier + service, enter weight, tap Generate Label → tracking # toasts and "View / Print Label" appears (PDF opens via the preview dialog; ZPL is on the receiving for the Zebra). Tap Mark Shipped → job goes done, leaves the Shipping column.
- Step 3: Shipping a multi-job order (ship-together gate)
Find an order with ≥2 jobs where one is awaiting_ship and a sibling is still in progress. Open the awaiting_ship job → the Ship Order panel shows the read-only Waiting on: WO-xxxx banner and no action buttons. Finish the sibling, refresh → the panel becomes actionable; Mark Shipped marks all the order's jobs done together.
- Step 4: Confirm no regression for Shop Manager / Manager
As a Shop Manager, confirm the back-office receiving form + the label-generate wizard still work unchanged.
Notes / deferred (from spec §8–§9)
- Carrier default source: the panel offers a carrier picker seeded from
rec.x_fc_carrier_id. If the shop wants a per-customer or company default to pre-select, that's a follow-up (setx_fc_carrier_idat receiving time or via a customer field). - Weight UoM / a part-catalog default weight: deferred.
- Combined "generate + mark shipped" single tap for single-job orders: deferred.
- The workspace auto-refreshes every 15s. The shipping inputs (carrier/service/weight) are NOT blur-saved (unlike the receiving panel's inputs), so a refresh mid-edit can reset an unsaved selection. Acceptable for MVP — the fill takes seconds and values re-select instantly. If it annoys operators in Phase 6, seed
state.shipFormfrom the payload once on load and bind the inputs toshipForm(controlled) so refresh can't clobber them. - Standalone dock/shipping tablet apps (S30/S32), auto-
mark_shippedfromdelivery.action_mark_delivered, route/vehicle/pickup tech ACL: out of scope.