feat(jobs): Phase 2 — card_state + mini_timeline + precedence helpers

PV-Phase2 of the plant-view redesign.

Implements the 13-state classifier on fp.job:
  - card_state Char field, stored + indexed for fast filtering
  - _compute_card_state with explicit precedence dispatch matching
    spec §6.2 / §9.3 exactly (no_parts → on_hold → awaiting_signoff
    → awaiting_qc → bake_due → predecessor_locked → idle_warning →
    done → contract_review → running/_mine → ready/_mine)

Six precedence helpers, each isolated for testability:
  _fp_inbound_not_received, _fp_has_open_hold, _fp_has_pending_qc,
  _fp_bake_window_due_soon, _fp_is_mine + _fp_has_unfinished_predecessors
  on fp.job.step.

mini_timeline_json compute: 9-element array (one per column) with
state in {done, current, upcoming} and an optional 'variant' on the
current marker keyed to card_state for renderer color mapping.

Verified live:
  - 14 jobs in contract_review (no active step yet)
  - 8 in no_parts (confirmed + draft fp.receiving)
  - 1 running (WO-30051 with Pre-Measurements at Plating column)
  - mini_timeline JSON renders the full 9-area structure with the
    plating slot marked current+variant=running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-23 20:48:14 -04:00
parent 63d692b322
commit 7c2ae84e32
2 changed files with 228 additions and 0 deletions

View File

