Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-29-technician-receiving-shipping-tablet-plan.md
gsinghpal b98ee8a6fb docs(plating): implementation plan for technician receiving + shipping tablet
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>
2026-05-29 00:50:23 -04:00

1024 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../specs/2026-05-29-technician-receiving-shipping-tablet-design.md)
**Conventions used below:**
- Local test runner (db `modsdev`, container `odoo-modsdev-app`):
```bash
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 `version` string 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 `.xml` template expression — do all coercion in the `.js` handler (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`:
```python
# -*- 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:
```python
from . import test_technician_receiving_acl
```
- [ ] **Step 2: Run the test, verify it FAILS**
```bash
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:
```csv
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()`:
```python
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**
```bash
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**
```bash
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`:
```python
# -*- 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`:
```python
from . import test_technician_delivery_acl
```
- [ ] **Step 2: Run the test, verify it FAILS**
```bash
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:
```csv
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**
```bash
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 near `button_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`:
```python
# -*- 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`:
```python
from . import test_order_ship_state
```
- [ ] **Step 2: Run the tests, verify they FAIL**
```bash
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):
```python
# ------------------------------------------------------------------
# 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**
```bash
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` (in `load`, before the final `return`, ~line 192; and add `'shipping': shipping_payload` to 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:
```python
# ---- 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,`):
```python
'receivings': receivings_payload,
'shipping': shipping_payload,
```
- [ ] **Step 2: Pyflakes the controller**
```bash
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 after `damage_delete`, ~line 624)
- [ ] **Step 1: Add the two endpoints**
Append to the `FpWorkspaceController` class in `workspace_controller.py`:
```python
# ======================================================================
# 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**
```bash
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:
```bash
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`.
```bash
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 `shipForm` to component state**
In `setup()`, extend the `useState` object (after `tickNow: Date.now(),`):
```javascript
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:
```javascript
// ---- 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 receivings `t-foreach` block, before the `state.data.steps.length` empty 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" ...>`:
```xml
<!-- 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):
```scss
// ---- 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**
```bash
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**
```bash
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:
```bash
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)**
```bash
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)**
```bash
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 (set `x_fc_carrier_id` at 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.shipForm` from the payload once on load and bind the inputs to `shipForm` (controlled) so refresh can't clobber them.
- Standalone dock/shipping tablet apps (S30/S32), auto-`mark_shipped` from `delivery.action_mark_delivered`, route/vehicle/pickup tech ACL: out of scope.