Per user request: technicians on the tablet should only see Discuss, To-do, Plating, AI, Maintenance, Time Off. Every other top-level app menu (Calendar, Contacts, CRM, Sales, Dashboards, RC, Faxes, Field Service, Fusion Clock, Invoicing, Accounting, Project, Timesheets, Planning, Shipping, Website, Purchase, Inventory, Sign, HR, Payroll, Attendances, Recruitment, Expenses, IoT, Link Tracker, Apps) is now restricted to a new group_fp_office_user. Architecture: - New group_fp_office_user (security/fp_menu_visibility.xml) — a marker group that controls back-office menu visibility. - Owner / Manager / Quality Manager / Shop Manager / Sales Rep all imply office_user via implied_ids — they see everything they did before. - Pure Technicians do NOT imply office_user — they see only the tablet-friendly menus. - A "!technician" filter would have hit managers too (because Manager → ... → Technician via implication), so office_user is the inverse pattern that gets the right scoping. Implementation: - post_init_hook + migrations/19.0.21.4.0/post-migrate.py both call _fp_apply_office_user_menu_visibility(env) which iterates a curated list of menu xmlids and sets group_ids = [office_user] on each. - Uses env.ref(..., raise_if_not_found=False) so menus from uninstalled modules silently skip — no hard depends added. - ir.ui.menu uses `group_ids` in Odoo 19 (was groups_id pre-18 — same rename pattern as res.users; CLAUDE.md Rule 13c). - Settings / Apps / Tests left untouched (already admin-restricted). - Some menus (Field Service) end up with office_user OR their original group — that's correct behavior: Plating Techs have neither so still don't see them; explicit Field Technicians keep access. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
618 lines
23 KiB
Python
618 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
# Part of the Fusion Plating product family.
|
|
|
|
import logging
|
|
import re
|
|
|
|
from . import controllers
|
|
from . import models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def post_init_hook(env):
|
|
"""Run on first install / module upgrade. Idempotent.
|
|
|
|
Does several things, each guarded by an "is this already done?"
|
|
check so re-running the hook doesn't clobber state:
|
|
1. Auto-detect a sensible default timezone (original behavior).
|
|
2. Sub 12a — backfill `kind='step_input'` on existing
|
|
fusion.plating.process.node.input rows that pre-date the
|
|
`kind` field.
|
|
3. Sub 12a — seed fp.step.template with starter library entries
|
|
derived from ENP-ALUM-BASIC if the library is currently empty.
|
|
4. Sub 12b — seed 4 starter rack tags if the registry is empty.
|
|
5. Phase H — create a pending fp.migration.preview if any user
|
|
still holds an old plating-role group + notify Owners.
|
|
"""
|
|
_seed_default_timezone(env)
|
|
_backfill_node_input_kind(env)
|
|
_seed_step_library_if_empty(env)
|
|
_backfill_contract_review_template(env)
|
|
_seed_rack_tags_if_empty(env)
|
|
_migrate_legacy_uom_columns(env)
|
|
_seed_starter_recipes_once(env)
|
|
_fp_post_init_role_migration(env)
|
|
_fp_apply_office_user_menu_visibility(env)
|
|
|
|
|
|
# Top-level app menus that technicians should NOT see. Each entry is an
|
|
# xmlid; env.ref(..., raise_if_not_found=False) silently skips menus
|
|
# from uninstalled modules so this is safe across configurations.
|
|
# Kept visible to technicians (NOT in this list): Discuss, To-do,
|
|
# Plating, AI, Maintenance, Time Off. Settings/Apps/Tests are admin-
|
|
# restricted upstream — also not in this list.
|
|
# See security/fp_menu_visibility.xml for the design rationale.
|
|
MENU_HIDE_FROM_TECHNICIANS = [
|
|
'calendar.mail_menu_calendar',
|
|
'contacts.menu_contacts',
|
|
'crm.crm_menu_root',
|
|
'sale.sale_menu_root',
|
|
'spreadsheet_dashboard.spreadsheet_dashboard_menu_root',
|
|
'fusion_ringcentral.menu_rc_root',
|
|
'fusion_faxes.menu_fusion_faxes_root',
|
|
'fusion_tasks.menu_field_service_root',
|
|
'fusion_clock.menu_fusion_clock_root',
|
|
'account.menu_finance',
|
|
'accountant.menu_accounting',
|
|
'project.menu_main_pm',
|
|
'hr_timesheet.timesheet_menu_root',
|
|
'planning.planning_menu_root',
|
|
'fusion_shipping.menu_fusion_shipping_root',
|
|
'website.menu_website_configuration',
|
|
'purchase.menu_purchase_root',
|
|
'stock.menu_stock_root',
|
|
'sign.menu_document',
|
|
'hr.menu_hr_root',
|
|
'hr_work_entry_enterprise.menu_hr_payroll_root',
|
|
'hr_attendance.menu_hr_attendance_root',
|
|
'hr_recruitment.menu_hr_recruitment_root',
|
|
'hr_expense.menu_hr_expense_root',
|
|
'iot.iot_menu_root',
|
|
'utm.menu_link_tracker_root',
|
|
'base.menu_management',
|
|
]
|
|
|
|
|
|
def _fp_apply_office_user_menu_visibility(env):
|
|
"""Set group_ids = [group_fp_office_user] on every menu in
|
|
MENU_HIDE_FROM_TECHNICIANS that exists in this DB.
|
|
|
|
Field is `group_ids` on ir.ui.menu in Odoo 19 (was `groups_id` in
|
|
earlier versions — Odoo 18 renamed it). Same naming-rename pattern
|
|
as res.users (CLAUDE.md Critical Rule 13c).
|
|
|
|
Idempotent: if a menu already has only the office_user group, no
|
|
change is made. If it has additional groups (e.g. a previous custom
|
|
restriction), they're REPLACED — the design accepts this trade-off
|
|
because office_user is implied by every fp role above Technician,
|
|
so non-fp users keep their access on entech.
|
|
|
|
Cross-module xmlids: env.ref(..., raise_if_not_found=False) returns
|
|
None for menus from uninstalled modules, which we silently skip.
|
|
"""
|
|
office = env.ref(
|
|
'fusion_plating.group_fp_office_user', raise_if_not_found=False,
|
|
)
|
|
if not office:
|
|
_logger.warning(
|
|
'[menu-visibility] group_fp_office_user not found; skipping'
|
|
)
|
|
return
|
|
touched = 0
|
|
for xmlid in MENU_HIDE_FROM_TECHNICIANS:
|
|
menu = env.ref(xmlid, raise_if_not_found=False)
|
|
if not menu:
|
|
continue
|
|
current_ids = set(menu.group_ids.ids)
|
|
if current_ids == {office.id}:
|
|
continue # already locked-down, nothing to do
|
|
menu.sudo().group_ids = [(6, 0, [office.id])]
|
|
touched += 1
|
|
_logger.info(
|
|
'[menu-visibility] restricted %s menu(s) to group_fp_office_user',
|
|
touched,
|
|
)
|
|
|
|
|
|
def _fp_post_init_role_migration(env):
|
|
"""Idempotent: creates a fp.migration.preview if none is pending or applied.
|
|
|
|
Called automatically on `-u fusion_plating`. The preview enters 'pending'
|
|
state and schedules a mail.activity on every Owner. Owner must explicitly
|
|
click 'Approve & Run' to actually apply the migration.
|
|
"""
|
|
Preview = env['fp.migration.preview']
|
|
if Preview.search_count([('state', '=', 'pending')]):
|
|
return
|
|
if Preview.search_count([('state', '=', 'approved')]):
|
|
# Already migrated previously; only re-fire if any unmigrated user remains
|
|
# An unmigrated user is one who still holds an OLD plating group directly
|
|
# AND does NOT hold any NEW role group. The compute on res.users.x_fc_plating_role
|
|
# returns 'no' for users without any new group regardless of their old groups.
|
|
# Heuristic: if any active user still holds an old group, re-fire.
|
|
from .models.fp_role_constants import _FP_OLD_GROUP_XMLIDS
|
|
any_unmigrated = False
|
|
for xmlid in _FP_OLD_GROUP_XMLIDS:
|
|
old_grp = env.ref(xmlid, raise_if_not_found=False)
|
|
if not old_grp:
|
|
continue
|
|
if old_grp.users.filtered(lambda u: u.active and not u.share):
|
|
# Found at least one user still on an old group → re-fire
|
|
any_unmigrated = True
|
|
break
|
|
if not any_unmigrated:
|
|
return # All users migrated; nothing to do
|
|
preview = Preview.create({})
|
|
preview._fp_build_lines()
|
|
preview._fp_notify_owners()
|
|
|
|
|
|
def _seed_starter_recipes_once(env):
|
|
"""Load starter recipe XML files on FIRST install only.
|
|
|
|
Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP,
|
|
ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With
|
|
``noupdate="1"`` we expected user edits / deletions to survive
|
|
module upgrades — but Odoo only treats noupdate=1 as "don't update
|
|
existing records". If a record's ir.model.data row is deleted via
|
|
unlink, Odoo on the next ``-u`` sees the xmlid as missing and
|
|
RE-CREATES the record from XML. Bug reported 2026-05-20: every
|
|
time the user deleted a substep from a starter recipe, the next
|
|
upgrade brought it back.
|
|
|
|
Fix: pull those files out of the manifest's data list, load them
|
|
here via convert_file ONCE per xmlid. Each file gets a sentinel
|
|
check (does the root recipe's xmlid exist in ir.model.data?); if
|
|
yes, skip. The hook is itself idempotent so it's safe to run on
|
|
every upgrade as well — but the sentinel ensures recipe content
|
|
is only seeded the very first time.
|
|
"""
|
|
from odoo.tools import convert
|
|
Module = env['ir.module.module']
|
|
mod = Module.search([('name', '=', 'fusion_plating')], limit=1)
|
|
if not mod:
|
|
return
|
|
|
|
# (xmlid_to_check, data_file_path) pairs.
|
|
# If the xmlid already exists in ir.model.data, the file is skipped.
|
|
sentinels = [
|
|
('fusion_plating.recipe_enp_alum_basic',
|
|
'data/fp_recipe_enp_alum_basic.xml'),
|
|
('fusion_plating.recipe_enp_steel_basic',
|
|
'data/fp_recipe_enp_steel_basic.xml'),
|
|
('fusion_plating.recipe_enp_sp',
|
|
'data/fp_recipe_enp_sp.xml'),
|
|
('fusion_plating.recipe_general_processing',
|
|
'data/fp_recipe_general_processing.xml'),
|
|
('fusion_plating.recipe_anodize',
|
|
'data/fp_recipe_anodize.xml'),
|
|
('fusion_plating.recipe_chem_conversion',
|
|
'data/fp_recipe_chem_conversion.xml'),
|
|
]
|
|
IMD = env['ir.model.data']
|
|
for xmlid, filepath in sentinels:
|
|
module_name, name = xmlid.split('.', 1)
|
|
if IMD.search_count([('module', '=', module_name), ('name', '=', name)]):
|
|
# Recipe already in DB (either from a previous install, or
|
|
# already loaded by an earlier hook run). Don't touch — user
|
|
# may have made edits.
|
|
continue
|
|
# File not yet loaded for this DB. Run it once.
|
|
try:
|
|
with open_module_data_file(filepath) as fh:
|
|
convert.convert_file(
|
|
env, module_name, filepath, idref={}, mode='init',
|
|
noupdate=True,
|
|
)
|
|
_logger.info('Seeded starter recipe %s', xmlid)
|
|
except FileNotFoundError:
|
|
_logger.warning('Starter recipe file %s not found, skipping',
|
|
filepath)
|
|
except Exception as exc:
|
|
_logger.warning('Could not seed %s: %s', xmlid, exc)
|
|
|
|
|
|
def open_module_data_file(relpath):
|
|
"""Open a file relative to the fusion_plating module root."""
|
|
import os
|
|
here = os.path.dirname(__file__)
|
|
return open(os.path.join(here, relpath), 'rb')
|
|
|
|
|
|
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.
|
|
|
|
`_seed_step_library_if_empty` only fires on a fresh DB; existing DBs
|
|
upgraded from pre-Policy-B versions still have a populated library
|
|
minus the Contract Review entry. This function fills that hole.
|
|
Re-running it is a no-op once the template exists.
|
|
"""
|
|
Tpl = env['fp.step.template']
|
|
if Tpl.search([('default_kind', '=', 'contract_review')], limit=1):
|
|
return # already there
|
|
tpl = Tpl.create({
|
|
'name': 'Contract Review',
|
|
'kind_id': _resolve_kind_id(env, 'contract_review'),
|
|
})
|
|
tpl.action_seed_default_inputs()
|
|
_logger.info(
|
|
"Fusion Plating: backfilled Contract Review library template "
|
|
"(id=%s, %s default inputs).",
|
|
tpl.id, len(tpl.input_template_ids),
|
|
)
|
|
|
|
|
|
def _seed_default_timezone(env):
|
|
from .models.fp_tz import detect_default_tz
|
|
|
|
detected = detect_default_tz(env)
|
|
for company in env['res.company'].sudo().search([]):
|
|
if not company.x_fc_default_tz:
|
|
company.x_fc_default_tz = detected
|
|
_logger.info(
|
|
'Fusion Plating: set default timezone for company %s -> %s',
|
|
company.name, detected,
|
|
)
|
|
|
|
|
|
def _backfill_node_input_kind(env):
|
|
"""Sub 12a — set kind='step_input' on rows that have NULL kind."""
|
|
cr = env.cr
|
|
cr.execute(
|
|
"UPDATE fusion_plating_process_node_input "
|
|
"SET kind = 'step_input' WHERE kind IS NULL"
|
|
)
|
|
if cr.rowcount:
|
|
_logger.info(
|
|
"Fusion Plating: backfilled kind='step_input' on %s "
|
|
"fusion.plating.process.node.input rows", cr.rowcount,
|
|
)
|
|
|
|
|
|
# Mapping of recipe-step name → default_kind. Drives sane-default
|
|
# input seeding on the starter library entries.
|
|
_STARTER_KIND_BY_NAME = {
|
|
# Policy B (2026-04-28) — recipe-side Contract Review step.
|
|
# When an author drops this template into a recipe, fp.job.step.button_*
|
|
# hooks in fusion_plating_jobs detect the kind=='contract_review' and
|
|
# auto-open / gate the QA-005 audit form (fp.contract.review).
|
|
'contract review': 'contract_review',
|
|
'qa-005': 'contract_review',
|
|
'soak clean': 'cleaning',
|
|
'electroclean': 'cleaning',
|
|
'solvent clean': 'cleaning',
|
|
'rinse': 'rinse',
|
|
'primary rinse': 'rinse',
|
|
'secondary rinse': 'rinse',
|
|
'hot rinse': 'rinse',
|
|
'final rinse': 'rinse',
|
|
'etch': 'etch',
|
|
'desmut': 'etch',
|
|
'zincate': 'etch',
|
|
'strip zincate': 'etch',
|
|
'acid dip': 'etch',
|
|
'hcl activation': 'etch',
|
|
'water break test': 'wbf_test',
|
|
'water break free test': 'wbf_test',
|
|
'issue panels': 'mask',
|
|
'masking': 'mask',
|
|
'mask': 'mask',
|
|
'racking': 'racking',
|
|
'rack': 'racking',
|
|
'e-nickel plate': 'plate',
|
|
'e-nickel plating': 'plate',
|
|
'electroless nickel plate': 'plate',
|
|
'electroless nickel plating': 'plate',
|
|
'enp': 'plate',
|
|
'plate': 'plate',
|
|
'plating': 'plate',
|
|
'drying': 'dry',
|
|
'dry': 'dry',
|
|
'bake': 'bake',
|
|
'oven baking': 'bake',
|
|
'oven bake': 'bake',
|
|
'baking': 'bake',
|
|
'hydrogen embrittlement bake': 'bake',
|
|
'he bake': 'bake',
|
|
'de-rack': 'derack',
|
|
'de-racking': 'derack',
|
|
'deracking': 'derack',
|
|
'derack': 'derack',
|
|
'demask': 'demask',
|
|
'de-mask': 'demask',
|
|
'de-masking': 'demask',
|
|
'demasking': 'demask',
|
|
'inspection': 'inspect',
|
|
'incoming inspection': 'inspect',
|
|
'post-plate inspection': 'inspect',
|
|
'post plate inspection': 'inspect',
|
|
'visual inspection': 'inspect',
|
|
'porosity test': 'inspect',
|
|
'adhesion test': 'inspect',
|
|
'final inspection': 'final_inspect',
|
|
'final inspection / packaging': 'final_inspect',
|
|
'shipping': 'ship',
|
|
'pack': 'ship',
|
|
'packaging': 'ship',
|
|
# Gating steps (Steelhead-style "Ready for X" intermediate states).
|
|
'ready for incoming inspection': 'gating',
|
|
'ready for plating': 'gating',
|
|
'ready for racking': 'gating',
|
|
'ready for de-masking': 'gating',
|
|
'ready for demasking': 'gating',
|
|
'ready for masking': 'gating',
|
|
'ready for bake': 'gating',
|
|
'ready for deracking': 'gating',
|
|
'ready for de-racking': 'gating',
|
|
'ready for post plate inspection': 'gating',
|
|
'ready for post-plate inspection': 'gating',
|
|
'ready for final inspection': 'gating',
|
|
'ready for shipping': 'gating',
|
|
# 2026-05-24 — Recipe cleanup additions (spec
|
|
# 2026-05-24-recipe-cleanup-design.md). Covers names the existing
|
|
# resolver didn't know that turned up in the entech recipes audit.
|
|
# Blasting variants
|
|
'blasting': 'blast',
|
|
'bead blast': 'blast',
|
|
'bead blasting': 'blast',
|
|
'media blast': 'blast',
|
|
'media blasting': 'blast',
|
|
# Inspection variants
|
|
'adhesion test coupon': 'inspect',
|
|
'adhesion testing': 'inspect',
|
|
'corrosion testing': 'inspect',
|
|
'lab testing': 'inspect',
|
|
'check sulfamate nickel area': 'inspect',
|
|
'pre-measurements': 'inspect',
|
|
'pre measurements': 'inspect',
|
|
'hot water porosity': 'inspect',
|
|
# Strip / chemical conversion / plugging (wet line)
|
|
'strip process': 'wet_process',
|
|
'strip process - al': 'wet_process',
|
|
'nickel strip - aluminum line': 'wet_process',
|
|
'chemical conversion': 'wet_process',
|
|
'trivalent chromate conversion': 'wet_process',
|
|
'plug the threaded holes': 'mask',
|
|
# Misc wet-line variants seen on entech recipes
|
|
'air dry': 'dry',
|
|
'desmut': 'etch',
|
|
'soak clean': 'cleaning',
|
|
'cleaner': 'cleaning',
|
|
'nickel strike': 'plate',
|
|
'nickel strip': 'plate',
|
|
}
|
|
|
|
|
|
def fp_resolve_step_kind(name):
|
|
"""Resolve a step name to a default_kind, tolerant of whitespace and
|
|
case. Used by both the seeder and the migration backfill so we don't
|
|
have two slightly-different lookup paths.
|
|
|
|
Handles parenthetical suffixes like "(Standard)", "(If Required)",
|
|
"(A-14 / A)" by stripping them and re-trying the lookup.
|
|
|
|
Returns the kind str or None when no match.
|
|
"""
|
|
if not name:
|
|
return None
|
|
key = name.strip().lower()
|
|
if key in _STARTER_KIND_BY_NAME:
|
|
return _STARTER_KIND_BY_NAME[key]
|
|
# Parenthetical strip — "Masking (If Required)" → "masking",
|
|
# "Incoming Inspection (Standard)" → "incoming inspection",
|
|
# "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion".
|
|
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
|
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
|
return _STARTER_KIND_BY_NAME[bare]
|
|
# Gating "Ready for / Ready For" prefix — anything starting with that
|
|
# is a gating node regardless of the destination step name.
|
|
if key.startswith('ready for ') or key.startswith('ready '):
|
|
return 'gating'
|
|
return None
|
|
|
|
|
|
# Translates resolver kind output to the active fp.step.kind.code values.
|
|
# The resolver still returns the OLD vocabulary (cleaning, electroclean,
|
|
# etch, rinse, strike, dry, wbf_test) which were deactivated in
|
|
# 19.0.20.6.0 — those roll up to the active wet_process kind. Other
|
|
# codes pass through 1:1. Used by the auto-classify hook on
|
|
# fusion.plating.process.node + the recipe-cleanup migration
|
|
# (fusion_plating_jobs 19.0.10.26.0).
|
|
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
|
# Wet-line kinds → wet_process (active rollup)
|
|
'cleaning': 'wet_process',
|
|
'electroclean': 'wet_process',
|
|
'etch': 'wet_process',
|
|
'rinse': 'wet_process',
|
|
'strike': 'wet_process',
|
|
'dry': 'wet_process',
|
|
'wbf_test': 'wet_process',
|
|
'wet_process': 'wet_process', # the alias added in 19.0.21.3.0
|
|
# for "Strip Process - AL", "Chemical
|
|
# Conversion", "Trivalent Chromate
|
|
# Conversion" maps DIRECTLY to
|
|
# 'wet_process' — this passthrough
|
|
# entry lets those land correctly.
|
|
# 1:1 mappings (kind exists and is active)
|
|
'contract_review': 'contract_review',
|
|
'mask': 'mask',
|
|
'racking': 'racking',
|
|
'plate': 'plate',
|
|
'bake': 'bake',
|
|
'derack': 'derack',
|
|
'demask': 'demask',
|
|
'inspect': 'inspect',
|
|
'final_inspect': 'final_inspect',
|
|
'ship': 'ship',
|
|
'gating': 'gating',
|
|
'blast': 'blast',
|
|
}
|
|
|
|
|
|
def _seed_step_library_if_empty(env):
|
|
"""Sub 12a — seed fp.step.template starter library.
|
|
|
|
Source priority:
|
|
1. ENP-ALUM-BASIC recipe's child nodes (best — reuses the
|
|
author-curated step set).
|
|
2. Hard-coded minimal list (fallback for fresh DBs).
|
|
"""
|
|
Tpl = env['fp.step.template']
|
|
if Tpl.search_count([]):
|
|
_logger.info(
|
|
'Fusion Plating: step library already populated, skip seed',
|
|
)
|
|
return
|
|
|
|
Node = env['fusion.plating.process.node']
|
|
src = Node.search([
|
|
('node_type', '=', 'recipe'),
|
|
'|', ('code', '=', 'ENP-ALUM-BASIC'),
|
|
('name', 'ilike', 'ENP-ALUM-BASIC'),
|
|
], limit=1)
|
|
|
|
if not src:
|
|
_seed_minimal_library(env)
|
|
return
|
|
|
|
seen = set()
|
|
for child in src.child_ids:
|
|
if child.node_type == 'step':
|
|
_create_template_from_node(env, child, seen)
|
|
else:
|
|
for grandchild in child.child_ids:
|
|
_create_template_from_node(env, grandchild, seen)
|
|
|
|
_logger.info(
|
|
"Fusion Plating: seeded step library with %s entries from %s",
|
|
len(seen), src.name,
|
|
)
|
|
|
|
|
|
def _create_template_from_node(env, node, seen):
|
|
if not node.name or node.name.lower() in seen:
|
|
return
|
|
seen.add(node.name.lower())
|
|
|
|
kind = fp_resolve_step_kind(node.name)
|
|
vals = {
|
|
'name': node.name,
|
|
'description': node.description or False,
|
|
'icon': node.icon or 'fa-cog',
|
|
'process_type_id': node.process_type_id.id,
|
|
'requires_signoff': node.requires_signoff,
|
|
'requires_predecessor_done': node.requires_predecessor_done,
|
|
'kind_id': _resolve_kind_id(env, kind),
|
|
}
|
|
# Snapshot tank_ids if the node has them (added by Sub 12a;
|
|
# existing nodes may not).
|
|
if 'tank_ids' in node._fields and node.tank_ids:
|
|
vals['tank_ids'] = [(6, 0, node.tank_ids.ids)]
|
|
# Snapshot any time/temp targets the node may already carry.
|
|
for f in ('time_min_target', 'time_max_target', 'time_unit',
|
|
'temp_min_target', 'temp_max_target', 'temp_unit'):
|
|
if f in node._fields:
|
|
vals[f] = node[f] or vals.get(f)
|
|
|
|
tpl = env['fp.step.template'].create(vals)
|
|
if kind:
|
|
tpl.action_seed_default_inputs()
|
|
|
|
|
|
def _seed_minimal_library(env):
|
|
"""Hard-coded minimal seed when ENP-ALUM-BASIC isn't on the target DB."""
|
|
Tpl = env['fp.step.template']
|
|
minimal = [
|
|
('Contract Review', 'contract_review'),
|
|
('Soak Clean', 'cleaning'),
|
|
('Electroclean', 'cleaning'),
|
|
('Rinse', 'rinse'),
|
|
('Etch', 'etch'),
|
|
('Desmut', 'etch'),
|
|
('Zincate', 'etch'),
|
|
('Acid Dip', 'etch'),
|
|
('Water Break Test', 'wbf_test'),
|
|
('Racking', 'racking'),
|
|
('De-Racking', 'derack'),
|
|
('E-Nickel Plate', 'plate'),
|
|
('Drying', 'dry'),
|
|
('Inspection', 'inspect'),
|
|
('Final Inspection', 'final_inspect'),
|
|
('Shipping', 'ship'),
|
|
]
|
|
for name, kind in minimal:
|
|
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)',
|
|
len(minimal),
|
|
)
|
|
|
|
|
|
def _migrate_legacy_uom_columns(env):
|
|
"""Translate every free-text UoM column in the plating suite into the
|
|
new curated Selection keys.
|
|
|
|
Runs unconditionally on every fusion_plating upgrade so the day a
|
|
downstream module's migration converts a Char to Selection, the data
|
|
follows. Each call is a no-op when:
|
|
* the column already holds selection keys (identity mapping)
|
|
* the table doesn't exist (module not installed on this DB)
|
|
"""
|
|
from .models._fp_uom_selection import fp_migrate_uom_column
|
|
|
|
targets = [
|
|
# core
|
|
('fusion_plating_bath_parameter', 'uom', 'bath parameter'),
|
|
('fusion_plating_process_node_input', 'uom', 'process node input'),
|
|
('fusion_plating_process_node_input', 'target_unit', 'process node target'),
|
|
('fp_step_template_input', 'target_unit', 'step template input target'),
|
|
# compliance
|
|
('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
|
|
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
|
|
('fusion_plating_waste_manifest', 'uom', 'waste manifest'),
|
|
('fusion_plating_waste_stream', 'generation_uom', 'waste stream'),
|
|
('fusion_plating_spill_register', 'uom', 'spill register'),
|
|
# safety
|
|
('fusion_plating_chemical', 'container_uom', 'chemical container'),
|
|
('fusion_plating_exposure_monitoring', 'uom', 'exposure monitoring'),
|
|
]
|
|
for table, column, label in targets:
|
|
fp_migrate_uom_column(env, table, column, label)
|
|
|
|
|
|
def _seed_rack_tags_if_empty(env):
|
|
"""Sub 12b — seed 4 starter rack tags."""
|
|
Tag = env['fp.rack.tag']
|
|
if Tag.search_count([]):
|
|
return
|
|
starters = [
|
|
('Rush', 1),
|
|
('Hold for QC', 3),
|
|
('Damaged', 9),
|
|
('Customer Sample', 5),
|
|
]
|
|
for name, color in starters:
|
|
Tag.create({'name': name, 'color': color})
|
|
_logger.info(
|
|
'Fusion Plating: seeded %s starter rack tags', len(starters),
|
|
)
|