This commit is contained in:
gsinghpal
2026-04-28 19:39:37 -04:00
parent 2d42b33d68
commit 13e300d90e
103 changed files with 4959 additions and 331 deletions

View File

@@ -27,7 +27,32 @@ def post_init_hook(env):
_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):
@@ -60,6 +85,12 @@ def _backfill_node_input_kind(env):
# 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',
@@ -73,23 +104,85 @@ _STARTER_KIND_BY_NAME = {
'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.
@@ -135,7 +228,7 @@ def _create_template_from_node(env, node, seen):
return
seen.add(node.name.lower())
kind = _STARTER_KIND_BY_NAME.get(node.name.lower())
kind = fp_resolve_step_kind(node.name)
vals = {
'name': node.name,
'description': node.description or False,
@@ -164,6 +257,7 @@ 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'),
@@ -189,6 +283,38 @@ def _seed_minimal_library(env):
)
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']

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.11.3.0',
'version': '19.0.12.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.1.0 — Convert every free-text UoM column to the curated
selection keys defined in models/_fp_uom_selection.py.
Runs after fusion_plating's tables have been re-described (so the
columns are now Selection-typed at the ORM level), but before users
hit the new views. Idempotent — re-running maps already-converted
values to themselves and leaves them in place.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating.models._fp_uom_selection import (
fp_migrate_uom_column,
)
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = Environment(cr, 1, {}) # SUPERUSER
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 (only migrated when the module is installed — the
# helper is no-op when the table doesn't exist)
('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'),
]
total_rewritten = total_cleared = 0
for table, column, label in targets:
rewritten, cleared = fp_migrate_uom_column(env, table, column, label)
total_rewritten += rewritten
total_cleared += cleared
_logger.info(
'Fusion Plating 19.0.12.1.0 — UoM migration complete: '
'%s rewritten, %s cleared (across %s columns).',
total_rewritten, total_cleared, len(targets),
)

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.4.0 — Step-library polish + Policy B Contract Review backfill.
post_init_hook only fires on fresh install. Existing DBs upgrading
from pre-Policy-B versions need this migration to:
1. Add the missing 'Contract Review' library template (the
_seed_step_library_if_empty seeder skipped it because their
library was already populated when 19.0.12.3.0 landed).
2. Backfill default_kind on existing library entries that landed
without a kind because the original seeder used a brittle
case-sensitive lookup that missed common name variations
("E-Nickel Plating" vs "E-Nickel Plate", "DeRacking" vs
"De-Racking", "Ready for X" gating prefixes, etc.). The new
`fp_resolve_step_kind` helper is hyphen / case / -ing tolerant.
3. Add canonical missing entries (Soak Clean, Rinse, Etch, Acid Dip,
Drying, Inspection, Shipping, Water Break Test, Desmut, Zincate)
that ENP-ALUM-BASIC's seed didn't include — these are the names
a fresh estimator would expect to find when they open the library
from scratch. Without them, an empty recipe has no obvious starting
templates for cleaning / rinsing / standard inspection.
All three steps are idempotent — re-running on an already-fixed DB
is a no-op.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__)
CANONICAL_MISSING = [
('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'),
('Rinse', 'rinse'),
('Etch', 'etch'),
('Desmut', 'etch'),
('Zincate', 'etch'),
('Acid Dip', 'etch'),
('HCl Activation', 'etch'),
('Water Break Test', 'wbf_test'),
('Drying', 'dry'),
('Inspection', 'inspect'),
('Final Inspection', 'final_inspect'),
('Shipping', 'ship'),
('Contract Review', 'contract_review'),
]
def migrate(cr, version):
env = Environment(cr, 1, {}) # SUPERUSER
Tpl = env['fp.step.template']
# ---- 1. Backfill default_kind on existing library entries -----------
blank_kind = Tpl.search([('default_kind', '=', False)])
fixed = 0
for tpl in blank_kind:
kind = fp_resolve_step_kind(tpl.name)
if kind:
tpl.default_kind = kind
tpl.action_seed_default_inputs()
fixed += 1
_logger.info(
'Fusion Plating 19.0.12.4.0: backfilled default_kind on %s/%s '
'library entries via fp_resolve_step_kind.',
fixed, len(blank_kind),
)
# ---- 2. Add canonical missing entries -------------------------------
existing_names_lower = {
(n.strip().lower()) for n in Tpl.search([]).mapped('name') if n
}
added = 0
for name, kind in CANONICAL_MISSING:
if name.lower() in existing_names_lower:
continue
tpl = Tpl.create({
'name': name,
'default_kind': kind,
})
tpl.action_seed_default_inputs()
added += 1
_logger.info(
'Fusion Plating 19.0.12.4.0: added %s canonical missing library '
'entries (Soak Clean, Rinse, Etch, etc.).', added,
)

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.5.0 — Backfill default_kind on existing recipe nodes.
The Page-2 audit (2026-04-28) showed that pre-Sub-12a recipe nodes
have NULL `default_kind` because the field was added later. The
recipe-side soft-gates (Sub 8 racking, Policy B contract review) fall
back to name-matching when the kind is missing, which means a
renamed step ("Hang on Bar" instead of "Racking") silently bypasses
the gate.
This migration walks `fusion.plating.process.node` rows with NULL
default_kind, resolves a sensible kind via the central
`fp_resolve_step_kind()` helper, and sets it.
It also walks `fp.job.step` rows whose `kind` is the legacy 'other'
placeholder and re-derives `kind` from `recipe_node_id.default_kind`
(after the node-side backfill above sets it). Non-other kinds are
left alone — operator may have set them deliberately.
Idempotent.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__)
# Same mapping as in fp_job.py — keep them in sync.
_NODE_KIND_TO_STEP_KIND = {
'cleaning': 'wet',
'etch': 'wet',
'rinse': 'wet',
'plate': 'wet',
'dry': 'wet',
'wbf_test': 'wet',
'bake': 'bake',
'mask': 'mask',
'demask': 'mask',
'racking': 'rack',
'derack': 'rack',
'inspect': 'inspect',
'final_inspect': 'inspect',
'contract_review': 'other',
'gating': 'other',
'ship': 'other',
}
def migrate(cr, version):
env = Environment(cr, 1, {})
# ---- 1. Backfill default_kind on recipe nodes -----------------------
Node = env['fusion.plating.process.node']
blank = Node.search([
('default_kind', '=', False),
('node_type', 'in', ('operation', 'step')),
])
fixed = 0
for n in blank:
kind = fp_resolve_step_kind(n.name)
if kind:
n.default_kind = kind
fixed += 1
_logger.info(
'19.0.12.5.0: backfilled default_kind on %s/%s recipe nodes via '
'fp_resolve_step_kind.', fixed, len(blank),
)
# ---- 2. Re-derive fp.job.step.kind from recipe node default_kind ----
Step = env['fp.job.step']
other_steps = Step.search([
('kind', '=', 'other'),
('recipe_node_id', '!=', False),
('state', 'not in', ('done', 'cancelled')),
])
rederived = 0
for s in other_steps:
node_kind = (
s.recipe_node_id.default_kind
if 'default_kind' in s.recipe_node_id._fields else None
)
new_kind = _NODE_KIND_TO_STEP_KIND.get(node_kind) if node_kind else None
if new_kind and new_kind != 'other':
s.kind = new_kind
rederived += 1
_logger.info(
'19.0.12.5.0: re-derived kind on %s/%s in-flight job steps from '
'recipe node default_kind.', rederived, len(other_steps),
)

