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