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 @@
+
@@ -58,9 +62,14 @@
-
+
+
+ Standing instructions the office gives operators for this
+ step. Snapshot-copied onto every recipe that uses this
+ step. Recipe authors can override per recipe.
+
+ placeholder="e.g. Mask threaded holes with vinyl plugs. Use Microshield for through-holes."/>
diff --git a/fusion_plating/fusion_plating_configurator/__init__.py b/fusion_plating/fusion_plating_configurator/__init__.py
index 9b8deb79..a5c84cd0 100644
--- a/fusion_plating/fusion_plating_configurator/__init__.py
+++ b/fusion_plating/fusion_plating_configurator/__init__.py
@@ -75,11 +75,55 @@ def _backfill_cloned_process_names(env):
renamed += 1
+def _backfill_part_material_id(env):
+ """Pin existing parts AND quote configurators to a row in the
+ shared material library.
+
+ Pre-Sub-12d, both models only had a `substrate_material` Selection.
+ This sets `material_id` on every record that doesn't yet have one,
+ matching by substrate_material → seed material XML id. Idempotent.
+ """
+ Part = env['fp.part.catalog']
+ Material = env['fp.part.material']
+ if Part is None or Material is None:
+ return
+ # Map legacy Selection key → seed XML id (the generic per-category entry).
+ xmlid_by_key = {
+ 'aluminium': 'fusion_plating_configurator.fp_material_aluminium',
+ 'steel': 'fusion_plating_configurator.fp_material_steel',
+ 'stainless': 'fusion_plating_configurator.fp_material_stainless',
+ 'copper': 'fusion_plating_configurator.fp_material_copper',
+ 'titanium': 'fusion_plating_configurator.fp_material_titanium',
+ 'other': 'fusion_plating_configurator.fp_material_other',
+ }
+ cache = {}
+ for key, xmlid in xmlid_by_key.items():
+ rec = env.ref(xmlid, raise_if_not_found=False)
+ if rec:
+ cache[key] = rec.id
+ if not cache:
+ return
+ # Parts
+ for part in Part.search([('material_id', '=', False)]):
+ mid = cache.get(part.substrate_material)
+ if mid:
+ part.material_id = mid
+ # Quote configurators (same Selection key → same library)
+ Quote = env['fp.quote.configurator']
+ if Quote is not None:
+ for q in Quote.search([('material_id', '=', False)]):
+ mid = cache.get(q.substrate_material)
+ if mid:
+ q.material_id = mid
+
+
def post_init_hook(env):
_backfill_currency(env)
_backfill_cloned_process_names(env)
+ _backfill_part_material_id(env)
def post_upgrade_hook(env):
_backfill_currency(env)
_backfill_cloned_process_names(env)
+ _backfill_part_material_id(env)
diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py
index 9f9265b5..39592016 100644
--- a/fusion_plating/fusion_plating_configurator/__manifest__.py
+++ b/fusion_plating/fusion_plating_configurator/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
- 'version': '19.0.18.3.2',
+ 'version': '19.0.18.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -40,7 +40,10 @@ Provides:
'data/fp_configurator_sequence_data.xml',
'data/fp_sub5_sequence_data.xml',
'data/fp_treatment_data.xml',
+ 'data/fp_part_material_data.xml',
'views/fp_treatment_views.xml',
+ 'views/fp_part_material_views.xml',
+ 'views/fp_coating_thickness_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_process_node_part_scoped_views.xml',
'views/fp_coating_config_views.xml',
diff --git a/fusion_plating/fusion_plating_configurator/data/fp_part_material_data.xml b/fusion_plating/fusion_plating_configurator/data/fp_part_material_data.xml
new file mode 100644
index 00000000..e63013e7
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/data/fp_part_material_data.xml
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+ Aluminium
+ aluminium
+ 10
+
+
+ Aluminium 6061
+ aluminium
+ 11
+ Common 6000-series alloy. Magnesium + silicon.
+
+
+ Aluminium 6063
+ aluminium
+ 12
+ Architectural 6000-series alloy.
+
+
+ Aluminium 7075
+ aluminium
+ 13
+ High-strength 7000-series. Aerospace.
+
+
+ Aluminium 2024
+ aluminium
+ 14
+ 2000-series. Copper alloy, aerospace.
+
+
+
+
+ Steel
+ steel
+ 20
+
+
+ Steel 1018
+ steel
+ 21
+ Low-carbon mild steel.
+
+
+ Steel 4140
+ steel
+ 22
+ Chrome-moly alloy steel.
+
+
+
+
+ Stainless Steel
+ stainless
+ 30
+
+
+ Stainless 304
+ stainless
+ 31
+ Austenitic. General-purpose stainless.
+
+
+ Stainless 316
+ stainless
+ 32
+ Marine-grade. Molybdenum-bearing.
+
+
+ Stainless 17-4 PH
+ stainless
+ 33
+ Precipitation hardening.
+
+
+
+
+ Copper
+ copper
+ 40
+
+
+ Brass C360
+ copper
+ 41
+ 8.5
+ Free-machining brass.
+
+
+ Bronze
+ copper
+ 42
+ 8.8
+
+
+
+
+ Titanium
+ titanium
+ 50
+
+
+ Titanium Grade 2
+ titanium
+ 51
+ Commercially pure titanium.
+
+
+ Titanium Grade 5 (Ti-6Al-4V)
+ titanium
+ 52
+ 4.43
+ Aerospace alloy.
+
+
+
+
+ Other
+ other
+ 99
+
+
+
diff --git a/fusion_plating/fusion_plating_configurator/models/__init__.py b/fusion_plating/fusion_plating_configurator/models/__init__.py
index 5e21cc87..849d8a79 100644
--- a/fusion_plating/fusion_plating_configurator/models/__init__.py
+++ b/fusion_plating/fusion_plating_configurator/models/__init__.py
@@ -4,6 +4,7 @@
# Part of the Fusion Plating product family.
from . import fp_treatment
+from . import fp_part_material
from . import fp_part_catalog
from . import fp_coating_thickness
from . import fp_coating_config
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py
index 77bc1008..1702877b 100644
--- a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py
+++ b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py
@@ -19,6 +19,11 @@ class FpPartCatalog(models.Model):
_description = 'Fusion Plating — Part Catalog'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'partner_id, part_number, revision desc'
+ # Customers always type the part NUMBER in m2o pickers, never the part
+ # name. Routing rec_name to part_number makes both quick-create and
+ # "Create and edit..." land the typed string in the correct field.
+ _rec_name = 'part_number'
+ _rec_names_search = ['part_number', 'name']
display_name = fields.Char(
string='Display Name',
@@ -44,10 +49,26 @@ class FpPartCatalog(models.Model):
revision_ids = fields.One2many(
'fp.part.catalog', 'parent_part_id', string='Revision History',
)
+ # User-facing material picker. Customers want custom materials
+ # (e.g. "Aluminium 6061", "Stainless 316") so this is a m2o into
+ # `fp.part.material`. The legacy `substrate_material` Selection
+ # below is now a stored compute that mirrors `material_id.category`,
+ # which keeps pricing rules / portal / import wizard working
+ # untouched (they still match against the category keys).
+ material_id = fields.Many2one(
+ 'fp.part.material', string='Material', tracking=True,
+ ondelete='restrict',
+ help='Pick from the material library or create a custom entry '
+ '(e.g. "Aluminium 6061", "Stainless 316", "Brass C360").',
+ )
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
- string='Substrate Material', default='steel',
+ string='Material Category', default='steel',
+ compute='_compute_substrate_material',
+ store=True, readonly=False,
+ help='Auto-derived from the selected material. Drives pricing '
+ 'rule matching and density defaults.',
)
geometry_source = fields.Selection(
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
@@ -76,6 +97,13 @@ class FpPartCatalog(models.Model):
string='Surface Area UoM', default='sq_in',
)
weight = fields.Float(string='Weight (kg)', digits=(12, 4))
+ x_fc_default_lead_time_days = fields.Integer(
+ string='Default Lead Time (days)',
+ help='Optional. How many days from the order\'s planned-start-date '
+ 'this part typically needs. Used as a smart default on order '
+ 'lines when no explicit deadline is set. Leave 0 to fall back '
+ 'to the order\'s customer deadline.',
+ )
dimensions_length = fields.Float(string='Length', digits=(12, 4))
dimensions_width = fields.Float(string='Width', digits=(12, 4))
dimensions_height = fields.Float(string='Height', digits=(12, 4))
@@ -224,13 +252,34 @@ class FpPartCatalog(models.Model):
'other': 7.85, # default to steel
}
- @api.depends('volume_mm3', 'substrate_material')
+ @api.depends('material_id', 'material_id.category')
+ def _compute_substrate_material(self):
+ """Mirror the m2o material's category onto the legacy field.
+
+ Editable: existing parts without a material_id keep whatever
+ value they had (default 'steel'), and admins can still flip
+ the category by hand if needed.
+ """
+ for rec in self:
+ if rec.material_id:
+ rec.substrate_material = rec.material_id.category
+ elif not rec.substrate_material:
+ rec.substrate_material = 'steel'
+
+ @api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
def _compute_material_weight(self):
for rec in self:
- if not rec.volume_mm3 or not rec.substrate_material:
+ if not rec.volume_mm3:
+ rec.material_weight_kg = 0.0
+ continue
+ # Prefer per-material density override; fall back to category default.
+ if rec.material_id:
+ density = rec.material_id.effective_density()
+ elif rec.substrate_material:
+ density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
+ else:
rec.material_weight_kg = 0.0
continue
- density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
# mm³ × g/cm³ × 1e-6 = kg
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
@@ -292,6 +341,27 @@ class FpPartCatalog(models.Model):
'Part number must be unique per customer.'),
]
+ @api.model
+ def default_get(self, fields_list):
+ """Re-route the m2o-typed string into part_number.
+
+ Odoo 19's m2o "Create and edit..." passes the typed text via
+ context as `default_name` regardless of the target model's
+ `_rec_name`. Customers always type the part NUMBER in the part
+ picker, so we swap it across when part_number wasn't provided
+ explicitly. The legacy `default_name` is dropped so the Part
+ Name field stays empty for the user to fill in (or leave blank).
+ """
+ ctx = self.env.context
+ if ctx.get('default_name') and not ctx.get('default_part_number'):
+ # with_context merges, so explicitly clear default_name to
+ # stop the typed string from also seeding the Part Name.
+ self = self.with_context(
+ default_part_number=ctx['default_name'],
+ default_name=False,
+ )
+ return super().default_get(fields_list)
+
def write(self, vals):
"""Track changes to attachments and propagate to linked configurators."""
# Snapshot before write
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_material.py b/fusion_plating/fusion_plating_configurator/models/fp_part_material.py
new file mode 100644
index 00000000..e5e43674
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/models/fp_part_material.py
@@ -0,0 +1,61 @@
+# -*- 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
+
+
+class FpPartMaterial(models.Model):
+ """Custom material library.
+
+ Lets shops define their own materials (e.g. "Aluminium 6061",
+ "Stainless 316", "Brass C360") instead of being limited to the
+ fixed Selection. Each material maps to a `category` that drives
+ legacy pricing-rule matching and the default density used for
+ material-weight rollups.
+ """
+ _name = 'fp.part.material'
+ _description = 'Fusion Plating — Part Material'
+ _order = 'sequence, name'
+ _rec_name = 'name'
+
+ name = fields.Char(string='Material', required=True, translate=False)
+ sequence = fields.Integer(string='Sequence', default=10)
+ category = fields.Selection(
+ [('aluminium', 'Aluminium'), ('steel', 'Steel'),
+ ('stainless', 'Stainless Steel'), ('copper', 'Copper'),
+ ('titanium', 'Titanium'), ('other', 'Other')],
+ string='Category', required=True, default='other',
+ help='Used for pricing-rule matching and to pick a default '
+ 'density when one is not set explicitly.',
+ )
+ density = fields.Float(
+ string='Density (g/cm³)', digits=(8, 4),
+ help='Override the category default. Leave 0 to use the '
+ 'category density (Aluminium 2.70, Steel 7.85, '
+ 'Stainless 8.00, Copper 8.96, Titanium 4.51).',
+ )
+ notes = fields.Char(string='Notes', help='Internal note (alloy spec, source, etc.).')
+ active = fields.Boolean(string='Active', default=True)
+
+ _CATEGORY_DENSITY = {
+ 'aluminium': 2.70,
+ 'steel': 7.85,
+ 'stainless': 8.00,
+ 'copper': 8.96,
+ 'titanium': 4.51,
+ 'other': 7.85,
+ }
+
+ _sql_constraints = [
+ ('fp_part_material_name_uniq', 'unique(name)',
+ 'Material name must be unique.'),
+ ]
+
+ def effective_density(self):
+ """Return density override if set, else the category default."""
+ self.ensure_one()
+ if self.density and self.density > 0:
+ return self.density
+ return self._CATEGORY_DENSITY.get(self.category, 7.85)
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py
index d68e346a..bada756b 100644
--- a/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py
+++ b/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py
@@ -116,9 +116,13 @@ class FpQuoteConfigurator(models.Model):
help='Surface area minus masked area, using the values on this quote.',
)
- @api.depends('volume_mm3', 'substrate_material')
+ @api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
def _compute_material_weight_kg(self):
- """Compute weight from part volume × THIS QUOTE'S substrate density."""
+ """Compute weight from part volume × THIS QUOTE'S substrate density.
+
+ Prefer the per-material density override; fall back to the
+ category default when only the legacy Selection is set.
+ """
density_map = {
'aluminium': 2.70,
'steel': 7.85,
@@ -128,10 +132,16 @@ class FpQuoteConfigurator(models.Model):
'other': 7.85,
}
for rec in self:
- if not rec.volume_mm3 or not rec.substrate_material:
+ if not rec.volume_mm3:
+ rec.material_weight_kg = 0.0
+ continue
+ if rec.material_id:
+ density = rec.material_id.effective_density()
+ elif rec.substrate_material:
+ density = density_map.get(rec.substrate_material, 7.85)
+ else:
rec.material_weight_kg = 0.0
continue
- density = density_map.get(rec.substrate_material, 7.85)
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
@@ -252,12 +262,35 @@ class FpQuoteConfigurator(models.Model):
('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity', default='simple',
)
+ # Single source of truth: pick a material from the shared library.
+ # `substrate_material` below is now a stored compute mirroring
+ # `material_id.category` so legacy consumers (pricing rules, portal,
+ # data exports) keep working unchanged.
+ material_id = fields.Many2one(
+ 'fp.part.material', string='Material',
+ ondelete='restrict',
+ help='Picks from the shared material library — same picker as '
+ 'the Part Catalog. Create custom alloys (e.g. "Aluminium '
+ '6061") on the fly.',
+ )
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
- string='Substrate', default='steel',
+ string='Material Category',
+ compute='_compute_substrate_material',
+ store=True, readonly=False, default='steel',
+ help='Auto-derived from the selected material. Drives pricing '
+ 'rule matching and density defaults.',
)
+ @api.depends('material_id', 'material_id.category')
+ def _compute_substrate_material(self):
+ for rec in self:
+ if rec.material_id:
+ rec.substrate_material = rec.material_id.category
+ elif not rec.substrate_material:
+ rec.substrate_material = 'steel'
+
# ----- Options ----------------------------------------------------------
rush_order = fields.Boolean(string='Rush Order')
turnaround_days = fields.Integer(string='Turnaround (days)')
@@ -302,7 +335,13 @@ class FpQuoteConfigurator(models.Model):
self.surface_area_uom = cat.surface_area_uom
self.complexity = cat.complexity
self.masking_zones = cat.masking_zones
- self.substrate_material = cat.substrate_material
+ # Pull the m2o material from the part — substrate_material
+ # auto-derives via the compute. Fall back to the legacy
+ # Selection only if the part has no material_id yet.
+ if cat.material_id:
+ self.material_id = cat.material_id
+ else:
+ self.substrate_material = cat.substrate_material
# Copy masking area too (for effective-area calculation)
self.masking_area_sqin = cat.masking_area_sqin
@@ -896,21 +935,26 @@ class FpQuoteConfigurator(models.Model):
def action_save_to_catalog(self):
"""Push this quote's geometry/material edits back to the master part catalog.
- Writes: substrate_material, surface_area, surface_area_uom,
- masking_area_sqin, masking_zones, complexity.
+ Writes: material_id (preferred) / substrate_material (fallback),
+ surface_area, surface_area_uom, masking_area_sqin,
+ masking_zones, complexity.
Only available when a part catalog entry is linked.
"""
self.ensure_one()
if not self.part_catalog_id:
raise UserError(_('No part catalog entry linked to this configurator.'))
- self.part_catalog_id.write({
- 'substrate_material': self.substrate_material,
+ vals = {
'surface_area': self.surface_area,
'surface_area_uom': self.surface_area_uom,
'masking_area_sqin': self.masking_area_sqin,
'masking_zones': self.masking_zones,
'complexity': self.complexity,
- })
+ }
+ if self.material_id:
+ vals['material_id'] = self.material_id.id
+ else:
+ vals['substrate_material'] = self.substrate_material
+ self.part_catalog_id.write(vals)
self.message_post(
body=Markup(_('Geometry and material saved back to part catalog %s.')) % self.part_catalog_id.name,
message_type='notification',
diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order.py b/fusion_plating/fusion_plating_configurator/models/sale_order.py
index 060ff459..69b97981 100644
--- a/fusion_plating/fusion_plating_configurator/models/sale_order.py
+++ b/fusion_plating/fusion_plating_configurator/models/sale_order.py
@@ -124,6 +124,22 @@ class SaleOrder(models.Model):
string='Deadline',
compute='_compute_deadline_countdown',
)
+ x_fc_order_completion_date = fields.Date(
+ string='Order Completion Date',
+ compute='_compute_order_completion_date',
+ store=True,
+ help='When the LATEST line is actually due. Auto-rolled up from '
+ 'each line\'s effective deadline. Distinct from Customer '
+ 'Deadline (what we promised) — this reflects shop reality.',
+ )
+ x_fc_is_late_forecast = fields.Boolean(
+ string='Late Forecast',
+ compute='_compute_is_late_forecast',
+ store=True,
+ help='True when the rolled-up Order Completion Date sits past the '
+ 'Customer Deadline. Suppressed on blanket orders since their '
+ 'spans are intentionally long.',
+ )
x_fc_margin_amount = fields.Monetary(
string='Margin',
compute='_compute_margin', currency_field='currency_id',
@@ -503,6 +519,38 @@ class SaleOrder(models.Model):
'overdue %s' % phrase if past else 'in %s' % phrase
)
+ @api.depends(
+ 'order_line.x_fc_effective_part_deadline',
+ 'order_line.x_fc_archived',
+ )
+ def _compute_order_completion_date(self):
+ """Roll up = max(line.x_fc_effective_part_deadline) over non-
+ archived lines. Empty / all-archived order returns False."""
+ for rec in self:
+ dates = [
+ line.x_fc_effective_part_deadline
+ for line in rec.order_line
+ if line.x_fc_effective_part_deadline and not line.x_fc_archived
+ ]
+ rec.x_fc_order_completion_date = max(dates) if dates else False
+
+ @api.depends(
+ 'x_fc_order_completion_date',
+ 'commitment_date',
+ 'x_fc_is_blanket_order',
+ )
+ def _compute_is_late_forecast(self):
+ for rec in self:
+ if rec.x_fc_is_blanket_order:
+ rec.x_fc_is_late_forecast = False
+ continue
+ commit = rec.commitment_date.date() if rec.commitment_date else False
+ rec.x_fc_is_late_forecast = bool(
+ rec.x_fc_order_completion_date
+ and commit
+ and rec.x_fc_order_completion_date > commit
+ )
+
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Margin = untaxed total − rolled-up cost from coating configs.
diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py
index 41d8bbcb..984c67b9 100644
--- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py
+++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py
@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
+from datetime import timedelta
+
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
@@ -63,7 +65,36 @@ class SaleOrderLine(models.Model):
x_fc_treatment_ids = fields.Many2many(
'fp.treatment', string='Additional Treatments',
)
- x_fc_part_deadline = fields.Date(string='Part Deadline')
+ x_fc_part_deadline = fields.Date(
+ string='Part Deadline Override',
+ help='Absolute-date manual override. When set, beats the days-offset '
+ 'and the part\'s default lead time. Leave blank to fall through '
+ 'to the offset, then part default, then the order\'s customer '
+ 'deadline.',
+ )
+ x_fc_part_deadline_offset_days = fields.Integer(
+ string='Days Offset',
+ help='Manual override expressed as "+N days from the order\'s '
+ 'customer deadline". Use this when you think in days rather '
+ 'than absolute dates. Ignored if Part Deadline Override is set.',
+ )
+ x_fc_effective_part_deadline = fields.Date(
+ string='Effective Deadline',
+ compute='_compute_effective_part_deadline',
+ store=True,
+ help='Computed deadline that actually drives shop scheduling. '
+ 'Resolution: explicit override → days offset → part default '
+ 'lead time → order customer deadline.',
+ )
+ x_fc_effective_internal_deadline = fields.Date(
+ string='Shop Target',
+ compute='_compute_effective_internal_deadline',
+ store=True,
+ help='Internal deadline for this line — effective customer '
+ 'deadline minus the order\'s shop buffer (commitment_date − '
+ 'internal_deadline gap). Clamped so it never exceeds the '
+ 'effective customer deadline.',
+ )
x_fc_rush_order = fields.Boolean(string='Rush')
x_fc_wo_group_tag = fields.Char(
string='Work Order Group',
@@ -181,6 +212,94 @@ class SaleOrderLine(models.Model):
def _compute_serial_count(self):
for line in self:
line.x_fc_serial_count = len(line.x_fc_serial_ids)
+
+ # ------------------------------------------------------------------
+ # Effective deadlines (Sub 12d)
+ # ------------------------------------------------------------------
+ @api.depends(
+ 'x_fc_part_deadline',
+ 'x_fc_part_deadline_offset_days',
+ 'x_fc_part_catalog_id',
+ 'x_fc_part_catalog_id.x_fc_default_lead_time_days',
+ 'order_id.commitment_date',
+ 'order_id.x_fc_planned_start_date',
+ )
+ def _compute_effective_part_deadline(self):
+ """Resolution chain (first match wins):
+ 1. explicit absolute-date override (x_fc_part_deadline)
+ 2. days offset from commitment_date (x_fc_part_deadline_offset_days)
+ 3. part's default lead time from planned_start_date
+ 4. order's commitment_date (= customer profile cascade)
+ 5. planned_start_date as last resort (orphan order with no deadline)
+ """
+ for line in self:
+ order = line.order_id
+ # commitment_date is a Datetime in Odoo standard; coerce to
+ # date for arithmetic with our Date fields.
+ commit_dt = order.commitment_date if order else False
+ commit = commit_dt.date() if commit_dt else False
+ start = (
+ order.x_fc_planned_start_date if order
+ else False
+ ) or fields.Date.context_today(line)
+
+ # 1. absolute-date override
+ if line.x_fc_part_deadline:
+ line.x_fc_effective_part_deadline = line.x_fc_part_deadline
+ continue
+ # 2. days offset from commitment
+ if line.x_fc_part_deadline_offset_days and commit:
+ line.x_fc_effective_part_deadline = (
+ commit + timedelta(days=line.x_fc_part_deadline_offset_days)
+ )
+ continue
+ # 3. part default lead time from planned_start
+ part_lead = (
+ line.x_fc_part_catalog_id
+ and line.x_fc_part_catalog_id.x_fc_default_lead_time_days
+ )
+ if part_lead:
+ line.x_fc_effective_part_deadline = (
+ start + timedelta(days=part_lead)
+ )
+ continue
+ # 4. order commitment (which itself derives from customer profile)
+ if commit:
+ line.x_fc_effective_part_deadline = commit
+ continue
+ # 5. last resort — planned start so the field is never null
+ line.x_fc_effective_part_deadline = start
+
+ @api.depends(
+ 'x_fc_effective_part_deadline',
+ 'order_id.commitment_date',
+ 'order_id.x_fc_internal_deadline',
+ )
+ def _compute_effective_internal_deadline(self):
+ """Apply the order's customer-vs-internal buffer to the line's
+ effective customer deadline. Buffer = commitment_date −
+ x_fc_internal_deadline (the gap implied by customer profile).
+ Clamp result so it never exceeds the customer deadline.
+ """
+ for line in self:
+ eff = line.x_fc_effective_part_deadline
+ if not eff:
+ line.x_fc_effective_internal_deadline = False
+ continue
+ order = line.order_id
+ commit_dt = order.commitment_date if order else False
+ commit = commit_dt.date() if commit_dt else False
+ internal = order.x_fc_internal_deadline if order else False
+ if commit and internal and commit >= internal:
+ buffer_days = (commit - internal).days
+ target = eff - timedelta(days=buffer_days)
+ # Clamp: internal can never sit after customer date
+ line.x_fc_effective_internal_deadline = (
+ target if target <= eff else eff
+ )
+ else:
+ # No buffer info → fall back to the customer date itself
+ line.x_fc_effective_internal_deadline = eff
x_fc_job_number = fields.Char(
string='Job #',
copy=False,
diff --git a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
index fd6f829a..8a823c51 100644
--- a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
@@ -49,3 +49,6 @@ access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bul
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
+access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
+access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
+access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_coating_thickness_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_coating_thickness_views.xml
new file mode 100644
index 00000000..d89aa353
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/views/fp_coating_thickness_views.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+ fp.coating.thickness.list
+ fp.coating.thickness
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.coating.thickness.form
+ fp.coating.thickness
+
+
+
+
+
+
+ fp.coating.thickness.search
+ fp.coating.thickness
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Coating Thicknesses
+ fp.coating.thickness
+ list,form
+
+
+
+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml b/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml
index 4563832f..13c6d4f8 100644
--- a/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml
+++ b/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml
@@ -111,4 +111,10 @@
action="action_fp_treatment"
sequence="40"/>
+
+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml
index 7b9caf1d..a4df5aa9 100644
--- a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml
@@ -16,7 +16,8 @@
-
+
+
@@ -116,7 +117,9 @@
-
+
+
@@ -135,6 +138,7 @@
+
@@ -324,6 +328,7 @@
+
@@ -340,7 +345,8 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_part_material_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_part_material_views.xml
new file mode 100644
index 00000000..2a7305d9
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/views/fp_part_material_views.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+ fp.part.material.list
+ fp.part.material
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.part.material.form
+ fp.part.material
+
+
+
+
+
+
+ fp.part.material.search
+ fp.part.material
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Materials
+ fp.part.material
+ list,form
+
+
+
No materials yet
+
Define the materials your shop processes. Each material
+ picks a category (Aluminium, Steel, etc.) used for pricing
+ rules and density-based weight calculations.