changes
This commit is contained in:
15
fusion-plating/fusion_plating/models/__init__.py
Normal file
15
fusion-plating/fusion_plating/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_process_category
|
||||
from . import fp_process_type
|
||||
from . import fp_facility
|
||||
from . import fp_work_center
|
||||
from . import fp_tank
|
||||
from . import fp_bath
|
||||
from . import fp_bath_log
|
||||
from . import fp_bath_log_line
|
||||
from . import fp_bath_parameter
|
||||
from . import res_company
|
||||
269
fusion-plating/fusion_plating/models/fp_bath.py
Normal file
269
fusion-plating/fusion_plating/models/fp_bath.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# -*- 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 FpBath(models.Model):
|
||||
"""A specific batch of chemistry in a tank.
|
||||
|
||||
Baths have their own lifecycle independent of the tank:
|
||||
|
||||
new → operational → under_review → dump_scheduled → dumped
|
||||
|
||||
Each bath carries:
|
||||
* its process type (which chemistry it runs)
|
||||
* per-bath target ranges (may override process defaults)
|
||||
* running MTO counter (set and maintained by the process pack)
|
||||
* chemistry log history (one2many to fusion.plating.bath.log)
|
||||
|
||||
Process packs (fusion_plating_process_en, etc.) add process-specific
|
||||
computed fields such as orthophosphite projection or P-content band
|
||||
without touching the generic bath model.
|
||||
"""
|
||||
_name = 'fusion.plating.bath'
|
||||
_description = 'Fusion Plating — Bath'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'state, makeup_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: self._default_name(),
|
||||
tracking=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
tank_id = fields.Many2one(
|
||||
'fusion.plating.tank',
|
||||
string='Tank',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
related='tank_id.facility_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type',
|
||||
string='Process',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
related='facility_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# ----- Lifecycle ------------------------------------------------------
|
||||
state = fields.Selection(
|
||||
[
|
||||
('new', 'New'),
|
||||
('operational', 'Operational'),
|
||||
('under_review', 'Under Review'),
|
||||
('dump_scheduled', 'Dump Scheduled'),
|
||||
('dumped', 'Dumped'),
|
||||
],
|
||||
string='Status',
|
||||
default='new',
|
||||
tracking=True,
|
||||
required=True,
|
||||
)
|
||||
status_color = fields.Integer(
|
||||
string='Status Color',
|
||||
compute='_compute_status_color',
|
||||
help='Kanban colour index derived from state and chemistry health.',
|
||||
)
|
||||
makeup_date = fields.Datetime(
|
||||
string='Makeup Date',
|
||||
help='When this bath was made up (initial fresh charge).',
|
||||
tracking=True,
|
||||
)
|
||||
makeup_by_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Made Up By',
|
||||
tracking=True,
|
||||
)
|
||||
dump_scheduled_date = fields.Datetime(
|
||||
string='Dump Scheduled',
|
||||
tracking=True,
|
||||
)
|
||||
dumped_date = fields.Datetime(
|
||||
string='Dumped Date',
|
||||
tracking=True,
|
||||
)
|
||||
dump_reason = fields.Text(
|
||||
string='Dump Reason',
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# ----- Chemistry target ranges (per-bath; override process defaults) --
|
||||
target_line_ids = fields.One2many(
|
||||
'fusion.plating.bath.target',
|
||||
'bath_id',
|
||||
string='Target Parameters',
|
||||
copy=True,
|
||||
)
|
||||
|
||||
# ----- Logs -----------------------------------------------------------
|
||||
log_ids = fields.One2many(
|
||||
'fusion.plating.bath.log',
|
||||
'bath_id',
|
||||
string='Chemistry Logs',
|
||||
)
|
||||
log_count = fields.Integer(
|
||||
compute='_compute_log_count',
|
||||
)
|
||||
last_log_date = fields.Datetime(
|
||||
compute='_compute_last_log',
|
||||
store=True,
|
||||
)
|
||||
last_log_status = fields.Selection(
|
||||
[
|
||||
('ok', 'OK'),
|
||||
('warning', 'Warning'),
|
||||
('out_of_spec', 'Out of Spec'),
|
||||
],
|
||||
compute='_compute_last_log',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ----- Generic age / volume (process packs refine) --------------------
|
||||
mto_count = fields.Float(
|
||||
string='MTO',
|
||||
default=0.0,
|
||||
help='Metal Turnovers. Maintained by process packs that model '
|
||||
'replenishment (e.g. fusion_plating_process_en).',
|
||||
)
|
||||
volume = fields.Float(
|
||||
string='Volume',
|
||||
help='Working volume (defaults to tank volume on makeup).',
|
||||
)
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ==========================================================================
|
||||
# Defaults
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.bath')
|
||||
return seq or '/'
|
||||
|
||||
# ==========================================================================
|
||||
# Computes
|
||||
# ==========================================================================
|
||||
@api.depends('name', 'process_type_id', 'tank_id')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
parts = [rec.name or '']
|
||||
if rec.process_type_id:
|
||||
parts.append(f'({rec.process_type_id.code})')
|
||||
if rec.tank_id:
|
||||
parts.append(f'@ {rec.tank_id.code}')
|
||||
rec.display_name = ' '.join(p for p in parts if p)
|
||||
|
||||
def _compute_log_count(self):
|
||||
for rec in self:
|
||||
rec.log_count = len(rec.log_ids)
|
||||
|
||||
@api.depends('log_ids', 'log_ids.log_date', 'log_ids.status')
|
||||
def _compute_last_log(self):
|
||||
for rec in self:
|
||||
last = rec.log_ids.sorted('log_date', reverse=True)[:1]
|
||||
rec.last_log_date = last.log_date if last else False
|
||||
rec.last_log_status = last.status if last else False
|
||||
|
||||
@api.depends('state', 'last_log_status')
|
||||
def _compute_status_color(self):
|
||||
"""Kanban colour index — neutral palette that works in light + dark.
|
||||
|
||||
Uses Odoo's built-in color index rather than hex codes, so themes
|
||||
control the final rendering.
|
||||
"""
|
||||
# 0=no color, 4=green, 3=yellow, 2=orange, 1=red, 5=purple, 10=grey
|
||||
for rec in self:
|
||||
if rec.state == 'dumped':
|
||||
rec.status_color = 10 # grey
|
||||
elif rec.state == 'dump_scheduled':
|
||||
rec.status_color = 2 # orange
|
||||
elif rec.state == 'under_review':
|
||||
rec.status_color = 3 # yellow
|
||||
elif rec.state == 'new':
|
||||
rec.status_color = 5 # purple
|
||||
elif rec.last_log_status == 'out_of_spec':
|
||||
rec.status_color = 1 # red
|
||||
elif rec.last_log_status == 'warning':
|
||||
rec.status_color = 3 # yellow
|
||||
else:
|
||||
rec.status_color = 4 # green
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_make_operational(self):
|
||||
self.write({'state': 'operational'})
|
||||
|
||||
def action_mark_under_review(self):
|
||||
self.write({'state': 'under_review'})
|
||||
|
||||
def action_schedule_dump(self):
|
||||
self.write({
|
||||
'state': 'dump_scheduled',
|
||||
'dump_scheduled_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_dump(self):
|
||||
self.write({
|
||||
'state': 'dumped',
|
||||
'dumped_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
|
||||
class FpBathTarget(models.Model):
|
||||
"""Per-bath target range for a chemistry parameter."""
|
||||
_name = 'fusion.plating.bath.target'
|
||||
_description = 'Fusion Plating — Bath Target'
|
||||
_order = 'bath_id, sequence, parameter_id'
|
||||
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath',
|
||||
string='Bath',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter',
|
||||
string='Parameter',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
target_min = fields.Float(string='Min')
|
||||
target_max = fields.Float(string='Max')
|
||||
uom = fields.Char(
|
||||
related='parameter_id.uom',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_bath_target_uniq',
|
||||
'unique(bath_id, parameter_id)',
|
||||
'Each parameter can only be defined once per bath.',
|
||||
),
|
||||
]
|
||||
144
fusion-plating/fusion_plating/models/fp_bath_log.py
Normal file
144
fusion-plating/fusion_plating/models/fp_bath_log.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# -*- 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 FpBathLog(models.Model):
|
||||
"""A daily / per-shift chemistry log for a bath.
|
||||
|
||||
One log record represents one sampling event: an operator walks to a
|
||||
tank, runs titrations or reads instruments, and enters the results.
|
||||
Each log has one or more lines (one per parameter).
|
||||
|
||||
Overall log status is rolled up from the lines:
|
||||
* ok — every line is within target
|
||||
* warning — at least one line is within warning tolerance
|
||||
* out_of_spec — at least one line is outside target
|
||||
"""
|
||||
_name = 'fusion.plating.bath.log'
|
||||
_description = 'Fusion Plating — Bath Chemistry Log'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'log_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: self._default_name(),
|
||||
tracking=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath',
|
||||
string='Bath',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
tracking=True,
|
||||
)
|
||||
tank_id = fields.Many2one(
|
||||
related='bath_id.tank_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
related='bath_id.facility_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
process_type_id = fields.Many2one(
|
||||
related='bath_id.process_type_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
related='bath_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
log_date = fields.Datetime(
|
||||
string='Logged At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
operator_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Operator',
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True,
|
||||
)
|
||||
shift = fields.Selection(
|
||||
[
|
||||
('day', 'Day'),
|
||||
('evening', 'Evening'),
|
||||
('night', 'Night'),
|
||||
],
|
||||
string='Shift',
|
||||
)
|
||||
|
||||
line_ids = fields.One2many(
|
||||
'fusion.plating.bath.log.line',
|
||||
'log_id',
|
||||
string='Readings',
|
||||
copy=True,
|
||||
)
|
||||
|
||||
status = fields.Selection(
|
||||
[
|
||||
('ok', 'OK'),
|
||||
('warning', 'Warning'),
|
||||
('out_of_spec', 'Out of Spec'),
|
||||
],
|
||||
string='Status',
|
||||
compute='_compute_status',
|
||||
store=True,
|
||||
tracking=True,
|
||||
)
|
||||
status_color = fields.Integer(
|
||||
compute='_compute_status_color',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.bath.log')
|
||||
return seq or '/'
|
||||
|
||||
@api.depends('name', 'bath_id', 'log_date')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
parts = []
|
||||
if rec.bath_id:
|
||||
parts.append(rec.bath_id.name)
|
||||
if rec.log_date:
|
||||
parts.append(fields.Datetime.to_string(rec.log_date))
|
||||
rec.display_name = ' — '.join(parts) if parts else rec.name
|
||||
|
||||
@api.depends('line_ids', 'line_ids.status')
|
||||
def _compute_status(self):
|
||||
for rec in self:
|
||||
statuses = set(rec.line_ids.mapped('status'))
|
||||
if 'out_of_spec' in statuses:
|
||||
rec.status = 'out_of_spec'
|
||||
elif 'warning' in statuses:
|
||||
rec.status = 'warning'
|
||||
else:
|
||||
rec.status = 'ok'
|
||||
|
||||
@api.depends('status')
|
||||
def _compute_status_color(self):
|
||||
# Kanban color indexes: 0 default, 1 red, 3 yellow, 4 green
|
||||
mapping = {'ok': 4, 'warning': 3, 'out_of_spec': 1}
|
||||
for rec in self:
|
||||
rec.status_color = mapping.get(rec.status, 0)
|
||||
114
fusion-plating/fusion_plating/models/fp_bath_log_line.py
Normal file
114
fusion-plating/fusion_plating/models/fp_bath_log_line.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# -*- 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 FpBathLogLine(models.Model):
|
||||
"""A single parameter reading on a bath log.
|
||||
|
||||
Each line = one titration result or one sensor reading. Target ranges
|
||||
are pulled from the bath's per-bath overrides if present, otherwise
|
||||
from the parameter's defaults on fusion.plating.bath.parameter.
|
||||
Status is computed per line (ok / warning / out_of_spec) and rolled
|
||||
up to the parent log.
|
||||
"""
|
||||
_name = 'fusion.plating.bath.log.line'
|
||||
_description = 'Fusion Plating — Bath Log Reading'
|
||||
_order = 'log_id, sequence, id'
|
||||
|
||||
log_id = fields.Many2one(
|
||||
'fusion.plating.bath.log',
|
||||
string='Log',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
related='log_id.bath_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter',
|
||||
string='Parameter',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
parameter_code = fields.Char(
|
||||
related='parameter_id.code',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
uom = fields.Char(
|
||||
related='parameter_id.uom',
|
||||
readonly=True,
|
||||
)
|
||||
value = fields.Float(
|
||||
string='Value',
|
||||
required=True,
|
||||
)
|
||||
target_min = fields.Float(
|
||||
string='Target Min',
|
||||
compute='_compute_targets',
|
||||
store=True,
|
||||
)
|
||||
target_max = fields.Float(
|
||||
string='Target Max',
|
||||
compute='_compute_targets',
|
||||
store=True,
|
||||
)
|
||||
status = fields.Selection(
|
||||
[
|
||||
('ok', 'OK'),
|
||||
('warning', 'Warning'),
|
||||
('out_of_spec', 'Out of Spec'),
|
||||
],
|
||||
string='Status',
|
||||
compute='_compute_status',
|
||||
store=True,
|
||||
)
|
||||
notes = fields.Char(
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
@api.depends('parameter_id', 'log_id.bath_id')
|
||||
def _compute_targets(self):
|
||||
"""Resolve target range: per-bath override first, parameter default second."""
|
||||
for rec in self:
|
||||
tmin = tmax = 0.0
|
||||
if rec.log_id.bath_id and rec.parameter_id:
|
||||
override = rec.log_id.bath_id.target_line_ids.filtered(
|
||||
lambda t: t.parameter_id.id == rec.parameter_id.id
|
||||
)[:1]
|
||||
if override:
|
||||
tmin, tmax = override.target_min, override.target_max
|
||||
else:
|
||||
tmin = rec.parameter_id.target_min
|
||||
tmax = rec.parameter_id.target_max
|
||||
rec.target_min = tmin
|
||||
rec.target_max = tmax
|
||||
|
||||
@api.depends('value', 'target_min', 'target_max', 'parameter_id.warning_tolerance')
|
||||
def _compute_status(self):
|
||||
for rec in self:
|
||||
if rec.target_min == 0.0 and rec.target_max == 0.0:
|
||||
rec.status = 'ok'
|
||||
continue
|
||||
v, lo, hi = rec.value, rec.target_min, rec.target_max
|
||||
if v < lo or v > hi:
|
||||
rec.status = 'out_of_spec'
|
||||
continue
|
||||
tol_pct = (rec.parameter_id.warning_tolerance or 0.0) / 100.0
|
||||
span = max(hi - lo, 1e-9)
|
||||
if tol_pct > 0 and (v - lo < span * tol_pct or hi - v < span * tol_pct):
|
||||
rec.status = 'warning'
|
||||
else:
|
||||
rec.status = 'ok'
|
||||
88
fusion-plating/fusion_plating/models/fp_bath_parameter.py
Normal file
88
fusion-plating/fusion_plating/models/fp_bath_parameter.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpBathParameter(models.Model):
|
||||
"""Definition of a bath chemistry parameter.
|
||||
|
||||
Parameters are process-agnostic at the schema level (e.g. "Temperature",
|
||||
"pH", "Nickel concentration"). Each process type references a set of
|
||||
parameters via fusion.plating.process.type.parameter_ids. Actual target
|
||||
ranges per bath are stored on fusion.plating.bath (per-bath overrides)
|
||||
or on the bath recipe.
|
||||
"""
|
||||
_name = 'fusion.plating.bath.parameter'
|
||||
_description = 'Fusion Plating — Bath Parameter'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Parameter',
|
||||
required=True,
|
||||
translate=True,
|
||||
help='Display name (e.g. "Nickel Concentration", "pH").',
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
help='Short code used in logs and exports (e.g. "Ni", "PH", "TEMP").',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
parameter_type = fields.Selection(
|
||||
[
|
||||
('concentration', 'Concentration'),
|
||||
('temperature', 'Temperature'),
|
||||
('ph', 'pH'),
|
||||
('conductivity', 'Conductivity'),
|
||||
('turbidity', 'Turbidity'),
|
||||
('ratio', 'Ratio'),
|
||||
('count', 'Count / Age'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
default='concentration',
|
||||
)
|
||||
uom = fields.Char(
|
||||
string='Unit',
|
||||
help='Display unit (e.g. "g/L", "°C", "pH", "MTO").',
|
||||
)
|
||||
target_min = fields.Float(
|
||||
string='Default Target Min',
|
||||
help='Default target minimum. Per-bath overrides are allowed.',
|
||||
)
|
||||
target_max = fields.Float(
|
||||
string='Default Target Max',
|
||||
help='Default target maximum. Per-bath overrides are allowed.',
|
||||
)
|
||||
warning_tolerance = fields.Float(
|
||||
string='Warning Tolerance %',
|
||||
default=10.0,
|
||||
help='Distance from target limit at which a reading is flagged as warning.',
|
||||
)
|
||||
decimals = fields.Integer(
|
||||
string='Decimals',
|
||||
default=2,
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
translate=True,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_bath_parameter_code_uniq',
|
||||
'unique(code)',
|
||||
'Bath parameter code must be unique.',
|
||||
),
|
||||
]
|
||||
102
fusion-plating/fusion_plating/models/fp_facility.py
Normal file
102
fusion-plating/fusion_plating/models/fp_facility.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpFacility(models.Model):
|
||||
"""A physical plating / finishing facility.
|
||||
|
||||
A company can operate 1..N facilities. Each facility has its own work
|
||||
centers, tanks, operators, regulatory profile (ECA, sewer permit, waste
|
||||
generator number), and capability footprint. Jobs are scheduled into
|
||||
a facility based on capability matching.
|
||||
|
||||
Compliance add-on modules (fusion_plating_compliance_*) extend this
|
||||
model with jurisdiction-specific fields via inheritance.
|
||||
"""
|
||||
_name = 'fusion.plating.facility'
|
||||
_description = 'Fusion Plating — Facility'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Facility',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Short facility code used in job numbers and reports.',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Address',
|
||||
help='Partner holding the facility postal address and contact details.',
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
|
||||
# ----- Capability -----------------------------------------------------
|
||||
capability_ids = fields.Many2many(
|
||||
'fusion.plating.process.type',
|
||||
'fp_facility_capability_rel',
|
||||
'facility_id',
|
||||
'process_type_id',
|
||||
string='Capabilities',
|
||||
help='Process types this facility can perform.',
|
||||
)
|
||||
|
||||
# ----- Child records --------------------------------------------------
|
||||
work_center_ids = fields.One2many(
|
||||
'fusion.plating.work.center',
|
||||
'facility_id',
|
||||
string='Work Centers',
|
||||
)
|
||||
tank_ids = fields.One2many(
|
||||
'fusion.plating.tank',
|
||||
'facility_id',
|
||||
string='Tanks',
|
||||
)
|
||||
work_center_count = fields.Integer(
|
||||
compute='_compute_counts',
|
||||
)
|
||||
tank_count = fields.Integer(
|
||||
compute='_compute_counts',
|
||||
)
|
||||
capability_count = fields.Integer(
|
||||
compute='_compute_counts',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_facility_code_company_uniq',
|
||||
'unique(code, company_id)',
|
||||
'Facility code must be unique within a company.',
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.work_center_count = len(rec.work_center_ids)
|
||||
rec.tank_count = len(rec.tank_ids)
|
||||
rec.capability_count = len(rec.capability_ids)
|
||||
|
||||
def name_get(self):
|
||||
return [(rec.id, f'{rec.name} [{rec.code}]') for rec in self]
|
||||
62
fusion-plating/fusion_plating/models/fp_process_category.py
Normal file
62
fusion-plating/fusion_plating/models/fp_process_category.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpProcessCategory(models.Model):
|
||||
"""High-level grouping of finishing process types.
|
||||
|
||||
Ships with a seed set (Plating, Anodizing, Coating, Conversion Coating,
|
||||
Stripping, Other). Process packs reference these categories when they
|
||||
load specific process types.
|
||||
"""
|
||||
_name = 'fusion.plating.process.category'
|
||||
_description = 'Fusion Plating — Process Category'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Category',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
help='Short identifier (e.g. "plating", "anodizing").',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
translate=True,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
process_type_ids = fields.One2many(
|
||||
'fusion.plating.process.type',
|
||||
'category_id',
|
||||
string='Process Types',
|
||||
)
|
||||
process_type_count = fields.Integer(
|
||||
string='Process Types',
|
||||
compute='_compute_process_type_count',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_process_category_code_uniq',
|
||||
'unique(code)',
|
||||
'Process category code must be unique.',
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_process_type_count(self):
|
||||
for rec in self:
|
||||
rec.process_type_count = len(rec.process_type_ids)
|
||||
92
fusion-plating/fusion_plating/models/fp_process_type.py
Normal file
92
fusion-plating/fusion_plating/models/fp_process_type.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpProcessType(models.Model):
|
||||
"""Extensible finishing process taxonomy.
|
||||
|
||||
Core ships this model empty. Process packs (fusion_plating_process_en,
|
||||
fusion_plating_process_chrome, etc.) load records via data XML with
|
||||
noupdate so shops and customisations are preserved across upgrades.
|
||||
|
||||
Each process type has a category (plating / anodizing / conversion / etc.),
|
||||
a reference to optional industry specs, and visual theming for the UI.
|
||||
Chemistry parameter schemas are defined on fusion.plating.bath.parameter
|
||||
and linked here via parameter_ids.
|
||||
"""
|
||||
_name = 'fusion.plating.process.type'
|
||||
_description = 'Fusion Plating — Process Type'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Process',
|
||||
required=True,
|
||||
translate=True,
|
||||
help='Display name (e.g. "Electroless Nickel — Mid Phosphorus").',
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
help='Short unique code (e.g. "EN_MID", "HARD_CR", "ANO_II").',
|
||||
)
|
||||
category_id = fields.Many2one(
|
||||
'fusion.plating.process.category',
|
||||
string='Category',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
translate=True,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
|
||||
# ----- Visual theming (kept neutral so it adapts to both light/dark) ----
|
||||
# Uses Odoo's built-in kanban/list color index (0-11).
|
||||
color = fields.Integer(
|
||||
string='Color Index',
|
||||
default=0,
|
||||
help='Colour index used in kanban and list views.',
|
||||
)
|
||||
icon = fields.Char(
|
||||
string='Icon',
|
||||
help='Optional Font Awesome class (e.g. "fa-flask").',
|
||||
default='fa-flask',
|
||||
)
|
||||
|
||||
# ----- Chemistry & routing support ----------------------------------------
|
||||
parameter_ids = fields.Many2many(
|
||||
'fusion.plating.bath.parameter',
|
||||
'fp_process_type_parameter_rel',
|
||||
'process_type_id',
|
||||
'parameter_id',
|
||||
string='Bath Parameters',
|
||||
help='Chemistry parameters tracked for baths running this process.',
|
||||
)
|
||||
hazard_notes = fields.Text(
|
||||
string='Hazard Notes',
|
||||
translate=True,
|
||||
help='Process-level hazard awareness (e.g. Cr(VI) carcinogen, hypophosphite reducer).',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_process_type_code_uniq',
|
||||
'unique(code)',
|
||||
'Process type code must be unique.',
|
||||
),
|
||||
]
|
||||
|
||||
def name_get(self):
|
||||
return [(rec.id, f'{rec.name} [{rec.code}]') for rec in self]
|
||||
170
fusion-plating/fusion_plating/models/fp_tank.py
Normal file
170
fusion-plating/fusion_plating/models/fp_tank.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# -*- 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 FpTank(models.Model):
|
||||
"""A physical vessel that holds a bath.
|
||||
|
||||
Tanks are long-lived assets. Baths come and go inside a tank. The
|
||||
separation lets a shop dump an exhausted bath without losing the
|
||||
tank's history, QR code, or equipment records.
|
||||
|
||||
Each tank carries a unique QR code for operator scanning at the
|
||||
shop-floor station.
|
||||
"""
|
||||
_name = 'fusion.plating.tank'
|
||||
_description = 'Fusion Plating — Tank'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'facility_id, work_center_id, sequence, code'
|
||||
|
||||
name = fields.Char(
|
||||
string='Tank',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Short unique tank identifier (e.g. "T-01", "EN-A1").',
|
||||
)
|
||||
qr_code = fields.Char(
|
||||
string='QR Code',
|
||||
help='Scannable identifier. Defaults to code, can be set to a longer URI.',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Center',
|
||||
domain="[('facility_id','=',facility_id)]",
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ----- Physical properties --------------------------------------------
|
||||
volume = fields.Float(
|
||||
string='Volume',
|
||||
help='Working volume.',
|
||||
)
|
||||
volume_uom = fields.Selection(
|
||||
[
|
||||
('l', 'Litres'),
|
||||
('gal_us', 'US gallons'),
|
||||
('gal_imp', 'Imperial gallons'),
|
||||
('m3', 'Cubic metres'),
|
||||
],
|
||||
string='Volume Unit',
|
||||
default='l',
|
||||
)
|
||||
material = fields.Selection(
|
||||
[
|
||||
('polypro', 'Polypropylene'),
|
||||
('pvc', 'PVC'),
|
||||
('pvdf', 'PVDF'),
|
||||
('ss', 'Stainless Steel'),
|
||||
('lined_steel', 'Lined Steel'),
|
||||
('glass', 'Glass'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Construction',
|
||||
)
|
||||
heating_type = fields.Selection(
|
||||
[
|
||||
('none', 'None'),
|
||||
('immersion', 'Immersion Heater'),
|
||||
('steam_coil', 'Steam Coil'),
|
||||
('jacket', 'Jacketed'),
|
||||
('external', 'External Heat Exchanger'),
|
||||
],
|
||||
string='Heating',
|
||||
default='none',
|
||||
)
|
||||
has_filtration = fields.Boolean(
|
||||
string='Has Filtration',
|
||||
)
|
||||
has_rectifier = fields.Boolean(
|
||||
string='Has Rectifier',
|
||||
help='Required for electrolytic processes (chrome, anodize, strike).',
|
||||
)
|
||||
|
||||
# ----- State ----------------------------------------------------------
|
||||
state = fields.Selection(
|
||||
[
|
||||
('empty', 'Empty'),
|
||||
('filled', 'Filled'),
|
||||
('in_use', 'In Use'),
|
||||
('draining', 'Draining'),
|
||||
('maintenance', 'Maintenance'),
|
||||
('out_of_service', 'Out of Service'),
|
||||
],
|
||||
string='Status',
|
||||
default='empty',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ----- Relations ------------------------------------------------------
|
||||
bath_ids = fields.One2many(
|
||||
'fusion.plating.bath',
|
||||
'tank_id',
|
||||
string='Bath History',
|
||||
)
|
||||
current_bath_id = fields.Many2one(
|
||||
'fusion.plating.bath',
|
||||
string='Current Bath',
|
||||
compute='_compute_current_bath',
|
||||
store=True,
|
||||
)
|
||||
current_process_id = fields.Many2one(
|
||||
'fusion.plating.process.type',
|
||||
string='Current Process',
|
||||
related='current_bath_id.process_type_id',
|
||||
store=True,
|
||||
)
|
||||
bath_count = fields.Integer(
|
||||
compute='_compute_bath_count',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_tank_code_facility_uniq',
|
||||
'unique(code, facility_id)',
|
||||
'Tank code must be unique within a facility.',
|
||||
),
|
||||
]
|
||||
|
||||
@api.depends('bath_ids', 'bath_ids.state')
|
||||
def _compute_current_bath(self):
|
||||
for rec in self:
|
||||
active = rec.bath_ids.filtered(
|
||||
lambda b: b.state in ('operational', 'under_review')
|
||||
)
|
||||
rec.current_bath_id = active[:1].id if active else False
|
||||
|
||||
def _compute_bath_count(self):
|
||||
for rec in self:
|
||||
rec.bath_count = len(rec.bath_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('qr_code') and vals.get('code'):
|
||||
vals['qr_code'] = f"FP-TANK:{vals['code']}"
|
||||
return super().create(vals_list)
|
||||
72
fusion-plating/fusion_plating/models/fp_work_center.py
Normal file
72
fusion-plating/fusion_plating/models/fp_work_center.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpWorkCenter(models.Model):
|
||||
"""A production line or station inside a facility.
|
||||
|
||||
Examples: "Line 1 - EN", "Anodize Line", "Prep Bay", "Bake Station",
|
||||
"Inspection Booth", "Shipping Dock". Work centers group tanks and
|
||||
provide scheduling capacity.
|
||||
"""
|
||||
_name = 'fusion.plating.work.center'
|
||||
_description = 'Fusion Plating — Work Center'
|
||||
_order = 'facility_id, sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Work Center',
|
||||
required=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
supported_process_ids = fields.Many2many(
|
||||
'fusion.plating.process.type',
|
||||
'fp_work_center_process_rel',
|
||||
'work_center_id',
|
||||
'process_type_id',
|
||||
string='Supported Processes',
|
||||
)
|
||||
tank_ids = fields.One2many(
|
||||
'fusion.plating.tank',
|
||||
'work_center_id',
|
||||
string='Tanks',
|
||||
)
|
||||
tank_count = fields.Integer(
|
||||
compute='_compute_tank_count',
|
||||
)
|
||||
capacity_per_day = fields.Float(
|
||||
string='Capacity / Day',
|
||||
help='Theoretical throughput (parts, jobs, or square metres per day) — unit depends on shop.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_work_center_code_facility_uniq',
|
||||
'unique(code, facility_id)',
|
||||
'Work center code must be unique within a facility.',
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_tank_count(self):
|
||||
for rec in self:
|
||||
rec.tank_count = len(rec.tank_ids)
|
||||
30
fusion-plating/fusion_plating/models/res_company.py
Normal file
30
fusion-plating/fusion_plating/models/res_company.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
# ----- Facility footprint for this legal entity ----------------------
|
||||
x_fc_facility_ids = fields.One2many(
|
||||
'fusion.plating.facility',
|
||||
'company_id',
|
||||
string='Plating Facilities',
|
||||
)
|
||||
x_fc_facility_count = fields.Integer(
|
||||
string='# Facilities',
|
||||
compute='_compute_x_fc_facility_count',
|
||||
)
|
||||
x_fc_default_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Default Facility',
|
||||
help='Facility used when the context does not specify one (single-site shops).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_facility_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
|
||||
Reference in New Issue
Block a user