diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 9e453037..65d6b435 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.12.6.2', + 'version': '19.0.18.7.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -119,6 +119,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'data/fp_recipe_general_processing.xml', 'data/fp_recipe_anodize.xml', 'data/fp_recipe_chem_conversion.xml', + 'data/fp_step_template_data.xml', ], 'post_init_hook': 'post_init_hook', 'assets': { diff --git a/fusion_plating/fusion_plating/data/fp_step_template_data.xml b/fusion_plating/fusion_plating/data/fp_step_template_data.xml new file mode 100644 index 00000000..5dc901d2 --- /dev/null +++ b/fusion_plating/fusion_plating/data/fp_step_template_data.xml @@ -0,0 +1,106 @@ + + + + + + Incoming Inspection (Standard) + RECV_STD + receiving + fa-inbox + Verify quantity received against packing slip. Visually inspect + for damage, corrosion, oil residue. Photo any damage. Record + inspector initials.

+ ]]>
+
+ + + Electroclean (Standard) + ELEC_CLEAN_STD + electroclean + fa-bolt + Submerge rack and energise. Record actual amperage, voltage, + and current density. Verify polarity per recipe spec.

+ ]]>
+
+ + + Wood's Nickel Strike (Standard) + STRIKE_STD + strike + fa-flash + Apply thin nickel strike to ensure adhesion before main plate. + Record bath ID, time, temperature, electrical readings.

+ ]]>
+
+ + + Salt Spray Test (ASTM B117) + SALT_SPRAY_STD + salt_spray + fa-tint + Submit test panel to salt spray cabinet for the specified + duration. Record red rust % and white corrosion %. Attach lab + report on completion.

+ ]]>
+
+ + + Adhesion Test (Bend / Tape) + ADHESION_STD + adhesion_test + fa-link + Perform adhesion test per spec (bend, tape, burnish, or file). + Photo coupon. Record PASS/FAIL.

+ ]]>
+
+ + + Microhardness Test + HARDNESS_STD + hardness_test + fa-cube + Take three indentations minimum on the test coupon. Record + test load, individual readings, and the computed average. + Confirm equipment calibration is current.

+ ]]>
+
+ + + Packaging (Standard) + PKG_STD + packaging + fa-archive + Wrap parts per customer spec (VCI bag, bubble wrap, separator + paper). Verify cert package included if required. Record quantity + per package and total package count.

+ ]]>
+
+ + + Tank Replenishment + REPL_STD + replenishment + fa-flask + Mid-shift bath top-up. Record bath ID, chemistry added (name + and amount), pH and concentration before/after. Operator must + sign.

