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

45 KiB
Raw Blame History

Technician Receiving + Shipping from the Workstation — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let Technicians receive a confirmed order and ship a finished order directly from the fp_job_workspace tablet surface.

Architecture: Receiving is already built on the tablet (panel + endpoints in fusion_plating_shopfloor); it only needs an ACL flip on the three fp.receiving* models plus one sudo() on the internal sale-order status write inside _update_so_receiving_status (the write that a read-only technician would otherwise be blocked on). Shipping is net-new: two model helpers on fp.job (_fp_order_ship_state, _fp_mark_order_shipped) enforce the "ship-together" gate, two thin JSON-RPC endpoints (generate_label, mark_shipped) wrap them with the FedEx machinery sudo()'d, and a new OWL Shipping panel in the workspace drives it.

Tech Stack: Odoo 19 (Python ORM, JSON-RPC http.route, OWL 2), SCSS bundles, FedEx via fusion_shipping. Tests are Odoo TransactionCase; OWL + the live FedEx call are verified manually on entech.

Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md

Conventions used below:

  • Local test runner (db modsdev, container odoo-modsdev-app):
    docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /MODULE \
        -u MODULE --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
    
  • After editing any controller, run docker exec odoo-modsdev-app python3 -m pyflakes <file> (catches undefined names — CLAUDE.md lesson).
  • Bump the 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:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Technician can receive (count + close) from the tablet.

Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
"""
from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError


class TestTechnicianReceivingAcl(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env['res.partner'].create({'name': 'AclCust'})
        cls.product = cls.env['product.product'].create({'name': 'AclWidget'})
        cls.so = cls.env['sale.order'].create({
            'partner_id': cls.partner.id,
            'order_line': [(0, 0, {
                'product_id': cls.product.id,
                'product_uom_qty': 1,
            })],
        })
        cls.tech = cls.env['res.users'].create({
            'name': 'Tech ACL',
            'login': 'tech_acl_recv',
            # Odoo 19: group_ids (NOT groups_id) — CLAUDE.md rule 13c.
            'group_ids': [(6, 0, [
                cls.env.ref('fusion_plating.group_fp_technician').id,
            ])],
        })

    def test_technician_can_count_and_close_receiving(self):
        # Created as admin; the technician must be able to count + close.
        rec = self.env['fp.receiving'].create({
            'sale_order_id': self.so.id,
            'box_count_in': 3,
        })
        rec_as_tech = rec.with_user(self.tech)
        try:
            rec_as_tech.action_mark_counted()
        except AccessError as e:
            self.fail("Technician blocked marking counted: %s" % e)
        self.assertEqual(rec.state, 'counted')
        rec_as_tech.action_close()
        self.assertEqual(rec.state, 'closed')
        # The SO status write inside _update_so_receiving_status must have
        # gone through (it is sudo'd) — proves no AccessError on sale.order.
        self.assertEqual(self.so.x_fc_receiving_status, 'received')

    def test_technician_can_create_damage(self):
        rec = self.env['fp.receiving'].create({'sale_order_id': self.so.id})
        dmg = self.env['fp.receiving.damage'].with_user(self.tech).create({
            'receiving_id': rec.id,
            'description': 'scratch on flange',
        })
        self.assertTrue(dmg.id)

Then register it in fusion_plating_receiving/tests/__init__.py — add:

from . import test_technician_receiving_acl
  • Step 2: Run the test, verify it FAILS
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
    --test-tags /fusion_plating_receiving -u fusion_plating_receiving \
    --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: FAIL — AccessError on fp.receiving write (technician is read-only) and/or on sale.order write inside _update_so_receiving_status.

  • Step 3: Flip the three Technician ACL rows

In fusion_plating_receiving/security/ir.model.access.csv, change the three *_operator rows (currently ...group_fp_technician,1,0,0,0) to grant write + create (1,1,1,0). Final rows:

access_fp_receiving_operator,fp.receiving.operator,model_fp_receiving,fusion_plating.group_fp_technician,1,1,1,0
access_fp_receiving_line_operator,fp.receiving.line.operator,model_fp_receiving_line,fusion_plating.group_fp_technician,1,1,1,0
access_fp_receiving_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fp_technician,1,1,1,0

(Leave perm_unlink = 0 — technicians count/correct but don't delete receivings. Shop Manager / Manager rows unchanged.)

  • Step 4: sudo() the internal SO status write

In fusion_plating_receiving/models/fp_receiving.py, _update_so_receiving_status writes rec.sale_order_id.x_fc_receiving_status directly — a technician lacks sale.order write. The field is an internal denormalized status (never user-entered), so elevate just that write. Replace the body's per-state writes to go through sudo():

        for rec in self:
            if not rec.sale_order_id:
                continue
            so = rec.sale_order_id.sudo()  # internal status field — safe to elevate
            if rec.state == 'closed':
                so.x_fc_receiving_status = 'received'
            elif rec.state in ('counted', 'staged'):
                so.x_fc_receiving_status = 'partial'
            # Legacy states preserved.
            elif rec.state in ('accepted', 'resolved'):
                so.x_fc_receiving_status = 'received'
            elif rec.state in ('discrepancy', 'inspecting'):
                so.x_fc_receiving_status = 'partial'
            elif rec.state == 'draft':
                so.x_fc_receiving_status = 'not_received'
            # Propagate the per-part qty onto the matching fp.job records
            # so the 2026-05-18 mark_done gate can see what was received.
            rec._update_job_qty_received()

Then in _update_job_qty_received, the writes to fp.job also run as the technician; technicians hold fp.job write, so leave them as-is. (If a future audit shows a tech can't write a matched field, elevate Job reads/writes there too — not needed today.)

  • Step 5: Bump the version

In fusion_plating_receiving/__manifest__.py, increment the last segment of version.

  • Step 6: Run the test, verify it PASSES
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
    --test-tags /fusion_plating_receiving -u fusion_plating_receiving \
    --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: PASS (2 tests).

  • Step 7: Commit
git add fusion_plating_receiving/security/ir.model.access.csv \
        fusion_plating_receiving/models/fp_receiving.py \
        fusion_plating_receiving/tests/test_technician_receiving_acl.py \
        fusion_plating_receiving/tests/__init__.py \
        fusion_plating_receiving/__manifest__.py
git commit -m "feat(receiving): technicians can count+close receivings from the tablet

ACL: grant group_fp_technician write+create on fp.receiving / line / damage.
sudo the internal sale.order x_fc_receiving_status write so a non-privileged
technician isn't blocked inside action_mark_counted / action_close."

Phase 2 — Delivery ACL for Technicians

Grants technicians write/create on the delivery-completion set (per spec D5). Dispatch records (route / vehicle / pickup) stay read-only.

Task 2: Technician can create a delivery record

Files:

  • Create: fusion_plating_logistics/tests/test_technician_delivery_acl.py

  • Modify: fusion_plating_logistics/security/ir.model.access.csv (rows 8, 17, 20)

  • Modify: fusion_plating_logistics/tests/__init__.py

  • Modify: fusion_plating_logistics/__manifest__.py (version bump)

  • Step 1: Write the failing test

Create fusion_plating_logistics/tests/test_technician_delivery_acl.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Technician can create/edit delivery-completion records (spec D5)."""
from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError


