feat(fusion_plating): partial order handling on the shop floor

Operators can now see and advance a job's parts across multiple stages
at once (e.g. 10 Masking / 20 Plating / 20 Baking on one 50-part job).
Tracking model C (fluid per-stage quantities + existing hold/scrap/
rework records for exceptions); board option 2 (a card per occupied
stage); wait-to-reconverge close. Additive only — no new model, no
migration, no change to the close/cert/ship lifecycle.

Board (fusion_plating_shopfloor/controllers/plant_kanban.py):
- One card PER (job, stage), composite key "{job_id}:{area}". Unsplit
  jobs render exactly as before. _job_presences/_render_presence;
  primary presence keeps full job card_state, secondary presences
  derive state from their focus step.

Card (plant_card.js/.xml/.scss):
- "20 of 50 here" badge; tap opens the workspace focused on that
  stage's step (focus_step_id, already accepted by the workspace).

Move + light-up (move_controller.py, fusion_plating_jobs/fp_job_step.py):
- Availability/pre-fill now from qty_at_step (step had no qty_done/
  qty_scrapped fields — the old read was always 0, dead path).
- Forward move auto-flips destination pending->ready (no auto-start;
  labour timer stays explicit) and auto-finishes a drained source
  (best-effort). Predecessor gate is qty-aware: a step with real
  arrived parts is startable regardless of upstream completion
  (_fp_has_real_incoming, single source of truth for can_start /
  blocker / button_start / move blockers).

Operator advance (job_workspace.js):
- "Send -> <next>" action on in_progress/paused steps opens the slimmed
  Move dialog (qty steppers, no keyboard; advanced fields collapsed).
  Was only wired into the deprecated shopfloor_tablet before.

Close (fp_job.py):
- button_mark_done counts move-based scrap (_fp_scrapped_via_moves) into
  qty_scrapped and derives qty_done = qty - scrapped (was blindly
  = job.qty, over-counting). Reconciliation gate unchanged.

Static-validated: pyflakes (py), lxml parse (xml), node --check (js).
Dynamic tests + browser check need an installed env (entech/trial) —
plating modules can't install on the local Community DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-02 00:32:52 -04:00
parent 249adf8145
commit ca44461b6f
16 changed files with 544 additions and 100 deletions

View File

