changes
This commit is contained in:
@@ -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
|
||||
|
||||
418
fusion_plating/fusion_plating/models/_fp_uom_selection.py
Normal file
418
fusion_plating/fusion_plating/models/_fp_uom_selection.py
Normal 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', 'm³'),
|
||||
('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', 'm²'),
|
||||
('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',
|
||||
'm³': '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',
|
||||
'm²': '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
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
216
fusion_plating/fusion_plating/models/fp_tank_composition.py
Normal file
216
fusion_plating/fusion_plating/models/fp_tank_composition.py
Normal 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()
|
||||
69
fusion_plating/fusion_plating/models/fp_tank_section.py
Normal file
69
fusion_plating/fusion_plating/models/fp_tank_section.py
Normal 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},
|
||||
}
|
||||
Reference in New Issue
Block a user