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

@@ -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},
}