diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index 586e177f..9505ba36 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -92,6 +92,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_process_node_views.xml',
'views/fp_rack_views.xml',
'views/fp_bath_replenishment_views.xml',
+ 'views/fp_operator_certification_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 59faad35..9c96365d 100644
--- a/fusion_plating/fusion_plating/models/__init__.py
+++ b/fusion_plating/fusion_plating/models/__init__.py
@@ -15,4 +15,5 @@ from . import fp_bath_parameter
from . import fp_bath_replenishment_rule
from . import fp_process_node
from . import fp_rack
+from . import fp_operator_certification
from . import res_company
diff --git a/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py b/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py
index 8aca863d..25c44b1c 100644
--- a/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py
+++ b/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py
@@ -144,8 +144,17 @@ class FpBathReplenishmentSuggestion(models.Model):
)
applied_at = fields.Datetime(readonly=True)
applied_by_id = fields.Many2one('res.users', readonly=True)
+ charged_to_mo_ref = fields.Char(
+ string='Charged to MO',
+ help='Manufacturing order this replenishment was charged against '
+ '(for job costing). Blank = unassigned.',
+ )
def action_apply(self):
+ """Mark applied + log to bath chatter. A follow-up JobConsumption
+ record can be created by `action_apply_and_charge()` to attribute
+ cost to a specific MO.
+ """
for rec in self:
rec.write({
'state': 'applied',
diff --git a/fusion_plating/fusion_plating/models/fp_operator_certification.py b/fusion_plating/fusion_plating/models/fp_operator_certification.py
new file mode 100644
index 00000000..89b51afd
--- /dev/null
+++ b/fusion_plating/fusion_plating/models/fp_operator_certification.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 FpOperatorCertification(models.Model):
+ """A signed-off training record that certifies an operator on a
+ specific process type.
+
+ Used to gate shop-floor work orders: an operator cannot start a
+ plating WO unless they hold a current (non-expired) certification
+ for that process.
+ """
+ _name = 'fp.operator.certification'
+ _description = 'Fusion Plating — Operator Certification'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _order = 'employee_id, process_type_id'
+
+ name = fields.Char(
+ string='Certification Ref',
+ compute='_compute_name', store=True,
+ )
+ employee_id = fields.Many2one(
+ 'hr.employee', string='Operator', required=True,
+ ondelete='cascade', tracking=True,
+ )
+ process_type_id = fields.Many2one(
+ 'fusion.plating.process.type', string='Process Type',
+ required=True, ondelete='restrict', tracking=True,
+ )
+ issued_date = fields.Date(
+ string='Issued', default=fields.Date.today, required=True,
+ )
+ expires_date = fields.Date(
+ string='Expires',
+ help='Blank = no expiry. Set a date for re-certification tracking.',
+ )
+ issued_by_id = fields.Many2one(
+ 'res.users', string='Certified By', default=lambda self: self.env.user,
+ )
+ training_record_attachment_id = fields.Many2one(
+ 'ir.attachment', string='Training Record',
+ )
+ notes = fields.Text(string='Notes')
+ state = fields.Selection(
+ [('active', 'Active'),
+ ('expired', 'Expired'),
+ ('revoked', 'Revoked')],
+ string='Status', default='active', required=True,
+ compute='_compute_state', store=True, readonly=False, tracking=True,
+ )
+ revoked_reason = fields.Text(string='Revoked Reason')
+
+ _sql_constraints = [
+ ('fp_operator_cert_unique',
+ 'unique(employee_id, process_type_id, state)',
+ 'An operator cannot hold two active certifications for the same process.'),
+ ]
+
+ @api.depends('employee_id', 'process_type_id')
+ def _compute_name(self):
+ for rec in self:
+ if rec.employee_id and rec.process_type_id:
+ rec.name = f'{rec.employee_id.name} / {rec.process_type_id.name}'
+ else:
+ rec.name = ''
+
+ @api.depends('expires_date')
+ def _compute_state(self):
+ today = fields.Date.today()
+ for rec in self:
+ if rec.state == 'revoked':
+ continue
+ if rec.expires_date and rec.expires_date < today:
+ rec.state = 'expired'
+ elif rec.state != 'active':
+ rec.state = 'active'
+
+ def action_revoke(self):
+ for rec in self:
+ rec.state = 'revoked'
+ rec.message_post(body=_('Certification revoked.'))
+
+ @api.model
+ def has_active_cert(self, employee_id, process_type_id):
+ """Utility — True if this employee holds a current certification
+ for this process type (or one of its ancestors in the category tree).
+ """
+ if not employee_id or not process_type_id:
+ return False
+ return bool(self.search_count([
+ ('employee_id', '=', employee_id),
+ ('process_type_id', '=', process_type_id),
+ ('state', '=', 'active'),
+ ]))
+
+
+class HrEmployee(models.Model):
+ _inherit = 'hr.employee'
+
+ x_fc_certification_ids = fields.One2many(
+ 'fp.operator.certification', 'employee_id',
+ string='Plating Certifications',
+ )
+ x_fc_certified_process_ids = fields.Many2many(
+ 'fusion.plating.process.type', compute='_compute_certified_processes',
+ string='Certified Processes',
+ )
+
+ @api.depends('x_fc_certification_ids.state', 'x_fc_certification_ids.process_type_id')
+ def _compute_certified_processes(self):
+ for emp in self:
+ active = emp.x_fc_certification_ids.filtered(lambda c: c.state == 'active')
+ emp.x_fc_certified_process_ids = active.mapped('process_type_id')
diff --git a/fusion_plating/fusion_plating/models/fp_process_type.py b/fusion_plating/fusion_plating/models/fp_process_type.py
index 6a912b11..a5537e00 100644
--- a/fusion_plating/fusion_plating/models/fp_process_type.py
+++ b/fusion_plating/fusion_plating/models/fp_process_type.py
@@ -39,6 +39,20 @@ class FpProcessType(models.Model):
required=True,
ondelete='restrict',
)
+ process_family = fields.Selection(
+ [('pre_treatment', 'Pre-Treatment'),
+ ('plating', 'Plating'),
+ ('post_treatment', 'Post-Treatment'),
+ ('bake', 'Hydrogen Bake / Heat Treat'),
+ ('strip', 'Strip'),
+ ('passivation', 'Passivation'),
+ ('masking', 'Masking / De-masking'),
+ ('inspection', 'Inspection / QC')],
+ string='Family', default='plating', required=True, tracking=True,
+ help='High-level grouping used to filter baths and plan routings. '
+ 'Pre-treatments (alkaline clean, acid etch, zincate) should be '
+ 'tracked as full baths with their own chemistry logs.',
+ )
sequence = fields.Integer(
string='Sequence',
default=10,
diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv
index c51caf68..715972b2 100644
--- a/fusion_plating/fusion_plating/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating/security/ir.model.access.csv
@@ -41,3 +41,6 @@ access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_
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
+access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,group_fusion_plating_operator,1,0,0,0
+access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,group_fusion_plating_supervisor,1,1,1,0
+access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,group_fusion_plating_manager,1,1,1,1
diff --git a/fusion_plating/fusion_plating/views/fp_bath_views.xml b/fusion_plating/fusion_plating/views/fp_bath_views.xml
index 1017029d..3f972344 100644
--- a/fusion_plating/fusion_plating/views/fp_bath_views.xml
+++ b/fusion_plating/fusion_plating/views/fp_bath_views.xml
@@ -172,6 +172,15 @@
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating/views/fp_menu.xml b/fusion_plating/fusion_plating/views/fp_menu.xml
index 0fbd5346..92907fda 100644
--- a/fusion_plating/fusion_plating/views/fp_menu.xml
+++ b/fusion_plating/fusion_plating/views/fp_menu.xml
@@ -61,6 +61,12 @@
action="action_fp_replenishment_rule"
sequence="55"/>
+
+