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

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