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; }"/> + +
+ + +
+ +
+ + +
diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml index da0362c8..2e8370b3 100644 --- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml @@ -412,10 +412,16 @@ t-model="state.libraryEditor.requires_signoff"/> Require QA Sign-off -