Add required area_kind Selection to fp.step.kind so each kind self-declares which plant-view column its steps belong in. Replaces the hardcoded _STEP_KIND_TO_AREA dict (removed in fp_job_step.py in the follow-up commit). - New `blast` kind for the Blasting column (sequence=35) - 26 existing kind records seeded with area_kind in XML - Pre-migrate 19.0.21.2.0 seeds existing rows BEFORE NOT NULL hits the schema; also activates derack/demask/gating that were deactivated in 19.0.20.6.0 but are needed for the full taxonomy - Step Kind form + list views surface area_kind (badge + chip) - Step Kind search adds Group By Shop Floor Column - Simple Editor kind picker shows "Masking — Masking column" suffix so authors see the routing at pick time - Add Hot Water Porosity Test (A-15) + Final Inspection / Packaging templates (used by 7+3 recipe nodes that previously had no library entry) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1068 lines
46 KiB
Python
1068 lines
46 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.
|
||
"""JSONRPC endpoints for the Simple Recipe Editor.
|
||
|
||
All endpoints expect the user to be authenticated. Permissions are
|
||
enforced by the underlying ACL on fp.step.template + process.node:
|
||
operators get read; supervisors+ get write.
|
||
"""
|
||
|
||
from odoo import _, http
|
||
from odoo.http import request
|
||
|
||
|
||
# Field list copied from a library template into a new recipe step on
|
||
# drag-drop. Snapshot semantics (Q4 from the design doc — editing a
|
||
# library template later does NOT change recipes already built).
|
||
_SNAPSHOT_FIELDS = [
|
||
'name', 'code', 'description', 'icon',
|
||
'material_callout',
|
||
'time_min_target', 'time_max_target', 'time_unit',
|
||
'temp_min_target', 'temp_max_target', 'temp_unit',
|
||
'voltage_target', 'viscosity_target',
|
||
'requires_signoff', 'requires_predecessor_done',
|
||
'parallel_start',
|
||
'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger
|
||
'requires_rack_assignment', 'requires_transition_form',
|
||
'kind_id', # Sub 14b — replaces default_kind (now a related Char)
|
||
]
|
||
|
||
# Fields on fp.step.template.input that copy 1:1 into
|
||
# fusion.plating.process.node.input on snapshot.
|
||
_INPUT_SNAPSHOT_FIELDS = [
|
||
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
|
||
'required', 'hint', 'selection_options', 'sequence',
|
||
]
|
||
|
||
|
||
def _copy_snapshot_fields(source, fields):
|
||
"""Copy ``fields`` from ``source`` record into a write-ready dict.
|
||
|
||
Many2one values must be unwrapped to their integer id — passing a
|
||
recordset to ``create`` triggers psycopg2 ``can't adapt type X``
|
||
because the SQL adapter doesn't know how to serialize a recordset.
|
||
Scalar fields pass through untouched.
|
||
"""
|
||
out = {}
|
||
for f in fields:
|
||
field = source._fields[f]
|
||
val = source[f]
|
||
if field.type == 'many2one':
|
||
out[f] = val.id if val else False
|
||
else:
|
||
out[f] = val
|
||
return out
|
||
|
||
|
||
class SimpleRecipeController(http.Controller):
|
||
|
||
# ------------------------------------------------------------------ load
|
||
@http.route('/fp/simple_recipe/load', type='jsonrpc', auth='user')
|
||
def load(self, recipe_id):
|
||
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
|
||
recipe.check_access('read')
|
||
# Tree-Editor-authored recipes carry FOUR node levels:
|
||
# recipe → sub_process → operation → step
|
||
# The Tree Editor shows all of them. The Simple Editor used to
|
||
# only show direct children of the recipe — so for
|
||
# ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step
|
||
# nodes), authors saw 10 rows out of 43. Work-order generation
|
||
# walked the full tree and emitted operations as fp.job.step
|
||
# rows with step-nodes folded in as instruction text.
|
||
#
|
||
# We now walk the full tree depth-first and surface EVERY
|
||
# operation and step node, in traversal order, each tagged
|
||
# with:
|
||
# - `nested_under`: chained sub-process path ("Steel Line",
|
||
# "Steel Line › Cleaner", etc.)
|
||
# - `node_type`: 'operation' or 'step'
|
||
# - `is_substep`: True for `step` nodes (renders indented)
|
||
#
|
||
# The Simple Editor's drag/insert/reorder semantics still
|
||
# treat operations as headline rows; substeps are read-only
|
||
# by default in the UI but their fields can be edited via the
|
||
# existing step_write endpoint (which doesn't care about
|
||
# node_type).
|
||
flat_nodes = self._flatten_recipe_nodes(recipe)
|
||
return {
|
||
'recipe': self._recipe_payload(recipe),
|
||
'steps': [
|
||
dict(self._step_payload(node),
|
||
nested_under=path,
|
||
node_type=node.node_type,
|
||
is_substep=(node.node_type == 'step'))
|
||
for node, path in flat_nodes
|
||
],
|
||
}
|
||
|
||
def _flatten_recipe_operations(self, recipe):
|
||
"""Legacy helper — returns ONLY operations.
|
||
|
||
Kept for back-compat with callers and tests that asked for the
|
||
operations-only view. Most paths should now use
|
||
``_flatten_recipe_nodes`` which also surfaces step children.
|
||
"""
|
||
return [
|
||
(n, p) for n, p in self._flatten_recipe_nodes(recipe)
|
||
if n.node_type == 'operation'
|
||
]
|
||
|
||
def _flatten_recipe_nodes(self, recipe):
|
||
"""Walk the recipe DFS, return [(node, path_label)].
|
||
|
||
Surfaces both `operation` and `step` nodes. The traversal order
|
||
matches what the Tree Editor displays:
|
||
recipe → recurse → operation (emit) → its step children (emit)
|
||
recipe → recurse → sub_process → recurse → operation → steps
|
||
|
||
Step children are emitted IMMEDIATELY after their parent
|
||
operation so the editor can render them as a contiguous block.
|
||
"""
|
||
out = []
|
||
|
||
def _walk(node, path):
|
||
if node.node_type == 'operation':
|
||
out.append((node, path))
|
||
# Emit step children right after the operation so the
|
||
# editor sees: [Op, step, step, NextOp, step, ...].
|
||
# The path label for a substep names its parent
|
||
# operation, chained from the sub-process if present.
|
||
sub_path = (
|
||
f"{path} › {node.name}" if path else node.name
|
||
)
|
||
for child in node.child_ids.sorted('sequence'):
|
||
if child.node_type == 'step':
|
||
out.append((child, sub_path))
|
||
return
|
||
if node.node_type in ('recipe', 'sub_process'):
|
||
sub_path = (
|
||
path if node.node_type == 'recipe'
|
||
else (f"{path} › {node.name}" if path else node.name)
|
||
)
|
||
for child in node.child_ids.sorted('sequence'):
|
||
_walk(child, sub_path)
|
||
# `step` nodes that are direct children of a recipe (rare,
|
||
# legacy seed data) are silently dropped — _generate_steps
|
||
# has always skipped them.
|
||
|
||
_walk(recipe, '')
|
||
return out
|
||
|
||
def _recipe_payload(self, recipe):
|
||
return {
|
||
'id': recipe.id,
|
||
'name': recipe.name,
|
||
'code': recipe.code,
|
||
'is_template': recipe.is_template,
|
||
'preferred_editor': recipe.preferred_editor,
|
||
'process_type_id': (
|
||
[recipe.process_type_id.id, recipe.process_type_id.name]
|
||
if recipe.process_type_id else False
|
||
),
|
||
# 2026-05-20 — drives the visibility of admin-only affordances
|
||
# in the Simple Editor (e.g. "+ New kind…" inline create).
|
||
'user_is_manager': request.env.user.has_group(
|
||
'fusion_plating.group_fusion_plating_manager'
|
||
),
|
||
}
|
||
|
||
def _step_payload(self, step):
|
||
# Sub 12d — measurement prompts. Filter to step_input only (transition
|
||
# prompts live on the move dialog). Sort by sequence so the editor
|
||
# renders them in author order.
|
||
step_inputs = step.input_ids.filtered(
|
||
lambda i: (i.kind or 'step_input') == 'step_input'
|
||
).sorted('sequence')
|
||
total = len(step_inputs)
|
||
on = sum(1 for i in step_inputs if getattr(i, 'collect', True))
|
||
if total == 0:
|
||
badge_text = 'No measurements'
|
||
badge_class = 'bg-secondary'
|
||
elif not step.collect_measurements:
|
||
badge_text = 'Off'
|
||
badge_class = 'bg-secondary'
|
||
elif on == total:
|
||
badge_text = '%d/%d collected' % (on, total)
|
||
badge_class = 'bg-success'
|
||
else:
|
||
badge_text = '%d/%d collected' % (on, total)
|
||
badge_class = 'bg-warning'
|
||
return {
|
||
'id': step.id,
|
||
'name': step.name,
|
||
'sequence': step.sequence,
|
||
'icon': step.icon,
|
||
'default_kind': step.default_kind,
|
||
'kind_id': step.kind_id.id if step.kind_id else False,
|
||
'kind_name': step.kind_id.name if step.kind_id else '',
|
||
'requires_signoff': step.requires_signoff,
|
||
'requires_rack_assignment': step.requires_rack_assignment,
|
||
'requires_transition_form': step.requires_transition_form,
|
||
'description': step.description or '',
|
||
'notes': step.notes or '',
|
||
'tank_ids': [
|
||
{'id': t.id, 'name': t.name, 'code': t.code}
|
||
for t in step.tank_ids
|
||
],
|
||
'work_center_id': step.work_center_id.id if step.work_center_id else False,
|
||
'source_template_id': step.source_template_id.id or False,
|
||
'collect_measurements': bool(step.collect_measurements),
|
||
# Sub 13 — per-step opt-out of the sequential gate
|
||
'parallel_start': bool(step.parallel_start),
|
||
# Sub 14 — workflow milestone trigger override
|
||
'triggers_workflow_state_id': (
|
||
step.triggers_workflow_state_id.id
|
||
if 'triggers_workflow_state_id' in step._fields
|
||
and step.triggers_workflow_state_id
|
||
else False
|
||
),
|
||
'measurements_badge_text': badge_text,
|
||
'measurements_badge_class': badge_class,
|
||
# Reference images attached to the step. Operators see
|
||
# these in the Record Inputs dialog and the step quick-look
|
||
# modal — recipe authors upload via the inline edit panel.
|
||
'instruction_images': [
|
||
{
|
||
'id': att.id,
|
||
'name': att.name or '',
|
||
'mimetype': att.mimetype or '',
|
||
'url': '/web/image/%s' % att.id,
|
||
}
|
||
for att in step.instruction_attachment_ids
|
||
],
|
||
'inputs': [
|
||
{
|
||
'id': i.id,
|
||
'name': i.name or '',
|
||
'input_type': i.input_type or 'text',
|
||
'collect': bool(getattr(i, 'collect', True)),
|
||
'required': bool(i.required),
|
||
'target_min': i.target_min or 0.0,
|
||
'target_max': i.target_max or 0.0,
|
||
'target_unit': i.target_unit or '',
|
||
'sequence': i.sequence or 0,
|
||
'from_library': bool(getattr(i, 'template_input_id', False)),
|
||
'hint': i.hint or '',
|
||
}
|
||
for i in step_inputs
|
||
],
|
||
}
|
||
|
||
# --------------------------------------------------------------- library
|
||
@http.route('/fp/simple_recipe/library/list', type='jsonrpc', auth='user')
|
||
def library_list(self, query='', limit=200):
|
||
Tpl = request.env['fp.step.template']
|
||
domain = [('active', '=', True)]
|
||
if query:
|
||
domain += ['|', '|',
|
||
('name', 'ilike', query),
|
||
('code', 'ilike', query),
|
||
('description', 'ilike', query)]
|
||
records = Tpl.search(domain, limit=limit)
|
||
return {
|
||
'templates': [
|
||
{
|
||
'id': t.id,
|
||
'name': t.name,
|
||
'code': t.code,
|
||
'icon': t.icon,
|
||
'default_kind': t.default_kind,
|
||
'kind_id': t.kind_id.id if t.kind_id else False,
|
||
'kind_name': t.kind_id.name if t.kind_id else '',
|
||
'station_count': len(t.tank_ids),
|
||
}
|
||
for t in records
|
||
],
|
||
}
|
||
|
||
@http.route('/fp/simple_recipe/library/create', type='jsonrpc', auth='user')
|
||
def library_create(self, vals):
|
||
tpl = request.env['fp.step.template'].create(vals)
|
||
return {'id': tpl.id, 'name': tpl.name}
|
||
|
||
@http.route('/fp/simple_recipe/library/write', type='jsonrpc', auth='user')
|
||
def library_write(self, template_id, vals):
|
||
tpl = request.env['fp.step.template'].browse(template_id)
|
||
tpl.write(vals)
|
||
return {'ok': True}
|
||
|
||
@http.route('/fp/simple_recipe/library/delete', type='jsonrpc', auth='user')
|
||
def library_delete(self, template_id):
|
||
tpl = request.env['fp.step.template'].browse(template_id)
|
||
Node = request.env['fusion.plating.process.node']
|
||
used_count = Node.search_count([('source_template_id', '=', template_id)])
|
||
if used_count:
|
||
tpl.write({'active': False})
|
||
return {'ok': True, 'soft_deleted': True, 'used_in': used_count}
|
||
tpl.unlink()
|
||
return {'ok': True, 'soft_deleted': False}
|
||
|
||
# ---- Inline authoring (Simple Editor right-pane "+ New Step" / pencil) ----
|
||
#
|
||
# Endpoints below let a shop foreman create or edit a library template
|
||
# (with prompts) without leaving the Simple Editor. Manager-grade
|
||
# features (transition form, advanced time/temp targets, common-audit
|
||
# seeding) still live on the dedicated form view.
|
||
|
||
@http.route('/fp/simple_recipe/library/load', type='jsonrpc', auth='user')
|
||
def library_load(self, template_id):
|
||
"""Return the full payload for one library template."""
|
||
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||
if not tpl.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
tpl.check_access('read')
|
||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||
|
||
def _library_payload(self, tpl):
|
||
return {
|
||
'id': tpl.id,
|
||
'name': tpl.name or '',
|
||
'code': tpl.code or '',
|
||
'icon': tpl.icon or 'fa-cog',
|
||
'default_kind': tpl.default_kind or '',
|
||
'kind_id': tpl.kind_id.id if tpl.kind_id else False,
|
||
'kind_name': tpl.kind_id.name if tpl.kind_id else '',
|
||
'description': tpl.description or '',
|
||
'requires_signoff': tpl.requires_signoff,
|
||
'requires_predecessor_done': tpl.requires_predecessor_done,
|
||
'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_transition_form': tpl.requires_transition_form,
|
||
'tank_ids': [
|
||
{'id': t.id, 'name': t.name, 'code': t.code or ''}
|
||
for t in tpl.tank_ids
|
||
],
|
||
'inputs': [
|
||
{
|
||
'id': i.id,
|
||
'name': i.name or '',
|
||
'input_type': i.input_type or 'text',
|
||
'target_min': i.target_min or 0.0,
|
||
'target_max': i.target_max or 0.0,
|
||
'target_unit': i.target_unit or '',
|
||
'required': bool(i.required),
|
||
'sequence': i.sequence or 0,
|
||
'hint': i.hint or '',
|
||
'selection_options': i.selection_options or '',
|
||
}
|
||
for i in tpl.input_template_ids.sorted('sequence')
|
||
],
|
||
}
|
||
|
||
@http.route('/fp/simple_recipe/library/save', type='jsonrpc', auth='user')
|
||
def library_save(self, template_id, vals):
|
||
"""Upsert: create when template_id is falsy, write otherwise.
|
||
Returns the full template payload so the OWL component can
|
||
refresh in one round-trip.
|
||
"""
|
||
Tpl = request.env['fp.step.template']
|
||
# Whitelist — never trust client-provided write_uid / id / etc.
|
||
# Sub 14b: `default_kind` is now a related read-only Char. The
|
||
# client may still send it as a string code for back-compat — we
|
||
# translate it to kind_id below.
|
||
allowed = {
|
||
'name', 'code', 'icon', 'kind_id', 'description',
|
||
'requires_signoff', 'requires_predecessor_done',
|
||
'parallel_start',
|
||
'triggers_workflow_state_id', # Sub 14
|
||
'requires_rack_assignment', 'requires_transition_form',
|
||
'tank_ids',
|
||
}
|
||
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||
# Back-compat: accept default_kind (string code) and resolve to kind_id.
|
||
if 'kind_id' not in clean and (vals or {}).get('default_kind'):
|
||
clean['kind_id'] = self._resolve_kind_id_from_code(
|
||
vals['default_kind'],
|
||
)
|
||
# tank_ids comes in as a plain list of ids from the OWL form;
|
||
# translate into the Odoo (6, 0, ids) command form.
|
||
if 'tank_ids' in clean:
|
||
clean['tank_ids'] = [(6, 0, [int(x) for x in clean['tank_ids']])]
|
||
if template_id:
|
||
tpl = Tpl.browse(int(template_id))
|
||
tpl.check_access('write')
|
||
tpl.write(clean)
|
||
else:
|
||
tpl = Tpl.create(clean)
|
||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||
|
||
def _resolve_kind_id_from_code(self, code):
|
||
"""Look up fp.step.kind id by code. Empty string → False."""
|
||
if not code:
|
||
return False
|
||
rec = request.env['fp.step.kind'].search(
|
||
[('code', '=', code)], limit=1,
|
||
)
|
||
return rec.id or False
|
||
|
||
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
|
||
def library_seed_defaults(self, template_id):
|
||
"""Run action_seed_default_inputs on this template. Idempotent —
|
||
only adds prompts whose name doesn't already exist.
|
||
"""
|
||
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||
if not tpl.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
tpl.check_access('write')
|
||
if not tpl.default_kind:
|
||
return {'ok': False, 'error': 'no_kind',
|
||
'message': 'Pick a Step Kind first to seed defaults.'}
|
||
tpl.action_seed_default_inputs()
|
||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||
|
||
@http.route('/fp/simple_recipe/library/input/add', type='jsonrpc', auth='user')
|
||
def library_input_add(self, template_id, payload):
|
||
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||
tpl.check_access('write')
|
||
Input = request.env['fp.step.template.input']
|
||
existing_max = max(tpl.input_template_ids.mapped('sequence') or [0])
|
||
rec = Input.create({
|
||
'template_id': tpl.id,
|
||
'name': (payload or {}).get('name') or 'New Prompt',
|
||
'input_type': (payload or {}).get('input_type') or 'text',
|
||
'sequence': existing_max + 10,
|
||
'required': bool((payload or {}).get('required')),
|
||
})
|
||
return {'ok': True, 'input_id': rec.id,
|
||
'template': self._library_payload(tpl)}
|
||
|
||
@http.route('/fp/simple_recipe/library/input/write', type='jsonrpc', auth='user')
|
||
def library_input_write(self, input_id, payload):
|
||
Input = request.env['fp.step.template.input']
|
||
rec = Input.browse(int(input_id))
|
||
if not rec.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
rec.template_id.check_access('write')
|
||
allowed = {
|
||
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
|
||
'required', 'sequence', 'selection_options', 'hint',
|
||
}
|
||
clean = {k: v for k, v in (payload or {}).items() if k in allowed}
|
||
if clean:
|
||
rec.write(clean)
|
||
return {'ok': True, 'template': self._library_payload(rec.template_id)}
|
||
|
||
@http.route('/fp/simple_recipe/library/input/remove', type='jsonrpc', auth='user')
|
||
def library_input_remove(self, input_id):
|
||
Input = request.env['fp.step.template.input']
|
||
rec = Input.browse(int(input_id))
|
||
if not rec.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
tpl = rec.template_id
|
||
tpl.check_access('write')
|
||
rec.unlink()
|
||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||
|
||
@http.route('/fp/simple_recipe/tank/list', type='jsonrpc', auth='user')
|
||
def tank_list(self, query=''):
|
||
"""Tank picker for the inline library form. Returns active tanks
|
||
scoped to the current company.
|
||
"""
|
||
Tank = request.env['fusion.plating.tank']
|
||
domain = [('active', '=', True)]
|
||
if query:
|
||
domain += ['|', ('name', 'ilike', query), ('code', 'ilike', query)]
|
||
return {
|
||
'tanks': [
|
||
{'id': t.id, 'name': t.name, 'code': t.code or ''}
|
||
for t in Tank.search(domain, limit=50)
|
||
],
|
||
}
|
||
|
||
@http.route('/fp/simple_recipe/kinds/list',
|
||
type='jsonrpc', auth='user')
|
||
def kinds_list(self):
|
||
"""Sub 14b — Step Kind dropdown options for the inline library
|
||
form. User-extensible via /fp/simple_recipe/kinds/create.
|
||
|
||
2026-05-24 — payload now includes `area_kind` + a humanized
|
||
`area_kind_label` so the Simple Editor picker can render
|
||
"Masking — Masking column" and authors see which Shop Floor
|
||
column they're routing the step to.
|
||
"""
|
||
Kind = request.env['fp.step.kind']
|
||
area_labels = dict(Kind._fields['area_kind'].selection)
|
||
return {
|
||
'kinds': [
|
||
{
|
||
'id': k.id,
|
||
'code': k.code or '',
|
||
'name': k.name or '',
|
||
'icon': k.icon or '',
|
||
'sequence': k.sequence,
|
||
'area_kind': k.area_kind or '',
|
||
'area_kind_label': area_labels.get(k.area_kind, ''),
|
||
}
|
||
for k in Kind.search(
|
||
[('active', '=', True)], order='sequence, name',
|
||
)
|
||
],
|
||
}
|
||
|
||
@http.route('/fp/simple_recipe/kinds/create',
|
||
type='jsonrpc', auth='user')
|
||
def kinds_create(self, name, code=''):
|
||
"""Inline create for "+ New kind…" in the library form.
|
||
Auto-derives a code from the name if blank.
|
||
|
||
2026-05-20 lockdown: manager group only. Kinds drive gates,
|
||
milestones, and operator routing — a user-created kind with no
|
||
corresponding behaviour is a silent foot-gun. The dropdown is
|
||
the curated catalog; adding a new kind requires manager
|
||
approval and follow-up code work to wire the new code into the
|
||
downstream behaviour map.
|
||
"""
|
||
Kind = request.env['fp.step.kind']
|
||
if not name or not name.strip():
|
||
return {'ok': False, 'error': 'name_required'}
|
||
if not request.env.user.has_group(
|
||
'fusion_plating.group_fusion_plating_manager'
|
||
):
|
||
return {
|
||
'ok': False, 'error': 'forbidden',
|
||
'message': (
|
||
'Only Plating Managers can add new Step Kinds. The '
|
||
'catalog is curated because each kind drives gates, '
|
||
'milestones, and operator routing. Pick "Other" if '
|
||
'no existing kind fits — or ask a manager to add the '
|
||
'new kind once the downstream behaviour is wired up.'
|
||
),
|
||
}
|
||
if not code:
|
||
code = name.strip().lower().replace(' ', '_').replace('/', '_')
|
||
existing = Kind.search([('code', '=', code)], limit=1)
|
||
if existing:
|
||
return {
|
||
'ok': True, 'id': existing.id,
|
||
'name': existing.name, 'code': existing.code,
|
||
'duplicate': True,
|
||
}
|
||
rec = Kind.create({
|
||
'name': name.strip(),
|
||
'code': code,
|
||
})
|
||
return {
|
||
'ok': True, 'id': rec.id,
|
||
'name': rec.name, 'code': rec.code,
|
||
'duplicate': False,
|
||
}
|
||
|
||
@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
|
||
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
|
||
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
|
||
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
|
||
target_seq = self._sequence_for_position(recipe, position)
|
||
|
||
new_vals = {
|
||
'parent_id': recipe.id,
|
||
# Must be 'operation' — fp.job._generate_steps() only creates
|
||
# fp.job.step rows for operation nodes. Flat 'step' children
|
||
# of a recipe were silently skipped pre-19.0.18.8.0.
|
||
'node_type': 'operation',
|
||
'sequence': target_seq,
|
||
}
|
||
tpl = False
|
||
if template_id:
|
||
tpl = request.env['fp.step.template'].browse(template_id)
|
||
new_vals.update(_copy_snapshot_fields(tpl, _SNAPSHOT_FIELDS))
|
||
if tpl.process_type_id:
|
||
new_vals['process_type_id'] = tpl.process_type_id.id
|
||
if tpl.tank_ids:
|
||
new_vals['tank_ids'] = [(6, 0, tpl.tank_ids.ids)]
|
||
new_vals['source_template_id'] = tpl.id
|
||
|
||
if vals:
|
||
new_vals.update(vals)
|
||
|
||
new_node = request.env['fusion.plating.process.node'].create(new_vals)
|
||
|
||
if tpl:
|
||
self._copy_inputs_from_template(tpl, new_node)
|
||
|
||
return {'id': new_node.id, 'sequence': new_node.sequence}
|
||
|
||
def _sequence_for_position(self, recipe, position):
|
||
siblings = recipe.child_ids.sorted('sequence')
|
||
if not siblings:
|
||
return 10
|
||
if position >= len(siblings):
|
||
return siblings[-1].sequence + 10
|
||
if position <= 0:
|
||
return max(1, siblings[0].sequence - 10)
|
||
before = siblings[position - 1].sequence
|
||
after = siblings[position].sequence
|
||
if after - before > 1:
|
||
return (before + after) // 2
|
||
# Sequences are tightly packed (gap == 1 → midpoint == after,
|
||
# which collides). Renumber siblings to 10/20/30… first, then
|
||
# the new step lands cleanly between renumbered neighbours.
|
||
for idx, sib in enumerate(siblings):
|
||
new_seq = (idx + 1) * 10
|
||
if sib.sequence != new_seq:
|
||
sib.sequence = new_seq
|
||
return position * 10 + 5
|
||
|
||
def _copy_inputs_from_template(self, tpl, new_node):
|
||
NodeInput = request.env['fusion.plating.process.node.input']
|
||
for ti in tpl.input_template_ids:
|
||
payload = {f: ti[f] for f in _INPUT_SNAPSHOT_FIELDS}
|
||
payload['node_id'] = new_node.id
|
||
payload['kind'] = 'step_input'
|
||
NodeInput.create(payload)
|
||
for tt in tpl.transition_input_ids:
|
||
NodeInput.create({
|
||
'node_id': new_node.id,
|
||
'name': tt.name,
|
||
'input_type': tt.input_type,
|
||
'required': tt.required,
|
||
'hint': tt.hint,
|
||
'selection_options': tt.selection_options,
|
||
'sequence': tt.sequence,
|
||
'compliance_tag': tt.compliance_tag,
|
||
'kind': 'transition_input',
|
||
})
|
||
|
||
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
|
||
def step_write(self, node_id, vals):
|
||
"""Update fields on an existing recipe step (operation node).
|
||
|
||
Whitelisted to the fields the inline edit panel actually surfaces
|
||
— never trust client-provided node_type / parent_id / etc.
|
||
"""
|
||
node = request.env['fusion.plating.process.node'].browse(int(node_id))
|
||
if not node.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
node.check_access('write')
|
||
allowed = {
|
||
'name', 'description', 'icon',
|
||
'kind_id', # Sub 14b — replaces default_kind
|
||
'requires_signoff', 'requires_predecessor_done',
|
||
'parallel_start', # Sub 13
|
||
'triggers_workflow_state_id', # Sub 14
|
||
'requires_rack_assignment',
|
||
'requires_transition_form',
|
||
'estimated_duration',
|
||
'collect_measurements',
|
||
}
|
||
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||
# Back-compat: accept default_kind (string code) and resolve.
|
||
if 'kind_id' not in clean and (vals or {}).get('default_kind'):
|
||
clean['kind_id'] = self._resolve_kind_id_from_code(
|
||
vals['default_kind'],
|
||
)
|
||
if clean:
|
||
node.write(clean)
|
||
return {'ok': True}
|
||
|
||
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')
|
||
def step_remove(self, node_id):
|
||
node = request.env['fusion.plating.process.node'].browse(node_id)
|
||
node.unlink()
|
||
return {'ok': True}
|
||
|
||
@http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user')
|
||
def step_reorder(self, node_ids):
|
||
"""Renumber sequence within each parent group.
|
||
|
||
Naive version (pre-19.0.20.5.0): renumber the entire flat list
|
||
1..N regardless of parent. Broke when the flat list mixed
|
||
operations and substeps — siblings got out-of-order numbers
|
||
because the list interleaved them.
|
||
|
||
New version: group node ids by their parent_id, then renumber
|
||
within each parent. Substeps stay sequenced under their
|
||
operation; operations stay sequenced under the recipe / sub-
|
||
process. Drop-across-parent shows up as a same-position no-op
|
||
— the UI's Promote/Demote buttons are the way to change
|
||
parents.
|
||
"""
|
||
Node = request.env['fusion.plating.process.node']
|
||
nodes = Node.browse([int(n) for n in node_ids])
|
||
# Group by parent_id (preserve client-provided order within each).
|
||
from collections import OrderedDict
|
||
by_parent = OrderedDict()
|
||
for n in nodes:
|
||
by_parent.setdefault(n.parent_id.id, []).append(n)
|
||
for parent_id, siblings in by_parent.items():
|
||
for i, n in enumerate(siblings, start=1):
|
||
target = i * 10
|
||
if n.sequence != target:
|
||
n.sequence = target
|
||
return {'ok': True}
|
||
|
||
@http.route('/fp/simple_recipe/step/promote', type='jsonrpc', auth='user')
|
||
def step_promote(self, node_id):
|
||
"""Promote a substep (`step` node) to an operation under the
|
||
recipe root.
|
||
|
||
Use case: author added a sub-step under an operation in the
|
||
Tree Editor, but actually wants it as a standalone operation
|
||
that the operator clocks separately. This call:
|
||
1. Flips node_type 'step' → 'operation'
|
||
2. Re-parents to the recipe root (or sub-process root if
|
||
the parent operation lives inside a sub_process)
|
||
3. Places the new operation immediately after its old
|
||
parent (so it shows up in a sensible position in the
|
||
editor list)
|
||
"""
|
||
Node = request.env['fusion.plating.process.node']
|
||
node = Node.browse(int(node_id))
|
||
if not node.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
node.check_access('write')
|
||
if node.node_type != 'step':
|
||
return {'ok': False, 'error': 'not_a_substep',
|
||
'message': 'Only substeps can be promoted.'}
|
||
parent_op = node.parent_id
|
||
if not parent_op or parent_op.node_type != 'operation':
|
||
return {'ok': False, 'error': 'no_parent_op',
|
||
'message': 'Substep has no operation parent to promote out of.'}
|
||
new_parent = parent_op.parent_id
|
||
if not new_parent or new_parent.node_type not in ('recipe', 'sub_process'):
|
||
return {'ok': False, 'error': 'no_grandparent',
|
||
'message': 'Cannot find a recipe / sub-process to promote into.'}
|
||
# Place the new operation right after parent_op.
|
||
new_seq = parent_op.sequence + 1
|
||
# Bump later siblings to make room (so we don't collide).
|
||
for sibling in new_parent.child_ids.filtered(
|
||
lambda s: s.sequence > parent_op.sequence and s.id != node.id
|
||
):
|
||
sibling.sequence = sibling.sequence + 10
|
||
node.write({
|
||
'node_type': 'operation',
|
||
'parent_id': new_parent.id,
|
||
'sequence': new_seq,
|
||
})
|
||
return {'ok': True, 'new_parent_id': new_parent.id,
|
||
'new_sequence': new_seq}
|
||
|
||
@http.route('/fp/simple_recipe/step/demote', type='jsonrpc', auth='user')
|
||
def step_demote(self, node_id, target_op_id=False):
|
||
"""Demote an operation to a substep under another operation.
|
||
|
||
If ``target_op_id`` is provided, the node becomes a substep of
|
||
that operation. Otherwise it falls under the operation
|
||
immediately preceding it in the editor list (most common case
|
||
— author drops a header into the preceding section).
|
||
"""
|
||
Node = request.env['fusion.plating.process.node']
|
||
node = Node.browse(int(node_id))
|
||
if not node.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
node.check_access('write')
|
||
if node.node_type != 'operation':
|
||
return {'ok': False, 'error': 'not_an_operation',
|
||
'message': 'Only operations can be demoted to substeps.'}
|
||
# Substeps of operations don't recurse further — bail if this
|
||
# operation has its own step children (would lose them on demote).
|
||
if node.child_ids:
|
||
return {'ok': False, 'error': 'has_children',
|
||
'message': (
|
||
'Operation "%s" has %d child step(s). Remove '
|
||
'or promote them first before demoting this '
|
||
'operation.'
|
||
) % (node.name, len(node.child_ids))}
|
||
# Resolve target operation.
|
||
if target_op_id:
|
||
target = Node.browse(int(target_op_id))
|
||
if not target.exists() or target.node_type != 'operation':
|
||
return {'ok': False, 'error': 'invalid_target',
|
||
'message': 'Target must be an operation.'}
|
||
else:
|
||
# Find the preceding operation in the same parent.
|
||
parent = node.parent_id
|
||
if not parent:
|
||
return {'ok': False, 'error': 'no_parent'}
|
||
siblings = parent.child_ids.sorted('sequence')
|
||
before = [s for s in siblings if s.sequence < node.sequence
|
||
and s.node_type == 'operation']
|
||
if not before:
|
||
return {'ok': False, 'error': 'no_preceding_op',
|
||
'message': (
|
||
'There is no preceding operation to demote '
|
||
'into. Add one above this step first, or '
|
||
'pick an operation manually.'
|
||
)}
|
||
target = before[-1]
|
||
# Place the substep at the end of the target operation's children.
|
||
last_seq = max(target.child_ids.mapped('sequence') or [0])
|
||
node.write({
|
||
'node_type': 'step',
|
||
'parent_id': target.id,
|
||
'sequence': last_seq + 10,
|
||
})
|
||
return {'ok': True, 'new_parent_id': target.id}
|
||
|
||
# -------------------------------------------------------------- template
|
||
@http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user')
|
||
def template_list(self):
|
||
Node = request.env['fusion.plating.process.node']
|
||
recipes = Node.search([
|
||
('node_type', '=', 'recipe'),
|
||
('is_template', '=', True),
|
||
('active', '=', True),
|
||
], order='name')
|
||
return {
|
||
'templates': [
|
||
{'id': r.id, 'name': r.name, 'code': r.code,
|
||
'step_count': len(r.child_ids)}
|
||
for r in recipes
|
||
],
|
||
}
|
||
|
||
@http.route('/fp/simple_recipe/template/import', type='jsonrpc', auth='user')
|
||
def template_import(self, source_recipe_id, target_recipe_id):
|
||
Node = request.env['fusion.plating.process.node']
|
||
source = Node.browse(source_recipe_id)
|
||
target = Node.browse(target_recipe_id)
|
||
imported = 0
|
||
for child in source.child_ids.sorted('sequence'):
|
||
self._snapshot_step_into(child, target)
|
||
imported += 1
|
||
return {'ok': True, 'imported_count': imported}
|
||
|
||
def _snapshot_step_into(self, src_node, target_recipe):
|
||
Node = request.env['fusion.plating.process.node']
|
||
new_vals = {
|
||
'parent_id': target_recipe.id,
|
||
# See _SNAPSHOT_FIELDS comment — operation, not step.
|
||
'node_type': 'operation',
|
||
'sequence': src_node.sequence,
|
||
'source_template_id': src_node.source_template_id.id or False,
|
||
}
|
||
new_vals.update(_copy_snapshot_fields(src_node, _SNAPSHOT_FIELDS))
|
||
if src_node.process_type_id:
|
||
new_vals['process_type_id'] = src_node.process_type_id.id
|
||
if src_node.tank_ids:
|
||
new_vals['tank_ids'] = [(6, 0, src_node.tank_ids.ids)]
|
||
new_node = Node.create(new_vals)
|
||
|
||
NodeInput = request.env['fusion.plating.process.node.input']
|
||
for src_in in src_node.input_ids:
|
||
NodeInput.create({
|
||
'node_id': new_node.id,
|
||
'name': src_in.name,
|
||
'input_type': src_in.input_type,
|
||
'required': src_in.required,
|
||
'hint': src_in.hint,
|
||
'selection_options': src_in.selection_options,
|
||
'sequence': src_in.sequence,
|
||
'kind': src_in.kind or 'step_input',
|
||
'target_min': src_in.target_min,
|
||
'target_max': src_in.target_max,
|
||
'target_unit': src_in.target_unit,
|
||
'compliance_tag': src_in.compliance_tag,
|
||
})
|
||
|
||
# ============================================================
|
||
# Sub 12d — per-recipe configurability endpoints
|
||
# ============================================================
|
||
|
||
@http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user')
|
||
def step_toggle_collect(self, node_id, collect):
|
||
"""Master switch — toggle collect_measurements on a recipe step."""
|
||
node = request.env['fusion.plating.process.node'].browse(int(node_id))
|
||
node.check_access('write')
|
||
node.collect_measurements = bool(collect)
|
||
return {'ok': True, 'collect_measurements': node.collect_measurements}
|
||
|
||
@http.route('/fp/simple_recipe/step/edit_input', type='jsonrpc', auth='user')
|
||
def step_edit_input(self, input_id, payload):
|
||
"""Edit a single recipe-step input. payload is a dict with any of:
|
||
collect, name, input_type, target_min, target_max, target_unit,
|
||
required, sequence, selection_options, hint."""
|
||
Input = request.env['fusion.plating.process.node.input']
|
||
rec = Input.browse(int(input_id))
|
||
if not rec.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
rec.node_id.check_access('write')
|
||
allowed = {
|
||
'collect', 'name', 'input_type', 'target_min', 'target_max',
|
||
'target_unit', 'required', 'sequence', 'selection_options', 'hint',
|
||
}
|
||
vals = {k: v for k, v in (payload or {}).items() if k in allowed}
|
||
if vals:
|
||
rec.write(vals)
|
||
return {'ok': True}
|
||
|
||
@http.route('/fp/simple_recipe/step/add_input', type='jsonrpc', auth='user')
|
||
def step_add_input(self, node_id, payload):
|
||
"""Add a custom prompt to a recipe step (no template_input_id link)."""
|
||
node = request.env['fusion.plating.process.node'].browse(int(node_id))
|
||
node.check_access('write')
|
||
Input = request.env['fusion.plating.process.node.input']
|
||
existing_max = max(node.input_ids.mapped('sequence') or [0])
|
||
rec = Input.create({
|
||
'node_id': node.id,
|
||
'name': (payload or {}).get('name') or 'Custom Prompt',
|
||
'input_type': (payload or {}).get('input_type') or 'text',
|
||
'kind': 'step_input',
|
||
'collect': True,
|
||
'sequence': existing_max + 10,
|
||
'required': bool((payload or {}).get('required')),
|
||
})
|
||
return {'ok': True, 'input_id': rec.id}
|
||
|
||
@http.route('/fp/simple_recipe/step/remove_input', type='jsonrpc', auth='user')
|
||
def step_remove_input(self, input_id):
|
||
"""Delete a custom prompt. Library-sourced rows are protected
|
||
— recipe authors should toggle collect=False instead of deleting."""
|
||
Input = request.env['fusion.plating.process.node.input']
|
||
rec = Input.browse(int(input_id))
|
||
if not rec.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
rec.node_id.check_access('write')
|
||
if getattr(rec, 'template_input_id', False) and rec.template_input_id:
|
||
return {
|
||
'ok': False,
|
||
'error': 'library_sourced',
|
||
'message': 'Toggle Collect off instead of deleting library prompts.',
|
||
}
|
||
rec.unlink()
|
||
return {'ok': True}
|
||
|
||
# ============================================================
|
||
# Step instruction images — recipe authors attach reference photos
|
||
# / screenshots / diagrams to a step from the Simple Editor's inline
|
||
# edit panel. Operators see them on the Record Inputs dialog and
|
||
# the step quick-look modal at runtime.
|
||
# ============================================================
|
||
|
||
@http.route('/fp/simple_recipe/step/image/add', type='jsonrpc', auth='user')
|
||
def step_image_add(self, node_id, filename, datas, mimetype=None):
|
||
"""Upload a new instruction image to a recipe step.
|
||
|
||
Args:
|
||
node_id: recipe node (fusion.plating.process.node) id
|
||
filename: display name (with extension) for the attachment
|
||
datas: base64-encoded image payload (no data: URL prefix)
|
||
mimetype: optional override; falls back to image/png
|
||
|
||
Returns the new attachment metadata so the JS can append it to
|
||
the step's gallery without a full reload.
|
||
"""
|
||
node = request.env['fusion.plating.process.node'].browse(int(node_id))
|
||
node.check_access('write')
|
||
att = request.env['ir.attachment'].create({
|
||
'name': filename or 'image.png',
|
||
'datas': datas,
|
||
'res_model': 'fusion.plating.process.node',
|
||
'res_id': node.id,
|
||
'mimetype': mimetype or 'image/png',
|
||
})
|
||
node.instruction_attachment_ids = [(4, att.id)]
|
||
return {
|
||
'ok': True,
|
||
'image': {
|
||
'id': att.id,
|
||
'name': att.name,
|
||
'mimetype': att.mimetype or '',
|
||
'url': '/web/image/%s' % att.id,
|
||
},
|
||
}
|
||
|
||
@http.route('/fp/simple_recipe/step/image/remove', type='jsonrpc', auth='user')
|
||
def step_image_remove(self, node_id, attachment_id):
|
||
"""Unlink an instruction image from a recipe step.
|
||
|
||
Soft-removes from the M2M; the underlying ir.attachment is
|
||
deleted only if it isn't referenced by any other recipe node.
|
||
"""
|
||
node = request.env['fusion.plating.process.node'].browse(int(node_id))
|
||
node.check_access('write')
|
||
Att = request.env['ir.attachment']
|
||
att = Att.browse(int(attachment_id))
|
||
if not att.exists():
|
||
return {'ok': False, 'error': 'not_found'}
|
||
node.instruction_attachment_ids = [(3, att.id)]
|
||
# Drop the attachment file too if no other node still links to it.
|
||
Node = request.env['fusion.plating.process.node']
|
||
still_used = Node.search_count([
|
||
('instruction_attachment_ids', '=', att.id),
|
||
])
|
||
if not still_used:
|
||
att.sudo().unlink()
|
||
return {'ok': True}
|
||
|
||
@http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user')
|
||
def step_reset_to_library(self, node_id):
|
||
"""Re-sync the recipe step's input_ids + description from the linked
|
||
library template. Preserves rows where template_input_id=False
|
||
(recipe-author-added custom prompts)."""
|
||
Node = request.env['fusion.plating.process.node']
|
||
Input = request.env['fusion.plating.process.node.input']
|
||
node = Node.browse(int(node_id))
|
||
if not node.exists() or not node.source_template_id:
|
||
return {'ok': False, 'error': 'no_library_template'}
|
||
node.check_access('write')
|
||
tpl = node.source_template_id
|
||
# Drop existing rows that came from the library (template_input_id set);
|
||
# preserve recipe-only customs.
|
||
node.input_ids.filtered(
|
||
lambda i: getattr(i, 'template_input_id', False)
|
||
and i.template_input_id
|
||
).unlink()
|
||
# Re-snapshot from library
|
||
for src in tpl.input_template_ids:
|
||
Input.create({
|
||
'node_id': node.id,
|
||
'template_input_id': src.id,
|
||
'name': src.name,
|
||
'input_type': src.input_type,
|
||
'target_min': src.target_min,
|
||
'target_max': src.target_max,
|
||
'target_unit': src.target_unit,
|
||
'required': src.required,
|
||
'hint': src.hint,
|
||
'sequence': src.sequence,
|
||
'selection_options': src.selection_options,
|
||
'kind': 'step_input',
|
||
'collect': True,
|
||
})
|
||
node.description = tpl.description or False
|
||
node.collect_measurements = True
|
||
node.message_post(
|
||
body='Reset to library defaults from template "%s"' % tpl.name,
|
||
message_type='notification',
|
||
)
|
||
return {'ok': True}
|