feat(jobs): Sub 14 polish — workflow state form layout + Simple Editor field

Two follow-ups on the workflow state work:

1) Form layout
   The "How triggers combine" help text was crammed into a 2-column
   group, taking ~25% of the available width. Pulled it out of the
   group and rendered as a full-width <div class="alert alert-info">
   below the trigger fields. Same fix applied to Notes — uses a
   <separator> + bare <field> for full sheet width.

2) Simple Recipe Editor support
   The trigger field was only exposed in the Tree Editor. Added it
   to the Simple Editor's inline library form too:

   * fp.step.template.triggers_workflow_state_id (new Many2one) —
     per-template default, snapshot-copied to recipe nodes when
     dropped into a recipe (added to _SNAPSHOT_FIELDS).
   * /fp/simple_recipe/workflow_states/list — new endpoint to feed
     the dropdown. Soft-fails when fusion_plating_jobs isn't
     installed (returns []).
   * Library editor JS — _fpEnsureWorkflowStatesLoaded helper
     caches the catalog on first open (create + edit paths both
     warm it). Save vals carry the trigger id.
   * Library editor XML — dropdown rendered after the flag
     checkboxes. Hidden when the catalog is empty so the form
     doesn't show a useless "— None —" pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-04 00:04:59 -04:00
parent 28bf6b5071
commit e54ffe7309
7 changed files with 157 additions and 19 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.12.2', 'version': '19.0.18.12.3',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -24,6 +24,7 @@ _SNAPSHOT_FIELDS = [
'voltage_target', 'viscosity_target', 'voltage_target', 'viscosity_target',
'requires_signoff', 'requires_predecessor_done', 'requires_signoff', 'requires_predecessor_done',
'parallel_start', 'parallel_start',
'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger
'requires_rack_assignment', 'requires_transition_form', 'requires_rack_assignment', 'requires_transition_form',
'default_kind', 'default_kind',
] ]
@@ -195,6 +196,15 @@ class SimpleRecipeController(http.Controller):
'requires_signoff': tpl.requires_signoff, 'requires_signoff': tpl.requires_signoff,
'requires_predecessor_done': tpl.requires_predecessor_done, 'requires_predecessor_done': tpl.requires_predecessor_done,
'parallel_start': tpl.parallel_start, 'parallel_start': tpl.parallel_start,
# Sub 14 — workflow trigger (id + name for display)
'triggers_workflow_state_id': (
tpl.triggers_workflow_state_id.id
if tpl.triggers_workflow_state_id else False
),
'triggers_workflow_state_name': (
tpl.triggers_workflow_state_id.name
if tpl.triggers_workflow_state_id else ''
),
'requires_rack_assignment': tpl.requires_rack_assignment, 'requires_rack_assignment': tpl.requires_rack_assignment,
'requires_transition_form': tpl.requires_transition_form, 'requires_transition_form': tpl.requires_transition_form,
'tank_ids': [ 'tank_ids': [
@@ -230,6 +240,7 @@ class SimpleRecipeController(http.Controller):
'name', 'code', 'icon', 'default_kind', 'description', 'name', 'code', 'icon', 'default_kind', 'description',
'requires_signoff', 'requires_predecessor_done', 'requires_signoff', 'requires_predecessor_done',
'parallel_start', 'parallel_start',
'triggers_workflow_state_id', # Sub 14
'requires_rack_assignment', 'requires_transition_form', 'requires_rack_assignment', 'requires_transition_form',
'tank_ids', 'tank_ids',
} }
@@ -320,6 +331,34 @@ class SimpleRecipeController(http.Controller):
], ],
} }
@http.route('/fp/simple_recipe/workflow_states/list',
type='jsonrpc', auth='user')
def workflow_states_list(self):
"""Sub 14 — workflow-state picker for the inline library form.
Returns active states ordered by sequence so the dropdown
renders left-to-right matching the status bar.
Soft-fail when fp.job.workflow.state isn't installed (rare,
only when fusion_plating_jobs is missing) — empty list lets the
dropdown render disabled instead of throwing.
"""
WS = request.env.get('fp.job.workflow.state')
if WS is None:
return {'workflow_states': []}
return {
'workflow_states': [
{
'id': ws.id,
'name': ws.name or '',
'code': ws.code or '',
'sequence': ws.sequence,
}
for ws in WS.search(
[('active', '=', True)], order='sequence, id',
)
],
}
# ------------------------------------------------------------------ step # ------------------------------------------------------------------ step
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user') @http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
def step_insert(self, recipe_id, template_id=False, position=99, vals=None): def step_insert(self, recipe_id, template_id=False, position=99, vals=None):