@@ -147,7 +147,12 @@ class FpTabletMoveController(http.Controller):
Step = request.env['fp.job.step']
from_step = Step.browse(from_step_id)
to_step = Step.browse(to_step_id)
qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
# 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
# effectively unusable. See partial-order-handling design.
qty = from_step.qty_at_step or 0
return {
'ok': True,
'qty_available': qty,
@@ -186,7 +191,7 @@ class FpTabletMoveController(http.Controller):
if hard:
raise UserError(hard[0]['message'])
qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
qty_avail = from_step.qty_at_step or 0
move = Move.create({
'job_id': from_step.job_id.id,
'from_step_id': from_step.id,
@@ -214,6 +219,28 @@ class FpTabletMoveController(http.Controller):
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
# 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
# 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
# elsewhere and must NOT auto-ready a recipe step, so gate on
# 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
# 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
# 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.
if transfer_type == 'step':
from_step._fp_try_autofinish_on_drain()
# Manager-bypass audit trail
ctx = request.env.context
bypass_flags = [
@@ -279,7 +306,7 @@ class FpTabletMoveController(http.Controller):
'batches': [
{
'step_id': s.id,
'qty': (s.qty_done or 0) - (s.qty_scrapped or 0),
'qty': s.qty_at_step or 0,
'part_number': (s.job_id.product_id.default_code or ''),
'wo_number': s.job_id.name or '',
}
@@ -343,7 +370,7 @@ class FpTabletMoveController(http.Controller):
moves = []
for batch in Step.search([('rack_id', '=', rack.id)]):
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)
qty = batch.qty_at_step or 0
move = Move.create({
'job_id': batch.job_id.id,
'from_step_id': batch.id,
@@ -353,9 +380,19 @@ class FpTabletMoveController(http.Controller):
'rack_id': rack.id,
'to_tank_id': to_tank_id or False,
})
batch.qty_at_step_finish = qty
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
# batch (best-effort; see _fp_try_autofinish_on_drain).
if transfer_type == 'step':
batch._fp_try_autofinish_on_drain()
# Auto-ready the destination once parts have arrived (pending ->
# ready) so the receiving operator sees an actionable card. No
# auto-start (labour timer stays an explicit tap).
if transfer_type == 'step' and to_step.state == 'pending':
to_step.state = 'ready'
rack.racking_state = 'in_use'
return {'move_ids': moves, 'count': len(moves)}

View File

@@ -10,7 +10,7 @@ docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
"""
import json
import logging
from datetime import date, datetime, timedelta
from datetime import date, datetime
from odoo import _, http
from odoo.http import request
@@ -110,19 +110,28 @@ class PlantKanbanController(http.Controller):
jobs = Job.search(domain, limit=500)
# Bucket by area_kind of the active step (or 'receiving' when no
# active step yet — matches the contract_review / no_parts states
# that live in Receiving column per spec §3 D5).
# Partial-order handling (2026-06-02): a job shows up as a card in
# 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 —
# byte-for-byte identical to the previous one-card-per-job board.
cards = {}
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
for job in jobs:
area = _resolve_card_area(job)
cards_by_area.setdefault(area, []).append(job.id)
cards[str(job.id)] = _render_card(job, paired)
active_area = (job.active_step_id.area_kind
if job.active_step_id else _resolve_card_area(job))
for area, focus_step, qty_here in _job_presences(job):
key = '%s:%s' % (job.id, area)
cards[key] = _render_presence(
job, area, focus_step, qty_here,
area == active_area, paired,
)
cards_by_area.setdefault(area, []).append(key)
# Sort within each column by priority then due date
for area in cards_by_area:
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
cards_by_area[area].sort(key=lambda k: _sort_key(cards[k]))
columns = [
{
@@ -251,21 +260,99 @@ def _resolve_card_area(job):
return 'receiving'
def _render_card(job, paired):
"""Build the full card payload for one fp.job."""
# Sudo the job recordset so cross-module field reads (sale.order,
# fp.part.catalog, fusion.plating.customer.spec) don't AccessError
# for low-privilege roles like Technician. The output is denormalized
# display data; the underlying record visibility is controlled by the
# caller's fp.job ACL (Technician can read all jobs).
def _job_presences(job):
"""Return the list of (area, focus_step, qty_here) presences for a 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
to the previous one-card-per-job board.
"""
job = job.sudo()
Step = job.env['fp.job.step']
# Post-shop + no-parts states are single-column, state-driven (mirrors
# _resolve_card_area). No per-stage fan-out once the job has cleared
# the line or hasn't received parts yet.
if job.card_state == 'no_parts':
return [('receiving', job.active_step_id, 0)]
if job.state == 'awaiting_cert':
return [('inspection', Step, 0)]
if job.state == 'awaiting_ship':
return [('shipping', Step, 0)]
open_steps = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
)
by_area = {}
for s in open_steps:
by_area.setdefault(s.area_kind or 'plating', []).append(s)
presences = []
for area, steps in by_area.items():
qty_here = sum((s.qty_at_step or 0) for s in steps)
actionable = any(
s.state in ('in_progress', 'paused', 'ready') for s in steps
)
if qty_here > 0 or actionable:
presences.append((area, _pick_focus_step(steps), qty_here))
if not presences:
# 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
def _pick_focus_step(steps):
"""The most-actionable step in an area: in_progress > paused > ready >
pending, lowest sequence within a state. Drives the presence card's
step label, operator pill, and tap target (focus_step_id)."""
ordered = sorted(steps, key=lambda s: s.sequence or 0)
for state in ('in_progress', 'paused', 'ready', 'pending'):
for s in ordered:
if s.state == state:
return s
return ordered[0] if ordered else None
def _secondary_card_state(step, paired):
"""Card state for a NON-primary presence (a stage other than the job's
active step). Derived purely from the focus step so the operator at
that stage gets an honest 'running' / 'ready' chip. The PRIMARY
presence keeps the full job-level card_state (holds, QC, bake, etc.)."""
if not step:
return 'ready'
mine = bool(
paired and step.work_centre_id
and step.work_centre_id.id == paired.id
)
if step.state == 'in_progress':
return 'running_mine' if mine else 'running'
if step.state == 'paused':
return 'running'
# ready / pending → queued at this stage
return 'ready_mine' if mine else 'ready'
def _render_presence(job, area, step, qty_here, is_primary, paired):
"""Build a card payload for one (job, stage) presence.
The PRIMARY presence (the job's active-step column) carries the full
job-level card_state so every existing job-level signal (hold, QC,
bake-due, sign-off, idle, post-shop) renders exactly as before.
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) —
the output is denormalized display data; fp.job ACL gates visibility.
"""
job = job.sudo()
step = job.active_step_id
try:
timeline = json.loads(job.mini_timeline_json or '[]')
except (TypeError, ValueError):
timeline = []
# Cross-module field probes (sudo'd via job.sudo() above)
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
so = job.sale_order_id
@@ -274,10 +361,11 @@ def _render_card(job, paired):
if so and 'x_fc_po_number' in so._fields:
po_number = so.x_fc_po_number or ''
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
tags = _compute_tags(job, part, spec)
# Step + tank labels
card_state = (job.card_state if is_primary
else _secondary_card_state(step, paired))
step_name = step.name if step else _('')
step_seq = step.sequence if step else 0
step_total = len(job.step_ids)
@@ -285,23 +373,15 @@ def _render_card(job, paired):
if step and step.work_centre_id:
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
# State chip
state_chip = _state_chip(job.card_state, step)
state_chip = _state_chip(card_state, step)
# Operator pill (only when step has an assigned user)
operator = None
if step and step.assigned_user_id:
u = step.assigned_user_id
operator = {
'id': u.id,
'name': u.name,
'initials': _initials_for(u),
}
operator = {'id': u.id, 'name': u.name, 'initials': _initials_for(u)}
# Icon row
icons = _icons(job, step)
# Due label
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
is_overdue = (
bool(job.date_deadline)
@@ -311,9 +391,17 @@ def _render_card(job, paired):
return {
'job_id': job.id,
# Composite identity — one job can have several presences.
'card_key': '%s:%s' % (job.id, area),
'area_kind': area,
'is_primary': is_primary,
# Partial-order fields: parts parked at THIS stage vs whole job.
'qty_here': int(qty_here or 0),
'job_qty': int(job.qty or 0),
'focus_step_id': step.id if step else False,
'wo_name': job.display_wo_name or job.name or '',
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
'card_state': job.card_state or '',
'is_mine': card_state in ('ready_mine', 'running_mine'),
'card_state': card_state or '',
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
if job.date_deadline else None),
'due_label': due_label,

View File

@@ -76,6 +76,11 @@ class FpWorkspaceController(http.Controller):
'kind': step.kind or 'other',
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
'state': step.state,
# 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.
'qty_at_step': int(getattr(step, 'qty_at_step', 0) or 0),
'assigned_user_id': step.assigned_user_id.id or False,
'assigned_user_name': step.assigned_user_id.name or '',
'work_centre_name': step.work_centre_id.name or '',