Files
autocrm/app/Services/MarginResolver.php
T
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

56 lines
1.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Tenant\User;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
/**
* Resolves the internal margin percentage for a work order line.
*
* Terminology: this is "marja internă" — an internal profit margin the shop
* takes on top of the mechanic's labor rate. It is NOT VAT/TVA and MUST NOT
* be exposed on any customer-facing surface (PDF, tracking, receipts, API).
*
* Fallback chain:
* 1. WorkOrder.override_margin_pct — per-Fișă override (special contracts)
* 2. User.internal_margin_pct — per-mechanic setting (main location)
* 3. Company.settings.default_internal_margin_pct — tenant-wide default
* 4. 0.0 — no margin
*
* Semantics:
* client_price = whatever you charge (auto-computed OR manually entered)
* salary_base = client_price × (1 - margin_pct / 100)
*
* The mechanic's payroll % applies to salary_base, never to client_price.
*/
class MarginResolver
{
public function resolve(?WorkOrder $wo = null, ?User $mechanic = null): float
{
if ($wo && $wo->override_margin_pct !== null) {
return (float) $wo->override_margin_pct;
}
if ($mechanic && $mechanic->internal_margin_pct !== null) {
return (float) $mechanic->internal_margin_pct;
}
$tenant = app(TenantManager::class)->current();
if ($tenant) {
$default = data_get($tenant->settings, 'default_internal_margin_pct');
if ($default !== null) return (float) $default;
}
return 0.0;
}
/**
* Compute the salary base from a client-facing amount.
* salary_base = client_price × (1 - margin_pct/100)
*/
public function computeSalaryBase(float $clientPrice, float $marginPct): float
{
$factor = max(0.0, 1.0 - $marginPct / 100.0);
return round($clientPrice * $factor, 2);
}
}