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:
gsinghpal
2026-05-03 21:24:12 -04:00
parent ee80673579
commit 9794a98de9
20 changed files with 1109 additions and 34 deletions

View File

@@ -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': """

View File

@@ -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:

View File

@@ -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',
}

View File

@@ -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 ''
),
)

View File

@@ -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 =====================

View File

@@ -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',

View File

@@ -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',

View File

@@ -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,

View File

@@ -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),

View File

@@ -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">

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.14.6',
'version': '19.0.8.16.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -71,15 +71,18 @@ full design rationale and §6.2 of the implementation plan for task list.
'report/report_fp_job_margin.xml',
],
'assets': {
# Sub 12d — Step Details quick-look modal styles. Registered in
# both bundles so light + dark mode each compile correctly
# Sub 12d — Step Details quick-look modal styles + Sub 12e — Record
# Inputs Wizard v3 card layout. Registered in both bundles so
# light + dark mode each compile correctly
# ($o-webclient-color-scheme branches at compile time per
# CLAUDE.md note).
'web.assets_backend': [
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
'fusion_plating_jobs/static/src/scss/fp_job_step_input_wizard_v3.scss',
],
'web.assets_web_dark': [
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
'fusion_plating_jobs/static/src/scss/fp_job_step_input_wizard_v3.scss',
],
},
'installable': True,

View File

@@ -53,6 +53,21 @@ class FpJob(models.Model):
'job_id',
string='Recipe Overrides',
)
# Sub 13 — sequential enforcement. Mirrored from the recipe root so
# button_start on each step can read the policy without walking the
# node tree. Stored so a recipe author flipping the recipe's flag
# AFTER job generation does NOT change behaviour mid-run (jobs
# snapshot the policy at creation, not on the fly).
enforce_sequential = fields.Boolean(
related='recipe_id.enforce_sequential',
string='Enforce Sequential Order',
store=True,
readonly=True,
help='Snapshotted from the recipe at job creation. When True, '
'every step waits for its predecessors before it can start '
'(unless the step itself is flagged Parallel Start, or a '
'manager bypasses via context).',
)
# Phase 7 — migration idempotency key. Populated by
# scripts/migrate_to_fp_jobs.py to mark a fp.job as the mirror of a
# specific mrp.production. Used to skip already-migrated MOs on

View File

