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