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

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