feat(shopfloor): tablet shipping endpoints + /load shipping payload

generate_label (sudo'd FedEx machinery) and mark_shipped (as the technician),
both enforcing the order-level ship-together gate. /load now returns a
shipping block (carrier/service/weight + readiness + any existing label)
when the job is awaiting_ship.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-29 09:16:36 -04:00
parent bb814a46ff
commit f1273798cd
2 changed files with 106 additions and 1 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.36.0.3',
'version': '19.0.36.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """

View File

@@ -189,6 +189,35 @@ class FpWorkspaceController(http.Controller):
} for dmg in rec.damage_ids],
})
# ---- 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. ship_rec (not rec) to
# avoid shadowing the receivings loop variable above.
shipping_payload = None
if job.state == 'awaiting_ship':
info = job._fp_order_ship_state()
ship_rec = info['order_receiving']
shipment = ship_rec.x_fc_outbound_shipment_id if ship_rec else False
carriers = env['delivery.carrier'].sudo().search([], limit=50)
shipping_payload = {
'ready': info['ready'],
'not_ready': info['not_ready'],
'receiving_id': ship_rec.id if ship_rec else False,
'carrier_id': (ship_rec.x_fc_carrier_id.id
if ship_rec and ship_rec.x_fc_carrier_id else False),
'carrier_options': [{'id': c.id, 'name': c.name} for c in carriers],
'service_type': (ship_rec.x_fc_outbound_service_type or '') if ship_rec else '',
'service_options': env['fp.receiving']._fp_get_service_type_selection(),
'weight': (ship_rec.x_fc_weight or 0.0) if ship_rec else 0.0,
'has_label': bool(ship_rec.x_fc_has_label) if ship_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),
}
return {
'ok': True,
'job': {
@@ -246,6 +275,7 @@ class FpWorkspaceController(http.Controller):
],
'required_certs': required_certs,
'receivings': receivings_payload,
'shipping': shipping_payload,
# 2026-05-24 — is_manager surfaces to the JS so it can offer
# the manager-bypass affordance (e.g. on the required-inputs
# gate dialog). Server-side endpoints re-check the group
@@ -622,3 +652,78 @@ class FpWorkspaceController(http.Controller):
_logger.exception("workspace/damage_delete failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True}
# ======================================================================
# 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}