Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py
gsinghpal 9794a98de9 feat(jobs): Sub 13 sequential step enforcement + Sub 12e v3 wizard
Two coherent feature drops shipping together because their fp_job_step
edits overlap. Both target operator workflow correctness.

## Sub 13 — Sequential step enforcement (recipe + per-step)

Background:
  Investigation on WH/JOB/00339 showed operators starting Incoming
  Inspection while Contract Review was still in_progress. Audit:
  98.7% of recipe operations system-wide had requires_predecessor_done
  = false (the legacy per-step opt-in defaults off, recipe authors
  rarely tick the box).

Architecture:
  Recipe-level toggle + per-step opt-out (Option A from /investigate).
  * fusion.plating.process.node.enforce_sequential — Boolean on the
    recipe root. Default True. When True, every operation under this
    recipe waits for earlier-sequence steps to finish before it can
    start.
  * fusion.plating.process.node.parallel_start — Boolean on operation
    nodes. When True, this step bypasses the sequential gate (e.g.
    paperwork or QA review that runs alongside production).
  * Mirrored on fp.step.template (parallel_start) so library steps
    carry the flag into snapshots.
  * fp.job.enforce_sequential — related from recipe_id. Snapshotted
    at job creation so a recipe author flipping the recipe's flag
    AFTER job generation does NOT change behaviour mid-run.
  * fp.job.step.parallel_start — related from recipe_node_id.
  * Decision matrix (encapsulated in
    fp.job.step._fp_should_block_predecessors):
        recipe.enforce_sequential | step.parallel_start | step.req_pred_done | block?
        --------------------------|---------------------|--------------------|------
                 True             |       False         |        any         |  YES
                 True             |       True          |        any         |   no
                 False            |        any          |       True         |  YES
                 False            |        any          |       False        |   no
  * Manager bypass via context fp_skip_predecessor_check=True (existing).

Runtime gates:
  * fp.job.step.button_start — calls _fp_should_block_predecessors;
    raises UserError naming the blocking earlier step(s).
  * fp.job.step.can_start — computed Boolean for view-side disable.
  * Move wizard predecessor check
    (fusion_plating_shopfloor/controllers/move_controller.py) — uses
    the same helper so tablet + backend behave identically.

UI surface:
  * Recipe form (fp_process_node_views.xml) — enforce_sequential
    toggle on recipe root, parallel_start checkbox on operations.
  * Step template form — parallel_start checkbox.
  * Simple Recipe Editor (inline library form) — Parallel Start
    checkbox + legacy flag demoted with muted styling + supervisor
    group gate.
  * Recipe Tree Editor (properties panel) — both flags exposed,
    only-show on the right node_type.
  * Controllers updated to allowlist + payload the new fields.

Migration:
  fusion_plating/migrations/19.0.18.12.0/post-migrate.py — sets
  enforce_sequential = TRUE on every existing recipe-root node.
  Idempotent. User confirmed dev-stage data, so retroactive flip
  is safe (no production jobs to disrupt).

Tests:
  TestSequentialEnforcement (10 tests) covering:
    * sequential mode blocks out-of-order start
    * first step always startable
    * predecessor finish/skip unlocks next
    * parallel_start opts out of gate
    * free-flow mode bypasses gate
    * legacy requires_predecessor_done still honoured in free-flow
    * manager bypass via context
    * can_start compute reflects state correctly
    * library template parallel_start snapshots into recipe-node

## Sub 12e — Record Inputs Wizard v3 (card layout, dark-mode aware)

Background:
  v2 wizard was a 17-column wide editable table. Operators got lost
  finding which value column applied to their row's type, horizontal
  scroll required on tablets, composite types crammed into one row.

New layout:
  * Each measurement renders as a stacked card (CSS Grid + display
    transformation on the existing list widget — preserves inline
    editing, no JS rewrite).
  * Card header: prompt name (large, bold) + type/unit pills.
  * Card body: ONLY the value widget for this row's type
    (number / boolean / date / text / photo / multi-point / panel).
  * Composite types (multi-point thickness 5x reading + avg, bath
    panel 4 fields) get inline sub-grid inside the card.
  * Empty state ("no measurement prompts") with friendly CTA.

