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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user