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>
385 lines
16 KiB
Python
385 lines
16 KiB
Python
# -*- 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
|