@@ -20,22 +20,93 @@ _logger = logging.getLogger(__name__)
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
# ===== Sub 13 — sequential enforcement (recipe + per-step) =============
# Decision matrix for whether button_start must verify predecessors:
#
# recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | 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.
# Encapsulated in _fp_should_block_predecessors() so the same gate
# is reused by button_start and the Move wizard's predecessor check
# (fusion_plating_shopfloor/controllers/move_controller.py).
# ======================================================================
def _fp_should_block_predecessors(self):
"""Return True if this step must verify that every earlier-
sequence step is in a terminal state before it can start.
See decision matrix in the section header above.
"""
self.ensure_one()
# Defensive: jobs without a recipe (manual-build jobs) default
# to enforce-on so freshly-created records don't accidentally
# leak permissive behaviour through a related-field None.
if not self.job_id:
return True
recipe_seq = self.job_id.enforce_sequential
if recipe_seq:
return not self.parallel_start
# Free-flow recipe — only the legacy per-step flag still gates.
return bool(self.requires_predecessor_done)
can_start = fields.Boolean(
string='Can Start',
compute='_compute_can_start',
help='True when this step is in a startable state AND the '
'predecessor gate (if any) is currently clear. Drives the '
'tablet/job form Start button visibility.',
)
@api.depends(
'state',
'sequence',
'parallel_start',
'requires_predecessor_done',
'job_id.enforce_sequential',
'job_id.step_ids.state',
'job_id.step_ids.sequence',
)
def _compute_can_start(self):
for step in self:
if step.state not in ('pending', 'ready', 'paused'):
step.can_start = False
continue
if not step._fp_should_block_predecessors():
step.can_start = True
continue
blocking = step.job_id.step_ids.filtered(
lambda s: s.sequence < step.sequence and s.state not in (
'done', 'skipped', 'cancelled',
)
)
step.can_start = not bool(blocking)
def button_start(self):
"""Override — soft gate when parts haven't been received yet,
plus hard predecessor gate for steps flagged
requires_predecessor_done by the recipe author.
plus hard predecessor gate driven by the recipe + per-step
sequential enforcement policy (Sub 13).
Receiving check is soft (logs to chatter) — manager wants the
shop to start prep regardless when parts are in-transit late.
Predecessor check IS hard-blocking — if the recipe author
marked this step as serial-required, every earlier-sequence
step must be terminal (done / skipped / cancelled) before
Start fires. Manager bypass via fp_skip_predecessor_check=True.
Predecessor check IS hard-blocking — every earlier-sequence
step must be terminal (done / skipped / cancelled) before Start
fires, UNLESS:
* this step is flagged parallel_start (explicit per-step opt-out), OR
* the parent recipe has enforce_sequential=False AND this step
is not flagged requires_predecessor_done (legacy free-flow).
Manager bypass via fp_skip_predecessor_check=True.
"""
skip_pred = self.env.context.get('fp_skip_predecessor_check')
for step in self:
if not step.requires_predecessor_done or skip_pred:
if skip_pred:
continue
if not step._fp_should_block_predecessors():
continue
blocking = step.job_id.step_ids.filtered(
lambda s: s.sequence < step.sequence and s.state not in (
@@ -44,10 +115,13 @@ class FpJobStep(models.Model):
)
if blocking:
raise UserError(_(
"Step '%s' requires predecessors done first. "
"Blocking earlier step(s):\n %s\n\nFinish or skip "
"those before starting this one (manager can "
"override via context fp_skip_predecessor_check=True)."
"Step '%s' cannot start until earlier steps are "
"finished, skipped, or cancelled.\n\nBlocking step(s):\n %s\n\n"
"Options:\n"
" * Finish/Skip the blocking step(s) first.\n"
" * If this step legitimately runs in parallel, ask "
"a manager to flag it as Parallel Start on the recipe.\n"
" * Manager override via context fp_skip_predecessor_check=True."
) % (
step.name,
'\n '.join(
@@ -412,7 +486,7 @@ class FpJobStep(models.Model):
the next one as a single atomic flow."""
self.ensure_one()
view = self.env.ref(
'fusion_plating_jobs.view_fp_job_step_input_wizard_form_v2'
'fusion_plating_jobs.view_fp_job_step_input_wizard_form_v3'
)
return {
'type': 'ir.actions.act_window',

View File

@@ -0,0 +1,422 @@
// =============================================================================
// Record Inputs Wizard — v3 card layout (light + dark mode)
// Copyright 2026 Nexa Systems Inc.
//
// Replaces the long-row table layout (v2) with a stacked card layout —
// one card per measurement prompt, the right input widget rendered per
// type, target range + required indicator visible inline.
//
// Pattern (per fusion-plating/CLAUDE.md):
// * SCSS branches at COMPILE TIME on $o-webclient-color-scheme
// * File is registered in BOTH web.assets_backend AND web.assets_web_dark
// * No reliance on runtime DOM classes (.o_dark_mode etc) — Odoo 19
// does not flip dark mode via runtime; it serves a separate bundle.
// * Tokens fall through to CSS custom properties so deployments can
// override via :root { --fp-card-bg: ... } without touching SCSS.
// =============================================================================
$o-webclient-color-scheme: bright !default;
// ---------- Surface tokens — branched at compile time ------------------------
$_fp-iw-card-hex : #ffffff;
$_fp-iw-card-hover-hex: #f8f9fa;
$_fp-iw-page-hex : #f3f4f6;
$_fp-iw-border-hex : #d8dadd;
$_fp-iw-border-focus-hex: #714B67; // Odoo brand purple
$_fp-iw-ink-hex : #1f2937;
$_fp-iw-ink-soft-hex : #4b5563;
$_fp-iw-ink-mute-hex : #6b7280;
$_fp-iw-ink-faint-hex : #9ca3af;
$_fp-iw-required-hex : #dc3545; // red asterisk
$_fp-iw-success-hex : #198754;
$_fp-iw-pill-bg-hex : #f1f3f5;
@if $o-webclient-color-scheme == dark {
$_fp-iw-card-hex : #22262d !global;
$_fp-iw-card-hover-hex: #2a2f37 !global;
$_fp-iw-page-hex : #1a1d21 !global;
$_fp-iw-border-hex : #343942 !global;
$_fp-iw-border-focus-hex: #a78bca !global; // lighter purple for dark
$_fp-iw-ink-hex : #e5e7eb !global;
$_fp-iw-ink-soft-hex : #c8ccd2 !global;
$_fp-iw-ink-mute-hex : #8a909a !global;
$_fp-iw-ink-faint-hex : #5a606b !global;
$_fp-iw-required-hex : #ea868f !global;
$_fp-iw-success-hex : #75b798 !global;
$_fp-iw-pill-bg-hex : #1c2027 !global;
}
// CSS-custom-property fallbacks so per-deployment overrides still work.
$fp-iw-card : var(--fp-card-bg, #{$_fp-iw-card-hex});
$fp-iw-card-hover : var(--fp-card-hover-bg, #{$_fp-iw-card-hover-hex});
$fp-iw-page : var(--fp-page-bg, #{$_fp-iw-page-hex});
$fp-iw-border : var(--fp-border-color, #{$_fp-iw-border-hex});
$fp-iw-border-focus: var(--fp-border-focus, #{$_fp-iw-border-focus-hex});
$fp-iw-ink : var(--fp-ink, #{$_fp-iw-ink-hex});
$fp-iw-ink-soft : var(--fp-ink-soft, #{$_fp-iw-ink-soft-hex});
$fp-iw-ink-mute : var(--fp-ink-mute, #{$_fp-iw-ink-mute-hex});
$fp-iw-ink-faint : var(--fp-ink-faint, #{$_fp-iw-ink-faint-hex});
$fp-iw-required : var(--fp-required, #{$_fp-iw-required-hex});
$fp-iw-success : var(--fp-success, #{$_fp-iw-success-hex});
$fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
// =============================================================================
// Wizard layout — header + section title + card grid + empty state
// =============================================================================
.o_fp_input_wizard_v3 {
background-color: $fp-iw-page;
.o_fp_input_header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid $fp-iw-border;
h2 {
margin: 0 0 4px 0;
font-size: 1.5rem;
font-weight: 600;
color: $fp-iw-ink;
}
.o_fp_input_subhead {
margin: 0;
font-size: 0.875rem;
color: $fp-iw-ink-mute;
// The job_id field renders as an inline anchor; keep the
// colour calm so the section title stays the focal point.
a, .o_field_widget {
color: $fp-iw-ink-soft;
}
}
}
.o_fp_input_section_title {
margin: 8px 0 12px 0;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $fp-iw-ink-mute;
}
.o_fp_input_empty_state {
padding: 32px 24px;
text-align: center;
color: $fp-iw-ink-mute;
background-color: $fp-iw-card;
border: 1px dashed $fp-iw-border;
border-radius: 12px;
strong {
color: $fp-iw-ink-soft;
}
}
}
// =============================================================================
// List → cards transformation
//
// We keep Odoo's <list editable="bottom"> for inline editing semantics
// (operators tab through cells, Enter saves the row) and re-render it
// as a stack of cards via CSS only. No JS, no OWL component — the
// existing wizard model is unchanged.
//
// Strategy: turn each <table>/<tr>/<td> into block-level / grid
// containers. Hide column headers entirely. Use CSS Grid on each row
// to position prompt + meta + value into a card layout.
// =============================================================================
.o_fp_input_card_list {
// Override the default list chrome — no border, no horizontal scroll
.o_list_renderer {
background: transparent;
border: none;
overflow: visible;
}
.o_list_table {
display: block;
width: 100%;
border: none;
border-collapse: separate;
background: transparent;
// No column headers — each card carries its own labels
> thead { display: none; }
> tbody {
display: flex;
flex-direction: column;
gap: 12px;
}
// Each row becomes a card
tr.o_data_row {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"prompt meta"
"value value"
"extras extras";
gap: 8px 16px;
align-items: start;
padding: 16px 20px;
background-color: $fp-iw-card;
border: 1px solid $fp-iw-border;
border-radius: 12px;
transition: border-color 150ms ease, background-color 150ms ease;
&:hover {
background-color: $fp-iw-card-hover;
}
&:focus-within {
border-color: $fp-iw-border-focus;
box-shadow: 0 0 0 3px
color-mix(in srgb, #{$fp-iw-border-focus} 18%, transparent);
}
// Per-cell rest — strip table styling; we'll re-position via
// grid-area on the cells we actually want visible.
> td {
display: block;
padding: 0;
margin: 0;
border: none !important;
background: transparent !important;
vertical-align: top;
// Inputs inherit row width
.o_field_widget {
width: 100%;
}
}
// Defensive: any cell whose widget is logically invisible
// (Odoo's invisible="..." attr) drops out of the grid so it
// doesn't punch an empty slot in our layout.
> td:has(.o_invisible_modifier),
> td.o_invisible_modifier,
> td:empty {
display: none;
}
// ---------- Card header — prompt name ----------
td.o_fp_iw_prompt {
grid-area: prompt;
input, .o_field_widget {
font-size: 1rem;
font-weight: 600;
color: $fp-iw-ink;
background: transparent !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
cursor: text;
&[readonly], &:disabled {
cursor: default;
}
}
// Required asterisk — driven by data-required attribute
// OR a server-side compute. We can't easily inspect the
// model field here, so the asterisk is rendered by the
// XML view via a span sibling (.o_fp_iw_required_marker).
}
// ---------- Meta — type + unit pill, target range ----------
td.o_fp_iw_meta {
grid-area: meta;
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
font-size: 0.75rem;
color: $fp-iw-ink-mute;
.o_field_widget {
width: auto;
}
// Type/unit selection looks like a pill
select, input {
font-size: 0.75rem;
padding: 4px 10px !important;
background-color: $fp-iw-pill-bg !important;
color: $fp-iw-ink-soft !important;
border: 1px solid $fp-iw-border !important;
border-radius: 999px !important;
line-height: 1.2 !important;
height: auto !important;
min-height: 0 !important;
}
}
// ---------- Value — the live widget for this row's type ----------
td.o_fp_iw_value {
grid-area: value;
max-width: 360px;
// Numeric / text / date inputs — large + comfortable
input[type="text"],
input[type="number"],
input[type="datetime-local"],
input:not([type]) {
font-size: 1.125rem;
font-weight: 500;
padding: 10px 14px;
min-height: 48px;
background-color: $fp-iw-card;
color: $fp-iw-ink;
border: 1px solid $fp-iw-border;
border-radius: 8px;
box-shadow: none;
transition: border-color 120ms ease,
box-shadow 120ms ease;
&:focus {
border-color: $fp-iw-border-focus;
box-shadow: 0 0 0 3px
color-mix(in srgb,
#{$fp-iw-border-focus} 25%, transparent);
outline: none;
}
&::placeholder {
color: $fp-iw-ink-faint;
font-weight: 400;
}
}
// Boolean toggle — make the pill bigger, easier to tap
.o_boolean_toggle {
transform: scale(1.4);
transform-origin: left center;
margin: 8px 0;
}
// Image / photo widget
.o_field_image {
img, .o_image, .o_form_uri {
max-width: 240px;
max-height: 180px;
border-radius: 8px;
border: 1px solid $fp-iw-border;
}
}
}
// ---------- Extras — composite types (multi-point, panel) ----------
td.o_fp_iw_extra {
grid-area: extras;
display: inline-flex;
gap: 8px;
align-items: baseline;
margin-right: 8px;
// Compact label-above-input grouping
&::before {
content: attr(data-label);
display: block;
font-size: 0.75rem;
color: $fp-iw-ink-mute;
margin-right: 4px;
}
input {
width: 80px !important;
font-size: 1rem;
padding: 6px 10px;
min-height: 38px;
background-color: $fp-iw-card;
color: $fp-iw-ink;
border: 1px solid $fp-iw-border;
border-radius: 6px;
}
}
// Trash button column — small, right-aligned, low contrast
td.o_list_record_remove {
grid-area: meta;
align-self: start;
justify-self: end;
opacity: 0.4;
&:hover { opacity: 1; }
button {
color: $fp-iw-ink-mute;
background: transparent !important;
border: none !important;
padding: 4px;
&:hover {
color: $fp-iw-required;
}
}
}
}
// "Add a line" footer — make it a tasteful CTA card
tfoot, .o_field_x2many_list_row_add {
display: block;
margin-top: 4px;
a, td {
display: inline-block;
padding: 10px 18px;
font-size: 0.875rem;
font-weight: 600;
color: $fp-iw-ink-soft;
background-color: $fp-iw-card;
border: 1px dashed $fp-iw-border;
border-radius: 10px;
cursor: pointer;
transition: border-color 120ms ease, color 120ms ease;
text-decoration: none;
&:hover {
color: $fp-iw-border-focus;
border-color: $fp-iw-border-focus;
background-color: $fp-iw-card-hover;
text-decoration: none;
}
}
}
}
}
// =============================================================================
// Tablet polish — operators on shop-floor tablets need bigger touch targets
// =============================================================================
@media (max-width: 900px) {
.o_fp_input_card_list .o_list_table tr.o_data_row {
grid-template-columns: 1fr;
grid-template-areas:
"prompt"
"meta"
"value"
"extras";
td.o_fp_iw_meta {
justify-content: flex-start;
}
td.o_fp_iw_value {
max-width: 100%;
input { min-height: 56px; }
}
td.o_list_record_remove {
justify-self: end;
}
}
}

View File

@@ -826,3 +826,231 @@ class TestContractReviewStepRouting(TransactionCase):
'Non-CR steps must NOT be redirected to QA-005, got: %r'
% action,
)
class TestSequentialEnforcement(TransactionCase):
"""Sub 13 — recipe-level + per-step sequential enforcement.
Decision matrix being verified:
recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
--------------------------|---------------------|------------------------|------
True | False | any | YES
True | True | any | no
False | any | True | YES
False | any | False | no
"""
def setUp(self):
super().setUp()
from odoo.exceptions import UserError
self._UserError = UserError
self.partner = self.env['res.partner'].create({'name': 'Seq Cust'})
self.product = self.env['product.product'].create({'name': 'Seq W'})
self.wc = self.env['fp.work.centre'].create({
'name': 'Bench', 'code': 'BENCH', 'kind': 'inspection',
})
def _build_recipe(self, enforce_sequential=True, names=None):
"""Build a 3-step recipe with the given enforcement setting."""
names = names or ['Step A', 'Step B', 'Step C']
recipe = self.env['fusion.plating.process.node'].create({
'name': 'Seq Recipe (%s)' % (
'sequential' if enforce_sequential else 'free-flow'),
'node_type': 'recipe',
'enforce_sequential': enforce_sequential,
})
nodes = []
for i, n in enumerate(names):
nodes.append(self.env['fusion.plating.process.node'].create({
'name': n,
'node_type': 'operation',
'parent_id': recipe.id,
'sequence': (i + 1) * 10,
}))
return recipe, nodes
def _build_job(self, recipe, nodes):
"""Create a job + matching steps in (pending) state.
Skips the auto-generator so the test doesn't depend on the SO
fixture chain.
"""
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'recipe_id': recipe.id,
})
steps = []
for n in nodes:
steps.append(self.env['fp.job.step'].create({
'job_id': job.id,
'name': n.name,
'recipe_node_id': n.id,
'work_centre_id': self.wc.id,
'sequence': n.sequence,
'kind': 'other',
'state': 'ready',
}))
# job.enforce_sequential is a related from recipe.enforce_sequential
# — invalidate to force re-read after the fact-of-life writes above.
job.invalidate_recordset(['enforce_sequential'])
return job, steps
# ---- Sequential mode (the new default) -----------------------------
def test_sequential_default_blocks_out_of_order_start(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# Start A first — should succeed
a.button_start()
self.assertEqual(a.state, 'in_progress')
# Now try to start C while A is still in_progress
with self.assertRaises(self._UserError):
c.button_start()
def test_sequential_starting_first_step_works(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# First step has no predecessors → should always be allowed
a.button_start()
self.assertEqual(a.state, 'in_progress')
def test_sequential_after_predecessor_finishes_unlocks_next(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
a.button_start()
a.button_finish()
# Now B should be startable
b.button_start()
self.assertEqual(b.state, 'in_progress')
def test_sequential_skipped_predecessor_unlocks_next(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
a.button_skip()
b.button_start()
self.assertEqual(b.state, 'in_progress')
# ---- Per-step parallel_start opt-out --------------------------------
def test_parallel_start_step_can_start_anytime(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
nodes[2].parallel_start = True # mark Step C as parallel
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# Start A so B+C are blocked under normal sequential rules
a.button_start()
# B is still blocked (default behaviour)
with self.assertRaises(self._UserError):
b.button_start()
# C is parallel — should start fine while A is in_progress
c.button_start()
self.assertEqual(c.state, 'in_progress')
# ---- Free-flow mode (legacy escape hatch) ---------------------------
def test_free_flow_does_not_block(self):
recipe, nodes = self._build_recipe(enforce_sequential=False)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# All three startable in any order — no enforcement
c.button_start()
a.button_start()
b.button_start()
self.assertEqual(a.state, 'in_progress')
self.assertEqual(b.state, 'in_progress')
self.assertEqual(c.state, 'in_progress')
def test_free_flow_with_legacy_per_step_flag_still_blocks(self):
recipe, nodes = self._build_recipe(enforce_sequential=False)
nodes[2].requires_predecessor_done = True # legacy flag
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
a.button_start()
# Even in free-flow, the legacy flag forces C to wait
with self.assertRaises(self._UserError):
c.button_start()
# ---- Manager bypass --------------------------------------------------
def test_manager_bypass_via_context(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
a.button_start()
# Manager override skips the gate
c.with_context(fp_skip_predecessor_check=True).button_start()
self.assertEqual(c.state, 'in_progress')
# ---- can_start compute ----------------------------------------------
def test_can_start_compute_reflects_gate(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# All ready — only first step can start
steps.invalidate_recordset(['can_start'])
self.assertTrue(a.can_start, 'First step has no predecessor — should be startable')
self.assertFalse(b.can_start, 'Step B blocked by Step A (ready, not done)')
self.assertFalse(c.can_start, 'Step C blocked by Step A')
# After A finishes, B becomes startable (C still blocked by B)
a.button_start()
a.button_finish()
steps.invalidate_recordset(['can_start'])
self.assertTrue(b.can_start)
self.assertFalse(c.can_start)
def test_can_start_false_for_done_steps(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
a.button_start()
a.button_finish()
steps.invalidate_recordset(['can_start'])
self.assertFalse(a.can_start, 'Done step is not startable')
def test_can_start_true_for_parallel_step(self):
recipe, nodes = self._build_recipe(enforce_sequential=True)
nodes[2].parallel_start = True
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
a.button_start()
steps.invalidate_recordset(['can_start'])
self.assertTrue(c.can_start, 'parallel_start step should always be startable')
# ---- Library template snapshot --------------------------------------
def test_parallel_start_snapshots_from_library_template(self):
"""When a library template with parallel_start=True is dropped
into a recipe via the Simple Editor controller, the new
recipe-node should inherit the flag.
"""
tpl = self.env['fp.step.template'].create({
'name': 'Parallel paperwork step',
'parallel_start': True,
})
recipe = self.env['fusion.plating.process.node'].create({
'name': 'Snap recipe',
'node_type': 'recipe',
})
# Mimic the controller's _SNAPSHOT_FIELDS copy
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import _SNAPSHOT_FIELDS
self.assertIn(
'parallel_start', _SNAPSHOT_FIELDS,
'parallel_start must be in the controller _SNAPSHOT_FIELDS list',
)
new_node = self.env['fusion.plating.process.node'].create({
'parent_id': recipe.id,
'node_type': 'operation',
'sequence': 10,
**{f: tpl[f] for f in _SNAPSHOT_FIELDS},
})
self.assertTrue(
new_node.parallel_start,
'parallel_start did not snapshot from library template',
)

View File

@@ -93,11 +93,154 @@
</field>
</record>
<!-- ================================================================== -->
<!-- v3 — Card-based stacked layout (light + dark mode aware) -->
<!-- -->
<!-- Replaces v2's wide editable table. Each measurement renders as a -->
<!-- card with: prompt name (header), type/unit pills (meta), and ONLY -->
<!-- the live input widget for that prompt's type (value). Composite -->
<!-- types (multi-point thickness, bath chemistry panel) keep their -->
<!-- sub-fields inside the same card via the "extras" grid area. -->
<!-- -->
<!-- Auto-adapts to custom recipes/steps because default_get on the -->
<!-- model already pre-fills line_ids from recipe_node.input_ids. -->
<!-- -->
<!-- All visual styling lives in -->
<!-- static/src/scss/fp_job_step_input_wizard_v3.scss -->
<!-- which is registered in BOTH web.assets_backend AND -->
<!-- web.assets_web_dark so both themes compile their own palette. -->
<!-- ================================================================== -->
<record id="view_fp_job_step_input_wizard_form_v3" model="ir.ui.view">
<field name="name">fp.job.step.input.wizard.form.v3</field>
<field name="model">fp.job.step.input.wizard</field>
<field name="arch" type="xml">
<form string="Record Step Inputs">
<sheet class="o_fp_input_wizard_v3">
<div class="o_fp_input_header">
<h2><field name="step_id" readonly="1" nolabel="1"/></h2>
<p class="o_fp_input_subhead">
Job <field name="job_id" readonly="1" nolabel="1"/>
</p>
</div>
<div class="o_fp_input_section_title">Measurements</div>
<p class="o_fp_input_empty_state" invisible="line_ids">
No measurement prompts on this step yet.
Click <strong>Add a line</strong> below to record an
ad-hoc reading.
</p>
<field name="line_ids" class="o_fp_input_card_list" nolabel="1">
<list editable="bottom" create="true" delete="true">
<!-- Hidden flag fields — drive value-cell visibility -->
<field name="is_authored" column_invisible="1"/>
<field name="is_boolean_type" column_invisible="1"/>
<field name="is_date_type" column_invisible="1"/>
<field name="is_numeric_type" column_invisible="1"/>
<field name="is_photo_type" column_invisible="1"/>
<field name="is_multi_point_type" column_invisible="1"/>
<field name="is_panel_type" column_invisible="1"/>
<field name="point_avg" column_invisible="1"/>
<field name="photo_filename" column_invisible="1"/>
<!-- Card header — prompt name (large, bold via SCSS) -->
<field name="name"
string="Measurement"
readonly="is_authored"
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"
class="o_fp_iw_prompt"/>
<!-- Meta — type + unit rendered as pills (top-right) -->
<field name="input_type"
string="Type"
readonly="is_authored"
class="o_fp_iw_meta"/>
<field name="target_unit"
string="Unit"
readonly="is_authored"
class="o_fp_iw_meta"
optional="show"/>
<!-- Hidden by default — operator can opt in via the cog menu
if they want to see/edit target ranges per row -->
<field name="target_min" optional="hide"/>
<field name="target_max" optional="hide"/>
<!-- Mutually exclusive value widgets — only the one
matching the row's input_type renders -->
<field name="value_number"
string="Value"
invisible="not is_numeric_type"
class="o_fp_iw_value"/>
<field name="value_boolean"
string="Value"
widget="boolean_toggle"
invisible="not is_boolean_type"
class="o_fp_iw_value"/>
<field name="value_date"
string="Value"
invisible="not is_date_type"
class="o_fp_iw_value"/>
<field name="value_text"
string="Value"
invisible="is_numeric_type or is_boolean_type or is_date_type or is_photo_type or is_multi_point_type or is_panel_type"
class="o_fp_iw_value"/>
<field name="photo_value"
string="Photo"
widget="image"
options="{'preview_image': 'photo_value'}"
invisible="not is_photo_type"
class="o_fp_iw_value"/>
<!-- Composite type 1: Multi-Point Thickness — 5 readings -->
<field name="point_1" string="R1"
invisible="not is_multi_point_type"
class="o_fp_iw_extra" optional="show"/>
<field name="point_2" string="R2"
invisible="not is_multi_point_type"
class="o_fp_iw_extra" optional="show"/>
<field name="point_3" string="R3"
invisible="not is_multi_point_type"
class="o_fp_iw_extra" optional="show"/>
<field name="point_4" string="R4"
invisible="not is_multi_point_type"
class="o_fp_iw_extra" optional="hide"/>
<field name="point_5" string="R5"
invisible="not is_multi_point_type"
class="o_fp_iw_extra" optional="hide"/>
<!-- Composite type 2: Bath Chemistry Panel -->
<field name="panel_ph" string="pH"
invisible="not is_panel_type"
class="o_fp_iw_extra"/>
<field name="panel_concentration" string="Conc"
invisible="not is_panel_type"
class="o_fp_iw_extra"/>
<field name="panel_temperature" string="Temp"
invisible="not is_panel_type"
class="o_fp_iw_extra"/>
<field name="panel_bath_id" string="Bath"
invisible="not is_panel_type"
class="o_fp_iw_extra"/>
</list>
</field>
</sheet>
<footer>
<button name="action_commit" type="object"
string="Save" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_job_step_input_wizard" model="ir.actions.act_window">
<field name="name">Record Step Inputs</field>
<field name="res_model">fp.job.step.input.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fp_job_step_input_wizard_form_v2"/>
<field name="view_id" ref="view_fp_job_step_input_wizard_form_v3"/>
<field name="target">new</field>
</record>

View File

@@ -115,8 +115,15 @@ class FpTabletMoveController(http.Controller):
'resolve_action': 'open_rack_parts_dialog',
})
# 2. Predecessor lock (S14 hard block)
if to_step.requires_predecessor_done and not skip_predecessor:
# 2. Predecessor lock (Sub 13 — recipe + per-step enforcement).
# Delegates to fp.job.step._fp_should_block_predecessors so the
# tablet Move dialog and the backend button_start use the same
# decision matrix (recipe.enforce_sequential, step.parallel_start,
# legacy step.requires_predecessor_done).
if not skip_predecessor and (
hasattr(to_step, '_fp_should_block_predecessors')
and to_step._fp_should_block_predecessors()
):
unfinished = to_step.job_id.step_ids.filtered(
lambda s: s.sequence < to_step.sequence
and s.state not in ('done', 'skipped', 'cancelled')