# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. import logging from . 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. """ _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) 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', 'default_kind': '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', } 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. 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] # 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 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, 'default_kind': 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, 'default_kind': 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), )