From 696f5da66283746157761d42f595508641c87cd2 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 3 Jun 2026 08:36:47 -0400 Subject: [PATCH] feat(fusion_plating_jobs): de-rack/bake area_kind name override + rack-load Phase 1 - _compute_area_kind: name-based override so de-rack/de-mask steps land in the De-Racking column and bake/oven steps in Baking, regardless of a mis-tagged recipe kind (fixed WO cards scattering into the wrong shop-floor columns). - fp.rack.load jobs extension: racking-step resolution by area_kind (not the corrupt kind), equal-split/override ops, fp.job qty_racked/unracked rollups, and independent rack movement (per-line moves) + de-racking unrack. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/__init__.py | 4 + .../fusion_plating_jobs/models/fp_job_rack.py | 166 ++++++++++++++++++ .../fusion_plating_jobs/models/fp_job_step.py | 67 ++++++- 4 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 fusion_plating/fusion_plating_jobs/models/fp_job_rack.py diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index dbb31df6..52f4ef4d 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.11.5.0', + 'version': '19.0.12.0.1', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/__init__.py b/fusion_plating/fusion_plating_jobs/models/__init__.py index d56ee422..d17b6a7b 100644 --- a/fusion_plating/fusion_plating_jobs/models/__init__.py +++ b/fusion_plating/fusion_plating_jobs/models/__init__.py @@ -35,6 +35,10 @@ from . import report_fp_job_margin # (fp.qc.checklist.template lives in fusion_plating_quality; can't depend # back on jobs without a cycle.) from . import fp_job_consumption + +# Multi-rack splitting at Racking (Phase 1) — jobs-side extension of +# fp.rack.load (core model in fusion_plating) + fp.job rollups. +from . import fp_job_rack # fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the # hr.employee shop-roles inherit live in fusion_plating core so every # downstream module (cgp, bridge_mrp residue, etc.) sees them without a diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_rack.py b/fusion_plating/fusion_plating_jobs/models/fp_job_rack.py new file mode 100644 index 00000000..f4ba6254 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_rack.py @@ -0,0 +1,166 @@ +# -*- 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) diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 9bffefb6..391f512e 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -128,34 +128,83 @@ class FpJobStep(models.Model): @api.depends( 'work_centre_id.area_kind', 'recipe_node_id.kind_id.area_kind', + 'name', ) def _compute_area_kind(self): """Resolve the plant-view column this step belongs in. Priority chain: - 1. work_centre.area_kind (explicit operator setup wins) - 2. recipe_node.kind_id.area_kind (kind taxonomy authoritative) - 3. catch-all 'plating' (data integrity issue if we land here) + 1. name-based override for unambiguous de-rack / de-mask steps + (2026-06-03): their recipe kind AND/OR work-centre is + frequently wrong (tagged 'racking'/'mask', a shared station, + or left blank), which scattered de-racking cards across the + Racking / Masking / Plating columns. The operator-facing step + NAME is unambiguous for these, so it wins OUTRIGHT — even over + an explicit work-centre. Bake/oven steps that merely mention + "de-rack" in their name are excluded so they stay in Baking. + 2. work_centre.area_kind (explicit operator setup) + 3. recipe_node.kind_id.area_kind (kind taxonomy authoritative) + 4. catch-all 'plating' (data integrity issue if we land here) - The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind - now self-declares its area_kind, so the kind taxonomy IS the - source of truth. See spec + The kind taxonomy remains the source of truth for every area + EXCEPT de-rack/de-mask (step 1). See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 6. """ for step in self: - # 1. Explicit work_centre wins + # 1. Name override (de-rack/de-mask -> De-Racking, bake/oven -> + # Baking) — unambiguous; the authored kind / work-centre is + # frequently wrong/blank for these. See _fp_area_from_step_name. + name_area = self._fp_area_from_step_name(step.name) + if name_area: + step.area_kind = name_area + continue + # 2. Explicit work_centre setup if step.work_centre_id and step.work_centre_id.area_kind: step.area_kind = step.work_centre_id.area_kind continue - # 2. Kind taxonomy + # 3. Kind taxonomy node = step.recipe_node_id if node and node.kind_id and node.kind_id.area_kind: step.area_kind = node.kind_id.area_kind continue - # 3. Catch-all — only reached for orphaned steps (no + # 4. Catch-all — only reached for orphaned steps (no # work_centre AND no recipe_node). step.area_kind = 'plating' + @staticmethod + def _fp_area_from_step_name(name): + """Unambiguous step-name -> area_kind override (area or None). + + The recipe kind is frequently wrong/blank for these step types, so + the operator-facing NAME is the more reliable signal and wins over + kind/work-centre in _compute_area_kind: + + - bake / oven -> 'baking' (checked FIRST so "Oven bake (Post + de-rack)" counts as a bake, not a de-rack). Excludes + inspection-of-bake names ("post-bake inspection/QC/test") and + part-number / generic references ("General Processing - + BAKE-K464034") so only real bake operations move. + - de-rack / de-mask -> 'de_racking'. + + Everything else returns None so the normal work-centre / kind / + fallback chain applies. + """ + x = (name or '').strip().lower() + if not x: + return None + # bake / oven first — a "post de-rack" oven bake IS a bake + if 'oven' in x or 'bake' in x: + if any(w in x for w in ( + 'processing', 'inspect', 'check', 'qc', + 'test', 'verif', 'review')): + return None + return 'baking' + # de-rack / de-mask + flat = x.replace('-', '').replace('_', '').replace(' ', '') + if 'derack' in flat or 'demask' in flat: + return 'de_racking' + return None + last_activity_at = fields.Datetime( string='Last Activity', index=True,