70ca2fa74a
Client sees only Total. Salary is calculated from salary_base = client_price
× (1 − margin/100). Margin never appears in customer-facing surfaces (PDF,
tracking JSON, portal).
Terminology: "marjă internă" — internal profit margin. NOT VAT/TVA. Never
called NDS/TVA anywhere in the code to avoid confusion with real Moldova
tax reporting (Doc 19/1C integration).
== Configuration ==
Fallback chain (in MarginResolver::resolve):
1. WorkOrder.override_margin_pct — per-Fișă for special contracts/VIP
2. User.internal_margin_pct — per-mechanic (main setting)
3. Company.settings.default_internal_margin_pct — tenant default
4. 0.0 — no margin
Example (mechanic Andrei with 20% margin):
User enters price_per_hour = 250 for 1h diagnosis
→ total = 250 (what client sees, goes into PDF)
→ salary_base = 250 × 0.80 = 200 (what mechanic gets salaried on)
→ applied_margin_pct = 20 (frozen)
If admin later changes Andrei's margin to 40%, the row's salary_base does
NOT change — history is immutable. Only new rows use the new margin.
Solves the retroactive-recompute problem for closed payroll periods.
== salary_base freeze semantics ==
wo_works gains 2 columns:
salary_base decimal(10,2) nullable
applied_margin_pct decimal(5,2) nullable
Frozen at save time by WorkOrderWork::saving hook. Recomputes only if
total OR master_id changes (i.e., someone actively edits the price or
reassigns the mechanic — in those cases we WANT the salary_base to
follow). Legacy rows (before this feature) have null salary_base;
PayrollCalculator falls back to total for them.
== PayrollCalculator uses salary_base ==
Previously: sum(wo_works.total) × works_pct → gave the mechanic a cut
of the price INCLUDING margin.
Now: sum(salary_base ?? total) × works_pct → the cut is from the
labor rate excluding margin.
Impact: for a 250 lei diagnosis at 20% margin with 50% payroll cut, the
mechanic gets 200 × 50% = 100 lei (was 250 × 50% = 125 lei). The shop
keeps the 50 lei margin regardless of the payroll %.
== RBAC gate ==
New permission FINANCE_VIEW_INTERNAL_MARGIN. Assigned to owner + admin +
manager + accountant in seed matrix. Not granted to mechanic,
receptionist, or viewer — those roles never see the "Bază salariu"
disclosure line or the margin % fields.
== UI surfaces ==
UserResource — new "Salariu & marjă" section (visible only with
FINANCE_VIEW_INTERNAL_MARGIN):
- Tarif orar (MDL)
- Marjă internă (%) with helper text explaining the -X% semantics
- Placeholder tells manager the exact formula
WorkOrderResource form — new override_margin_pct field in the "Plată &
total" section, gated by same permission. Helper text: "Doar pentru
cazuri speciale. Lasă gol pentru a folosi marja mecanicului."
WorksRelationManager (WO edit page) — Total column now shows a gray
subtitle line "Bază salariu: 200.00 MDL · marjă 20%" ONLY for users
with FINANCE_VIEW_INTERNAL_MARGIN. Everyone else sees just Total.
== Contract tests: NO leak ==
InternalMarginTest verifies with black-box grepping that:
- WorkOrderPdfService::generate output contains NONE of
{salary_base, internal_margin, applied_margin_pct, marja intern,
Bază salariu}
- /api/track/{token} JSON payload contains NONE of the same terms
- wo_parts table has no salary_base column (margin ONLY on labor)
- Changing mechanic.internal_margin_pct after work is saved does NOT
rewrite the historical salary_base (frozen)
- WO override wins over mechanic margin (contract-priced clients)
- Fallback chain: WO → mechanic → company default → 0
== Suite ==
298 passed (828 assertions). Was 285. +13 InternalMarginTest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
9.7 KiB
PHP
226 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Auth;
|
|
|
|
/**
|
|
* Central permission catalog — 50 fine-grained permissions across 9 modules.
|
|
* Mirrors the catalog in /tmp/service/new/01-TZ-rbac-utilizator-tehnician.md §2.3.
|
|
*
|
|
* Use these constants instead of magic strings:
|
|
* $user->can(Permissions::WORK_ORDERS_CREATE)
|
|
*/
|
|
class Permissions
|
|
{
|
|
// Clients
|
|
public const CLIENTS_VIEW_ALL = 'clients.view_all';
|
|
public const CLIENTS_VIEW_OWN = 'clients.view_own';
|
|
public const CLIENTS_CREATE = 'clients.create';
|
|
public const CLIENTS_EDIT = 'clients.edit';
|
|
public const CLIENTS_DELETE = 'clients.delete';
|
|
public const CLIENTS_EXPORT = 'clients.export';
|
|
|
|
// Vehicles
|
|
public const VEHICLES_VIEW_ALL = 'vehicles.view_all';
|
|
public const VEHICLES_CREATE = 'vehicles.create';
|
|
public const VEHICLES_EDIT = 'vehicles.edit';
|
|
public const VEHICLES_DELETE = 'vehicles.delete';
|
|
|
|
// Work orders (Fișe)
|
|
public const WORK_ORDERS_VIEW_ALL = 'work_orders.view_all';
|
|
public const WORK_ORDERS_VIEW_OWN_ASSIGNED = 'work_orders.view_own_assigned';
|
|
public const WORK_ORDERS_CREATE = 'work_orders.create';
|
|
public const WORK_ORDERS_EDIT = 'work_orders.edit';
|
|
public const WORK_ORDERS_DELETE = 'work_orders.delete';
|
|
public const WORK_ORDERS_CHANGE_STATUS = 'work_orders.change_status';
|
|
public const WORK_ORDERS_APPROVE_DISCOUNT_5 = 'work_orders.approve_discount_5';
|
|
public const WORK_ORDERS_APPROVE_DISCOUNT_20 = 'work_orders.approve_discount_20';
|
|
public const WORK_ORDERS_APPROVE_DISCOUNT_ANY = 'work_orders.approve_discount_any';
|
|
public const WORK_ORDERS_PRINT = 'work_orders.print';
|
|
|
|
// Finance
|
|
public const FINANCE_VIEW_OVERVIEW = 'finance.view_overview';
|
|
public const FINANCE_VIEW_PL = 'finance.view_pl';
|
|
public const FINANCE_VIEW_INTERNAL_MARGIN = 'finance.view_internal_margin';
|
|
public const FINANCE_CREATE_PAYMENT = 'finance.create_payment';
|
|
public const FINANCE_DELETE_PAYMENT = 'finance.delete_payment';
|
|
public const FINANCE_CREATE_EXPENSE = 'finance.create_expense';
|
|
public const FINANCE_EXPORT = 'finance.export';
|
|
|
|
// Salaries
|
|
public const SALARIES_VIEW_OWN = 'salaries.view_own';
|
|
public const SALARIES_VIEW_ALL = 'salaries.view_all';
|
|
public const SALARIES_CALCULATE = 'salaries.calculate';
|
|
public const SALARIES_MARK_PAID = 'salaries.mark_paid';
|
|
|
|
// Inventory
|
|
public const INVENTORY_VIEW = 'inventory.view';
|
|
public const INVENTORY_CREATE_PART = 'inventory.create_part';
|
|
public const INVENTORY_EDIT_PART = 'inventory.edit_part';
|
|
public const INVENTORY_DELETE_PART = 'inventory.delete_part';
|
|
public const INVENTORY_ADJUST_STOCK = 'inventory.adjust_stock';
|
|
public const INVENTORY_CREATE_PURCHASE = 'inventory.create_purchase';
|
|
public const INVENTORY_RECEIVE_GOODS = 'inventory.receive_goods';
|
|
|
|
// Suppliers
|
|
public const SUPPLIERS_VIEW = 'suppliers.view';
|
|
public const SUPPLIERS_EDIT = 'suppliers.edit';
|
|
public const SUPPLIERS_DELETE = 'suppliers.delete';
|
|
|
|
// Admin
|
|
public const ADMIN_USERS_VIEW = 'admin.users.view';
|
|
public const ADMIN_USERS_MANAGE = 'admin.users.manage';
|
|
public const ADMIN_ROLES_MANAGE = 'admin.roles.manage';
|
|
public const ADMIN_SETTINGS_EDIT = 'admin.settings.edit';
|
|
public const ADMIN_INTEGRATIONS = 'admin.settings.integrations';
|
|
public const ADMIN_API_TOKENS_MANAGE = 'admin.api_tokens.manage';
|
|
public const ADMIN_AUDIT_LOG_VIEW = 'admin.audit_log.view';
|
|
public const ADMIN_BACKUP_DOWNLOAD = 'admin.backup.download';
|
|
|
|
// AI & Analytics
|
|
public const AI_ASSISTANT_USE = 'ai_assistant.use';
|
|
public const AI_ASSISTANT_CONFIGURE_KEYS = 'ai_assistant.configure_keys';
|
|
public const ANALYTICS_VIEW = 'analytics.view';
|
|
|
|
/** Full list — used by seeder. */
|
|
public static function all(): array
|
|
{
|
|
return [
|
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_VIEW_OWN, self::CLIENTS_CREATE, self::CLIENTS_EDIT,
|
|
self::CLIENTS_DELETE, self::CLIENTS_EXPORT,
|
|
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT, self::VEHICLES_DELETE,
|
|
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CREATE,
|
|
self::WORK_ORDERS_EDIT, self::WORK_ORDERS_DELETE, self::WORK_ORDERS_CHANGE_STATUS,
|
|
self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_APPROVE_DISCOUNT_20,
|
|
self::WORK_ORDERS_APPROVE_DISCOUNT_ANY, self::WORK_ORDERS_PRINT,
|
|
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_PL, self::FINANCE_VIEW_INTERNAL_MARGIN, self::FINANCE_CREATE_PAYMENT,
|
|
self::FINANCE_DELETE_PAYMENT, self::FINANCE_CREATE_EXPENSE, self::FINANCE_EXPORT,
|
|
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_ALL, self::SALARIES_CALCULATE, self::SALARIES_MARK_PAID,
|
|
self::INVENTORY_VIEW, self::INVENTORY_CREATE_PART, self::INVENTORY_EDIT_PART, self::INVENTORY_DELETE_PART,
|
|
self::INVENTORY_ADJUST_STOCK, self::INVENTORY_CREATE_PURCHASE, self::INVENTORY_RECEIVE_GOODS,
|
|
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT, self::SUPPLIERS_DELETE,
|
|
self::ADMIN_USERS_VIEW, self::ADMIN_USERS_MANAGE, self::ADMIN_ROLES_MANAGE,
|
|
self::ADMIN_SETTINGS_EDIT, self::ADMIN_INTEGRATIONS, self::ADMIN_API_TOKENS_MANAGE,
|
|
self::ADMIN_AUDIT_LOG_VIEW, self::ADMIN_BACKUP_DOWNLOAD,
|
|
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_CONFIGURE_KEYS, self::ANALYTICS_VIEW,
|
|
];
|
|
}
|
|
|
|
/** Groups for UI rendering: module → list of permissions. */
|
|
public static function grouped(): array
|
|
{
|
|
$groups = [];
|
|
foreach (self::all() as $slug) {
|
|
[$module] = explode('.', $slug, 2);
|
|
$groups[$module][] = $slug;
|
|
}
|
|
return $groups;
|
|
}
|
|
|
|
public static function labels(): array
|
|
{
|
|
return [
|
|
'clients' => 'Clienți',
|
|
'vehicles' => 'Mașini',
|
|
'work_orders' => 'Fișe lucru',
|
|
'finance' => 'Finanțe',
|
|
'salaries' => 'Salarii',
|
|
'inventory' => 'Stoc',
|
|
'suppliers' => 'Furnizori',
|
|
'admin' => 'Administrare',
|
|
'ai_assistant' => 'AI Assistant',
|
|
'analytics' => 'Analitică',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Role-permission matrix per TZ §2.4.
|
|
* Returns: ['owner' => [perm1, perm2, ...], ...]
|
|
*/
|
|
public static function roleMatrix(): array
|
|
{
|
|
$all = self::all();
|
|
$matrix = [];
|
|
|
|
// owner = everything
|
|
$matrix['owner'] = $all;
|
|
|
|
// admin = everything except billing-only stuff (we don't have that yet, so = all)
|
|
$matrix['admin'] = $all;
|
|
|
|
// manager: operations + reporting, no destructive
|
|
$matrix['manager'] = [
|
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_CREATE, self::CLIENTS_EDIT, self::CLIENTS_EXPORT,
|
|
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT,
|
|
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CREATE,
|
|
self::WORK_ORDERS_EDIT, self::WORK_ORDERS_CHANGE_STATUS,
|
|
self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_APPROVE_DISCOUNT_20,
|
|
self::WORK_ORDERS_PRINT,
|
|
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_INTERNAL_MARGIN, self::FINANCE_CREATE_PAYMENT, self::FINANCE_CREATE_EXPENSE,
|
|
self::SALARIES_VIEW_OWN,
|
|
self::INVENTORY_VIEW, self::INVENTORY_CREATE_PART, self::INVENTORY_EDIT_PART,
|
|
self::INVENTORY_ADJUST_STOCK, self::INVENTORY_CREATE_PURCHASE, self::INVENTORY_RECEIVE_GOODS,
|
|
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT,
|
|
self::AI_ASSISTANT_USE,
|
|
self::ANALYTICS_VIEW,
|
|
];
|
|
|
|
// accountant: finance + reporting only
|
|
$matrix['accountant'] = [
|
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_EXPORT,
|
|
self::VEHICLES_VIEW_ALL,
|
|
self::WORK_ORDERS_VIEW_ALL,
|
|
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_PL, self::FINANCE_CREATE_PAYMENT,
|
|
self::FINANCE_DELETE_PAYMENT, self::FINANCE_CREATE_EXPENSE, self::FINANCE_EXPORT,
|
|
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_ALL, self::SALARIES_CALCULATE, self::SALARIES_MARK_PAID,
|
|
self::INVENTORY_VIEW,
|
|
self::SUPPLIERS_VIEW,
|
|
self::ANALYTICS_VIEW,
|
|
self::AI_ASSISTANT_USE,
|
|
];
|
|
|
|
// receptionist: front-desk operations
|
|
$matrix['receptionist'] = [
|
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_CREATE, self::CLIENTS_EDIT,
|
|
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT,
|
|
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_CREATE, self::WORK_ORDERS_EDIT,
|
|
self::WORK_ORDERS_CHANGE_STATUS, self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_PRINT,
|
|
self::FINANCE_CREATE_PAYMENT,
|
|
self::SALARIES_VIEW_OWN,
|
|
self::INVENTORY_VIEW,
|
|
self::AI_ASSISTANT_USE,
|
|
];
|
|
|
|
// mechanic: only own WOs + inventory view
|
|
$matrix['mechanic'] = [
|
|
self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CHANGE_STATUS,
|
|
self::INVENTORY_VIEW,
|
|
self::SALARIES_VIEW_OWN,
|
|
];
|
|
|
|
// viewer: read-only
|
|
$matrix['viewer'] = [
|
|
self::CLIENTS_VIEW_ALL,
|
|
self::VEHICLES_VIEW_ALL,
|
|
self::WORK_ORDERS_VIEW_ALL,
|
|
self::INVENTORY_VIEW,
|
|
self::SUPPLIERS_VIEW,
|
|
self::AI_ASSISTANT_USE,
|
|
];
|
|
|
|
return $matrix;
|
|
}
|
|
|
|
public static function roleLabels(): array
|
|
{
|
|
return [
|
|
'owner' => 'Proprietar',
|
|
'admin' => 'Administrator',
|
|
'manager' => 'Manager',
|
|
'accountant' => 'Contabil',
|
|
'receptionist' => 'Recepție',
|
|
'mechanic' => 'Mecanic',
|
|
'viewer' => 'Vizitator',
|
|
];
|
|
}
|
|
}
|