The existing 'state' field tracks wear (active/needs_strip/stripping/ retired). Sub 12b adds an orthogonal 'racking_state' (empty/loading/ loaded/in_use/awaiting_unrack/out_of_service) for the load lifecycle — a rack can be wear-active AND racking-loaded simultaneously. New fields: racking_state — operational lifecycle tag_ids (M2M) — fp.rack.tag chips on plant overview capacity_count — soft warn (distinct from existing 'capacity') current_job_step_id — compute, derived from latest fp.job.step.move current_tank_id — compute current_part_count — compute Form view picks up the new fields under Sub-12b group + a 'Current Use' panel that hides when racking_state is empty / out_of_service. The compute references fp.job.step.move which lands in Task 4 — the module won't load cleanly on entech until Tasks 4-5 ship; that's expected for batch deployment at the end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
6.9 KiB
Python
182 lines
6.9 KiB
Python
# -*- 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
|