@@ -10,6 +10,8 @@
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
import datetime
import json
import logging
from markupsafe import Markup
@@ -20,6 +22,15 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Plant-view kanban — fixed 9-column sequence (spec §4.1). The order
# here is the visual order on the board AND the order in the
# mini-timeline strip. Never reorder; columns are first-class identity.
_COLUMN_SEQUENCE = [
'receiving', 'masking', 'blasting', 'racking', 'plating',
'baking', 'de_racking', 'inspection', 'shipping',
]
class FpJob(models.Model):
_inherit = 'fp.job'
@@ -138,6 +149,213 @@ class FpJob(models.Model):
'defensive). Drives JobWorkspace landing focus.',
)
# ===== 2026-05-23 Plant-view kanban — card_state + mini_timeline ====
card_state = fields.Char(
string='Card State (plant view)',
compute='_compute_card_state',
store=True,
index=True,
help='One of 13 mutually-exclusive states driving the plant-view '
'kanban card chrome. See spec §6 for the catalog and the '
'explicit precedence dispatch. Stored for fast filter '
'queries (count by state, filter "blocked", etc.).',
)
mini_timeline_json = fields.Text(
string='Mini-Timeline (JSON)',
compute='_compute_mini_timeline_json',
help='Serialized 9-element array, one per Shop Floor column, '
'each {area, state, variant?}. Card UI reads this to render '
'the bottom timeline strip without knowing recipe shape.',
)
# ----- Precedence helpers (spec §6.2 + §9.4) -----------------------
# Each returns a bool. _compute_card_state calls them in precedence
# order and the first truthy one wins. Centralized here so future
# audit-found states can be added by writing one new helper + one new
# rule in the dispatcher.
def _fp_inbound_not_received(self):
"""no_parts — job confirmed, customer's parts in transit."""
self.ensure_one()
if self.state != 'confirmed':
return False
so = self.sale_order_id
if not so or 'x_fc_receiving_ids' not in so._fields:
return False
return any(r.state == 'draft' for r in so.x_fc_receiving_ids)
def _fp_has_open_hold(self):
"""on_hold — fusion.plating.quality.hold open on this job."""
self.ensure_one()
if 'fusion.plating.quality.hold' not in self.env:
return False
Hold = self.env['fusion.plating.quality.hold']
return bool(Hold.search_count([
('job_id', '=', self.id),
('state', '=', 'open'),
]))
def _fp_has_pending_qc(self):
"""awaiting_qc — quality check in draft / in_progress on this job."""
self.ensure_one()
if 'fusion.plating.quality.check' not in self.env:
return False
QC = self.env['fusion.plating.quality.check']
return bool(QC.search_count([
('job_id', '=', self.id),
('state', 'in', ('draft', 'in_progress')),
]))
def _fp_bake_window_due_soon(self, threshold_hours=1):
"""bake_due — bake.window awaiting_bake with deadline < threshold."""
self.ensure_one()
if 'fusion.plating.bake.window' not in self.env:
return False
Window = self.env['fusion.plating.bake.window']
cutoff = fields.Datetime.now() + datetime.timedelta(hours=threshold_hours)
domain = [
('state', '=', 'awaiting_bake'),
('bake_required_by', '<=', cutoff),
]
# bake.window's link to a job varies across installs — fall back
# to SO when no direct fp.job link exists.
if 'job_id' in Window._fields:
domain.append(('job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Window._fields:
domain.append(('sale_order_id', '=', self.sale_order_id.id))
else:
return False
return bool(Window.search_count(domain))
def _fp_is_mine(self, user=None):
"""*_mine variants — active step's work centre is in operator's
paired stations. MVP holds 1 row in paired_work_centre_ids; Phase 2
multi-station picker can populate multiple."""
self.ensure_one()
user = user or self.env.user
step = self.active_step_id
if not step or not step.work_centre_id:
return False
if 'paired_work_centre_ids' not in user._fields:
return False
return step.work_centre_id.id in user.paired_work_centre_ids.ids
# ----- card_state compute -------------------------------------------
@api.depends(
'state',
'active_step_id',
'active_step_id.state',
'active_step_id.requires_signoff',
'active_step_id.signoff_user_id',
'active_step_id.last_activity_at',
'active_step_id.area_kind',
'active_step_id.recipe_node_id.default_kind',
)
def _compute_card_state(self):
"""Dispatch matching spec §6.2 / §9.3 explicit precedence list."""
for job in self:
# Edge: no active step (all pending or all done)
if not job.active_step_id:
if (job.state == 'confirmed'
and job._fp_inbound_not_received()):
job.card_state = 'no_parts'
else:
job.card_state = 'contract_review'
continue
step = job.active_step_id
# Rule 1 — no_parts
if job._fp_inbound_not_received():
job.card_state = 'no_parts'
continue
# Rule 2 — on_hold
if job._fp_has_open_hold():
job.card_state = 'on_hold'
continue
# Rule 3 — awaiting_signoff (S22)
if (step.requires_signoff and step.state == 'done'
and not step.signoff_user_id):
job.card_state = 'awaiting_signoff'
continue
# Rule 4 — awaiting_qc
if job._fp_has_pending_qc():
job.card_state = 'awaiting_qc'
continue
# Rule 5 — bake_due
if job._fp_bake_window_due_soon():
job.card_state = 'bake_due'
continue
# Rule 6 — predecessor_locked
if (step._fp_should_block_predecessors()
and step._fp_has_unfinished_predecessors()):
job.card_state = 'predecessor_locked'
continue
# Rule 7 — idle_warning (S16)
if (step.state == 'in_progress'
and step._fp_is_idle(threshold_hours=8)):
job.card_state = 'idle_warning'
continue
# Rule 8 — done
if step.area_kind == 'shipping' and job.state == 'done':
job.card_state = 'done'
continue
# Rule 9 — contract_review
if (step.recipe_node_id
and step.recipe_node_id.default_kind == 'contract_review'):
job.card_state = 'contract_review'
continue
# Rules 10/12 — running (mine vs not)
if step.state == 'in_progress':
job.card_state = ('running_mine' if job._fp_is_mine()
else 'running')
continue
# Rules 11/13 — ready (mine vs not)
if step.state == 'ready':
job.card_state = ('ready_mine' if job._fp_is_mine()
else 'ready')
continue
# Safe default
job.card_state = 'ready'
# ----- mini-timeline compute ----------------------------------------
@api.depends(
'step_ids.state',
'step_ids.area_kind',
'active_step_id',
'card_state',
)
def _compute_mini_timeline_json(self):
"""9-element JSON array, one per Shop Floor column."""
for job in self:
active_area = (job.active_step_id.area_kind
if job.active_step_id else None)
timeline = []
for area in _COLUMN_SEQUENCE:
steps_in_area = job.step_ids.filtered(
lambda s: s.area_kind == area,
)
if not steps_in_area:
# Recipe doesn't visit this area — show as upcoming
# to keep visual alignment across cards
timeline.append({'area': area, 'state': 'upcoming'})
continue
if all(s.state in ('done', 'skipped') for s in steps_in_area):
timeline.append({'area': area, 'state': 'done'})
elif area == active_area:
timeline.append({
'area': area,
'state': 'current',
'variant': job.card_state or '',
})
else:
timeline.append({'area': area, 'state': 'upcoming'})
job.mini_timeline_json = json.dumps(timeline)
@api.depends(
'date_deadline',
'step_ids.state',

View File

@@ -109,6 +109,16 @@ class FpJobStep(models.Model):
# Free-flow recipe — only the legacy per-step flag still gates.
return bool(self.requires_predecessor_done)
def _fp_has_unfinished_predecessors(self):
"""True when an earlier-sequence step on the same job is not yet
in a terminal state. Composes with _fp_should_block_predecessors
to drive the plant-view predecessor_locked card state."""
self.ensure_one()
return bool(self.job_id.step_ids.filtered(
lambda s: s.sequence < self.sequence
and s.state not in ('done', 'skipped', 'cancelled')
))
can_start = fields.Boolean(
string='Can Start',
compute='_compute_can_start',