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>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.11.0',
|
||||
'version': '19.0.18.12.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -86,6 +86,8 @@ class FpRecipeController(http.Controller):
|
||||
'estimated_duration',
|
||||
'auto_complete', 'customer_visible', 'is_manual',
|
||||
'requires_signoff', 'opt_in_out', 'sequence', 'version',
|
||||
# Sub 13 — sequential enforcement
|
||||
'enforce_sequential', 'parallel_start',
|
||||
}
|
||||
safe_vals = {k: v for k, v in vals.items() if k in allowed}
|
||||
if not safe_vals:
|
||||
|
||||
@@ -23,6 +23,7 @@ _SNAPSHOT_FIELDS = [
|
||||
'temp_min_target', 'temp_max_target', 'temp_unit',
|
||||
'voltage_target', 'viscosity_target',
|
||||
'requires_signoff', 'requires_predecessor_done',
|
||||
'parallel_start',
|
||||
'requires_rack_assignment', 'requires_transition_form',
|
||||
'default_kind',
|
||||
]
|
||||
@@ -193,6 +194,7 @@ class SimpleRecipeController(http.Controller):
|
||||
'description': tpl.description or '',
|
||||
'requires_signoff': tpl.requires_signoff,
|
||||
'requires_predecessor_done': tpl.requires_predecessor_done,
|
||||
'parallel_start': tpl.parallel_start,
|
||||
'requires_rack_assignment': tpl.requires_rack_assignment,
|
||||
'requires_transition_form': tpl.requires_transition_form,
|
||||
'tank_ids': [
|
||||
@@ -227,6 +229,7 @@ class SimpleRecipeController(http.Controller):
|
||||
allowed = {
|
||||
'name', 'code', 'icon', 'default_kind', 'description',
|
||||
'requires_signoff', 'requires_predecessor_done',
|
||||
'parallel_start',
|
||||
'requires_rack_assignment', 'requires_transition_form',
|
||||
'tank_ids',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""
|
||||
Migration 19.0.18.12.0 — Sub 13 sequential step enforcement.
|
||||
|
||||
Background:
|
||||
The legacy per-step `requires_predecessor_done` opt-in defaulted to
|
||||
False, so 98.7% of operations system-wide had no enforcement and
|
||||
operators were able to start arbitrary steps out of order (e.g. job
|
||||
WH/JOB/00339 — Incoming Inspection ran while Contract Review was
|
||||
still in progress).
|
||||
|
||||
This migration:
|
||||
* Sets `enforce_sequential = True` on every existing recipe-root
|
||||
node so the new default behaviour kicks in immediately on upgrade.
|
||||
Existing per-step `requires_predecessor_done` flags are preserved
|
||||
and continue to work for any recipe whose author opts back into
|
||||
free-flow mode (sets enforce_sequential = False).
|
||||
|
||||
Idempotent — safe to re-run.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return # Brand new install — defaults already correct.
|
||||
|
||||
_logger.info(
|
||||
'[migration 19.0.18.12.0] Promoting all existing recipes to '
|
||||
'enforce_sequential=True (Sub 13 — sequential step enforcement).'
|
||||
)
|
||||
|
||||
# Direct SQL — avoids loading the ORM at this stage and is fast.
|
||||
# We intentionally only flip nodes that are CURRENTLY False; nodes
|
||||
# already set True (or set by a manual install of a newer version)
|
||||
# are skipped so the operation is clean to re-run.
|
||||
cr.execute("""
|
||||
UPDATE fusion_plating_process_node
|
||||
SET enforce_sequential = TRUE
|
||||
WHERE node_type = 'recipe'
|
||||
AND (enforce_sequential IS NULL OR enforce_sequential = FALSE)
|
||||
""")
|
||||
flipped = cr.rowcount
|
||||
_logger.info(
|
||||
'[migration 19.0.18.12.0] enforce_sequential flipped to True '
|
||||
'on %d existing recipe(s).', flipped,
|
||||
)
|
||||
|
||||
# Audit chatter on each affected recipe so the change is visible
|
||||
# to recipe authors (they get a notification on their next visit).
|
||||
if flipped:
|
||||
cr.execute("""
|
||||
SELECT id, name FROM fusion_plating_process_node
|
||||
WHERE node_type = 'recipe' AND enforce_sequential = TRUE
|
||||
""")
|
||||
recipes = cr.fetchall()
|
||||
_logger.info(
|
||||
'[migration 19.0.18.12.0] %d recipe(s) now Sequential by default. '
|
||||
'Affected recipes: %s',
|
||||
len(recipes),
|
||||
', '.join(name for _, name in recipes[:20]) + (
|
||||
', ...' if len(recipes) > 20 else ''
|
||||
),
|
||||
)
|
||||
@@ -135,8 +135,26 @@ class FpJobStep(models.Model):
|
||||
requires_predecessor_done = fields.Boolean(
|
||||
related='recipe_node_id.requires_predecessor_done',
|
||||
store=True,
|
||||
help='If True, button_start blocks until every earlier-sequence '
|
||||
'step in this job is done/skipped/cancelled.',
|
||||
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 =====================
|
||||
|
||||
@@ -181,14 +181,41 @@ class FpProcessNode(models.Model):
|
||||
help='Quality hold point — requires operator sign-off.',
|
||||
)
|
||||
requires_predecessor_done = fields.Boolean(
|
||||
string='Requires Predecessor Done',
|
||||
string='Requires Predecessor Done (legacy)',
|
||||
default=False,
|
||||
help='If checked, this step cannot start until ALL earlier-'
|
||||
'sequence steps in the job are done / skipped / cancelled. '
|
||||
'Use for serial-required operations (e.g. Plating must '
|
||||
'follow Acid Etch with no time gap — passivation layer '
|
||||
'forms in seconds). Leaving unchecked allows parallel '
|
||||
'work across tanks (the default).',
|
||||
help='LEGACY per-step opt-in for predecessor enforcement. As of '
|
||||
'19.0.X, recipes default to enforce_sequential=True so every '
|
||||
'step naturally waits for its predecessors. This flag still '
|
||||
'works on recipes whose enforce_sequential is False — turn '
|
||||
'it on to make a single step block in an otherwise free-flow '
|
||||
'recipe.',
|
||||
)
|
||||
# ===== Sub 13 — sequential step enforcement (recipe + per-step) ==========
|
||||
# Replaces the unused per-step requires_predecessor_done as the primary
|
||||
# enforcement vector. Two layers:
|
||||
# 1. enforce_sequential (recipe root) — entire recipe is sequential
|
||||
# by default. Author can disable for free-flow recipes.
|
||||
# 2. parallel_start (operation step) — escape hatch within a
|
||||
# sequential recipe, for steps that legitimately run in parallel
|
||||
# (e.g. paperwork that doesn't need previous step done).
|
||||
enforce_sequential = fields.Boolean(
|
||||
string='Enforce Sequential Order',
|
||||
default=True,
|
||||
help='Only meaningful on the recipe root node. When True (the '
|
||||
'default), every operation under this recipe waits for all '
|
||||
'earlier-sequence steps to be done/skipped/cancelled before '
|
||||
'it can start. Mark a specific step as Parallel Start to '
|
||||
'opt it out. Disable on the recipe to fall back to the '
|
||||
'legacy per-step Requires Predecessor Done flag.',
|
||||
)
|
||||
parallel_start = fields.Boolean(
|
||||
string='Parallel Start',
|
||||
default=False,
|
||||
help='Only meaningful on operation nodes inside a recipe with '
|
||||
'Enforce Sequential Order = True. When checked, this step '
|
||||
'can be started while earlier-sequence steps are still in '
|
||||
'progress (e.g. paperwork or QA review that runs alongside '
|
||||
'production).',
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
[
|
||||
@@ -538,6 +565,10 @@ class FpProcessNode(models.Model):
|
||||
'customer_visible': self.customer_visible,
|
||||
'is_manual': self.is_manual,
|
||||
'requires_signoff': self.requires_signoff,
|
||||
# Sub 13 — sequential enforcement
|
||||
'enforce_sequential': self.enforce_sequential,
|
||||
'parallel_start': self.parallel_start,
|
||||
'requires_predecessor_done': self.requires_predecessor_done,
|
||||
'version': self.version,
|
||||
'child_count': len(children),
|
||||
'opt_in_out': self.opt_in_out or 'disabled',
|
||||
|
||||
@@ -66,9 +66,19 @@ class FpStepTemplate(models.Model):
|
||||
viscosity_target = fields.Float(string='Viscosity Target')
|
||||
|
||||
requires_signoff = fields.Boolean(string='Require QA Sign-off')
|
||||
requires_predecessor_done = fields.Boolean(string='Require Predecessor Done',
|
||||
help='S14 lock — operator cannot start this step until earlier '
|
||||
'sequenced steps are done.')
|
||||
requires_predecessor_done = fields.Boolean(
|
||||
string='Require Predecessor Done (legacy)',
|
||||
help='Legacy per-step opt-in for predecessor enforcement. Recipes '
|
||||
'now default to Enforce Sequential Order — use Parallel '
|
||||
'Start instead when you want a step to run alongside others.',
|
||||
)
|
||||
parallel_start = fields.Boolean(
|
||||
string='Parallel Start',
|
||||
help='Sub 13. When this template lands inside a sequential '
|
||||
'recipe, the resulting step can be started while '
|
||||
'earlier-sequence steps are still in progress (e.g. '
|
||||
'paperwork that runs alongside production).',
|
||||
)
|
||||
requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment',
|
||||
help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).')
|
||||
requires_transition_form = fields.Boolean(string='Requires Transition Form',
|
||||
|
||||
@@ -268,6 +268,9 @@ export class RecipeTreeEditor extends Component {
|
||||
customer_visible: node.customer_visible,
|
||||
is_manual: node.is_manual,
|
||||
requires_signoff: node.requires_signoff,
|
||||
// Sub 13 — sequential enforcement
|
||||
enforce_sequential: !!node.enforce_sequential,
|
||||
parallel_start: !!node.parallel_start,
|
||||
};
|
||||
const result = await rpc("/fp/recipe/node/write", {
|
||||
node_id: node.id,
|
||||
|
||||
@@ -241,6 +241,7 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
description: "",
|
||||
requires_signoff: false,
|
||||
requires_predecessor_done: false,
|
||||
parallel_start: false, // Sub 13 — per-step opt-out
|
||||
requires_rack_assignment: false,
|
||||
requires_transition_form: false,
|
||||
tank_ids: [],
|
||||
@@ -289,6 +290,7 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
description: ed.description,
|
||||
requires_signoff: !!ed.requires_signoff,
|
||||
requires_predecessor_done: !!ed.requires_predecessor_done,
|
||||
parallel_start: !!ed.parallel_start,
|
||||
requires_rack_assignment: !!ed.requires_rack_assignment,
|
||||
requires_transition_form: !!ed.requires_transition_form,
|
||||
tank_ids: (ed.tank_ids || []).map((t) => t.id),
|
||||
|
||||
@@ -350,6 +350,28 @@
|
||||
t-on-change="(ev) => { state.selectedNode.customer_visible = ev.target.checked; }"/>
|
||||
<label class="form-check-label" for="fp_re_chk_visible">Customer visible</label>
|
||||
</div>
|
||||
<!-- Sub 13 — sequential enforcement (recipe root) -->
|
||||
<div class="form-check"
|
||||
t-if="state.selectedNode.node_type === 'recipe'">
|
||||
<input type="checkbox" class="form-check-input" id="fp_re_chk_seq"
|
||||
t-att-checked="state.selectedNode.enforce_sequential"
|
||||
t-on-change="(ev) => { state.selectedNode.enforce_sequential = ev.target.checked; }"/>
|
||||
<label class="form-check-label" for="fp_re_chk_seq"
|
||||
title="When ON (the default), every operation under this recipe waits for earlier-sequence steps to finish before it can start. Mark a specific step as Parallel Start to opt that one out.">
|
||||
Enforce Sequential Order
|
||||
</label>
|
||||
</div>
|
||||
<!-- Sub 13 — per-step opt-out (operation/step nodes) -->
|
||||
<div class="form-check"
|
||||
t-if="state.selectedNode.node_type === 'operation' or state.selectedNode.node_type === 'step'">
|
||||
<input type="checkbox" class="form-check-input" id="fp_re_chk_par"
|
||||
t-att-checked="state.selectedNode.parallel_start"
|
||||
t-on-change="(ev) => { state.selectedNode.parallel_start = ev.target.checked; }"/>
|
||||
<label class="form-check-label" for="fp_re_chk_par"
|
||||
title="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress.">
|
||||
Parallel Start
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_re_field">
|
||||
|
||||
@@ -412,10 +412,16 @@
|
||||
t-model="state.libraryEditor.requires_signoff"/>
|
||||
Require QA Sign-off
|
||||
</label>
|
||||
<label>
|
||||
<label title="Sub 13. When this template lands inside a sequential recipe, the step can start while earlier-sequence steps are still in progress. Use for paperwork or QA review steps that don't need previous step done.">
|
||||
<input type="checkbox"
|
||||
t-model="state.libraryEditor.parallel_start"/>
|
||||
Parallel Start
|
||||
</label>
|
||||
<label title="Legacy. Only fires when the parent recipe is in Free-Flow mode (Enforce Sequential = False)."
|
||||
class="text-muted">
|
||||
<input type="checkbox"
|
||||
t-model="state.libraryEditor.requires_predecessor_done"/>
|
||||
Require Predecessor Done
|
||||
Require Predecessor Done <em>(legacy)</em>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
<field name="opt_in_out"/>
|
||||
<field name="version"/>
|
||||
<field name="active" invisible="True"/>
|
||||
<!-- Sub 13 — per-step opt-out from sequential gate -->
|
||||
<field name="parallel_start"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
|
||||
<field name="requires_predecessor_done"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||
help="LEGACY per-step gate. Only fires when the recipe is in Free-Flow mode (Enforce Sequential = False)."/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Recipe-only metadata (lead time, product link,
|
||||
@@ -103,6 +111,10 @@
|
||||
<field name="product_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="preferred_editor"/>
|
||||
<!-- Sub 13 — recipe-level enforcement toggle -->
|
||||
<field name="enforce_sequential"
|
||||
widget="boolean_toggle"
|
||||
help="When ON (the default), every operation under this recipe waits for earlier-sequence steps to finish before it can start. Mark a specific step as Parallel Start to opt that one out."/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="contract_review_user_ids"
|
||||
|
||||
@@ -56,7 +56,11 @@
|
||||
<group string="Stations + Flags">
|
||||
<field name="tank_ids" widget="many2many_tags"/>
|
||||
<field name="requires_signoff"/>
|
||||
<field name="requires_predecessor_done"/>
|
||||
<field name="parallel_start"
|
||||
help="Sub 13. When this template lands inside a sequential recipe, the step can start while earlier-sequence steps are still in progress."/>
|
||||
<field name="requires_predecessor_done"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||
help="LEGACY. Only useful for steps inside Free-Flow recipes (where Enforce Sequential is OFF)."/>
|
||||
<field name="requires_rack_assignment"/>
|
||||
<field name="requires_transition_form"/>
|
||||
</group>
|
||||
|
||||
Reference in New Issue
Block a user