From de3ec7d97a3f7e9f0ece0805149cfc3dfb64ee9f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 02:11:35 -0400 Subject: [PATCH] feat(plating-sec): SO confirm gate + fix _administrator typo + Python sweep Phase G of permissions overhaul. G2: sale.order.action_confirm now requires group_fp_sales_manager (spec Section 2.B). Sales Reps can save drafts but cannot move SOs to 'sale' state. UserError raised with clear message if attempted. G3: Fixed audit-finding-11 typo bug in 2 files. The original code checked has_group('fusion_plating.group_fusion_plating_administrator'), an xmlid that has NEVER existed - so the gate always returned False and only the Manager-side check actually fired. Fixed both: - fusion_plating_invoicing/models/res_partner.py:34 - fusion_plating_configurator/wizard/fp_direct_order_wizard.py:467 Both now check has_group('fusion_plating.group_fp_manager') which transitively includes Owner via implied_ids. G4: Swept all Python has_group() calls to reference new group xmlids. Backward-compat keeps old refs working today (Phase A's implied_ids), but the sweep ensures correctness after the 30-day rollback window deletes old groups. Replacements: group_fusion_plating_operator -> group_fp_technician group_fusion_plating_supervisor -> group_fp_shop_manager_v2 group_fusion_plating_manager -> group_fp_manager group_fusion_plating_admin -> group_fp_owner group_fusion_plating_cgp_officer -> group_fp_quality_manager group_fusion_plating_cgp_designated_official -> group_fp_owner group_fp_estimator -> group_fp_sales_rep group_fp_accounting -> group_fp_manager group_fp_receiving -> group_fp_shop_manager_v2 group_fp_shop_manager (legacy) -> group_fp_manager G1: test_sales_manager_gate.py covers the new confirm gate (SR blocked, SMg allowed, Manager allowed via diamond implication). Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../fusion_plating/tests/__init__.py | 1 + .../tests/test_sales_manager_gate.py | 46 +++++++++++++++++++ .../fusion_plating_bridge_mrp/__manifest__.py | 2 +- .../models/mrp_workorder.py | 4 +- .../__manifest__.py | 2 +- .../models/fp_serial.py | 2 +- .../models/sale_order.py | 14 +++++- .../wizard/fp_direct_order_wizard.py | 15 +++--- .../fusion_plating_invoicing/__manifest__.py | 2 +- .../models/res_partner.py | 10 ++-- .../fusion_plating_jobs/__manifest__.py | 2 +- .../controllers/job_scan.py | 2 +- .../fusion_plating_quality/__manifest__.py | 2 +- .../models/fp_contract_review.py | 4 +- .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/tablet_controller.py | 2 +- 17 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 fusion_plating/fusion_plating/tests/test_sales_manager_gate.py diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 28682dcc..4a4f150f 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.21.0.4', + 'version': '19.0.21.0.5', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index 3ef5dd19..987e873f 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_quality_split from . import test_menu_visibility from . import test_landing_resolver from . import test_team_page +from . import test_sales_manager_gate diff --git a/fusion_plating/fusion_plating/tests/test_sales_manager_gate.py b/fusion_plating/fusion_plating/tests/test_sales_manager_gate.py new file mode 100644 index 00000000..51e8d1fe --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_sales_manager_gate.py @@ -0,0 +1,46 @@ +from odoo.tests.common import TransactionCase, tagged +from odoo.exceptions import UserError + + +@tagged('-at_install', 'post_install', 'fp_perms') +class TestSalesManagerGate(TransactionCase): + + def setUp(self): + super().setUp() + Users = self.env['res.users'].with_context(no_reset_password=True) + self.u_sr = Users.create({ + 'login': 'gate_sr', 'name': 'Gate SR', + 'email': 'gate_sr@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_sales_rep').id])], + }) + self.u_smg = Users.create({ + 'login': 'gate_smg', 'name': 'Gate SMg', + 'email': 'gate_smg@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_sales_manager').id])], + }) + partner = self.env['res.partner'].create({'name': 'Gate Test Customer'}) + product = self.env['product.product'].create({'name': 'Gate Test Product'}) + self.so = self.env['sale.order'].create({ + 'partner_id': partner.id, + 'order_line': [(0, 0, { + 'product_id': product.id, 'product_uom_qty': 1, 'price_unit': 100, + })], + }) + + def test_sales_rep_cannot_confirm(self): + with self.assertRaises(UserError): + self.so.with_user(self.u_sr).action_confirm() + + def test_sales_manager_can_confirm(self): + self.so.with_user(self.u_smg).action_confirm() + self.assertEqual(self.so.state, 'sale') + + def test_manager_can_confirm(self): + # Manager implies Sales Manager via the diamond — should also be able to confirm + u_mgr = self.env['res.users'].with_context(no_reset_password=True).create({ + 'login': 'gate_mgr', 'name': 'Gate Mgr', + 'email': 'gate_mgr@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_manager').id])], + }) + self.so.with_user(u_mgr).action_confirm() + self.assertEqual(self.so.state, 'sale') diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 3a6751ca..763adb15 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.13.0.4', + 'version': '19.0.13.0.5', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py index c256ff5e..2834121b 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py @@ -922,7 +922,7 @@ class MrpWorkorder(models.Model): employee = self.env.user.employee_id if not employee: # Admins without an employee record skip the check. - if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): + if not self.env.user.has_group('fusion_plating.group_fp_manager'): raise UserError(_( 'You must be linked to an HR employee record to start ' 'plating work orders. Contact your manager.' @@ -942,7 +942,7 @@ class MrpWorkorder(models.Model): inspection was cleared earlier. Plating Manager bypasses. """ from odoo.exceptions import UserError - if self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): + if self.env.user.has_group('fusion_plating.group_fp_manager'): return Insp = self.env.get('fp.racking.inspection') if Insp is None: diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index d480f3ab..23f5cc4e 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Configurator', - 'version': '19.0.21.8.3', + 'version': '19.0.21.8.4', 'category': 'Manufacturing/Plating', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'description': """ diff --git a/fusion_plating/fusion_plating_configurator/models/fp_serial.py b/fusion_plating/fusion_plating_configurator/models/fp_serial.py index 2c0596d6..75890cc2 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_serial.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_serial.py @@ -212,7 +212,7 @@ class FpSerial(models.Model): correction is needed (e.g. wrong serial marked shipped). Audit trail preserved via chatter; never silently rewrites history.""" for rec in self: - if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): + if not self.env.user.has_group('fusion_plating.group_fp_manager'): from odoo.exceptions import UserError raise UserError(_( 'Only the Plating Manager group can reopen a terminal ' diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order.py b/fusion_plating/fusion_plating_configurator/models/sale_order.py index decaaa93..c9be307a 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order.py @@ -3,7 +3,8 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError class SaleOrder(models.Model): @@ -835,6 +836,17 @@ class SaleOrder(models.Model): # Auto-assigned once at confirm so every confirmed line has one; still # editable afterwards (clearable, overridable to match a customer scheme). def action_confirm(self): + # Phase G of permissions overhaul: only Sales Manager+ can confirm + # Sale Orders. Sales Rep can save drafts but cannot move them to + # 'sale' state. The has_group() check resolves True for Sales Manager, + # Manager (implies Sales Manager via diamond), Quality Manager + # (implies Manager), and Owner (implies Quality Manager) — see + # spec Section 2.B. + if not self.env.user.has_group('fusion_plating.group_fp_sales_manager'): + raise UserError(_( + 'Only Sales Manager or higher can confirm Sale Orders. ' + 'Please ask a Sales Manager to confirm this quote.' + )) res = super().action_confirm() Sequence = self.env['ir.sequence'] for so in self: diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py index deac806c..443739ca 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py @@ -458,14 +458,13 @@ class FpDirectOrderWizard(models.Model): # Resolved through commercial_partner so a hold on the company # blocks every child-contact entry too. commercial = self.partner_id.commercial_partner_id - # Bypass: Plating Manager OR Plating Administrator. Both checked - # because Odoo's implied_ids cascade (Administrator → Manager) - # doesn't always propagate to existing users on upgrade. See - # CLAUDE.md "Implied group cascade" rule. - can_override = ( - self.env.user.has_group('fusion_plating.group_fusion_plating_manager') - or self.env.user.has_group('fusion_plating.group_fusion_plating_administrator') - ) + # Bypass: Plating Manager (or anything above — Quality Manager, + # Owner — via the Phase A implied_ids diamond). Phase G fix: + # old code also checked 'group_fusion_plating_administrator', + # an xmlid that never existed and always returned False + # (audit-finding-11). The Manager check alone is now correct + # because Manager → Quality Manager → Owner via Phase A. + can_override = self.env.user.has_group('fusion_plating.group_fp_manager') if (getattr(commercial, 'x_fc_account_hold', False) and not self.env.context.get('fp_skip_account_hold') and not can_override): diff --git a/fusion_plating/fusion_plating_invoicing/__manifest__.py b/fusion_plating/fusion_plating_invoicing/__manifest__.py index 040b86be..86be11ef 100644 --- a/fusion_plating/fusion_plating_invoicing/__manifest__.py +++ b/fusion_plating/fusion_plating_invoicing/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Invoicing', - 'version': '19.0.3.6.2', + 'version': '19.0.3.6.3', 'category': 'Manufacturing/Plating', 'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.', 'description': """ diff --git a/fusion_plating/fusion_plating_invoicing/models/res_partner.py b/fusion_plating/fusion_plating_invoicing/models/res_partner.py index 8fe11c0e..597c9578 100644 --- a/fusion_plating/fusion_plating_invoicing/models/res_partner.py +++ b/fusion_plating/fusion_plating_invoicing/models/res_partner.py @@ -29,10 +29,12 @@ class ResPartner(models.Model): See CLAUDE.md "Implied group cascade" rule. """ user = self.env.user - return ( - user.has_group('fusion_plating.group_fusion_plating_manager') - or user.has_group('fusion_plating.group_fusion_plating_administrator') - ) + # Phase G: fixed audit-finding-11 — old code referenced + # 'fusion_plating.group_fusion_plating_administrator', an xmlid + # that never existed, so the gate always returned False. Replaced + # with group_fp_manager which transitively implies Owner via + # implied_ids in Phase A's diamond hierarchy. + return user.has_group('fusion_plating.group_fp_manager') x_fc_account_hold_reason = fields.Text(string='Hold Reason') x_fc_account_hold_date = fields.Datetime( string='Hold Date', help='When the hold was placed.', diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 5ab902f2..2ae9bf2a 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.10.24.1', + 'version': '19.0.10.24.2', '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/controllers/job_scan.py b/fusion_plating/fusion_plating_jobs/controllers/job_scan.py index 69945db4..6cd3a9ff 100644 --- a/fusion_plating/fusion_plating_jobs/controllers/job_scan.py +++ b/fusion_plating/fusion_plating_jobs/controllers/job_scan.py @@ -26,7 +26,7 @@ class FpJobScanController(http.Controller): # Otherwise (operator) → land on process tree client action # (will be wired once process tree is added). user = request.env.user - is_manager = user.has_group('fusion_plating.group_fusion_plating_manager') + is_manager = user.has_group('fusion_plating.group_fp_manager') if is_manager: return request.redirect( '/odoo/action-fusion_plating.action_fp_job/%d' % job.id diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index adee94a9..4e4a0522 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Quality (QMS)', - 'version': '19.0.6.6.5', + 'version': '19.0.6.6.6', 'category': 'Manufacturing/Plating', 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'internal audits, customer specs, document control. CE + EE compatible.', diff --git a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py index 725955e8..a9cb7d9c 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py +++ b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py @@ -371,7 +371,7 @@ class FpContractReview(models.Model): def action_reopen(self): """Clear all sign-off data and revert to draft. Manager only.""" self.ensure_one() - if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): + if not self.env.user.has_group('fusion_plating.group_fp_manager'): raise UserError(_( 'Only a Plating Manager can re-open a signed Contract Review.' )) @@ -541,7 +541,7 @@ class FpContractReview(models.Model): waiting on a designated signer who is away. """ self.ensure_one() - if self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): + if self.env.user.has_group('fusion_plating.group_fp_manager'): return allowed = self.company_id._fp_get_qa_signers(section) if self.env.user not in allowed: diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 120c0e61..996e0e6e 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.32.0.3', + 'version': '19.0.32.0.4', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py index 3d93a9b2..0906ae99 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py @@ -24,7 +24,7 @@ _logger = logging.getLogger(__name__) def _is_manager(env): """True if calling user is in the fusion_plating manager group.""" - return env.user.has_group('fusion_plating.group_fusion_plating_manager') + return env.user.has_group('fusion_plating.group_fp_manager') # ===== 2026-05-24 lock-screen redesign helpers =========================