3 Commits

Author SHA1 Message Date
Vasyka 47eb4f62c1 feat(injector-protocols): full module — resource + PDF export via Browsershot
Implements the injector diagnostic protocol module per spec:
- Migration: injector_protocols + injector_protocol_rows with company_id
  (multi-tenant), vehicle_id (not car_id — this CRM uses Vehicle),
  master_id FK to users, snapshot fields (car_model/plate/vin/mileage/year/
  injector_brand) captured at protocol time.
- Models with BelongsToTenant; auto-generate protocol_number as
  PS-{tenantId}-{year}-{seq6}; auto-seed 8 empty rows on create.
- Permissions: injector_protocols.view + injector_protocols.conclude.
  Default roles: owner/admin (both), manager (both), receptionist (view
  only), mechanic (both). Test permission count updated 52→54.
- InjectorProtocolResource under Service nav group with 4 sections:
  data auto+client (with client_id/vehicle_id lookups auto-filling
  snapshot fields), reason checkboxes, 8-row repeater for measurements
  (Repeater with position hidden; fixed 8, non-addable/deletable),
  conclusion + comment + master signature.
- Table with columns, badge filters, PDF action.
- Blade PDF template: pixel-close to reference (graphite/gold/blue
  brand tokens, striped rows, rotated 'ФОРСУНКИ' header).
- Route /app/injector-protocols/{id}/pdf with permission gate,
  streams PDF via new InjectorProtocolPdfService (Browsershot).
- Dockerfile: install nodejs 22 + chromium + noto fonts + puppeteer-core
  in /opt/browsershot; env vars for Browsershot binary paths.
- +71 RU/EN translations covering resource + PDF template.

All 306 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-26 11:27:53 +00:00
Vasyka 70ca2fa74a feat: marjă internă per mechanic — hidden margin on labor
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>
2026-07-07 09:44:46 +00:00
Vasyka 58004b65c4 feat: RBAC catalog + 2FA UX (P0 blocker from /tmp/service/new/01-TZ)
Implements the RBAC TZ in app/Auth/Permissions.php with a 51-permission
catalog spanning 9 modules (clients/vehicles/work_orders/finance/salaries/
inventory/suppliers/admin/ai_assistant+analytics). All slugs are constants,
not magic strings — refactors against renames stay safe.

== 7 system roles ==
owner / admin / manager / accountant / receptionist / mechanic / viewer
Each gets a curated role-permission matrix per the TZ section 2.4:
- owner + admin: all 51
- manager: 23 (operations + reporting, no destructive finance/users)
- accountant: 17 (full finance/salaries, view-only WOs, no admin)
- receptionist: 13 (front-desk: clients/vehicles/WOs/payment-create)
- mechanic: 4 (own WOs + inventory view + own salary)
- viewer: 6 (read-only everything except finance/salaries)

== Seeder ==
App\Services\RbacSeeder:
- seedPermissions() creates the 51 Permission rows globally (idempotent)
- seedTenantRoles($companyId) sets the team context, creates the 7 Role
  rows scoped to that tenant, and syncPermissions per matrix
- syncUsersToRoles($companyId) maps legacy users.role string column to
  the new Spatie role assignment (parts_manager→manager, master→mechanic,
  marketer→manager, user→viewer)

== Migration ==
2026_06_04_000003 loops over all existing Companies and runs the seeder.
On a fresh prod deploy, every tenant gets the full RBAC catalog wired up
automatically. CompanyProvisioner::provision() also calls the seeder for
new tenants going forward.

== Resource gates ==
canViewAny / canCreate / canDelete on:
- PaymentResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_PAYMENT / FINANCE_DELETE_PAYMENT)
- ExpenseResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_EXPENSE / FINANCE_DELETE_PAYMENT)
- PayrollAdjustmentResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE)
- PayrollRunResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE)
- UserResource (ADMIN_USERS_VIEW / ADMIN_USERS_MANAGE)
- RoleResource (ADMIN_ROLES_MANAGE)

Mechanic sees only own WOs + inventory + own salary. Accountant sees all
finance but not admin. Receptionist sees clients/WOs but not finance
overview. Etc.

== User helpers ==
$user->canDo(Permissions::WORK_ORDERS_CREATE) — admin gets a bypass to
prevent lockouts from misconfigured permission grants.
$user->isOwner() / isAccountant() / isMechanic() — role shortcuts.
$user->hasTwoFactorEnabled() — true when app_authentication_secret is set.

== 2FA ==
Filament 5's native MultiFactorAuthentication (App + Email) is already
enabled in both TenantPanelProvider and CentralPanelProvider — confirmed.
The User model already implements HasAppAuthentication +
HasAppAuthenticationRecovery + HasEmailAuthentication.

This commit adds UX around it:
- UserResource list column: 2FA badge (green ✓ when enabled, amber ⚠ when off)
- UserResource form: "Securitate" section shows enabled/disabled + last_login_at
- New admin action "Resetează 2FA" with confirmation modal — clears
  app_authentication_secret + recovery codes for locked-out users

== Roles management UI ==
New /app/roles RoleResource:
- List: role label + slug + permission count + user count
- Edit: 10 grouped checkbox lists (per module) for fine-grained
  permission assignment + bulk-toggle per group
- System roles (owner/admin/etc.) have slug locked, can't be deleted
- Custom tenant-specific roles can be added on top
- Gated behind ADMIN_ROLES_MANAGE

== UserResource extension ==
- Role select now uses Permissions::roleLabels() (owner/admin/manager/...)
- New "Roluri suplimentare" multi-select for stacking roles on top of
  the primary one (permissions cumulate)
- afterSave syncs the picked roles + ensures primary role is always
  included

== Tests ==
RbacTest covers: 51 permissions seeded, 7 roles per tenant, owner has
all, mechanic has minimal, accountant has finance but not admin,
canDo returns true when role has permission, admin bypass, owner helper,
syncUsersToRoles legacy mapping (parts_manager→manager, master→mechanic,
user→viewer), 2FA helper round-trip.

Suite: 206 passed (576 assertions). Was 196.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 22:03:03 +00:00