View File

@@ -8,7 +8,9 @@ from . import fp_process_type
from . import fp_facility
from . import fp_work_center
from . import fp_work_centre
from . import fp_tank_section
from . import fp_tank
from . import fp_tank_composition
from . import fp_bath
from . import fp_bath_log
from . import fp_bath_log_line

View File

@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Shared Unit-of-Measure selection list for plating chemistry, physical
quantities, and process inputs.
Free-text unit fields invite typos ("kgs", "Kg", "kilo", "KG") that
break filters, reports, and trend graphs. Every UoM in the plating
domain — chemistry, mass, volume, length, area, electrical, time,
pressure, dimensionless — lives here as a curated selection so users
pick from a known list instead of typing.
Re-use:
from .._fp_uom_selection import FP_UOM_SELECTION, FP_UOM_LEGACY_MAP
uom = fields.Selection(FP_UOM_SELECTION, string='Unit')
Migration:
Use FP_UOM_LEGACY_MAP to translate pre-existing free-text values
into selection keys during post_init / migration. Anything not in
the map gets cleared (NULL) so the user is forced to pick.
"""
# Single source of truth — keep alphabetised within each section.
FP_UOM_SELECTION = [
# --- Concentration / chemistry ---------------------------------------
('g_l', 'g/L'),
('mg_l', 'mg/L'),
('ug_l', 'µg/L'),
('kg_l', 'kg/L'),
('oz_gal', 'oz/gal (US)'),
('oz_gal_imp', 'oz/Imp gal'),
('ml_l', 'mL/L'),
('mol_l', 'mol/L'),
('n', 'N (Normality)'),
('ppm', 'ppm'),
('ppb', 'ppb'),
('pct', '%'),
('pct_w', '% (w/w)'),
('pct_v', '% (v/v)'),
('pct_vw', '% (v/w)'),
# --- Temperature -----------------------------------------------------
('c', '°C'),
('f', '°F'),
('k', 'K'),
# --- Dimensionless / pH / specific units -----------------------------
('ph', 'pH'),
('su', 'SU (Standard Units)'),
('ratio', 'Ratio (e.g. 5:1)'),
('none', '— (none)'),
# --- Conductivity / turbidity ----------------------------------------
('us_cm', 'µS/cm'),
('ms_cm', 'mS/cm'),
('ntu', 'NTU'),
# --- Time ------------------------------------------------------------
('s', 's (seconds)'),
('min', 'min'),
('h', 'h'),
('day', 'day'),
# --- Mass ------------------------------------------------------------
('mg', 'mg'),
('g', 'g'),
('kg', 'kg'),
('t', 't (tonne)'),
('oz', 'oz'),
('lb', 'lb'),
# --- Volume ----------------------------------------------------------
('ml', 'mL'),
('l', 'L'),
('m3', ''),
('gal_us', 'US gal'),
('gal_imp', 'Imp gal'),
('ft3', 'ft³'),
# --- Length / thickness ----------------------------------------------
('nm', 'nm'),
('um', 'µm'),
('mm', 'mm'),
('cm', 'cm'),
('m', 'm'),
('mil', 'mil (0.001 in)'),
('in', 'in'),
('ft', 'ft'),
# --- Area ------------------------------------------------------------
('cm2', 'cm²'),
('m2', ''),
('in2', 'in²'),
('ft2', 'ft²'),
('dm2', 'dm²'),
# --- Electrical / current density ------------------------------------
('a', 'A'),
('ma', 'mA'),
('v', 'V'),
('asd_a_dm2', 'A/dm² (ASD)'),
('asd_a_ft2', 'A/ft² (ASF)'),
('dm2_l', 'dm²/L (load)'),
# --- Pressure --------------------------------------------------------
('pa', 'Pa'),
('kpa', 'kPa'),
('bar', 'bar'),
('psi', 'psi'),
('mmhg', 'mmHg'),
# --- Rate / flow / generation ----------------------------------------
('kg_day', 'kg/day'),
('l_day', 'L/day'),
('kg_month', 'kg/month'),
('l_min', 'L/min'),
('gpm', 'gpm'),
('cfm', 'cfm'),
# --- Exposure / occupational hygiene ---------------------------------
('mg_m3', 'mg/m³'),
('ug_m3', 'µg/m³'),
('dba', 'dBA'),
('lux', 'lux'),
# --- Plating-specific counts -----------------------------------------
('mto', 'MTO (metal turnover)'),
('cycles', 'cycles'),
('count', 'count'),
('each', 'each'),
('rpm', 'rpm'),
]
# Map free-text values produced before this list existed → selection keys.
# Keep keys lower-cased + stripped during lookup.
FP_UOM_LEGACY_MAP = {
# Concentration
'g/l': 'g_l',
'gpl': 'g_l',
'grams/l': 'g_l',
'g per l': 'g_l',
'mg/l': 'mg_l',
'ug/l': 'ug_l',
'µg/l': 'ug_l',
'kg/l': 'kg_l',
'oz/gal': 'oz_gal',
'oz/g': 'oz_gal',
'oz/gallon': 'oz_gal',
'oz/imp gal': 'oz_gal_imp',
'ml/l': 'ml_l',
'mol/l': 'mol_l',
'molar': 'mol_l',
'm': 'mol_l',
'n': 'n',
'normal': 'n',
'normality': 'n',
'ppm': 'ppm',
'ppb': 'ppb',
'%': 'pct',
'percent': 'pct',
'pct': 'pct',
'% w/w': 'pct_w',
'%(w/w)': 'pct_w',
'%w/w': 'pct_w',
'% v/v': 'pct_v',
'%v/v': 'pct_v',
'% v/w': 'pct_vw',
# Temperature
'c': 'c',
'°c': 'c',
'celsius': 'c',
'deg c': 'c',
'degc': 'c',
'f': 'f',
'°f': 'f',
'fahrenheit': 'f',
'deg f': 'f',
'degf': 'f',
'k': 'k',
'kelvin': 'k',
# Dimensionless
'ph': 'ph',
'su': 'su',
'standard units': 'su',
'ratio': 'ratio',
'-': 'none',
'none': 'none',
# Conductivity / turbidity
'us/cm': 'us_cm',
'µs/cm': 'us_cm',
'ms/cm': 'ms_cm',
'ntu': 'ntu',
# Time
'second': 's',
'seconds': 's',
'sec': 's',
'secs': 's',
's': 's',
'minute': 'min',
'minutes': 'min',
'min': 'min',
'mins': 'min',
'hour': 'h',
'hours': 'h',
'hr': 'h',
'hrs': 'h',
'h': 'h',
'day': 'day',
'days': 'day',
'd': 'day',
# Mass
'mg': 'mg',
'g': 'g',
'gr': 'g',
'gram': 'g',
'grams': 'g',
'kg': 'kg',
'kgs': 'kg',
'kilogram': 'kg',
'kilograms': 'kg',
't': 't',
'tonne': 't',
'tonnes': 't',
'metric ton': 't',
'oz': 'oz',
'ounce': 'oz',
'ounces': 'oz',
'lb': 'lb',
'lbs': 'lb',
'pound': 'lb',
'pounds': 'lb',
# Volume
'ml': 'ml',
'l': 'l',
'liter': 'l',
'liters': 'l',
'litre': 'l',
'litres': 'l',
'm3': 'm3',
'': 'm3',
'cubic meter': 'm3',
'gal': 'gal_us',
'gal_us': 'gal_us',
'us gal': 'gal_us',
'gallon': 'gal_us',
'gallons': 'gal_us',
'imp gal': 'gal_imp',
'imperial gallon': 'gal_imp',
'ft3': 'ft3',
'ft³': 'ft3',
'cubic feet': 'ft3',
'cu ft': 'ft3',
# Length
'nm': 'nm',
'um': 'um',
'µm': 'um',
'micron': 'um',
'mm': 'mm',
'cm': 'cm',
'mil': 'mil',
'in': 'in',
'inch': 'in',
'inches': 'in',
'"': 'in',
'ft': 'ft',
'feet': 'ft',
'foot': 'ft',
# Area
'cm2': 'cm2',
'cm²': 'cm2',
'm2': 'm2',
'': 'm2',
'in2': 'in2',
'in²': 'in2',
'sq in': 'in2',
'ft2': 'ft2',
'ft²': 'ft2',
'sq ft': 'ft2',
'dm2': 'dm2',
'dm²': 'dm2',
# Electrical
'a': 'a',
'amp': 'a',
'amps': 'a',
'ampere': 'a',
'amperes': 'a',
'ma': 'ma',
'milliamp': 'ma',
'milliamps': 'ma',
'v': 'v',
'volt': 'v',
'volts': 'v',
'a/dm2': 'asd_a_dm2',
'a/dm²': 'asd_a_dm2',
'asd': 'asd_a_dm2',
'a/ft2': 'asd_a_ft2',
'a/ft²': 'asd_a_ft2',
'asf': 'asd_a_ft2',
'dm2/l': 'dm2_l',
'dm²/l': 'dm2_l',
# Pressure
'pa': 'pa',
'kpa': 'kpa',
'bar': 'bar',
'psi': 'psi',
'mmhg': 'mmhg',
# Rate
'kg/day': 'kg_day',
'l/day': 'l_day',
'kg/month': 'kg_month',
'l/min': 'l_min',
'lpm': 'l_min',
'gpm': 'gpm',
'cfm': 'cfm',
# Exposure
'mg/m3': 'mg_m3',
'mg/m³': 'mg_m3',
'ug/m3': 'ug_m3',
'µg/m³': 'ug_m3',
'dba': 'dba',
'db': 'dba',
'lux': 'lux',
# Plating counts
'mto': 'mto',
'cycle': 'cycles',
'cycles': 'cycles',
'count': 'count',
'each': 'each',
'ea': 'each',
'pcs': 'each',
'pieces': 'each',
'rpm': 'rpm',
}
def fp_normalize_legacy_uom(raw_value):
"""Translate a legacy free-text UoM string to a selection key.
Returns the selection key, or None if no match (caller decides whether
to NULL the column or leave it).
"""
if raw_value is None:
return None
key = (raw_value or '').strip().lower()
if not key:
return None
return FP_UOM_LEGACY_MAP.get(key)
def fp_migrate_uom_column(env, table, column, label_for_log=None):
"""Walk a table's free-text uom column and rewrite values into the
selection keys. Unmapped values are set to NULL so the user is forced
to pick a valid one.
Idempotent — running on a column that's already converted is a no-op
because all values will already be selection keys (which are a subset
of FP_UOM_LEGACY_MAP via identity mappings like 'g_l''g_l').
Args:
env: Odoo environment.
table: SQL table name (e.g. 'fusion_plating_bath_parameter').
column: SQL column name (e.g. 'uom').
label_for_log: human-readable name for the migration log line.
"""
cr = env.cr
cr.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = %s AND column_name = %s",
(table, column),
)
if not cr.fetchone():
return 0, 0 # table/column not present (module not installed)
cr.execute(f'SELECT id, "{column}" FROM "{table}" WHERE "{column}" IS NOT NULL')
rows = cr.fetchall()
valid_keys = {k for k, _ in FP_UOM_SELECTION}
cleared = 0
rewritten = 0
for row_id, raw in rows:
if raw in valid_keys:
continue # already a selection key
new_key = fp_normalize_legacy_uom(raw)
if new_key:
cr.execute(
f'UPDATE "{table}" SET "{column}" = %s WHERE id = %s',
(new_key, row_id),
)
rewritten += 1
else:
cr.execute(
f'UPDATE "{table}" SET "{column}" = NULL WHERE id = %s',
(row_id,),
)
cleared += 1
import logging
_logger = logging.getLogger(__name__)
_logger.info(
'Fusion Plating UoM migration — %s.%s%s: %s rewritten, %s cleared',
table, column, f' ({label_for_log})' if label_for_log else '',
rewritten, cleared,
)
return rewritten, cleared

View File

@@ -256,8 +256,9 @@ class FpBathTarget(models.Model):
target_min = fields.Float(string='Min')
target_max = fields.Float(string='Max')
uom = fields.Char(
related='parameter_id.uom',
related='parameter_id.uom_display',
readonly=True,
string='Unit',
)
_sql_constraints = [

View File

@@ -47,8 +47,9 @@ class FpBathLogLine(models.Model):
readonly=True,
)
uom = fields.Char(
related='parameter_id.uom',
related='parameter_id.uom_display',
readonly=True,
string='Unit',
)
value = fields.Float(
string='Value',
@@ -79,6 +80,28 @@ class FpBathLogLine(models.Model):
)
# ==========================================================================
@api.onchange('parameter_id')
def _onchange_parameter_prefill_value(self):
"""Pre-fill `value` from the tank's setpoint when the parameter is a
temperature reading.
This means the operator (or backend user) hits "add reading", picks
Temperature, and the tank's `default_temperature` lands in the value
column automatically — they confirm with one tap or nudge with
keyboard arrows. Avoids retyping the same number every shift.
Fires only when value is currently empty so the user's edits aren't
clobbered if they go back and pick a different parameter.
"""
for rec in self:
if not rec.parameter_id or rec.value:
continue
if rec.parameter_id.parameter_type != 'temperature':
continue
tank = rec.log_id.bath_id.tank_id
if tank and tank.default_temperature:
rec.value = tank.default_temperature
@api.depends('parameter_id', 'log_id.bath_id')
def _compute_targets(self):
"""Resolve target range: per-bath override first, parameter default second."""

View File

@@ -3,7 +3,9 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpBathParameter(models.Model):
@@ -49,23 +51,37 @@ class FpBathParameter(models.Model):
required=True,
default='concentration',
)
uom = fields.Char(
uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit',
help='Display unit (e.g. "g/L", "°C", "pH", "MTO").',
help='Pick the unit this parameter is measured in. Drives the unit '
'shown on every reading, target, and replenishment suggestion '
'derived from this parameter.',
)
uom_display = fields.Char(
string='Unit (display)',
compute='_compute_uom_display',
help='Resolved display string for the chosen unit '
'(e.g. "g/L", "°C") — used by views that need plain text.',
)
target_min = fields.Float(
string='Default Target Min',
help='Default target minimum. Per-bath overrides are allowed.',
help='Smallest acceptable reading, expressed in the unit selected '
'above. Anything below this is flagged Out of Spec. '
'Per-bath overrides allowed.',
)
target_max = fields.Float(
string='Default Target Max',
help='Default target maximum. Per-bath overrides are allowed.',
help='Largest acceptable reading, expressed in the unit selected '
'above. Anything above this is flagged Out of Spec. '
'Per-bath overrides allowed.',
)
target_value = fields.Float(
string='Default Setpoint / Optimum',
help='The IDEAL operating value — what the heater/chiller controls '
'toward, what dashboards compare against. Sits between '
'target_min and target_max. Per-sensor override via '
help='The IDEAL operating value, expressed in the unit selected '
'above — what the heater/chiller controls toward, what '
'dashboards compare against. Sits between Target Min and '
'Target Max. Per-sensor override via '
'fp.tank.sensor.target_value_override.',
)
warning_tolerance = fields.Float(
@@ -86,6 +102,12 @@ class FpBathParameter(models.Model):
default=True,
)
@api.depends('uom')
def _compute_uom_display(self):
labels = dict(FP_UOM_SELECTION)
for rec in self:
rec.uom_display = labels.get(rec.uom, '') if rec.uom else ''
_sql_constraints = [
(
'fp_bath_parameter_code_uniq',

View File

@@ -215,23 +215,64 @@ class FpJobStep(models.Model):
# ------------------------------------------------------------------
def button_pause(self):
raise NotImplementedError(_(
"button_pause is not yet implemented (operator pause / break / "
"end-of-shift). Use button_finish to complete a step or set "
"state directly via privileged code."
))
"""Operator pause / break / end-of-shift. Closes the open timelog
without finishing the step, flips state to 'paused'. button_start
will reopen a fresh timelog when resuming.
"""
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'paused'
step.message_post(body=_('Step paused by %s') % self.env.user.name)
return True
def button_resume(self):
"""Resume a paused step — thin alias over button_start so views
can show distinct labels (Resume vs Start) without duplicating
the state-machine logic."""
for step in self:
if step.state != 'paused':
raise UserError(_(
"Step '%s' is in state '%s' — only paused steps can resume."
) % (step.name, step.state))
return self.button_start()
def button_skip(self):
raise NotImplementedError(_(
"button_skip is not yet implemented (skip an opt-in step that "
"wasn't activated for this job)."
))
"""Skip an opt-in step that wasn't activated for this job. Allowed
from pending or ready only — a step that's already running shouldn't
be skipped without an audit narrative (use button_cancel for that).
"""
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' — only pending/ready steps can skip."
) % (step.name, step.state))
step.state = 'skipped'
step.message_post(body=_('Step skipped by %s') % self.env.user.name)
return True
def button_cancel(self):
raise NotImplementedError(_(
"button_cancel is not yet implemented (cancelling a single step; "
"cancelling the whole job runs through fp.job.action_cancel)."
))
"""Cancel a single step. Used when an operator realises mid-stream
that a step doesn't apply to this job (e.g. a customer-specific
step that's not needed). Closes any open timelog so labour cost
already incurred is preserved.
"""
for step in self:
if step.state in ('done', 'cancelled'):
raise UserError(_(
"Step '%s' is in state '%s' — cannot cancel."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'cancelled'
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
return True
def button_start(self):
for step in self:

View File

@@ -7,6 +7,7 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from .fp_tz import fp_isoformat_utc
from ._fp_uom_selection import FP_UOM_SELECTION
class FpProcessNode(models.Model):
@@ -352,6 +353,7 @@ class FpProcessNode(models.Model):
('final_inspect', 'Final Inspection'),
('ship', 'Shipping'),
('gating', 'Gating'),
('contract_review', 'Contract Review (QA-005)'),
],
string='Step Kind',
)
@@ -652,9 +654,11 @@ class FpProcessNodeInput(models.Model):
string='Sequence',
default=10,
)
uom = fields.Char(
uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit',
help='Unit label (e.g. °C, min, psi).',
help='Unit the operator is recording in (pick from the curated list — '
'avoids "kg" vs "kgs" vs "kilo" inconsistencies).',
)
# ===== Sub 12a — kind + target ranges + compliance tag ==================
@@ -668,9 +672,19 @@ class FpProcessNodeInput(models.Model):
'recorded when leaving the step (Sub 12b uses these in the '
'Move Parts dialog).',
)
target_min = fields.Float(string='Target Min')
target_max = fields.Float(string='Target Max')
target_unit = fields.Char(string='Target Unit')
target_min = fields.Float(
string='Target Min',
help='Lower bound of the acceptable range, expressed in Target Unit.',
)
target_max = fields.Float(
string='Target Max',
help='Upper bound of the acceptable range, expressed in Target Unit.',
)
target_unit = fields.Selection(
FP_UOM_SELECTION,
string='Target Unit',
help='Unit Target Min / Target Max are measured in.',
)
compliance_tag = fields.Selection(
[
('none', 'None'),

View File

@@ -90,6 +90,7 @@ class FpStepTemplate(models.Model):
('final_inspect', 'Final Inspection'),
('ship', 'Shipping'),
('gating', 'Gating'),
('contract_review', 'Contract Review (QA-005)'),
], string='Step Kind', help='Drives sane-default input seeding.')
input_template_ids = fields.One2many(
@@ -130,35 +131,39 @@ class FpStepTemplate(models.Model):
# ----- Sane defaults seeding ---------------------------------------------
# NB target_unit must be a valid FP_UOM_SELECTION key — it became a
# Selection in 19.0.12.1.0 (uom cleanup). Free-text values like
# 'HH:MM', '°F', 'sec', 'in', 'each' raise ValueError on create.
# Mapping cheatsheet: sec → 's', °F → 'f', °C → 'c', in → 'in',
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
# left blank since they're not units.
DEFAULT_INPUTS_BY_KIND = {
'cleaning': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 'sec', 'sequence': 10},
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20},
'target_unit': 'f', 'sequence': 20},
],
'etch': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 'sec', 'sequence': 10},
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20},
'target_unit': 'f', 'sequence': 20},
],
'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},
'target_unit': 'f', 'sequence': 20},
{'name': 'Plating Thickness', 'input_type': 'thickness',
'target_unit': 'in', 'sequence': 30},
],
'bake': [
{'name': 'Time In', 'input_type': 'text',
'target_unit': 'HH:MM', 'sequence': 10},
{'name': 'Time Out', 'input_type': 'text',
'target_unit': 'HH:MM', 'sequence': 20},
{'name': 'Time In', 'input_type': 'text', 'sequence': 10},
{'name': 'Time Out', 'input_type': 'text', 'sequence': 20},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 30},
'target_unit': 'f', 'sequence': 30},
],
'racking': [
{'name': 'Actual Qty', 'input_type': 'number',
@@ -196,6 +201,15 @@ class FpStepTemplate(models.Model):
'target_unit': 'each', 'sequence': 10},
],
'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': 'Date Reviewed', 'input_type': 'date', 'sequence': 20},
{'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30},
],
}
def action_seed_default_inputs(self):

View File

@@ -5,6 +5,8 @@
from odoo import fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpStepTemplateInput(models.Model):
"""Operation measurement definition on a step library template.
@@ -35,10 +37,13 @@ class FpStepTemplateInput(models.Model):
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
], string='Input Type', required=True, default='text')
target_min = fields.Float(string='Target Min')
target_max = fields.Float(string='Target Max')
target_unit = fields.Char(string='Target Unit',
help='Display unit, e.g. "min", "°F", "A", "FT2", "in".')
target_min = fields.Float(string='Target Min',
help='Lower bound of the acceptable range, expressed in Target Unit.')
target_max = fields.Float(string='Target Max',
help='Upper bound of the acceptable range, expressed in Target Unit.')
target_unit = fields.Selection(FP_UOM_SELECTION, string='Target Unit',
help='Unit Target Min / Target Max are measured in. Pick from the '
'curated list to keep readings consistent across templates.')
required = fields.Boolean(string='Required', default=False,
help='If True, sign-off is hard-blocked while this input is blank.')
hint = fields.Char(string='Hint')

