feat(workspace): pre-recipe receiving card with box count + damage log

Adds the receiver workflow to the Job Workspace tablet view (was the
gap behind WO-30057 sitting in Receiving with no way to advance).
Receivers no longer need to go to the backend form.

Workspace card (renders above the step list when fp.receiving in
state draft/counted on the linked SO):
- Draft state: numeric box-count input + per-line received_qty /
  condition picker (good/damaged/mixed) + Damage Log panel + Mark
  Counted button. Autosaves on input blur.
- Counted state: read-only summary (boxes, parts, who/when) +
  Damage Log still editable + Close Receiving button.
- Closed: card disappears, recipe takes over.

New FpDamageDialog OWL modal:
- Severity pill picker (Cosmetic / Functional / Rejected) with
  color-coded active state
- Required description textarea
- Action Required pill picker (None / Notify / Return / As-Is)
- Photo capture: both "Take Photo" (input capture="environment"
  triggers tablet camera) AND "Upload" (file picker fallback).
  Multi-photo with preview grid + per-photo remove.

5 new endpoints on workspace_controller.py:
- receiving_save_lines (autosave box_count_in + per-line qty/cond)
- receiving_mark_counted (wraps action_mark_counted)
- receiving_close (wraps action_close)
- damage_create (creates fp.receiving.damage + attaches base64 photos)
- damage_delete (removes a damage row)

No model changes — wraps existing fp.receiving actions and damage
CRUD. C3 (outbound shipping carrier/label) is a separate spec.

Spec: in-conversation brainstorm (C1+C2) following the 2026-05-24
workspace step actions spec; no standalone doc since scope is small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 19:08:30 -04:00
parent 170398ab6f
commit eed1c4619d
7 changed files with 1004 additions and 2 deletions

View File

