Operator-reported foot-gun: Step Kind dropdown had 24 options, most
of which were visual-only (cleaning, electroclean, etch, rinse,
strike, dry, wbf_test, hardness_test, adhesion_test, salt_spray,
packaging, etc.) and didn't drive any gate or milestone. Picking the
wrong one meant nothing happened; picking Generic (left default)
meant nothing happened. Authors couldn't tell which choice mattered.
Curation: 24 → 11 active kinds. Each remaining kind has a concrete
downstream behaviour (gate, portal milestone, hardware tie-in, or
"explicitly no behaviour" for Other):
other Other (catch-all, default — no special behaviour)
receiving Received portal milestone
contract_review QA-005 form gate + button_finish lock
racking Rack-assignment dialog + button_finish lock
mask Visual mask kind (covers Masking + De-Masking)
wet_process Visual wet kind (NEW, covers cleaning, rinse,
etch, strike, dry, electroclean, wbf_test)
plate Plated portal milestone (last plate step closes)
bake Bake-window state machine + Baked milestone
inspect Intermediate inspection milestone
final_inspect Inspected (terminal) portal milestone
ship Shipped milestone (back-compat; delivery-state
driven is preferred)
Retired kinds (active=False, hidden from dropdown): cleaning,
electroclean, etch, rinse, strike, dry, wbf_test, demask, derack,
replenishment, hardness_test, adhesion_test, salt_spray, packaging,
gating. Kept in DB for audit / history but not selectable.
Mandatory enforcement:
- fp.step.kind_id on fusion.plating.process.node and fp.step.template
is now required=True with ondelete='restrict' and a default that
resolves to the 'other' kind. Existing NULL rows are backfilled by
the pre-migrate before the NOT NULL constraint hits the schema.
- Dropdown no longer offers a blank / "Generic" option. New steps
land on 'other' instead of NULL.
Admin-only catalog:
- /fp/simple_recipe/kinds/create endpoint now refuses requests from
non-managers (group_fusion_plating_manager). Returns a clear
message explaining why ("each kind drives gates / milestones /
routing — pick Other if none fits, or ask a manager to wire up a
new kind").
- "+ Add a new kind…" sentinel option in the library form is hidden
unless state.recipe.user_is_manager. Backend gate is the authority;
the UI hide is just to stop showing a button that will error.
- The Step Type dropdown in the inline step-edit panel switched from
a 24-line hard-coded XML option list to a t-foreach over
state.kindOptions (the same kinds/list endpoint payload). One
source of truth — retire / add a kind in the catalog and every
picker reflects the change.
Migration impact (entech): 5 templates + 579 nodes backfilled via
name-match heuristic. 15 kinds flipped to active=False. Distribution
of the 579 backfilled nodes:
racking 105, other 97, bake 91, wet_process 90, mask 74,
inspect 44, plate 32, final_inspect 25, receiving 10,
contract_review 9, ship 2.
Drive-by:
- Migration uses _ensure_kind() that also registers ir.model.data
for the new xmlids so the subsequent data XML load doesn't create
duplicate kind records.
- Stored related default_kind on fusion.plating.process.node /
fp.step.template is written alongside kind_id in every SQL UPDATE
so legacy `node.default_kind == 'foo'` comparisons stay accurate
(the ORM doesn't recompute stored related fields after direct
SQL writes).
Module: fusion_plating 19.0.20.5.0 → 19.0.20.6.0.
15 existing tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
10 KiB
Python
260 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
#
|
|
# 2026-05-20 Step Kind curation — pre-migrate.
|
|
#
|
|
# Runs BEFORE the model schema is applied so `kind_id` can become
|
|
# required=True without choking on existing NULL rows. Three jobs:
|
|
#
|
|
# 1. Ensure the new `other` and `wet_process` kinds exist in the DB.
|
|
# The data XML hasn't loaded yet at pre-migrate time, so we SQL
|
|
# them in directly. The XML on the install path will see them and
|
|
# skip via noupdate.
|
|
#
|
|
# 2. Re-map every template + recipe-node pointing at a RETIRED kind
|
|
# to its new home:
|
|
# cleaning, electroclean, etch, rinse, strike, dry, wbf_test
|
|
# → wet_process
|
|
# plate
|
|
# → wet_process (but we KEEP `plate` separately for the
|
|
# Plated milestone trigger; only auto-remap when the
|
|
# caller explicitly wants to retire it. Plate stays
|
|
# active.)
|
|
# demask → mask
|
|
# derack → racking
|
|
# replenishment, hardness_test, adhesion_test, salt_spray,
|
|
# packaging, gating
|
|
# → other
|
|
#
|
|
# 3. Backfill every NULL kind_id via name-matching heuristic. Anything
|
|
# that doesn't match → 'other'.
|
|
#
|
|
# After this script the schema can safely add NOT NULL to kind_id.
|
|
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# -- Remap table — retired-kind code -> new-kind code ----------------------
|
|
# IMPORTANT: `plate` stays active (its own milestone trigger). Only the
|
|
# wet-bath specialisations roll up into wet_process.
|
|
_REMAP = {
|
|
'cleaning': 'wet_process',
|
|
'electroclean': 'wet_process',
|
|
'etch': 'wet_process',
|
|
'rinse': 'wet_process',
|
|
'strike': 'wet_process',
|
|
'dry': 'wet_process',
|
|
'wbf_test': 'wet_process',
|
|
'demask': 'mask',
|
|
'derack': 'racking',
|
|
'replenishment': 'other',
|
|
'hardness_test': 'other',
|
|
'adhesion_test': 'other',
|
|
'salt_spray': 'other',
|
|
'packaging': 'other',
|
|
'gating': 'other',
|
|
}
|
|
|
|
# -- Name-match heuristic for NULL backfill --------------------------------
|
|
# Each rule: (substring to match in lower(name), target kind code). First
|
|
# match wins. Order matters — more specific patterns come first.
|
|
_NAME_HEURISTIC = [
|
|
# Most specific
|
|
('qa-005', 'contract_review'),
|
|
('contract review', 'contract_review'),
|
|
('final inspect', 'final_inspect'),
|
|
('final inspection', 'final_inspect'),
|
|
('post plate inspect', 'final_inspect'),
|
|
# Bake / oven
|
|
('bake', 'bake'),
|
|
('oven', 'bake'),
|
|
('he relief', 'bake'),
|
|
('embrittlement', 'bake'),
|
|
('stress relief', 'bake'),
|
|
# Receiving / shipping
|
|
('receiv', 'receiving'),
|
|
('incoming inspect', 'receiving'),
|
|
('ship', 'ship'),
|
|
('pack', 'ship'),
|
|
# Racking
|
|
('de-rack', 'racking'),
|
|
('deracking', 'racking'),
|
|
('derack', 'racking'),
|
|
('rack', 'racking'),
|
|
# Masking
|
|
('de-mask', 'mask'),
|
|
('demask', 'mask'),
|
|
('unmask', 'mask'),
|
|
('mask', 'mask'),
|
|
# Inspection
|
|
('inspect', 'inspect'),
|
|
# Plating
|
|
('plate', 'plate'),
|
|
('plating', 'plate'),
|
|
('nickel', 'plate'),
|
|
('chrome', 'plate'),
|
|
('anodi', 'plate'),
|
|
# Wet processes (broad)
|
|
('soak clean', 'wet_process'),
|
|
('electroclean', 'wet_process'),
|
|
('clean', 'wet_process'),
|
|
('rinse', 'wet_process'),
|
|
('etch', 'wet_process'),
|
|
('activ', 'wet_process'),
|
|
('strike', 'wet_process'),
|
|
('desmut', 'wet_process'),
|
|
('zincate', 'wet_process'),
|
|
('acid', 'wet_process'),
|
|
('dry', 'wet_process'),
|
|
('water break', 'wet_process'),
|
|
('wbf', 'wet_process'),
|
|
# Gating / ready / wait — soft sequencers, no behaviour
|
|
('ready for', 'other'),
|
|
('ready to', 'other'),
|
|
]
|
|
|
|
|
|
def migrate(cr, version):
|
|
# 1. Ensure `other` and `wet_process` exist. Use SQL directly so
|
|
# we don't depend on the XML having loaded yet.
|
|
_ensure_kind(cr, 'other', 'Other', 'fa-circle-o', 5)
|
|
_ensure_kind(cr, 'wet_process', 'Wet Process (Clean / Rinse / Etch / Dry / etc.)', 'fa-tint', 55)
|
|
|
|
# 2. Build a code → id map for ALL kinds present in DB.
|
|
cr.execute("SELECT id, code FROM fp_step_kind")
|
|
by_code = {code: kid for kid, code in cr.fetchall()}
|
|
if 'other' not in by_code:
|
|
_logger.error('pre-migrate: `other` kind missing after _ensure_kind — aborting')
|
|
return
|
|
other_id = by_code['other']
|
|
|
|
# 3. Re-map references to retired kinds.
|
|
# `default_kind` is a stored related on `kind_id.code` — updating
|
|
# kind_id via SQL doesn't auto-recompute the stored copy, so we
|
|
# write both columns together.
|
|
for retired_code, new_code in _REMAP.items():
|
|
retired_id = by_code.get(retired_code)
|
|
new_id = by_code.get(new_code) or other_id
|
|
if not retired_id:
|
|
continue # not in this DB — nothing to remap
|
|
cr.execute("""
|
|
UPDATE fp_step_template
|
|
SET kind_id = %s, default_kind = %s
|
|
WHERE kind_id = %s
|
|
""", (new_id, new_code, retired_id))
|
|
tpl_n = cr.rowcount
|
|
cr.execute("""
|
|
UPDATE fusion_plating_process_node
|
|
SET kind_id = %s, default_kind = %s
|
|
WHERE kind_id = %s
|
|
""", (new_id, new_code, retired_id))
|
|
node_n = cr.rowcount
|
|
if tpl_n or node_n:
|
|
_logger.info(
|
|
'Step Kind curation: remapped %d template(s) + %d node(s) '
|
|
'from %s → %s', tpl_n, node_n, retired_code, new_code,
|
|
)
|
|
|
|
# 4. Backfill NULL kind_id on both tables via name heuristic.
|
|
# `name` is jsonb on fp_step_template (translatable in Odoo 19) but
|
|
# plain varchar on fusion_plating_process_node. Sniff the column
|
|
# type so the right expression is used.
|
|
for table in ('fp_step_template', 'fusion_plating_process_node'):
|
|
cr.execute("""
|
|
SELECT data_type FROM information_schema.columns
|
|
WHERE table_name = %s AND column_name = 'name'
|
|
""", (table,))
|
|
row = cr.fetchone()
|
|
col_type = (row[0] if row else '') or ''
|
|
if 'json' in col_type.lower():
|
|
name_expr = "COALESCE(name->>'en_US', name::text)"
|
|
else:
|
|
name_expr = 'name'
|
|
cr.execute(f"""
|
|
SELECT id, {name_expr} AS name_str
|
|
FROM {table}
|
|
WHERE kind_id IS NULL
|
|
""")
|
|
rows = cr.fetchall()
|
|
if not rows:
|
|
continue
|
|
# In-process classification to avoid pummelling the DB with
|
|
# one UPDATE per row.
|
|
per_kind = {} # kind_id → list of row ids
|
|
for rid, raw_name in rows:
|
|
target_code = _classify_by_name(raw_name)
|
|
target_id = by_code.get(target_code) or other_id
|
|
per_kind.setdefault(target_id, []).append(rid)
|
|
# Build a kid → code lookup so we can write default_kind together.
|
|
by_id = {kid: code for code, kid in by_code.items()}
|
|
for kid, ids in per_kind.items():
|
|
cr.execute(
|
|
f"UPDATE {table} SET kind_id = %s, default_kind = %s "
|
|
f"WHERE id = ANY(%s)",
|
|
(kid, by_id.get(kid, 'other'), ids),
|
|
)
|
|
_logger.info(
|
|
'Step Kind curation: backfilled %d %s row(s) — '
|
|
'distribution: %s',
|
|
len(rows), table,
|
|
{next(c for c, i in by_code.items() if i == k): len(v)
|
|
for k, v in per_kind.items()},
|
|
)
|
|
|
|
|
|
def _classify_by_name(name):
|
|
"""Return a step-kind code based on a name match. Falls back to 'other'."""
|
|
if not name:
|
|
return 'other'
|
|
lower = name.lower()
|
|
for needle, code in _NAME_HEURISTIC:
|
|
if needle in lower:
|
|
return code
|
|
return 'other'
|
|
|
|
|
|
def _ensure_kind(cr, code, name, icon, sequence):
|
|
"""Create the kind via SQL if it doesn't exist yet. Idempotent.
|
|
|
|
fp_step_kind.name is a jsonb (translatable) column in Odoo 19, so
|
|
we wrap the string in jsonb_build_object('en_US', ...).
|
|
|
|
Also registers the ir.model.data entry so the subsequent XML data
|
|
load (which runs AFTER pre-migrate) sees the xmlid as already
|
|
bound and skips creation — otherwise we get duplicate records.
|
|
"""
|
|
cr.execute("SELECT id FROM fp_step_kind WHERE code = %s", (code,))
|
|
row = cr.fetchone()
|
|
if row:
|
|
kid = row[0]
|
|
else:
|
|
cr.execute("""
|
|
INSERT INTO fp_step_kind (code, name, sequence, icon, active,
|
|
create_uid, create_date, write_uid, write_date)
|
|
VALUES (%s, jsonb_build_object('en_US', %s::text), %s, %s, true,
|
|
1, NOW() AT TIME ZONE 'UTC', 1, NOW() AT TIME ZONE 'UTC')
|
|
RETURNING id
|
|
""", (code, name, sequence, icon))
|
|
kid = cr.fetchone()[0]
|
|
_logger.info('Step Kind curation: created kind %s (id=%s)', code, kid)
|
|
|
|
# Bind the xmlid so XML noupdate=1 finds the record on next load.
|
|
xmlid_name = 'step_kind_%s' % code
|
|
cr.execute("""
|
|
SELECT id FROM ir_model_data
|
|
WHERE module = 'fusion_plating' AND name = %s
|
|
""", (xmlid_name,))
|
|
if cr.fetchone():
|
|
return
|
|
cr.execute("""
|
|
INSERT INTO ir_model_data (module, name, model, res_id, noupdate,
|
|
create_uid, create_date, write_uid, write_date)
|
|
VALUES ('fusion_plating', %s, 'fp.step.kind', %s, true,
|
|
1, NOW() AT TIME ZONE 'UTC', 1, NOW() AT TIME ZONE 'UTC')
|
|
""", (xmlid_name, kid))
|
|
_logger.info('Step Kind curation: bound xmlid fusion_plating.%s -> id %s',
|
|
xmlid_name, kid)
|