View File

@@ -19,7 +19,7 @@ class FpTank(models.Model):
_name = 'fusion.plating.tank'
_description = 'Fusion Plating — Tank'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, work_center_id, sequence, code'
_order = 'facility_id, section_id, sequence, code'
name = fields.Char(
string='Tank Name',
@@ -51,9 +51,16 @@ class FpTank(models.Model):
ondelete='restrict',
tracking=True,
)
section_id = fields.Many2one(
'fusion.plating.tank.section',
string='Section',
ondelete='set null',
tracking=True,
help='Free-form grouping (e.g. Steel Line, Aluminum Line, Specialty Line).',
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Work Center',
string='Production Line',
domain="[('facility_id','=',facility_id)]",
ondelete='restrict',
tracking=True,
@@ -126,6 +133,22 @@ class FpTank(models.Model):
tracking=True,
)
# ----- Default temperature (used as pre-fill on bath log lines) -------
default_temperature = fields.Float(
string='Default Temperature',
digits=(6, 2),
tracking=True,
help='Operating temperature setpoint. Pre-fills the temperature '
'reading on new chemistry logs so the operator can confirm with '
'one tap. Use the up/down arrows on the input to nudge by 1 unit.',
)
default_temperature_uom = fields.Selection(
[('c', '°C'), ('f', '°F')],
string='Temperature Unit',
default='c',
tracking=True,
)
# ----- Relations ------------------------------------------------------
bath_ids = fields.One2many(
'fusion.plating.bath',
@@ -138,16 +161,45 @@ class FpTank(models.Model):
compute='_compute_current_bath',
store=True,
)
current_bath_process_id = fields.Many2one(
'fusion.plating.process.type',
string='Current Bath Process',
related='current_bath_id.process_type_id',
store=True,
help='Process derived from the active bath. The editable "Current '
'Process" overrides this when the operator needs to flag a '
'different process (e.g. between bath swaps).',
)
current_process_id = fields.Many2one(
'fusion.plating.process.type',
string='Current Process',
related='current_bath_id.process_type_id',
store=True,
ondelete='restrict',
tracking=True,
help='User-settable process flag. Defaults to the active bath\'s '
'process; can be overridden when operating off-recipe.',
)
bath_count = fields.Integer(
compute='_compute_bath_count',
)
# ----- Compositions ---------------------------------------------------
composition_ids = fields.One2many(
'fusion.plating.tank.composition',
'tank_id',
string='Compositions',
)
active_composition_id = fields.Many2one(
'fusion.plating.tank.composition',
string='Active Composition',
domain="[('tank_id', '=', id)]",
tracking=True,
help='The composition currently in service. Switching is logged in '
'the chatter for full audit history.',
)
composition_count = fields.Integer(
compute='_compute_composition_count',
)
_sql_constraints = [
(
'fp_tank_code_facility_uniq',
@@ -168,6 +220,20 @@ class FpTank(models.Model):
for rec in self:
rec.bath_count = len(rec.bath_ids)
@api.depends('composition_ids')
def _compute_composition_count(self):
for rec in self:
rec.composition_count = len(rec.composition_ids)
@api.onchange('current_bath_process_id')
def _onchange_seed_current_process(self):
"""Pre-fill the editable Current Process from the active bath when
the operator hasn't already set one — keeps the field useful out of
the box while still allowing manual override."""
for rec in self:
if not rec.current_process_id and rec.current_bath_process_id:
rec.current_process_id = rec.current_bath_process_id
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:

View File

@@ -0,0 +1,216 @@
# -*- 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 FpTankComposition(models.Model):
"""A defined chemistry composition for a tank (e.g. "Composition A",
"High-P Mix", "Strike Solution").
A tank can carry multiple authored compositions; one is "active" at any
given time. Switching compositions is a tracked, audit-logged event so
the shop has a chronological record of "what did this tank actually
contain on Tuesday afternoon?"
Each composition has its own ingredient list with per-chemical
percentages. Changes to ingredients are also chatter-tracked.
"""
_name = 'fusion.plating.tank.composition'
_description = 'Fusion Plating — Tank Composition'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'tank_id, sequence, name'
name = fields.Char(
string='Composition',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
tracking=True,
help='Short identifier — "A", "B", "C".',
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
tank_id = fields.Many2one(
'fusion.plating.tank',
string='Tank',
required=True,
ondelete='cascade',
tracking=True,
)
facility_id = fields.Many2one(
related='tank_id.facility_id',
store=True,
readonly=True,
)
is_active = fields.Boolean(
string='Currently Active',
compute='_compute_is_active',
help='True when this composition is the tank\'s active composition.',
)
description = fields.Text(
string='Description',
)
notes = fields.Html(
string='Notes',
)
ingredient_ids = fields.One2many(
'fusion.plating.tank.composition.ingredient',
'composition_id',
string='Ingredients',
copy=True,
tracking=True,
)
total_percentage = fields.Float(
string='Total %',
compute='_compute_total_percentage',
store=True,
)
active = fields.Boolean(default=True)
@api.depends('ingredient_ids', 'ingredient_ids.percentage')
def _compute_total_percentage(self):
for rec in self:
rec.total_percentage = sum(rec.ingredient_ids.mapped('percentage'))
@api.depends('tank_id', 'tank_id.active_composition_id')
def _compute_is_active(self):
for rec in self:
rec.is_active = rec.tank_id.active_composition_id.id == rec.id
def action_set_active(self):
"""Mark this composition as the tank's active composition. Logs to
both this composition's chatter and the tank's chatter so the audit
trail captures who flipped the switch and when.
"""
self.ensure_one()
old = self.tank_id.active_composition_id
if old.id == self.id:
return True
self.tank_id.active_composition_id = self.id
msg_tank = _(
'Active composition changed: %(old)s%(new)s by %(user)s'
) % {
'old': old.display_name or _('(none)'),
'new': self.display_name,
'user': self.env.user.name,
}
self.tank_id.message_post(body=msg_tank)
self.message_post(body=_('Activated by %s') % self.env.user.name)
return True
class FpTankCompositionIngredient(models.Model):
"""A single chemical entry in a tank composition.
Free-form chemical name (no FK to fusion.plating.chemical because core
must not depend on the safety module). Percentage is the share of the
composition; total % roll-up lives on the parent composition.
"""
_name = 'fusion.plating.tank.composition.ingredient'
_description = 'Fusion Plating — Tank Composition Ingredient'
_order = 'composition_id, sequence, id'
composition_id = fields.Many2one(
'fusion.plating.tank.composition',
string='Composition',
required=True,
ondelete='cascade',
index=True,
)
tank_id = fields.Many2one(
related='composition_id.tank_id',
store=True,
readonly=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
name = fields.Char(
string='Chemical',
required=True,
)
percentage = fields.Float(
string='Percentage',
digits=(6, 3),
required=True,
help='Share of this composition, in percent.',
)
uom = fields.Selection(
[
('pct', '% by Volume'),
('pct_w', '% by Weight'),
('g_l', 'g/L'),
('ml_l', 'mL/L'),
('oz_gal', 'oz/gal'),
],
string='Unit',
default='pct',
)
notes = fields.Char(
string='Notes',
)
# Mirror create/write/unlink to the parent composition's chatter so
# ingredient changes show up in the audit log even though this row
# doesn't carry mail.thread itself (kept lean for repeater UX).
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
rec.composition_id.message_post(body=_(
'Ingredient added: %(name)s%(pct)s %(uom)s'
) % {
'name': rec.name,
'pct': rec.percentage,
'uom': dict(rec._fields['uom'].selection).get(rec.uom, rec.uom),
})
return records
def write(self, vals):
# Capture before-state per-record so we can describe the diff.
snapshots = {
rec.id: {
'name': rec.name,
'percentage': rec.percentage,
'uom': rec.uom,
}
for rec in self
}
result = super().write(vals)
for rec in self:
before = snapshots.get(rec.id) or {}
changed = []
if 'name' in vals and before.get('name') != rec.name:
changed.append(_('name: %s%s') % (before.get('name'), rec.name))
if 'percentage' in vals and before.get('percentage') != rec.percentage:
changed.append(_('percentage: %s%s') % (
before.get('percentage'), rec.percentage,
))
if 'uom' in vals and before.get('uom') != rec.uom:
changed.append(_('unit: %s%s') % (before.get('uom'), rec.uom))
if changed:
rec.composition_id.message_post(body=_(
'Ingredient %(name)s updated — %(changes)s'
) % {
'name': rec.name,
'changes': '; '.join(changed),
})
return result
def unlink(self):
for rec in self:
rec.composition_id.message_post(body=_(
'Ingredient removed: %(name)s%(pct)s'
) % {
'name': rec.name,
'pct': rec.percentage,
})
return super().unlink()

View File

@@ -0,0 +1,69 @@
# -*- 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 FpTankSection(models.Model):
"""A user-defined grouping of tanks (e.g. "Steel Line", "Aluminum Line",
"Specialty Line").
Sections give the shop a familiar way to slice the tank list: every shop
organises its tanks differently — by metal, by chemistry family, by
physical aisle, or by customer programme — and a fixed taxonomy never
fits. Sections are free-form, renameable, and per-facility.
"""
_name = 'fusion.plating.tank.section'
_description = 'Fusion Plating — Tank Section'
_order = 'sequence, name'
name = fields.Char(
string='Section',
required=True,
translate=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
ondelete='restrict',
)
color = fields.Integer(
string='Color',
default=0,
)
description = fields.Text(
string='Description',
translate=True,
)
active = fields.Boolean(default=True)
tank_ids = fields.One2many(
'fusion.plating.tank',
'section_id',
string='Tanks',
)
tank_count = fields.Integer(
compute='_compute_tank_count',
)
@api.depends('tank_ids')
def _compute_tank_count(self):
for rec in self:
rec.tank_count = len(rec.tank_ids)
def action_view_tanks(self):
self.ensure_one()
return {
'name': self.name,
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.tank',
'view_mode': 'list,kanban,form',
'domain': [('section_id', '=', self.id)],
'context': {'default_section_id': self.id},
}

View File

@@ -14,6 +14,15 @@ access_fp_work_center_manager,fp.work.center.manager,model_fusion_plating_work_c
access_fp_tank_operator,fp.tank.operator,model_fusion_plating_tank,group_fusion_plating_operator,1,0,0,0
access_fp_tank_supervisor,fp.tank.supervisor,model_fusion_plating_tank,group_fusion_plating_supervisor,1,1,0,0
access_fp_tank_manager,fp.tank.manager,model_fusion_plating_tank,group_fusion_plating_manager,1,1,1,1
access_fp_tank_section_operator,fp.tank.section.operator,model_fusion_plating_tank_section,group_fusion_plating_operator,1,0,0,0
access_fp_tank_section_supervisor,fp.tank.section.supervisor,model_fusion_plating_tank_section,group_fusion_plating_supervisor,1,1,0,0
access_fp_tank_section_manager,fp.tank.section.manager,model_fusion_plating_tank_section,group_fusion_plating_manager,1,1,1,1
access_fp_tank_composition_operator,fp.tank.composition.operator,model_fusion_plating_tank_composition,group_fusion_plating_operator,1,0,0,0
access_fp_tank_composition_supervisor,fp.tank.composition.supervisor,model_fusion_plating_tank_composition,group_fusion_plating_supervisor,1,1,1,0
access_fp_tank_composition_manager,fp.tank.composition.manager,model_fusion_plating_tank_composition,group_fusion_plating_manager,1,1,1,1
access_fp_tank_comp_ing_operator,fp.tank.composition.ingredient.operator,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_operator,1,0,0,0
access_fp_tank_comp_ing_supervisor,fp.tank.composition.ingredient.supervisor,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_supervisor,1,1,1,1
access_fp_tank_comp_ing_manager,fp.tank.composition.ingredient.manager,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_manager,1,1,1,1
access_fp_bath_operator,fp.bath.operator,model_fusion_plating_bath,group_fusion_plating_operator,1,0,0,0
access_fp_bath_supervisor,fp.bath.supervisor,model_fusion_plating_bath,group_fusion_plating_supervisor,1,1,1,0
access_fp_bath_manager,fp.bath.manager,model_fusion_plating_bath,group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_tank_operator fp.tank.operator model_fusion_plating_tank group_fusion_plating_operator 1 0 0 0
15 access_fp_tank_supervisor fp.tank.supervisor model_fusion_plating_tank group_fusion_plating_supervisor 1 1 0 0
16 access_fp_tank_manager fp.tank.manager model_fusion_plating_tank group_fusion_plating_manager 1 1 1 1
17 access_fp_tank_section_operator fp.tank.section.operator model_fusion_plating_tank_section group_fusion_plating_operator 1 0 0 0
18 access_fp_tank_section_supervisor fp.tank.section.supervisor model_fusion_plating_tank_section group_fusion_plating_supervisor 1 1 0 0
19 access_fp_tank_section_manager fp.tank.section.manager model_fusion_plating_tank_section group_fusion_plating_manager 1 1 1 1
20 access_fp_tank_composition_operator fp.tank.composition.operator model_fusion_plating_tank_composition group_fusion_plating_operator 1 0 0 0
21 access_fp_tank_composition_supervisor fp.tank.composition.supervisor model_fusion_plating_tank_composition group_fusion_plating_supervisor 1 1 1 0
22 access_fp_tank_composition_manager fp.tank.composition.manager model_fusion_plating_tank_composition group_fusion_plating_manager 1 1 1 1
23 access_fp_tank_comp_ing_operator fp.tank.composition.ingredient.operator model_fusion_plating_tank_composition_ingredient group_fusion_plating_operator 1 0 0 0
24 access_fp_tank_comp_ing_supervisor fp.tank.composition.ingredient.supervisor model_fusion_plating_tank_composition_ingredient group_fusion_plating_supervisor 1 1 1 1
25 access_fp_tank_comp_ing_manager fp.tank.composition.ingredient.manager model_fusion_plating_tank_composition_ingredient group_fusion_plating_manager 1 1 1 1
26 access_fp_bath_operator fp.bath.operator model_fusion_plating_bath group_fusion_plating_operator 1 0 0 0
27 access_fp_bath_supervisor fp.bath.supervisor model_fusion_plating_bath group_fusion_plating_supervisor 1 1 1 0
28 access_fp_bath_manager fp.bath.manager model_fusion_plating_bath group_fusion_plating_manager 1 1 1 1

View File

@@ -46,9 +46,8 @@
<field name="max_dose"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
<group>
<field name="active" widget="boolean_toggle"/>
</group>

View File

@@ -82,9 +82,8 @@
<field name="product_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
</form>
</field>

View File

@@ -48,12 +48,10 @@
<field name="training_record_attachment_id"/>
</group>
</group>
<group string="Revocation" invisible="state != 'revoked'">
<field name="revoked_reason" nolabel="1" colspan="2"/>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<separator string="Revocation"/>
<field name="revoked_reason" colspan="2"/>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>

View File

@@ -211,21 +211,34 @@
</div>
</div>
<group>
<group>
<group string="Definition">
<field name="parameter_type"/>
<field name="uom"/>
<field name="decimals"/>
</group>
<group>
<field name="target_min"/>
<field name="target_max"/>
<group string="Default Targets (in selected unit)">
<label for="target_min"/>
<div class="o_row">
<field name="target_min" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<label for="target_max"/>
<div class="o_row">
<field name="target_max" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<label for="target_value"/>
<div class="o_row">
<field name="target_value" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<field name="warning_tolerance"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
<group string="Description">
<field name="description" nolabel="1"/>
</group>
<separator string="Description"/>
<field name="description" nolabel="1"
placeholder="What is this parameter, how is it measured, why does it matter?"/>
</sheet>
</form>
</field>

View File

@@ -82,9 +82,8 @@
<field name="current_part_count" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>

View File

@@ -10,20 +10,24 @@
<field name="name">fp.tank.list</field>
<field name="model">fusion.plating.tank</field>
<field name="arch" type="xml">
<list string="Tanks">
<field name="facility_id"/>
<field name="work_center_id"/>
<list string="Tanks" multi_edit="1" expand="1">
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="section_id" optional="show"/>
<field name="facility_id" optional="hide"/>
<field name="work_center_id" optional="hide"/>
<field name="current_process_id"/>
<field name="default_temperature" optional="show"/>
<field name="default_temperature_uom" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'in_use'"
decoration-info="state == 'filled'"
decoration-warning="state in ('draining', 'maintenance')"
decoration-muted="state in ('empty', 'out_of_service')"/>
<field name="material" optional="hide"/>
<field name="volume" optional="show"/>
<field name="volume_uom" optional="show"/>
<field name="volume" optional="hide"/>
<field name="volume_uom" optional="hide"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
@@ -69,12 +73,19 @@
<group>
<group string="Location">
<field name="facility_id"/>
<field name="section_id"
options="{'no_quick_create': False}"/>
<field name="work_center_id"/>
<field name="sequence"/>
</group>
<group string="Current Bath">
<field name="current_bath_id" readonly="1"/>
<field name="current_process_id" readonly="1"/>
<group string="Operating Setpoints">
<field name="current_process_id"
help="Editable. Defaults to the active bath's process."/>
<label for="default_temperature"/>
<div class="o_row">
<field name="default_temperature" nolabel="1" class="oe_inline"/>
<field name="default_temperature_uom" nolabel="1" class="oe_inline"/>
</div>
<field name="qr_code"/>
</group>
</group>
@@ -96,6 +107,62 @@
</group>
</group>
</page>
<page string="Compositions">
<group>
<field name="active_composition_id"
options="{'no_create_edit': True}"/>
</group>
<field name="composition_ids" context="{'default_tank_id': id}">
<list decoration-bf="is_active">
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="total_percentage"
decoration-warning="total_percentage != 100.0 and total_percentage > 0"/>
<field name="is_active" string="Active"/>
<button name="action_set_active" type="object"
string="Set Active" class="btn-link"
icon="fa-check-circle"
invisible="is_active"/>
</list>
<form string="Composition">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Composition A"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="sequence"/>
</group>
<group>
<field name="total_percentage"/>
<field name="is_active"/>
</group>
</group>
<field name="description" placeholder="Short description..."/>
<notebook>
<page string="Ingredients">
<field name="ingredient_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="percentage"/>
<field name="uom"/>
<field name="notes"/>
</list>
</field>
</page>
<page string="Notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</page>
<page string="Bath History">
<field name="bath_ids">
<list decoration-muted="state == 'dumped'">
@@ -126,6 +193,7 @@
<field name="state"/>
<field name="current_bath_id"/>
<field name="current_process_id"/>
<field name="section_id"/>
<field name="facility_id"/>
<field name="work_center_id"/>
<templates>
@@ -142,7 +210,7 @@
</div>
<div class="mt-2 small">
<div><i class="fa fa-flask me-1 text-muted"/><field name="current_process_id"/></div>
<div class="text-muted"><field name="work_center_id"/></div>
<div class="text-muted"><field name="section_id"/></div>
</div>
</div>
</t>
@@ -160,6 +228,7 @@
<field name="code"/>
<field name="qr_code"/>
<field name="facility_id"/>
<field name="section_id"/>
<field name="work_center_id"/>
<field name="current_process_id"/>
<separator/>
@@ -170,8 +239,9 @@
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Section" name="group_section" context="{'group_by':'section_id'}"/>
<filter string="Facility" name="group_facility" context="{'group_by':'facility_id'}"/>
<filter string="Work Center" name="group_wc" context="{'group_by':'work_center_id'}"/>
<filter string="Production Line" name="group_wc" context="{'group_by':'work_center_id'}"/>
<filter string="Process" name="group_process" context="{'group_by':'current_process_id'}"/>
<filter string="Status" name="group_state" context="{'group_by':'state'}"/>
</group>
@@ -182,8 +252,70 @@
<record id="action_fp_tank" model="ir.actions.act_window">
<field name="name">Tanks</field>
<field name="res_model">fusion.plating.tank</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_fp_tank_search"/>
<field name="context">{'search_default_group_section': 1}</field>
</record>
<!-- ==================================================================
Tank Sections — manageable from Configuration → Shop Setup
================================================================== -->
<record id="view_fp_tank_section_list" model="ir.ui.view">
<field name="name">fp.tank.section.list</field>
<field name="model">fusion.plating.tank.section</field>
<field name="arch" type="xml">
<list string="Tank Sections" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="facility_id"/>
<field name="tank_count"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_tank_section_form" model="ir.ui.view">
<field name="name">fp.tank.section.form</field>
<field name="model">fusion.plating.tank.section</field>
<field name="arch" type="xml">
<form string="Tank Section">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_tanks" type="object"
class="oe_stat_button" icon="fa-flask">
<field name="tank_count" widget="statinfo" string="Tanks"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Steel Line"/></h1>
</div>
<group>
<group>
<field name="facility_id"/>
<field name="sequence"/>
<field name="color" widget="color_picker"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<field name="description" placeholder="What kinds of tanks belong in this section?"/>
</sheet>
</form>
</field>
</record>
<record id="action_fp_tank_section" model="ir.actions.act_window">
<field name="name">Tank Sections</field>
<field name="res_model">fusion.plating.tank.section</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fp_tank_sections"
name="Tank Sections"
parent="menu_fp_config_shop_setup"
action="action_fp_tank_section"
sequence="35"/>
</odoo>

View File

@@ -42,10 +42,8 @@
<field name="mastery_required"/>
</group>
</group>
<group>
<field name="description"
<field name="description"
placeholder="Short operator-facing description of what this role covers."/>
</group>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-1"/>
<strong>Mastery Threshold</strong> controls auto-promotion: when an