# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. # # Multi-rack splitting at Racking — Phase 1 jobs-module extension. # Core models live in fusion_plating/models/fp_rack_load.py. This file owns # everything that touches jobs-module fields (fp.job.step.area_kind, # fp.job.part_catalog_id) and the racking-step detection (_fp_is_racking_step). # Spec/plan: docs/superpowers/{specs,plans}/2026-06-03-racking-multi-rack-*.md from odoo import api, fields, models, _ from odoo.exceptions import UserError class FpRackLoad(models.Model): _inherit = 'fp.rack.load' current_area_kind = fields.Char( string='Current Area', compute='_compute_current_area_kind', store=True) @api.depends('current_step_id.area_kind') def _compute_current_area_kind(self): for load in self: load.current_area_kind = load.current_step_id.area_kind or False # ------------------------------------------------------------------ # Racking-step resolution + the "total parts available to rack" # ------------------------------------------------------------------ @api.model def _fp_racking_step_for(self, job): # Detect the racking step by area_kind == 'racking' (the corrected # classification), NOT _fp_is_racking_step() — the latter keys off the # step's kind, and de-racking steps are frequently mis-tagged # kind='racking' in the data, which would wrongly match De-Racking. return job.step_ids.filtered(lambda s: s.area_kind == 'racking')[:1] @api.model def _fp_racking_total(self, job): step = self._fp_racking_step_for(job) if step and step.qty_at_step: return int(step.qty_at_step) return int(job.qty or 0) @api.model def _fp_job_loads(self, job): """Active (not unracked/cancelled) loads carrying this job's parts.""" return self.search([ ('line_ids.job_id', '=', job.id), ('state', 'in', ('loading', 'loaded', 'running')), ], order='id') # ------------------------------------------------------------------ # Division API (operator's split + manual override) # ------------------------------------------------------------------ @api.model def _fp_split_job(self, job, n): """(Re)create n loads for `job`, equal split of the racking total. Drops existing unmoved 'loading' loads first. Moved/assigned loads are left alone (can't re-split parts that already advanced).""" total = self._fp_racking_total(job) self._fp_job_loads(job).filtered( lambda l: l.state == 'loading' and not l.current_step_id).unlink() qtys = self._fp_equal_split(total, max(int(n), 1)) loads = self.browse() for q in qtys: loads |= self.create({ 'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})], }) return loads @api.model def _fp_ensure_seeded(self, job): """Default state: one rack carrying all the parts.""" if not self._fp_job_loads(job): self._fp_split_job(job, 1) return self._fp_job_loads(job) @api.model def _fp_add_rack(self, job): return self._fp_split_job(job, len(self._fp_job_loads(job)) + 1) @api.model def _fp_divide_equally(self, job): return self._fp_split_job(job, max(len(self._fp_job_loads(job)), 1)) def _fp_set_qty(self, qty): """Manual override of a single load's quantity (must not exceed the job's available parts across all its loads).""" self.ensure_one() line = self.line_ids[:1] if not line: raise UserError(_('This rack has no work order line.')) job = line.job_id total = self._fp_racking_total(job) other = sum((self._fp_job_loads(job) - self).mapped('qty_total')) if other + int(qty) > total: raise UserError( _('Assigned %(a)s exceeds the %(t)s parts available to rack.') % {'a': other + int(qty), 't': total}) line.qty = int(qty) def _fp_remove_rack(self): self.ensure_one() if self.current_step_id: raise UserError(_('Cannot remove a rack that has already moved.')) self.unlink() # ------------------------------------------------------------------ # Independent movement + de-racking # ------------------------------------------------------------------ def _fp_advance_to(self, to_step): """Move these rack-loads to `to_step`: one move row per line (per WO), carrying the rack + line qty, then update position/state.""" Move = self.env['fp.job.step.move'] for load in self: from_step = load.current_step_id for line in load.line_ids: Move.create({ 'job_id': line.job_id.id, 'from_step_id': from_step.id if from_step else False, 'to_step_id': to_step.id, 'qty_moved': line.qty, 'rack_id': load.rack_id.id if load.rack_id else False, 'transfer_type': 'step', 'moved_by_user_id': self.env.user.id, }) load.current_step_id = to_step load.state = 'running' def _fp_unrack(self): """De-Racking: free the physical rack; each line's parts continue in its own job's flow (the per-line moves already attributed qty).""" for load in self: load.state = 'unracked' if load.rack_id: load.rack_id.racking_state = 'empty' class FpRackLoadLine(models.Model): _inherit = 'fp.rack.load.line' part_catalog_id = fields.Many2one( related='job_id.part_catalog_id', store=True, string='Part') class FpJob(models.Model): _inherit = 'fp.job' rack_load_line_ids = fields.One2many( 'fp.rack.load.line', 'job_id', string='Rack Loads') qty_racked = fields.Integer( string='Parts Racked', compute='_compute_qty_racked') qty_unracked = fields.Integer( string='Parts Unassigned', compute='_compute_qty_racked') @api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state') def _compute_qty_racked(self): Load = self.env['fp.rack.load'] for job in self: active = job.rack_load_line_ids.filtered( lambda l: l.load_id.state in ('loading', 'loaded', 'running')) job.qty_racked = sum(active.mapped('qty')) total = Load._fp_racking_total(job) job.qty_unracked = max(total - job.qty_racked, 0)