From 70ca2fa74a74fd10d6cfc5505e52ae9f18dffb40 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Tue, 7 Jul 2026 09:44:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20marj=C4=83=20intern=C4=83=20per=20mecha?= =?UTF-8?q?nic=20=E2=80=94=20hidden=20margin=20on=20labor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Auth/Permissions.php | 5 +- .../Tenant/Resources/UserResource.php | 19 ++ .../Tenant/Resources/WorkOrderResource.php | 9 + .../RelationManagers/WorksRelationManager.php | 8 +- app/Models/Tenant/User.php | 2 +- app/Models/Tenant/WorkOrder.php | 2 +- app/Models/Tenant/WorkOrderWork.php | 13 + app/Services/MarginResolver.php | 55 ++++ app/Services/PayrollCalculator.php | 5 +- ...7_07_000001_add_internal_margin_fields.php | 49 ++++ tests/Feature/InternalMarginTest.php | 237 ++++++++++++++++++ tests/Feature/RbacApiTest.php | 4 +- tests/Feature/RbacTest.php | 4 +- 13 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 app/Services/MarginResolver.php create mode 100644 database/migrations/2026_07_07_000001_add_internal_margin_fields.php create mode 100644 tests/Feature/InternalMarginTest.php diff --git a/app/Auth/Permissions.php b/app/Auth/Permissions.php index 7f6ebd4..859a81a 100644 --- a/app/Auth/Permissions.php +++ b/app/Auth/Permissions.php @@ -40,6 +40,7 @@ class Permissions // 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'; @@ -91,7 +92,7 @@ class Permissions 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_CREATE_PAYMENT, + 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, @@ -154,7 +155,7 @@ class Permissions 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_CREATE_PAYMENT, self::FINANCE_CREATE_EXPENSE, + 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, diff --git a/app/Filament/Tenant/Resources/UserResource.php b/app/Filament/Tenant/Resources/UserResource.php index 9a8bc6e..d9c5591 100644 --- a/app/Filament/Tenant/Resources/UserResource.php +++ b/app/Filament/Tenant/Resources/UserResource.php @@ -90,6 +90,25 @@ class UserResource extends Resource ->columnSpanFull() ->helperText('Roluri suplimentare peste rolul primar — drepturile se cumulează.'), ]), + Schemas\Components\Section::make('Salariu & marjă') + ->description('Configurează procentele pentru calcul salariu. Marja internă (nu TVA) se scade din prețul de manoperă pentru a determina baza salariului.') + ->columns(2) + ->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false) + ->schema([ + Forms\Components\TextInput::make('hourly_rate') + ->label('Tarif orar (MDL)') + ->numeric() + ->step(0.01) + ->placeholder('Ex: 100'), + Forms\Components\TextInput::make('internal_margin_pct') + ->label('Marjă internă (%)') + ->numeric() + ->step(0.01) + ->minValue(0) + ->maxValue(90) + ->placeholder('Ex: 20 pentru +20%') + ->helperText('Doar la manopere (proprii + subcontract). Baza salariu = preț client × (1 − marjă/100). Lasă gol pentru a folosi valoarea implicită a companiei.'), + ]), Schemas\Components\Section::make('Securitate') ->columns(2) ->schema([ diff --git a/app/Filament/Tenant/Resources/WorkOrderResource.php b/app/Filament/Tenant/Resources/WorkOrderResource.php index 9bff76c..3b4e433 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource.php @@ -143,6 +143,15 @@ class WorkOrderResource extends Resource ->default('unpaid') ->required(), Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0), + Forms\Components\TextInput::make('override_margin_pct') + ->label('Marjă internă (%) — override') + ->numeric() + ->step(0.01) + ->minValue(0) + ->maxValue(90) + ->placeholder('Ex: 25 pentru VIP / contract') + ->helperText('Doar pentru cazuri speciale. Lasă gol pentru a folosi marja mecanicului.') + ->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false), Forms\Components\TextInput::make('total')->label('Total')->numeric()->disabled()->dehydrated(false), Forms\Components\Toggle::make('approved')->label('Aprobat de client'), Forms\Components\DatePicker::make('closed_at')->label('Închis'), diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php index 613068a..1d28e21 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php @@ -61,7 +61,13 @@ class WorksRelationManager extends RelationManager Tables\Columns\TextColumn::make('name')->label('Manoperă')->wrap(), Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(), Tables\Columns\TextColumn::make('price_per_hour')->label('Preț/h')->money('MDL')->alignRight(), - Tables\Columns\TextColumn::make('total')->label('Total')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('total') + ->label('Total') + ->money('MDL') + ->alignRight() + ->description(fn ($record) => (auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) && $record->salary_base !== null) + ? 'Bază salariu: ' . number_format((float) $record->salary_base, 2) . ' MDL · marjă ' . rtrim(rtrim(number_format((float) $record->applied_margin_pct, 2), '0'), '.') . '%' + : null), Tables\Columns\TextColumn::make('master.name')->label('Maistru')->placeholder('—'), Tables\Columns\TextColumn::make('status') ->formatStateUsing(fn ($s) => WorkOrderWork::STATUSES[$s] ?? $s) diff --git a/app/Models/Tenant/User.php b/app/Models/Tenant/User.php index c3dfedf..197fd3f 100644 --- a/app/Models/Tenant/User.php +++ b/app/Models/Tenant/User.php @@ -31,7 +31,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication protected $fillable = [ 'company_id', 'name', 'email', 'phone', 'avatar_url', 'role', 'status', 'locale', - 'specialization', 'color', 'hourly_rate', + 'specialization', 'color', 'hourly_rate', 'internal_margin_pct', 'email_verified_at', 'password', 'last_login_at', 'email_authentication_at', 'app_authentication_secret', 'app_authentication_recovery_codes', diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 5a17cad..546e031 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -39,7 +39,7 @@ class WorkOrder extends Model implements HasMedia 'opened_at', 'closed_at', 'mileage_in', 'mileage_out', 'complaint', 'diagnosis', 'recommendations', 'status', 'urgency', 'pay_status', 'approved', 'approved_at', - 'discount_pct', 'total', + 'discount_pct', 'override_margin_pct', 'total', 'eta_at', 'eta_promised', 'eta_change_reason', 'eta_updated_at', 'tracking_token', ]; diff --git a/app/Models/Tenant/WorkOrderWork.php b/app/Models/Tenant/WorkOrderWork.php index 693bc99..6306915 100644 --- a/app/Models/Tenant/WorkOrderWork.php +++ b/app/Models/Tenant/WorkOrderWork.php @@ -45,6 +45,7 @@ class WorkOrderWork extends Model 'mechanic_status', 'mechanic_started_at', 'mechanic_done_at', 'actual_hours', 'paused_seconds_total', 'paused_at', 'block_reason', 'block_note', + 'salary_base', 'applied_margin_pct', ]; protected $casts = [ @@ -59,6 +60,8 @@ class WorkOrderWork extends Model 'paused_at' => 'datetime', 'actual_hours' => 'decimal:2', 'paused_seconds_total' => 'integer', + 'salary_base' => 'decimal:2', + 'applied_margin_pct' => 'decimal:2', ]; // ── State machine ──────────────────────────────────────────── @@ -172,6 +175,16 @@ class WorkOrderWork extends Model if ($row->requires_approval && empty($row->approval_token)) { $row->approval_token = \Illuminate\Support\Str::random(24); } + // Compute internal margin & freeze salary_base at save time. + // Once frozen, changing user.internal_margin_pct later does NOT rewrite history. + if (($row->salary_base === null || $row->isDirty(['total', 'master_id'])) && (float) $row->total > 0) { + $resolver = app(\App\Services\MarginResolver::class); + $mechanic = $row->master_id ? User::find($row->master_id) : null; + $wo = $row->workOrder; + $marginPct = $resolver->resolve($wo, $mechanic); + $row->applied_margin_pct = $marginPct; + $row->salary_base = $resolver->computeSalaryBase((float) $row->total, $marginPct); + } }); static::saved(fn (self $row) => $row->workOrder?->recalcTotal()); static::deleted(fn (self $row) => $row->workOrder?->recalcTotal()); diff --git a/app/Services/MarginResolver.php b/app/Services/MarginResolver.php new file mode 100644 index 0000000..7b8e49e --- /dev/null +++ b/app/Services/MarginResolver.php @@ -0,0 +1,55 @@ +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); + } +} diff --git a/app/Services/PayrollCalculator.php b/app/Services/PayrollCalculator.php index 6b752fe..54930a6 100644 --- a/app/Services/PayrollCalculator.php +++ b/app/Services/PayrollCalculator.php @@ -26,10 +26,13 @@ class PayrollCalculator $partsPct = (float) ($profile?->parts_pct ?? 0); // Manopere finalizate de utilizator în perioadă. + // Salariul se calculează pe salary_base (frozen la salvare, izolat de marja internă). + // Fallback la total pentru rânduri legacy (înainte de introducerea marjei interne). $worksRevenue = (float) WorkOrderWork::where('master_id', $userId) ->where('status', 'done') ->whereBetween('updated_at', [$start, $end]) - ->sum('total'); + ->get() + ->sum(fn ($w) => (float) ($w->salary_base ?? $w->total)); $worksPctAmount = round($worksRevenue * $worksPct / 100, 2); // Marja pe piesele montate de utilizator (nu există FK direct, aprox via work_order.master_id) diff --git a/database/migrations/2026_07_07_000001_add_internal_margin_fields.php b/database/migrations/2026_07_07_000001_add_internal_margin_fields.php new file mode 100644 index 0000000..5cef97c --- /dev/null +++ b/database/migrations/2026_07_07_000001_add_internal_margin_fields.php @@ -0,0 +1,49 @@ +decimal('internal_margin_pct', 5, 2)->nullable()->after('hourly_rate'); + } + }); + + Schema::table('work_orders', function (Blueprint $t) { + if (! Schema::hasColumn('work_orders', 'override_margin_pct')) { + $t->decimal('override_margin_pct', 5, 2)->nullable()->after('discount_pct'); + } + }); + + Schema::table('wo_works', function (Blueprint $t) { + if (! Schema::hasColumn('wo_works', 'salary_base')) { + // Frozen at save time so changing margin later doesn't rewrite history + $t->decimal('salary_base', 10, 2)->nullable()->after('total'); + } + if (! Schema::hasColumn('wo_works', 'applied_margin_pct')) { + // The exact margin % used when salary_base was computed + $t->decimal('applied_margin_pct', 5, 2)->nullable()->after('salary_base'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $t) { + if (Schema::hasColumn('users', 'internal_margin_pct')) $t->dropColumn('internal_margin_pct'); + }); + Schema::table('work_orders', function (Blueprint $t) { + if (Schema::hasColumn('work_orders', 'override_margin_pct')) $t->dropColumn('override_margin_pct'); + }); + Schema::table('wo_works', function (Blueprint $t) { + foreach (['salary_base', 'applied_margin_pct'] as $col) { + if (Schema::hasColumn('wo_works', $col)) $t->dropColumn($col); + } + }); + } +}; diff --git a/tests/Feature/InternalMarginTest.php b/tests/Feature/InternalMarginTest.php new file mode 100644 index 0000000..ed52207 --- /dev/null +++ b/tests/Feature/InternalMarginTest.php @@ -0,0 +1,237 @@ + '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); + } +} diff --git a/tests/Feature/RbacApiTest.php b/tests/Feature/RbacApiTest.php index 0598bd3..424406c 100644 --- a/tests/Feature/RbacApiTest.php +++ b/tests/Feature/RbacApiTest.php @@ -149,7 +149,7 @@ class RbacApiTest extends TestCase $this->assertGreaterThanOrEqual(7, $roles->count()); $owner = $roles->firstWhere('name', 'owner'); $this->assertNotNull($owner); - $this->assertEquals(51, $owner['permissions_count']); + $this->assertEquals(52, $owner['permissions_count']); } public function test_role_sync_permissions_updates_role(): void @@ -181,7 +181,7 @@ class RbacApiTest extends TestCase $resp = $this->getJson('/api/v1/permissions'); $resp->assertOk(); - $this->assertEquals(51, count($resp->json('data'))); + $this->assertEquals(52, count($resp->json('data'))); $this->assertArrayHasKey('grouped', $resp->json()); $this->assertArrayHasKey('clients', $resp->json('grouped')); $this->assertArrayHasKey('roles', $resp->json()); diff --git a/tests/Feature/RbacTest.php b/tests/Feature/RbacTest.php index 8f5a531..36db7d8 100644 --- a/tests/Feature/RbacTest.php +++ b/tests/Feature/RbacTest.php @@ -35,7 +35,7 @@ class RbacTest extends TestCase public function test_seeder_creates_51_permissions(): void { - $this->assertEquals(51, Permission::where('guard_name', 'web')->count()); + $this->assertEquals(52, Permission::where('guard_name', 'web')->count()); } public function test_seeder_creates_7_roles_per_tenant(): void @@ -48,7 +48,7 @@ class RbacTest extends TestCase public function test_owner_role_has_all_permissions(): void { $owner = Role::where('company_id', $this->company->id)->where('name', 'owner')->first(); - $this->assertEquals(51, $owner->permissions->count()); + $this->assertEquals(52, $owner->permissions->count()); } public function test_mechanic_role_has_minimal_permissions(): void