# -*- 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 # ===== Sub 12b — racking lifecycle (orthogonal to wear-tracking state) = racking_state = fields.Selection( [ ('empty', 'Empty'), ('loading', 'Loading'), ('loaded', 'Loaded'), ('in_use', 'In Use'), ('awaiting_unrack', 'Awaiting Unrack'), ('out_of_service', 'Out of Service'), ], string='Racking State', default='empty', tracking=True, help='Operational state in the rack→step→tank flow. Distinct ' 'from the wear-tracking `state` (active/needs_strip/...).', ) tag_ids = fields.Many2many( 'fp.rack.tag', 'fp_rack_tag_rel', 'rack_id', 'tag_id', string='Tags', ) capacity_count = fields.Integer( string='Capacity (parts) — soft warn', help='Soft warning threshold — runtime informs operator when ' 'rack is loaded beyond this. Not enforced. Distinct from ' '`capacity` field (planning capacity).', ) current_job_step_id = fields.Many2one( 'fp.job.step', string='Current Step', compute='_compute_current_use', store=True, ) current_tank_id = fields.Many2one( 'fusion.plating.tank', string='Current Tank', compute='_compute_current_use', store=True, ) current_part_count = fields.Integer( string='Parts on Rack', compute='_compute_current_use', store=True, ) @api.depends('racking_state') def _compute_current_use(self): # Walks the most recent fp.job.step.move row per rack to derive # current step + tank + part count. For racks not currently in # use, all values are blank. Move = self.env['fp.job.step.move'] for rack in self: if rack.racking_state in ('empty', 'out_of_service'): rack.current_job_step_id = False rack.current_tank_id = False rack.current_part_count = 0 continue recent = Move.search( [('rack_id', '=', rack.id)], order='move_datetime desc', limit=1, ) rack.current_job_step_id = recent.to_step_id if recent else False # current_tank_id pulls from the destination step's tank if set rack.current_tank_id = ( recent.to_tank_id or (recent.to_step_id.tank_id if recent and recent.to_step_id else False) ) if recent else False rack.current_part_count = recent.qty_moved if recent else 0