diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 5f1820d0..d0455558 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -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', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 3680433f..af6f2047 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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',