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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="seq_fp_rack_load" model="ir.sequence">
|
||||
<field name="name">Rack Load</field>
|
||||
<field name="code">fp.rack.load</field>
|
||||
<field name="prefix">RACKLOAD/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -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
|
||||
|
||||
94
fusion_plating/fusion_plating/models/fp_rack_load.py
Normal file
94
fusion_plating/fusion_plating/models/fp_rack_load.py
Normal file
@@ -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.')
|
||||
@@ -40,6 +40,14 @@
|
||||
<field name="privilege_id"
|
||||
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="sequence">90</field>
|
||||
<!-- 2026-06-02: office_user also grants "Contact Creation"
|
||||
(base.group_partner_manager) so back-office staff + managers
|
||||
can create contacts/companies. office_user is implied by every
|
||||
fp role ABOVE Technician (Sales Rep, Shop Manager, Manager,
|
||||
Quality Manager, Owner; Sales Manager via Sales Rep), so they
|
||||
all inherit contact-creation. Pure Technicians do NOT imply
|
||||
office_user, so they stay unable to create contacts. -->
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_partner_manager'))]"/>
|
||||
<field name="comment">Marker group that controls visibility of
|
||||
non-tablet app menus (Calendar, Sales, Inventory, etc.).
|
||||
Implied by every fp role above Technician (Owner, Manager,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
27
fusion_plating/fusion_plating/tests/test_rack_load.py
Normal file
27
fusion_plating/fusion_plating/tests/test_rack_load.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user