folder rename
This commit is contained in:
16
fusion_plating/fusion_plating/models/__init__.py
Normal file
16
fusion_plating/fusion_plating/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# -*- 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 fp_process_node
|
||||
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)
|
||||
401
fusion_plating/fusion_plating/models/fp_process_node.py
Normal file
401
fusion_plating/fusion_plating/models/fp_process_node.py
Normal file
@@ -0,0 +1,401 @@
|
||||
# -*- 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, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""A node in the process recipe tree.
|
||||
|
||||
Recipes are hierarchical templates that define how to plate a part.
|
||||
They are reusable across production orders and serve as the single
|
||||
source of truth for the shop's plating processes.
|
||||
|
||||
Node types
|
||||
----------
|
||||
* recipe — top-level root (e.g. "Electroless Nickel — Steel Line")
|
||||
* sub_process — a group of operations (e.g. "Steel Line", "Cleaner")
|
||||
* operation — a single production step (e.g. "Acid Dip", "Nickel Strike")
|
||||
* step — a sub-step within an operation (e.g. "Ready for Blast", "Blast")
|
||||
|
||||
Hierarchy uses Odoo's _parent_store for efficient tree queries.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node'
|
||||
_description = 'Fusion Plating — Process Node'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_parent_store = True
|
||||
_parent_name = 'parent_id'
|
||||
_order = 'parent_path, sequence, id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ---- Identity & hierarchy ------------------------------------------------
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
help='Optional short code (e.g. EN_STEEL).',
|
||||
tracking=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
[
|
||||
('recipe', 'Recipe'),
|
||||
('sub_process', 'Sub-Process'),
|
||||
('operation', 'Operation'),
|
||||
('step', 'Step'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
default='operation',
|
||||
tracking=True,
|
||||
)
|
||||
parent_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Parent',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
parent_path = fields.Char(
|
||||
index=True,
|
||||
)
|
||||
child_ids = fields.One2many(
|
||||
'fusion.plating.process.node',
|
||||
'parent_id',
|
||||
string='Child Steps',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
depth = fields.Integer(
|
||||
string='Depth',
|
||||
compute='_compute_depth',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Process references --------------------------------------------------
|
||||
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type',
|
||||
string='Process Type',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Centre',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Content & metadata --------------------------------------------------
|
||||
|
||||
description = fields.Html(
|
||||
string='Description',
|
||||
help='Rich text instructions for this step.',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Internal Notes',
|
||||
help='Internal notes (not shown to customers).',
|
||||
)
|
||||
icon = fields.Selection(
|
||||
[
|
||||
('fa-flask', 'Flask / Chemistry'),
|
||||
('fa-industry', 'Industry / Line'),
|
||||
('fa-sitemap', 'Sitemap / Process'),
|
||||
('fa-wrench', 'Wrench / Operation'),
|
||||
('fa-cog', 'Gear / General'),
|
||||
('fa-cogs', 'Gears / System'),
|
||||
('fa-paint-brush', 'Paint / Masking'),
|
||||
('fa-eraser', 'Eraser / De-Masking'),
|
||||
('fa-th', 'Grid / Racking'),
|
||||
('fa-fire', 'Fire / Bake'),
|
||||
('fa-bolt', 'Bolt / Electric'),
|
||||
('fa-diamond', 'Diamond / Plating'),
|
||||
('fa-tint', 'Tint / Rinse'),
|
||||
('fa-shower', 'Shower / Clean'),
|
||||
('fa-bullseye', 'Target / Blast'),
|
||||
('fa-search', 'Search / Inspect'),
|
||||
('fa-check-circle', 'Check / Approve'),
|
||||
('fa-clock-o', 'Clock / Wait'),
|
||||
('fa-sun-o', 'Sun / Dry'),
|
||||
('fa-thermometer-half', 'Temp / Heat'),
|
||||
('fa-eye', 'Eye / Visual'),
|
||||
('fa-hand-paper-o', 'Hand / Manual'),
|
||||
('fa-cube', 'Cube / Part'),
|
||||
('fa-shield', 'Shield / Protect'),
|
||||
],
|
||||
string='Icon',
|
||||
default='fa-cog',
|
||||
)
|
||||
color = fields.Integer(
|
||||
string='Colour',
|
||||
default=0,
|
||||
)
|
||||
|
||||
# ---- Timing --------------------------------------------------------------
|
||||
|
||||
estimated_duration = fields.Float(
|
||||
string='Estimated Duration (min)',
|
||||
help='Expected time in minutes.',
|
||||
)
|
||||
|
||||
# ---- Behaviour flags -----------------------------------------------------
|
||||
|
||||
auto_complete = fields.Boolean(
|
||||
string='Auto-Complete',
|
||||
default=False,
|
||||
help='Automatically marks done when all children complete.',
|
||||
)
|
||||
customer_visible = fields.Boolean(
|
||||
string='Customer Visible',
|
||||
default=True,
|
||||
help='Whether to show this step name to customers.',
|
||||
)
|
||||
is_manual = fields.Boolean(
|
||||
string='Manual Operation',
|
||||
default=True,
|
||||
help='Unchecked = automated (e.g. timed immersion).',
|
||||
)
|
||||
requires_signoff = fields.Boolean(
|
||||
string='Requires Sign-Off',
|
||||
default=False,
|
||||
help='Quality hold point — requires operator sign-off.',
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
[
|
||||
('disabled', 'Disabled'),
|
||||
('opt_in', 'Opt-In'),
|
||||
('opt_out', 'Opt-Out'),
|
||||
],
|
||||
string='Opt In/Out',
|
||||
default='disabled',
|
||||
help='Controls whether this step is optional for a given job.',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Lifecycle -----------------------------------------------------------
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
version = fields.Integer(
|
||||
string='Version',
|
||||
default=1,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Computed fields -----------------------------------------------------
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
recursive=True,
|
||||
)
|
||||
child_count = fields.Integer(
|
||||
string='Children',
|
||||
compute='_compute_child_count',
|
||||
)
|
||||
recipe_root_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Root',
|
||||
compute='_compute_recipe_root_id',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Operator inputs (one2many) ------------------------------------------
|
||||
|
||||
input_ids = fields.One2many(
|
||||
'fusion.plating.process.node.input',
|
||||
'node_id',
|
||||
string='Operator Inputs',
|
||||
)
|
||||
|
||||
# ---- SQL constraints -----------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_process_node_code_uniq',
|
||||
'unique(code)',
|
||||
'Recipe node code must be unique.'),
|
||||
]
|
||||
|
||||
# ---- Computes ------------------------------------------------------------
|
||||
|
||||
@api.depends('name', 'code', 'parent_id.display_name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
if rec.parent_id and rec.node_type != 'recipe':
|
||||
rec.display_name = f'{rec.parent_id.display_name} / {rec.name}'
|
||||
else:
|
||||
rec.display_name = rec.name or ''
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_depth(self):
|
||||
for rec in self:
|
||||
rec.depth = (rec.parent_path or '').count('/') - 1
|
||||
|
||||
@api.depends('child_ids')
|
||||
def _compute_child_count(self):
|
||||
for rec in self:
|
||||
rec.child_count = len(rec.child_ids)
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_recipe_root_id(self):
|
||||
for rec in self:
|
||||
if rec.parent_path:
|
||||
root_id = int(rec.parent_path.split('/')[0])
|
||||
rec.recipe_root_id = root_id
|
||||
else:
|
||||
rec.recipe_root_id = rec.id
|
||||
|
||||
# ---- Constraints ---------------------------------------------------------
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_recursion_constraint(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(
|
||||
_('A process node cannot be its own ancestor.'))
|
||||
|
||||
# ---- Tree data for OWL component -----------------------------------------
|
||||
|
||||
def get_tree_data(self):
|
||||
"""Return full nested dict for the OWL recipe tree editor.
|
||||
|
||||
Called via the controller. Returns the tree rooted at `self`,
|
||||
recursively including all descendants.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._node_to_dict()
|
||||
|
||||
def _node_to_dict(self, max_depth=10):
|
||||
"""Recursively convert this node + children to a dict."""
|
||||
if max_depth <= 0:
|
||||
return None
|
||||
children = []
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child_dict = child._node_to_dict(max_depth=max_depth - 1)
|
||||
if child_dict:
|
||||
children.append(child_dict)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name or '',
|
||||
'code': self.code or '',
|
||||
'node_type': self.node_type,
|
||||
'sequence': self.sequence,
|
||||
'depth': self.depth,
|
||||
'icon': self.icon or 'fa-cog',
|
||||
'color': self.color,
|
||||
'process_type': self.process_type_id.name if self.process_type_id else '',
|
||||
'process_type_id': self.process_type_id.id if self.process_type_id else False,
|
||||
'work_center': self.work_center_id.name if self.work_center_id else '',
|
||||
'work_center_id': self.work_center_id.id if self.work_center_id else False,
|
||||
'description': self.description or '',
|
||||
'notes': self.notes or '',
|
||||
'estimated_duration': self.estimated_duration,
|
||||
'auto_complete': self.auto_complete,
|
||||
'customer_visible': self.customer_visible,
|
||||
'is_manual': self.is_manual,
|
||||
'requires_signoff': self.requires_signoff,
|
||||
'version': self.version,
|
||||
'child_count': len(children),
|
||||
'opt_in_out': self.opt_in_out or 'disabled',
|
||||
'input_count': len(self.input_ids),
|
||||
'create_date': self.create_date.isoformat() if self.create_date else '',
|
||||
'create_uid_name': self.create_uid.name if self.create_uid else '',
|
||||
'write_date': self.write_date.isoformat() if self.write_date else '',
|
||||
'write_uid_name': self.write_uid.name if self.write_uid else '',
|
||||
'children': children,
|
||||
}
|
||||
|
||||
# ---- Actions -------------------------------------------------------------
|
||||
|
||||
def action_open_tree_editor(self):
|
||||
"""Open the OWL recipe tree editor for this recipe."""
|
||||
self.ensure_one()
|
||||
root = self if self.node_type == 'recipe' else self.recipe_root_id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_recipe_tree_editor',
|
||||
'name': f'Recipe — {root.name}',
|
||||
'context': {'recipe_id': root.id},
|
||||
}
|
||||
|
||||
# ---- Copy (deep-duplicate) -----------------------------------------------
|
||||
|
||||
def copy(self, default=None):
|
||||
"""Deep-copy: duplicates the node and all descendants."""
|
||||
default = dict(default or {})
|
||||
if self.node_type == 'recipe':
|
||||
default.setdefault('name', _('%s (Copy)', self.name))
|
||||
default.setdefault('code', f'{self.code}_copy' if self.code else False)
|
||||
new_node = super().copy(default)
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child.copy({'parent_id': new_node.id})
|
||||
return new_node
|
||||
|
||||
|
||||
class FpProcessNodeInput(models.Model):
|
||||
"""An operator input definition attached to a process node.
|
||||
|
||||
These define what the operator needs to record when executing this
|
||||
step — temperature readings, visual inspections, timing, etc.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node.input'
|
||||
_description = 'Fusion Plating — Process Node Input'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='E.g. "Temperature Reading", "Visual Inspection".',
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
input_type = fields.Selection(
|
||||
[
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes / No'),
|
||||
('selection', 'Selection'),
|
||||
('photo', 'Photo'),
|
||||
],
|
||||
string='Input Type',
|
||||
required=True,
|
||||
default='text',
|
||||
)
|
||||
required = fields.Boolean(
|
||||
string='Required',
|
||||
default=False,
|
||||
)
|
||||
hint = fields.Char(
|
||||
string='Hint',
|
||||
help='Placeholder text shown to the operator.',
|
||||
)
|
||||
selection_options = fields.Text(
|
||||
string='Options',
|
||||
help='Comma-separated list of options (for Selection type).',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
uom = fields.Char(
|
||||
string='Unit',
|
||||
help='Unit label (e.g. °C, min, psi).',
|
||||
)
|
||||
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