This commit is contained in:
gsinghpal
2026-05-04 02:14:34 -04:00
parent 3cc393454d
commit 586f05d567
43 changed files with 3656 additions and 112 deletions

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Helpdesk Reporter',
'version': '19.0.1.2.0',
'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
'description': """
Fusion Helpdesk Reporter
========================
A standalone module that gives every backend user a quick "Report an
Issue / Request a Feature" button in the Odoo top systray.
Submissions are forwarded over JSON-RPC / XML-RPC to a central Odoo
instance (typically the support shop's main Odoo) and become
`helpdesk.ticket` records with screenshots, file attachments, and
diagnostic context (URL, user agent, current user/company, error
code) automatically captured.
Designed to ship alongside any other Fusion / Nexa Systems client
module bundle. No dependencies on the rest of Fusion Plating.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': ['base', 'web', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/res_config_settings_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_helpdesk/static/src/scss/fusion_helpdesk.scss',
'fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml',
'fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml',
'fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js',
'fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main

View File

@@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""HTTP routes for the Fusion Helpdesk Reporter.
`/fusion_helpdesk/submit` accepts the dialog payload, forwards it to a
remote Odoo Helpdesk over XML-RPC, attaches uploaded files +
screenshot, and returns the resulting ticket id/url to the OWL dialog.
Why XML-RPC and not JSON-RPC? Helpdesk's external API surface is
exposed via Odoo's standard `/xmlrpc/2/object` endpoint which is
the most stable cross-version contract. JSON-RPC `/jsonrpc` works
too but historically had quirkier error handling on file payloads.
"""
import base64
import logging
import socket
import ssl
import xmlrpc.client
from urllib.parse import urljoin, urlparse
from odoo import _, http
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionHelpdeskController(http.Controller):
@http.route(
'/fusion_helpdesk/submit',
type='jsonrpc', auth='user', methods=['POST'],
)
def submit(self, kind, subject, description,
error_code=None, attachments=None,
page_url=None, user_agent=None):
"""Forward a bug report or feature request to the central Odoo
Helpdesk and return {ok, ticket_id, ticket_url, error}.
Args:
kind: 'bug' or 'feature'.
subject: short title.
description: long-form. May contain HTML or plain text.
error_code: optional traceback / error string.
attachments: list of {name, mimetype, data_b64} dicts.
page_url: client-side window.location.href at submit time.
user_agent: client-side navigator.userAgent.
"""
cfg = self._read_config()
if not all([cfg['url'], cfg['db'], cfg['login'], cfg['password']]):
return {
'ok': False,
'error': 'config_missing',
'message': _(
'Fusion Helpdesk is not fully configured. Ask an '
'administrator to fill in the remote URL, database, '
'login and password under Settings → Fusion Helpdesk.'
),
}
# ---- Build the ticket payload ---------------------------------
prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else ''
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)')
body_parts = []
if description:
body_parts.append(
'<h4>Description</h4><div>%s</div>' % _html_escape(description)
)
if error_code:
body_parts.append(
'<h4>Error Code / Traceback</h4>'
'<pre style="background:#f5f5f5;padding:8px;border-radius:4px;'
'white-space:pre-wrap;">%s</pre>' % _html_escape(error_code)
)
body_parts.append(self._build_diag_block(page_url, user_agent))
ticket_vals = {
'name': full_subject,
'description': '\n'.join(body_parts),
}
if cfg['team_id']:
ticket_vals['team_id'] = cfg['team_id']
# ---- Talk to remote Odoo --------------------------------------
try:
uid, models_proxy = self._authenticate(cfg)
except _RemoteError as e:
return e.to_response()
try:
ticket_id = models_proxy.execute_kw(
cfg['db'], uid, cfg['password'],
'helpdesk.ticket', 'create', [ticket_vals],
)
except xmlrpc.client.Fault as e:
fault = (e.faultString or '').strip()
_logger.warning(
'fusion_helpdesk: helpdesk.ticket.create failed: %s',
fault,
)
# The Helpdesk app might not be installed on the remote.
if 'helpdesk.ticket' in fault and 'does not exist' in fault.lower():
return {
'ok': False, 'error': 'helpdesk_not_installed',
'message': _(
'The Helpdesk app is not installed on the central '
'Odoo at %s. Install the Helpdesk app there before '
'submitting tickets.'
) % cfg['url'],
}
if 'access' in fault.lower() or 'rights' in fault.lower():
return {
'ok': False, 'error': 'permission_denied',
'message': _(
'The remote service account "%s" does not have '
'permission to create helpdesk tickets. Ask a '
'central Odoo admin to grant Helpdesk Officer '
'rights to that user.'
) % cfg['login'],
}
return {
'ok': False, 'error': 'create_failed',
'message': _('The remote Helpdesk rejected the ticket: %s'
) % fault,
}
except (socket.timeout, OSError, ssl.SSLError) as e:
_logger.warning(
'fusion_helpdesk: ticket create network error: %s', e,
)
return _network_error_response(cfg['url'], e)
# ---- Push attachments -----------------------------------------
attached = 0
for att in attachments or []:
data_b64 = (att or {}).get('data_b64')
name = (att or {}).get('name') or 'attachment.bin'
mimetype = (att or {}).get('mimetype') or 'application/octet-stream'
if not data_b64:
continue
try:
models_proxy.execute_kw(
cfg['db'], uid, cfg['password'],
'ir.attachment', 'create', [{
'name': name,
'datas': data_b64,
'res_model': 'helpdesk.ticket',
'res_id': ticket_id,
'mimetype': mimetype,
}],
)
attached += 1
except xmlrpc.client.Fault as e:
_logger.warning(
'fusion_helpdesk: attachment "%s" upload failed: %s',
name, e.faultString,
)
ticket_url = urljoin(
cfg['url'].rstrip('/') + '/',
'odoo/helpdesk/%s' % ticket_id,
)
_logger.info(
'fusion_helpdesk: created remote ticket #%s (%s attachments) '
'on %s for user %s',
ticket_id, attached, cfg['url'],
request.env.user.login,
)
return {
'ok': True,
'ticket_id': ticket_id,
'ticket_url': ticket_url,
'attached': attached,
}
# ------------------------------------------------------------------
def _read_config(self):
"""Return the active config as a plain dict. Run as sudo so
regular users can submit even without read-access on
ir.config_parameter (the password row is system-write but
readable by anyone with backend access)."""
ICP = request.env['ir.config_parameter'].sudo()
return {
'url': (ICP.get_param('fusion_helpdesk.remote_url') or '').strip(),
'db': (ICP.get_param('fusion_helpdesk.remote_db') or '').strip(),
'login': (ICP.get_param('fusion_helpdesk.remote_login') or '').strip(),
'password': ICP.get_param('fusion_helpdesk.remote_password') or '',
'team_id': int(
ICP.get_param('fusion_helpdesk.remote_team_id') or 0
) or False,
'client_label': (
ICP.get_param('fusion_helpdesk.client_label') or ''
).strip(),
}
def _authenticate(self, cfg):
"""Authenticate against the remote and return (uid, models_proxy).
Raises _RemoteError with a granular `error` code and a friendly
end-user message so the dialog can show what actually broke
(network vs. credentials vs. server problem).
"""
url = cfg['url'].rstrip('/')
try:
common = xmlrpc.client.ServerProxy(
'%s/xmlrpc/2/common' % url, allow_none=True,
)
try:
uid = common.authenticate(
cfg['db'], cfg['login'], cfg['password'], {},
)
except xmlrpc.client.ProtocolError as e:
# Server returned an HTTP error status.
_logger.warning(
'fusion_helpdesk: HTTP %s from %s during authenticate: %s',
e.errcode, url, e.errmsg,
)
if e.errcode in (401, 403):
raise _RemoteError(
'auth_failed',
_('The central Odoo at %s rejected the login. '
'Check the Service Login and API Key in '
'Settings → Fusion Helpdesk.') % url,
)
if e.errcode == 404:
raise _RemoteError(
'endpoint_not_found',
_('The XML-RPC endpoint at %s/xmlrpc/2/common '
'returned 404. Verify the Remote URL points '
'at an Odoo server (no trailing path).') % url,
)
raise _RemoteError(
'remote_http_error',
_('The central Odoo returned HTTP %(code)s while '
'authenticating. Try again, or check that '
'%(url)s is reachable from a browser.'
) % {'code': e.errcode, 'url': url},
)
except xmlrpc.client.Fault as e:
# Server-side application error — usually wrong DB name.
fault = (e.faultString or '').strip()
_logger.warning(
'fusion_helpdesk: XML-RPC fault during authenticate: %s',
fault,
)
if 'database' in fault.lower() and (
'not exist' in fault.lower() or 'unknown' in fault.lower()
):
raise _RemoteError(
'wrong_database',
_('Database "%(db)s" does not exist on %(url)s. '
'Verify the Remote DB in Settings → Fusion '
'Helpdesk.') % {'db': cfg['db'], 'url': url},
)
raise _RemoteError(
'auth_failed',
_('The central Odoo rejected authentication: %s'
) % fault,
)
except ssl.SSLError as e:
_logger.warning(
'fusion_helpdesk: TLS error against %s: %s', url, e,
)
raise _RemoteError(
'tls_error',
_('TLS / SSL handshake with %(url)s failed: '
'%(msg)s. Either the server cert is invalid or '
'the system cert store on this host is out of '
'date.') % {'url': url, 'msg': str(e)},
)
except socket.gaierror as e:
_logger.warning(
'fusion_helpdesk: DNS lookup failed for %s: %s', url, e,
)
raise _RemoteError(
'dns_error',
_('Could not resolve "%(host)s". Check Settings → '
'Fusion Helpdesk → Remote URL, or your server\'s '
'DNS / outbound network.'
) % {'host': urlparse(url).hostname or url},
)
except (ConnectionRefusedError, socket.timeout) as e:
_logger.warning(
'fusion_helpdesk: connection problem to %s: %s', url, e,
)
raise _RemoteError(
'unreachable',
_('Could not reach %(url)s — connection refused or '
'timed out. Check that this server can make '
'outbound HTTPS to %(host)s (firewall, proxy).'
) % {'url': url, 'host': urlparse(url).hostname or url},
)
except OSError as e:
# Catch-all for socket / network issues we didn't classify.
_logger.warning(
'fusion_helpdesk: network error to %s: %s', url, e,
)
raise _RemoteError(
'unreachable',
_('Network error reaching %(url)s: %(msg)s. Verify '
'this server has outbound internet access.'
) % {'url': url, 'msg': str(e)},
)
except _RemoteError:
raise
except Exception as e:
_logger.exception(
'fusion_helpdesk: unexpected error during authenticate '
'against %s', url,
)
raise _RemoteError(
'unknown_error',
_('Unexpected error contacting the central Helpdesk: %s'
) % str(e),
)
if not uid:
raise _RemoteError(
'auth_failed',
_('The central Odoo at %(url)s did not accept the '
'login "%(login)s". Verify the Service Login and '
'API Key in Settings → Fusion Helpdesk.'
) % {'url': url, 'login': cfg['login']},
)
models_proxy = xmlrpc.client.ServerProxy(
'%s/xmlrpc/2/object' % url, allow_none=True,
)
return uid, models_proxy
def _build_diag_block(self, page_url, user_agent):
env = request.env
company = env.company
user = env.user
rows = [
('User', '%s (#%s, %s)' % (user.name, user.id, user.login)),
('Company', '%s (#%s)' % (company.name, company.id)),
('Source page', page_url or ''),
('User agent', user_agent or ''),
('Source DB', request.env.cr.dbname),
('Source host', request.httprequest.host_url),
]
body = '<h4>Diagnostic context</h4><table>'
for k, v in rows:
body += (
'<tr><td style="padding:2px 8px;color:#666;">%s</td>'
'<td style="padding:2px 8px;"><code>%s</code></td></tr>'
) % (_html_escape(k), _html_escape(str(v)))
body += '</table>'
return body
def _html_escape(s):
return (
(s or '')
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
)
class _RemoteError(Exception):
"""Typed wrapper that carries an `error` code + a friendly message
so the dialog can show the right thing."""
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def to_response(self):
return {'ok': False, 'error': self.code, 'message': self.message}
def _network_error_response(url, err):
"""Map a raw network exception raised mid-RPC into the same
response shape the dialog already knows how to render."""
host = urlparse(url).hostname or url
if isinstance(err, ssl.SSLError):
return {
'ok': False, 'error': 'tls_error',
'message': _('TLS / SSL error talking to %(url)s: %(msg)s'
) % {'url': url, 'msg': str(err)},
}
if isinstance(err, socket.gaierror):
return {
'ok': False, 'error': 'dns_error',
'message': _('Could not resolve "%s" — check DNS / outbound network.'
) % host,
}
if isinstance(err, socket.timeout):
return {
'ok': False, 'error': 'unreachable',
'message': _('Timed out talking to %s — check the firewall '
'allows outbound HTTPS.') % url,
}
return {
'ok': False, 'error': 'unreachable',
'message': _('Network error talking to %(url)s: %(msg)s'
) % {'url': url, 'msg': str(err)},
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Defaults for the Fusion Helpdesk Reporter. noupdate=1 so admins
can override on the Settings page without losing their values
on `-u`.
-->
<odoo noupdate="1">
<record id="fhd_default_remote_url" model="ir.config_parameter">
<field name="key">fusion_helpdesk.remote_url</field>
<field name="value">https://erp.nexasystems.ca</field>
</record>
<record id="fhd_default_remote_db" model="ir.config_parameter">
<field name="key">fusion_helpdesk.remote_db</field>
<field name="value">nexamain</field>
</record>
<record id="fhd_default_remote_login" model="ir.config_parameter">
<field name="key">fusion_helpdesk.remote_login</field>
<field name="value">helpdesk_bot@nexasystems.ca</field>
</record>
<record id="fhd_default_client_label" model="ir.config_parameter">
<field name="key">fusion_helpdesk.client_label</field>
<field name="value"></field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import res_config_settings

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Configuration for the Fusion Helpdesk Reporter.
Stores the central Odoo Helpdesk endpoint that submissions are
forwarded to. Defaults point at erp.nexasystems.ca / nexamain;
each client deployment can override per system parameter.
"""
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fhd_remote_url = fields.Char(
string='Helpdesk Remote URL',
config_parameter='fusion_helpdesk.remote_url',
help='Base URL of the central Odoo running the Helpdesk app, '
'e.g. https://erp.nexasystems.ca',
)
fhd_remote_db = fields.Char(
string='Helpdesk Remote DB',
config_parameter='fusion_helpdesk.remote_db',
help='Database name on the remote Odoo (e.g. nexamain).',
)
fhd_remote_login = fields.Char(
string='Helpdesk Remote Login',
config_parameter='fusion_helpdesk.remote_login',
help='Service-account login on the remote Odoo. Needs create '
'rights on helpdesk.ticket and ir.attachment.',
)
fhd_remote_password = fields.Char(
string='Helpdesk Remote Password / API Key',
config_parameter='fusion_helpdesk.remote_password',
help='Service-account password or API key. Stored in '
'ir.config_parameter — restrict read access if needed.',
)
fhd_remote_team_id = fields.Integer(
string='Helpdesk Team ID',
config_parameter='fusion_helpdesk.remote_team_id',
help='Optional. ID of the helpdesk.team on the remote that '
'should own all incoming tickets. Leave blank to use '
'the remote default routing.',
)
fhd_client_label = fields.Char(
string='Client Label (auto-prepended to subject)',
config_parameter='fusion_helpdesk.client_label',
help='Short tag prefixed onto the ticket subject so support '
'can tell which client deployment a ticket came from. '
'e.g. "ENTECH""[ENTECH] My subject"',
)

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,244 @@
/** @odoo-module **/
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
// Feature, fill in subject + description, paste an error code, attach
// files, and capture a screenshot via the browser's getDisplayMedia
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file
export class FusionHelpdeskDialog extends Component {
static template = "fusion_helpdesk.Dialog";
static components = { Dialog };
static props = {
close: Function,
};
setup() {
this.notification = useService("notification");
this.state = useState({
kind: "bug", // 'bug' | 'feature'
subject: "",
description: "",
errorCode: "",
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
capturing: false,
submitting: false,
error: "",
success: false,
ticketId: null,
ticketUrl: "",
attached: 0,
});
}
get dialogTitle() {
return this.state.kind === "bug"
? _t("Report a Bug")
: _t("Request a Feature");
}
setKind(kind) {
this.state.kind = kind;
}
// ------------------------------------------------------------------
// File input → b64
async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []);
for (const f of files) {
if (f.size > MAX_BYTES_PER_FILE) {
this.notification.add(
_t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name),
{ type: "warning" }
);
continue;
}
try {
const b64 = await this._fileToB64(f);
this._addAttachment({
name: f.name,
mimetype: f.type || "application/octet-stream",
data_b64: b64,
rawSize: f.size,
});
} catch (err) {
this.notification.add(
_t("Could not read '%s'.").replace("%s", f.name),
{ type: "danger" }
);
}
}
// Reset the input so picking the same file again re-fires onchange.
ev.target.value = "";
}
_fileToB64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result || "";
const idx = result.indexOf(",");
resolve(idx >= 0 ? result.slice(idx + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ------------------------------------------------------------------
// Screenshot capture via getDisplayMedia
async onTakeScreenshot() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
this.notification.add(
_t("Your browser doesn't support screen capture. Use Attach files instead."),
{ type: "warning" }
);
return;
}
this.state.capturing = true;
let stream;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
audio: false,
});
const blob = await this._streamToBlob(stream);
const b64 = await this._blobToB64(blob);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
this._addAttachment({
name: `screenshot-${ts}.png`,
mimetype: "image/png",
data_b64: b64,
rawSize: blob.size,
});
} catch (err) {
// User cancelled the picker — silently swallow. Other errors → notify.
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
this.notification.add(
_t("Screenshot failed: %s").replace("%s", err.message || err),
{ type: "danger" }
);
}
} finally {
if (stream) {
stream.getTracks().forEach((t) => t.stop());
}
this.state.capturing = false;
}
}
async _streamToBlob(stream) {
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
// Give the browser one frame to settle the picker chrome.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b), "image/png", 0.92)
);
}
_blobToB64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result || "";
const idx = result.indexOf(",");
resolve(idx >= 0 ? result.slice(idx + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
_addAttachment({ name, mimetype, data_b64, rawSize }) {
this.state.attachments.push({
name,
mimetype,
data_b64,
sizeLabel: this._formatBytes(rawSize),
iconClass: this._iconForMime(mimetype),
});
}
removeAttachment(idx) {
this.state.attachments.splice(idx, 1);
}
_formatBytes(n) {
if (!n) return "";
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / (1024 * 1024)).toFixed(1) + " MB";
}
_iconForMime(mt) {
mt = (mt || "").toLowerCase();
if (mt.startsWith("image/")) return "fa fa-file-image-o";
if (mt.startsWith("video/")) return "fa fa-file-video-o";
if (mt.startsWith("audio/")) return "fa fa-file-audio-o";
if (mt.includes("pdf")) return "fa fa-file-pdf-o";
if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o";
if (mt.includes("text")) return "fa fa-file-text-o";
return "fa fa-file-o";
}
// ------------------------------------------------------------------
// Submit
async onSubmit() {
if (this.state.submitting) return;
const subject = (this.state.subject || "").trim();
if (!subject) {
this.state.error = _t("Subject is required.");
return;
}
this.state.error = "";
this.state.success = false;
this.state.submitting = true;
try {
const payload = {
kind: this.state.kind,
subject,
description: this.state.description || "",
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
attachments: this.state.attachments.map((a) => ({
name: a.name,
mimetype: a.mimetype,
data_b64: a.data_b64,
})),
page_url: window.location.href,
user_agent: navigator.userAgent,
};
const res = await rpc("/fusion_helpdesk/submit", payload);
if (!res.ok) {
this.state.error = res.message || _t("Submission failed.");
} else {
this.state.success = true;
this.state.ticketId = res.ticket_id;
this.state.ticketUrl = res.ticket_url;
this.state.attached = res.attached || 0;
// Reset the editable fields so user can file another if they want.
this.state.subject = "";
this.state.description = "";
this.state.errorCode = "";
this.state.attachments = [];
}
} catch (err) {
this.state.error = (err && err.message) || _t("Network error.");
} finally {
this.state.submitting = false;
}
}
}

View File

@@ -0,0 +1,33 @@
/** @odoo-module **/
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
// appears to the LEFT of the attendance check-in button. Odoo
// systray ordering is by sequence ascending (lower = leftmost in the
// systray bar). hr_attendance ships at sequence 100, so we use 99.
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
class FusionHelpdeskSystray extends Component {
static template = "fusion_helpdesk.SystrayItem";
static props = {};
setup() {
this.dialog = useService("dialog");
}
onClick() {
this.dialog.add(FusionHelpdeskDialog, {});
}
}
export const fusionHelpdeskSystrayItem = {
Component: FusionHelpdeskSystray,
};
registry
.category("systray")
.add("fusion_helpdesk.report_button", fusionHelpdeskSystrayItem, {
sequence: 99,
});

View File

@@ -0,0 +1,172 @@
// Fusion Helpdesk Reporter — systray button + dialog styling.
// Dark-mode aware via Odoo's $o-webclient-color-scheme branch.
$o-webclient-color-scheme: bright !default;
$_fhd-text-hex: #21252b;
$_fhd-muted-hex: #6c757d;
$_fhd-bg-hex: #ffffff;
$_fhd-border-hex: #d8dadd;
$_fhd-hover-hex: #f3f4f6;
$_fhd-accent-hex: #2c89e9;
@if $o-webclient-color-scheme == dark {
$_fhd-text-hex: #e6e9ef !global;
$_fhd-muted-hex: #9aa3ad !global;
$_fhd-bg-hex: #22262d !global;
$_fhd-border-hex: #3a3f47 !global;
$_fhd-hover-hex: #2c313a !global;
$_fhd-accent-hex: #4ea3ff !global;
}
$fhd-text: var(--fhd-text, $_fhd-text-hex);
$fhd-muted: var(--fhd-muted, $_fhd-muted-hex);
$fhd-bg: var(--fhd-bg, $_fhd-bg-hex);
$fhd-border: var(--fhd-border, $_fhd-border-hex);
$fhd-hover: var(--fhd-hover, $_fhd-hover-hex);
$fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
// Systray icon
.o_fhd_systray {
.o_fhd_systray_btn {
background: transparent;
border: none;
padding: 0 0.5rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
&:hover .o_fhd_systray_img {
transform: scale(1.08);
filter: drop-shadow(0 0 2px rgba(78, 163, 255, 0.45));
}
}
.o_fhd_systray_img {
height: 22px;
width: 22px;
object-fit: contain;
transition: transform 120ms ease, filter 120ms ease;
}
// Hide the dropdown caret Bootstrap injects.
.dropdown-toggle::after { display: none; }
}
// Dialog
.o_fhd_dialog {
color: $fhd-text;
.o_fhd_kind_row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.o_fhd_kind_chip {
flex: 1;
padding: 0.6rem 0.75rem;
background-color: $fhd-bg;
border: 1px solid $fhd-border;
border-radius: 6px;
color: $fhd-text;
cursor: pointer;
font-weight: 500;
&:hover { background-color: $fhd-hover; }
&.o_fhd_kind_active {
border-color: $fhd-accent;
color: $fhd-accent;
background-color: rgba(44, 137, 233, 0.10);
}
}
.o_fhd_field {
margin-bottom: 0.85rem;
> label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
color: $fhd-text;
}
}
.o_fhd_hint {
font-weight: 400;
font-size: 0.8rem;
color: $fhd-muted;
margin-left: 0.4rem;
}
.o_fhd_mono {
font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
font-size: 0.85rem;
}
.o_fhd_actions_row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.o_fhd_btn {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
border: 1px solid $fhd-border;
background-color: $fhd-bg;
color: $fhd-text;
&:hover:not(:disabled) { background-color: $fhd-hover; }
&:disabled { opacity: 0.6; cursor: default; }
}
.o_fhd_attach_list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 0.5rem;
}
.o_fhd_attach_item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
background-color: $fhd-hover;
border: 1px solid $fhd-border;
border-radius: 4px;
font-size: 0.85rem;
}
.o_fhd_attach_name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_fhd_attach_size {
color: $fhd-muted;
font-variant-numeric: tabular-nums;
}
.o_fhd_attach_remove {
background: transparent;
border: none;
color: $fhd-muted;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0 0.25rem;
&:hover { color: #d32f2f; }
}
}

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_helpdesk.Dialog">
<Dialog title="dialogTitle" size="'lg'">
<div class="o_fhd_dialog">
<!-- Kind selector -->
<div class="o_fhd_kind_row">
<button type="button"
class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-click="() => this.setKind('bug')">
<i class="fa fa-bug me-1"/> Report a Bug
</button>
<button type="button"
class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
t-on-click="() => this.setKind('feature')">
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
</button>
</div>
<!-- Subject -->
<div class="o_fhd_field">
<label>Subject *</label>
<input type="text" class="form-control"
t-att-value="state.subject"
t-on-input="(ev) => state.subject = ev.target.value"
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
</div>
<!-- Description -->
<div class="o_fhd_field">
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
<textarea class="form-control" rows="5"
t-att-value="state.description"
t-on-input="(ev) => state.description = ev.target.value"
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
</div>
<!-- Error code (bug only) -->
<div class="o_fhd_field" t-if="state.kind === 'bug'">
<label>
Error code / traceback
<span class="o_fhd_hint">paste any error message or stack trace</span>
</label>
<textarea class="form-control o_fhd_mono" rows="3"
t-att-value="state.errorCode"
t-on-input="(ev) => state.errorCode = ev.target.value"
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
</div>
<!-- Attachments -->
<div class="o_fhd_field">
<label>Attachments</label>
<div class="o_fhd_actions_row">
<label class="o_fhd_btn o_fhd_btn_secondary">
<i class="fa fa-paperclip me-1"/> Attach files
<input type="file" multiple="multiple" class="d-none"
t-on-change="onFilesPicked"/>
</label>
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
t-on-click="onTakeScreenshot"
t-att-disabled="state.capturing">
<i class="fa fa-camera me-1"/>
<t t-if="state.capturing">Capturing…</t>
<t t-else="">Capture screenshot</t>
</button>
</div>
<div t-if="state.attachments.length" class="o_fhd_attach_list">
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
class="o_fhd_attach_item">
<i t-att-class="att.iconClass"/>
<span class="o_fhd_attach_name" t-esc="att.name"/>
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
<button type="button" class="o_fhd_attach_remove"
t-on-click="() => this.removeAttachment(att_index)">×</button>
</div>
</div>
</div>
<!-- Result feedback -->
<div t-if="state.error" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
</div>
<div t-if="state.success" class="alert alert-success mt-2">
<i class="fa fa-check-circle me-1"/>
Thanks — ticket
<a t-att-href="state.ticketUrl" target="_blank">
#<t t-esc="state.ticketId"/>
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary"
t-on-click="onSubmit"
t-att-disabled="state.submitting or !state.subject.trim()">
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit
</button>
<button class="btn btn-secondary" t-on-click="props.close">
Close
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_helpdesk.SystrayItem">
<div class="o_fhd_systray dropdown">
<button type="button"
class="o_fhd_systray_btn dropdown-toggle"
title="Report a bug or request a feature"
t-on-click="onClick">
<img src="/fusion_helpdesk/static/description/icon.png"
alt="Help"
class="o_fhd_systray_img"/>
</button>
</div>
</t>
</templates>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
-->
<odoo>
<record id="res_config_settings_view_form_fhd" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.helpdesk</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Helpdesk"
string="Fusion Helpdesk"
name="fusion_helpdesk">
<block title="Central Helpdesk Endpoint"
name="fhd_endpoint">
<setting id="fhd_remote_url"
string="Remote URL"
help="Base URL of the Odoo instance running the Helpdesk app.">
<field name="fhd_remote_url" placeholder="https://erp.nexasystems.ca"/>
</setting>
<setting id="fhd_remote_db"
string="Remote Database">
<field name="fhd_remote_db" placeholder="nexamain"/>
</setting>
<setting id="fhd_remote_login"
string="Service Login">
<field name="fhd_remote_login" placeholder="helpdesk_bot@nexasystems.ca"/>
</setting>
<setting id="fhd_remote_password"
string="Service Password / API Key">
<field name="fhd_remote_password" password="True"/>
</setting>
<setting id="fhd_remote_team_id"
string="Helpdesk Team ID (optional)">
<field name="fhd_remote_team_id"/>
</setting>
<setting id="fhd_client_label"
string="Client Label"
help="Tag prefixed to every ticket subject so support can identify the source deployment.">
<field name="fhd_client_label" placeholder="ENTECH"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.1.0.2',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',
'description': """
Fusion Helpdesk Central
=======================
Companion to `fusion_helpdesk`. Install on the central Odoo (the one
running the Helpdesk app) to manage **per-client API keys** instead of
shipping a shared bot password to every client deployment.
Each row in *Helpdesk → Configuration → Client API Keys* maps a client
label (e.g. ENTECH, MOBILITY) to a real `res.users.apikeys` row on the
shared bot user. The plaintext key is shown ONCE on creation; revoke
in one click if a deployment is compromised.
Depends only on `helpdesk`. No client-side install needed.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': ['helpdesk'],
'data': [
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/fusion_helpdesk_client_key_views.xml',
],
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
-->
<odoo noupdate="1">
<record id="fhc_default_bot_login" model="ir.config_parameter">
<field name="key">fusion_helpdesk.bot_login</field>
<field name="value">helpdesk_bot@nexasystems.ca</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import fusion_helpdesk_client_key

View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Per-client API key registry for fusion_helpdesk.
Each row links a client deployment label (e.g. ENTECH) to a real
`res.users.apikeys` row on a shared bot account. The plaintext key is
shown ONCE on creation, then cleared. Revoking a row deletes the
underlying API key so the client deployment can no longer authenticate.
"""
import logging
from datetime import datetime, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionHelpdeskClientKey(models.Model):
_name = 'fusion.helpdesk.client.key'
_description = 'Fusion Helpdesk — Client API Key'
_order = 'create_date desc'
_rec_name = 'client_label'
client_label = fields.Char(
string='Client Label', required=True, index=True,
help='Short tag identifying this client deployment '
'(e.g. ENTECH, MOBILITY). Free text — used for your '
'reference and to find keys quickly when revoking.',
)
notes = fields.Text(
string='Notes',
help='Optional. Stamp deployment URL, contact, install date.',
)
bot_user_id = fields.Many2one(
'res.users', string='Bot User', readonly=True,
ondelete='restrict',
)
apikey_id = fields.Many2one(
'res.users.apikeys', string='API Key Record',
readonly=True, ondelete='set null',
help='Underlying res.users.apikeys row. Cleared if the key '
'is revoked or deleted out-of-band.',
)
apikey_name = fields.Char(
string='Key Name', related='apikey_id.name', store=True,
readonly=True,
)
plaintext_key = fields.Char(
string='Plaintext Key (one-time display)',
readonly=True, copy=False,
help='Shown ONCE right after creation. Copy it now — once you '
'click "Mark Stored" it is wiped from this record forever. '
'The key keeps working; we just stop showing it.',
)
is_revoked = fields.Boolean(
string='Revoked', compute='_compute_is_revoked', store=True,
)
create_date = fields.Datetime(readonly=True)
display_name = fields.Char(
string='Display Name', compute='_compute_display_name', store=True,
)
@api.depends('client_label', 'is_revoked')
def _compute_display_name(self):
for rec in self:
base = rec.client_label or _('(unnamed)')
rec.display_name = ('%s [REVOKED]' % base) if rec.is_revoked else base
_sql_constraints = [
('fhc_client_label_unique',
'unique(client_label)',
'Client label must be unique — one row per client deployment.'),
]
@api.depends('apikey_id')
def _compute_is_revoked(self):
for rec in self:
rec.is_revoked = not rec.apikey_id
# ----------------------------------------------------------------------
@api.model
def _default_expiration_date(self):
"""Odoo 19 requires API keys to carry an expiration. Default to
10 years out so client deployments don't break unexpectedly.
Override per row by writing to apikey_id.expiration_date later."""
years = int(
self.env['ir.config_parameter'].sudo().get_param(
'fusion_helpdesk.key_expiration_years', '10',
) or 10
)
return datetime.utcnow() + timedelta(days=365 * years)
@api.model
def _get_bot_user(self):
ICP = self.env['ir.config_parameter'].sudo()
login = (ICP.get_param('fusion_helpdesk.bot_login') or '').strip()
if not login:
raise UserError(_(
'No bot user configured. Set `fusion_helpdesk.bot_login` '
'in System Parameters first.'
))
user = self.env['res.users'].search([('login', '=', login)], limit=1)
if not user:
raise UserError(_(
'Bot user "%s" not found. Create it first or update '
'`fusion_helpdesk.bot_login`.', login
))
return user
@api.model_create_multi
def create(self, vals_list):
ApiKey = self.env['res.users.apikeys'].with_context(
mail_create_nosubscribe=True,
)
bot = self._get_bot_user()
records = super().create(vals_list)
for rec in records:
# Generate the apikey AS the bot user — the key is bound to
# the user that creates it.
label = 'fusion_helpdesk:%s' % (rec.client_label or rec.id)
key = ApiKey.with_user(bot)._generate(
'rpc', label, self._default_expiration_date(),
)
# Find the apikey row that was just created so we can link it.
apikey_row = self.env['res.users.apikeys'].sudo().search(
[('user_id', '=', bot.id), ('name', '=', label)],
limit=1, order='id desc',
)
rec.write({
'bot_user_id': bot.id,
'apikey_id': apikey_row.id if apikey_row else False,
'plaintext_key': key,
})
_logger.info(
'fusion_helpdesk_central: issued API key for client "%s" '
'(apikey_id=%s, bot_user=%s)',
rec.client_label, apikey_row.id if apikey_row else '?',
bot.login,
)
return records
def action_mark_stored(self):
"""User confirms they have copied the plaintext key. Wipe it
from the DB so a later read can't recover it. The underlying
res.users.apikeys row keeps working — only the plaintext copy
on this row is destroyed."""
self.write({'plaintext_key': False})
return True
def action_revoke(self):
"""Delete the underlying res.users.apikeys row. The client
deployment's next request will get an auth_failed error."""
for rec in self:
if rec.apikey_id:
rec.apikey_id.sudo().unlink()
_logger.warning(
'fusion_helpdesk_central: REVOKED API key for "%s" '
'(was apikey_id=%s)',
rec.client_label, rec.apikey_id.id,
)
rec.write({'plaintext_key': False})
return True
def action_rotate(self):
"""One-shot: revoke the existing key + issue a new one. Plaintext
is shown once on the same row."""
ApiKey = self.env['res.users.apikeys']
bot = self._get_bot_user()
for rec in self:
if rec.apikey_id:
rec.apikey_id.sudo().unlink()
label = 'fusion_helpdesk:%s' % rec.client_label
key = ApiKey.with_user(bot)._generate(
'rpc', label, self._default_expiration_date(),
)
apikey_row = self.env['res.users.apikeys'].sudo().search(
[('user_id', '=', bot.id), ('name', '=', label)],
limit=1, order='id desc',
)
rec.write({
'apikey_id': apikey_row.id if apikey_row else False,
'plaintext_key': key,
})
return True

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fhc_client_key_admin fusion.helpdesk.client.key.admin model_fusion_helpdesk_client_key base.group_system 1 1 1 1

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
-->
<odoo>
<record id="view_fhc_client_key_list" model="ir.ui.view">
<field name="name">fusion.helpdesk.client.key.list</field>
<field name="model">fusion.helpdesk.client.key</field>
<field name="arch" type="xml">
<list string="Helpdesk Client API Keys"
decoration-muted="is_revoked">
<field name="client_label"/>
<field name="apikey_name" optional="hide"/>
<field name="bot_user_id"/>
<field name="create_date"/>
<field name="is_revoked" widget="boolean_toggle" readonly="1"/>
<field name="notes" optional="hide"/>
</list>
</field>
</record>
<record id="view_fhc_client_key_form" model="ir.ui.view">
<field name="name">fusion.helpdesk.client.key.form</field>
<field name="model">fusion.helpdesk.client.key</field>
<field name="arch" type="xml">
<form string="Helpdesk Client API Key">
<header>
<button name="action_mark_stored" type="object"
string="Mark Key Stored"
class="btn-primary"
invisible="not plaintext_key"
confirm="Wipe the plaintext key from this record? It can't be recovered after this."/>
<button name="action_rotate" type="object"
string="Rotate Key"
class="btn-secondary"
invisible="is_revoked or plaintext_key"
confirm="Revoke the existing key and issue a fresh one? The client deployment will need its config updated to keep working."/>
<button name="action_revoke" type="object"
string="Revoke"
class="btn-danger"
invisible="is_revoked"
confirm="Revoke this key permanently? The client deployment will stop being able to file tickets."/>
</header>
<sheet>
<widget name="web_ribbon" title="Revoked"
bg_color="bg-danger"
invisible="not is_revoked"/>
<div class="oe_title">
<label for="client_label"/>
<h1><field name="client_label" placeholder="e.g. ENTECH"/></h1>
</div>
<div class="alert alert-warning"
role="alert"
invisible="not plaintext_key">
<h4 class="alert-heading">
<i class="fa fa-key me-1"/> Copy the key now
</h4>
<p>
This is the <strong>only time</strong> the plaintext key
is shown. After you click <em>Mark Key Stored</em> in the
header it will be wiped from this record forever.
The key keeps working — we just stop displaying it.
</p>
<pre class="mt-2 p-2 bg-light text-dark"
style="user-select: all; word-break: break-all;"
><field name="plaintext_key" nolabel="1" readonly="1"/></pre>
</div>
<group>
<group>
<field name="bot_user_id"/>
<field name="apikey_name"/>
<field name="create_date"/>
</group>
<group>
<field name="apikey_id" readonly="1"/>
<field name="is_revoked" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" placeholder="Deployment URL, contact email, install date…"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fhc_client_key_search" model="ir.ui.view">
<field name="name">fusion.helpdesk.client.key.search</field>
<field name="model">fusion.helpdesk.client.key</field>
<field name="arch" type="xml">
<search>
<field name="client_label"/>
<field name="notes"/>
<separator/>
<filter string="Active" name="active"
domain="[('is_revoked', '=', False)]"/>
<filter string="Revoked" name="revoked"
domain="[('is_revoked', '=', True)]"/>
<group>
<filter string="Bot User" name="g_bot"
context="{'group_by': 'bot_user_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fhc_client_key" model="ir.actions.act_window">
<field name="name">Helpdesk Client API Keys</field>
<field name="res_model">fusion.helpdesk.client.key</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Issue a new API key
</p>
<p>
Each row maps a client deployment (e.g. ENTECH, MOBILITY)
to a real API key on the shared bot user. The plaintext
key is shown ONCE on creation — copy it then click
"Mark Key Stored". Revoke any key in one click if a
deployment is compromised.
</p>
</field>
</record>
<menuitem id="menu_fhc_client_key"
name="Client API Keys"
parent="helpdesk.helpdesk_menu_config"
action="action_fhc_client_key"
sequence="90"/>
</odoo>

View File

@@ -0,0 +1,96 @@
# FP Step Kind — User-Extensible Model
Date: 2026-05-04
Status: design + implementation
## Problem
`default_kind` on `fp.step.template` is a hardcoded `Selection` of 24 entries. Users can't add new kinds (e.g. "Shot Peen", "Passivation"). The same Selection list is duplicated on `fusion.plating.process.node`. Default-input seeding (`DEFAULT_INPUTS_BY_KIND`) is also locked in Python — adding a new kind would require a code change + module update.
## Solution
Convert `default_kind` from `Selection``Many2one('fp.step.kind')`. Move the default-input templates from a Python dict into seeded data records on a new `fp.step.kind.default.input` child model. Users add new kinds through the standard Odoo CRUD form.
## Models
### `fp.step.kind`
| Field | Type | Notes |
|---|---|---|
| `code` | Char, required, unique per company | Technical key (lowercase). Stable XML IDs use this. |
| `name` | Char, required, translated | UI label (e.g. "Cleaning") |
| `sequence` | Integer | Order in dropdown |
| `active` | Boolean default True | Archive instead of delete |
| `icon` | Selection (reuses 24-icon list from `fp.process.node`) | Optional |
| `description` | Html | Optional ops note |
| `company_id` | Many2one res.company | Multi-co support |
| `default_input_ids` | One2many → `fp.step.kind.default.input` | The seed list |
### `fp.step.kind.default.input`
Same shape as `fp.step.template.input`: `name`, `input_type`, `target_unit`, `sequence`, `required`, `hint`, `selection_options`. Plus `kind_id` parent FK.
## Field changes
### `fp.step.template`
- **add** `kind_id = Many2one('fp.step.kind', ondelete='restrict', string='Step Kind')` — user-facing input
- **change** existing `default_kind` from `Selection(...)` to `default_kind = fields.Char(related='kind_id.code', store=True, readonly=True)` — back-compat shim. Lets every legacy `node.default_kind == 'cleaning'` comparison keep working without touching 30+ sites. Stored=True so existing search domains (`('default_kind', '=', 'foo')`) still work.
### `fusion.plating.process.node`
- Same: add `kind_id`, convert `default_kind` to `related='kind_id.code'` stored Char.
### `fp.job.workflow.state.trigger_default_kinds`
- **No change in Phase 1.** Stays a Char of comma-separated codes. The codes still match `kind_id.code` after migration. Phase 2 deferred convert to m2m.
## DEFAULT_INPUTS_BY_KIND
Removed from `fp_step_template.py`. Lives in seed data XML. `action_seed_default_inputs` reads `tpl.kind_id.default_input_ids` instead of the dict.
## Migration `19.0.18.13.0/post-migrate.py`
1. SQL-read existing `default_kind` text values from `fp_step_template` and `fusion_plating_process_node` BEFORE Odoo recomputes the related field.
2. Build map `code → fp.step.kind.id` from seeded records.
3. SQL UPDATE `kind_id` per row.
4. Trigger recompute of stored related `default_kind` (or leave — values are already there from step 1).
5. Log counts.
## Why not drop `default_kind` entirely?
- Comma-CSV `trigger_default_kinds` on workflow state still searches it.
- 30+ comparison sites and existing search domains in views.
- Stored related Char keeps it a single source of truth (m2o-derived) without a churn-PR through every dependent.
## View changes
- `fp_step_template_views.xml` — replace `<field name="default_kind"/>` with `<field name="kind_id" options="{'no_create_edit': false, 'no_quick_create': false}"/>`. Same for search filter and `group_by="default_kind"``group_by="kind_id"`.
- `fp_process_node_views.xml` — same swap on the one occurrence.
- New `fp_step_kind_views.xml` — list/form/menu so admins can manage kinds. Form embeds `default_input_ids` as inline list.
## Controller / JS changes
- `simple_recipe_controller.py`:
- Add `kind_id` (id) and `kind_name` (label) to all step-payload responses.
- Accept `kind_id` int on `_save_recipe`/template-create endpoints.
- Keep returning `default_kind` (the code Char) for back-compat with deployed editor sessions until cache flushes.
- `simple_recipe_editor.js`:
- Replace the `<select>` over selection options with a Many2one-style typeahead. Fetch list from new endpoint `/fusion_plating/recipe/kinds` returning `[{id, code, name, icon}]`.
- Submit `kind_id` (int) instead of `default_kind` (string).
- `simple_recipe_editor.xml`:
- Replace `<select t-model=".default_kind">` block with a `<select t-model=".kind_id">` populated from fetched list, plus a "+ New kind…" link that opens a small inline form via `/fusion_plating/recipe/kinds/create`.
## ACLs (`ir.model.access.csv`)
```
access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
access_fp_step_kind_default_input_* … same triplet
```
## Manifest
- Bump `version` to `19.0.18.13.0`
- Add to `data`:
- `data/fp_step_kind_data.xml` (BEFORE `fp_step_template_data.xml` since templates reference kinds)
- `views/fp_step_kind_views.xml`
## Out of scope (Phase 2)
- Convert `trigger_default_kinds` Char-CSV to m2m on `fp.job.workflow.state`
- Remove DEFAULT_INPUTS_BY_KIND dict from migration scripts (one-shot dev scripts — no harm leaving)
- Migrate stored `default_kind` Char to a non-stored related once all callers refactored
## Test plan
1. `odoo -d admin -u fusion_plating --stop-after-init` (entech) — install + migration runs cleanly
2. UI smoke: open a step template, kind dropdown shows 24 seeded kinds, dropdown allows quick-create
3. Create new kind "Passivation" with 3 default inputs → save → return to step template → "Seed defaults" button populates the 3 inputs
4. Open recipe editor (simple): kind dropdown matches; existing recipes show correct kind label
5. Run a step that's wired through workflow-state trigger (e.g. completion of a `receiving` step → state changes to "On Floor") to confirm CSV-trigger backwards compat still works

View File

@@ -32,6 +32,18 @@ def post_init_hook(env):
_migrate_legacy_uom_columns(env)
def _resolve_kind_id(env, code):
"""Look up an fp.step.kind id by code. Returns False if not found.
Cheap helper used during seeding so legacy code paths that referenced
string codes can keep their semantics."""
if not code:
return False
rec = env['fp.step.kind'].search(
[('code', '=', code)], limit=1,
)
return rec.id or False
def _backfill_contract_review_template(env):
"""Idempotent — ensure the Contract Review library template exists.
@@ -45,7 +57,7 @@ def _backfill_contract_review_template(env):
return # already there
tpl = Tpl.create({
'name': 'Contract Review',
'default_kind': 'contract_review',
'kind_id': _resolve_kind_id(env, 'contract_review'),
})
tpl.action_seed_default_inputs()
_logger.info(
@@ -236,7 +248,7 @@ def _create_template_from_node(env, node, seen):
'process_type_id': node.process_type_id.id,
'requires_signoff': node.requires_signoff,
'requires_predecessor_done': node.requires_predecessor_done,
'default_kind': kind,
'kind_id': _resolve_kind_id(env, kind),
}
# Snapshot tank_ids if the node has them (added by Sub 12a;
# existing nodes may not).
@@ -275,7 +287,10 @@ def _seed_minimal_library(env):
('Shipping', 'ship'),
]
for name, kind in minimal:
tpl = Tpl.create({'name': name, 'default_kind': kind})
tpl = Tpl.create({
'name': name,
'kind_id': _resolve_kind_id(env, kind),
})
tpl.action_seed_default_inputs()
_logger.info(
'Fusion Plating: seeded minimal step library (%s entries)',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.18.12.4',
'version': '19.0.18.13.8',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -98,6 +98,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml',
'views/fp_bath_views.xml',
'views/fp_process_node_views.xml',
# Sub 14b — fp.step.kind catalog. MUST load before
# fp_step_template_data.xml (templates reference kinds via
# kind_id) AND before fp_step_template_views.xml (the form
# references the kind action menu).
'views/fp_step_kind_views.xml',
'data/fp_step_kind_data.xml',
'views/fp_step_template_views.xml',
'views/fp_rack_tag_views.xml',
'views/fp_job_step_move_views.xml',
@@ -128,10 +134,14 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
'fusion_plating/static/src/scss/simple_recipe_editor.scss',
# Sub 14b — visual icon picker for fp.step.kind etc.
'fusion_plating/static/src/scss/fp_icon_picker.scss',
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
'fusion_plating/static/src/xml/simple_recipe_editor.xml',
'fusion_plating/static/src/xml/fp_icon_picker.xml',
'fusion_plating/static/src/js/recipe_tree_editor.js',
'fusion_plating/static/src/js/simple_recipe_editor.js',
'fusion_plating/static/src/js/fp_icon_picker.js',
],
},
'demo': [

View File

@@ -26,7 +26,7 @@ _SNAPSHOT_FIELDS = [
'parallel_start',
'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger
'requires_rack_assignment', 'requires_transition_form',
'default_kind',
'kind_id', # Sub 14b — replaces default_kind (now a related Char)
]
# Fields on fp.step.template.input that copy 1:1 into
@@ -90,6 +90,8 @@ class SimpleRecipeController(http.Controller):
'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,
@@ -150,6 +152,8 @@ class SimpleRecipeController(http.Controller):
'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
@@ -201,6 +205,8 @@ class SimpleRecipeController(http.Controller):
'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,
@@ -245,8 +251,11 @@ class SimpleRecipeController(http.Controller):
"""
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', 'default_kind', 'description',
'name', 'code', 'icon', 'kind_id', 'description',
'requires_signoff', 'requires_predecessor_done',
'parallel_start',
'triggers_workflow_state_id', # Sub 14
@@ -254,6 +263,11 @@ class SimpleRecipeController(http.Controller):
'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:
@@ -266,6 +280,15 @@ class SimpleRecipeController(http.Controller):
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 —
@@ -340,6 +363,55 @@ class SimpleRecipeController(http.Controller):
],
}
@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."""
Kind = request.env['fp.step.kind']
return {
'kinds': [
{
'id': k.id,
'code': k.code or '',
'name': k.name or '',
'icon': k.icon or '',
'sequence': k.sequence,
}
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=''):
"""Sub 14b — Inline create for "+ New kind…" in the library
form. Auto-derives a code from the name if blank."""
Kind = request.env['fp.step.kind']
if not name or not name.strip():
return {'ok': False, 'error': 'name_required'}
# check_access via create attempt — supervisors+ allowed (ACL).
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):
@@ -457,7 +529,7 @@ class SimpleRecipeController(http.Controller):
node.check_access('write')
allowed = {
'name', 'description', 'icon',
'default_kind',
'kind_id', # Sub 14b — replaces default_kind
'requires_signoff', 'requires_predecessor_done',
'parallel_start', # Sub 13
'triggers_workflow_state_id', # Sub 14
@@ -467,6 +539,11 @@ class SimpleRecipeController(http.Controller):
'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}

View File

@@ -0,0 +1,928 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- 24 seeded Step Kinds — XML IDs use the original Selection
keys so post-migrate can map old default_kind = 'cleaning'
to env.ref('fusion_plating.step_kind_cleaning').
noupdate=1 so user edits to defaults survive `-u`. -->
<record id="step_kind_receiving" model="fp.step.kind">
<field name="code">receiving</field>
<field name="name">Receiving / Incoming Inspection</field>
<field name="sequence">10</field>
<field name="icon">fa-truck</field>
</record>
<record id="step_kind_contract_review" model="fp.step.kind">
<field name="code">contract_review</field>
<field name="name">Contract Review (QA-005)</field>
<field name="sequence">20</field>
<field name="icon">fa-file-text-o</field>
</record>
<record id="step_kind_racking" model="fp.step.kind">
<field name="code">racking</field>
<field name="name">Racking</field>
<field name="sequence">30</field>
<field name="icon">fa-server</field>
</record>
<record id="step_kind_mask" model="fp.step.kind">
<field name="code">mask</field>
<field name="name">Masking</field>
<field name="sequence">40</field>
<field name="icon">fa-eye-slash</field>
</record>
<record id="step_kind_cleaning" model="fp.step.kind">
<field name="code">cleaning</field>
<field name="name">Cleaning</field>
<field name="sequence">50</field>
<field name="icon">fa-tint</field>
</record>
<record id="step_kind_electroclean" model="fp.step.kind">
<field name="code">electroclean</field>
<field name="name">Electroclean</field>
<field name="sequence">60</field>
<field name="icon">fa-bolt</field>
</record>
<record id="step_kind_etch" model="fp.step.kind">
<field name="code">etch</field>
<field name="name">Etch / Activation</field>
<field name="sequence">70</field>
<field name="icon">fa-flask</field>
</record>
<record id="step_kind_rinse" model="fp.step.kind">
<field name="code">rinse</field>
<field name="name">Rinse</field>
<field name="sequence">80</field>
<field name="icon">fa-tint</field>
</record>
<record id="step_kind_strike" model="fp.step.kind">
<field name="code">strike</field>
<field name="name">Strike (Wood's Nickel / Activation)</field>
<field name="sequence">90</field>
<field name="icon">fa-bolt</field>
</record>
<record id="step_kind_plate" model="fp.step.kind">
<field name="code">plate</field>
<field name="name">Plating</field>
<field name="sequence">100</field>
<field name="icon">fa-shield</field>
</record>
<record id="step_kind_replenishment" model="fp.step.kind">
<field name="code">replenishment</field>
<field name="name">Tank Replenishment</field>
<field name="sequence">110</field>
<field name="icon">fa-plus-circle</field>
</record>
<record id="step_kind_wbf_test" model="fp.step.kind">
<field name="code">wbf_test</field>
<field name="name">Water Break Free Test</field>
<field name="sequence">120</field>
<field name="icon">fa-check-square-o</field>
</record>
<record id="step_kind_dry" model="fp.step.kind">
<field name="code">dry</field>
<field name="name">Drying</field>
<field name="sequence">130</field>
<field name="icon">fa-sun-o</field>
</record>
<record id="step_kind_bake" model="fp.step.kind">
<field name="code">bake</field>
<field name="name">Bake (HE Relief / Stress Relief)</field>
<field name="sequence">140</field>
<field name="icon">fa-fire</field>
</record>
<record id="step_kind_demask" model="fp.step.kind">
<field name="code">demask</field>
<field name="name">De-Masking</field>
<field name="sequence">150</field>
<field name="icon">fa-eye</field>
</record>
<record id="step_kind_derack" model="fp.step.kind">
<field name="code">derack</field>
<field name="name">De-Racking</field>
<field name="sequence">160</field>
<field name="icon">fa-server</field>
</record>
<record id="step_kind_inspect" model="fp.step.kind">
<field name="code">inspect</field>
<field name="name">Inspection</field>
<field name="sequence">170</field>
<field name="icon">fa-search</field>
</record>
<record id="step_kind_hardness_test" model="fp.step.kind">
<field name="code">hardness_test</field>
<field name="name">Hardness Test (HV / HK / HRC)</field>
<field name="sequence">180</field>
<field name="icon">fa-tachometer</field>
</record>
<record id="step_kind_adhesion_test" model="fp.step.kind">
<field name="code">adhesion_test</field>
<field name="name">Adhesion Test</field>
<field name="sequence">190</field>
<field name="icon">fa-link</field>
</record>
<record id="step_kind_salt_spray" model="fp.step.kind">
<field name="code">salt_spray</field>
<field name="name">Salt Spray / Corrosion Test</field>
<field name="sequence">200</field>
<field name="icon">fa-cloud</field>
</record>
<record id="step_kind_final_inspect" model="fp.step.kind">
<field name="code">final_inspect</field>
<field name="name">Final Inspection</field>
<field name="sequence">210</field>
<field name="icon">fa-check-circle</field>
</record>
<record id="step_kind_packaging" model="fp.step.kind">
<field name="code">packaging</field>
<field name="name">Packaging / Pre-Ship</field>
<field name="sequence">220</field>
<field name="icon">fa-archive</field>
</record>
<record id="step_kind_ship" model="fp.step.kind">
<field name="code">ship</field>
<field name="name">Shipping</field>
<field name="sequence">230</field>
<field name="icon">fa-paper-plane</field>
</record>
<record id="step_kind_gating" model="fp.step.kind">
<field name="code">gating</field>
<field name="name">Gating</field>
<field name="sequence">240</field>
<field name="icon">fa-pause-circle</field>
</record>
<!-- ============================================================
Default inputs per kind — 1:1 port of DEFAULT_INPUTS_BY_KIND
============================================================ -->
<!-- receiving -->
<record id="step_kind_input_receiving_qty_received" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Qty Received</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">10</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_receiving_qty_rejected" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Qty Rejected</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_receiving_po_verified" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Customer PO# Verified</field>
<field name="input_type">boolean</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_receiving_packing_slip" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Packing Slip #</field>
<field name="input_type">text</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_receiving_condition" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Condition Notes</field>
<field name="input_type">text</field>
<field name="sequence">50</field>
</record>
<record id="step_kind_input_receiving_damage_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Damage Photo</field>
<field name="input_type">photo</field>
<field name="sequence">60</field>
</record>
<record id="step_kind_input_receiving_inspector_init" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_receiving"/>
<field name="name">Inspector Initials</field>
<field name="input_type">signature</field>
<field name="sequence">70</field>
<field name="required">True</field>
</record>
<!-- cleaning -->
<record id="step_kind_input_cleaning_actual_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_cleaning"/>
<field name="name">Actual Time</field>
<field name="input_type">time_seconds</field>
<field name="target_unit">s</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_cleaning_actual_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_cleaning"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_cleaning_bath_id" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_cleaning"/>
<field name="name">Bath ID</field>
<field name="input_type">text</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_cleaning_us_on" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_cleaning"/>
<field name="name">Ultrasonic On</field>
<field name="input_type">boolean</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_cleaning_titration" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_cleaning"/>
<field name="name">Titration Done</field>
<field name="input_type">boolean</field>
<field name="sequence">50</field>
</record>
<!-- electroclean -->
<record id="step_kind_input_eclean_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Actual Time</field>
<field name="input_type">time_seconds</field>
<field name="target_unit">s</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_eclean_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_eclean_amp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Amperage</field>
<field name="input_type">number</field>
<field name="hint">A</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_eclean_volt" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Voltage</field>
<field name="input_type">number</field>
<field name="hint">V</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_eclean_cd" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Current Density</field>
<field name="input_type">number</field>
<field name="hint">ASF (A per sq ft)</field>
<field name="sequence">50</field>
</record>
<record id="step_kind_input_eclean_pol" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Polarity</field>
<field name="input_type">selection</field>
<field name="selection_options">anodic,cathodic,periodic</field>
<field name="sequence">60</field>
</record>
<record id="step_kind_input_eclean_bath" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="name">Bath ID</field>
<field name="input_type">text</field>
<field name="sequence">70</field>
</record>
<!-- etch -->
<record id="step_kind_input_etch_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_etch"/>
<field name="name">Actual Time</field>
<field name="input_type">time_seconds</field>
<field name="target_unit">s</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_etch_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_etch"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_etch_acid" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_etch"/>
<field name="name">Acid Concentration</field>
<field name="input_type">number</field>
<field name="hint">% or g/L</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_etch_bath" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_etch"/>
<field name="name">Bath ID</field>
<field name="input_type">text</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_etch_he" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_etch"/>
<field name="name">HE Risk Flag</field>
<field name="input_type">boolean</field>
<field name="hint">Hydrogen Embrittlement risk for high-strength steel</field>
<field name="sequence">50</field>
</record>
<!-- rinse -->
<record id="step_kind_input_rinse_type" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_rinse"/>
<field name="name">Rinse Type</field>
<field name="input_type">selection</field>
<field name="selection_options">cascade,spray,DI,city</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_rinse_cond" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_rinse"/>
<field name="name">Conductivity</field>
<field name="input_type">number</field>
<field name="hint">µS/cm — required for DI rinses</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_rinse_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_rinse"/>
<field name="name">Actual Time</field>
<field name="input_type">time_seconds</field>
<field name="target_unit">s</field>
<field name="sequence">30</field>
</record>
<!-- strike -->
<record id="step_kind_input_strike_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_strike"/>
<field name="name">Actual Time</field>
<field name="input_type">time_seconds</field>
<field name="target_unit">s</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_strike_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_strike"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_strike_amp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_strike"/>
<field name="name">Amperage</field>
<field name="input_type">number</field>
<field name="hint">A</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_strike_volt" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_strike"/>
<field name="name">Voltage</field>
<field name="input_type">number</field>
<field name="hint">V</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_strike_cd" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_strike"/>
<field name="name">Current Density</field>
<field name="input_type">number</field>
<field name="hint">ASF</field>
<field name="sequence">50</field>
</record>
<record id="step_kind_input_strike_bath" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_strike"/>
<field name="name">Bath ID</field>
<field name="input_type">text</field>
<field name="sequence">60</field>
</record>
<!-- plate -->
<record id="step_kind_input_plate_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">Actual Time</field>
<field name="input_type">time_hms</field>
<field name="target_unit">min</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_plate_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_plate_bath" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">Bath ID</field>
<field name="input_type">text</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_plate_ph" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">pH</field>
<field name="input_type">ph</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_plate_conc" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">Bath Concentration</field>
<field name="input_type">number</field>
<field name="hint">g/L</field>
<field name="sequence">50</field>
</record>
<record id="step_kind_input_plate_cd" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">Current Density</field>
<field name="input_type">number</field>
<field name="hint">ASF — electroplate only</field>
<field name="sequence">60</field>
</record>
<record id="step_kind_input_plate_thick" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_plate"/>
<field name="name">Plating Thickness</field>
<field name="input_type">multi_point_thickness</field>
<field name="target_unit">in</field>
<field name="sequence">70</field>
</record>
<!-- replenishment -->
<record id="step_kind_input_replen_bath" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">Bath ID</field>
<field name="input_type">text</field>
<field name="sequence">10</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_replen_chem" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">Chemistry Added</field>
<field name="input_type">text</field>
<field name="hint">name + amount, e.g. "Nickel sulfamate 500mL"</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_replen_ph_b" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">pH Before</field>
<field name="input_type">ph</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_replen_ph_a" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">pH After</field>
<field name="input_type">ph</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_replen_conc_b" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">Concentration Before</field>
<field name="input_type">number</field>
<field name="sequence">50</field>
</record>
<record id="step_kind_input_replen_conc_a" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">Concentration After</field>
<field name="input_type">number</field>
<field name="sequence">60</field>
</record>
<record id="step_kind_input_replen_op" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="name">Operator Initials</field>
<field name="input_type">signature</field>
<field name="sequence">70</field>
<field name="required">True</field>
</record>
<!-- wbf_test -->
<record id="step_kind_input_wbf_result" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_wbf_test"/>
<field name="name">Result</field>
<field name="input_type">pass_fail</field>
<field name="sequence">10</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_wbf_retest" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_wbf_test"/>
<field name="name">Retest Count</field>
<field name="input_type">number</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_wbf_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_wbf_test"/>
<field name="name">Photo on FAIL</field>
<field name="input_type">photo</field>
<field name="sequence">30</field>
</record>
<!-- dry -->
<record id="step_kind_input_dry_method" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_dry"/>
<field name="name">Dry Method</field>
<field name="input_type">selection</field>
<field name="selection_options">hot air,oven,spin</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_dry_time" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_dry"/>
<field name="name">Actual Time</field>
<field name="input_type">time_seconds</field>
<field name="target_unit">s</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_dry_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_dry"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">30</field>
</record>
<!-- bake -->
<record id="step_kind_input_bake_in" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_bake"/>
<field name="name">Time In</field>
<field name="input_type">date</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_bake_out" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_bake"/>
<field name="name">Time Out</field>
<field name="input_type">date</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_bake_temp" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_bake"/>
<field name="name">Actual Temperature</field>
<field name="input_type">temperature</field>
<field name="target_unit">f</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_bake_oven" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_bake"/>
<field name="name">Oven ID</field>
<field name="input_type">text</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_bake_chart" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_bake"/>
<field name="name">Chart Recorder File</field>
<field name="input_type">photo</field>
<field name="hint">Attach AMS-2759 chart-recorder file</field>
<field name="sequence">50</field>
</record>
<!-- racking -->
<record id="step_kind_input_racking_qty" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_racking"/>
<field name="name">Actual Qty</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">10</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_racking_rack" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_racking"/>
<field name="name">Rack ID</field>
<field name="input_type">text</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_racking_mask" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_racking"/>
<field name="name">Masking Applied</field>
<field name="input_type">boolean</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_racking_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_racking"/>
<field name="name">Photo of Racked Load</field>
<field name="input_type">photo</field>
<field name="sequence">40</field>
</record>
<!-- derack -->
<record id="step_kind_input_derack_qty" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_derack"/>
<field name="name">Actual Qty</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_derack_method" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_derack"/>
<field name="name">Mask Removal Method</field>
<field name="input_type">selection</field>
<field name="selection_options">mechanical,solvent,thermal,not applicable</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_derack_residue" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_derack"/>
<field name="name">Residue Check</field>
<field name="input_type">pass_fail</field>
<field name="sequence">30</field>
</record>
<!-- mask -->
<record id="step_kind_input_mask_qty" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_mask"/>
<field name="name">Actual Qty</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_mask_material" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_mask"/>
<field name="name">Mask Material</field>
<field name="input_type">selection</field>
<field name="selection_options">Microshield,latex tape,vinyl plugs,wax,other</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_mask_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_mask"/>
<field name="name">Photo of Masked Parts</field>
<field name="input_type">photo</field>
<field name="sequence">30</field>
</record>
<!-- demask -->
<record id="step_kind_input_demask_residue" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_demask"/>
<field name="name">Residue Check</field>
<field name="input_type">pass_fail</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_demask_surface" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_demask"/>
<field name="name">Surface Condition</field>
<field name="input_type">selection</field>
<field name="selection_options">clean,marks,needs rework</field>
<field name="sequence">20</field>
</record>
<!-- inspect -->
<record id="step_kind_input_inspect_result" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_inspect"/>
<field name="name">Result</field>
<field name="input_type">pass_fail</field>
<field name="sequence">10</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_inspect_defect" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_inspect"/>
<field name="name">Defect Type</field>
<field name="input_type">selection</field>
<field name="selection_options">pitting,burn,blister,peel,missing coverage,none</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_inspect_thick" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_inspect"/>
<field name="name">Thickness Sample</field>
<field name="input_type">thickness</field>
<field name="target_unit">in</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_inspect_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_inspect"/>
<field name="name">Photo</field>
<field name="input_type">photo</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_inspect_sig" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_inspect"/>
<field name="name">Inspector Signature</field>
<field name="input_type">signature</field>
<field name="sequence">50</field>
</record>
<!-- hardness_test -->
<record id="step_kind_input_hard_load" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_hardness_test"/>
<field name="name">Test Load</field>
<field name="input_type">number</field>
<field name="hint">gf</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_hard_readings" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_hardness_test"/>
<field name="name">Readings (HV/HK/HRC)</field>
<field name="input_type">multi_point_thickness</field>
<field name="hint">Three indents minimum</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_hard_eq" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_hardness_test"/>
<field name="name">Equipment ID</field>
<field name="input_type">text</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_hard_cal" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_hardness_test"/>
<field name="name">Last Calibration Date</field>
<field name="input_type">date</field>
<field name="sequence">40</field>
</record>
<!-- adhesion_test -->
<record id="step_kind_input_adh_method" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_adhesion_test"/>
<field name="name">Test Method</field>
<field name="input_type">selection</field>
<field name="selection_options">bend,tape,burnish,file</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_adh_result" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_adhesion_test"/>
<field name="name">Result</field>
<field name="input_type">pass_fail</field>
<field name="sequence">20</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_adh_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_adhesion_test"/>
<field name="name">Photo of Coupon</field>
<field name="input_type">photo</field>
<field name="sequence">30</field>
</record>
<!-- salt_spray -->
<record id="step_kind_input_salt_dur" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_salt_spray"/>
<field name="name">Test Duration</field>
<field name="input_type">number</field>
<field name="hint">hours</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_salt_result" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_salt_spray"/>
<field name="name">Result</field>
<field name="input_type">pass_fail</field>
<field name="sequence">20</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_salt_red" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_salt_spray"/>
<field name="name">Red Rust %</field>
<field name="input_type">number</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_salt_white" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_salt_spray"/>
<field name="name">White Corrosion %</field>
<field name="input_type">number</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_salt_lab" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_salt_spray"/>
<field name="name">Lab Report</field>
<field name="input_type">photo</field>
<field name="hint">Attach scanned lab report</field>
<field name="sequence">50</field>
</record>
<!-- final_inspect -->
<record id="step_kind_input_fin_count" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Outgoing Part Count Verified</field>
<field name="input_type">boolean</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_fin_qty_acc" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Qty Accepted</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_fin_qty_rej" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Qty Rejected</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_fin_defect" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Defect Categorization</field>
<field name="input_type">selection</field>
<field name="selection_options">pitting,burn,blister,peel,missing coverage,dimensional,none</field>
<field name="sequence">35</field>
</record>
<record id="step_kind_input_fin_thick" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Actual Coating Thickness</field>
<field name="input_type">multi_point_thickness</field>
<field name="target_unit">in</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_fin_dim" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Dimensional Verification</field>
<field name="input_type">pass_fail</field>
<field name="sequence">45</field>
</record>
<record id="step_kind_input_fin_ra" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Surface Finish (Ra)</field>
<field name="input_type">number</field>
<field name="hint">µin</field>
<field name="sequence">47</field>
</record>
<record id="step_kind_input_fin_pass" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Pass/Fail</field>
<field name="input_type">pass_fail</field>
<field name="sequence">50</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_fin_sig" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="name">Inspector Signature</field>
<field name="input_type">signature</field>
<field name="sequence">60</field>
</record>
<!-- packaging -->
<record id="step_kind_input_pkg_type" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_packaging"/>
<field name="name">Packaging Type</field>
<field name="input_type">selection</field>
<field name="selection_options">VCI bag,bubble wrap,separator paper,custom crate,other</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_pkg_qty" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_packaging"/>
<field name="name">Qty Per Package</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_pkg_count" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_packaging"/>
<field name="name">Package Count</field>
<field name="input_type">number</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_pkg_cert" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_packaging"/>
<field name="name">Cert Package Included</field>
<field name="input_type">boolean</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_pkg_cust" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_packaging"/>
<field name="name">Customer-Supplied Packaging</field>
<field name="input_type">boolean</field>
<field name="sequence">50</field>
</record>
<!-- ship -->
<record id="step_kind_input_ship_qty" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_ship"/>
<field name="name">Outgoing Qty</field>
<field name="input_type">number</field>
<field name="target_unit">each</field>
<field name="sequence">10</field>
<field name="required">True</field>
</record>
<record id="step_kind_input_ship_carrier" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_ship"/>
<field name="name">Carrier</field>
<field name="input_type">selection</field>
<field name="selection_options">UPS,FedEx,Purolator,Customer Pickup,Other</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_ship_track" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_ship"/>
<field name="name">Tracking #</field>
<field name="input_type">text</field>
<field name="sequence">30</field>
</record>
<record id="step_kind_input_ship_bol" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_ship"/>
<field name="name">BoL #</field>
<field name="input_type">text</field>
<field name="sequence">40</field>
</record>
<record id="step_kind_input_ship_photo" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_ship"/>
<field name="name">Photo of Sealed Shipment</field>
<field name="input_type">photo</field>
<field name="sequence">50</field>
</record>
<!-- contract_review -->
<record id="step_kind_input_cr_init" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_contract_review"/>
<field name="name">Reviewer Initials</field>
<field name="input_type">signature</field>
<field name="sequence">10</field>
</record>
<record id="step_kind_input_cr_date" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_contract_review"/>
<field name="name">Date Reviewed</field>
<field name="input_type">date</field>
<field name="sequence">20</field>
</record>
<record id="step_kind_input_cr_qa" model="fp.step.kind.default.input">
<field name="kind_id" ref="step_kind_contract_review"/>
<field name="name">QA-005 Approved</field>
<field name="input_type">pass_fail</field>
<field name="sequence">30</field>
</record>
<!-- gating: intentionally no default inputs -->
</data>
</odoo>

View File

@@ -13,7 +13,7 @@
<record id="fp_step_template_receiving_std" model="fp.step.template">
<field name="name">Incoming Inspection (Standard)</field>
<field name="code">RECV_STD</field>
<field name="default_kind">receiving</field>
<field name="kind_id" ref="step_kind_receiving"/>
<field name="icon">fa-inbox</field>
<field name="description"><![CDATA[
<p>Verify quantity received against packing slip. Visually inspect
@@ -25,7 +25,7 @@
<record id="fp_step_template_electroclean_std" model="fp.step.template">
<field name="name">Electroclean (Standard)</field>
<field name="code">ELEC_CLEAN_STD</field>
<field name="default_kind">electroclean</field>
<field name="kind_id" ref="step_kind_electroclean"/>
<field name="icon">fa-bolt</field>
<field name="description"><![CDATA[
<p>Submerge rack and energise. Record actual amperage, voltage,
@@ -36,7 +36,7 @@
<record id="fp_step_template_strike_std" model="fp.step.template">
<field name="name">Wood's Nickel Strike (Standard)</field>
<field name="code">STRIKE_STD</field>
<field name="default_kind">strike</field>
<field name="kind_id" ref="step_kind_strike"/>
<field name="icon">fa-flash</field>
<field name="description"><![CDATA[
<p>Apply thin nickel strike to ensure adhesion before main plate.
@@ -47,7 +47,7 @@
<record id="fp_step_template_salt_spray_std" model="fp.step.template">
<field name="name">Salt Spray Test (ASTM B117)</field>
<field name="code">SALT_SPRAY_STD</field>
<field name="default_kind">salt_spray</field>
<field name="kind_id" ref="step_kind_salt_spray"/>
<field name="icon">fa-tint</field>
<field name="description"><![CDATA[
<p>Submit test panel to salt spray cabinet for the specified
@@ -59,7 +59,7 @@
<record id="fp_step_template_adhesion_std" model="fp.step.template">
<field name="name">Adhesion Test (Bend / Tape)</field>
<field name="code">ADHESION_STD</field>
<field name="default_kind">adhesion_test</field>
<field name="kind_id" ref="step_kind_adhesion_test"/>
<field name="icon">fa-link</field>
<field name="description"><![CDATA[
<p>Perform adhesion test per spec (bend, tape, burnish, or file).
@@ -70,7 +70,7 @@
<record id="fp_step_template_hardness_std" model="fp.step.template">
<field name="name">Microhardness Test</field>
<field name="code">HARDNESS_STD</field>
<field name="default_kind">hardness_test</field>
<field name="kind_id" ref="step_kind_hardness_test"/>
<field name="icon">fa-cube</field>
<field name="description"><![CDATA[
<p>Take three indentations minimum on the test coupon. Record
@@ -82,7 +82,7 @@
<record id="fp_step_template_packaging_std" model="fp.step.template">
<field name="name">Packaging (Standard)</field>
<field name="code">PKG_STD</field>
<field name="default_kind">packaging</field>
<field name="kind_id" ref="step_kind_packaging"/>
<field name="icon">fa-archive</field>
<field name="description"><![CDATA[
<p>Wrap parts per customer spec (VCI bag, bubble wrap, separator
@@ -94,7 +94,7 @@
<record id="fp_step_template_replenishment_std" model="fp.step.template">
<field name="name">Tank Replenishment</field>
<field name="code">REPL_STD</field>
<field name="default_kind">replenishment</field>
<field name="kind_id" ref="step_kind_replenishment"/>
<field name="icon">fa-flask</field>
<field name="description"><![CDATA[
<p>Mid-shift bath top-up. Record bath ID, chemistry added (name

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.18.13.0 — Backfill kind_id from legacy default_kind text values.
Sub 14b — `default_kind` was a Selection on fp.step.template and
fusion.plating.process.node. It is now a stored related Char that
reads from kind_id.code on a new fp.step.kind catalog.
When a Selection field is converted into a Many2one in code, Odoo's
ORM does NOT auto-migrate the data — the new column starts empty. This
script reads the (still-present) text values out of the OLD `default_kind`
column and points kind_id at the seeded fp.step.kind record whose code
matches.
Idempotent — running it twice is a no-op.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Run by the Odoo migration framework on -u to 19.0.18.13.0."""
# ---- Build code → kind_id map from seeded data --------------------
cr.execute("SELECT id, code FROM fp_step_kind")
code_to_id = {code: kid for kid, code in cr.fetchall()}
if not code_to_id:
_logger.warning(
'19.0.18.13.0: fp_step_kind is empty — seed data did not '
'load before post-migrate. Skipping backfill.',
)
return
# ---- 1. fp.step.template.kind_id ----------------------------------
_backfill(
cr,
table='fp_step_template',
text_col='x_default_kind_legacy',
m2o_col='kind_id',
code_to_id=code_to_id,
)
# ---- 2. fusion.plating.process.node.kind_id -----------------------
_backfill(
cr,
table='fusion_plating_process_node',
text_col='x_default_kind_legacy',
m2o_col='kind_id',
code_to_id=code_to_id,
)
# ---- 3. Force recompute of stored related default_kind --------------
# Now that kind_id is populated, the related Char will read the right
# value on next access. Trigger an explicit recompute via SQL so the
# column reflects current state immediately (faster than waiting for
# ORM to do it lazily on each row).
for table in ('fp_step_template', 'fusion_plating_process_node'):
cr.execute(f"""
UPDATE {table} t
SET default_kind = k.code
FROM fp_step_kind k
WHERE t.kind_id = k.id
""")
_logger.info(
'19.0.18.13.0: synced default_kind from kind_id.code on '
'%s rows of %s', cr.rowcount, table,
)
def _backfill(cr, table, text_col, m2o_col, code_to_id):
"""Generic backfill helper. Reads `text_col` text values, updates
`m2o_col` per row using the supplied code→id lookup table.
"""
# Defensive — if either column is missing (fresh install, never had
# default_kind data) skip silently.
cr.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = %s AND column_name IN (%s, %s)
""", (table, text_col, m2o_col))
cols = {row[0] for row in cr.fetchall()}
if text_col not in cols or m2o_col not in cols:
_logger.info(
'19.0.18.13.0: %s missing %s or %s, skipping backfill',
table, text_col, m2o_col,
)
return
# Pull every row with a non-null default_kind that doesn't yet have
# a kind_id — leaves manually-edited rows alone if migration is
# rerun.
cr.execute(f"""
SELECT id, {text_col} FROM {table}
WHERE {text_col} IS NOT NULL AND {m2o_col} IS NULL
""")
rows = cr.fetchall()
if not rows:
_logger.info(
'19.0.18.13.0: %s has no rows needing backfill', table,
)
return
updated = 0
skipped = 0
by_kind = {}
for rec_id, code in rows:
kid = code_to_id.get(code)
if not kid:
skipped += 1
continue
by_kind.setdefault(kid, []).append(rec_id)
for kid, ids in by_kind.items():
cr.execute(
f"UPDATE {table} SET {m2o_col} = %s WHERE id = ANY(%s)",
(kid, ids),
)
updated += len(ids)
_logger.info(
'19.0.18.13.0: backfilled %s.%s on %s rows (%s skipped — code '
'not found in fp_step_kind seed data)',
table, m2o_col, updated, skipped,
)

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.18.13.0 — Snapshot legacy default_kind values BEFORE the field
type change wipes them.
Sub 14b — `default_kind` was a Selection on fp.step.template and
fusion.plating.process.node. The new model code defines it as a
stored related Char (`related='kind_id.code', store=True`). On first
ORM access after upgrade, Odoo recomputes the stored related from
kind_id (which is NULL → wipes the column). We must snapshot the
original text values now, so post-migrate can read them and resolve
kind_id.
Idempotent — running it twice is a no-op (snapshot column gets
re-written with the same data).
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
for table in ('fp_step_template', 'fusion_plating_process_node'):
_snapshot(cr, table)
def _snapshot(cr, table):
cr.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = %s AND column_name = 'default_kind'
""", (table,))
if not cr.fetchone():
_logger.info(
'19.0.18.13.0/pre: %s has no default_kind column, skip',
table,
)
return
# Add the snapshot column if missing.
cr.execute(f"""
ALTER TABLE {table}
ADD COLUMN IF NOT EXISTS x_default_kind_legacy VARCHAR
""")
# Copy the live data.
cr.execute(f"""
UPDATE {table}
SET x_default_kind_legacy = default_kind
WHERE default_kind IS NOT NULL
""")
_logger.info(
'19.0.18.13.0/pre: snapshotted %s rows from %s.default_kind '
'to x_default_kind_legacy',
cr.rowcount, table,
)

