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>
1024 lines
45 KiB
Markdown
1024 lines
45 KiB
Markdown
# 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.
|