Dark mode:
  * SCSS branches at compile time on $o-webclient-color-scheme
    (per fusion-plating/CLAUDE.md note).
  * Tokens: 7 surface colours + 4 ink levels with light/dark hex
    pairs, all behind var(--fp-*) custom properties for per-deploy
    override.
  * Registered in BOTH web.assets_backend AND web.assets_web_dark
    so each bundle compiles its own palette.

Tablet polish:
  @media (max-width: 900px) — collapse meta below prompt + bump
  numeric input min-height to 56px.

Defensive:
  * v2 view kept in the XML file (instant rollback by changing one
    view_id ref).
  * `:has(.o_invisible_modifier)` rule drops empty cells out of the
    grid so Odoo's invisible="..." doesn't punch holes in layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:24:12 -04:00

385 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.step — one operation within a plating job.
#
# Replaces mrp.workorder. Each step instantiates from a recipe
# operation node (recipe_node_id). Container nodes (recipe,
# sub_process) and step nodes (instructions) are NOT rows here —
# they live on the recipe template and are used at view-render time
# to display hierarchy. See spec §5.2 (Option A — operations only).
#
# State machine:
# pending → ready → in_progress → done
# ↓ ↓ ↑
# skipped paused
# ↓
# cancelled
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJobStep(models.Model):
_name = 'fp.job.step'
_description = 'Plating Job Step'
_inherit = ['mail.thread']
_order = 'job_id, sequence, id'
job_id = fields.Many2one(
'fp.job',
required=True,
ondelete='cascade',
index=True,
)
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
state = fields.Selection(
[
('pending', 'Pending'),
('ready', 'Ready'),
('in_progress', 'In Progress'),
('paused', 'Paused'),
('done', 'Done'),
('skipped', 'Skipped'),
('cancelled', 'Cancelled'),
],
default='pending',
required=True,
tracking=True,
index=True,
)
recipe_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Operation',
domain=[('node_type', '=', 'operation')],
)
work_centre_id = fields.Many2one('fp.work.centre', index=True)
kind = fields.Selection(
[
('wet', 'Wet'),
('bake', 'Bake'),
('mask', 'Mask'),
('rack', 'Rack'),
('inspect', 'Inspect'),
('other', 'Other'),
],
default='other',
)
assigned_user_id = fields.Many2one('res.users', tracking=True)
started_by_user_id = fields.Many2one('res.users', readonly=True)
finished_by_user_id = fields.Many2one('res.users', readonly=True)
date_started = fields.Datetime(readonly=True)
date_finished = fields.Datetime(readonly=True)
duration_expected = fields.Float(string='Expected Minutes')
duration_actual = fields.Float(string='Actual Minutes', readonly=True)
instructions = fields.Html(string='Step Instructions')
time_log_ids = fields.One2many(
'fp.job.step.timelog',
'step_id',
string='Time Logs',
)
# ------------------------------------------------------------------
# Equipment + audit (Task 1.6)
# oven_id is deferred to a bridge module — fusion.plating.bake.oven
# lives in fusion_plating_shopfloor and core can't depend on it.
# masking_material_id is deferred — fusion.plating.masking.material
# does not yet exist in any installed module; will be added when
# the masking model lands (likely in fusion_plating_process_en
# or a future fusion_plating_masking module).
# ------------------------------------------------------------------
bath_id = fields.Many2one('fusion.plating.bath')
tank_id = fields.Many2one('fusion.plating.tank')
rack_id = fields.Many2one('fusion.plating.rack')
signoff_user_id = fields.Many2one('res.users', readonly=True)
facility_id = fields.Many2one(
'fusion.plating.facility',
related='work_centre_id.facility_id',
store=True,
)
# ------------------------------------------------------------------
# Plating spec (Task 1.6)
# ------------------------------------------------------------------
thickness_target = fields.Float(string='Target Thickness')
thickness_uom = fields.Selection(
[('um', 'µm'), ('mil', 'mil'), ('inch', 'in')],
default='um',
)
dwell_time_minutes = fields.Float()
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
# ------------------------------------------------------------------
# Recipe-related (Task 1.6)
# ------------------------------------------------------------------
requires_signoff = fields.Boolean(
related='recipe_node_id.requires_signoff',
store=True,
)
auto_complete = fields.Boolean(
related='recipe_node_id.auto_complete',
store=True,
)
is_manual = fields.Boolean(
related='recipe_node_id.is_manual',
store=True,
)
customer_visible = fields.Boolean(
related='recipe_node_id.customer_visible',
store=True,
)
requires_predecessor_done = fields.Boolean(
related='recipe_node_id.requires_predecessor_done',
store=True,
help='LEGACY: per-step opt-in for predecessor enforcement. '
'Still honoured when the parent recipe has '
'enforce_sequential=False (free-flow recipe with one '
'specific step that needs to wait).',
)
# Sub 13 — sequential enforcement (recipe + per-step). New default
# behaviour is "every step waits for predecessors", with two escape
# hatches: enforce_sequential=False on the recipe (free-flow), or
# parallel_start=True on this specific step (explicit parallelism).
# The per-step parallel_start field is on this CORE model because
# it just mirrors a core field (recipe_node_id.parallel_start).
# The runtime gate logic (can_start, _fp_should_block_predecessors)
# lives in fusion_plating_jobs because it reads the recipe-level
# enforce_sequential which only exists when that bridge is loaded.
parallel_start = fields.Boolean(
related='recipe_node_id.parallel_start',
store=True,
help='If True, this step can start while earlier-sequence '
'steps are still in progress. Only meaningful when the '
'parent recipe has enforce_sequential=True.',
)
# ===== Sub 12b — chain-of-custody + rack awareness =====================
# Note: rack_id (line 95 above) already exists — reused as the "current
# rack on this step" pointer. Sub 12b builds the runtime guards on top.
requires_rack_assignment = fields.Boolean(
related='recipe_node_id.requires_rack_assignment',
store=True,
help='If True, the Move Parts dialog requires a rack to be '
'assigned to the parts before the move commits. Snapshot '
'from the recipe step at job creation.',
)
requires_transition_form = fields.Boolean(
related='recipe_node_id.requires_transition_form',
store=True,
)
move_ids = fields.One2many(
'fp.job.step.move', 'from_step_id',
string='Outgoing Moves',
)
incoming_move_ids = fields.One2many(
'fp.job.step.move', 'to_step_id',
string='Incoming Moves',
)
is_racked = fields.Boolean(
string='Racked', compute='_compute_is_racked', store=True,
help='True when rack_id is set — drives the tablet rack-vs-parts '
'button-state guard (Move Parts greys out).',
)
qty_at_step_start = fields.Integer(string='Qty at Step Start')
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
# Live "qty currently parked at this step" — drives partial-qty
# workflows. = (incoming moves' qty outgoing moves' qty), with a
# first-step seed: the lowest-sequence step on a confirmed job
# implicitly receives the full job qty when the job starts (no
# explicit "kickoff" move record). Without that seed, the first
# step would always show 0 here until the operator manually moved
# parts in, which doesn't match how the floor thinks about it.
qty_at_step = fields.Integer(
string='Qty Here',
compute='_compute_qty_at_step',
help='Quantity currently parked at this step. Drains as moves '
'transfer parts to later steps. The Move dialog defaults '
'to this value and blocks moves above it.',
)
@api.depends('move_ids.qty_moved', 'move_ids.to_step_id',
'incoming_move_ids.qty_moved',
'incoming_move_ids.from_step_id',
'state', 'job_id.qty', 'job_id.step_ids',
'job_id.step_ids.sequence', 'sequence')
def _compute_qty_at_step(self):
for rec in self:
# Terminal states: nothing parked here anymore. Operators
# don't care if "done" steps technically have qty residue —
# surfacing zero keeps the column readable.
if rec.state in ('done', 'cancelled', 'skipped'):
rec.qty_at_step = 0
continue
# Self-loop moves (from_step == to_step, transfer_type='step')
# are how the Record Inputs wizard logs measurements; they
# don't move qty so we exclude them on both sides.
incoming = sum(
m.qty_moved for m in rec.incoming_move_ids
if m.from_step_id != rec
)
outgoing = sum(
m.qty_moved for m in rec.move_ids
if m.to_step_id != rec
)
# First-step seed: the earliest non-terminal step on a job
# implicitly receives the full job qty when the job kicks
# off (no explicit kickoff move). Without this seed, qty
# here would read 0 even when the floor has the full batch.
if not incoming and rec.job_id and rec.job_id.qty:
first_active = rec.job_id.step_ids.filtered(
lambda s: s.state not in ('done', 'cancelled', 'skipped')
).sorted('sequence')[:1]
if rec == first_active:
incoming = int(rec.job_id.qty)
rec.qty_at_step = max(0, incoming - outgoing)
@api.depends('rack_id')
def _compute_is_racked(self):
for rec in self:
rec.is_racked = bool(rec.rack_id)
# ------------------------------------------------------------------
# Cost rollup (Task 1.6)
# cost_per_hour comes from fp.work.centre (Task 1.2 added it there).
# cost_total recomputes when duration_actual or rate changes.
# duration_actual is set by button_finish as the sum of timelog
# row durations (see fp.job.step.timelog).
# ------------------------------------------------------------------
cost_per_hour = fields.Monetary(
related='work_centre_id.cost_per_hour',
currency_field='currency_id',
)
cost_total = fields.Monetary(
compute='_compute_cost_total',
store=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
related='work_centre_id.currency_id',
)
@api.depends('duration_actual', 'cost_per_hour')
def _compute_cost_total(self):
for step in self:
step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour
# ------------------------------------------------------------------
# State machine — actions
# ------------------------------------------------------------------
# Implemented: button_start (ready/paused → in_progress),
# button_finish (in_progress → done).
# Stubs (raise NotImplementedError; wiring deferred):
# button_pause (in_progress → paused)
# button_resume (covered by button_start when state='paused')
# button_skip (pending/ready → skipped)
# button_cancel (any non-done → cancelled)
# Predecessor-driven transition pending → ready will be wired
# alongside first-step / dependency logic in a future task.
# ------------------------------------------------------------------
def button_pause(self):
"""Operator pause / break / end-of-shift. Closes the open timelog
without finishing the step, flips state to 'paused'. button_start
will reopen a fresh timelog when resuming.
"""
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now, 'state': 'paused'})
step.state = 'paused'
step.message_post(body=_('Step paused by %s') % self.env.user.name)
return True
def button_resume(self):
"""Resume a paused step — thin alias over button_start so views
can show distinct labels (Resume vs Start) without duplicating
the state-machine logic."""
for step in self:
if step.state != 'paused':
raise UserError(_(
"Step '%s' is in state '%s' — only paused steps can resume."
) % (step.name, step.state))
return self.button_start()
def button_skip(self):
"""Skip an opt-in step that wasn't activated for this job. Allowed
from pending or ready only — a step that's already running shouldn't
be skipped without an audit narrative (use button_cancel for that).
"""
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' — only pending/ready steps can skip."
) % (step.name, step.state))
step.state = 'skipped'
step.message_post(body=_('Step skipped by %s') % self.env.user.name)
return True
def button_cancel(self):
"""Cancel a single step. Used when an operator realises mid-stream
that a step doesn't apply to this job (e.g. a customer-specific
step that's not needed). Closes any open timelog so labour cost
already incurred is preserved.
"""
for step in self:
if step.state in ('done', 'cancelled'):
raise UserError(_(
"Step '%s' is in state '%s' — cannot cancel."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now, 'state': 'stopped'})
step.state = 'cancelled'
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
return True
def button_start(self):
for step in self:
if step.state not in ('ready', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' — only ready/paused steps can start."
) % (step.name, step.state))
now = fields.Datetime.now()
step.state = 'in_progress'
# First-start audit (mirrors button_finish first-finish guard)
if not step.date_started:
step.date_started = now
step.started_by_user_id = self.env.user
# Open a fresh timelog row for this start interval — uses the
# same `now` as the first-start stamp so the step and its
# first log share a single instant.
self.env['fp.job.step.timelog'].create({
'step_id': step.id,
'user_id': self.env.user.id,
'date_started': now,
})
return True
def button_finish(self):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now, 'state': 'stopped'})
step.state = 'done'
# First-finish audit (mirrors button_start first-start guard)
if not step.date_finished:
step.date_finished = now
step.finished_by_user_id = self.env.user
# Sum of all interval durations becomes duration_actual
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
return True