changes
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user