diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index 9af0a456..697d2988 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -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': """
diff --git a/fusion_plating/fusion_plating/controllers/recipe_controller.py b/fusion_plating/fusion_plating/controllers/recipe_controller.py
index f0d73d3d..a3f988b6 100644
--- a/fusion_plating/fusion_plating/controllers/recipe_controller.py
+++ b/fusion_plating/fusion_plating/controllers/recipe_controller.py
@@ -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:
diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
index 51f8f192..f6c3f1b6 100644
--- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
+++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
@@ -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',
}
diff --git a/fusion_plating/fusion_plating/migrations/19.0.18.12.0/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.18.12.0/post-migrate.py
new file mode 100644
index 00000000..1657004f
--- /dev/null
+++ b/fusion_plating/fusion_plating/migrations/19.0.18.12.0/post-migrate.py
@@ -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 ''
+ ),
+ )
diff --git a/fusion_plating/fusion_plating/models/fp_job_step.py b/fusion_plating/fusion_plating/models/fp_job_step.py
index 9e95857c..ac4c27fe 100644
--- a/fusion_plating/fusion_plating/models/fp_job_step.py
+++ b/fusion_plating/fusion_plating/models/fp_job_step.py
@@ -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 =====================
diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py
index 33e732fc..890d01c3 100644
--- a/fusion_plating/fusion_plating/models/fp_process_node.py
+++ b/fusion_plating/fusion_plating/models/fp_process_node.py
@@ -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',
diff --git a/fusion_plating/fusion_plating/models/fp_step_template.py b/fusion_plating/fusion_plating/models/fp_step_template.py
index 3fe3eec9..113960e0 100644
--- a/fusion_plating/fusion_plating/models/fp_step_template.py
+++ b/fusion_plating/fusion_plating/models/fp_step_template.py
@@ -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',
diff --git a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js
index 5a63f01e..29bd5aea 100644
--- a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js
+++ b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js
@@ -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,
diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
index 45dd1805..7a6871a8 100644
--- a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
+++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
@@ -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),
diff --git a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml
index 8c2e3b83..a09aecf2 100644
--- a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml
+++ b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml
@@ -350,6 +350,28 @@
t-on-change="(ev) => { state.selectedNode.customer_visible = ev.target.checked; }"/>
+
+