diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index d9713878..586e177f 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
- 'version': '19.0.1.0.0',
+ 'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -90,6 +90,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml',
'views/fp_bath_views.xml',
'views/fp_process_node_views.xml',
+ 'views/fp_rack_views.xml',
+ 'views/fp_bath_replenishment_views.xml',
'views/fp_menu.xml',
'data/fp_recipe_enp_alum_basic.xml',
],
diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py
index c4a4eb43..59faad35 100644
--- a/fusion_plating/fusion_plating/models/__init__.py
+++ b/fusion_plating/fusion_plating/models/__init__.py
@@ -12,5 +12,7 @@ from . import fp_bath
from . import fp_bath_log
from . import fp_bath_log_line
from . import fp_bath_parameter
+from . import fp_bath_replenishment_rule
from . import fp_process_node
+from . import fp_rack
from . import res_company
diff --git a/fusion_plating/fusion_plating/models/fp_bath_log_line.py b/fusion_plating/fusion_plating/models/fp_bath_log_line.py
index 8a21077c..c8b31d2f 100644
--- a/fusion_plating/fusion_plating/models/fp_bath_log_line.py
+++ b/fusion_plating/fusion_plating/models/fp_bath_log_line.py
@@ -112,3 +112,47 @@ class FpBathLogLine(models.Model):
rec.status = 'warning'
else:
rec.status = 'ok'
+
+ # ------------------------------------------------------------------
+ # T1.2 — Auto-suggest replenishment on every log line
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ lines = super().create(vals_list)
+ lines._spawn_replenishment_suggestions()
+ return lines
+
+ def _spawn_replenishment_suggestions(self):
+ """For every out-of-spec reading, run the matching replenishment
+ rule and create a pending suggestion the operator can apply."""
+ Rule = self.env['fusion.plating.bath.replenishment.rule']
+ Suggestion = self.env['fusion.plating.bath.replenishment.suggestion']
+ for line in self:
+ if not line.parameter_id or not line.log_id.bath_id:
+ continue
+ bath = line.log_id.bath_id
+ rules = Rule._find_rules(bath, line.parameter_id.id)
+ for rule in rules:
+ dose = rule._compute_dose(
+ line.value, line.target_min, line.target_max, bath.volume,
+ )
+ if dose <= 0:
+ continue
+ Suggestion.create({
+ 'bath_id': bath.id,
+ 'log_line_id': line.id,
+ 'rule_id': rule.id,
+ 'parameter_id': line.parameter_id.id,
+ 'current_value': line.value,
+ 'target_min': line.target_min,
+ 'target_max': line.target_max,
+ 'product_name': rule.product_name,
+ 'dose_amount': dose,
+ 'dose_uom': rule.dose_uom,
+ 'state': 'pending',
+ })
+ bath.message_post(
+ body=f'Replenishment suggested: add {dose} {rule.dose_uom} '
+ f'of {rule.product_name} ({line.parameter_id.name} '
+ f'reading: {line.value})',
+ )
diff --git a/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py b/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py
new file mode 100644
index 00000000..8aca863d
--- /dev/null
+++ b/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py
@@ -0,0 +1,161 @@
+# -*- 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 FpBathReplenishmentRule(models.Model):
+ """Linear replenishment rule: when a chemistry reading drifts outside
+ target, calculate how much replenisher to add.
+
+ The formula is deliberately simple:
+ dose = deficit × bath.volume × dose_rate
+
+ where deficit = (target_min − value) for below_min rules
+ or = (value − target_max) for above_max rules.
+
+ Shops wanting non-linear or piecewise rules can extend this model.
+ """
+ _name = 'fusion.plating.bath.replenishment.rule'
+ _description = 'Fusion Plating — Replenishment Rule'
+ _order = 'process_type_id, parameter_id'
+
+ name = fields.Char(string='Rule Name', required=True)
+ process_type_id = fields.Many2one(
+ 'fusion.plating.process.type', string='Process Type',
+ help='If set, this rule applies to every bath running this process. '
+ 'Leave blank and set bath_id for a bath-specific rule.',
+ )
+ bath_id = fields.Many2one(
+ 'fusion.plating.bath', string='Specific Bath',
+ help='Narrow the rule to a single bath (overrides process-level rule).',
+ )
+ parameter_id = fields.Many2one(
+ 'fusion.plating.bath.parameter', string='Parameter', required=True,
+ )
+ trigger = fields.Selection(
+ [('below_min', 'Reading Below Target Min'),
+ ('above_max', 'Reading Above Target Max')],
+ string='Trigger', required=True, default='below_min',
+ )
+ product_name = fields.Char(
+ string='Replenisher Name', required=True,
+ help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"',
+ )
+ product_id = fields.Many2one(
+ 'product.product', string='Product (Inventory)',
+ help='Optional link to an inventory product for consumption tracking.',
+ )
+ dose_rate = fields.Float(
+ string='Dose Rate', required=True, digits=(12, 4),
+ help='Amount of replenisher per unit of parameter deficit per gallon '
+ 'of bath volume. E.g. 0.5 means "add 0.5 mL per (g/L deficit) per gallon".',
+ )
+ dose_uom = fields.Selection(
+ [('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
+ string='Dose UoM', required=True, default='ml',
+ )
+ min_dose = fields.Float(
+ string='Minimum Dose', default=0.0,
+ help='Do not suggest doses below this (useful to avoid noise).',
+ )
+ max_dose = fields.Float(
+ string='Safety Cap', default=0.0,
+ help='Cap the suggested dose. 0 = no cap.',
+ )
+ notes = fields.Text(string='Operator Notes')
+ active = fields.Boolean(default=True)
+
+ @api.model
+ def _find_rules(self, bath, parameter_id):
+ """Return rules applicable to this (bath, parameter). Bath-specific
+ rules take precedence over process-level ones.
+ """
+ bath_rule = self.search([
+ ('bath_id', '=', bath.id),
+ ('parameter_id', '=', parameter_id),
+ ('active', '=', True),
+ ])
+ if bath_rule:
+ return bath_rule
+ return self.search([
+ ('bath_id', '=', False),
+ ('process_type_id', '=', bath.process_type_id.id),
+ ('parameter_id', '=', parameter_id),
+ ('active', '=', True),
+ ])
+
+ def _compute_dose(self, value, target_min, target_max, bath_volume):
+ """Return a dose amount for this rule given the reading context.
+ Returns 0.0 if the trigger doesn't apply.
+ """
+ self.ensure_one()
+ deficit = 0.0
+ if self.trigger == 'below_min' and target_min and value < target_min:
+ deficit = target_min - value
+ elif self.trigger == 'above_max' and target_max and value > target_max:
+ deficit = value - target_max
+ if deficit <= 0:
+ return 0.0
+ dose = deficit * (bath_volume or 1.0) * self.dose_rate
+ if self.min_dose and dose < self.min_dose:
+ return 0.0
+ if self.max_dose and dose > self.max_dose:
+ dose = self.max_dose
+ return round(dose, 3)
+
+
+class FpBathReplenishmentSuggestion(models.Model):
+ """One suggestion generated from a bath-log reading. Operators mark
+ them applied or dismissed once the dose has been added."""
+ _name = 'fusion.plating.bath.replenishment.suggestion'
+ _description = 'Fusion Plating — Replenishment Suggestion'
+ _inherit = ['mail.thread']
+ _order = 'create_date desc, id desc'
+
+ bath_id = fields.Many2one(
+ 'fusion.plating.bath', string='Bath', required=True, ondelete='cascade',
+ )
+ log_line_id = fields.Many2one(
+ 'fusion.plating.bath.log.line', string='Triggering Reading',
+ ondelete='cascade',
+ )
+ rule_id = fields.Many2one(
+ 'fusion.plating.bath.replenishment.rule', string='Rule',
+ ondelete='set null',
+ )
+ parameter_id = fields.Many2one(
+ 'fusion.plating.bath.parameter', string='Parameter', required=True,
+ )
+ current_value = fields.Float(string='Current Reading', digits=(12, 4))
+ target_min = fields.Float(string='Target Min', digits=(12, 4))
+ target_max = fields.Float(string='Target Max', digits=(12, 4))
+ product_name = fields.Char(string='Replenisher', required=True)
+ dose_amount = fields.Float(string='Suggested Dose', digits=(12, 3))
+ dose_uom = fields.Selection(
+ [('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
+ string='UoM', required=True, default='ml',
+ )
+ state = fields.Selection(
+ [('pending', 'Pending'), ('applied', 'Applied'), ('dismissed', 'Dismissed')],
+ default='pending', tracking=True,
+ )
+ applied_at = fields.Datetime(readonly=True)
+ applied_by_id = fields.Many2one('res.users', readonly=True)
+
+ def action_apply(self):
+ for rec in self:
+ rec.write({
+ 'state': 'applied',
+ 'applied_at': fields.Datetime.now(),
+ 'applied_by_id': self.env.user.id,
+ })
+ rec.bath_id.message_post(
+ body=f'Replenishment applied: {rec.dose_amount} {rec.dose_uom} '
+ f'of {rec.product_name} (parameter: {rec.parameter_id.name})'
+ )
+
+ def action_dismiss(self):
+ self.write({'state': 'dismissed'})
diff --git a/fusion_plating/fusion_plating/models/fp_rack.py b/fusion_plating/fusion_plating/models/fp_rack.py
new file mode 100644
index 00000000..58402ec9
--- /dev/null
+++ b/fusion_plating/fusion_plating/models/fp_rack.py
@@ -0,0 +1,117 @@
+# -*- 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 FpRack(models.Model):
+ """Plating rack / barrel / fixture.
+
+ Racks carry parts through baths and accumulate nickel themselves over
+ time. Once the rack's metal turnover (MTO) count exceeds the strip
+ interval, the rack must be stripped before re-use to avoid bald spots
+ on parts.
+ """
+ _name = 'fusion.plating.rack'
+ _description = 'Fusion Plating — Rack / Fixture'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _order = 'facility_id, rack_type, name'
+
+ name = fields.Char(string='Rack ID', required=True, tracking=True)
+ rack_type = fields.Selection(
+ [('rack', 'Rack'), ('barrel', 'Barrel'),
+ ('fixture', 'Fixture'), ('basket', 'Basket')],
+ string='Type', required=True, default='rack',
+ )
+ facility_id = fields.Many2one(
+ 'fusion.plating.facility', string='Facility', required=True, tracking=True,
+ )
+ company_id = fields.Many2one(
+ 'res.company', related='facility_id.company_id', store=True, readonly=True,
+ )
+ capacity = fields.Integer(
+ string='Capacity (parts)',
+ help='Max parts per load. Used for batch planning.',
+ )
+ contact_points = fields.Integer(
+ string='Contact Points',
+ help='Number of clips/tips that touch parts. Wear points for re-stripping.',
+ )
+
+ # --- Wear tracking ---
+ mto_count = fields.Float(
+ string='MTO (current)', default=0.0, tracking=True,
+ help='Metal turnover accumulated since last strip.',
+ )
+ strip_interval_mto = fields.Float(
+ string='Strip After (MTO)', default=3.0,
+ help='When MTO crosses this value, rack needs stripping.',
+ )
+ last_stripped_date = fields.Datetime(string='Last Stripped', tracking=True)
+ last_stripped_by_id = fields.Many2one(
+ 'res.users', string='Stripped By', tracking=True,
+ )
+ strips_count = fields.Integer(string='Total Strips', default=0, readonly=True)
+
+ state = fields.Selection(
+ [('active', 'Active'),
+ ('needs_strip', 'Needs Strip'),
+ ('stripping', 'Stripping'),
+ ('retired', 'Retired')],
+ string='Status', default='active', required=True, tracking=True,
+ compute='_compute_state', store=True, readonly=False,
+ )
+ status_color = fields.Integer(compute='_compute_status_color')
+ notes = fields.Html(string='Notes')
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ ('fp_rack_facility_name_uniq', 'unique(facility_id, name)',
+ 'Rack ID must be unique per facility.'),
+ ]
+
+ # ------------------------------------------------------------------
+ # Computes
+ # ------------------------------------------------------------------
+ @api.depends('mto_count', 'strip_interval_mto')
+ def _compute_state(self):
+ for rec in self:
+ if rec.state in ('stripping', 'retired'):
+ continue # Manually set — don't override
+ if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto:
+ rec.state = 'needs_strip'
+ elif rec.state != 'active':
+ rec.state = 'active'
+
+ @api.depends('state')
+ def _compute_status_color(self):
+ mapping = {'active': 4, 'needs_strip': 3, 'stripping': 2, 'retired': 10}
+ for rec in self:
+ rec.status_color = mapping.get(rec.state, 0)
+
+ # ------------------------------------------------------------------
+ # Actions
+ # ------------------------------------------------------------------
+ def action_start_strip(self):
+ self.write({'state': 'stripping'})
+
+ def action_mark_stripped(self):
+ for rec in self:
+ rec.write({
+ 'state': 'active',
+ 'mto_count': 0.0,
+ 'last_stripped_date': fields.Datetime.now(),
+ 'last_stripped_by_id': self.env.user.id,
+ 'strips_count': rec.strips_count + 1,
+ })
+ rec.message_post(body=_('Rack stripped and returned to service.'))
+
+ def action_retire(self):
+ self.write({'state': 'retired', 'active': False})
+
+ def _increment_mto(self, delta=1.0):
+ """Add `delta` to the rack's MTO count. Called by the WO finish hook."""
+ for rec in self:
+ rec.mto_count = (rec.mto_count or 0.0) + delta
diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv
index ebc7dcb7..c51caf68 100644
--- a/fusion_plating/fusion_plating/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating/security/ir.model.access.csv
@@ -32,3 +32,12 @@ access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_proc
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
+access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,group_fusion_plating_operator,1,1,0,0
+access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,group_fusion_plating_supervisor,1,1,1,0
+access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,group_fusion_plating_manager,1,1,1,1
+access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_operator,1,0,0,0
+access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_supervisor,1,1,1,0
+access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_manager,1,1,1,1
+access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_operator,1,1,1,0
+access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_supervisor,1,1,1,0
+access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_manager,1,1,1,1
diff --git a/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml b/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml
new file mode 100644
index 00000000..148ebbec
--- /dev/null
+++ b/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+ fp.replenishment.rule.list
+ fusion.plating.bath.replenishment.rule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.replenishment.rule.form
+ fusion.plating.bath.replenishment.rule
+
+
+
+
+
+
+ Replenishment Rules
+ fusion.plating.bath.replenishment.rule
+ list,form
+
+
+
+
+ fp.replenishment.suggestion.list
+ fusion.plating.bath.replenishment.suggestion
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.replenishment.suggestion.form
+ fusion.plating.bath.replenishment.suggestion
+
+
+
+
+
+
+ Replenishment Suggestions
+ fusion.plating.bath.replenishment.suggestion
+ list,form
+ {'search_default_pending': 1}
+
+
+
diff --git a/fusion_plating/fusion_plating/views/fp_menu.xml b/fusion_plating/fusion_plating/views/fp_menu.xml
index 5d044a99..0fbd5346 100644
--- a/fusion_plating/fusion_plating/views/fp_menu.xml
+++ b/fusion_plating/fusion_plating/views/fp_menu.xml
@@ -43,6 +43,24 @@
action="action_fp_tank"
sequence="30"/>
+
+
+
+
+
+