From a19a299c7f9ede9b4020554d361c5ddc0d06ebe1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 3 Jun 2026 08:36:27 -0400 Subject: [PATCH] feat(fusion_plating): Office User grants Contact Creation + rack-load Phase 1 models - group_fp_office_user now implies base.group_partner_manager so every office/ manager role can create contacts (Technicians excluded). Fixes the live "create a company contact, it doesn't show" report (AccessError on save). - New fp.rack.load + fp.rack.load.line models (multi-rack split at Racking, Phase 1) with sequence, ACLs, equal-split math, and tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_plating/fusion_plating/__manifest__.py | 3 +- .../data/fp_rack_load_sequence.xml | 9 ++ .../fusion_plating/models/__init__.py | 1 + .../fusion_plating/models/fp_rack_load.py | 94 +++++++++++++++++++ .../security/fp_menu_visibility.xml | 8 ++ .../security/ir.model.access.csv | 4 + .../fusion_plating/tests/__init__.py | 1 + .../fusion_plating/tests/test_rack_load.py | 27 ++++++ 8 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating/data/fp_rack_load_sequence.xml create mode 100644 fusion_plating/fusion_plating/models/fp_rack_load.py create mode 100644 fusion_plating/fusion_plating/tests/test_rack_load.py diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index bf273657..17f92b45 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.22.1.0', + 'version': '19.0.23.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -93,6 +93,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'data/fp_sequence_data.xml', 'data/fp_job_sequences.xml', 'data/fp_numbering_sequences.xml', + 'data/fp_rack_load_sequence.xml', 'data/fp_process_category_data.xml', # fp_menu.xml MUST load early — defines menu_fp_root, menu_fp_config, # menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder diff --git a/fusion_plating/fusion_plating/data/fp_rack_load_sequence.xml b/fusion_plating/fusion_plating/data/fp_rack_load_sequence.xml new file mode 100644 index 00000000..9aaf8db4 --- /dev/null +++ b/fusion_plating/fusion_plating/data/fp_rack_load_sequence.xml @@ -0,0 +1,9 @@ + + + + Rack Load + fp.rack.load + RACKLOAD/%(year)s/ + 4 + + diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 466db4ac..a7681251 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -55,3 +55,4 @@ from . import fp_landing # imports the predicate chain + xmlid maps from the former). from . import fp_role_constants from . import fp_migration +from . import fp_rack_load diff --git a/fusion_plating/fusion_plating/models/fp_rack_load.py b/fusion_plating/fusion_plating/models/fp_rack_load.py new file mode 100644 index 00000000..93b34655 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_rack_load.py @@ -0,0 +1,94 @@ +# -*- 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 + WO grouping at Racking — Phase 1 core models. +# Spec: docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md +# Plan: docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md +# +# This file (core module) deliberately depends only on CORE fields. The +# racking-step-aware division ops, the fp.job rollups, movement, and the +# current_area_kind compute live in fusion_plating_jobs/models/fp_job_rack.py +# (that module owns fp.job.step.area_kind, fp.job.part_catalog_id, and +# _fp_is_racking_step). + +from odoo import api, fields, models, _ + + +class FpRackLoad(models.Model): + _name = 'fp.rack.load' + _description = 'Rack Load (parts on one physical rack)' + _inherit = ['mail.thread'] + _order = 'id desc' + + name = fields.Char( + string='Reference', required=True, copy=False, index=True, + default=lambda self: _('New')) + rack_id = fields.Many2one( + 'fusion.plating.rack', string='Rack', tracking=True) + line_ids = fields.One2many( + 'fp.rack.load.line', 'load_id', string='Work Orders') + qty_total = fields.Integer( + string='Total Parts', compute='_compute_qty_total', store=True) + current_step_id = fields.Many2one( + 'fp.job.step', string='Current Step', tracking=True) + state = fields.Selection([ + ('loading', 'Loading'), + ('loaded', 'Loaded'), + ('running', 'Running'), + ('unracked', 'Unracked'), + ('cancelled', 'Cancelled'), + ], string='State', default='loading', required=True, tracking=True) + tag_ids = fields.Many2many('fp.rack.tag', string='Tags') + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company) + + @api.depends('line_ids.qty') + def _compute_qty_total(self): + for load in self: + load.qty_total = sum(load.line_ids.mapped('qty')) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = ( + self.env['ir.sequence'].next_by_code('fp.rack.load') + or _('New')) + return super().create(vals_list) + + # ------------------------------------------------------------------ + # Pure division math (no DB) — verifiable in isolation. + # ------------------------------------------------------------------ + @api.model + def _fp_equal_split(self, total, n): + """Split ``total`` parts across ``n`` racks as evenly as possible. + + Remainder goes to the FIRST racks (spec D4): 100/3 -> [34, 33, 33]. + Returns a list of n ints summing to total. n < 1 -> []. + """ + n = int(n) + if n < 1: + return [] + base, rem = divmod(int(total), n) + return [base + 1 if i < rem else base for i in range(n)] + + _qty_total_non_negative = models.Constraint( + 'CHECK (qty_total >= 0)', + 'Rack load total quantity cannot be negative.') + + +class FpRackLoadLine(models.Model): + _name = 'fp.rack.load.line' + _description = 'Rack Load Line (one work order on a rack)' + + load_id = fields.Many2one( + 'fp.rack.load', string='Rack Load', required=True, ondelete='cascade') + job_id = fields.Many2one('fp.job', string='Work Order', required=True) + qty = fields.Integer(string='Parts', required=True, default=0) + + _qty_non_negative = models.Constraint( + 'CHECK (qty >= 0)', + 'Rack load line quantity cannot be negative.') diff --git a/fusion_plating/fusion_plating/security/fp_menu_visibility.xml b/fusion_plating/fusion_plating/security/fp_menu_visibility.xml index ee86d673..b3d57e32 100644 --- a/fusion_plating/fusion_plating/security/fp_menu_visibility.xml +++ b/fusion_plating/fusion_plating/security/fp_menu_visibility.xml @@ -40,6 +40,14 @@ 90 + + Marker group that controls visibility of non-tablet app menus (Calendar, Sales, Inventory, etc.). Implied by every fp role above Technician (Owner, Manager, diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index 70ec2856..317cb7eb 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -97,3 +97,7 @@ access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager access_fp_migration_preview_owner,fp.migration.preview.owner,model_fp_migration_preview,fusion_plating.group_fp_owner,1,1,1,1 access_fp_migration_preview_line_owner,fp.migration.preview.line.owner,model_fp_migration_preview_line,fusion_plating.group_fp_owner,1,1,1,1 access_ir_actions_actions_plating,ir.actions.actions.plating.read,base.model_ir_actions_actions,fusion_plating.group_fp_technician,1,0,0,0 +access_fp_rack_load_tech,fp.rack.load.tech,model_fp_rack_load,fusion_plating.group_fp_technician,1,1,1,0 +access_fp_rack_load_mgr,fp.rack.load.mgr,model_fp_rack_load,fusion_plating.group_fp_manager,1,1,1,1 +access_fp_rack_load_line_tech,fp.rack.load.line.tech,model_fp_rack_load_line,fusion_plating.group_fp_technician,1,1,1,1 +access_fp_rack_load_line_mgr,fp.rack.load.line.mgr,model_fp_rack_load_line,fusion_plating.group_fp_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index 50a9fc0f..de0ff4b1 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -11,3 +11,4 @@ from . import test_landing_resolver from . import test_team_page from . import test_sales_manager_gate from . import test_migration_workflow +from . import test_rack_load diff --git a/fusion_plating/fusion_plating/tests/test_rack_load.py b/fusion_plating/fusion_plating/tests/test_rack_load.py new file mode 100644 index 00000000..2058a54b --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_rack_load.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Phase 1 — rack-load core model tests. +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestRackLoad(TransactionCase): + + def test_equal_split_math(self): + """Remainder goes to the first racks (spec D4).""" + Load = self.env['fp.rack.load'] + self.assertEqual(Load._fp_equal_split(100, 1), [100]) + self.assertEqual(Load._fp_equal_split(100, 2), [50, 50]) + self.assertEqual(Load._fp_equal_split(100, 3), [34, 33, 33]) + self.assertEqual(Load._fp_equal_split(100, 4), [25, 25, 25, 25]) + self.assertEqual(Load._fp_equal_split(10, 3), [4, 3, 3]) + self.assertEqual(Load._fp_equal_split(0, 3), [0, 0, 0]) + self.assertEqual(Load._fp_equal_split(5, 0), []) + # sums always equal the total + self.assertEqual(sum(Load._fp_equal_split(97, 6)), 97) + + def test_create_sequence_and_qty_total(self): + rack = self.env['fusion.plating.rack'].create({'name': 'RL-TEST-RACK'}) + load = self.env['fp.rack.load'].create({'rack_id': rack.id}) + self.assertTrue(load.name.startswith('RACKLOAD/')) + self.assertEqual(load.state, 'loading') + self.assertEqual(load.qty_total, 0)