+ ]]>
+
+ +
diff --git a/fusion_plating/fusion_plating/migrations/19.0.18.7.0/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.18.7.0/post-migrate.py new file mode 100644 index 00000000..3aa97d0a --- /dev/null +++ b/fusion_plating/fusion_plating/migrations/19.0.18.7.0/post-migrate.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Post-migration for 19.0.18.7.0 — Step Library audit expansion. + +1. Default `collect=True` on all existing recipe-step inputs. +2. Default `collect_measurements=True` on all existing recipe steps. +3. Re-run action_seed_default_inputs on every existing template to + pull in the newly-added prompts (idempotent — skips rows whose + name is already present, so user edits survive). +4. Backfill template_input_id by name-matching against the linked + library template (best-effort). +""" + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + from odoo.api import Environment, SUPERUSER_ID + env = Environment(cr, SUPERUSER_ID, {}) + + # 1. Default collect=True on all recipe-step inputs that have NULL + cr.execute(""" + UPDATE fusion_plating_process_node_input + SET collect = TRUE + WHERE collect IS NULL + """) + _logger.info( + "Backfilled collect=True on %s recipe-step inputs", cr.rowcount + ) + + # 2. Default collect_measurements=True on recipe steps with NULL + cr.execute(""" + UPDATE fusion_plating_process_node + SET collect_measurements = TRUE + WHERE collect_measurements IS NULL + """) + _logger.info( + "Backfilled collect_measurements=True on %s recipe steps", cr.rowcount + ) + + # 3. Re-seed defaults on every existing template (idempotent) + Template = env['fp.step.template'] + templates = Template.search([('default_kind', '!=', False)]) + for tpl in templates: + try: + tpl.action_seed_default_inputs() + except Exception as e: + _logger.warning( + "Failed to re-seed defaults on template %s: %s", tpl.id, e + ) + _logger.info("Re-seeded defaults on %s templates", len(templates)) + + # 4. Backfill template_input_id — name-match recipe-node inputs against + # their parent recipe's source library template. + # Note: fusion_plating_process_node_input.name is plain varchar; + # fp_step_template_input.name is translatable JSONB (use ->>'en_US'). + cr.execute(""" + SELECT ni.id, ni.name, n.source_template_id + FROM fusion_plating_process_node_input ni + JOIN fusion_plating_process_node n ON n.id = ni.node_id + WHERE ni.template_input_id IS NULL + AND n.source_template_id IS NOT NULL + """) + rows = cr.fetchall() + matched = 0 + for ni_id, name, tpl_id in rows: + if not name: + continue + cr.execute(""" + SELECT id FROM fp_step_template_input + WHERE template_id = %s + AND name->>'en_US' = %s + LIMIT 1 + """, (tpl_id, name)) + match = cr.fetchone() + if match: + cr.execute(""" + UPDATE fusion_plating_process_node_input + SET template_input_id = %s WHERE id = %s + """, (match[0], ni_id)) + matched += 1 + _logger.info( + "Backfilled template_input_id on %s recipe-step inputs", matched + ) diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index 5bf1cd67..d36ff083 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -103,6 +103,15 @@ class FpProcessNode(models.Model): string='Description', help='Rich text instructions for this step.', ) + # Sub 12d — master switch for runtime data collection. When False the + # operator wizard skips this step entirely (no input prompts shown). + collect_measurements = fields.Boolean( + string='Collect Measurements at Runtime', + default=True, + help='Master switch. When off, the operator wizard skips this step ' + 'entirely (no input prompts shown). Use for housekeeping steps ' + 'or when no measurement is needed for this recipe.', + ) notes = fields.Text( string='Internal Notes', help='Internal notes (not shown to customers).', @@ -633,6 +642,10 @@ class FpProcessNodeInput(models.Model): ('signature', 'Signature'), ('location_picker', 'Location Picker'), ('customer_wo', 'Customer WO #'), + ('photo', 'Photo'), + ('multi_point_thickness', 'Multi-Point Thickness (avg)'), + ('bath_chemistry_panel', 'Bath Chemistry Panel'), + ('ph', 'pH'), ], string='Input Type', required=True, @@ -695,3 +708,21 @@ class FpProcessNodeInput(models.Model): ], string='Compliance Tag', default='none', ) + + # ===== Sub 12d — per-recipe configurability ============================= + collect = fields.Boolean( + string='Collect This Measurement', + default=True, + help='Toggle off to skip this prompt at runtime without deleting ' + 'it. Recipe authors use this to opt out of library-seeded ' + 'prompts without affecting the library itself.', + ) + template_input_id = fields.Many2one( + 'fp.step.template.input', + string='Source Library Prompt', + ondelete='set null', + help='Set when this row was snapshot-copied from a library template ' + 'prompt. Powers "Reset to Library Defaults" — rows where this ' + 'is False are treated as recipe-only custom prompts and survive ' + 'the reset.', + ) diff --git a/fusion_plating/fusion_plating/models/fp_step_template.py b/fusion_plating/fusion_plating/models/fp_step_template.py index 506ad9e0..3fe3eec9 100644 --- a/fusion_plating/fusion_plating/models/fp_step_template.py +++ b/fusion_plating/fusion_plating/models/fp_step_template.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import api, fields, models +from odoo import _, api, fields, models class FpStepTemplate(models.Model): @@ -75,22 +75,30 @@ class FpStepTemplate(models.Model): help='Opens the transition form before Mark Done (Sub 12b).') default_kind = fields.Selection([ - ('cleaning', 'Cleaning'), - ('etch', 'Etch'), - ('rinse', 'Rinse'), - ('plate', 'Plating'), - ('bake', 'Bake'), - ('inspect', 'Inspection'), + ('receiving', 'Receiving / Incoming Inspection'), + ('contract_review', 'Contract Review (QA-005)'), ('racking', 'Racking'), - ('derack', 'De-Racking'), ('mask', 'Masking'), - ('demask', 'De-Masking'), - ('dry', 'Drying'), + ('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'), - ('contract_review', 'Contract Review (QA-005)'), ], string='Step Kind', help='Drives sane-default input seeding.') input_template_ids = fields.One2many( @@ -138,43 +146,183 @@ class FpStepTemplate(models.Model): # each → 'each', min → 'min'. Format-only strings ('HH:MM') get # left blank since they're not units. DEFAULT_INPUTS_BY_KIND = { + 'receiving': [ + {'name': 'Qty Received', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10, 'required': True}, + {'name': 'Qty Rejected', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 20}, + {'name': 'Customer PO# Verified', 'input_type': 'boolean', 'sequence': 30}, + {'name': 'Packing Slip #', 'input_type': 'text', 'sequence': 40}, + {'name': 'Condition Notes', 'input_type': 'text', 'sequence': 50}, + {'name': 'Damage Photo', 'input_type': 'photo', 'sequence': 60}, + {'name': 'Inspector Initials', 'input_type': 'signature', + 'sequence': 70, 'required': True}, + ], 'cleaning': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 30}, + {'name': 'Ultrasonic On', 'input_type': 'boolean', 'sequence': 40}, + {'name': 'Titration Done', 'input_type': 'boolean', 'sequence': 50}, + ], + 'electroclean': [ + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 's', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': 'f', 'sequence': 20}, + {'name': 'Amperage', 'input_type': 'number', 'sequence': 30, + 'hint': 'A'}, + {'name': 'Voltage', 'input_type': 'number', 'sequence': 40, + 'hint': 'V'}, + {'name': 'Current Density', 'input_type': 'number', 'sequence': 50, + 'hint': 'ASF (A per sq ft)'}, + {'name': 'Polarity', 'input_type': 'selection', 'sequence': 60, + 'selection_options': 'anodic,cathodic,periodic'}, + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 70}, ], 'etch': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, + {'name': 'Acid Concentration', 'input_type': 'number', 'sequence': 30, + 'hint': '% or g/L'}, + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 40}, + {'name': 'HE Risk Flag', 'input_type': 'boolean', 'sequence': 50, + 'hint': 'Hydrogen Embrittlement risk for high-strength steel'}, + ], + 'rinse': [ + {'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10, + 'selection_options': 'cascade,spray,DI,city'}, + {'name': 'Conductivity', 'input_type': 'number', 'sequence': 20, + 'hint': 'µS/cm — required for DI rinses'}, + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 's', 'sequence': 30}, + ], + 'strike': [ + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 's', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': 'f', 'sequence': 20}, + {'name': 'Amperage', 'input_type': 'number', 'sequence': 30, + 'hint': 'A'}, + {'name': 'Voltage', 'input_type': 'number', 'sequence': 40, + 'hint': 'V'}, + {'name': 'Current Density', 'input_type': 'number', 'sequence': 50, + 'hint': 'ASF'}, + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 60}, ], - 'rinse': [], 'plate': [ {'name': 'Actual Time', 'input_type': 'time_hms', 'target_unit': 'min', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, - {'name': 'Plating Thickness', 'input_type': 'thickness', - 'target_unit': 'in', 'sequence': 30}, + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 30}, + {'name': 'pH', 'input_type': 'ph', 'sequence': 40}, + {'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50, + 'hint': 'g/L'}, + {'name': 'Current Density', 'input_type': 'number', 'sequence': 60, + 'hint': 'ASF — electroplate only'}, + {'name': 'Plating Thickness', 'input_type': 'multi_point_thickness', + 'target_unit': 'in', 'sequence': 70}, ], - 'bake': [ - {'name': 'Time In', 'input_type': 'text', 'sequence': 10}, - {'name': 'Time Out', 'input_type': 'text', 'sequence': 20}, + 'replenishment': [ + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 10, + 'required': True}, + {'name': 'Chemistry Added', 'input_type': 'text', 'sequence': 20, + 'hint': 'name + amount, e.g. "Nickel sulfamate 500mL"'}, + {'name': 'pH Before', 'input_type': 'ph', 'sequence': 30}, + {'name': 'pH After', 'input_type': 'ph', 'sequence': 40}, + {'name': 'Concentration Before', 'input_type': 'number', 'sequence': 50}, + {'name': 'Concentration After', 'input_type': 'number', 'sequence': 60}, + {'name': 'Operator Initials', 'input_type': 'signature', + 'sequence': 70, 'required': True}, + ], + 'wbf_test': [ + {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10, + 'required': True}, + {'name': 'Retest Count', 'input_type': 'number', 'sequence': 20}, + {'name': 'Photo on FAIL', 'input_type': 'photo', 'sequence': 30}, + ], + 'dry': [ + {'name': 'Dry Method', 'input_type': 'selection', 'sequence': 10, + 'selection_options': 'hot air,oven,spin'}, + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 's', 'sequence': 20}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 30}, ], + 'bake': [ + {'name': 'Time In', 'input_type': 'date', 'sequence': 10}, + {'name': 'Time Out', 'input_type': 'date', 'sequence': 20}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': 'f', 'sequence': 30}, + {'name': 'Oven ID', 'input_type': 'text', 'sequence': 40}, + {'name': 'Chart Recorder File', 'input_type': 'photo', 'sequence': 50, + 'hint': 'Attach AMS-2759 chart-recorder file'}, + ], 'racking': [ {'name': 'Actual Qty', 'input_type': 'number', - 'target_unit': 'each', 'sequence': 10}, + 'target_unit': 'each', 'sequence': 10, 'required': True}, + {'name': 'Rack ID', 'input_type': 'text', 'sequence': 20}, + {'name': 'Masking Applied', 'input_type': 'boolean', 'sequence': 30}, + {'name': 'Photo of Racked Load', 'input_type': 'photo', 'sequence': 40}, ], 'derack': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10}, + {'name': 'Mask Removal Method', 'input_type': 'selection', 'sequence': 20, + 'selection_options': 'mechanical,solvent,thermal,not applicable'}, + {'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 30}, + ], + 'mask': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + {'name': 'Mask Material', 'input_type': 'selection', 'sequence': 20, + 'selection_options': 'Microshield,latex tape,vinyl plugs,wax,other'}, + {'name': 'Photo of Masked Parts', 'input_type': 'photo', 'sequence': 30}, + ], + 'demask': [ + {'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 10}, + {'name': 'Surface Condition', 'input_type': 'selection', 'sequence': 20, + 'selection_options': 'clean,marks,needs rework'}, ], 'inspect': [ - {'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10}, + {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10, + 'required': True}, + {'name': 'Defect Type', 'input_type': 'selection', 'sequence': 20, + 'selection_options': 'pitting,burn,blister,peel,missing coverage,none'}, + {'name': 'Thickness Sample', 'input_type': 'thickness', + 'target_unit': 'in', 'sequence': 30}, + {'name': 'Photo', 'input_type': 'photo', 'sequence': 40}, + {'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 50}, + ], + 'hardness_test': [ + {'name': 'Test Load', 'input_type': 'number', 'sequence': 10, + 'hint': 'gf'}, + {'name': 'Readings (HV/HK/HRC)', 'input_type': 'multi_point_thickness', + 'sequence': 20, 'hint': 'Three indents minimum'}, + {'name': 'Equipment ID', 'input_type': 'text', 'sequence': 30}, + {'name': 'Last Calibration Date', 'input_type': 'date', 'sequence': 40}, + ], + 'adhesion_test': [ + {'name': 'Test Method', 'input_type': 'selection', 'sequence': 10, + 'selection_options': 'bend,tape,burnish,file'}, + {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20, + 'required': True}, + {'name': 'Photo of Coupon', 'input_type': 'photo', 'sequence': 30}, + ], + 'salt_spray': [ + {'name': 'Test Duration', 'input_type': 'number', 'sequence': 10, + 'hint': 'hours'}, + {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20, + 'required': True}, + {'name': 'Red Rust %', 'input_type': 'number', 'sequence': 30}, + {'name': 'White Corrosion %', 'input_type': 'number', 'sequence': 40}, + {'name': 'Lab Report', 'input_type': 'photo', 'sequence': 50, + 'hint': 'Attach scanned lab report'}, ], 'final_inspect': [ {'name': 'Outgoing Part Count Verified', @@ -183,35 +331,80 @@ class FpStepTemplate(models.Model): 'target_unit': 'each', 'sequence': 20}, {'name': 'Qty Rejected', 'input_type': 'number', 'target_unit': 'each', 'sequence': 30}, + {'name': 'Defect Categorization', 'input_type': 'selection', 'sequence': 35, + 'selection_options': 'pitting,burn,blister,peel,missing coverage,dimensional,none'}, {'name': 'Actual Coating Thickness', - 'input_type': 'thickness', 'target_unit': 'in', 'sequence': 40}, - {'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50}, + 'input_type': 'multi_point_thickness', + 'target_unit': 'in', 'sequence': 40}, + {'name': 'Dimensional Verification', 'input_type': 'pass_fail', + 'sequence': 45}, + {'name': 'Surface Finish (Ra)', 'input_type': 'number', 'sequence': 47, + 'hint': 'µin'}, + {'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50, + 'required': True}, + {'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 60}, ], - 'wbf_test': [ - {'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10}, + 'packaging': [ + {'name': 'Packaging Type', 'input_type': 'selection', 'sequence': 10, + 'selection_options': 'VCI bag,bubble wrap,separator paper,custom crate,other'}, + {'name': 'Qty Per Package', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 20}, + {'name': 'Package Count', 'input_type': 'number', 'sequence': 30}, + {'name': 'Cert Package Included', 'input_type': 'boolean', 'sequence': 40}, + {'name': 'Customer-Supplied Packaging', 'input_type': 'boolean', + 'sequence': 50}, ], - 'mask': [ - {'name': 'Actual Qty', 'input_type': 'number', - 'target_unit': 'each', 'sequence': 10}, - ], - 'demask': [], - 'dry': [], 'ship': [ {'name': 'Outgoing Qty', 'input_type': 'number', - 'target_unit': 'each', 'sequence': 10}, + 'target_unit': 'each', 'sequence': 10, 'required': True}, + {'name': 'Carrier', 'input_type': 'selection', 'sequence': 20, + 'selection_options': 'UPS,FedEx,Purolator,Customer Pickup,Other'}, + {'name': 'Tracking #', 'input_type': 'text', 'sequence': 30}, + {'name': 'BoL #', 'input_type': 'text', 'sequence': 40}, + {'name': 'Photo of Sealed Shipment', 'input_type': 'photo', + 'sequence': 50}, ], 'gating': [], - # Sub 4 + 12c follow-up — Contract Review step (Policy B). - # The shop-floor step itself is a tickbox; the heavy QA-005 form - # is opened via fp.contract.review (separate model). These - # inputs capture summary fields for the chronological CoC. 'contract_review': [ - {'name': 'Reviewer Initials', 'input_type': 'text', 'sequence': 10}, + {'name': 'Reviewer Initials', 'input_type': 'signature', 'sequence': 10}, {'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20}, {'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30}, ], } + COMMON_AUDIT_FIELDS = [ + {'name': 'Operator Initials', 'input_type': 'signature', + 'required': True, 'sequence': 800}, + {'name': 'Bath ID', 'input_type': 'text', 'sequence': 810}, + {'name': 'Photo on Failure', 'input_type': 'photo', 'sequence': 820, + 'hint': 'upload only if failure observed'}, + {'name': 'Equipment ID', 'input_type': 'text', 'sequence': 830}, + ] + + def action_add_common_audit_fields(self): + """Idempotently append the common audit fields to this template. + Skips rows whose name already exists. Logs to chatter. + """ + Input = self.env['fp.step.template.input'] + for tpl in self: + existing_names = set(tpl.input_template_ids.mapped('name')) + added = [] + for spec in self.COMMON_AUDIT_FIELDS: + if spec['name'] in existing_names: + continue + Input.create({ + 'template_id': tpl.id, + **spec, + }) + added.append(spec['name']) + if added: + tpl.message_post( + body=_('Added common audit fields: %s') % ', '.join(added), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + return True + 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. diff --git a/fusion_plating/fusion_plating/models/fp_step_template_input.py b/fusion_plating/fusion_plating/models/fp_step_template_input.py index 3ef93436..25cb2c09 100644 --- a/fusion_plating/fusion_plating/models/fp_step_template_input.py +++ b/fusion_plating/fusion_plating/models/fp_step_template_input.py @@ -36,6 +36,10 @@ class FpStepTemplateInput(models.Model): ('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_min = fields.Float(string='Target Min', help='Lower bound of the acceptable range, expressed in Target Unit.') diff --git a/fusion_plating/fusion_plating/scripts/bt_step_library_audit.py b/fusion_plating/fusion_plating/scripts/bt_step_library_audit.py new file mode 100644 index 00000000..affd4f3f --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_step_library_audit.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +"""Battle test — Step Library audit expansion (Sub 12d). + +Run via odoo-shell on entech: + + cat bt_step_library_audit.py | ssh pve-worker5 "pct exec 111 -- bash -c \\ + 'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'" + +Asserts properties of the new architecture and prints PASS/FAIL. +""" + +NEW_KINDS = [ + 'receiving', 'electroclean', 'strike', 'salt_spray', + 'adhesion_test', 'hardness_test', 'packaging', 'replenishment', +] + +results = [] + + +def check(idx, name, condition, detail=''): + status = 'PASS' if condition else 'FAIL' + results.append((idx, name, status, detail)) + print('[%s] #%-2d %s -- %s' % (status, idx, name, detail)) + + +Template = env['fp.step.template'] +Node = env['fusion.plating.process.node'] +NodeInput = env['fusion.plating.process.node.input'] + +# 1. Every new Step Kind has at least 1 seed template loaded +for kind in NEW_KINDS: + cnt = Template.search_count([('default_kind', '=', kind)]) + check(1, 'seed template for kind %s' % kind, cnt >= 1, + '%d found' % cnt) + +# 2. New input types reachable from the library Selection +itypes = dict(Template._fields['default_kind'].selection) +all_kinds_present = all(k in itypes for k in NEW_KINDS) +check(2, 'all 8 new kinds in Selection', all_kinds_present, + 'kinds=%d total in selection' % len(itypes)) + +# 3. fp.step.template.input has the 4 new input_type entries +ti = dict(env['fp.step.template.input']._fields['input_type'].selection) +new_types_present = all(t in ti for t in + ['photo', 'multi_point_thickness', + 'bath_chemistry_panel', 'ph']) +check(3, 'library input has 4 new types', new_types_present, + '%d total types' % len(ti)) + +# 4. Recipe-node input has the 4 new input_type entries +ni = dict(NodeInput._fields['input_type'].selection) +new_types_in_node = all(t in ni for t in + ['photo', 'multi_point_thickness', + 'bath_chemistry_panel', 'ph']) +check(4, 'recipe-node input has 4 new types', new_types_in_node, + '%d total types' % len(ni)) + +# 5. collect + collect_measurements + template_input_id fields exist +check(5, 'collect on node-input', 'collect' in NodeInput._fields, + 'present' if 'collect' in NodeInput._fields else 'missing') +check(6, 'collect_measurements on node', 'collect_measurements' in Node._fields, + 'present') +check(7, 'template_input_id on node-input', 'template_input_id' in NodeInput._fields, + 'present') + +# 8. action_seed_default_inputs is idempotent + preserves edits +tpl = Template.create({ + 'name': 'BT-SeedIdem-%s' % env.cr.now(), + 'default_kind': 'plate', +}) +tpl.action_seed_default_inputs() +n1 = len(tpl.input_template_ids) +# user edit +tpl.input_template_ids[0].name = 'EDITED-DO-NOT-CLOBBER' +tpl.action_seed_default_inputs() +n2 = len(tpl.input_template_ids) +edited = tpl.input_template_ids.filtered( + lambda i: i.name == 'EDITED-DO-NOT-CLOBBER' +) +check(8, 'seed idempotent + preserves edits', + n1 <= n2 and len(edited) == 1, + 'before=%d after=%d edited_kept=%s' % (n1, n2, bool(edited))) +tpl.unlink() + +# 9. action_add_common_audit_fields is idempotent +tpl = Template.create({ + 'name': 'BT-AuditIdem-%s' % env.cr.now(), + 'default_kind': 'plate', +}) +tpl.action_add_common_audit_fields() +m1 = len(tpl.input_template_ids) +tpl.action_add_common_audit_fields() +m2 = len(tpl.input_template_ids) +check(9, 'common audit fields idempotent', m1 == m2, + 'first=%d second=%d' % (m1, m2)) +tpl.unlink() + +# 10. collect=True is default on new node-inputs +node = Node.create({ + 'name': 'BT-CollectDefault', + 'node_type': 'step', +}) +ni = NodeInput.create({ + 'node_id': node.id, + 'name': 'BT-Prompt', + 'input_type': 'text', + 'kind': 'step_input', +}) +check(10, 'collect default=True on new node-input', ni.collect, + 'collect=%s' % ni.collect) + +# 11. collect_measurements=True default on new node +check(11, 'collect_measurements default=True on new node', + node.collect_measurements, + 'collect_measurements=%s' % node.collect_measurements) +node.unlink() + +# 12. Wizard filter excludes collect=False rows (simulated) +node = Node.create({'name': 'BT-Filter', 'node_type': 'step'}) +ni_on = NodeInput.create({ + 'node_id': node.id, 'name': 'On', 'input_type': 'text', + 'kind': 'step_input', 'collect': True, +}) +ni_off = NodeInput.create({ + 'node_id': node.id, 'name': 'Off', 'input_type': 'text', + 'kind': 'step_input', 'collect': False, +}) +visible = node.input_ids.filtered( + lambda i: i.kind == 'step_input' and i.collect +) +check(12, 'wizard filter excludes collect=False', + ni_off not in visible and ni_on in visible, + '%d/%d visible' % (len(visible), len(node.input_ids))) + +# 13. Master switch path — when False, filter returns empty +node.collect_measurements = False +empty_path = (not node.collect_measurements) +check(13, 'master collect_measurements=False short-circuits', + empty_path, 'master=False') +node.unlink() + +# 14. Multi-point thickness average compute (unit math, no DB) +class _Stub: + def __init__(self, *vals): + self.point_1, self.point_2, self.point_3, \ + self.point_4, self.point_5 = vals + non_empty = [v for v in vals if v] + self.point_avg = sum(non_empty) / len(non_empty) if non_empty else 0 +s = _Stub(0.001, 0.0012, 0.0011, 0, 0) +check(14, 'multi-point avg skips empties', + round(s.point_avg, 5) == 0.0011, + 'avg=%.5f' % s.point_avg) + +# 15. Sample DEFAULT_INPUTS_BY_KIND payload present for each new kind +for kind in NEW_KINDS: + seeded = Template.DEFAULT_INPUTS_BY_KIND.get(kind, []) + check(15, 'defaults dict has entries for %s' % kind, + len(seeded) >= 1, + '%d default prompts' % len(seeded)) + +# Summary +total = len(results) +passed = sum(1 for r in results if r[2] == 'PASS') +failed = sum(1 for r in results if r[2] == 'FAIL') +print('\n=== %d / %d PASSED -- %d FAILED ===' % (passed, total, failed)) + +env.cr.commit() diff --git a/fusion_plating/fusion_plating/views/fp_process_node_views.xml b/fusion_plating/fusion_plating/views/fp_process_node_views.xml index 7f086e2e..5bd4014e 100644 --- a/fusion_plating/fusion_plating/views/fp_process_node_views.xml +++ b/fusion_plating/fusion_plating/views/fp_process_node_views.xml @@ -117,16 +117,28 @@ + + + + + + + + - + diff --git a/fusion_plating/fusion_plating/views/fp_step_template_views.xml b/fusion_plating/fusion_plating/views/fp_step_template_views.xml index 9af2dd93..8b690457 100644 --- a/fusion_plating/fusion_plating/views/fp_step_template_views.xml +++ b/fusion_plating/fusion_plating/views/fp_step_template_views.xml @@ -33,6 +33,10 @@