diff --git a/fusion_plating/docs/superpowers/plans/2026-05-29-technician-receiving-shipping-tablet-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-29-technician-receiving-shipping-tablet-plan.md new file mode 100644 index 00000000..bda6c5f7 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-29-technician-receiving-shipping-tablet-plan.md @@ -0,0 +1,1023 @@ +# 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 ` (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 `` of the receivings `t-foreach` (the `` on ~line 214), before `
`: + +```xml + +
+
+ 🚚 + Ship Order + + Label ready · + +
+ + +
+ Waiting on: + + + + + +
+ + + +
+ + + +
+
+ + + +
+
+
+``` + +### 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//...` per the CLAUDE.md entech file-copy pattern: + +```bash +cat | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/'" +``` +(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. diff --git a/fusion_plating/docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md b/fusion_plating/docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md index e3c36ef9..e1c34a8a 100644 --- a/fusion_plating/docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md +++ b/fusion_plating/docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md @@ -58,7 +58,7 @@ surface), instead of those actions being Shop-Manager+/back-office only. ## Design -### 1. Permissions (ACL changes only — no new groups) +### 1. Permissions (ACL changes + one `sudo()` — no new groups) `fusion_plating_receiving/security/ir.model.access.csv` — flip `perm_write` + `perm_create` `0 → 1` on the three Technician rows: @@ -81,9 +81,14 @@ Leave the vehicle / pickup / route / route-stop Technician rows **read-only**. **Version bumps:** `fusion_plating_receiving` + `fusion_plating_logistics` manifest versions (ACL rows only reload on `-u` when the version changes). -### 2. Receiving on the tablet (ACL-only; UI + endpoints already exist) +### 2. Receiving on the tablet (ACL + one `sudo()`; UI + endpoints already exist) -**No code change beyond §1.** After the ACL flip: +**ACL flip (§1) + one `sudo()`.** `action_mark_counted` / `action_close` call +`_update_so_receiving_status`, which writes `sale.order.x_fc_receiving_status` directly — a +technician lacks `sale.order` write, so that internal denormalized-status write must be +elevated (`rec.sale_order_id.sudo()…`). Without it the ACL flip alone still AccessErrors +inside mark-counted. (Discovered during planning; the rest of the receiving UI + endpoints +are untouched.) After both changes: - Tech taps a card in the **Receiving** column → workspace opens → the existing receiving panel renders from the `receivings` payload. @@ -95,8 +100,9 @@ Leave the vehicle / pickup / route / route-stop Technician rows **read-only**. order), so the panel is identical regardless of which sibling job's card was tapped; counting/closing clears all sibling job cards from Receiving together. - **Attribution:** the endpoints already run as `request.env.user` (the tech); - `received_by_id = env.user` and chatter is authored by the tech. No `sudo` — the - now-granted ACL is what makes the write legal. + `received_by_id = env.user` and chatter is authored by the tech. The receiving-record + writes go through the now-granted ACL; only the internal `sale.order` status write inside + `_update_so_receiving_status` is `sudo()`'d (denormalized status, never user-entered). **Acceptance:** a user holding only `group_fp_technician` can call `receiving_save_lines` / `receiving_mark_counted` / `receiving_close` / `damage_create` without `AccessError`.