chore(plating): de-dash shipped code + intake-neutral customer emails
Replace em-dashes and en-dashes with hyphens across 789 shipped source files (py/xml/js/scss) so the delivered module reads as human-written; em-dashes had become a recognizable AI-generated tell. Internal .md dev notes are excluded. The WO-sticker mojibake strippers keep their dash search targets (now written — / –). No logic changes: comments and display strings only; validated with py_compile + lxml parse. Rewrite the 7 customer notification emails to be intake-neutral (ship-in / drop-off / pickup) and repair-aware, and fix the Shipped email documents line (packing slip vs bill of lading; certificate only when issued). Subjects use a hyphen separator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,9 @@
|
||||
Replaces the data path for both fp_shopfloor_tablet (legacy) and
|
||||
fp_plant_overview (legacy). Two modes:
|
||||
|
||||
station — paired station's work centre + Unassigned + next 1-2 WCs
|
||||
station - paired station's work centre + Unassigned + next 1-2 WCs
|
||||
in the recipe flow. The physical-station view.
|
||||
all_plant — every active work centre, sorted by recipe flow.
|
||||
all_plant - every active work centre, sorted by recipe flow.
|
||||
|
||||
The card payload shape matches the existing plant_overview cards so
|
||||
the front-end can share the KanbanCard component. Tapping a card opens
|
||||
@@ -72,7 +72,7 @@ class FpLandingController(http.Controller):
|
||||
if mode == 'station' and relevant_wcs:
|
||||
# In station mode, include the relevant WCs + Unassigned only.
|
||||
# The OR-of-three-leaves is what makes this filter "this WC,
|
||||
# the next 1-2 WCs, or Unassigned" — three branches OR'd.
|
||||
# the next 1-2 WCs, or Unassigned" - three branches OR'd.
|
||||
step_dom = step_dom + [
|
||||
'|', '|',
|
||||
('work_centre_id', 'in', relevant_wcs.ids),
|
||||
@@ -113,7 +113,7 @@ class FpLandingController(http.Controller):
|
||||
'cards': cards_by_wc[0],
|
||||
})
|
||||
|
||||
# ---- KPIs — 4 tech-relevant tiles --------------------------------
|
||||
# ---- KPIs - 4 tech-relevant tiles --------------------------------
|
||||
ready = sum(1 for s in steps if s.state == 'ready')
|
||||
running = sum(1 for s in steps if s.state == 'in_progress')
|
||||
|
||||
@@ -165,7 +165,7 @@ class FpLandingController(http.Controller):
|
||||
def _step_to_card(self, step):
|
||||
"""Build the kanban card payload for one fp.job.step.
|
||||
|
||||
Shape matches the KanbanCard OWL component (Phase 1 — P1.7).
|
||||
Shape matches the KanbanCard OWL component (Phase 1 - P1.7).
|
||||
"""
|
||||
job = step.job_id
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"""JSON-RPC endpoints for the Manager Desk (client action).
|
||||
|
||||
Native fp.job / fp.job.step edition. Speaks fp.job/fp.job.step
|
||||
end-to-end — payload keys, variables, and RPC kwargs all use the
|
||||
end-to-end - payload keys, variables, and RPC kwargs all use the
|
||||
job/step vocabulary.
|
||||
|
||||
Manager Desk ergonomics:
|
||||
@@ -62,7 +62,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
"""Manager-level view: unassigned jobs, in-progress jobs, team workload."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overview snapshot — used on initial load + 8s auto-refresh
|
||||
# Overview snapshot - used on initial load + 8s auto-refresh
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
|
||||
def overview(self, facility_id=None, known_hash=None):
|
||||
@@ -255,7 +255,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
|
||||
|
||||
# ---- Compliance / floor-health KPIs ---------------------------
|
||||
# v19.0.24.3.0 — added the things a manager actually loses sleep
|
||||
# v19.0.24.3.0 - added the things a manager actually loses sleep
|
||||
# over: stale steps (S10/S16), missed bake windows (S15), open
|
||||
# holds (S17 + QC fail), pending QCs, predecessor-locked steps
|
||||
# (S14), and the cert pipeline. Without these the Manager Desk
|
||||
@@ -272,7 +272,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
QC = env.get('fusion.plating.quality.check')
|
||||
Cert = env.get('fp.certificate')
|
||||
|
||||
# Stale steps — same domains the crons use
|
||||
# Stale steps - same domains the crons use
|
||||
stale_paused = Step.search_count([
|
||||
('state', '=', 'paused'),
|
||||
('write_date', '<=', stale_paused_threshold),
|
||||
@@ -282,7 +282,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
('date_started', '<=', stale_inprogress_threshold),
|
||||
])
|
||||
|
||||
# Missed bake windows — compliance bomb
|
||||
# Missed bake windows - compliance bomb
|
||||
missed_bakes = BakeWindow.search_count([
|
||||
('state', '=', 'missed_window'),
|
||||
])
|
||||
@@ -295,7 +295,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
('state', 'in', ('on_hold', 'under_review')),
|
||||
])
|
||||
|
||||
# Predecessor-locked steps — operators stuck waiting on others
|
||||
# Predecessor-locked steps - operators stuck waiting on others
|
||||
# Only count when the step is ready/paused AND has the lock flag
|
||||
# AND has at least one earlier-sequence step still open.
|
||||
pred_locked = 0
|
||||
@@ -463,7 +463,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
user = env.user
|
||||
previous = step.assigned_user_id.name or '—'
|
||||
previous = step.assigned_user_id.name or '-'
|
||||
step.assigned_user_id = user.id
|
||||
step.message_post(
|
||||
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (
|
||||
@@ -473,7 +473,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
return {'ok': True, 'user_name': user.name}
|
||||
|
||||
# ======================================================================
|
||||
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
|
||||
# Phase 4 tablet redesign - 3 new tabs on the Manager Desk
|
||||
# ======================================================================
|
||||
|
||||
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
|
||||
@@ -494,7 +494,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
|
||||
|
||||
# Group jobs by workflow_state_id (in-memory — list is bounded by
|
||||
# Group jobs by workflow_state_id (in-memory - list is bounded by
|
||||
# active job count, typically < 200)
|
||||
by_stage = {ws.id: [] for ws in all_states}
|
||||
for job in jobs:
|
||||
@@ -532,7 +532,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
"""Approval Inbox: things waiting on a manager decision.
|
||||
|
||||
Four buckets: holds to release, certs to issue, recent scrap to
|
||||
acknowledge, override requests (deferred — empty for now).
|
||||
acknowledge, override requests (deferred - empty for now).
|
||||
"""
|
||||
env = request.env
|
||||
|
||||
@@ -553,7 +553,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
h.hold_reason, h.hold_reason or '',
|
||||
),
|
||||
'qty': h.qty_on_hold or 0,
|
||||
'requested_by': h.operator_id.name or '—',
|
||||
'requested_by': h.operator_id.name or '-',
|
||||
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
|
||||
}
|
||||
for h in holds
|
||||
@@ -603,7 +603,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
),
|
||||
'scrap_qty': h.qty_on_hold or 0,
|
||||
'reason': h.description or '',
|
||||
'operator': h.operator_id.name or '—',
|
||||
'operator': h.operator_id.name or '-',
|
||||
'at': fp_format(env, h.create_date) if h.create_date else '',
|
||||
}
|
||||
for h in scrap_holds
|
||||
@@ -614,7 +614,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
'holds_to_release': holds_to_release,
|
||||
'certs_to_issue': certs_to_issue,
|
||||
'scrap_to_review': scrap_to_review,
|
||||
'override_requests': [], # deferred — placeholder
|
||||
'override_requests': [], # deferred - placeholder
|
||||
}
|
||||
|
||||
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
|
||||
@@ -682,7 +682,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
wcs = WC.search(wc_dom)
|
||||
bottlenecks = []
|
||||
for wc in wcs:
|
||||
# Skip work centres with zero queue — no signal
|
||||
# Skip work centres with zero queue - no signal
|
||||
if wc.bottleneck_score <= 0:
|
||||
continue
|
||||
bottlenecks.append({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Tablet endpoints for Sub 12b — Move Parts / Move Rack / Rack Parts /
|
||||
"""Tablet endpoints for Sub 12b - Move Parts / Move Rack / Rack Parts /
|
||||
Stop Timer dialogs.
|
||||
|
||||
All routes JSONRPC, auth='user'. Errors return {ok: False, error: msg}
|
||||
@@ -47,7 +47,7 @@ class FpTabletMoveController(http.Controller):
|
||||
"""Returns the list of allowed tanks for this step.
|
||||
|
||||
Pulls from the recipe node's tank_ids M2M (Sub 12a authored
|
||||
list). Falls back to [] if the recipe node has none — runtime
|
||||
list). Falls back to [] if the recipe node has none - runtime
|
||||
will hide the To Station selector.
|
||||
"""
|
||||
node = step.recipe_node_id
|
||||
@@ -104,7 +104,7 @@ class FpTabletMoveController(http.Controller):
|
||||
skip_rack = bool(
|
||||
ctx.get('fp_skip_rack_assignment') and is_manager)
|
||||
|
||||
# 1. Rack-required gate (soft block — operator may RACK PARTS)
|
||||
# 1. Rack-required gate (soft block - operator may RACK PARTS)
|
||||
if (to_step.requires_rack_assignment
|
||||
and not from_step.rack_id
|
||||
and not skip_rack):
|
||||
@@ -115,7 +115,7 @@ class FpTabletMoveController(http.Controller):
|
||||
'resolve_action': 'open_rack_parts_dialog',
|
||||
})
|
||||
|
||||
# 2. Predecessor lock (Sub 13 — recipe + per-step enforcement).
|
||||
# 2. Predecessor lock (Sub 13 - recipe + per-step enforcement).
|
||||
# Delegates to fp.job.step._fp_should_block_predecessors so the
|
||||
# tablet Move dialog and the backend button_start use the same
|
||||
# decision matrix (recipe.enforce_sequential, step.parallel_start,
|
||||
@@ -128,7 +128,7 @@ class FpTabletMoveController(http.Controller):
|
||||
# BETWEEN from_step and to_step blocks the move (you'd be skipping
|
||||
# an incomplete intermediate stage). The from_step itself is
|
||||
# in-progress BY DEFINITION when advancing partial parts out of
|
||||
# it — counting it (or any earlier step) as an "unfinished
|
||||
# it - counting it (or any earlier step) as an "unfinished
|
||||
# predecessor" blocked every partial advance to a not-yet-started
|
||||
# next step. Steps before from_step are irrelevant: the parts
|
||||
# being moved are physically at from_step, ready for the next
|
||||
@@ -157,7 +157,7 @@ class FpTabletMoveController(http.Controller):
|
||||
Step = request.env['fp.job.step']
|
||||
from_step = Step.browse(from_step_id)
|
||||
to_step = Step.browse(to_step_id)
|
||||
# Available-to-move = parts currently parked here (qty_at_step —
|
||||
# Available-to-move = parts currently parked here (qty_at_step -
|
||||
# the exact number the operator sees on the card). The old
|
||||
# qty_done − qty_scrapped read referenced step fields that don't
|
||||
# exist on fp.job.step (always 0), which is why the move path was
|
||||
@@ -194,7 +194,7 @@ class FpTabletMoveController(http.Controller):
|
||||
from_step = Step.browse(from_step_id)
|
||||
to_step = Step.browse(to_step_id)
|
||||
|
||||
# Hard-block re-check on commit (defence in depth — preview can
|
||||
# Hard-block re-check on commit (defence in depth - preview can
|
||||
# lie if state changed between preview and commit).
|
||||
blockers = self._blockers_for_move(from_step, to_step, qty)
|
||||
hard = [b for b in blockers if b['severity'] == 'hard']
|
||||
@@ -218,7 +218,7 @@ class FpTabletMoveController(http.Controller):
|
||||
for prompt_id, value in (prompt_values or {}).items():
|
||||
self._capture_prompt_value(move, int(prompt_id), value)
|
||||
|
||||
# S23 — required transition-input gate. Runs AFTER value capture
|
||||
# S23 - required transition-input gate. Runs AFTER value capture
|
||||
# so the operator gets credit for whatever they filled in. Raises
|
||||
# UserError if to_step.requires_transition_form=True and any
|
||||
# required transition_input prompt has no value. Rollback unwinds
|
||||
@@ -231,7 +231,7 @@ class FpTabletMoveController(http.Controller):
|
||||
|
||||
# Partial-flow "light up" (2026-06-02 partial-order handling).
|
||||
# A normal forward transfer that parks parts at the destination
|
||||
# makes that stage actionable — flip pending -> ready so the
|
||||
# makes that stage actionable - flip pending -> ready so the
|
||||
# receiving operator immediately sees a "Ready" card in their
|
||||
# column with zero action by anyone. Never downgrade a step that
|
||||
# is already past pending. Hold/scrap/rework/return route parts
|
||||
@@ -239,12 +239,12 @@ class FpTabletMoveController(http.Controller):
|
||||
# transfer_type == 'step'.
|
||||
if transfer_type == 'step' and to_step.state == 'pending':
|
||||
to_step.state = 'ready'
|
||||
# No auto-START — that begins the labour timer, which stays an
|
||||
# No auto-START - that begins the labour timer, which stays an
|
||||
# explicit operator tap (keeps cost accurate; avoids the S16
|
||||
# phantom-timer problem).
|
||||
|
||||
# Auto-finish the source when THIS forward move drained it to zero
|
||||
# parked parts — one fewer tap. Best-effort: swallows finish-gate
|
||||
# parked parts - one fewer tap. Best-effort: swallows finish-gate
|
||||
# failures so the move always stands. Restricted to 'step' moves:
|
||||
# a step drained by a HOLD still has unresolved held parts and
|
||||
# must not auto-finish.
|
||||
@@ -261,7 +261,7 @@ class FpTabletMoveController(http.Controller):
|
||||
if bypass_flags and request.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'):
|
||||
move.message_post(body=_(
|
||||
"Manager bypass activated by %s — flags: %s"
|
||||
"Manager bypass activated by %s - flags: %s"
|
||||
) % (request.env.user.name, ', '.join(bypass_flags)))
|
||||
|
||||
return {'move_id': move.id, 'move_name': move.name}
|
||||
@@ -342,14 +342,14 @@ class FpTabletMoveController(http.Controller):
|
||||
rack = Rack.browse(rack_id)
|
||||
to_step = Step.browse(to_step_id)
|
||||
|
||||
# S23 — pre-check: rack moves don't capture transition prompts
|
||||
# S23 - pre-check: rack moves don't capture transition prompts
|
||||
# (no per-move dialog), so if to_step.requires_transition_form
|
||||
# we must reject up-front and force the operator through Move
|
||||
# Parts (which has the form UI). Without this check, rack moves
|
||||
# silently bypass the audit gate that Move Parts enforces.
|
||||
if (to_step.requires_transition_form
|
||||
and not request.env.context.get('fp_skip_transition_form')):
|
||||
# Use the same model helper for consistency — build a dummy
|
||||
# Use the same model helper for consistency - build a dummy
|
||||
# in-memory move to compute "missing" set, then surface a
|
||||
# clear message that points operators at the right tool.
|
||||
recipe_node = to_step.recipe_node_id
|
||||
@@ -393,7 +393,7 @@ class FpTabletMoveController(http.Controller):
|
||||
batch.qty_at_step_finish = (batch.qty_at_step_finish or 0) + qty
|
||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||
moves.append(move.id)
|
||||
# Partial-flow "light up" — auto-finish the drained source
|
||||
# Partial-flow "light up" - auto-finish the drained source
|
||||
# batch (best-effort; see _fp_try_autofinish_on_drain).
|
||||
if transfer_type == 'step':
|
||||
batch._fp_try_autofinish_on_drain()
|
||||
|
||||
@@ -18,7 +18,7 @@ from odoo.http import request
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Mirrors fusion_plating_jobs.models.fp_job._COLUMN_SEQUENCE exactly.
|
||||
# Keep these two in sync — the column order on the board IS the sequence.
|
||||
# Keep these two in sync - the column order on the board IS the sequence.
|
||||
_COLUMN_LABELS = [
|
||||
('receiving', _('Receiving')),
|
||||
('masking', _('Masking')),
|
||||
@@ -38,13 +38,13 @@ _SORT_PRIORITY = {
|
||||
'no_parts': 1,
|
||||
'bake_due': 2,
|
||||
'awaiting_signoff': 3,
|
||||
'awaiting_cert': 3.5, # spec 2026-05-25 — after awaiting_signoff
|
||||
'awaiting_cert': 3.5, # spec 2026-05-25 - after awaiting_signoff
|
||||
'awaiting_qc': 4,
|
||||
'ready_mine': 5,
|
||||
'running_mine': 6,
|
||||
'ready': 7,
|
||||
'running': 8,
|
||||
'awaiting_ship': 8.5, # spec 2026-05-25 — after running
|
||||
'awaiting_ship': 8.5, # spec 2026-05-25 - after running
|
||||
'idle_warning': 9,
|
||||
'predecessor_locked': 10,
|
||||
'contract_review': 11,
|
||||
@@ -67,7 +67,7 @@ class PlantKanbanController(http.Controller):
|
||||
else env['fp.work.centre'])
|
||||
paired_area = paired.area_kind if paired else None
|
||||
|
||||
# Base domain — in-flight jobs.
|
||||
# Base domain - in-flight jobs.
|
||||
# 2026-05-24 (spec 2026-05-24-shopfloor-live-step-fix-design.md
|
||||
# Defect 4 / Change 3): done + cancelled jobs drop off the live
|
||||
# board. They stay reachable via smart buttons, the Plating Jobs
|
||||
@@ -97,7 +97,7 @@ class PlantKanbanController(http.Controller):
|
||||
domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
|
||||
if filters.get('awaiting_qc'):
|
||||
domain.append(('card_state', '=', 'awaiting_qc'))
|
||||
# Spec 2026-05-25 — post-shop state filter chips
|
||||
# Spec 2026-05-25 - post-shop state filter chips
|
||||
if filters.get('awaiting_cert'):
|
||||
domain.append(('state', '=', 'awaiting_cert'))
|
||||
if filters.get('awaiting_ship'):
|
||||
@@ -114,7 +114,7 @@ class PlantKanbanController(http.Controller):
|
||||
# EVERY stage where it currently has parts (a "presence"), not just
|
||||
# the single active-step column. Cards are keyed by a composite
|
||||
# "{job_id}:{area}" so one job can appear in several columns. A job
|
||||
# whose parts are all at one stage produces exactly one presence —
|
||||
# whose parts are all at one stage produces exactly one presence -
|
||||
# byte-for-byte identical to the previous one-card-per-job board.
|
||||
cards = {}
|
||||
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
||||
@@ -159,7 +159,7 @@ class PlantKanbanController(http.Controller):
|
||||
'awaiting_qc': sum(
|
||||
1 for j in jobs if j.card_state == 'awaiting_qc'
|
||||
),
|
||||
# Spec 2026-05-25 — post-shop state KPIs
|
||||
# Spec 2026-05-25 - post-shop state KPIs
|
||||
'awaiting_cert': sum(
|
||||
1 for j in jobs if j.state == 'awaiting_cert'
|
||||
),
|
||||
@@ -188,7 +188,7 @@ class PlantKanbanController(http.Controller):
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Pair the current user to a work centre (used by station-QR scan from
|
||||
# plant_kanban). 2026-05-25 — replaces the legacy
|
||||
# plant_kanban). 2026-05-25 - replaces the legacy
|
||||
# localStorage-based pairing that shopfloor_landing used; plant_kanban
|
||||
# reads the pairing from res.users.paired_work_centre_ids server-side,
|
||||
# so QR scans must persist there.
|
||||
@@ -230,7 +230,7 @@ def _resolve_card_area(job):
|
||||
"""Pick the column a card lives in.
|
||||
|
||||
Active-step area_kind wins, EXCEPT for no_parts cards which always
|
||||
land in Receiving regardless of active step — the receiver is who
|
||||
land in Receiving regardless of active step - the receiver is who
|
||||
needs to act, and they work the Receiving column. With the
|
||||
live-step priority chain (see fp.job._compute_active_step_id),
|
||||
active_step_id is False only when the job has NO steps at all
|
||||
@@ -242,7 +242,7 @@ def _resolve_card_area(job):
|
||||
and 2026-05-24-recipe-cleanup-design.md Change 6.
|
||||
"""
|
||||
# no_parts cards belong in Receiving regardless of where the
|
||||
# active step is — the receiver is who acts.
|
||||
# active step is - the receiver is who acts.
|
||||
if job.card_state == 'no_parts':
|
||||
return 'receiving'
|
||||
# 2026-05-25 (spec post-shop-cert-shipping-job-states): state drives
|
||||
@@ -255,7 +255,7 @@ def _resolve_card_area(job):
|
||||
return 'shipping'
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
# Orphan fallback — represents a data integrity issue, not a
|
||||
# Orphan fallback - represents a data integrity issue, not a
|
||||
# normal state. Cards here have NO steps assigned at all.
|
||||
return 'receiving'
|
||||
|
||||
@@ -266,7 +266,7 @@ def _job_presences(job):
|
||||
One entry per Shop Floor area where the job currently has parts parked
|
||||
OR an actionable (in_progress / paused / ready) step. This is what lets
|
||||
a split job appear in several columns at once. A job whose parts are
|
||||
all at one stage yields exactly ONE presence — byte-for-byte identical
|
||||
all at one stage yields exactly ONE presence - byte-for-byte identical
|
||||
to the previous one-card-per-job board.
|
||||
"""
|
||||
job = job.sudo()
|
||||
@@ -291,11 +291,11 @@ def _job_presences(job):
|
||||
presences = []
|
||||
for area, steps in by_area.items():
|
||||
qty_here = sum((s.qty_at_step or 0) for s in steps)
|
||||
# A stage shows ONLY where parts physically are (qty_here > 0 —
|
||||
# A stage shows ONLY where parts physically are (qty_here > 0 -
|
||||
# which includes the first-active step's qty_at_step seed) OR where
|
||||
# a step is actively being worked (in_progress / paused — e.g.
|
||||
# a step is actively being worked (in_progress / paused - e.g.
|
||||
# drained to zero but not yet finished). A merely `ready` / `pending`
|
||||
# step with NO parts is a FUTURE stage and must NOT show — otherwise
|
||||
# step with NO parts is a FUTURE stage and must NOT show - otherwise
|
||||
# the job appears in every not-yet-started step at once (these
|
||||
# recipes seed all downstream steps to `ready`, so 6 ready steps =
|
||||
# 6 phantom cards; bug on WO-30061). Strict sequential progress
|
||||
@@ -308,7 +308,7 @@ def _job_presences(job):
|
||||
presences.append((area, _pick_focus_step(steps), qty_here))
|
||||
|
||||
if not presences:
|
||||
# Nothing parked and nothing actionable — fall back to the single
|
||||
# Nothing parked and nothing actionable - fall back to the single
|
||||
# resolved column so the job never vanishes from the board.
|
||||
return [(_resolve_card_area(job), job.active_step_id, 0)]
|
||||
return presences
|
||||
@@ -354,7 +354,7 @@ def _render_presence(job, area, step, qty_here, is_primary, paired):
|
||||
SECONDARY presences derive a simpler state from their own focus step.
|
||||
|
||||
Sudo the job so cross-module reads (sale.order, fp.part.catalog,
|
||||
customer.spec) don't AccessError for low-privilege roles (Rule 13m) —
|
||||
customer.spec) don't AccessError for low-privilege roles (Rule 13m) -
|
||||
the output is denormalized display data; fp.job ACL gates visibility.
|
||||
"""
|
||||
job = job.sudo()
|
||||
@@ -376,7 +376,7 @@ def _render_presence(job, area, step, qty_here, is_primary, paired):
|
||||
card_state = (job.card_state if is_primary
|
||||
else _secondary_card_state(step, paired))
|
||||
|
||||
step_name = step.name if step else _('—')
|
||||
step_name = step.name if step else _('-')
|
||||
step_seq = step.sequence if step else 0
|
||||
step_total = len(job.step_ids)
|
||||
tank_label = ''
|
||||
@@ -401,7 +401,7 @@ def _render_presence(job, area, step, qty_here, is_primary, paired):
|
||||
|
||||
return {
|
||||
'job_id': job.id,
|
||||
# Composite identity — one job can have several presences.
|
||||
# Composite identity - one job can have several presences.
|
||||
'card_key': '%s:%s' % (job.id, area),
|
||||
'area_kind': area,
|
||||
'is_primary': is_primary,
|
||||
@@ -480,7 +480,7 @@ def _state_chip(card_state, step):
|
||||
return {'label': _('📦 Parts in transit'), 'kind': 'no_parts'}
|
||||
if card_state == 'contract_review':
|
||||
return {'label': _('📋 QA-005 review'), 'kind': 'paperwork'}
|
||||
# Spec 2026-05-25 — post-shop states
|
||||
# Spec 2026-05-25 - post-shop states
|
||||
if card_state == 'awaiting_cert':
|
||||
return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
|
||||
if card_state == 'awaiting_ship':
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Multi-rack splitting at Racking — Phase 1 controller. Endpoints run as the
|
||||
# Multi-rack splitting at Racking - Phase 1 controller. Endpoints run as the
|
||||
# technician (request.env.user); the rack-load + division logic lives on
|
||||
# fp.rack.load (core + fusion_plating_jobs extension).
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def _step_can_finish(step):
|
||||
class FpShopfloorController(http.Controller):
|
||||
"""JSON-RPC endpoints for the shop-floor tablet client.
|
||||
|
||||
NOTE — Odoo 19 requires `type='jsonrpc'`. The legacy `type='json'`
|
||||
NOTE - Odoo 19 requires `type='jsonrpc'`. The legacy `type='json'`
|
||||
decorator was removed.
|
||||
"""
|
||||
|
||||
@@ -367,7 +367,7 @@ class FpShopfloorController(http.Controller):
|
||||
if not _step_can_start(step):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': f'Step is in state {step.state} — only ready/paused steps can start.',
|
||||
'error': f'Step is in state {step.state} - only ready/paused steps can start.',
|
||||
}
|
||||
try:
|
||||
step.button_start()
|
||||
@@ -384,7 +384,7 @@ class FpShopfloorController(http.Controller):
|
||||
"""Finish the timer on a fp.job.step.
|
||||
|
||||
finish=True calls button_finish(); other values are no-ops for
|
||||
now (button_pause is not yet implemented on fp.job.step — see
|
||||
now (button_pause is not yet implemented on fp.job.step - see
|
||||
fp_job_step.py).
|
||||
|
||||
button_finish() can raise UserError (e.g. required sign-off
|
||||
@@ -399,7 +399,7 @@ class FpShopfloorController(http.Controller):
|
||||
if not _step_can_finish(step):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': f'Step is in state {step.state} — only in-progress steps can finish.',
|
||||
'error': f'Step is in state {step.state} - only in-progress steps can finish.',
|
||||
}
|
||||
try:
|
||||
step.button_finish()
|
||||
@@ -416,7 +416,7 @@ class FpShopfloorController(http.Controller):
|
||||
# ----------------------------------------------------------------------
|
||||
# Without these endpoints Carlos has to leave the tablet, navigate
|
||||
# to the back-office job form, and edit qty_done / qty_scrapped
|
||||
# there. Most operators won't bother — scrap goes unrecorded and
|
||||
# there. Most operators won't bother - scrap goes unrecorded and
|
||||
# the AS9100 trail breaks. These two endpoints let the tablet bump
|
||||
# both with a single tap. Scrap auto-spawns a hold via fp.job.write
|
||||
# (S17 hook, no extra wiring needed here).
|
||||
@@ -450,7 +450,7 @@ class FpShopfloorController(http.Controller):
|
||||
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on
|
||||
fp.job auto-spawns a fusion.plating.quality.hold for the delta;
|
||||
the operator can edit the description on that hold later.
|
||||
`reason` is optional — passed through to the hold's description.
|
||||
`reason` is optional - passed through to the hold's description.
|
||||
"""
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
@@ -476,7 +476,7 @@ class FpShopfloorController(http.Controller):
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Thickness reading — Fischerscope log entry from inspection station
|
||||
# Thickness reading - Fischerscope log entry from inspection station
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/log_thickness_reading', type='jsonrpc', auth='user')
|
||||
def log_thickness_reading(self, job_id=None, production_id=None,
|
||||
@@ -564,7 +564,7 @@ class FpShopfloorController(http.Controller):
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Quality hold — partial qty split
|
||||
# Quality hold - partial qty split
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/quality_hold', type='jsonrpc', auth='user')
|
||||
def quality_hold(self, step_id=None, workorder_id=None,
|
||||
@@ -634,13 +634,13 @@ class FpShopfloorController(http.Controller):
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Tablet Overview — one-shot dashboard payload
|
||||
# Tablet Overview - one-shot dashboard payload
|
||||
# ----------------------------------------------------------------------
|
||||
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||
# DEPRECATED (Phase 3 tablet redesign - 2026-05-22).
|
||||
# New Shop Floor Landing client action (fp_shopfloor_landing) uses
|
||||
# /fp/landing/kanban. The Tablet Station menu now points at the new
|
||||
# surface. This endpoint stays live as long as the legacy
|
||||
# fp_shopfloor_tablet OWL component is still registered — it consumes
|
||||
# fp_shopfloor_tablet OWL component is still registered - it consumes
|
||||
# the rich payload (my_queue, active_wo, baths, bake_windows, holds,
|
||||
# pending_qcs, stations). Phase 5 cleanup will retire both the
|
||||
# legacy component and this endpoint together.
|
||||
@@ -652,7 +652,7 @@ class FpShopfloorController(http.Controller):
|
||||
fp_shopfloor_landing client action (Phase 3 tablet redesign).
|
||||
"""
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s — "
|
||||
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s - "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
@@ -691,7 +691,7 @@ class FpShopfloorController(http.Controller):
|
||||
steps_ready = len(my_steps.filtered(lambda s: s.state == 'ready'))
|
||||
steps_progress = len(my_steps.filtered(lambda s: s.state == 'in_progress'))
|
||||
|
||||
# KPI scoping — match the panels below so the operator never
|
||||
# KPI scoping - match the panels below so the operator never
|
||||
# sees "12 Quality Holds" on the strip but an empty list in the
|
||||
# panel. Operator-scoped where the panel is operator-scoped;
|
||||
# facility-scoped otherwise. The manager dashboard owns the
|
||||
@@ -717,7 +717,7 @@ class FpShopfloorController(http.Controller):
|
||||
]
|
||||
|
||||
# -- My Queue (top 8) --------------------------------------------
|
||||
# v19.0.24.3.0 — every step row now carries the recipe-author
|
||||
# v19.0.24.3.0 - every step row now carries the recipe-author
|
||||
# fields (instructions / thickness_target / dwell_time_minutes /
|
||||
# bake_setpoint_temp / requires_signoff) plus a S14 predecessor-
|
||||
# block flag so the tablet can:
|
||||
@@ -732,7 +732,7 @@ class FpShopfloorController(http.Controller):
|
||||
partner_name = job.partner_id.name or ''
|
||||
product_name = job.product_id.display_name if job.product_id else ''
|
||||
|
||||
# S14 — predecessor block. Same domain the model enforces.
|
||||
# S14 - predecessor block. Same domain the model enforces.
|
||||
predecessor_blocked = False
|
||||
blocked_by_name = ''
|
||||
if (getattr(step, 'requires_predecessor_done', False)
|
||||
@@ -765,14 +765,14 @@ class FpShopfloorController(http.Controller):
|
||||
'wo_name': step.name or '',
|
||||
'can_start': can_start,
|
||||
'can_finish': _step_can_finish(step),
|
||||
# S13 — recipe-author metadata so operator sees it inline
|
||||
# S13 - recipe-author metadata so operator sees it inline
|
||||
'instructions': step.instructions or '',
|
||||
'thickness_target': step.thickness_target or 0,
|
||||
'thickness_uom': step.thickness_uom or '',
|
||||
'dwell_time_minutes': step.dwell_time_minutes or 0,
|
||||
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
|
||||
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
|
||||
# S14 — predecessor block reason
|
||||
# S14 - predecessor block reason
|
||||
'predecessor_blocked': predecessor_blocked,
|
||||
'blocked_by_name': blocked_by_name,
|
||||
# Sequence so the operator can see "Step 3 of 9"
|
||||
@@ -840,7 +840,7 @@ class FpShopfloorController(http.Controller):
|
||||
]
|
||||
|
||||
# -- Bake windows ------------------------------------------------
|
||||
# v19.0.24.3.0 — scope to the operator's own jobs first so Carlos
|
||||
# v19.0.24.3.0 - scope to the operator's own jobs first so Carlos
|
||||
# doesn't see 6 unrelated plant-wide bakes in his sidebar.
|
||||
# Fallback: facility-wide for managers / when the user has no
|
||||
# active steps. Order missed_window first so accountability
|
||||
@@ -871,7 +871,7 @@ class FpShopfloorController(http.Controller):
|
||||
]
|
||||
|
||||
# -- Quality holds -----------------------------------------------
|
||||
# v19.0.24.3.0 — scope holds to operator's jobs so Carlos's
|
||||
# v19.0.24.3.0 - scope holds to operator's jobs so Carlos's
|
||||
# sidebar isn't flooded with plant-wide HOLD-XXXX from other
|
||||
# crews. Falls back to all-open for managers.
|
||||
hold_states = ('on_hold', 'under_review')
|
||||
@@ -989,17 +989,17 @@ class FpShopfloorController(http.Controller):
|
||||
# ----------------------------------------------------------------------
|
||||
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
|
||||
# ----------------------------------------------------------------------
|
||||
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||
# DEPRECATED (Phase 3 tablet redesign - 2026-05-22).
|
||||
# The new fp_shopfloor_landing component does NOT use this endpoint;
|
||||
# it uses /fp/landing/kanban which already filters per station. The
|
||||
# only remaining consumer is the legacy fp_shopfloor_tablet OWL
|
||||
# component (still registered, no menu pointing at it). Phase 5
|
||||
# cleanup will retire both this endpoint and the legacy component
|
||||
# together — no replacement, the kanban supersedes it entirely.
|
||||
# together - no replacement, the kanban supersedes it entirely.
|
||||
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
|
||||
def queue(self, facility_id=None):
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/queue called by uid %s — "
|
||||
"DEPRECATED /fp/shopfloor/queue called by uid %s - "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
@@ -1063,7 +1063,7 @@ class FpShopfloorController(http.Controller):
|
||||
target_workcenter_id=None):
|
||||
"""Move a step card to a different work centre (drag & drop).
|
||||
|
||||
`source_model` is accepted for backward compatibility but ignored —
|
||||
`source_model` is accepted for backward compatibility but ignored -
|
||||
Plant Overview now only ever serves fp.job.step cards. A target
|
||||
of 0 / falsy clears the work centre.
|
||||
"""
|
||||
@@ -1093,7 +1093,7 @@ class FpShopfloorController(http.Controller):
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||
# DEPRECATED (Phase 3 tablet redesign - 2026-05-22).
|
||||
# The new fp_shopfloor_landing client action has an "All Plant" mode
|
||||
# that supersedes the standalone Plant Overview surface. Old endpoint
|
||||
# stays live for the move_card sibling endpoint and the legacy
|
||||
@@ -1106,10 +1106,10 @@ class FpShopfloorController(http.Controller):
|
||||
New consumers should use /fp/landing/kanban with mode='all_plant'
|
||||
via the fp_shopfloor_landing client action (Phase 3 tablet
|
||||
redesign). Note: /fp/shopfloor/plant_overview/move_card is NOT
|
||||
deprecated — the Landing component still uses it for drag-drop.
|
||||
deprecated - the Landing component still uses it for drag-drop.
|
||||
"""
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/plant_overview called by uid %s — "
|
||||
"DEPRECATED /fp/shopfloor/plant_overview called by uid %s - "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
@@ -1215,7 +1215,7 @@ class FpShopfloorController(http.Controller):
|
||||
# HOT first, then overdue, bake-risk, stuck, due-today, due-soon,
|
||||
# then FIFO. Score sums all those factors so equal-band cards
|
||||
# still rank by combined risk. Tiebreakers: deadline asc, then id
|
||||
# asc — so the oldest card with the same risk wins.
|
||||
# asc - so the oldest card with the same risk wins.
|
||||
FAR_FUTURE = '9999-12-31'
|
||||
for wc_id, cards in cards_by_wc.items():
|
||||
cards.sort(key=lambda c: (
|
||||
@@ -1239,8 +1239,8 @@ class FpShopfloorController(http.Controller):
|
||||
c['urgency_pulse'] = False
|
||||
# Flag every critical card so the template can apply a
|
||||
# plain class instead of relying on the `:has()` CSS
|
||||
# selector — `:has()` re-evaluates on every layout pass
|
||||
# and was the real reason 5–6 second freezes happened
|
||||
# selector - `:has()` re-evaluates on every layout pass
|
||||
# and was the real reason 5-6 second freezes happened
|
||||
# during drag-drop on a busy board (v19.0.24.11.0).
|
||||
c['is_urgent'] = c.get('urgency_band') in (
|
||||
'hot', 'overdue', 'bake_risk',
|
||||
@@ -1291,7 +1291,7 @@ class FpShopfloorController(http.Controller):
|
||||
'cards': cards_by_wc[0],
|
||||
})
|
||||
|
||||
# Sub 12b — Racks pane payload alongside the existing parts cards.
|
||||
# Sub 12b - Racks pane payload alongside the existing parts cards.
|
||||
# Filters to racks currently in active racking-state. Each entry
|
||||
# has the data the OWL plant overview's Racks pane renders:
|
||||
# tag chips, current node breadcrumb, part count, and the
|
||||
@@ -1348,7 +1348,7 @@ class FpShopfloorController(http.Controller):
|
||||
# each card from 7 signals and sort columns by score desc. The score
|
||||
# is internal; the supervisor sees a `urgency_band` chip explaining
|
||||
# WHY the card is where it is ("HOT", "OVERDUE 2d", "Bake by 14:30",
|
||||
# "Paused 28h", etc) — not just a coloured ranking.
|
||||
# "Paused 28h", etc) - not just a coloured ranking.
|
||||
#
|
||||
# Bands chosen so the most severe reason wins the chip (a HOT job
|
||||
# that's also overdue still shows "HOT"). Score adds them all so two
|
||||
@@ -1369,7 +1369,7 @@ class FpShopfloorController(http.Controller):
|
||||
"""Return a dict with urgency_score (int), urgency_band (str),
|
||||
urgency_label (chip text), urgency_icon (FontAwesome class),
|
||||
urgency_tone (chip color: danger/warning/info/muted), and
|
||||
urgency_pulse (bool — chip animates when critical).
|
||||
urgency_pulse (bool - chip animates when critical).
|
||||
|
||||
Higher score = sort earlier. Score is a sum so multiple mildly
|
||||
urgent factors aggregate (e.g. paused 12h + due in 30h together
|
||||
@@ -1418,7 +1418,7 @@ class FpShopfloorController(http.Controller):
|
||||
dd = int(delta_s / 86400)
|
||||
bands.append(('due_soon', f'Due in {dd}d', 'fa-clock-o', 'info', False))
|
||||
|
||||
# 3. Stuck — paused too long, or running past 1.5× planned
|
||||
# 3. Stuck - paused too long, or running past 1.5× planned
|
||||
if step.state == 'paused' and step.write_date:
|
||||
paused_s = (now - step.write_date).total_seconds()
|
||||
if paused_s > 24 * 3600:
|
||||
@@ -1436,7 +1436,7 @@ class FpShopfloorController(http.Controller):
|
||||
score += 200
|
||||
bands.append(('stuck', f'Overrun {ratio:.1f}x', 'fa-bolt', 'danger', True))
|
||||
|
||||
# 4. Bake-window risk — compliance bomb
|
||||
# 4. Bake-window risk - compliance bomb
|
||||
for bw in job_bakes:
|
||||
if bw.state == 'missed_window':
|
||||
score += 400
|
||||
@@ -1482,7 +1482,7 @@ class FpShopfloorController(http.Controller):
|
||||
queued_start=None, job_bakes=None, now=None):
|
||||
"""Convert one fp.job.step to a card dict for Plant Overview.
|
||||
|
||||
v19.0.24.7.0 — caller now precomputes `step_idx`,
|
||||
v19.0.24.7.0 - caller now precomputes `step_idx`,
|
||||
`job_step_count`, and `queued_start` once per job (see
|
||||
plant_overview()) and passes them in. Eliminates per-card
|
||||
re-iteration of job.step_ids; drag-and-drop reload is now
|
||||
@@ -1540,14 +1540,14 @@ class FpShopfloorController(http.Controller):
|
||||
env, step.write_date or step.date_started or job.write_date,
|
||||
)
|
||||
|
||||
# Tags from job priority — keep the existing HOT / Priority labels
|
||||
# Tags from job priority - keep the existing HOT / Priority labels
|
||||
tags = []
|
||||
if job.priority == 'rush':
|
||||
tags.append('HOT')
|
||||
elif job.priority == 'high':
|
||||
tags.append('Priority')
|
||||
|
||||
# Date display — the job's planned start (operator-readable)
|
||||
# Date display - the job's planned start (operator-readable)
|
||||
date_display = fp_format(
|
||||
env, step.date_started or job.date_planned_start, fmt='%-m/%-d',
|
||||
)
|
||||
@@ -1579,7 +1579,7 @@ class FpShopfloorController(http.Controller):
|
||||
getattr(part, 'part_number', '') or part.name or ''
|
||||
)
|
||||
part_revision = getattr(part, 'revision', '') or ''
|
||||
# coating_label kept blank — Phase E removed coating; downstream
|
||||
# coating_label kept blank - Phase E removed coating; downstream
|
||||
# tablet templates read spec_label instead.
|
||||
coating_label = ''
|
||||
|
||||
@@ -1626,7 +1626,7 @@ class FpShopfloorController(http.Controller):
|
||||
'timer_kind': timer_kind,
|
||||
'timer_started_at_iso': timer_started_at_iso,
|
||||
'timer_expected_minutes': step.duration_expected or 0,
|
||||
# 1-based step ordinal + total — replaces raw recipe sequence
|
||||
# 1-based step ordinal + total - replaces raw recipe sequence
|
||||
# in the card badge so the operator sees "step 4 of 9" not
|
||||
# "step 40" (v19.0.24.6.0). Both precomputed by caller.
|
||||
'step_index': step_idx,
|
||||
@@ -1729,7 +1729,7 @@ class FpShopfloorController(http.Controller):
|
||||
'duration_display': _dur_disp(step.duration_actual or 0),
|
||||
'duration_expected_display': _dur_disp(step.duration_expected or 0),
|
||||
'missing_for_release': '',
|
||||
# Operator-facing recipe guidance — without this the
|
||||
# Operator-facing recipe guidance - without this the
|
||||
# tablet UI has no way to surface bake setpoints,
|
||||
# masking patterns, dwell times, etc.
|
||||
'instructions': step.instructions or '',
|
||||
@@ -1813,7 +1813,7 @@ class FpShopfloorController(http.Controller):
|
||||
'children': [],
|
||||
})
|
||||
else:
|
||||
# No recipe — synth a root with steps as direct operation children.
|
||||
# No recipe - synth a root with steps as direct operation children.
|
||||
child_nodes = []
|
||||
for step in all_steps:
|
||||
step_data = _step_payload(step)
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoints for the tablet PIN gate (Phase 6 tablet redesign).
|
||||
|
||||
POST /fp/tablet/tiles — list of tiles for the lock screen
|
||||
POST /fp/tablet/unlock — verify PIN + clear/increment failure counter
|
||||
POST /fp/tablet/set_pin — self-service set/change PIN
|
||||
POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN
|
||||
POST /fp/tablet/ping — bump server-side last-active timestamp
|
||||
POST /fp/tablet/tiles - list of tiles for the lock screen
|
||||
POST /fp/tablet/unlock - verify PIN + clear/increment failure counter
|
||||
POST /fp/tablet/set_pin - self-service set/change PIN
|
||||
POST /fp/tablet/reset_pin_for - manager-only reset of another user's PIN
|
||||
POST /fp/tablet/ping - bump server-side last-active timestamp
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md
|
||||
"""
|
||||
@@ -67,7 +67,7 @@ def _initials_from(name):
|
||||
def _avatar_gradient_for(user_id):
|
||||
"""Deterministic gradient per user id.
|
||||
|
||||
Modulo the gradient list — same operator gets the same color
|
||||
Modulo the gradient list - same operator gets the same color
|
||||
across sessions so they learn to recognize their own tile. 8
|
||||
colors are enough for a small shop (10-15 ops) with at most 2
|
||||
color collisions on average.
|
||||
@@ -99,19 +99,19 @@ class FpTabletController(http.Controller):
|
||||
"""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/set_pin — self-service set or change
|
||||
# /fp/tablet/set_pin - self-service set or change
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user')
|
||||
def set_pin(self, new_pin, old_pin=None, reset_token=None, user_id=None):
|
||||
"""Set or change a tablet PIN. Three authorization paths:
|
||||
|
||||
1. old_pin provided — verify old PIN matches stored hash, then set
|
||||
new (existing behavior; user_id ignored — uses env.user).
|
||||
2. reset_token provided — verify HMAC-signed token from
|
||||
1. old_pin provided - verify old PIN matches stored hash, then set
|
||||
new (existing behavior; user_id ignored - uses env.user).
|
||||
2. reset_token provided - verify HMAC-signed token from
|
||||
/fp/tablet/verify_reset_code (Task 3). Token carries the
|
||||
target user_id; new PIN set on that user even though the
|
||||
browser session is still the kiosk. (Spec D16.)
|
||||
3. Neither — only allowed for users with NO existing PIN
|
||||
3. Neither - only allowed for users with NO existing PIN
|
||||
hash on env.user; same as the pre-redesign behavior.
|
||||
"""
|
||||
env = request.env
|
||||
@@ -156,17 +156,17 @@ class FpTabletController(http.Controller):
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/request_reset_code — self-service PIN reset (D1 + D2)
|
||||
# /fp/tablet/request_reset_code - self-service PIN reset (D1 + D2)
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/request_reset_code',
|
||||
type='jsonrpc', auth='user')
|
||||
def request_reset_code(self, user_id):
|
||||
"""Generate + email a temporary 4-digit code to `user_id`.
|
||||
|
||||
Per spec D1 (create flow) and D2 (reset flow) — same backend,
|
||||
Per spec D1 (create flow) and D2 (reset flow) - same backend,
|
||||
triggered from either the no-PIN tile click or the 3-fail
|
||||
forgot button. Caller passes user_id (already known from the
|
||||
tile click), NOT login — matches the existing unlock_session
|
||||
tile click), NOT login - matches the existing unlock_session
|
||||
signature.
|
||||
|
||||
Returns:
|
||||
@@ -198,7 +198,7 @@ class FpTabletController(http.Controller):
|
||||
}
|
||||
if not (shop_branch_ids & set(target.all_group_ids.ids)):
|
||||
return {'ok': False, 'error': 'no_role'}
|
||||
# Resolve recipient email — MUST mirror the mail.template's
|
||||
# Resolve recipient email - MUST mirror the mail.template's
|
||||
# `{{ object.email or object.login }}` priority so the masked
|
||||
# address shown in the UI matches where the email actually
|
||||
# lands. (Pre-2026-05-25 these two were inverted: the controller
|
||||
@@ -225,7 +225,7 @@ class FpTabletController(http.Controller):
|
||||
requester_ip=request.httprequest.remote_addr or '',
|
||||
)
|
||||
except UserError as e:
|
||||
# Rate limit — parse the minutes hint out of the message
|
||||
# Rate limit - parse the minutes hint out of the message
|
||||
msg = str(e.args[0]) if e.args else str(e)
|
||||
wait_min = 60 # fallback
|
||||
import re
|
||||
@@ -239,7 +239,7 @@ class FpTabletController(http.Controller):
|
||||
}
|
||||
# Render + send IMMEDIATELY (force_send=True). The legacy
|
||||
# `force_send=False` queued the mail for the `Mail: Email Queue
|
||||
# Manager` cron — which runs every 1 HOUR on entech (not per
|
||||
# Manager` cron - which runs every 1 HOUR on entech (not per
|
||||
# minute), so a tech tapping "Send temporary PIN" could wait up
|
||||
# to 60 min for the code. PIN reset is an interactive flow; the
|
||||
# user is staring at the screen. Synchronous send adds ~1s of
|
||||
@@ -259,7 +259,7 @@ class FpTabletController(http.Controller):
|
||||
'tablet_pin_reset email dispatch failed for uid %s: %s',
|
||||
target.id, exc,
|
||||
)
|
||||
# Still return success — the code IS issued; user can
|
||||
# Still return success - the code IS issued; user can
|
||||
# request another if email truly failed. Audit captures it.
|
||||
# Audit
|
||||
write_event(env,
|
||||
@@ -270,7 +270,7 @@ class FpTabletController(http.Controller):
|
||||
return {'ok': True, 'masked_email': masked}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/verify_reset_code — exchange code for reset_token
|
||||
# /fp/tablet/verify_reset_code - exchange code for reset_token
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/verify_reset_code',
|
||||
type='jsonrpc', auth='user')
|
||||
@@ -299,7 +299,7 @@ class FpTabletController(http.Controller):
|
||||
return {'ok': False, 'error': 'no_active_code'}
|
||||
ok, err = active._verify_and_consume(str(code))
|
||||
if not ok:
|
||||
# Audit failures too — useful for forensics
|
||||
# Audit failures too - useful for forensics
|
||||
write_event(env,
|
||||
event_type='failed_unlock', # reuse existing label
|
||||
attempted_user_id=target.id,
|
||||
@@ -312,7 +312,7 @@ class FpTabletController(http.Controller):
|
||||
if err == 'wrong_code':
|
||||
resp['attempts_left'] = attempts_left
|
||||
return resp
|
||||
# Success — mint reset_token and audit
|
||||
# Success - mint reset_token and audit
|
||||
token = Reset.sudo()._sign_reset_token(target.id)
|
||||
write_event(env,
|
||||
event_type='pin_reset_code_verified',
|
||||
@@ -330,7 +330,7 @@ class FpTabletController(http.Controller):
|
||||
return f'{local[0]}***@{domain}'
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/reset_pin_for — manager-only
|
||||
# /fp/tablet/reset_pin_for - manager-only
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user')
|
||||
def reset_pin_for(self, user_id):
|
||||
@@ -352,7 +352,7 @@ class FpTabletController(http.Controller):
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/unlock_session — verify PIN + mint REAL Odoo session as tech
|
||||
# /fp/tablet/unlock_session - verify PIN + mint REAL Odoo session as tech
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/unlock_session', type='jsonrpc', auth='user')
|
||||
def unlock_session(self, user_id, pin):
|
||||
@@ -408,7 +408,7 @@ class FpTabletController(http.Controller):
|
||||
# Attempt the real Odoo session swap via the custom auth manager.
|
||||
# session.authenticate validates credentials through _check_credentials,
|
||||
# issues a new sid, sets the cookie, returns the user dict.
|
||||
# NB: Odoo 19's Session.authenticate signature is (env, credential) —
|
||||
# NB: Odoo 19's Session.authenticate signature is (env, credential) -
|
||||
# passing request.db (a string) raises TypeError: 'str' object is not
|
||||
# callable on the internal env(user=None, su=False) reset. Pass
|
||||
# request.env instead.
|
||||
@@ -420,7 +420,7 @@ class FpTabletController(http.Controller):
|
||||
'pin': pin},
|
||||
)
|
||||
except AccessDenied:
|
||||
# Wrong PIN — increment failure counter
|
||||
# Wrong PIN - increment failure counter
|
||||
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
|
||||
threshold = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_threshold', 5))
|
||||
@@ -464,14 +464,14 @@ class FpTabletController(http.Controller):
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/lock_session — destroy tech session + re-auth as kiosk
|
||||
# /fp/tablet/lock_session - destroy tech session + re-auth as kiosk
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/lock_session', type='jsonrpc', auth='user')
|
||||
def lock_session(self, reason='manual'):
|
||||
"""Lock the tablet — destroy the tech's session and re-auth the
|
||||
"""Lock the tablet - destroy the tech's session and re-auth the
|
||||
browser as fp_tablet_kiosk. Audit-log the event.
|
||||
|
||||
`reason` is one of 'manual' / 'idle' / 'ceiling' — controls which
|
||||
`reason` is one of 'manual' / 'idle' / 'ceiling' - controls which
|
||||
event_type gets written. The corresponding event_type names:
|
||||
manual -> manual_lock
|
||||
idle -> idle_lock
|
||||
@@ -493,7 +493,7 @@ class FpTabletController(http.Controller):
|
||||
|
||||
# Find the matching open session_event so we can compute duration.
|
||||
# We look for the most recent unlock for this user without a
|
||||
# session_ended_at — that's the open one.
|
||||
# session_ended_at - that's the open one.
|
||||
SessionEvent = env['fp.tablet.session.event'].sudo()
|
||||
open_event = SessionEvent.search([
|
||||
('event_type', '=', 'unlock'),
|
||||
@@ -569,7 +569,7 @@ class FpTabletController(http.Controller):
|
||||
'needs_kiosk_relogin': True}
|
||||
|
||||
try:
|
||||
# Odoo 19 signature: (env, credential) — see unlock_session note.
|
||||
# Odoo 19 signature: (env, credential) - see unlock_session note.
|
||||
request.session.authenticate(
|
||||
request.env,
|
||||
{'type': 'password',
|
||||
@@ -585,20 +585,20 @@ class FpTabletController(http.Controller):
|
||||
return {'ok': True, 'locked_at': now.isoformat()}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/tiles — lock-screen tile grid
|
||||
# /fp/tablet/tiles - lock-screen tile grid
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
|
||||
def tiles(self, station_id=None):
|
||||
env = request.env
|
||||
# Phase 1 permissions overhaul (2026-05-24): show everyone on the
|
||||
# SHOP branch — Technician, Shop Manager, Manager, Quality Manager,
|
||||
# SHOP branch - Technician, Shop Manager, Manager, Quality Manager,
|
||||
# Owner. Managers/Owners need to "chip in" on the floor occasionally.
|
||||
# Search-based query because res.groups.user_ids returns DIRECT
|
||||
# memberships only — implied groups (Owner → ... → Technician) don't
|
||||
# memberships only - implied groups (Owner → ... → Technician) don't
|
||||
# get stored in user.groups_id by Odoo's group-write propagation.
|
||||
# OR across the 5 shop-branch role group ids; sales-branch users
|
||||
# (Sales Rep / Sales Manager directly held without any shop-branch
|
||||
# role) are intentionally excluded — they don't operate the tablet.
|
||||
# role) are intentionally excluded - they don't operate the tablet.
|
||||
shop_branch_xmlids = (
|
||||
'fusion_plating.group_fp_technician',
|
||||
'fusion_plating.group_fp_shop_manager_v2',
|
||||
@@ -647,7 +647,7 @@ class FpTabletController(http.Controller):
|
||||
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
|
||||
# has_photo lets the frontend skip the avatar img when
|
||||
# the user has no uploaded photo (avoids the 1×1 default
|
||||
# image flash). sudo-read of image_128 — the field is
|
||||
# image flash). sudo-read of image_128 - the field is
|
||||
# restricted to the user themselves otherwise.
|
||||
'has_photo': bool(u_sudo.image_128),
|
||||
'avatar_gradient': _avatar_gradient_for(u.id),
|
||||
@@ -665,7 +665,7 @@ class FpTabletController(http.Controller):
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
kiosk_uid = kiosk.id if kiosk else None
|
||||
# tz_name — resolved per fp_tz.fp_user_tz (kiosk user.tz → company
|
||||
# tz_name - resolved per fp_tz.fp_user_tz (kiosk user.tz → company
|
||||
# x_fc_default_tz → UTC). Frontend uses Intl.DateTimeFormat with
|
||||
# this tz so the lock-screen clock follows the FP regional
|
||||
# setting, not the iPad's system tz (caught 2026-05-25 when the
|
||||
@@ -680,12 +680,12 @@ class FpTabletController(http.Controller):
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/ping — heartbeat used by the OWL component on every action
|
||||
# /fp/tablet/ping - heartbeat used by the OWL component on every action
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/ping', type='jsonrpc', auth='user')
|
||||
def ping(self):
|
||||
"""Lightweight heartbeat. Used by the OWL component to confirm
|
||||
the server-side session is alive. The session uid IS the tech
|
||||
post-Phase-G — no extra plumbing needed.
|
||||
post-Phase-G - no extra plumbing needed.
|
||||
"""
|
||||
return {'ok': True, 'server_time': fields.Datetime.now().isoformat()}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# /fp/tank/<id> — mobile-friendly tank status page. Linked from NFC
|
||||
# /fp/tank/<id> - mobile-friendly tank status page. Linked from NFC
|
||||
# tags on the physical tank. The operator taps the tag with a phone,
|
||||
# the tag's URL opens this page in their default browser.
|
||||
#
|
||||
@@ -44,7 +44,7 @@ class FpTankStatusController(http.Controller):
|
||||
('state', 'in', ('in_progress', 'paused')),
|
||||
], order='date_started desc', limit=1)
|
||||
|
||||
# Up to 5 ready steps for this tank — the operator's "what's
|
||||
# Up to 5 ready steps for this tank - the operator's "what's
|
||||
# coming next" signal.
|
||||
ready_steps = Step.search([
|
||||
('tank_id', '=', tank.id),
|
||||
|
||||
@@ -9,10 +9,10 @@ data (spec PDF, attachments, chatter) + action endpoints (hold, sign-off,
|
||||
milestone advance).
|
||||
|
||||
Endpoints:
|
||||
POST /fp/workspace/load — full payload for one fp.job
|
||||
POST /fp/workspace/hold — create quality.hold with photo
|
||||
POST /fp/workspace/sign_off — capture signature + finish step
|
||||
POST /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
POST /fp/workspace/load - full payload for one fp.job
|
||||
POST /fp/workspace/hold - create quality.hold with photo
|
||||
POST /fp/workspace/sign_off - capture signature + finish step
|
||||
POST /fp/workspace/advance_milestone - fire next_milestone_action
|
||||
|
||||
Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md
|
||||
"""
|
||||
@@ -31,7 +31,7 @@ class FpWorkspaceController(http.Controller):
|
||||
"""JSON-RPC endpoints for the JobWorkspace OWL client action."""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/load — full workspace payload
|
||||
# /fp/workspace/load - full workspace payload
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/load', type='jsonrpc', auth='user')
|
||||
def load(self, job_id):
|
||||
@@ -90,7 +90,7 @@ class FpWorkspaceController(http.Controller):
|
||||
# Drives the embedded rack-split panel inside this step's row.
|
||||
'is_racking': step.area_kind == 'racking',
|
||||
'state': step.state,
|
||||
# Partial-order handling — parts currently parked at this
|
||||
# Partial-order handling - parts currently parked at this
|
||||
# step. Drives the "Send to next" button visibility + the
|
||||
# per-step "N here" hint; the Move dialog pre-fills from the
|
||||
# same number via the preview endpoint.
|
||||
@@ -100,7 +100,7 @@ class FpWorkspaceController(http.Controller):
|
||||
'work_centre_name': step.work_centre_id.name or '',
|
||||
'duration_actual': step.duration_actual or 0,
|
||||
'duration_expected': step.duration_expected or 0,
|
||||
# fp_isoformat_utc — preserves UTC with explicit +00:00
|
||||
# fp_isoformat_utc - preserves UTC with explicit +00:00
|
||||
# offset so the JS timer parses it as UTC (not local wall
|
||||
# time). fp_format would convert to user tz first, then
|
||||
# the JS would re-interpret that wall time as UTC and
|
||||
@@ -301,20 +301,20 @@ 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
|
||||
# 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
|
||||
# before honouring any bypass param — this is for UI only.
|
||||
# before honouring any bypass param - this is for UI only.
|
||||
'is_manager': env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
),
|
||||
# Note: the rack-split panel is gated per-step via each step's
|
||||
# 'is_racking' flag (area_kind == 'racking'), embedded in the
|
||||
# racking step's row — not a job-level panel.
|
||||
# racking step's row - not a job-level panel.
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/hold — create a quality.hold from HoldComposer
|
||||
# /fp/workspace/hold - create a quality.hold from HoldComposer
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
|
||||
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
|
||||
@@ -348,7 +348,7 @@ class FpWorkspaceController(http.Controller):
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# Attach photo if provided (base64 string from the camera input).
|
||||
# Photo attach failure does NOT roll back the hold — log + continue.
|
||||
# Photo attach failure does NOT roll back the hold - log + continue.
|
||||
attachment_id = False
|
||||
if photo_data:
|
||||
try:
|
||||
@@ -379,13 +379,13 @@ class FpWorkspaceController(http.Controller):
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/finish_step — structured-error finish with manager bypass
|
||||
# /fp/workspace/finish_step - structured-error finish with manager bypass
|
||||
# ======================================================================
|
||||
# Wraps step.button_finish with structured response shape so the
|
||||
# frontend can render the FpFinishBlockDialog (Cancel / Record /
|
||||
# Bypass) instead of a generic toast. Manager bypass requires the
|
||||
# caller to set bypass_required_inputs=True AND the server confirms
|
||||
# the user has the fusion_plating_manager group — trusting the
|
||||
# the user has the fusion_plating_manager group - trusting the
|
||||
# context flag alone would let any operator bypass via raw RPC.
|
||||
|
||||
@http.route('/fp/workspace/finish_step', type='jsonrpc', auth='user')
|
||||
@@ -395,7 +395,7 @@ class FpWorkspaceController(http.Controller):
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
|
||||
# Server-side bypass authorization — can't trust the client.
|
||||
# Server-side bypass authorization - can't trust the client.
|
||||
is_manager = env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
)
|
||||
@@ -450,7 +450,7 @@ class FpWorkspaceController(http.Controller):
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# /fp/workspace/sign_off - capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri=None):
|
||||
@@ -477,7 +477,7 @@ class FpWorkspaceController(http.Controller):
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save your signature.'}
|
||||
elif not user.x_fc_signature_image:
|
||||
# No drawing AND no saved signature — nothing to sign with.
|
||||
# No drawing AND no saved signature - nothing to sign with.
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required. Draw one to continue.',
|
||||
@@ -493,7 +493,7 @@ class FpWorkspaceController(http.Controller):
|
||||
return {'ok': True, 'step_id': step.id, 'state': step.state}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
# /fp/workspace/advance_milestone - fire next_milestone_action
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
|
||||
def advance_milestone(self, job_id):
|
||||
@@ -504,7 +504,7 @@ class FpWorkspaceController(http.Controller):
|
||||
if not job.next_milestone_action:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'No milestone advance available — finish all steps first.',
|
||||
'error': 'No milestone advance available - finish all steps first.',
|
||||
}
|
||||
try:
|
||||
job.action_advance_next_milestone()
|
||||
@@ -532,11 +532,11 @@ class FpWorkspaceController(http.Controller):
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# Receiving — pre-recipe box-count + damage log (Spec C1+C2 2026-05-24)
|
||||
# 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
|
||||
# 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',
|
||||
@@ -554,7 +554,7 @@ class FpWorkspaceController(http.Controller):
|
||||
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.'
|
||||
'Receiving is %s - only Awaiting Parts / Counted are editable.'
|
||||
) % rec.state}
|
||||
try:
|
||||
if box_count_in is not None:
|
||||
@@ -562,7 +562,7 @@ class FpWorkspaceController(http.Controller):
|
||||
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
|
||||
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',
|
||||
@@ -631,7 +631,7 @@ class FpWorkspaceController(http.Controller):
|
||||
'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 —
|
||||
# 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 []):
|
||||
@@ -676,11 +676,11 @@ class FpWorkspaceController(http.Controller):
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# Shipping — generate outbound label + mark shipped (2026-05-29)
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
@@ -724,7 +724,7 @@ class FpWorkspaceController(http.Controller):
|
||||
'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
|
||||
# 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,
|
||||
@@ -740,7 +740,7 @@ class FpWorkspaceController(http.Controller):
|
||||
_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".
|
||||
# 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': (
|
||||
|
||||
Reference in New Issue
Block a user