feat(jobs): Sub 14 — configurable workflow state bar (Path B)
Replaces the generic Draft/Confirmed/In Progress/Done statusbar with
a shop-configurable list of plating-specific milestones. Bar advances
automatically as recipe steps complete; no manual button clicks.
What ships
==========
* New model: fp.job.workflow.state
Catalog of milestones (name, code, sequence, color, triggers).
Triggers can be:
- trigger_default_kinds: "receiving,inspect" matches by step.default_kind
- trigger_first_step_started: any wet/bake/mask/rack step started
- trigger_all_steps_done: every non-cancelled step in done/skipped
- block_when_quality_hold: held back while NCR/hold open
Plus per-recipe-node override (see below).
* Default 7-state seed (data/fp_workflow_state_data.xml):
Draft → Confirmed → Received → In Progress → Inspected → Shipped → Done
noupdate=1 so per-shop edits survive module upgrade.
* Recipe-side trigger field on fusion.plating.process.node:
triggers_workflow_state_id (Many2one, optional)
Wins over default_kind matching. Lets the recipe author pin a
specific step as a milestone trigger even when default_kind isn't
set or doesn't match. Exposed in the Recipe Tree Editor properties
panel (dropdown sourced from the catalog).
* fp.job.workflow_state_id (computed, stored)
Iterates the catalog in sequence order; lands at the highest passed
milestone. Recomputes on step state / kind / recipe_node / quality
hold changes. Replaces fp.job.state on the form's statusbar.
* Settings UI: Configuration > Workflow States
Standard list+form pages so admins can add / edit / deactivate
states. Manager-group write permission, supervisor read.
What this does NOT do
=====================
* Doesn't drop fp.job.state — that field still drives the internal
state machine (button_confirm, action_cancel, etc.). Only the
UI statusbar is reassigned.
* No migration for existing jobs — they auto-recompute on next read
because workflow_state_id is a stored compute with the right
api.depends. Existing WH/JOB/00342 will display its current
workflow state on next page load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,27 @@ class FpRecipeController(http.Controller):
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/tree', type='jsonrpc', auth='user')
|
||||
def get_tree(self, recipe_id):
|
||||
"""Return the full nested tree for a recipe."""
|
||||
"""Return the full nested tree for a recipe + the workflow
|
||||
states catalog for the per-step "Triggers Workflow State"
|
||||
dropdown in the properties panel (Sub 14).
|
||||
"""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
recipe = Node.browse(int(recipe_id))
|
||||
if not recipe.exists():
|
||||
return {'ok': False, 'error': f'Recipe {recipe_id} not found.'}
|
||||
# Workflow states for the dropdown — runtime-detect the model
|
||||
# so the tree editor still works on installs without
|
||||
# fusion_plating_jobs (where the model lives).
|
||||
workflow_states = []
|
||||
WS = request.env.get('fp.job.workflow.state')
|
||||
if WS is not None:
|
||||
for ws in WS.search([('active', '=', True)], order='sequence, id'):
|
||||
workflow_states.append({
|
||||
'id': ws.id,
|
||||
'name': ws.name or '',
|
||||
'code': ws.code or '',
|
||||
'sequence': ws.sequence,
|
||||
})
|
||||
return {
|
||||
'ok': True,
|
||||
'recipe': {
|
||||
@@ -34,6 +50,7 @@ class FpRecipeController(http.Controller):
|
||||
'process_type': recipe.process_type_id.name if recipe.process_type_id else '',
|
||||
},
|
||||
'tree': recipe.get_tree_data(),
|
||||
'workflow_states': workflow_states,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -88,6 +105,8 @@ class FpRecipeController(http.Controller):
|
||||
'requires_signoff', 'opt_in_out', 'sequence', 'version',
|
||||
# Sub 13 — sequential enforcement
|
||||
'enforce_sequential', 'parallel_start',
|
||||
# Sub 14 — workflow milestone trigger
|
||||
'triggers_workflow_state_id',
|
||||
}
|
||||
safe_vals = {k: v for k, v in vals.items() if k in allowed}
|
||||
if not safe_vals:
|
||||
|
||||
@@ -569,6 +569,19 @@ class FpProcessNode(models.Model):
|
||||
'enforce_sequential': self.enforce_sequential,
|
||||
'parallel_start': self.parallel_start,
|
||||
'requires_predecessor_done': self.requires_predecessor_done,
|
||||
# Sub 14 — workflow milestone trigger (Many2one or False)
|
||||
'triggers_workflow_state_id': (
|
||||
self.triggers_workflow_state_id.id
|
||||
if 'triggers_workflow_state_id' in self._fields
|
||||
and self.triggers_workflow_state_id
|
||||
else False
|
||||
),
|
||||
'triggers_workflow_state_name': (
|
||||
self.triggers_workflow_state_id.name
|
||||
if 'triggers_workflow_state_id' in self._fields
|
||||
and self.triggers_workflow_state_id
|
||||
else ''
|
||||
),
|
||||
'version': self.version,
|
||||
'child_count': len(children),
|
||||
'opt_in_out': self.opt_in_out or 'disabled',
|
||||
|
||||
@@ -102,6 +102,7 @@ export class RecipeTreeEditor extends Component {
|
||||
this.state = useState({
|
||||
recipe: null,
|
||||
tree: null,
|
||||
workflowStates: [], // Sub 14 — populated by loadTree
|
||||
loading: false,
|
||||
saving: false,
|
||||
selectedNodeId: null,
|
||||
@@ -157,6 +158,9 @@ export class RecipeTreeEditor extends Component {
|
||||
if (result && result.ok) {
|
||||
this.state.recipe = result.recipe;
|
||||
this.state.tree = result.tree;
|
||||
// Sub 14 — workflow states for the per-step trigger
|
||||
// dropdown in the properties panel.
|
||||
this.state.workflowStates = result.workflow_states || [];
|
||||
// Auto-expand every node on first load AND auto-expand
|
||||
// any node we haven't seen before (e.g. freshly imported
|
||||
// nodes after a "Import from recipe" run). Nodes the
|
||||
@@ -271,6 +275,8 @@ export class RecipeTreeEditor extends Component {
|
||||
// Sub 13 — sequential enforcement
|
||||
enforce_sequential: !!node.enforce_sequential,
|
||||
parallel_start: !!node.parallel_start,
|
||||
// Sub 14 — workflow milestone trigger
|
||||
triggers_workflow_state_id: node.triggers_workflow_state_id || false,
|
||||
};
|
||||
const result = await rpc("/fp/recipe/node/write", {
|
||||
node_id: node.id,
|
||||
|
||||
@@ -374,6 +374,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub 14 — workflow milestone trigger (operation / step nodes) -->
|
||||
<div class="o_fp_re_field"
|
||||
t-if="(state.selectedNode.node_type === 'operation' or state.selectedNode.node_type === 'step') and state.workflowStates.length">
|
||||
<label for="fp_re_workflow_state">Triggers Workflow State</label>
|
||||
<select id="fp_re_workflow_state"
|
||||
class="form-select"
|
||||
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
<option value=""
|
||||
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
</option>
|
||||
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
|
||||
<option t-att-value="ws.id"
|
||||
t-att-selected="state.selectedNode.triggers_workflow_state_id === ws.id"
|
||||
t-esc="ws.name"/>
|
||||
</t>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
When this step finishes (or is skipped/cancelled), the
|
||||
job's status bar advances to the chosen state. Leave
|
||||
blank to fall back to the default-kind mapping
|
||||
configured on the workflow state catalog.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_re_field">
|
||||
<label>Step Usage</label>
|
||||
<select class="form-select"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.17.5',
|
||||
'version': '19.0.8.18.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -53,6 +53,10 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'security/legacy_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_cron_data.xml',
|
||||
# Sub 14 — workflow state catalog (must load before fp_job_form_inherit
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
'views/fp_workflow_state_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_job_step_quick_look_views.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Sub 14 — Default 7-state workflow milestone catalog.
|
||||
|
||||
Shops can edit, deactivate, or add their own states via
|
||||
Settings → Fusion Plating → Workflow States. These records use
|
||||
noupdate="1" so per-shop edits aren't blown away on module upgrade.
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="workflow_state_draft" model="fp.job.workflow.state">
|
||||
<field name="name">Draft</field>
|
||||
<field name="code">draft</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="color">grey</field>
|
||||
<field name="is_initial" eval="True"/>
|
||||
<field name="description">Job created from SO, not yet kicked off. Awaiting setup or start-of-day batch.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_confirmed" model="fp.job.workflow.state">
|
||||
<field name="name">Confirmed</field>
|
||||
<field name="code">confirmed</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="color">blue</field>
|
||||
<field name="description">SO confirmed, recipe locked, parts on order. Financially committed.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_received" model="fp.job.workflow.state">
|
||||
<field name="name">Received</field>
|
||||
<field name="code">received</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="color">cyan</field>
|
||||
<field name="trigger_default_kinds">receiving</field>
|
||||
<field name="description">Parts physically on the floor — production CAN start. Triggered by completion of any step with default_kind='receiving'.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_in_progress" model="fp.job.workflow.state">
|
||||
<field name="name">In Progress</field>
|
||||
<field name="code">in_progress</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="color">yellow</field>
|
||||
<field name="trigger_first_step_started" eval="True"/>
|
||||
<field name="description">First wet/production step started. Line is running, real shop time accruing. Triggered by any step with kind in (wet, bake, mask, rack) reaching in_progress or beyond.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_inspected" model="fp.job.workflow.state">
|
||||
<field name="name">Inspected</field>
|
||||
<field name="code">inspected</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="color">green</field>
|
||||
<field name="trigger_default_kinds">final_inspect</field>
|
||||
<field name="block_when_quality_hold" eval="True"/>
|
||||
<field name="description">Final inspection passed AND no open quality hold. Safe to generate CoC + invoice. Blocked while any quality hold is open.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_shipped" model="fp.job.workflow.state">
|
||||
<field name="name">Shipped</field>
|
||||
<field name="code">shipped</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="color">success</field>
|
||||
<field name="trigger_default_kinds">ship</field>
|
||||
<field name="description">Shipment confirmed (BOL or carrier pickup). Customer can be notified.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_done" model="fp.job.workflow.state">
|
||||
<field name="name">Done</field>
|
||||
<field name="code">done</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="color">success</field>
|
||||
<field name="is_terminal" eval="True"/>
|
||||
<field name="trigger_all_steps_done" eval="True"/>
|
||||
<field name="description">Every non-cancelled step is in done/skipped state. Audit trail closed.</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -5,6 +5,7 @@
|
||||
# Phase 2 of the native plating job model migration. Models are added
|
||||
# task-by-task in Tasks 2.2 onwards.
|
||||
|
||||
from . import fp_job_workflow_state # Sub 14 — must load before fp_job (FK target)
|
||||
from . import fp_job
|
||||
from . import fp_job_step
|
||||
from . import fp_job_node_override
|
||||
|
||||
@@ -80,6 +80,46 @@ class FpJob(models.Model):
|
||||
'idempotency. Cleared post-cutover.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub 14 — Configurable workflow state (status bar milestone)
|
||||
# ------------------------------------------------------------------
|
||||
# workflow_state_id auto-advances along the highest passed milestone
|
||||
# in fp.job.workflow.state's sequence order. Replaces the hardcoded
|
||||
# state Selection on the form's statusbar.
|
||||
workflow_state_id = fields.Many2one(
|
||||
'fp.job.workflow.state',
|
||||
string='Workflow Stage',
|
||||
compute='_compute_workflow_state_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
help='Highest workflow milestone this job has passed, computed '
|
||||
'from step states + per-state trigger conditions. Updates '
|
||||
'automatically — the operator never sets it.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'step_ids',
|
||||
'step_ids.state',
|
||||
'step_ids.kind',
|
||||
'step_ids.recipe_node_id',
|
||||
'step_ids.recipe_node_id.default_kind',
|
||||
'step_ids.recipe_node_id.triggers_workflow_state_id',
|
||||
'quality_hold_count',
|
||||
)
|
||||
def _compute_workflow_state_id(self):
|
||||
WS = self.env['fp.job.workflow.state']
|
||||
all_states = WS.search([], order='sequence, id')
|
||||
for job in self:
|
||||
passed = WS.browse()
|
||||
for ws in all_states:
|
||||
if ws._fp_is_passed_for_job(job):
|
||||
passed = ws
|
||||
else:
|
||||
# First non-passed state stops the bar's progress
|
||||
break
|
||||
job.workflow_state_id = passed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Smart-button counts (Feature A — operator workflow)
|
||||
#
|
||||
@@ -1350,3 +1390,26 @@ class FpJobStep(models.Model):
|
||||
'migrated from. Used by the migration script for '
|
||||
'idempotency. Cleared post-cutover.',
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Sub 14 — Recipe-side trigger field
|
||||
# ==========================================================================
|
||||
# Adds an optional Many2one on every recipe operation node so the recipe
|
||||
# author can explicitly map "completion of this step triggers workflow
|
||||
# state X". Wins over the default-kind matching defined on the workflow
|
||||
# state itself. Lives here (not core) because the target model
|
||||
# (fp.job.workflow.state) is defined in this module.
|
||||
|
||||
class FusionPlatingProcessNodeWorkflow(models.Model):
|
||||
_inherit = 'fusion.plating.process.node'
|
||||
|
||||
triggers_workflow_state_id = fields.Many2one(
|
||||
'fp.job.workflow.state',
|
||||
string='Triggers Workflow State',
|
||||
ondelete='set null',
|
||||
help='When a job step generated from this recipe node finishes '
|
||||
'(or is skipped/cancelled), the job advances to this '
|
||||
'workflow state. Leave blank to fall back to default-kind '
|
||||
'matching defined on the workflow state catalog.',
|
||||
)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Sub 14 — Configurable workflow status bar.
|
||||
|
||||
Each job carries a workflow_state_id that auto-advances along a
|
||||
shop-configurable sequence of milestones (Draft → Confirmed → Received
|
||||
→ In Progress → Inspected → Shipped → Done — by default).
|
||||
|
||||
Recipe authors tag specific recipe steps as "this step's completion
|
||||
triggers workflow state X" via process_node.triggers_workflow_state_id.
|
||||
The default mapping is by step.default_kind (so out-of-the-box recipes
|
||||
just work), with per-recipe override on each operation node.
|
||||
|
||||
Why this lives in fusion_plating_jobs (not core):
|
||||
* It depends on fp.job.step which is implemented here
|
||||
* Recipe-side trigger fields are added via _inherit on
|
||||
fusion.plating.process.node (also here, in fp_job.py)
|
||||
|
||||
The catalog seed lives in data/fp_workflow_state_data.xml and ships
|
||||
the 7 default milestones. Settings UI lets shops add more.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpJobWorkflowState(models.Model):
|
||||
_name = 'fp.job.workflow.state'
|
||||
_description = 'Fusion Plating — Job Workflow State (status bar milestone)'
|
||||
_order = 'sequence, id'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='State Name',
|
||||
required=True,
|
||||
translate=True,
|
||||
help='Operator-facing label shown in the job status bar '
|
||||
'(e.g. "Received", "Inspected", "Shipped").',
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
help='Stable identifier — used by code/migrations to reference '
|
||||
'this state without depending on the (translatable) name. '
|
||||
'Lowercase snake_case (e.g. "received", "inspected").',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
required=True,
|
||||
help='Position of this state on the bar (left to right). '
|
||||
'10-spacing convention so authors can insert new states '
|
||||
'between existing ones without renumbering.',
|
||||
)
|
||||
color = fields.Selection(
|
||||
[
|
||||
('grey', 'Grey'),
|
||||
('blue', 'Blue'),
|
||||
('cyan', 'Cyan'),
|
||||
('yellow', 'Yellow'),
|
||||
('orange', 'Orange'),
|
||||
('green', 'Green'),
|
||||
('success', 'Success Green'),
|
||||
('danger', 'Danger Red'),
|
||||
('purple', 'Purple'),
|
||||
],
|
||||
string='Color',
|
||||
default='grey',
|
||||
help='Status pill colour on the bar.',
|
||||
)
|
||||
is_initial = fields.Boolean(
|
||||
string='Initial State',
|
||||
default=False,
|
||||
help='Marks this as the starting state for new jobs. Only one '
|
||||
'state should be marked initial.',
|
||||
)
|
||||
is_terminal = fields.Boolean(
|
||||
string='Terminal State',
|
||||
default=False,
|
||||
help='Marks this as the final state. The bar stops advancing '
|
||||
'once a job reaches it. Only one state should be marked '
|
||||
'terminal.',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Internal notes on what this milestone represents and '
|
||||
'when it should fire. Not shown to operators.',
|
||||
)
|
||||
|
||||
# ---- Trigger conditions --------------------------------------------------
|
||||
#
|
||||
# A state is "passed" when ALL recipe steps matching its trigger
|
||||
# conditions are in done/skipped/cancelled. Two ways to define
|
||||
# which steps trigger:
|
||||
# 1. trigger_default_kinds — match on recipe step's default_kind
|
||||
# Selection. Easiest path — covers standard recipes that use
|
||||
# the curated kind values (receiving, final_inspect, ship, etc.)
|
||||
# 2. Per-recipe-node override via
|
||||
# fusion.plating.process.node.triggers_workflow_state_id
|
||||
# (defined in fp_job.py). Wins over default_kind matching.
|
||||
|
||||
trigger_default_kinds = fields.Char(
|
||||
string='Trigger Default Kinds',
|
||||
help='Comma-separated list of step.default_kind values. When the '
|
||||
'last recipe step matching any of these kinds is finished, '
|
||||
'the state passes. Example: "receiving,inspect" for a '
|
||||
'"Received" state. Leave blank if you only want to use '
|
||||
'per-recipe-node overrides.',
|
||||
)
|
||||
|
||||
trigger_first_step_started = fields.Boolean(
|
||||
string='Trigger on First Step Started',
|
||||
default=False,
|
||||
help='Special trigger — passes as soon as the first wet step '
|
||||
'(or any step with kind not in inspection/admin) starts. '
|
||||
'Used for the "In Progress" milestone.',
|
||||
)
|
||||
|
||||
trigger_all_steps_done = fields.Boolean(
|
||||
string='Trigger on All Steps Done',
|
||||
default=False,
|
||||
help='Special trigger — passes when every non-cancelled step '
|
||||
'is in done/skipped state. Used for the "Done" milestone.',
|
||||
)
|
||||
|
||||
block_when_quality_hold = fields.Boolean(
|
||||
string='Blocked by Quality Hold',
|
||||
default=False,
|
||||
help='If True, this state will NOT pass while there is an open '
|
||||
'quality hold on the job. Used for the "Inspected" '
|
||||
'milestone — you can finish the inspection step but the '
|
||||
'state stays at the previous milestone until the NCR clears.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_workflow_state_code_uniq', 'unique(code)',
|
||||
'Workflow state code must be unique.'),
|
||||
]
|
||||
|
||||
@api.depends('name', 'code')
|
||||
def _compute_display_name(self):
|
||||
for s in self:
|
||||
s.display_name = '%s [%s]' % (s.name or '', s.code or '')
|
||||
|
||||
# ---- Trigger evaluation --------------------------------------------------
|
||||
|
||||
def _fp_kinds_set(self):
|
||||
"""Parse trigger_default_kinds into a set of kind strings."""
|
||||
self.ensure_one()
|
||||
if not self.trigger_default_kinds:
|
||||
return set()
|
||||
return {
|
||||
k.strip() for k in self.trigger_default_kinds.split(',')
|
||||
if k.strip()
|
||||
}
|
||||
|
||||
def _fp_is_passed_for_job(self, job):
|
||||
"""Return True if this state's trigger conditions are met by
|
||||
the given fp.job. Called from the job's compute method.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Initial state — always passed (every job starts here)
|
||||
if self.is_initial:
|
||||
return True
|
||||
|
||||
Step = self.env['fp.job.step']
|
||||
steps = job.step_ids
|
||||
|
||||
# Special trigger: all steps done
|
||||
if self.trigger_all_steps_done:
|
||||
non_cancelled = steps.filtered(lambda s: s.state != 'cancelled')
|
||||
if not non_cancelled:
|
||||
return False
|
||||
return all(s.state in ('done', 'skipped') for s in non_cancelled)
|
||||
|
||||
# Special trigger: first wet step started
|
||||
if self.trigger_first_step_started:
|
||||
wet_kinds = ('wet', 'bake', 'mask', 'rack')
|
||||
production_started = any(
|
||||
s.state in ('in_progress', 'paused', 'done')
|
||||
and (s.kind in wet_kinds)
|
||||
for s in steps
|
||||
)
|
||||
if not production_started:
|
||||
return False
|
||||
# Production milestone — not blocked by quality hold here
|
||||
return True
|
||||
|
||||
# Standard trigger: ALL recipe steps matching the trigger
|
||||
# (default_kind in our list OR per-node override pointing at
|
||||
# us) must be in a terminal state.
|
||||
kinds = self._fp_kinds_set()
|
||||
matching_steps = steps.filtered(
|
||||
lambda s: (
|
||||
# Per-node override wins
|
||||
(s.recipe_node_id
|
||||
and s.recipe_node_id.triggers_workflow_state_id
|
||||
and s.recipe_node_id.triggers_workflow_state_id.id == self.id)
|
||||
or
|
||||
# Default-kind match
|
||||
(kinds and s.recipe_node_id
|
||||
and s.recipe_node_id.default_kind in kinds)
|
||||
)
|
||||
)
|
||||
if not matching_steps:
|
||||
# Nothing matches — this state can't pass for this recipe.
|
||||
# Treat as not-passed so the bar stays at the previous state.
|
||||
return False
|
||||
|
||||
# Every matching step must be terminal
|
||||
if not all(
|
||||
s.state in ('done', 'skipped', 'cancelled')
|
||||
for s in matching_steps
|
||||
):
|
||||
return False
|
||||
|
||||
# Quality-hold gate (optional)
|
||||
if self.block_when_quality_hold:
|
||||
QH = self.env.get('fusion.plating.quality.hold')
|
||||
if QH is not None:
|
||||
open_holds = QH.search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', 'not in', ('closed', 'cancelled')),
|
||||
])
|
||||
if open_holds:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -17,3 +17,6 @@ access_fp_job_step_input_wiz_mgr,fp.job.step.input.wiz.manager,model_fp_job_step
|
||||
access_fp_job_step_input_wiz_l_op,fp.job.step.input.wiz.l.operator,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_operator,1,1,1,1
|
||||
access_fp_job_step_input_wiz_l_sup,fp.job.step.input.wiz.l.supervisor,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -48,6 +48,16 @@
|
||||
invisible="state in ('draft', 'cancelled')"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done
|
||||
statusbar with the configurable workflow_state_id bar.
|
||||
Operators see meaningful plating milestones (Received,
|
||||
Inspected, Shipped, etc.) instead of generic Odoo states. -->
|
||||
<xpath expr="//header/field[@name='state']" position="replace">
|
||||
<field name="workflow_state_id"
|
||||
widget="statusbar"
|
||||
options="{'clickable': '0'}"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Surface part / coating / recipe on the header so the
|
||||
floor knows WHAT they're plating without diving into
|
||||
Source. The "Reference Product" line in core is just
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Sub 14 — Workflow state catalog UI (admin / Settings).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ====================== List view ====================== -->
|
||||
<record id="view_fp_workflow_state_list" model="ir.ui.view">
|
||||
<field name="name">fp.job.workflow.state.list</field>
|
||||
<field name="model">fp.job.workflow.state</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Workflow States" default_order="sequence, id">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="color" widget="badge"
|
||||
decoration-info="color == 'blue'"
|
||||
decoration-success="color == 'success' or color == 'green'"
|
||||
decoration-warning="color == 'yellow' or color == 'orange'"
|
||||
decoration-danger="color == 'danger'"
|
||||
decoration-muted="color == 'grey'"/>
|
||||
<field name="trigger_default_kinds"/>
|
||||
<field name="trigger_first_step_started"/>
|
||||
<field name="trigger_all_steps_done"/>
|
||||
<field name="block_when_quality_hold"/>
|
||||
<field name="is_initial"/>
|
||||
<field name="is_terminal"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ====================== Form view ====================== -->
|
||||
<record id="view_fp_workflow_state_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.workflow.state.form</field>
|
||||
<field name="model">fp.job.workflow.state</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Workflow State">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="Received"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="code" placeholder="received"/>
|
||||
<field name="sequence"/>
|
||||
<field name="color" widget="badge"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Lifecycle role">
|
||||
<field name="is_initial"/>
|
||||
<field name="is_terminal"/>
|
||||
<field name="block_when_quality_hold"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Trigger conditions">
|
||||
<field name="trigger_default_kinds"
|
||||
placeholder="receiving, inspect"/>
|
||||
<field name="trigger_first_step_started"/>
|
||||
<field name="trigger_all_steps_done"/>
|
||||
<p class="text-muted oe_grey mt-2">
|
||||
<strong>How triggers combine:</strong> a state is "passed"
|
||||
when EITHER the special trigger is true, OR every
|
||||
recipe step matching the listed default_kinds (or
|
||||
tagged via the per-node override on the recipe) is
|
||||
in done/skipped/cancelled state.
|
||||
<br/>
|
||||
<em>block_when_quality_hold</em>: holds back the
|
||||
advance even if the trigger conditions are met,
|
||||
until all open quality holds on the job are closed.
|
||||
</p>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="description" nolabel="1"
|
||||
placeholder="What this milestone represents and when it should fire..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ====================== Action + Menu ====================== -->
|
||||
<record id="action_fp_workflow_state" model="ir.actions.act_window">
|
||||
<field name="name">Workflow States</field>
|
||||
<field name="res_model">fp.job.workflow.state</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first workflow state.
|
||||
</p>
|
||||
<p>
|
||||
Workflow states are the milestones that appear on the
|
||||
status bar of every plating job. Each state passes
|
||||
automatically when its trigger conditions are met
|
||||
(recipe step kind finishes, all steps done, etc.).
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_workflow_state"
|
||||
name="Workflow States"
|
||||
parent="fusion_plating.menu_fp_config"
|
||||
action="action_fp_workflow_state"
|
||||
sequence="80"/>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user