View File

@@ -36,6 +36,7 @@ from . import hr_employee
from . import fp_process_node_inherit
# Sub 12a — Simple Recipe Editor + Step Library
from . import fp_step_kind # MUST load before fp_step_template (dependency)
from . import fp_step_template
from . import fp_step_template_input
from . import fp_step_template_transition_input

View File

@@ -373,34 +373,16 @@ class FpProcessNode(models.Model):
string='Requires Transition Form',
help='Sub 12b — opens the transition form before Mark Done.',
)
default_kind = fields.Selection(
[
('receiving', 'Receiving / Incoming Inspection'),
('contract_review', 'Contract Review (QA-005)'),
('racking', 'Racking'),
('mask', 'Masking'),
('cleaning', 'Cleaning'),
('electroclean', 'Electroclean'),
('etch', 'Etch / Activation'),
('rinse', 'Rinse'),
('strike', 'Strike (Wood\'s Nickel / Activation)'),
('plate', 'Plating'),
('replenishment', 'Tank Replenishment'),
('wbf_test', 'Water Break Free Test'),
('dry', 'Drying'),
('bake', 'Bake (HE Relief / Stress Relief)'),
('demask', 'De-Masking'),
('derack', 'De-Racking'),
('inspect', 'Inspection'),
('hardness_test', 'Hardness Test (HV / HK / HRC)'),
('adhesion_test', 'Adhesion Test'),
('salt_spray', 'Salt Spray / Corrosion Test'),
('final_inspect', 'Final Inspection'),
('packaging', 'Packaging / Pre-Ship'),
('ship', 'Shipping'),
('gating', 'Gating'),
],
string='Step Kind',
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='set null', index=True,
help='Pick from the catalog or create a new kind.',
)
# Back-compat: code-string accessor that all legacy
# `node.default_kind == "cleaning"` comparisons keep using.
default_kind = fields.Char(
related='kind_id.code', store=True, readonly=True, index=True,
string='Step Kind Code',
)
preferred_editor = fields.Selection(
[

View File

@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpStepKind(models.Model):
"""User-extensible Step Kind catalog.
Replaces the hardcoded `default_kind` Selection on fp.step.template
and fusion.plating.process.node. Each kind carries a list of default
inputs that get seeded onto a step template when the kind is picked.
"""
_name = 'fp.step.kind'
_description = 'Fusion Plating — Step Kind'
_order = 'sequence, name'
code = fields.Char(
string='Code', required=True, index=True,
help='Stable lowercase technical key. Used by automation rules and '
'workflow-state triggers (e.g. "cleaning", "plate"). Lower-case'
' enforced; underscores allowed.',
)
name = fields.Char(string='Name', required=True, translate=True)
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
description = fields.Html(string='Description')
icon = fields.Selection(
selection='_get_icon_selection',
string='Icon',
default='fa-cog',
)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
default_input_ids = fields.One2many(
'fp.step.kind.default.input', 'kind_id',
string='Default Inputs', copy=True,
help='Auto-seeded onto a step template when this kind is picked '
'(via the "Seed Defaults" action).',
)
template_count = fields.Integer(
string='Templates', compute='_compute_template_count')
_sql_constraints = [
('fp_step_kind_code_company_uniq',
'unique(code, company_id)',
'Step kind code must be unique within a company.'),
]
# Curated FontAwesome 4 icon catalog for the visual icon picker.
# Bigger than the 24 historical fp.process.node list — covers
# manufacturing, lab, quality, shipping, safety, time, status etc.
# FA4 ships with Odoo (no extra deps). Key = CSS class, Value = label.
_ICON_SELECTION = [
# Process / chemistry
('fa-flask', 'Flask / Chemistry'),
('fa-tint', 'Drop / Liquid'),
('fa-tachometer', 'Gauge / Measure'),
('fa-thermometer-half', 'Temp / Heat'),
('fa-fire', 'Fire / Bake'),
('fa-snowflake-o', 'Cold / Freeze'),
('fa-bolt', 'Bolt / Electric'),
('fa-plug', 'Plug / Power'),
('fa-magnet', 'Magnet'),
('fa-bullseye', 'Target / Blast'),
('fa-shower', 'Shower / Clean'),
('fa-bathtub', 'Bathtub / Soak'),
('fa-tachometer', 'Tachometer'),
# Equipment / shop
('fa-industry', 'Industry / Line'),
('fa-wrench', 'Wrench / Operation'),
('fa-cog', 'Gear / General'),
('fa-cogs', 'Gears / System'),
('fa-sitemap', 'Sitemap / Process'),
('fa-cubes', 'Cubes / Batch'),
('fa-cube', 'Cube / Part'),
('fa-th', 'Grid / Racking'),
('fa-th-large', 'Large Grid'),
('fa-server', 'Server / Rack'),
('fa-database', 'Database / Tank'),
('fa-archive', 'Archive / Box'),
('fa-recycle', 'Recycle / Reuse'),
('fa-balance-scale', 'Scale / Balance'),
# Masking / surface
('fa-paint-brush', 'Paint / Masking'),
('fa-eraser', 'Eraser / De-Masking'),
('fa-shield', 'Shield / Protect'),
('fa-diamond', 'Diamond / Plating'),
('fa-circle-o-notch', 'Coating Layer'),
# Inspection / quality
('fa-search', 'Search / Inspect'),
('fa-search-plus', 'Search Plus'),
('fa-eye', 'Eye / Visual'),
('fa-eye-slash', 'Eye Slash / Hidden'),
('fa-camera', 'Camera / Photo'),
('fa-check', 'Check'),
('fa-check-circle', 'Check / Approve'),
('fa-check-square-o', 'Checkbox'),
('fa-times', 'Reject / Cancel'),
('fa-times-circle', 'Reject Circle'),
('fa-exclamation-triangle', 'Warning'),
('fa-exclamation-circle', 'Alert'),
('fa-info-circle', 'Info'),
('fa-question-circle', 'Question'),
('fa-bug', 'Bug / Defect'),
('fa-flag', 'Flag'),
('fa-flag-checkered', 'Finish / Done'),
('fa-trophy', 'Trophy / Pass'),
('fa-thumbs-up', 'Thumbs Up'),
('fa-thumbs-down', 'Thumbs Down'),
('fa-star', 'Star'),
('fa-bookmark', 'Bookmark'),
('fa-certificate', 'Certificate'),
# Time
('fa-clock-o', 'Clock / Wait'),
('fa-hourglass-half', 'Hourglass'),
('fa-hourglass-end', 'Hourglass End'),
('fa-calendar', 'Calendar'),
('fa-calendar-check-o', 'Scheduled'),
('fa-history', 'History'),
# Safety / handling
('fa-hand-paper-o', 'Hand / Manual'),
('fa-hand-stop-o', 'Stop Hand'),
('fa-life-ring', 'Safety / Life Ring'),
('fa-medkit', 'First Aid'),
('fa-user-md', 'Inspector'),
('fa-lock', 'Lock / Hold'),
('fa-unlock', 'Unlock / Release'),
('fa-key', 'Key'),
# Documentation / certs
('fa-file-text-o', 'Document'),
('fa-file-pdf-o', 'PDF'),
('fa-file-image-o', 'Image File'),
('fa-clipboard', 'Clipboard'),
('fa-list-alt', 'Checklist'),
('fa-list-ul', 'List'),
('fa-tags', 'Tags'),
('fa-tag', 'Tag'),
('fa-barcode', 'Barcode'),
('fa-qrcode', 'QR Code'),
('fa-pencil', 'Pencil'),
('fa-edit', 'Edit'),
('fa-print', 'Print'),
('fa-paperclip', 'Attach'),
# Shipping / logistics
('fa-truck', 'Truck / Receiving'),
('fa-paper-plane', 'Ship / Send'),
('fa-plane', 'Plane / Airfreight'),
('fa-ship', 'Ship'),
('fa-shopping-cart', 'Cart'),
('fa-shopping-bag', 'Bag / Pack'),
('fa-gift', 'Gift / Package'),
('fa-suitcase', 'Suitcase'),
('fa-globe', 'Global'),
('fa-map-marker', 'Location'),
('fa-road', 'In Transit'),
# Status / process flow
('fa-play-circle', 'Start'),
('fa-pause-circle', 'Pause / Hold'),
('fa-stop-circle', 'Stop'),
('fa-step-forward', 'Step Forward'),
('fa-fast-forward', 'Fast Forward'),
('fa-refresh', 'Refresh / Repeat'),
('fa-undo', 'Undo / Rework'),
('fa-share', 'Hand-off'),
('fa-arrow-right', 'Arrow Right'),
('fa-arrow-down', 'Arrow Down'),
('fa-long-arrow-right', 'Long Arrow Right'),
('fa-random', 'Random / Mix'),
('fa-exchange', 'Exchange'),
('fa-sort-amount-asc', 'Sort Asc'),
('fa-sort-amount-desc', 'Sort Desc'),
('fa-tasks', 'Tasks'),
# Misc useful
('fa-sun-o', 'Sun / Dry'),
('fa-moon-o', 'Moon / Night'),
('fa-cloud', 'Cloud'),
('fa-leaf', 'Leaf / Eco'),
('fa-tree', 'Tree'),
('fa-bell', 'Bell / Alert'),
('fa-bullhorn', 'Announce'),
('fa-trash', 'Trash / Discard'),
('fa-plus-circle', 'Add'),
('fa-minus-circle', 'Remove'),
('fa-circle', 'Circle'),
('fa-square', 'Square'),
('fa-asterisk', 'Asterisk'),
('fa-cutlery', 'Cutlery / Bend'),
('fa-link', 'Link / Adhesion'),
('fa-chain-broken', 'Broken Chain'),
('fa-anchor', 'Anchor'),
('fa-ban', 'Ban / Forbidden'),
]
@api.model
def _get_icon_selection(self):
return self._ICON_SELECTION
@api.depends()
def _compute_template_count(self):
Tpl = self.env['fp.step.template']
for k in self:
k.template_count = Tpl.search_count([('kind_id', '=', k.id)])
@api.model_create_multi
def create(self, vals_list):
for v in vals_list:
if v.get('code'):
v['code'] = v['code'].lower().strip().replace(' ', '_')
return super().create(vals_list)
def write(self, vals):
if vals.get('code'):
vals['code'] = vals['code'].lower().strip().replace(' ', '_')
return super().write(vals)
def action_open_templates(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Step Templates — %s') % self.name,
'res_model': 'fp.step.template',
'view_mode': 'list,form',
'domain': [('kind_id', '=', self.id)],
'context': {'default_kind_id': self.id},
}
class FpStepKindDefaultInput(models.Model):
"""Default input prototype attached to a step kind.
When a recipe author picks a kind on a step template and clicks
'Seed Defaults', these get copied into the template's input list
(idempotent — skips by name).
"""
_name = 'fp.step.kind.default.input'
_description = 'Fusion Plating — Step Kind Default Input'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
kind_id = fields.Many2one(
'fp.step.kind', string='Kind',
required=True, ondelete='cascade', index=True,
)
input_type = fields.Selection([
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('photo', 'Photo'),
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
('bath_chemistry_panel', 'Bath Chemistry Panel'),
('ph', 'pH'),
], string='Input Type', required=True, default='text')
target_unit = fields.Selection(FP_UOM_SELECTION, string='Target Unit')
required = fields.Boolean(string='Required', default=False)
hint = fields.Char(string='Hint')
selection_options = fields.Text(string='Selection Options',
help='Comma-separated when input_type is "selection".')
sequence = fields.Integer(string='Sequence', default=10)

View File

@@ -88,32 +88,21 @@ class FpStepTemplate(models.Model):
requires_transition_form = fields.Boolean(string='Requires Transition Form',
help='Opens the transition form before Mark Done (Sub 12b).')
default_kind = fields.Selection([
('receiving', 'Receiving / Incoming Inspection'),
('contract_review', 'Contract Review (QA-005)'),
('racking', 'Racking'),
('mask', 'Masking'),
('cleaning', 'Cleaning'),
('electroclean', 'Electroclean'),
('etch', 'Etch / Activation'),
('rinse', 'Rinse'),
('strike', 'Strike (Wood\'s Nickel / Activation)'),
('plate', 'Plating'),
('replenishment', 'Tank Replenishment'),
('wbf_test', 'Water Break Free Test'),
('dry', 'Drying'),
('bake', 'Bake (HE Relief / Stress Relief)'),
('demask', 'De-Masking'),
('derack', 'De-Racking'),
('inspect', 'Inspection'),
('hardness_test', 'Hardness Test (HV / HK / HRC)'),
('adhesion_test', 'Adhesion Test'),
('salt_spray', 'Salt Spray / Corrosion Test'),
('final_inspect', 'Final Inspection'),
('packaging', 'Packaging / Pre-Ship'),
('ship', 'Shipping'),
('gating', 'Gating'),
], string='Step Kind', help='Drives sane-default input seeding.')
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='restrict',
index=True, tracking=True,
help='Pick from the catalog or create a new kind. Drives sane-'
'default input seeding.',
)
# Back-compat shim — every legacy `tpl.default_kind == "cleaning"`
# call site keeps working without a refactor. Stored=True so existing
# search domains [('default_kind', '=', 'cleaning')] still hit an
# indexed column.
default_kind = fields.Char(
related='kind_id.code', store=True, readonly=True, index=True,
string='Step Kind Code',
)
input_template_ids = fields.One2many(
'fp.step.template.input', 'template_id',
@@ -152,13 +141,11 @@ class FpStepTemplate(models.Model):
return super().write(vals)
# ----- Sane defaults seeding ---------------------------------------------
# NB target_unit must be a valid FP_UOM_SELECTION key — it became a
# Selection in 19.0.12.1.0 (uom cleanup). Free-text values like
# 'HH:MM', '°F', 'sec', 'in', 'each' raise ValueError on create.
# Mapping cheatsheet: sec → 's', °F → 'f', °C → 'c', in → 'in',
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
# left blank since they're not units.
# Sub 14b — moved from a Python dict into seeded fp.step.kind records
# so users can add new kinds + their default inputs through the
# standard UI. The dict below is preserved as a fallback only for
# codes that don't have a matching kind_id record (legacy data after
# migration). It will be removed in a future version.
DEFAULT_INPUTS_BY_KIND = {
'receiving': [
{'name': 'Qty Received', 'input_type': 'number',
@@ -419,19 +406,37 @@ class FpStepTemplate(models.Model):
)
return True
# Mapping from fp.step.kind.default.input fields → fp.step.template.input
# spec dict. Keep narrow — copy only the columns both models share.
_KIND_DEFAULT_INPUT_FIELDS = (
'name', 'input_type', 'target_unit', 'required',
'hint', 'selection_options', 'sequence',
)
def action_seed_default_inputs(self):
"""Seed input_template_ids based on default_kind. Idempotent —
only adds inputs whose names don't already exist on this template.
"""Seed input_template_ids from kind_id.default_input_ids.
Idempotent — only adds inputs whose names don't already exist on
this template.
Falls back to the legacy DEFAULT_INPUTS_BY_KIND dict if the
template has no kind_id but still carries a default_kind code
(defensive — shouldn't happen post-migration).
Public method (Odoo 19 requires non-underscore-prefixed names
for methods called from a view button).
"""
Input = self.env['fp.step.template.input']
for tpl in self:
if not tpl.default_kind:
continue
existing_names = set(tpl.input_template_ids.mapped('name'))
for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []):
specs = []
if tpl.kind_id:
for d in tpl.kind_id.default_input_ids:
spec = {f: d[f] for f in self._KIND_DEFAULT_INPUT_FIELDS}
specs.append(spec)
elif tpl.default_kind:
# Legacy fallback — kind_id never got linked.
specs = self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, [])
for spec in specs:
if spec['name'] in existing_names:
continue
Input.create({

View File

@@ -70,6 +70,12 @@ access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
access_fp_step_kind_default_input_operator,fp.step.kind.default.input.operator,model_fp_step_kind_default_input,group_fusion_plating_operator,1,0,0,0
access_fp_step_kind_default_input_supervisor,fp.step.kind.default.input.supervisor,model_fp_step_kind_default_input,group_fusion_plating_supervisor,1,1,1,1
access_fp_step_kind_default_input_manager,fp.step.kind.default.input.manager,model_fp_step_kind_default_input,group_fusion_plating_manager,1,1,1,1
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,group_fusion_plating_operator,1,0,0,0
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,1,1,1,0
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
70 access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency group_fusion_plating_operator 1 0 0 0
71 access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency group_fusion_plating_supervisor 1 1 1 0
72 access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency group_fusion_plating_manager 1 1 1 1
73 access_fp_step_kind_operator fp.step.kind.operator model_fp_step_kind group_fusion_plating_operator 1 0 0 0
74 access_fp_step_kind_supervisor fp.step.kind.supervisor model_fp_step_kind group_fusion_plating_supervisor 1 1 1 0
75 access_fp_step_kind_manager fp.step.kind.manager model_fp_step_kind group_fusion_plating_manager 1 1 1 1
76 access_fp_step_kind_default_input_operator fp.step.kind.default.input.operator model_fp_step_kind_default_input group_fusion_plating_operator 1 0 0 0
77 access_fp_step_kind_default_input_supervisor fp.step.kind.default.input.supervisor model_fp_step_kind_default_input group_fusion_plating_supervisor 1 1 1 1
78 access_fp_step_kind_default_input_manager fp.step.kind.default.input.manager model_fp_step_kind_default_input group_fusion_plating_manager 1 1 1 1
79 access_fp_step_template_operator fp.step.template.operator model_fp_step_template group_fusion_plating_operator 1 0 0 0
80 access_fp_step_template_supervisor fp.step.template.supervisor model_fp_step_template group_fusion_plating_supervisor 1 1 1 0
81 access_fp_step_template_manager fp.step.template.manager model_fp_step_template group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,58 @@
/** @odoo-module **/
// Sub 14b — visual FontAwesome icon picker for fp.step.kind.icon and any
// other Selection field whose values are FA classes (e.g. 'fa-flask').
//
// Always-visible compact grid with a Search box. Glyph-only tiles
// (label appears on hover via title attribute). Designed for the form
// view; the list shows raw text and opens the form for editing.
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class FpIconPickerField extends Component {
static template = "fusion_plating.FpIconPicker";
static props = { ...standardFieldProps };
setup() {
this.state = useState({ filter: "" });
}
get options() {
const field = this.props.record.fields[this.props.name];
return (field && field.selection) || [];
}
get filteredOptions() {
const q = (this.state.filter || "").trim().toLowerCase();
if (!q) return this.options;
return this.options.filter(([code, label]) =>
code.toLowerCase().includes(q) ||
(label || "").toLowerCase().includes(q)
);
}
get currentValue() {
return this.props.record.data[this.props.name] || "";
}
get currentLabel() {
const opt = this.options.find((o) => o[0] === this.currentValue);
return opt ? opt[1] : "";
}
async onPick(value, ev) {
if (this.props.readonly) return;
ev.preventDefault();
ev.stopPropagation();
await this.props.record.update({ [this.props.name]: value });
}
}
export const fpIconPickerField = {
component: FpIconPickerField,
displayName: "Icon Picker",
supportedTypes: ["selection"],
};
registry.category("fields").add("fp_icon_picker", fpIconPickerField);

View File

@@ -243,6 +243,7 @@ export class FpSimpleRecipeEditor extends Component {
*/
async onOpenLibraryCreate() {
await this._fpEnsureWorkflowStatesLoaded();
await this._fpEnsureKindOptionsLoaded();
this.state.libraryEditor = {
id: null, // null = create
name: "",
@@ -282,9 +283,67 @@ export class FpSimpleRecipeEditor extends Component {
}
}
/**
* Sub 14b — fetch the user-extensible Step Kind catalog once per
* editor session, cache on this.state.kindOptions. Used by both
* create + edit flows to populate the "Step Kind" dropdown so
* user-added kinds appear without a page reload.
*/
async _fpEnsureKindOptionsLoaded() {
if (this.state.kindOptions && this.state.kindOptions.length) {
return;
}
try {
const data = await rpc("/fp/simple_recipe/kinds/list", {});
this.state.kindOptions = data.kinds || [];
} catch (err) {
this.state.kindOptions = [];
}
}
/**
* Sub 14b — handler for Step Kind dropdown change. Special-cases
* the "+ Add a new kind…" sentinel: prompt the user for a name,
* round-trip to /kinds/create, refresh the cached options, then
* select the newly-created kind.
*/
async onKindChange(ev) {
const code = ev.target.value;
if (code !== "__new__") {
this.state.libraryEditor.default_kind = code || "";
return;
}
// Reset the dropdown so it doesn't stay on the sentinel if the
// user cancels the prompt.
ev.target.value = this.state.libraryEditor.default_kind || "";
const name = window.prompt(
"Name your new Step Kind (e.g. 'Passivation', 'Shot Peen')",
""
);
if (!name || !name.trim()) {
return;
}
try {
const data = await rpc("/fp/simple_recipe/kinds/create", {
name: name.trim(),
});
if (!data.ok) {
alert(data.error || "Could not create Step Kind.");
return;
}
// Drop the cached list so the next ensure() refetches it.
this.state.kindOptions = null;
await this._fpEnsureKindOptionsLoaded();
this.state.libraryEditor.default_kind = data.code;
} catch (err) {
alert("Could not create Step Kind: " + (err.message || err));
}
}
async onOpenLibraryEdit(templateId) {
this.state.libraryEditorBusy = true;
await this._fpEnsureWorkflowStatesLoaded();
await this._fpEnsureKindOptionsLoaded();
const data = await rpc("/fp/simple_recipe/library/load", {
template_id: templateId,
});

View File

@@ -0,0 +1,150 @@
// Sub 14b — Visual icon picker for fp.step.kind.icon and similar
// Selection fields whose values are FontAwesome class names.
//
// Compact 12-column grid with Search filter. Glyph-only tiles
// (label appears on hover via the browser tooltip). Capped at ~280px
// scrollable height so it doesn't dominate the form.
//
// Dark-mode aware via $o-webclient-color-scheme branch (see CLAUDE.md).
$o-webclient-color-scheme: bright !default;
$_fp-icon-picker-bg-hex: #ffffff;
$_fp-icon-picker-border-hex: #d8dadd;
$_fp-icon-picker-hover-hex: #f3f4f6;
$_fp-icon-picker-active-hex: #2c89e9;
$_fp-icon-picker-text-hex: #21252b;
$_fp-icon-picker-muted-hex: #6c757d;
@if $o-webclient-color-scheme == dark {
$_fp-icon-picker-bg-hex: #22262d !global;
$_fp-icon-picker-border-hex: #3a3f47 !global;
$_fp-icon-picker-hover-hex: #2c313a !global;
$_fp-icon-picker-active-hex: #4ea3ff !global;
$_fp-icon-picker-text-hex: #e6e9ef !global;
$_fp-icon-picker-muted-hex: #9aa3ad !global;
}
$fp-icon-picker-bg: var(--fp-icon-picker-bg, $_fp-icon-picker-bg-hex);
$fp-icon-picker-border: var(--fp-icon-picker-border, $_fp-icon-picker-border-hex);
$fp-icon-picker-hover: var(--fp-icon-picker-hover, $_fp-icon-picker-hover-hex);
$fp-icon-picker-active: var(--fp-icon-picker-active, $_fp-icon-picker-active-hex);
$fp-icon-picker-text: var(--fp-icon-picker-text, $_fp-icon-picker-text-hex);
$fp-icon-picker-muted: var(--fp-icon-picker-muted, $_fp-icon-picker-muted-hex);
// Force full sheet width even when Odoo wraps the field in a fixed-width
// .o_field_widget cell. Selecting both the wrapper and the inline root
// belt-and-suspenders any group container that tries to clip us.
.o_field_widget[name="icon"]:has(.o_fp_icon_picker_inline) {
width: 100% !important;
max-width: none !important;
flex: 1 1 100% !important;
}
.o_fp_icon_picker_inline {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: 100%;
.o_fp_icon_picker_top {
display: flex;
align-items: center;
gap: 0.5rem;
}
.o_fp_icon_picker_current {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem;
background-color: $fp-icon-picker-bg;
border: 1px solid $fp-icon-picker-border;
border-radius: 4px;
color: $fp-icon-picker-text;
font-size: 0.85rem;
flex: 1;
min-width: 0;
}
.o_fp_icon_picker_current_glyph {
font-size: 1rem;
color: $fp-icon-picker-active;
width: 1.2em;
text-align: center;
}
.o_fp_icon_picker_current_empty {
color: $fp-icon-picker-muted;
}
.o_fp_icon_picker_current_label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_fp_icon_picker_filter {
flex: 0 0 140px;
height: 28px;
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
.o_fp_icon_picker_inline_grid {
// Auto-fill tracks of ~36px each → grid expands to fill
// whatever width the form column gives it.
display: grid;
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
gap: 2px;
padding: 0.4rem;
background-color: $fp-icon-picker-bg;
border: 1px solid $fp-icon-picker-border;
border-radius: 4px;
max-height: 240px;
overflow-y: auto;
}
.o_fp_icon_picker_tile {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
color: $fp-icon-picker-text;
height: 30px;
width: 100%;
&:hover:not(:disabled) {
background-color: $fp-icon-picker-hover;
border-color: $fp-icon-picker-border;
}
&.o_fp_icon_picker_active {
border-color: $fp-icon-picker-active;
color: $fp-icon-picker-active;
background-color: rgba(44, 137, 233, 0.10);
}
&:disabled {
cursor: default;
opacity: 0.55;
}
}
.o_fp_icon_picker_tile_glyph {
font-size: 1rem;
}
.o_fp_icon_picker_empty_results {
grid-column: 1 / -1;
padding: 0.5rem;
text-align: center;
color: $fp-icon-picker-muted;
font-size: 0.8rem;
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating.FpIconPicker">
<div class="o_fp_icon_picker_inline">
<div class="o_fp_icon_picker_top">
<div class="o_fp_icon_picker_current">
<i t-if="currentValue" t-att-class="'fa ' + currentValue + ' o_fp_icon_picker_current_glyph'"/>
<i t-if="!currentValue" class="fa fa-square-o o_fp_icon_picker_current_glyph o_fp_icon_picker_current_empty"/>
<span class="o_fp_icon_picker_current_label" t-esc="currentLabel || 'Pick an icon'"/>
</div>
<input type="text" class="o_fp_icon_picker_filter form-control"
placeholder="Search…"
t-att-value="state.filter"
t-on-input="(ev) => state.filter = ev.target.value"
t-att-disabled="props.readonly"/>
</div>
<div class="o_fp_icon_picker_inline_grid">
<button type="button"
t-foreach="filteredOptions" t-as="opt" t-key="opt[0]"
class="o_fp_icon_picker_tile"
t-att-class="{ 'o_fp_icon_picker_active': opt[0] === currentValue }"
t-att-title="opt[1]"
t-att-disabled="props.readonly"
t-on-click="(ev) => this.onPick(opt[0], ev)">
<i t-att-class="'fa ' + opt[0] + ' o_fp_icon_picker_tile_glyph'"/>
</button>
<div t-if="filteredOptions.length === 0" class="o_fp_icon_picker_empty_results">
No icons match "<t t-esc="state.filter"/>"
</div>
</div>
</div>
</t>
</templates>

View File

@@ -414,32 +414,15 @@
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
</label>
<select class="form-select"
t-model="state.libraryEditor.default_kind">
t-on-change="(ev) => this.onKindChange(ev)"
t-att-value="state.libraryEditor.default_kind">
<option value="">Generic — no automatic behaviour</option>
<option value="receiving">Receiving / Incoming Inspection</option>
<option value="contract_review">Contract Review (QA-005)</option>
<option value="racking">Racking</option>
<option value="mask">Masking</option>
<option value="cleaning">Cleaning</option>
<option value="electroclean">Electroclean</option>
<option value="etch">Etch / Activation</option>
<option value="rinse">Rinse</option>
<option value="strike">Strike (Wood's Nickel / Activation)</option>
<option value="plate">Plating</option>
<option value="replenishment">Tank Replenishment</option>
<option value="wbf_test">Water Break Free Test</option>
<option value="dry">Drying</option>
<option value="bake">Bake (HE Relief / Stress Relief)</option>
<option value="demask">De-Masking</option>
<option value="derack">De-Racking</option>
<option value="inspect">Inspection</option>
<option value="hardness_test">Hardness Test</option>
<option value="adhesion_test">Adhesion Test</option>
<option value="salt_spray">Salt Spray / Corrosion Test</option>
<option value="final_inspect">Final Inspection</option>
<option value="packaging">Packaging / Pre-Ship</option>
<option value="ship">Shipping</option>
<option value="gating">Gating</option>
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
<t t-esc="k.name"/>
</option>
</t>
<option value="__new__">+ Add a new kind…</option>
</select>
</div>
<div class="o_fp_le_field">

View File

@@ -179,7 +179,8 @@
<group>
<group string="Stations">
<field name="tank_ids" widget="many2many_tags"/>
<field name="default_kind"/>
<field name="kind_id"
options="{'no_create_edit': false, 'no_quick_create': false}"/>
<field name="material_callout"/>
</group>
<group string="Flags">

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 14b — User-extensible Step Kind catalog. Replaces the hardcoded
`default_kind` Selection on fp.step.template / fusion.plating.process.node.
-->
<odoo>
<record id="view_fp_step_kind_list" model="ir.ui.view">
<field name="name">fp.step.kind.list</field>
<field name="model">fp.step.kind</field>
<field name="arch" type="xml">
<list string="Step Kinds">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="icon"/>
<field name="template_count"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_step_kind_form" model="ir.ui.view">
<field name="name">fp.step.kind.form</field>
<field name="model">fp.step.kind</field>
<field name="arch" type="xml">
<form string="Step Kind">
<header>
<button name="action_open_templates" type="object"
string="View Templates" class="btn-secondary"
invisible="template_count == 0"/>
</header>
<sheet>
<widget name="web_ribbon" title="Archived"
bg_color="bg-danger"
invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Passivation"/></h1>
<div class="text-muted">
<field name="code" placeholder="passivation"/>
</div>
</div>
<group>
<group>
<field name="sequence"/>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="active" invisible="1"/>
</group>
<group>
<field name="template_count"/>
</group>
</group>
<h3 class="mt-3 mb-2">Icon</h3>
<field name="icon" widget="fp_icon_picker" nolabel="1"/>
<notebook>
<page string="Default Inputs" name="defaults">
<div class="alert alert-info" role="alert">
These inputs auto-seed onto a Step Template when an
author picks this kind and clicks "Seed Default Inputs".
Idempotent — won't duplicate prompts that already exist.
</div>
<field name="default_input_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="input_type"/>
<field name="target_unit"/>
<field name="required" widget="boolean_toggle"/>
<field name="hint"/>
<field name="selection_options"/>
</list>
</field>
</page>
<page string="Description" name="description">
<field name="description"
placeholder="Optional ops note shown to recipe authors when they pick this kind."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_fp_step_kind_search" model="ir.ui.view">
<field name="name">fp.step.kind.search</field>
<field name="model">fp.step.kind</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="code"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
</search>
</field>
</record>
<record id="action_fp_step_kind" model="ir.actions.act_window">
<field name="name">Step Kinds</field>
<field name="res_model">fp.step.kind</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_step_kind_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new Step Kind
</p>
<p>
Step Kinds drive the dropdown shown when a recipe author
picks the type of a step (Cleaning, Plating, Bake…). Each
kind can carry default inputs that get auto-seeded onto
templates.
</p>
</field>
</record>
<menuitem id="menu_fp_step_kind"
name="Step Kinds"
parent="menu_fp_config_recipes_steps"
action="action_fp_step_kind"
sequence="50"/>
</odoo>

View File

@@ -14,7 +14,7 @@
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="default_kind"/>
<field name="kind_id"/>
<field name="tank_ids" widget="many2many_tags" optional="show"/>
<field name="requires_signoff" optional="hide"/>
<field name="requires_rack_assignment" optional="hide"/>
@@ -32,7 +32,7 @@
<header>
<button name="action_seed_default_inputs" type="object"
string="Seed Default Inputs" class="btn-secondary"
invisible="not default_kind"/>
invisible="not kind_id"/>
<button name="action_add_common_audit_fields" type="object"
string="+ Common Audit Fields"
class="btn-secondary"
@@ -48,7 +48,8 @@
</div>
<group>
<group string="Classification">
<field name="default_kind"/>
<field name="kind_id"
options="{'no_create_edit': false, 'no_quick_create': false}"/>
<field name="icon"/>
<field name="process_type_id"/>
<field name="material_callout"/>
@@ -133,13 +134,13 @@
<search>
<field name="name"/>
<field name="code"/>
<field name="default_kind"/>
<field name="kind_id"/>
<field name="tank_ids"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Step Kind" name="group_kind"
context="{'group_by':'default_kind'}"/>
context="{'group_by':'kind_id'}"/>
<filter string="Process Type" name="group_proc"
context="{'group_by':'process_type_id'}"/>
</group>