@@ -21,6 +21,7 @@ import logging
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
@@ -133,6 +134,58 @@ class FpWorkspaceController(http.Controller):
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
)
# ---- Receivings (pre-recipe box-count gate) ---------------------
# Spec C1+C2 (2026-05-24): tablet UI for box count + per-line
# received qty + damage log. Renders a card above the step list
# for any receiving in state draft/counted. Closed receivings drop
# off (recipe takes over). See fp.receiving model + the receiver
# persona note in CLAUDE.md Sub 8.
receivings_payload = []
so = job.sale_order_id if 'sale_order_id' in job._fields else False
if so and 'x_fc_receiving_ids' in so._fields:
for rec in so.x_fc_receiving_ids.filtered(
lambda r: r.state in ('draft', 'counted')
):
receivings_payload.append({
'id': rec.id,
'name': rec.name or '',
'state': rec.state,
'state_label': dict(rec._fields['state'].selection).get(
rec.state, rec.state,
),
'box_count_in': int(rec.box_count_in or 0),
'expected_qty': int(rec.expected_qty or 0),
'received_qty': int(rec.received_qty or 0),
'received_date': fp_format(
env, rec.received_date, fmt='%Y-%m-%d %H:%M',
) if rec.received_date else '',
'received_by_name': rec.received_by_id.name or '',
'lines': [{
'id': line.id,
'part_number': line.part_number or (
line.part_catalog_id.part_number
if line.part_catalog_id else ''
),
'description': line.description or '',
'expected_qty': int(line.expected_qty or 0),
'received_qty': int(line.received_qty or 0),
'condition': line.condition or 'good',
} for line in rec.line_ids],
'damages': [{
'id': dmg.id,
'severity': dmg.severity or 'cosmetic',
'severity_label': dict(
dmg._fields['severity'].selection,
).get(dmg.severity, dmg.severity),
'description': dmg.description or '',
'action_required': dmg.action_required or 'none',
'action_label': dict(
dmg._fields['action_required'].selection,
).get(dmg.action_required, dmg.action_required),
'photo_count': len(dmg.photo_ids),
} for dmg in rec.damage_ids],
})
return {
'ok': True,
'job': {
@@ -189,6 +242,7 @@ class FpWorkspaceController(http.Controller):
for m in chatter
],
'required_certs': required_certs,
'receivings': receivings_payload,
}
# ======================================================================
@@ -343,3 +397,147 @@ class FpWorkspaceController(http.Controller):
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
}
# ======================================================================
# Receiving — pre-recipe box-count + damage log (Spec C1+C2 2026-05-24)
# ======================================================================
# Mirrors the backend fp.receiving form just enough for the receiver
# persona to count boxes, log damage with photos, and close the
# receiving from the tablet workspace. No new backend models — wraps
# action_mark_counted / action_close and fp.receiving.damage CRUD.
@http.route('/fp/workspace/receiving_save_lines',
type='jsonrpc', auth='user')
def receiving_save_lines(self, receiving_id, box_count_in=None,
lines=None):
"""Bulk-save box_count_in (on the receiving) + per-line
received_qty/condition. Called on input blur for autosave.
`lines` is a list of {id, received_qty, condition}.
"""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
if rec.state not in ('draft', 'counted'):
return {'ok': False, 'error': (
'Receiving is %s — only Awaiting Parts / Counted are editable.'
) % rec.state}
try:
if box_count_in is not None:
rec.box_count_in = int(box_count_in or 0)
for line_dict in (lines or []):
line = env['fp.receiving.line'].browse(int(line_dict['id']))
if not line.exists() or line.receiving_id.id != rec.id:
continue # stale/foreign line id — skip silently
line.write({
'received_qty': int(line_dict.get('received_qty') or 0),
'condition': line_dict.get('condition') or 'good',
})
except Exception as exc:
_logger.exception("workspace/receiving_save_lines failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True}
@http.route('/fp/workspace/receiving_mark_counted',
type='jsonrpc', auth='user')
def receiving_mark_counted(self, receiving_id):
"""Receiver finished counting → advance state draft → counted."""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
try:
rec.action_mark_counted()
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
except Exception as exc:
_logger.exception("workspace/receiving_mark_counted failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'state': rec.state}
@http.route('/fp/workspace/receiving_close',
type='jsonrpc', auth='user')
def receiving_close(self, receiving_id):
"""Close the receiving → advance state counted → closed.
Clears the no_parts card_state on the linked job(s).
"""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
try:
rec.action_close()
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
except Exception as exc:
_logger.exception("workspace/receiving_close failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'state': rec.state}
@http.route('/fp/workspace/damage_create',
type='jsonrpc', auth='user')
def damage_create(self, receiving_id, description,
severity='cosmetic', action_required='none',
photos=None):
"""Create a fp.receiving.damage row. `photos` is a list of
{filename, data_base64}; each gets attached as ir.attachment
and added to damage.photo_ids.
"""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
if not description or not description.strip():
return {'ok': False, 'error': 'Description is required'}
try:
damage = env['fp.receiving.damage'].create({
'receiving_id': rec.id,
'description': description.strip(),
'severity': severity or 'cosmetic',
'action_required': action_required or 'none',
})
# Attach photos (base64 from camera / file picker). Failure
# on a single attach doesn't roll back the damage row —
# operator can re-upload via the back office form if needed.
photo_atts = env['ir.attachment']
for p in (photos or []):
try:
att = env['ir.attachment'].create({
'name': p.get('filename') or 'damage.jpg',
'datas': p.get('data_base64') or '',
'res_model': 'fp.receiving.damage',
'res_id': damage.id,
})
photo_atts |= att
except Exception:
_logger.exception(
'damage_create: photo attach failed for damage %s',
damage.id,
)
if photo_atts:
damage.photo_ids = [(6, 0, photo_atts.ids)]
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
except Exception as exc:
_logger.exception("workspace/damage_create failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'damage_id': damage.id}
@http.route('/fp/workspace/damage_delete',
type='jsonrpc', auth='user')
def damage_delete(self, damage_id):
"""Remove a damage row (operator changed their mind, or logged
in error). CASCADE removes the attachment link rows; the
ir.attachment records themselves persist (might be referenced
elsewhere)."""
env = request.env
dmg = env['fp.receiving.damage'].browse(int(damage_id))
if not dmg.exists():
return {'ok': False, 'error': 'Damage row not found'}
try:
dmg.unlink()
except Exception as exc:
_logger.exception("workspace/damage_delete failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True}