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>
389 lines
16 KiB
Python
389 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
# Part of the Fusion Plating product family.
|
|
|
|
import logging
|
|
|
|
from odoo import http
|
|
from odoo.http import request
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FpRecipeController(http.Controller):
|
|
"""JSON-RPC endpoints for the process recipe tree editor."""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Read — full tree
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/tree', type='jsonrpc', auth='user')
|
|
def get_tree(self, recipe_id):
|
|
"""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': {
|
|
'id': recipe.id,
|
|
'name': recipe.name,
|
|
'code': recipe.code or '',
|
|
'version': recipe.version,
|
|
'process_type': recipe.process_type_id.name if recipe.process_type_id else '',
|
|
},
|
|
'tree': recipe.get_tree_data(),
|
|
'workflow_states': workflow_states,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Create node
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/create', type='jsonrpc', auth='user')
|
|
def create_node(self, parent_id, name, node_type='operation', vals=None):
|
|
"""Create a new child node under parent_id."""
|
|
Node = request.env['fusion.plating.process.node']
|
|
parent = Node.browse(int(parent_id))
|
|
if not parent.exists():
|
|
return {'ok': False, 'error': 'Parent node not found.'}
|
|
|
|
# Determine next sequence
|
|
max_seq = max((c.sequence for c in parent.child_ids), default=0)
|
|
data = {
|
|
'name': name,
|
|
'node_type': node_type,
|
|
'parent_id': parent.id,
|
|
'sequence': max_seq + 10,
|
|
}
|
|
if vals:
|
|
data.update(vals)
|
|
|
|
try:
|
|
new_node = Node.create(data)
|
|
_logger.info('Recipe: created node %s (%s) under %s by uid %s',
|
|
new_node.id, name, parent.id, request.env.uid)
|
|
return {'ok': True, 'node_id': new_node.id}
|
|
except Exception as exc:
|
|
_logger.exception('Recipe create_node failed')
|
|
return {'ok': False, 'error': str(exc)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Update node
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/write', type='jsonrpc', auth='user')
|
|
def write_node(self, node_id, vals):
|
|
"""Update fields on an existing node."""
|
|
Node = request.env['fusion.plating.process.node']
|
|
node = Node.browse(int(node_id))
|
|
if not node.exists():
|
|
return {'ok': False, 'error': 'Node not found.'}
|
|
|
|
# Filter to allowed fields only
|
|
allowed = {
|
|
'name', 'code', 'node_type', 'icon', 'color',
|
|
'process_type_id', 'work_center_id',
|
|
'description', 'notes',
|
|
'estimated_duration',
|
|
'auto_complete', 'customer_visible', 'is_manual',
|
|
'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:
|
|
return {'ok': False, 'error': 'No valid fields to update.'}
|
|
|
|
try:
|
|
node.write(safe_vals)
|
|
return {'ok': True}
|
|
except Exception as exc:
|
|
_logger.exception('Recipe write_node failed')
|
|
return {'ok': False, 'error': str(exc)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Delete node
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/unlink', type='jsonrpc', auth='user')
|
|
def unlink_node(self, node_id):
|
|
"""Delete a node and all its children (cascade)."""
|
|
Node = request.env['fusion.plating.process.node']
|
|
node = Node.browse(int(node_id))
|
|
if not node.exists():
|
|
return {'ok': False, 'error': 'Node not found.'}
|
|
if node.node_type == 'recipe':
|
|
return {'ok': False, 'error': 'Cannot delete a recipe root from the tree editor. Use the list view.'}
|
|
|
|
try:
|
|
name = node.name
|
|
node.unlink()
|
|
_logger.info('Recipe: deleted node %s (%s) by uid %s',
|
|
node_id, name, request.env.uid)
|
|
return {'ok': True}
|
|
except Exception as exc:
|
|
_logger.exception('Recipe unlink_node failed')
|
|
return {'ok': False, 'error': str(exc)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Reorder siblings
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/reorder', type='jsonrpc', auth='user')
|
|
def reorder_nodes(self, node_ids):
|
|
"""Bulk-update sequence for an ordered list of sibling node IDs."""
|
|
Node = request.env['fusion.plating.process.node']
|
|
try:
|
|
for idx, nid in enumerate(node_ids):
|
|
Node.browse(int(nid)).write({'sequence': (idx + 1) * 10})
|
|
return {'ok': True}
|
|
except Exception as exc:
|
|
_logger.exception('Recipe reorder failed')
|
|
return {'ok': False, 'error': str(exc)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# List every recipe-root for the "Import from recipe" picker
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/list', type='jsonrpc', auth='user')
|
|
def list_recipes(self, exclude_id=None):
|
|
"""Return all recipe-roots available for the import picker.
|
|
|
|
exclude_id: optional — skip this recipe (usually the currently-
|
|
open one, so the user can't import from themselves).
|
|
"""
|
|
Node = request.env['fusion.plating.process.node']
|
|
domain = [('node_type', '=', 'recipe')]
|
|
if exclude_id:
|
|
domain.append(('id', '!=', int(exclude_id)))
|
|
recipes = Node.search(domain, order='name')
|
|
return {
|
|
'ok': True,
|
|
'recipes': [
|
|
{'id': r.id, 'name': r.name or f'Recipe #{r.id}'}
|
|
for r in recipes
|
|
],
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Move sibling up / down — explicit button-driven reorder
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/move_sibling', type='jsonrpc', auth='user')
|
|
def move_sibling(self, node_id, direction):
|
|
"""Swap this node's sequence with its immediate sibling.
|
|
|
|
direction: 'up' or 'down'. Safer than drag-and-drop for the
|
|
common "nudge one slot" case that the DnD flow can't do
|
|
reliably on long lists.
|
|
"""
|
|
Node = request.env['fusion.plating.process.node']
|
|
node = Node.browse(int(node_id))
|
|
if not node.exists():
|
|
return {'ok': False, 'error': 'Node not found.'}
|
|
if not node.parent_id:
|
|
return {'ok': False, 'error': 'Cannot move a recipe root.'}
|
|
siblings = node.parent_id.child_ids.sorted('sequence')
|
|
idx = list(siblings.ids).index(node.id)
|
|
if direction == 'up':
|
|
if idx == 0:
|
|
return {'ok': True, 'no_move': True}
|
|
other = siblings[idx - 1]
|
|
elif direction == 'down':
|
|
if idx >= len(siblings) - 1:
|
|
return {'ok': True, 'no_move': True}
|
|
other = siblings[idx + 1]
|
|
else:
|
|
return {'ok': False, 'error': 'Invalid direction.'}
|
|
# Swap the two sequence values
|
|
a_seq, b_seq = node.sequence, other.sequence
|
|
if a_seq == b_seq:
|
|
# Sequences collided — renumber everyone cleanly, then swap
|
|
for i, s in enumerate(siblings, 1):
|
|
s.sequence = i * 10
|
|
a_seq, b_seq = node.sequence, other.sequence
|
|
node.sequence, other.sequence = b_seq, a_seq
|
|
return {'ok': True}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Import children from another recipe into a target parent
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/import_children', type='jsonrpc', auth='user')
|
|
def import_children(self, source_recipe_id, target_parent_id,
|
|
dedupe_by_name=True, insert_before_id=None):
|
|
"""Copy every top-level child of `source_recipe_id` under
|
|
`target_parent_id`, preserving the sub-tree structure.
|
|
|
|
Args:
|
|
source_recipe_id: recipe to copy children from.
|
|
target_parent_id: parent to drop the copied sub-trees under.
|
|
dedupe_by_name: when True, skip any immediate child whose name
|
|
already exists under target_parent_id. Useful for importing
|
|
a preset without clobbering custom tweaks already made.
|
|
insert_before_id: controls WHERE the imported nodes land in
|
|
the target's top-level ordering.
|
|
* None / missing → append to the end (default).
|
|
* 0 → insert at the start.
|
|
* <positive int> → insert right before that child id.
|
|
Needed because "General Processing" has Shipping as the
|
|
LAST operation — importing a plating pack belongs between
|
|
Scheduling and Shipping, not after Shipping.
|
|
|
|
Returns: {ok, imported_count, skipped_count}
|
|
"""
|
|
Node = request.env['fusion.plating.process.node']
|
|
source = Node.browse(int(source_recipe_id))
|
|
target = Node.browse(int(target_parent_id))
|
|
if not source.exists() or not target.exists():
|
|
return {'ok': False, 'error': 'Source or target not found.'}
|
|
if f'/{source.id}/' in (target.parent_path or ''):
|
|
return {'ok': False, 'error': 'Target is inside source — would loop.'}
|
|
|
|
existing_names = set()
|
|
if dedupe_by_name:
|
|
existing_names = {
|
|
(c.name or '').strip().lower()
|
|
for c in target.child_ids
|
|
}
|
|
|
|
imported = 0
|
|
skipped = 0
|
|
|
|
def _copy_subtree(src_node, new_parent, base_seq):
|
|
"""Deep-copy src_node under new_parent, recursing children.
|
|
|
|
Uses copy_data() + manual create() rather than copy() so we
|
|
have full control over what's carried:
|
|
* strip child_ids (we recurse ourselves)
|
|
* strip parent_path (Odoo recomputes from parent_id)
|
|
* force parent_id + sequence to our target values
|
|
"""
|
|
[vals] = src_node.copy_data()
|
|
vals.pop('child_ids', None)
|
|
vals.pop('parent_path', None)
|
|
vals['parent_id'] = new_parent.id
|
|
vals['sequence'] = base_seq
|
|
new_node = Node.create(vals)
|
|
for i, child in enumerate(src_node.child_ids.sorted('sequence'), 1):
|
|
_copy_subtree(child, new_node, i * 10)
|
|
return new_node
|
|
|
|
# Phase 1 — create every copied top-level child, tracking their
|
|
# ids so we can separate them from the original children when
|
|
# reordering below.
|
|
new_top_level_ids = []
|
|
for child in source.child_ids.sorted('sequence'):
|
|
key = (child.name or '').strip().lower()
|
|
if dedupe_by_name and key and key in existing_names:
|
|
skipped += 1
|
|
continue
|
|
# Placeholder sequence; Phase 2 reassigns all top-level seqs.
|
|
new_node = _copy_subtree(child, target, 0)
|
|
new_top_level_ids.append(new_node.id)
|
|
imported += 1
|
|
if key:
|
|
existing_names.add(key)
|
|
|
|
# Phase 2 — compute the final top-level ordering and reassign
|
|
# sequences so imported nodes land at the requested position
|
|
# instead of always appearing after every existing child.
|
|
target.invalidate_recordset(['child_ids'])
|
|
all_top = list(target.child_ids.sorted('sequence'))
|
|
existing_top = [c for c in all_top if c.id not in new_top_level_ids]
|
|
new_nodes = [c for c in all_top if c.id in new_top_level_ids]
|
|
|
|
# Resolve the insertion anchor in the EXISTING list.
|
|
anchor_idx = len(existing_top) # default: at the end
|
|
if insert_before_id is not None:
|
|
try:
|
|
before_id = int(insert_before_id)
|
|
except (TypeError, ValueError):
|
|
before_id = None
|
|
if before_id == 0:
|
|
anchor_idx = 0
|
|
elif before_id:
|
|
for idx, node in enumerate(existing_top):
|
|
if node.id == before_id:
|
|
anchor_idx = idx
|
|
break
|
|
|
|
final_order = (
|
|
existing_top[:anchor_idx]
|
|
+ new_nodes
|
|
+ existing_top[anchor_idx:]
|
|
)
|
|
for i, node in enumerate(final_order, 1):
|
|
if node.sequence != i * 10:
|
|
node.sequence = i * 10
|
|
|
|
return {
|
|
'ok': True,
|
|
'imported_count': imported,
|
|
'skipped_count': skipped,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Move node to new parent
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/node/move', type='jsonrpc', auth='user')
|
|
def move_node(self, node_id, new_parent_id):
|
|
"""Move a node to a new parent (drag between sub-trees)."""
|
|
Node = request.env['fusion.plating.process.node']
|
|
node = Node.browse(int(node_id))
|
|
parent = Node.browse(int(new_parent_id))
|
|
if not node.exists() or not parent.exists():
|
|
return {'ok': False, 'error': 'Node or parent not found.'}
|
|
|
|
# Prevent moving a recipe root
|
|
if node.node_type == 'recipe':
|
|
return {'ok': False, 'error': 'Cannot move a recipe root.'}
|
|
|
|
# Prevent making a node its own descendant
|
|
if f'/{node.id}/' in (parent.parent_path or ''):
|
|
return {'ok': False, 'error': 'Cannot move a node under its own descendant.'}
|
|
|
|
try:
|
|
max_seq = max((c.sequence for c in parent.child_ids), default=0)
|
|
node.write({
|
|
'parent_id': parent.id,
|
|
'sequence': max_seq + 10,
|
|
})
|
|
return {'ok': True}
|
|
except Exception as exc:
|
|
_logger.exception('Recipe move_node failed')
|
|
return {'ok': False, 'error': str(exc)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Duplicate recipe
|
|
# ------------------------------------------------------------------
|
|
@http.route('/fp/recipe/duplicate', type='jsonrpc', auth='user')
|
|
def duplicate_recipe(self, recipe_id):
|
|
"""Deep-copy an entire recipe tree."""
|
|
Node = request.env['fusion.plating.process.node']
|
|
recipe = Node.browse(int(recipe_id))
|
|
if not recipe.exists():
|
|
return {'ok': False, 'error': 'Recipe not found.'}
|
|
if recipe.node_type != 'recipe':
|
|
return {'ok': False, 'error': 'Can only duplicate recipe roots.'}
|
|
|
|
try:
|
|
new_recipe = recipe.copy()
|
|
return {'ok': True, 'recipe_id': new_recipe.id}
|
|
except Exception as exc:
|
|
_logger.exception('Recipe duplicate failed')
|
|
return {'ok': False, 'error': str(exc)}
|