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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user