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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-03 08:36:47 -04:00
parent fc3fd513a9
commit 696f5da662
4 changed files with 229 additions and 10 deletions

View File

@@ -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.',

View File

@@ -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

View File

@@ -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)

View File

@@ -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,