# Fusion Plating — Permissions Overhaul (Phase 1) **Date:** 2026-05-23 **Status:** Approved for implementation **Owner module:** `fusion_plating` (with co-changes in 9 dependent modules) **Brainstorm transcript:** session with @gsinghpal, 2026-05-23 **Linked plan:** TBD (writing-plans skill, next step) --- ## Problem Statement The current Fusion Plating permission system has 12 `res.groups` defined across 6 modules. An audit (2026-05-23) found: - **3 groups are zero-reference orphans** — Shop Manager, CGP Designated Official, Plating Legacy Menus - **1 group is functionally orphaned** — Administrator (the 2 Python checks that reference it use a typo'd XML ID `_administrator` instead of `_admin`, so the gate never fires) - The role dropdown in the user form lists 10 entries with confusing ordering (sequence ties at 50 and 60 cause Estimator/CGP Officer and Shop Manager/CGP DO to render in arbitrary alphabetical order) - Default landing page is hardcoded to "Shop view" for everyone — Managers complain about being dumped into a Workstation tablet when they open the Plating app - The landing-page picklist in user preferences offers only 3 options (Quotations, Sale Orders, Process Recipes) — missing Manager Desk, Plant View, Quality Dashboard - Menu visibility relies on a mix of explicit `groups=` attributes and implicit action-level ACLs — fragile and inconsistent This Phase 1 work consolidates the 12 groups into **8 well-defined roles**, fixes the landing-page UX with role-based defaults, and ships an Owner-only "Team" page for clean role assignment. --- ## Locked Decisions | # | Question | Decision | |---|---|---| | Q1 | Quality Manager vs Manager — what quality permissions split? | **Option B** — Manager handles reactive Quality (NCR/Hold/Check/routine Cert/RMA). Quality Manager owns strategic Quality (CAPA closure, audit sign-off, FAIR/Nadcap signing, AVL approval, Customer Spec library, Doc Control approval, all CGP). | | Q2 | CGP/Aerospace/Nuclear verticals — fold or keep as add-on flags? | **Option A** — All vertical ACLs gate on Quality Manager. CGP Officer group dropped (folds into QM). CGP Designated Official becomes `res.company.x_fc_cgp_designated_official_id` field (Many2one to res.users, domain `[Owner, QM]`). Aerospace/Nuclear/Safety unchanged (already on Manager backbone). | | Q3 | Landing page per role — hardcoded, configurable, or seeded? | **Option B** — Hardcoded role→action mapping in the resolver. Per-user override stays in preferences. Company default stays as a final fallback. | | Q4 | Owner-only Permissions config page — yes/no, and what does it configure? | **Yes, Interpretation A only** — Owner-only "Team" page for role assignment, designated officials, and audit log. NO permission-definition editing (Interpretation B explicitly killed — would defeat the 8-role spec). | | Q5 | Migration of existing users — auto-map or force manual? | **Option B** — Dry-run preview + Owner approval. Auto-map runs on `-u`, creates a `fp.migration.preview` in `pending` state, schedules a `mail.activity` on every Owner. Migration only applies after Owner clicks "Approve & Run". 30-day rollback window via archived old groups. | | Q4b | Menu/submenu/field visibility — explicit `groups=` or inherit from parent? | **Confirmed by user pre-spec-write** — All three layers (top-level menus, submenus, fields/buttons) get explicit `groups=` matching the new roles. No reliance on action-level ACLs for menu visibility. | --- ## Section 1 — Role Hierarchy & XML IDs ### The 8 new roles All under the existing `fusion_plating.res_groups_privilege_fusion_plating` privilege block (same place in the user form). Sequence numbers picked uniquely to avoid the current audit's "tied at 50/60" rendering bug. | Seq | Display Name | XML ID | Implies | Auto-assigned to | |---:|---|---|---|---| | 10 | Technician | `fusion_plating.group_fp_technician` | `base.group_user` | — | | 20 | Sales Representative | `fusion_plating.group_fp_sales_rep` | `base.group_user` | — | | 30 | Shop Manager | `fusion_plating.group_fp_shop_manager_v2` | Technician | — | | 40 | Sales Manager | `fusion_plating.group_fp_sales_manager` | Sales Representative | — | | 50 | Manager | `fusion_plating.group_fp_manager` | Shop Manager + Sales Manager | — | | 60 | Quality Manager | `fusion_plating.group_fp_quality_manager` | Manager | — | | 70 | Owner | `fusion_plating.group_fp_owner` | Quality Manager + `base.group_system` | uid 1, uid 2 | | — | (No) | — implicit (no Fusion Plating group held) | — | — | ### Design notes 1. **`group_fp_shop_manager_v2` suffix** — the existing `fusion_plating_configurator.group_fp_shop_manager` (today's 0-ref label bundle) gets retired. Suffix `_v2` avoids xmlid collision during migration; we rename to `_shop_manager` in a follow-up housekeeping pass once old refs are confirmed dead. 2. **Sales branch and Shop branch are parallel** — both inherit only `base.group_user`. A Technician can't see Quotations; a Sales Rep can't see the Workstation. They cross-join at Manager. 3. **Diamond at Manager** — Manager implies BOTH Shop Manager AND Sales Manager; gets the union. 4. **"No" is the absence of any group** — no `res.groups` record needed. The Plating menu root gates on an OR of all 7 plating roles. An internal user with none sees no plating menu. 5. **Owner implies `base.group_system`** — replaces today's broken Administrator pattern. Owners get Settings access, can install modules, etc. 6. **Old groups stay defined post-migration but become "DEPRECATED" + auto-archived.** 30-day rollback window. `_cron_purge_expired_migrations` deletes them after 30 days. 7. **CGP Designated Official is no longer a group** — `res.company.x_fc_cgp_designated_official_id` (Many2one to res.users, domain `[('groups_id', 'in', [QM_id, Owner_id])]`). 8. **Field Technician (`fusion_tasks.group_field_technician`) is untouched** — orthogonal to plating roles, separate privilege block. ### Hierarchy visual ``` base.group_user (Internal User) ├── (no plating group) = "No" ├── Technician [10] │ └── Shop Manager v2 [30] │ └── Manager [50] ←──┐ (diamond) │ │ └── Sales Representative [20]│ └── Sales Manager [40] │ └── Manager [50] ←───┘ └── Quality Manager [60] └── Owner [70] (also implies base.group_system) ``` --- ## Section 2 — ACL Re-gating Plan ### 2.A Standard mapping pattern Applies to ~80% of the ~475 ACL refs mechanically: | Old gate | New gate | Why | |---|---|---| | `group_fusion_plating_operator` | `group_fp_technician` | Pure rename | | `group_fusion_plating_supervisor` | `group_fp_shop_manager_v2` | Supervisor's daily-floor leadership IS Shop Manager's job | | `group_fusion_plating_manager` | `group_fp_manager` | Pure rename | | `group_fp_estimator` | `group_fp_sales_rep` | Pure rename, but lose order-confirm (Section 2.B) | | `group_fp_receiving` | `group_fp_shop_manager_v2` | Receiving folds in | | `group_fp_accounting` | `group_fp_manager` | Accounting folds in | | `group_fusion_plating_admin` | `group_fp_owner` | Pure rename + fixes `_administrator` typo bug | Implied-chain handles the rest (e.g., Manager auto-gets everything Shop Manager has, so a model gated on Shop Manager is automatically accessible to Manager+). ### 2.B New gates ADDED | Action | Today | New gate | |---|---|---| | `sale.order.action_confirm` | Any internal user | `group_fp_sales_manager` | | `sale.order` set `x_fc_account_hold_override` | Manager (with `_administrator` typo) | `group_fp_manager` (clean) | | `account.move.action_post` for FP-invoiced SOs | Implicit | `group_fp_manager` | | Owner-only Team page menu | Doesn't exist | `group_fp_owner` | Sales Reps can still save Sale Orders in `draft`; the confirm button is hidden in the view and the model-level gate raises `UserError` if called directly: *"Only Sales Manager or higher can confirm orders."* ### 2.C Quality split — Manager vs Quality Manager | Model | Manager rights | QM-only rights | |---|---|---| | `fusion.plating.ncr` | CRUD + state transitions through `closed` | — | | `fusion.plating.capa` | **Read + comment only** | CRUD + `action_close` + effectiveness verification | | `fusion.plating.quality.hold` | CRUD + release | — | | `fusion.plating.quality.check` | CRUD + pass/fail | — | | `fp.certificate` (routine CoC, thickness) | CRUD + sign + issue + send | — | | `fp.certificate` where `cert_type='fair'` | Read + create | Sign + issue (record rule on cert_type) | | `fp.certificate` where `cert_type='nadcap'` | Read + create | Sign + issue (record rule on cert_type) | | `fusion.plating.rma` | CRUD + authorise + resolve | — | | `fusion.plating.audit` | Read | CRUD + close | | `fusion.plating.customer.spec` | Read + attach to parts | CRUD (library curator) | | `fp.approved.vendor.list` | Read | Add / approve / disqualify | | `fp.contract.review` (QA-005) | Complete reviews assigned to them | Set QA Manager roster + override gates | | Doc Control + Doc Approval | Read + request approval | Approve / supersede / retire | | Calibration equipment | Log events + view schedule | Configure equipment + set intervals + dispose out-of-tolerance | | **All `fp.cgp.*` models** (8 ACLs + 2 ir.rules) | None | All (entire CGP fold-in lands here) | **Implementation note:** FAIR/Nadcap cert split uses an `ir.rule` on `fp.certificate` (domain `[('cert_type', 'in', ['fair','nadcap'])]`) restricted to QM for write. Routine CoCs (cert_type = `coc` or `thickness_report`) stay open to Manager. ### 2.D Verticals (Aerospace / Nuclear / Safety) No change. Their ACLs already gate on `group_fusion_plating_manager` (now `group_fp_manager`). The standard mapping in 2.A covers them. No new vertical-specific gates needed. ### 2.E Three-layer menu / submenu / field hiding policy **Rule:** if a user can't use it, they don't see it. No reliance on action-level ACLs for visibility — explicit `groups=` at every layer. #### Layer 1 — Top-level menus | Top-level menu | `groups=` | |---|---| | **Plating** (root) | OR of all 7 plating roles | | **Sales & Quoting** | `group_fp_sales_rep` | | **Shop Floor** | `group_fp_technician` | | **Operations** | `group_fp_technician` | | **Receiving & Shipping** | `group_fp_shop_manager_v2` | | **Quality** | `group_fp_manager` | | **Compliance** (hub) | `group_fp_quality_manager` | | **KPIs** | `group_fp_manager` | | **Configuration** | `group_fp_manager` | #### Layer 2 — Submenus (explicit on every child) | Submenu | New gate | |---|---| | Quality > Audits | `group_fp_quality_manager` | | Quality > Customer Specs | `group_fp_quality_manager` | | Quality > Approved Vendor List | `group_fp_quality_manager` | | Quality > NCRs / Holds / Checks / RMAs / Certs | `group_fp_manager` | | Quality > CAPAs | `group_fp_manager` (visibility); QM-only for close button (Layer 3) | | Operations > Maintenance | `group_fp_shop_manager_v2` | | Operations > Move Log | `group_fp_shop_manager_v2` | | Operations > Labor History | `group_fp_shop_manager_v2` | | Operations > Replenishment Suggestions | `group_fp_manager` | | Configuration > Team | `group_fp_owner` | | Configuration > Settings | `group_fp_manager` (explicit) | | Configuration > all 7 themed folders | `group_fp_manager` (explicit) | | Sales & Quoting > Configurator | `group_fp_sales_rep` | | Sales & Quoting > Sale Orders | `group_fp_sales_rep` (visibility); SM+ for confirm (Layer 3) | | Receiving & Shipping > all children | `group_fp_shop_manager_v2` | | Compliance > CGP | `group_fp_quality_manager` | | Compliance > General / Safety / Aerospace / Nuclear | `group_fp_quality_manager` | #### Layer 3 — Fields, buttons, smart buttons | View element | New gate | |---|---| | `sale.order` view — Confirm button | `group_fp_sales_manager` | | `sale.order` view — `x_fc_account_hold_override` | `group_fp_manager` (was broken Administrator typo) | | `sale.order` form — pricing columns on lines | `group_fp_sales_rep` (defense in depth — Technician/Shop Manager don't see pricing) | | `fp.certificate` form — Sign button (FAIR / Nadcap) | `group_fp_quality_manager` | | `fusion.plating.capa` form — Close button + edit fields | `group_fp_quality_manager` | | `fusion.plating.audit` form — all buttons | `group_fp_quality_manager` | | `fp.approved.vendor.list` form — Approve / Disqualify | `group_fp_quality_manager` | | `fusion.plating.customer.spec` form — edit fields | `group_fp_quality_manager` | | All CGP form buttons | `group_fp_quality_manager` | | Smart buttons (cross-record navigation) | Match the underlying action's visibility | ### 2.F Per-role menu visibility matrix (sanity check) | Menu | No | Tech | SR | SM | SalesMgr | Mgr | QM | Owner | |---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:| | Plating (root) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Sales & Quoting | — | — | ✓ | — | ✓ | ✓ | ✓ | ✓ | | Shop Floor | — | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | | Operations | — | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | | Receiving & Shipping | — | — | — | ✓ | — | ✓ | ✓ | ✓ | | Quality | — | — | — | — | — | ✓ | ✓ | ✓ | | Compliance | — | — | — | — | — | — | ✓ | ✓ | | KPIs | — | — | — | — | — | ✓ | ✓ | ✓ | | Configuration | — | — | — | — | — | ✓ | ✓ | ✓ | | Configuration > Team | — | — | — | — | — | — | — | ✓ | (SR = Sales Rep, SM = Shop Manager, SalesMgr = Sales Manager, Mgr = Manager) ### 2.G Manager-bypass context flags (no ownership change) All 9 existing bypass flags from the battle tests remain gated on Manager+: `fp_skip_step_gate`, `fp_skip_qc_gate`, `fp_skip_qty_reconcile`, `fp_skip_bake_gate`, `fp_skip_predecessor_check`, `fp_skip_missed_window`, `fp_skip_required_inputs_gate`, `fp_skip_signoff_gate`, `fp_skip_transition_form`. New name in the check: `user.has_group('fusion_plating.group_fp_manager')`. Shop Manager CANNOT bypass these gates — matches spec ("Technicians cannot override system"). Override authority sits at Manager. ### 2.H ir.rules (record rules) | Rule | Old | New | |---|---|---| | `fp.cgp.psa` Officer-only | CGP Officer | `group_fp_quality_manager` | | `fp.cgp.security.incident` Officer-only | CGP Officer | `group_fp_quality_manager` | | `fusion.technician.task` Field-Tech-own | Field Technician | Unchanged (orthogonal) | | **NEW:** `fp.certificate` write-gate for cert_type in ('fair','nadcap') | — | `group_fp_quality_manager` | | **NEW:** `sale.order` write-gate for state→'sale' transition | — | `group_fp_sales_manager` | No multi-company changes — existing multi-company rules untouched. --- ## Section 3 — Landing Resolver ### Resolver flow (server action `action_fp_resolve_plating_landing`) ```python def _fp_resolve_landing(self): user = self.env.user company = self.env.company # 1. Per-user override (set in preferences) if user.x_fc_plating_landing_action_id: return user.x_fc_plating_landing_action_id._render_action() # 2. Role-based default (precedence: highest role wins) role_landing = self._fp_role_default_landing(user, company) if role_landing: return role_landing._render_action() # 3. Company default (admin fallback) if company.x_fc_default_landing_action_id: return company.x_fc_default_landing_action_id._render_action() # 4. Hardcoded last-ditch return self.env.ref('fusion_plating_configurator.action_fp_sale_orders')._render_action() ``` ### Role → action mapping (Step 2) ```python def _fp_role_default_landing(self, user, company): workstation_action = self._fp_workstation_action_for_layout(company) if user.has_group('fusion_plating.group_fp_owner'): return self.env.ref('fusion_plating_shopfloor.action_fp_manager_dashboard', raise_if_not_found=False) if user.has_group('fusion_plating.group_fp_quality_manager'): return self.env.ref('fusion_plating_quality.action_fp_quality_dashboard', raise_if_not_found=False) if user.has_group('fusion_plating.group_fp_manager'): return self.env.ref('fusion_plating_shopfloor.action_fp_manager_dashboard', raise_if_not_found=False) if user.has_group('fusion_plating.group_fp_sales_manager'): return self.env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False) if user.has_group('fusion_plating.group_fp_shop_manager_v2'): return workstation_action if user.has_group('fusion_plating.group_fp_sales_rep'): return self.env.ref('fusion_plating_configurator.action_fp_quotations', raise_if_not_found=False) if user.has_group('fusion_plating.group_fp_technician'): return workstation_action return False ``` ### Workstation = layout-flag aware (single source of truth) ```python def _fp_workstation_action_for_layout(self, company): """Single source of truth: which Shop Floor surface is active on this DB?""" param = self.env['ir.config_parameter'].sudo().get_param( 'fusion_plating_shopfloor.layout', 'v2') if param == 'v2': return self.env.ref('fusion_plating_shopfloor.action_fp_plant_kanban', raise_if_not_found=False) return self.env.ref('fusion_plating_shopfloor.action_fp_shopfloor_landing', raise_if_not_found=False) ``` Flipping `ir.config_parameter['fusion_plating_shopfloor.layout']` instantly changes the default landing for every Technician and Shop Manager on the next page load. ### Pickable actions (`x_fc_pickable_landing=True`) Adding 4 net-new (3 are already pickable). Total picklist = **7 entries**. | Action XML ID | Display in dropdown | Default for | |---|---|---| | `fusion_plating_shopfloor.action_fp_manager_dashboard` | Manager Desk | Owner / QM / Manager | | `fusion_plating_shopfloor.action_fp_plant_kanban` | Plant View Kanban | Shop Mgr / Tech (v2 layout) | | `fusion_plating_shopfloor.action_fp_shopfloor_landing` | Workstation (Legacy) | Shop Mgr / Tech (legacy layout) | | `fusion_plating_quality.action_fp_quality_dashboard` | Quality Dashboard | QM | | `fusion_plating_configurator.action_fp_quotations` | Quotations | Sales Rep (already pickable) | | `fusion_plating_configurator.action_fp_sale_orders` | Sale Orders | Sales Manager (already pickable) | | `fusion_plating.action_fp_process_recipe` | Process Recipes | (niche option, already pickable) | ### Per-user override picklist domain Today: `[('x_fc_pickable_landing', '=', True)]`. **Tightened**: also filter by user's accessible actions, so a Technician can't pick "Manager Desk" as their landing if they can't see it. Domain becomes computed: `[('x_fc_pickable_landing', '=', True), ('id', 'in', user_accessible_action_ids)]`. The `user_accessible_action_ids` list comes from a compute that runs `env['ir.ui.menu']._visible_menu_ids()` mapped to action IDs. ### Edge cases 1. **Multi-role user (Manager promoted to QM):** precedence chain picks higher role. Deterministic. 2. **User in "No" state opening resolver directly:** falls through to company default → hardcoded Sale Orders → standard Odoo home. 3. **xmlref deleted or module uninstalled:** `raise_if_not_found=False` returns False, resolver falls through. 4. **First-login user with no preference / company default / roles:** lands on Sale Orders. 5. **Demo / fresh DB:** Sale Orders fallback works without any FP modules beyond core. --- ## Section 4 — Owner-only Team Page ### Implementation — standard Odoo views, not custom OWL Single new field on `res.users` + standard kanban/form views. Zero custom JS. ```python # fusion_plating/models/res_users.py class ResUsers(models.Model): _inherit = 'res.users' x_fc_plating_role = fields.Selection([ ('no', 'No'), ('technician', 'Technician'), ('sales_rep', 'Sales Representative'), ('shop_manager', 'Shop Manager'), ('sales_manager', 'Sales Manager'), ('manager', 'Manager'), ('quality_manager', 'Quality Manager'), ('owner', 'Owner'), ], compute='_compute_plating_role', inverse='_inverse_plating_role', store=True, string='Fusion Plating Role') ``` - **Compute** reads `groups_id`, returns the highest-precedence plating role - **Inverse** clears all plating groups + writes only the chosen one + posts a `Markup()` chatter audit - **Stored** so kanban `default_group_by="x_fc_plating_role"` and drag-and-drop work ### Menu placement ``` Plating └── Configuration (Manager+) └── ⚡ Settings (existing) └── 👥 Team (NEW — Owner-only) └── (opens action_fp_team) ``` XML ID: `fusion_plating.menu_fp_team`, `groups="fusion_plating.group_fp_owner"`. ### 4 tabs | Tab | View type | Domain | What it does | |---|---|---|---| | **Active Team** | Kanban grouped by `x_fc_plating_role` | `[('share','=',False), ('active','=',True)]` | 8 columns; drag-and-drop role changes; click card → user form | | **Designated Officials** | Form on `res.company` | — | CGP DO + Nadcap Authority Many2one fields | | **Role Reference** | QWeb static template | — | 8 cards with plain-English "can / cannot" per role | | **Audit Log** | List on `mail.message` | `[('model','=','res.users'), ('subtype_id','=',mt_note), ('body','ilike','plating role')]` | 90-day role-change history | All 4 tabs are separate `ir.actions.act_window` records reached via a tabbed notebook. Each has its own xmlid for direct linking. ### Active Team kanban — card layout ``` ┌─────────────────────────────────┐ │ [avatar] Jane Doe │ │ jdoe@enplating.com │ │ Last seen: 2h ago │ │ ───────────────────────────── │ │ ⭐ CGP DO │ (only if user.id == company.x_fc_cgp_designated_official_id) │ 🏆 Nadcap Authority │ (only if user.id == company.x_fc_nadcap_authority_user_id) │ ───────────────────────────── │ │ Created: 2025-03-14 │ │ Last role change: 2026-05-01 │ └─────────────────────────────────┘ ``` Columns (left-to-right by sequence): `No` · `Technician` · `Sales Rep` · `Shop Manager` · `Sales Manager` · `Manager` · `QM` · `Owner` Folded by default: `No`, `Sales Rep` (less common in plating shops). Owner can unfold. Search: name / email / department. Filters: Active (default), With Archived, Has Login Last 30 Days, Has Never Logged In. ### Designated Officials tab — single form Form on `res.company` with two fields: - `x_fc_cgp_designated_official_id` (Many2one res.users, domain `[QM, Owner]`) - `x_fc_nadcap_authority_user_id` (Many2one res.users, domain `[QM, Owner]`) Save posts to `res.company` chatter for auditability: > "CGP Designated Official changed: Jane Doe → John Smith by owner@enplating.com on 2026-05-23." ### Role Reference tab — auto-generated Single source of truth in `fusion_plating/models/res_users.py`: ```python PLATING_ROLE_DESCRIPTIONS = { 'technician': { 'icon': 'fa-wrench', 'tagline': 'Runs the shop floor.', 'can': [ 'See and operate the Workstation tablet', 'Start/finish/pause job steps', 'Capture quality checks and step inputs', 'Issue routine Certificates of Conformance', 'Log scrap and bake events', ], 'cannot': [ 'See pricing, quotations, or sales orders', 'Edit recipes or process configurations', 'Override system gates (predecessor lock, signoff, bake window, etc.)', 'Approve CAPAs or sign FAIR/Nadcap certs', ], }, # ... 7 more entries } ``` QWeb tab renders cards from this dict. Same dict used by the spec doc generator and by future onboarding wizards. ### What the page does NOT do - ❌ No editing individual permissions (Q4 Interpretation B — killed) - ❌ No custom role definitions - ❌ No per-user exception flags - ❌ No role hand-off workflows (transfer Bob's open jobs to Alice) — Phase 3 - ❌ No bulk import of roles — defer until 100+ employee shops ### Phase 2 hooks (designed-in, not built) The 4-tab structure leaves room for: - **Audit Dashboard** tab — read-only ACL matrix for CGP/Nadcap audit prep - **Departure Handoff** tab — wizard for terminating users --- ## Section 5 — Migration Workflow ### Models ```python # fusion_plating/models/fp_migration.py class FpMigrationPreview(models.Model): _name = 'fp.migration.preview' _description = 'Fusion Plating Role Migration Preview' _order = 'create_date desc' name = fields.Char(default=lambda s: _('Migration %s') % fields.Datetime.now()) state = fields.Selection([ ('pending', 'Pending Review'), ('approved', 'Approved & Applied'), ('cancelled', 'Cancelled'), ('rolled_back','Rolled Back'), ], default='pending', tracking=True) line_ids = fields.One2many('fp.migration.preview.line', 'preview_id') user_count = fields.Integer(compute='_compute_counts', store=True) warning_count = fields.Integer(compute='_compute_counts', store=True) approved_by_id = fields.Many2one('res.users', readonly=True) approved_at = fields.Datetime(readonly=True) rollback_deadline = fields.Datetime(compute='_compute_rollback_deadline') class FpMigrationPreviewLine(models.Model): _name = 'fp.migration.preview.line' _description = 'Migration Preview Line' preview_id = fields.Many2one('fp.migration.preview', required=True, ondelete='cascade') user_id = fields.Many2one('res.users', required=True) current_groups = fields.Char(compute='_compute_current_groups') proposed_role = fields.Selection(_FP_ROLE_SELECTION) capability_delta = fields.Char() warning = fields.Boolean() notes = fields.Text() applied_groups_snapshot = fields.Text() # JSON of pre-migration groups_id for rollback ``` ### Trigger — runs ONCE on `-u`, enters pending ```python # fusion_plating/__manifest__.py 'post_init_hook': '_fp_post_init_role_migration', # fusion_plating/__init__.py def _fp_post_init_role_migration(env): """Idempotent: only creates a preview if one isn't already pending.""" pending = env['fp.migration.preview'].search([('state','=','pending')], limit=1) if pending: return completed = env['fp.migration.preview'].search([('state','=','approved')], limit=1) if completed: users = env['res.users'].search(_fp_unmigrated_user_domain(env)) if not users: return preview = env['fp.migration.preview'].create({}) preview._fp_build_lines() preview._fp_notify_owners() ``` Properties: 1. **Idempotent** — `-u` re-runs don't duplicate previews 2. **Non-destructive** — only creates preview, never touches users 3. **Owner-gated** — actual migration only on Owner click ### Mapping table (in code) ```python _FP_ROLE_MAPPING = [ # (predicate_fn, new_role, capability_delta_or_None) (lambda u: u.id in (1, 2), 'owner', None), (lambda u: u.has_group('fusion_plating.group_fusion_plating_admin'), 'owner', None), (lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'), 'owner', 'Was CGP DO; field set on res.company'), (lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_officer'), 'quality_manager', None), (lambda u: u.has_group('fusion_plating.group_fusion_plating_manager'), 'manager', None), (lambda u: u.has_group('fusion_plating_configurator.group_fp_shop_manager'), 'manager', None), (lambda u: u.has_group('fusion_plating_invoicing.group_fp_accounting'), 'manager', None), (lambda u: u.has_group('fusion_plating_configurator.group_fp_estimator') and not u.has_group('fusion_plating.group_fusion_plating_manager'), 'sales_rep', 'Loses order-confirm authority'), # ⚠️ (lambda u: u.has_group('fusion_plating.group_fusion_plating_supervisor'), 'shop_manager', None), (lambda u: u.has_group('fusion_plating_receiving.group_fp_receiving'), 'shop_manager', None), (lambda u: u.has_group('fusion_plating.group_fusion_plating_operator'), 'technician', None), (lambda u: True, 'no', None), ] ``` First matching predicate wins (highest-precedence first). ### Preview screen UX ``` ┌──────────────────────────────────────────────────────────────────────┐ │ Fusion Plating Role Migration — Preview │ │ Created: 2026-05-23 14:22 by system upgrade │ │ State: Pending Review │ │ │ │ Summary: │ │ • 28 users will be migrated │ │ • 2 will lose capabilities (highlighted ⚠️) │ │ • 1 will become CGP Designated Official (Jane Doe) │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐│ │ │ User │ Current Groups │ → New Role │ Notes ││ │ ├──────────────────────────────────────────────────────────────────┤│ │ │ admin │ Administrator, … │ Owner │ ││ │ │ Jane Doe │ Manager, CGP DO │ Owner │ DO set ││ │ │ John Smith │ Estimator │ Sales Rep │ ⚠️ loses││ │ │ │ │ │ confirm ││ │ │ Carlos Lopez │ Operator │ Technician │ ││ │ │ Bob Chen │ Supervisor, Receiving │ Shop Mgr │ ││ │ │ … 23 more … ││ │ └──────────────────────────────────────────────────────────────────┘│ │ │ │ [ Approve & Run ] [ Cancel ] [ Export to CSV ] │ └──────────────────────────────────────────────────────────────────────┘ ``` Per-line `Edit role to:` dropdown lets Owner override any auto-mapping inline before approving. ### Approve & Run ```python def action_approve_and_run(self): self.ensure_one() if not self.env.user.has_group('fusion_plating.group_fp_owner'): raise UserError(_('Only Owners can approve role migrations.')) for line in self.line_ids: user = line.user_id line.applied_groups_snapshot = json.dumps(user.groups_id.ids) old_group_ids = self.env['res.groups'].search([ ('id', 'in', _FP_OLD_GROUP_IDS(self.env))]).ids user.write({'groups_id': [(3, gid) for gid in old_group_ids]}) target_group = self.env.ref(_NEW_ROLE_XMLID[line.proposed_role]) if target_group: user.write({'groups_id': [(4, target_group.id)]}) user.message_post(body=Markup(_( 'Plating role assigned by migration: %s' )) % line.proposed_role, message_type='notification') if line.notes and 'CGP DO' in line.notes: user.company_id.x_fc_cgp_designated_official_id = user.id self.write({ 'state': 'approved', 'approved_by_id': self.env.user.id, 'approved_at': fields.Datetime.now(), }) ``` ### Rollback — 30-day undo ```python def action_rollback(self): self.ensure_one() if self.state != 'approved': raise UserError(_('Only approved migrations can be rolled back.')) if fields.Datetime.now() > self.rollback_deadline: raise UserError(_('Rollback window has expired (30 days after approval).')) for line in self.line_ids: if line.applied_groups_snapshot: old_ids = json.loads(line.applied_groups_snapshot) line.user_id.write({'groups_id': [(6, 0, old_ids)]}) self.state = 'rolled_back' def _cron_purge_expired_migrations(self): deadline = fields.Datetime.now() - timedelta(days=30) expired = self.search([ ('state', '=', 'approved'), ('approved_at', '<', deadline)]) for preview in expired: preview.line_ids.write({'applied_groups_snapshot': False}) self.env['res.groups'].browse(_FP_OLD_GROUP_IDS(self.env)).unlink() ``` ### Owner activity notification ```python def _fp_notify_owners(self): owners = self.env['res.users'].search([ ('groups_id', 'in', self.env.ref('fusion_plating.group_fp_owner').ids)]) for owner in owners: self.env['mail.activity'].create({ 'res_model_id': self.env.ref('fusion_plating.model_fp_migration_preview').id, 'res_id': self.id, 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, 'summary': _('Review Fusion Plating role migration'), 'note': _('A role migration is pending review. %d users affected, %d with capability changes.') % ( self.user_count, self.warning_count), 'user_id': owner.id, 'date_deadline': fields.Date.today(), }) ``` ### Failure modes handled 1. Approver isn't Owner → UserError, no changes 2. Approver clicks twice → second click no-ops (state check) 3. User deleted between dry-run and approval → skipped, logged 4. New group xmlid missing → migration aborts at that line, logs warning 5. Rollback past 30 days → UserError, point Owner at Settings → Users 6. Multiple Owners approve simultaneously → record lock; second sees "Already approved" --- ## Out of Scope (Phase 2+) | Item | Why deferred | Trigger to build | |---|---|---| | Read-only Team page for Manager+ | Owner-only is sufficient for now; can add later as a filter+view variant | If Manager complains about not seeing org chart | | Audit Dashboard (ACL matrix) | Useful for compliance audits but not blocking | First CGP/Nadcap audit preparation | | Departure Handoff wizard | Useful when shop grows; one-off manual reassignment works for now | 50+ employee shop | | Bulk CSV import of roles | Overkill for current shop size | 100+ employee shop | | Per-permission override page (Interpretation B from Q4) | Killed — defeats the 8-role spec | Never — fundamentally against design | | Sales Rep "own quotes only" record rule | Sales Reps see all quotes today; adding per-rep ownership is a separate feature | If client requests it | | Role expiration / time-bound permissions | Out of scope for the consolidation | If contract-employee workflows emerge | | Per-customer permission overrides | Out of scope | If multi-company tenancy is added | --- ## Acceptance Criteria Phase 1 is complete when ALL of these are true on `entech`: 1. **Group inventory**: exactly 8 plating roles defined under the Fusion Plating privilege block, in the correct sequence order (10, 20, 30, 40, 50, 60, 70). No `_administrator` typo'd references remain in Python. 2. **Old groups archived**: `group_fusion_plating_operator`, `_supervisor`, `_manager`, `_admin`, `group_fp_estimator`, `_receiving`, `_accounting`, `_shop_manager`, `_cgp_officer`, `_cgp_designated_official`, `_legacy_menus` all set `active=False`. No user holds them post-migration. 3. **ACL coverage**: every `ir.model.access.csv` row that previously referenced an old plating group now references its mapped new group. `grep` for old group xmlids in CSV files returns zero results. 4. **Menu visibility**: opening Plating as each of the 7 roles shows the expected menu tree per Section 2.F. No "ghost" menus (visible but click → error). 5. **Landing resolver**: each role lands on the correct default action: - Owner / Manager / QM → Manager Desk - Sales Manager → Sale Orders - Sales Rep → Quotations - Shop Manager / Technician → Plant View Kanban (v2 layout) or Workstation (legacy) - "No" user → company default → Sale Orders fallback 6. **Picklist contains 7 entries**, filtered per user's accessible actions. 7. **Team page reachable** at Plating → Configuration → Team. Drag-and-drop role change posts to user chatter. Visible to Owner only. 8. **Designated Officials field** on `res.company` set; CGP records gated to QM via ir.rule. 9. **Sales Manager + gate works**: Sales Rep saves SO in draft, sees no Confirm button, can't post via API. Sales Manager can confirm. 10. **Quality split works**: Manager can create/close NCRs but CAPAs are read-only for them. QM can close CAPAs, sign FAIR/Nadcap certs. 11. **Bypass flags**: Shop Manager cannot bypass any of the 9 gates; Manager can. Bypass posts chatter audit. 12. **Migration round-trip**: on a test DB, run `-u`, see pending preview, approve, see all users migrated, run rollback within 30 days, see all users restored to original groups. 13. **CLAUDE.md updated** with the new role names + which group implies which (canonical hierarchy doc). --- ## Files Affected (high-level count) | Module | Files changed | Type | |---|---|---| | `fusion_plating` | ~15 | security XML, models (res.users, fp_migration), views (team page, settings), data (role-description dict), post-init hook, migration file | | `fusion_plating_configurator` | ~8 | ACL CSV updates, view button gates, group XMLs to archive | | `fusion_plating_invoicing` | ~3 | ACL CSV updates, group archive | | `fusion_plating_receiving` | ~3 | ACL CSV updates, group archive | | `fusion_plating_cgp` | ~5 | ACL CSV updates, ir.rule updates, group archives, ResCompany field for DO | | `fusion_plating_quality` | ~6 | ACL CSV updates for QM/Manager split, ir.rules for FAIR/Nadcap, view button gates | | `fusion_plating_aerospace` / `_nuclear` / `_safety` | ~3 each | ACL CSV updates (mechanical rename) | | `fusion_plating_shopfloor` | ~3 | Landing resolver updates, picklist tagging on action_fp_manager_dashboard + action_fp_plant_kanban | | `fusion_plating_jobs` | ~4 | Legacy menus group archive, ACL CSV updates | | `fusion_plating_certificates` | ~2 | FAIR/Nadcap signing button gates | **Estimated total: ~55 files**. Most are mechanical CSV updates (`grep`-and-replace pattern from Section 2.A). --- ## Migration Notes for entech ### Pre-deploy checklist 1. **Backup `admin` DB on entech** — full pg_dump before `-u` (rollback safety beyond the 30-day archive) 2. **Read `_FP_OLD_GROUP_IDS(env)` count** — log expected pre-migration group membership counts 3. **Confirm no other migration running** — `SELECT count(*) FROM fp_migration_preview WHERE state='pending';` returns 0 ### Deploy command ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \ su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \ -u fusion_plating,fusion_plating_configurator,fusion_plating_invoicing,\ fusion_plating_receiving,fusion_plating_cgp,fusion_plating_quality,\ fusion_plating_aerospace,fusion_plating_nuclear,fusion_plating_safety,\ fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating_certificates \ --stop-after-init\" && systemctl start odoo'" ``` Bump every module version to `+0.1.0` to ensure the migration scripts fire. ### Post-deploy verification ```sql -- Pending migration? SELECT state, user_count, warning_count, create_date FROM fp_migration_preview ORDER BY id DESC LIMIT 1; -- Verify Owner activity scheduled SELECT count(*) FROM mail_activity WHERE res_model = 'fp.migration.preview' AND date_deadline >= CURRENT_DATE; ``` Login as Owner → see activity in the home dashboard → click → review preview → approve. ### Post-approval verification ```sql -- All users mapped to new roles? SELECT u.login, ARRAY_AGG(g.name) AS groups FROM res_users u JOIN res_groups_users_rel r ON r.uid = u.id JOIN res_groups g ON g.id = r.gid WHERE g.privilege_id IS NOT NULL GROUP BY u.id, u.login ORDER BY u.login; -- No one still holds old groups? SELECT count(*) FROM res_groups_users_rel r WHERE r.gid IN (SELECT id FROM res_groups WHERE name IN ( 'Operator','Supervisor','Manager','Administrator','Estimator', 'Receiving','Accounting','Shop Manager','CGP Officer','CGP Designated Official')); -- Expected: 0 (or low number of stale rows that the migration intentionally left) -- CGP DO set? SELECT name, x_fc_cgp_designated_official_id FROM res_company; ``` ### Rollback plan If migration goes wrong within 30 days: 1. Login as Owner → Plating → Configuration → migrations list (or direct URL `/odoo/action-fp.migration.preview`) 2. Click most recent approved migration 3. Click "Rollback" button → all users restored to pre-migration groups 4. Old plating groups remain active (archived after 30 days; rollback un-archives them) If migration goes wrong AFTER 30 days (cron has purged): 1. Restore from pg_dump backup taken pre-deploy 2. File a follow-up issue to extend the rollback window if this happens repeatedly --- ## Open Risks 1. **Inverse handler on `x_fc_plating_role`** must be robust against partial state. If a user holds NO plating group and gets assigned to `manager`, the inverse adds Manager group; the compute then reads `manager`. If a user holds BOTH `manager` and `technician` somehow (e.g., bug), compute should pick the higher one and the inverse should clean up. Unit tests required for: assign role with no prior role, assign role overwriting prior role, assign 'no' role (should clear all plating groups). 2. **Group rename window**: between archive of old groups and unlink (30 days), the old XMLIDs are still resolvable via `env.ref`. Code that hardcodes old xmlids will keep working accidentally — caught only when groups are finally deleted. **Mitigation:** add a deprecation log to the old groups' `_check_company_auto` or a model-load-time grep to flag any old-xmlid usage that survived the migration. 3. **Landing-page action visibility**: if a new role's hardcoded default action (e.g. `action_fp_manager_dashboard` for Manager) is itself gated by a different group, the resolver returns it but the user gets a permission error on render. **Mitigation:** the picklist domain filter (Section 3) already checks user accessibility. Apply the same check inside `_fp_role_default_landing` — if the role's default action isn't accessible, fall through to the next step instead of returning it. 4. **Mail-template references**: a few mail templates reference Manager / Estimator by xmlid (e.g., notification routing). These must be updated in the same deploy or chatter routing breaks. Grep all `mail_template_*.xml` for old group xmlids during implementation. 5. **CLAUDE.md drift**: after deploy, the role hierarchy in CLAUDE.md must be updated. If skipped, future sessions will reason from stale assumptions. Mandatory part of the implementation plan. --- ## Status & Next Steps - ✅ Brainstorm complete (5 questions answered + Q4b menu-hiding policy) - ✅ Design doc written - ⏳ Self-review (next) - ⏳ User review of this spec - ⏳ Invoke `writing-plans` skill to create the implementation plan - ⏳ Execute implementation per the plan - ⏳ Deploy + verify on entech - ⏳ Update CLAUDE.md with new role hierarchy --- *End of design document.*