class TestTechnicianDeliveryAcl(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env['res.partner'].create({'name': 'DelCust'})
        cls.tech = cls.env['res.users'].create({
            'name': 'Tech Del',
            'login': 'tech_acl_del',
            'group_ids': [(6, 0, [
                cls.env.ref('fusion_plating.group_fp_technician').id,
            ])],
        })

    def test_technician_can_create_delivery(self):
        try:
            delivery = self.env['fusion.plating.delivery'].with_user(
                self.tech,
            ).create({'partner_id': self.partner.id})
        except AccessError as e:
            self.fail("Technician blocked creating delivery: %s" % e)
        self.assertTrue(delivery.id)

Register in fusion_plating_logistics/tests/__init__.py:

from . import test_technician_delivery_acl
  • Step 2: Run the test, verify it FAILS
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
    --test-tags /fusion_plating_logistics -u fusion_plating_logistics \
    --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: FAIL — AccessError creating fusion.plating.delivery (technician read-only).

  • Step 3: Flip the three Technician ACL rows

In fusion_plating_logistics/security/ir.model.access.csv, change the *_operator rows for delivery, chain-of-custody, and proof-of-delivery from 1,0,0,0 to 1,1,1,0. Final rows:

access_fp_delivery_operator,fp.delivery.operator,model_fusion_plating_delivery,fusion_plating.group_fp_technician,1,1,1,0
access_fp_chain_of_custody_operator,fp.chain.of.custody.operator,model_fusion_plating_chain_of_custody,fusion_plating.group_fp_technician,1,1,1,0
access_fp_proof_of_delivery_operator,fp.proof.of.delivery.operator,model_fusion_plating_proof_of_delivery,fusion_plating.group_fp_technician,1,1,1,0

(Leave fp.vehicle, fp.pickup.request, fp.route, fp.route.stop technician rows at 1,0,0,0 — dispatch/planning stays Shop-Manager+.)

  • Step 4: Bump the version

In fusion_plating_logistics/__manifest__.py, increment the last segment of version.

  • Step 5: Run the test, verify it PASSES

Same command as Step 2. Expected: PASS (1 test).

  • Step 6: Commit
git add fusion_plating_logistics/security/ir.model.access.csv \
        fusion_plating_logistics/tests/test_technician_delivery_acl.py \
        fusion_plating_logistics/tests/__init__.py \
        fusion_plating_logistics/__manifest__.py
git commit -m "feat(logistics): technicians can create/edit delivery, POD, chain-of-custody

Dispatch records (route/vehicle/pickup) stay read-only for technicians."

Phase 3 — Order ship-readiness helpers on fp.job

The "ship-together" gate (spec D4) lives in two reusable model methods so both endpoints and /load share one source of truth.

Task 3: _fp_order_ship_state + _fp_mark_order_shipped

Files:

  • Create: fusion_plating_jobs/tests/test_order_ship_state.py

  • Modify: fusion_plating_jobs/models/fp_job.py (add two methods 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:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Order-level ship-readiness gate (spec D4 — ship together).

Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
"""
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError


class TestOrderShipState(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
        cls.product = cls.env['product.product'].create({'name': 'ShipWidget'})

    def _make_so(self):
        return self.env['sale.order'].create({
            'partner_id': self.partner.id,
            'order_line': [(0, 0, {
                'product_id': self.product.id,
                'product_uom_qty': 1,
            })],
        })

    def _make_job(self, so, state):
        return self.env['fp.job'].create({
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1.0,
            'state': state,
            'sale_order_id': so.id,
        })

    def test_ready_single_awaiting_ship_job(self):
        so = self._make_so()
        job = self._make_job(so, 'awaiting_ship')
        info = job._fp_order_ship_state()
        self.assertTrue(info['ready'])
        self.assertEqual(info['awaiting_ship_jobs'], job)
        self.assertEqual(info['not_ready'], [])

    def test_not_ready_with_unfinished_sibling(self):
        so = self._make_so()
        j1 = self._make_job(so, 'awaiting_ship')
        self._make_job(so, 'in_progress')
        info = j1._fp_order_ship_state()
        self.assertFalse(info['ready'])
        self.assertEqual(len(info['not_ready']), 1)

    def test_done_sibling_does_not_block(self):
        so = self._make_so()
        j1 = self._make_job(so, 'awaiting_ship')
        self._make_job(so, 'done')
        info = j1._fp_order_ship_state()
        self.assertTrue(info['ready'])

    def test_mark_order_shipped_marks_all_awaiting(self):
        so = self._make_so()
        j1 = self._make_job(so, 'awaiting_ship')
        j2 = self._make_job(so, 'awaiting_ship')
        j1._fp_mark_order_shipped()
        self.assertEqual(j1.state, 'done')
        self.assertEqual(j2.state, 'done')

    def test_mark_order_shipped_blocks_on_unfinished_sibling(self):
        so = self._make_so()
        j1 = self._make_job(so, 'awaiting_ship')
        self._make_job(so, 'in_progress')
        with self.assertRaises(UserError):
            j1._fp_mark_order_shipped()

Register in fusion_plating_jobs/tests/__init__.py:

from . import test_order_ship_state
  • Step 2: Run the tests, verify they FAIL
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
    --test-tags /fusion_plating_jobs -u fusion_plating_jobs \
    --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: FAIL — AttributeError: 'fp.job' object has no attribute '_fp_order_ship_state'.

  • Step 3: Implement the two helpers

In fusion_plating_jobs/models/fp_job.py, add immediately after button_mark_shipped (the method ends ~line 2185). Ensure from odoo import _ and from odoo.exceptions import UserError are already imported at the top of the file (they are — button_mark_shipped uses both):

    # ------------------------------------------------------------------
    # Order-level ship readiness (tablet receiving+shipping, 2026-05-29)
    #
    # An order can split into several jobs (one per part/recipe) but has
    # ONE outbound shipment (the physical boxes). Spec D4 = "ship
    # together": the order can ship only when EVERY active job on it is
    # awaiting_ship or done, with at least one awaiting_ship to act on.
    # Both the tablet endpoints and /fp/workspace/load read this.
    # ------------------------------------------------------------------
    def _fp_order_ship_state(self):
        """Return ship-readiness for the whole order this job belongs to.

        {ready, not_ready:[{wo_name, state_label}], awaiting_ship_jobs,
         order_jobs, order_receiving}
        Runs in the caller's env: call on a sudo job for display, on a
        user job (the tablet tech) when you want real write attribution.
        """
        self.ensure_one()
        empty_job = self.browse()
        empty_rcv = (self.env['fp.receiving'].browse()
                     if 'fp.receiving' in self.env else empty_job)
        so = self.sale_order_id
        if not so:
            return {'ready': False, 'not_ready': [],
                    'awaiting_ship_jobs': empty_job, 'order_jobs': self,
                    'order_receiving': empty_rcv}
        jobs = self.search([
            ('sale_order_id', '=', so.id),
            ('state', '!=', 'cancelled'),
        ])
        not_ready = jobs.filtered(lambda j: j.state not in ('awaiting_ship', 'done'))
        awaiting = jobs.filtered(lambda j: j.state == 'awaiting_ship')
        ready = bool(jobs) and not not_ready and bool(awaiting)
        state_sel = dict(self._fields['state'].selection)
        rcv = empty_rcv
        if 'fp.receiving' in self.env:
            rcv = self.env['fp.receiving'].search(
                [('sale_order_id', '=', so.id)], order='id desc', limit=1)
        return {
            'ready': ready,
            'not_ready': [{'wo_name': j.display_wo_name,
                           'state_label': state_sel.get(j.state, j.state)}
                          for j in not_ready],
            'awaiting_ship_jobs': awaiting,
            'order_jobs': jobs,
            'order_receiving': rcv,
        }

    def _fp_mark_order_shipped(self):
        """Mark every awaiting_ship job on the order as shipped (done).

        Gated on _fp_order_ship_state['ready']; raises UserError naming
        the unfinished jobs otherwise. Returns the recordset marked.
        """
        self.ensure_one()
        info = self._fp_order_ship_state()
        if not info['ready']:
            names = ', '.join(n['wo_name'] for n in info['not_ready']) or _('none')
            raise UserError(_(
                'Cannot ship yet — these jobs on the order are not '
                'finished: %s'
            ) % names)
        awaiting = info['awaiting_ship_jobs']
        awaiting.button_mark_shipped()
        return awaiting
  • Step 4: Bump the version

In fusion_plating_jobs/__manifest__.py, increment the last segment of version.

  • Step 5: Run the tests, verify they PASS

Same command as Step 2. Expected: PASS (5 tests).

  • Step 6: Commit
git add fusion_plating_jobs/models/fp_job.py \
        fusion_plating_jobs/tests/test_order_ship_state.py \
        fusion_plating_jobs/tests/__init__.py \
        fusion_plating_jobs/__manifest__.py
git commit -m "feat(jobs): order-level ship-readiness helpers (_fp_order_ship_state, _fp_mark_order_shipped)

Spec D4 ship-together gate: the order ships only when every active job
on it is awaiting_ship/done. Shared by the tablet shipping endpoints."

Phase 4 — Shipping endpoints + /load payload (shopfloor controller)

Task 4: Extend /fp/workspace/load with a shipping block

Files:

  • Modify: fusion_plating_shopfloor/controllers/workspace_controller.py (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:

        # ---- Shipping (awaiting_ship jobs) ------------------------------
        # Spec 2026-05-29. Surfaces the order-level ship-readiness gate +
        # the carrier/service/weight inputs + any label already on the
        # order's single outbound shipment. job is already sudo() here,
        # so the helper's reads are fine for display.
        shipping_payload = None
        if job.state == 'awaiting_ship':
            info = job._fp_order_ship_state()
            rec = info['order_receiving']
            shipment = rec.x_fc_outbound_shipment_id if rec else False
            carriers = env['delivery.carrier'].sudo().search([], limit=50)
            shipping_payload = {
                'ready': info['ready'],
                'not_ready': info['not_ready'],
                'receiving_id': rec.id if rec else False,
                'carrier_id': (rec.x_fc_carrier_id.id
                               if rec and rec.x_fc_carrier_id else False),
                'carrier_options': [{'id': c.id, 'name': c.name} for c in carriers],
                'service_type': (rec.x_fc_outbound_service_type or '') if rec else '',
                'service_options': env['fp.receiving']._fp_get_service_type_selection(),
                'weight': (rec.x_fc_weight or 0.0) if rec else 0.0,
                'has_label': bool(rec.x_fc_has_label) if rec else False,
                'tracking_number': (shipment.tracking_number or '') if shipment else '',
                'label_attachment_id': (shipment.label_attachment_id.id
                                        if shipment and shipment.label_attachment_id
                                        else False),
            }

Then add one key to the returned dict (next to 'receivings': receivings_payload,):

            'receivings': receivings_payload,
            'shipping': shipping_payload,
  • Step 2: Pyflakes the controller
docker exec odoo-modsdev-app python3 -m pyflakes \
    /mnt/odoo-modules/fusion_plating_shopfloor/controllers/workspace_controller.py

Expected: no output (clean).

Task 5: /fp/workspace/mark_shipped + /fp/workspace/generate_label

Files:

  • Modify: fusion_plating_shopfloor/controllers/workspace_controller.py (add two routes after damage_delete, ~line 624)

  • Step 1: Add the two endpoints

Append to the FpWorkspaceController class in workspace_controller.py:

    # ======================================================================
    # Shipping — generate outbound label + mark shipped (2026-05-29)
    # ======================================================================
    # Spec D3/D4. mark_shipped runs as the technician (real chatter
    # attribution; the method has no group gate). generate_label sudo's
    # the carrier/stock/shipment machinery — technicians intentionally
    # don't hold those ACLs. Both re-check the order-level "ship together"
    # gate server-side via fp.job._fp_order_ship_state.

    @http.route('/fp/workspace/mark_shipped', type='jsonrpc', auth='user')
    def mark_shipped(self, job_id):
        env = request.env
        job = env['fp.job'].browse(int(job_id))  # as the tech
        if not job.exists():
            return {'ok': False, 'error': f'Job {job_id} not found'}
        try:
            shipped = job._fp_mark_order_shipped()
        except UserError as e:
            return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
        except Exception as exc:
            _logger.exception("workspace/mark_shipped failed")
            return {'ok': False, 'error': str(exc)}
        return {'ok': True, 'shipped': shipped.mapped('display_wo_name')}

    @http.route('/fp/workspace/generate_label', type='jsonrpc', auth='user')
    def generate_label(self, job_id, weight, service_type='', carrier_id=False):
        env = request.env
        job = env['fp.job'].browse(int(job_id))
        if not job.exists():
            return {'ok': False, 'error': f'Job {job_id} not found'}
        info = job._fp_order_ship_state()
        if not info['ready']:
            names = ', '.join(n['wo_name'] for n in info['not_ready']) or 'none'
            return {'ok': False,
                    'error': 'Not all jobs on the order are finished: %s' % names,
                    'not_ready': info['not_ready']}
        rec = info['order_receiving']
        if not rec:
            return {'ok': False,
                    'error': 'No receiving record on this order to ship from.'}
        try:
            w = float(weight or 0)
        except (TypeError, ValueError):
            w = 0.0
        if w <= 0:
            return {'ok': False,
                    'error': 'Enter a non-zero weight before generating the label.'}
        # sudo: carrier write triggers delivery.carrier read; the actual
        # generate synthesizes a stock.picking + fusion.shipment + label
        # attachment — all privileged. The carrier choice came from the
        # sudo'd options list in /load, so this is safe.
        rec = rec.sudo()
        vals = {'x_fc_weight': w,
                'x_fc_outbound_service_type': service_type or False}
        if carrier_id:
            vals['x_fc_carrier_id'] = int(carrier_id)
        try:
            rec.write(vals)
            rec._fp_actually_generate_outbound_label()
        except UserError as e:
            return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
        except Exception as exc:
            _logger.exception("workspace/generate_label failed")
            return {'ok': False, 'error': 'Label generation failed: %s' % exc}
        # The model returns a manual-wizard action (no raise) on API
        # failure — so success is "a label landed on the shipment".
        shipment = rec.x_fc_outbound_shipment_id
        if not (shipment and shipment.label_attachment_id):
            return {'ok': False, 'error': (
                'The carrier did not return a label. Ask the office to '
                'generate it from the receiving record.')}
        return {'ok': True,
                'tracking_number': shipment.tracking_number or '',
                'label_attachment_id': shipment.label_attachment_id.id}
  • Step 2: Pyflakes the controller
docker exec odoo-modsdev-app python3 -m pyflakes \
    /mnt/odoo-modules/fusion_plating_shopfloor/controllers/workspace_controller.py

Expected: clean (no undefined names — UserError, http, request, _logger are already imported in this file).

  • Step 3: Smoke the endpoints' gate via odoo shell (no FedEx needed)

The FedEx call needs live carrier config (entech), but the gate + plumbing can be smoke-tested locally. Optional but recommended:

docker exec odoo-modsdev-app odoo shell -d modsdev --no-http <<'PY'
# Find any awaiting_ship job; assert the helper returns a dict shape.
job = env['fp.job'].search([('state','=','awaiting_ship')], limit=1)
print('job:', job)
if job:
    print(job._fp_order_ship_state().keys())
PY

Expected: prints the dict keys (ready, not_ready, ...). No traceback.

  • Step 4: Bump shopfloor version + commit

Bump the last segment of version in fusion_plating_shopfloor/__manifest__.py.

git add fusion_plating_shopfloor/controllers/workspace_controller.py \
        fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(shopfloor): tablet shipping endpoints (generate_label, mark_shipped) + /load payload

mark_shipped runs as the technician (attribution); generate_label sudo's the
FedEx/stock machinery. Both enforce the order-level ship-together gate."

Phase 5 — Shipping OWL panel (shopfloor static)

No automated test (OWL isn't unit-tested in this project); verified manually on entech in Phase 6.

Task 6: Shipping panel handlers in job_workspace.js

Files:

  • Modify: fusion_plating_shopfloor/static/src/js/job_workspace.js (add state init + 4 handlers)

  • Step 1: Add shipForm to component state

In setup(), extend the useState object (after tickNow: Date.now(),):

            tickNow: Date.now(),
            // Shipping panel input buffer (carrier/service/weight). Seeded
            // lazily from the payload defaults in the handlers below.
            shipForm: {},
  • Step 2: Add the shipping handlers

Add these methods to the FpJobWorkspace class (e.g. after onAdvanceMilestone, before the closing brace ~line 613). All coercion is JS-side per CLAUDE.md Rule 20:

    // ---- Shipping handlers (tablet receiving+shipping 2026-05-29) ----------
    onShipInput(field, ev) {
        const raw = ev.target.value;
        // carrier_id + service_type stay strings; weight is numeric.
        this.state.shipForm[field] =
            field === "weight" ? (parseFloat(raw) || 0) : raw;
    }

    async onGenerateLabel() {
        const sh = (this.state.data && this.state.data.shipping) || {};
        const f = this.state.shipForm || {};
        const weight = f.weight != null ? f.weight : (sh.weight || 0);
        const serviceType = f.service_type != null ? f.service_type : (sh.service_type || "");
        const carrierRaw = f.carrier_id != null ? f.carrier_id : (sh.carrier_id || false);
        const carrierId = carrierRaw ? parseInt(carrierRaw, 10) : false;
        if (!weight || weight <= 0) {
            this.notification.add("Enter a non-zero weight before generating the label.", { type: "danger" });
            return;
        }
        if (!carrierId) {
            this.notification.add("Pick an outbound carrier first.", { type: "danger" });
            return;
        }
        try {
            const res = await rpc("/fp/workspace/generate_label", {
                job_id: this.state.jobId,
                weight: weight,
                service_type: serviceType,
                carrier_id: carrierId,
            });
            if (res && res.ok) {
                this.notification.add(
                    "Label generated — tracking " + (res.tracking_number || "n/a"),
                    { type: "success" },
                );
                await this.refresh();
            } else {
                this.notification.add((res && res.error) || "Label generation failed", { type: "danger" });
            }
        } catch (err) {
            this.notification.add(err.message || String(err), { type: "danger" });
        }
    }

    async onViewLabel() {
        const sh = (this.state.data && this.state.data.shipping) || {};
        if (!sh.label_attachment_id) return;
        try {
            // Route through fusion_pdf_preview (CLAUDE.md PDF-preview rule).
            const action = await rpc("/web/dataset/call_kw", {
                model: "ir.attachment",
                method: "action_fusion_preview",
                args: [[sh.label_attachment_id]],
                kwargs: { title: "Shipping Label" },
            });
            if (action) await this.action.doAction(action);
        } catch (err) {
            // Fallback: plain content open if the preview helper is absent.
            window.open("/web/content/" + sh.label_attachment_id, "_blank");
        }
    }

    async onMarkShipped() {
        try {
            const res = await rpc("/fp/workspace/mark_shipped", { job_id: this.state.jobId });
            if (res && res.ok) {
                this.notification.add(
                    "Marked shipped: " + ((res.shipped || []).join(", ") || "done"),
                    { type: "success" },
                );
                await this.refresh();
            } else {
                this.notification.add((res && res.error) || "Mark shipped failed", { type: "danger" });
            }
        } catch (err) {
            this.notification.add(err.message || String(err), { type: "danger" });
        }
    }
  • Step 3: Commit (with the XML + SCSS in Task 7/8 — or commit JS now and the rest after)

Defer commit to end of Phase 5 (Task 8) so the panel lands as one coherent change.

Task 7: Shipping panel markup in job_workspace.xml

Files:

  • Modify: fusion_plating_shopfloor/static/src/xml/job_workspace.xml (insert after the 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" ...>:

                        <!-- SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
                             Shown when the job is awaiting_ship. Actions are
                             enabled only when ALL of the order's jobs are
                             awaiting_ship (spec D4 ship-together). -->
                        <div t-if="state.data.shipping" class="o_fp_ws_ship">
                            <div class="o_fp_ws_ship_head">
                                <span class="o_fp_ws_ship_icon">🚚</span>
                                <span class="o_fp_ws_ship_title">Ship Order</span>
                                <span t-if="state.data.shipping.has_label"
                                      class="o_fp_chip o_fp_chip_info">
                                    Label ready · <t t-esc="state.data.shipping.tracking_number or 'no tracking'"/>
                                </span>
                            </div>

                            <!-- Not-ready: read-only, list the blocking jobs -->
                            <div t-if="!state.data.shipping.ready"
                                 class="o_fp_ws_ship_waiting">
                                <i class="fa fa-hourglass-half"/> Waiting on:
                                <t t-foreach="state.data.shipping.not_ready"
                                   t-as="nr" t-key="nr.wo_name">
                                    <span class="o_fp_chip o_fp_chip_warning">
                                        <t t-esc="nr.wo_name"/><t t-esc="nr.state_label"/>
                                    </span>
                                </t>
                            </div>

                            <!-- Ready: inputs + actions -->
                            <t t-if="state.data.shipping.ready">
                                <div class="o_fp_ws_ship_fields">
                                    <label>Carrier
                                        <select class="form-select"
                                                t-on-change="(ev) => this.onShipInput('carrier_id', ev)">
                                            <option value="">— pick carrier —</option>
                                            <t t-foreach="state.data.shipping.carrier_options"
                                               t-as="c" t-key="c.id">
                                                <option t-att-value="c.id"
                                                        t-att-selected="c.id === state.data.shipping.carrier_id">
                                                    <t t-esc="c.name"/>
                                                </option>
                                            </t>
                                        </select>
                                    </label>
                                    <label>Service
                                        <select class="form-select"
                                                t-on-change="(ev) => this.onShipInput('service_type', ev)">
                                            <option value="">Carrier default</option>
                                            <t t-foreach="state.data.shipping.service_options"
                                               t-as="s" t-key="s[0]">
                                                <option t-att-value="s[0]"
                                                        t-att-selected="s[0] === state.data.shipping.service_type">
                                                    <t t-esc="s[1]"/>
                                                </option>
                                            </t>
                                        </select>
                                    </label>
                                    <label>Weight
                                        <input type="number" step="0.001" inputmode="decimal"
                                               class="form-control"
                                               t-att-value="state.data.shipping.weight || ''"
                                               t-on-blur="(ev) => this.onShipInput('weight', ev)"/>
                                    </label>
                                </div>
                                <div class="o_fp_ws_ship_actions">
                                    <button class="btn btn-primary me-2" t-on-click="onGenerateLabel">
                                        <i class="fa fa-tag"/> Generate Label
                                    </button>
                                    <button t-if="state.data.shipping.has_label"
                                            class="btn btn-light me-2" t-on-click="onViewLabel">
                                        <i class="fa fa-print"/> View / Print Label
                                    </button>
                                    <button class="btn btn-success" t-on-click="onMarkShipped">
                                        <i class="fa fa-check-circle"/> Mark Shipped
                                    </button>
                                </div>
                            </t>
                        </div>

Task 8: Shipping panel SCSS + commit

Files:

  • Modify: fusion_plating_shopfloor/static/src/scss/job_workspace.scss (append panel styles with a dark-mode branch)

  • Step 1: Add styles

Append to job_workspace.scss. Match the receiving-card token approach already in this file (reuse the existing o_fp_ws_rcv* colour vars if present; otherwise these explicit hex values with a dark branch per CLAUDE.md dark-mode rule):

// ---- Shipping panel (tablet receiving+shipping 2026-05-29) ---------------
$o-webclient-color-scheme: bright !default;

$_fp-ship-bg-hex:     #f0f7ff;
$_fp-ship-border-hex: #b6d4fe;
$_fp-ship-text-hex:   #1d1f1e;

@if $o-webclient-color-scheme == dark {
    $_fp-ship-bg-hex:     #18222e !global;
    $_fp-ship-border-hex: #2f4256 !global;
    $_fp-ship-text-hex:   #e6e6e6 !global;
}

.o_fp_ws_ship {
    background-color: var(--fp-ship-bg, $_fp-ship-bg-hex);
    border: 1px solid var(--fp-ship-border, $_fp-ship-border-hex);
    color: var(--fp-ship-text, $_fp-ship-text-hex);
    border-radius: 8px;
    padding: 12px 14px;
    margin-bottom: 12px;

    .o_fp_ws_ship_head {
        display: flex;
        align-items: center;
        gap: 8px;
        font-weight: 600;
        margin-bottom: 8px;
    }
    .o_fp_ws_ship_icon { font-size: 1.25rem; }
    .o_fp_ws_ship_title { font-size: 1.05rem; }

    .o_fp_ws_ship_waiting {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 6px;
        font-size: 0.95rem;
    }

    .o_fp_ws_ship_fields {
        display: flex;
        flex-wrap: wrap;
        gap: 12px;
        margin-bottom: 10px;

        label {
            display: flex;
            flex-direction: column;
            font-size: 0.85rem;
            font-weight: 500;
            min-width: 160px;
            flex: 1;
        }
        .form-select, .form-control { margin-top: 4px; }
    }

    .o_fp_ws_ship_actions {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
    }
}
  • Step 2: Bump shopfloor version (if not already bumped in Task 5)

If Phase 4 already bumped fusion_plating_shopfloor, bump it again here so the asset bundle re-hashes for the JS/XML/SCSS change.

  • Step 3: Commit the panel
git add fusion_plating_shopfloor/static/src/js/job_workspace.js \
        fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
        fusion_plating_shopfloor/static/src/scss/job_workspace.scss \
        fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(shopfloor): tablet Shipping panel on the Job Workspace

Carrier/service/weight inputs + Generate Label + Mark Shipped, gated
read-only until all of the order's jobs are awaiting_ship (ship-together)."

Phase 6 — Deploy to entech + manual verification

The OWL panel + the live FedEx label call can only be exercised on entech (carrier configured there; local is Community without the FedEx sandbox wired). The user runs from a Mac — verify on the iPad/entech browser, not a Windows-side localhost preview.

Task 9: Deploy the four modules to entech

Files: none (deployment)

  • Step 1: Push commits
git push origin main
  • Step 2: Sync the changed files to entech (LXC 111 / pve-worker5)

For each changed file, copy to /mnt/extra-addons/custom/<module>/... per the CLAUDE.md entech file-copy pattern:

cat <LOCAL_FILE> | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<REMOTE_PATH>'"

(Or git pull on entech if the repo is checked out there — confirm with the user which sync method they use for entech.)

  • Step 3: Upgrade the four modules (single stop/start)
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
  su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
    -u fusion_plating_receiving,fusion_plating_logistics,fusion_plating_jobs,fusion_plating_shopfloor \
    --stop-after-init\" && systemctl start odoo'"
  • Step 4: Bust the asset cache (SCSS/JS changed)
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
ssh pve-worker5 "pct exec 111 -- systemctl restart odoo"

Task 10: Manual walkthrough on the tablet

Files: none (verification)

  • Step 1: Receiving as a technician

On the iPad (or entech browser), PIN-unlock as a Technician, open the plant kanban → tap a card in the Receiving column → in the workspace, set Boxes Received, adjust a line qty, tap Mark Counted, then Close Receiving. Confirm: no error, the card leaves Receiving, and (back office) the order's receiving status shows received. Previously this AccessError'd for a technician.

  • Step 2: Shipping a single-job order

Find a job in awaiting_ship, open the workspace, confirm the Ship Order panel shows. Pick carrier + service, enter weight, tap Generate Label → tracking # toasts and "View / Print Label" appears (PDF opens via the preview dialog; ZPL is on the receiving for the Zebra). Tap Mark Shipped → job goes done, leaves the Shipping column.

  • Step 3: Shipping a multi-job order (ship-together gate)

Find an order with ≥2 jobs where one is awaiting_ship and a sibling is still in progress. Open the awaiting_ship job → the Ship Order panel shows the read-only Waiting on: WO-xxxx banner and no action buttons. Finish the sibling, refresh → the panel becomes actionable; Mark Shipped marks all the order's jobs done together.

  • Step 4: Confirm no regression for Shop Manager / Manager

As a Shop Manager, confirm the back-office receiving form + the label-generate wizard still work unchanged.


Notes / deferred (from spec §8§9)

  • Carrier default source: the panel offers a carrier picker seeded from rec.x_fc_carrier_id. If the shop wants a per-customer or company default to pre-select, that's a follow-up (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.