Files
autocrm/tests/Feature/InternalMarginTest.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

238 lines
9.7 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 Tests\Feature;
use App\Auth\Permissions;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\EmployeeProfile;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use App\Services\MarginResolver;
use App\Services\PayrollCalculator;
use App\Services\RbacSeeder;
use App\Services\WorkOrderPdfService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class InternalMarginTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private User $mechanic;
private WorkOrder $wo;
protected function setUp(): void
{
parent::setUp();
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$this->company = Company::create(['plan_id' => $plan->id, 'slug' => 'im-' . uniqid(), 'name' => 'IM', 'status' => 'active']);
app(TenantManager::class)->setCurrent($this->company);
app(RbacSeeder::class)->seedTenantRoles($this->company->id);
app(PermissionRegistrar::class)->setPermissionsTeamId($this->company->id);
$this->mechanic = User::create(['name' => 'Andrei', 'email' => 'a@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active', 'internal_margin_pct' => 20.00]);
$client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'IM-1']);
$this->wo = WorkOrder::create([
'number' => WorkOrder::generateNumber($this->company->id),
'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'master_id' => $this->mechanic->id,
'opened_at' => today(), 'status' => 'in_work', 'total' => 0,
]);
}
// ─── MarginResolver fallback chain ───
public function test_margin_resolver_uses_wo_override_first(): void
{
$this->wo->override_margin_pct = 30;
$resolver = app(MarginResolver::class);
$this->assertEquals(30.0, $resolver->resolve($this->wo, $this->mechanic));
}
public function test_margin_resolver_falls_back_to_mechanic(): void
{
// No WO override → mechanic's 20%
$this->assertEquals(20.0, app(MarginResolver::class)->resolve($this->wo, $this->mechanic));
}
public function test_margin_resolver_falls_back_to_company_default(): void
{
$bareUser = User::create(['name' => 'B', 'email' => 'b@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']);
$this->company->update(['settings' => ['default_internal_margin_pct' => 15]]);
app(TenantManager::class)->setCurrent($this->company->fresh());
$this->assertEquals(15.0, app(MarginResolver::class)->resolve($this->wo, $bareUser));
}
public function test_margin_resolver_returns_zero_when_nothing_configured(): void
{
$bareUser = User::create(['name' => 'C', 'email' => 'c@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']);
$this->assertEquals(0.0, app(MarginResolver::class)->resolve($this->wo, $bareUser));
}
// ─── salary_base freeze semantics ───
public function test_saving_work_freezes_salary_base_at_20_percent_off(): void
{
// client_price 250 · marja 20% → salary_base 200 (= 250 × 0.80)
$work = WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'Diagnoză', 'hours' => 1, 'price_per_hour' => 250,
]);
$this->assertEquals(250.00, (float) $work->total);
$this->assertEquals(200.00, (float) $work->salary_base);
$this->assertEquals(20.00, (float) $work->applied_margin_pct);
}
public function test_changing_mechanic_margin_later_does_not_rewrite_history(): void
{
// Create work at 20% → salary_base=200
$work = WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'X', 'hours' => 1, 'price_per_hour' => 250,
]);
$originalBase = (float) $work->salary_base;
// Manager changes mechanic's margin to 40% RETROACTIVELY
$this->mechanic->update(['internal_margin_pct' => 40]);
// Freshly loaded work should still have the old salary_base
$reload = WorkOrderWork::find($work->id);
$this->assertEquals($originalBase, (float) $reload->salary_base);
$this->assertEquals(20.00, (float) $reload->applied_margin_pct);
}
public function test_updating_total_recomputes_salary_base_with_current_margin(): void
{
$work = WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'X', 'hours' => 1, 'price_per_hour' => 250,
]);
// Change price → total becomes 500 → salary_base recomputes at current margin (20%)
$work->update(['price_per_hour' => 500]);
$work->refresh();
$this->assertEquals(500.00, (float) $work->total);
$this->assertEquals(400.00, (float) $work->salary_base);
}
// ─── PayrollCalculator uses salary_base ───
public function test_payroll_uses_salary_base_not_total(): void
{
Carbon::setTestNow('2026-07-10');
EmployeeProfile::create([
'user_id' => $this->mechanic->id,
'base_salary' => 0, 'works_pct' => 50, 'parts_pct' => 0,
]);
WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'X', 'hours' => 1, 'price_per_hour' => 250, 'status' => 'done',
]);
$run = app(PayrollCalculator::class)->compute($this->mechanic->id, '2026-07');
// 50% of salary_base=200 → 100 (NOT 50% of total=250 → 125)
$this->assertEquals(100.0, (float) $run->works_pct_amount);
Carbon::setTestNow();
}
public function test_payroll_falls_back_to_total_when_salary_base_null(): void
{
// Simulate legacy WOs from before this feature by clearing salary_base
Carbon::setTestNow('2026-07-10');
EmployeeProfile::create(['user_id' => $this->mechanic->id, 'base_salary' => 0, 'works_pct' => 50, 'parts_pct' => 0]);
$work = WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'X', 'hours' => 1, 'price_per_hour' => 100, 'status' => 'done',
]);
// Manually null out salary_base
\DB::table('wo_works')->where('id', $work->id)->update(['salary_base' => null, 'applied_margin_pct' => null]);
$run = app(PayrollCalculator::class)->compute($this->mechanic->id, '2026-07');
// Falls back to total: 50% of 100 → 50
$this->assertEquals(50.0, (float) $run->works_pct_amount);
Carbon::setTestNow();
}
// ─── Contract: NO leak in client-facing surfaces ───
public function test_workorder_pdf_does_not_leak_salary_base_or_margin(): void
{
WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'Diagnoză computer', 'hours' => 1, 'price_per_hour' => 250, 'status' => 'done',
]);
$pdf = app(WorkOrderPdfService::class)->generate($this->wo->fresh());
$output = $pdf->output();
// Strings that MUST NOT appear in a client-facing PDF:
$forbidden = ['salary_base', 'internal_margin', 'applied_margin_pct', 'marja intern', 'Bază salariu'];
foreach ($forbidden as $needle) {
$this->assertStringNotContainsString(
$needle, $output,
"Client-facing PDF leaked internal-margin term: $needle"
);
}
}
public function test_tracking_json_does_not_leak_salary_or_margin(): void
{
WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'Diagnoză', 'hours' => 1, 'price_per_hour' => 250, 'status' => 'done',
]);
$resp = $this->getJson("/api/track/{$this->wo->tracking_token}");
$resp->assertOk();
$body = json_encode($resp->json());
foreach (['salary_base', 'internal_margin', 'applied_margin', 'override_margin'] as $needle) {
$this->assertStringNotContainsString($needle, $body, "Tracking JSON leaked: $needle");
}
}
// ─── Applies only to labor, not parts ───
public function test_parts_are_not_affected_by_internal_margin(): void
{
// Add a part — should NOT get salary_base column touched
$part = WorkOrderPart::create([
'work_order_id' => $this->wo->id, 'name' => 'Filtru',
'qty' => 1, 'sell_price' => 250, 'buy_price' => 100,
]);
// The wo_parts table doesn't even have a salary_base column
$this->assertFalse(\Schema::hasColumn('wo_parts', 'salary_base'));
}
public function test_wo_override_wins_over_mechanic_margin(): void
{
$this->wo->update(['override_margin_pct' => 40]); // WO-specific
$work = WorkOrderWork::create([
'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id,
'name' => 'X', 'hours' => 1, 'price_per_hour' => 250,
]);
// Should use 40% not 20% → salary_base = 250 × 0.60 = 150
$this->assertEquals(150.00, (float) $work->salary_base);
$this->assertEquals(40.00, (float) $work->applied_margin_pct);
}
}