View File

@@ -79,6 +79,22 @@ class FpStepTemplate(models.Model):
'earlier-sequence steps are still in progress (e.g. ' 'earlier-sequence steps are still in progress (e.g. '
'paperwork that runs alongside production).', 'paperwork that runs alongside production).',
) )
# Sub 14 — workflow milestone trigger (optional)
# The fp.job.workflow.state model lives in fusion_plating_jobs, so
# this Many2one resolves at runtime only when that module is loaded.
# When the library template is dropped into a recipe, the value is
# snapshot-copied to the new process_node via _SNAPSHOT_FIELDS in
# simple_recipe_controller.py.
triggers_workflow_state_id = fields.Many2one(
'fp.job.workflow.state',
string='Triggers Workflow State',
ondelete='set null',
help='Sub 14. When a recipe step generated from this template '
'finishes (or is skipped/cancelled), the parent job '
'advances to this workflow state. Leave blank to fall '
'back to default-kind matching defined on the workflow '
'state catalog.',
)
requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment', requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment',
help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).') help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).')
requires_transition_form = fields.Boolean(string='Requires Transition Form', requires_transition_form = fields.Boolean(string='Requires Transition Form',

View File

@@ -50,6 +50,10 @@ export class FpSimpleRecipeEditor extends Component {
libraryEditor: null, libraryEditor: null,
libraryEditorBusy: false, libraryEditorBusy: false,
tankSearchResults: [], tankSearchResults: [],
// Sub 14 — workflow-state catalog cache for the inline
// library form's "Triggers Workflow State" dropdown. Lazy-
// loaded the first time the user opens the library editor.
workflowStates: [],
}); });
this._recipeId = null; this._recipeId = null;
@@ -231,7 +235,8 @@ export class FpSimpleRecipeEditor extends Component {
* mirrors the shape returned by `/fp/simple_recipe/library/load` so * mirrors the shape returned by `/fp/simple_recipe/library/load` so
* the same template renders both create + edit. * the same template renders both create + edit.
*/ */
onOpenLibraryCreate() { async onOpenLibraryCreate() {
await this._fpEnsureWorkflowStatesLoaded();
this.state.libraryEditor = { this.state.libraryEditor = {
id: null, // null = create id: null, // null = create
name: "", name: "",
@@ -242,6 +247,8 @@ export class FpSimpleRecipeEditor extends Component {
requires_signoff: false, requires_signoff: false,
requires_predecessor_done: false, requires_predecessor_done: false,
parallel_start: false, // Sub 13 — per-step opt-out parallel_start: false, // Sub 13 — per-step opt-out
triggers_workflow_state_id: false, // Sub 14 — workflow trigger
triggers_workflow_state_name: "",
requires_rack_assignment: false, requires_rack_assignment: false,
requires_transition_form: false, requires_transition_form: false,
tank_ids: [], tank_ids: [],
@@ -250,8 +257,28 @@ export class FpSimpleRecipeEditor extends Component {
this.state.tankSearchResults = []; this.state.tankSearchResults = [];
} }
/**
* Sub 14 — fetch the workflow-state catalog once per editor session,
* cache on this.state.workflowStates. Used by both create + edit
* flows to populate the "Triggers Workflow State" dropdown.
*/
async _fpEnsureWorkflowStatesLoaded() {
if (this.state.workflowStates && this.state.workflowStates.length) {
return;
}
try {
const data = await rpc(
"/fp/simple_recipe/workflow_states/list", {}
);
this.state.workflowStates = data.workflow_states || [];
} catch (err) {
this.state.workflowStates = [];
}
}
async onOpenLibraryEdit(templateId) { async onOpenLibraryEdit(templateId) {
this.state.libraryEditorBusy = true; this.state.libraryEditorBusy = true;
await this._fpEnsureWorkflowStatesLoaded();
const data = await rpc("/fp/simple_recipe/library/load", { const data = await rpc("/fp/simple_recipe/library/load", {
template_id: templateId, template_id: templateId,
}); });
@@ -291,6 +318,8 @@ export class FpSimpleRecipeEditor extends Component {
requires_signoff: !!ed.requires_signoff, requires_signoff: !!ed.requires_signoff,
requires_predecessor_done: !!ed.requires_predecessor_done, requires_predecessor_done: !!ed.requires_predecessor_done,
parallel_start: !!ed.parallel_start, parallel_start: !!ed.parallel_start,
// Sub 14 — workflow trigger (Many2one int or false)
triggers_workflow_state_id: ed.triggers_workflow_state_id || false,
requires_rack_assignment: !!ed.requires_rack_assignment, requires_rack_assignment: !!ed.requires_rack_assignment,
requires_transition_form: !!ed.requires_transition_form, requires_transition_form: !!ed.requires_transition_form,
tank_ids: (ed.tank_ids || []).map((t) => t.id), tank_ids: (ed.tank_ids || []).map((t) => t.id),

View File

@@ -435,6 +435,33 @@
</label> </label>
</div> </div>
<!-- Sub 14 — workflow milestone trigger dropdown.
Hidden when no states exist (e.g. catalog
not seeded yet). -->
<div class="o_fp_le_field"
t-if="state.workflowStates and state.workflowStates.length">
<label class="form-label">Triggers Workflow State</label>
<select class="form-select"
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
<option value=""
t-att-selected="!state.libraryEditor.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.libraryEditor.triggers_workflow_state_id === ws.id"
t-esc="ws.name"/>
</t>
</select>
<small class="text-muted d-block mt-1">
When a recipe step generated from this template
finishes (or is skipped/cancelled), the parent
job advances to the chosen state on its status
bar. Leave blank to fall back to default-kind
matching configured on the workflow state catalog.
</small>
</div>
<!-- ============== PROMPTS ============== --> <!-- ============== PROMPTS ============== -->
<div class="o_fp_le_prompts"> <div class="o_fp_le_prompts">
<div class="o_fp_le_prompts_header"> <div class="o_fp_le_prompts_header">

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.18.2', 'version': '19.0.8.18.3',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -43,6 +43,7 @@
<label for="name"/> <label for="name"/>
<h1><field name="name" placeholder="Received"/></h1> <h1><field name="name" placeholder="Received"/></h1>
</div> </div>
<group> <group>
<group string="Identity"> <group string="Identity">
<field name="code" placeholder="received"/> <field name="code" placeholder="received"/>
@@ -56,27 +57,53 @@
<field name="block_when_quality_hold"/> <field name="block_when_quality_hold"/>
</group> </group>
</group> </group>
<group string="Trigger conditions">
<separator string="Trigger Conditions"/>
<group>
<field name="trigger_default_kinds" <field name="trigger_default_kinds"
placeholder="receiving, inspect"/> placeholder="receiving, inspect"/>
<field name="trigger_first_step_started"/> <field name="trigger_first_step_started"/>
<field name="trigger_all_steps_done"/> <field name="trigger_all_steps_done"/>
<p class="text-muted oe_grey mt-2"> </group>
<strong>How triggers combine:</strong> a state is "passed"
when EITHER the special trigger is true, OR every <!-- Help block — full sheet width, alert-info card so
recipe step matching the listed default_kinds (or the explanation is readable instead of squeezed
tagged via the per-node override on the recipe) is into a 2-column form layout. -->
in done/skipped/cancelled state. <div class="alert alert-info mt-3" role="alert">
<br/> <h6 class="alert-heading mb-2">
<em>block_when_quality_hold</em>: holds back the <i class="fa fa-info-circle me-2"/>
advance even if the trigger conditions are met, How triggers combine
until all open quality holds on the job are closed. </h6>
<p class="mb-2">
A state is <strong>"passed"</strong> when
<strong>either</strong>:
</p> </p>
</group> <ul class="mb-2">
<group string="Notes"> <li>
<field name="description" nolabel="1" The special trigger is true
placeholder="What this milestone represents and when it should fire..."/> (<code>trigger_first_step_started</code> or
</group> <code>trigger_all_steps_done</code>),
<strong>OR</strong>
</li>
<li>
Every recipe step matching the listed
<code>trigger_default_kinds</code> (or tagged
via the per-node override on the recipe) is
in <code>done</code> / <code>skipped</code> /
<code>cancelled</code> state.
</li>
</ul>
<p class="mb-0">
<strong>Blocked by Quality Hold:</strong> holds
back the advance even if the trigger conditions
are met, until all open quality holds on the job
are closed.
</p>
</div>
<separator string="Notes"/>
<field name="description" nolabel="1"
placeholder="What this milestone represents and when it should fire..."/>
</sheet> </sheet>
</form> </form>
</field> </field>