folder rename

This commit is contained in:
gsinghpal
2026-04-16 20:53:53 -04:00
parent 3f3ddcbab4
commit 7c7ef06057
634 changed files with 0 additions and 0 deletions

View 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

View 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.',
),
]

View 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)

View 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'

View 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.',
),
]

View 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]

View 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)

View 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).',
)

View 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]

View 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)

View 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)

View 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)