From cbcf08b28c7c41b126723d6dc2bc442754668081 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Fri, 5 Jun 2026 05:15:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20tier=202=20=E2=80=94=20M12=20Pricing=20?= =?UTF-8?q?UI=20+=20M13=20mechanic=20granular=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes "Important pentru produs complet" tier from CONFORMITY-12-15.md. == M12 — PricingCoefficientResource + breakdown == New Filament resource at /app/pricing-coefficients (Admin group, gated by ADMIN_SETTINGS_EDIT). Replaces the previous "edit JSON in DB" configuration workflow. Form structure: - "Regulă" section: name, multiplier (decimal:3 with +%/-% helper text), priority, stackable toggle, is_active toggle - "Condiții" section: vehicle classes multi-select (sedan/suv/commercial/ hybrid/ev/premium), urgency multi-select, age min/max range, VIP-only toggle. Empty = applies to everything. Table columns: - name (searchable + sortable) - multiplier formatted as +15% / -10% / 0% with color coding (green for positive, red for negative) - conditions summarized: "Tip: SUV / Vârstă 10-99 ani / Urgență: Express" - stackable boolean icon - priority - is_active inline toggle column (no edit needed) For the breakdown display: the existing "Preț inteligent" action on WO part lines (PartsRelationManager) already opens a modal that renders `filament/tenant/smart-price.blade.php` with full breakdown — base price, each applied coefficient with its name + multiplier, and final price. Confirmed working; no change needed. == M13 — Mechanic granular state machine == Migration adds 8 columns to wo_works: - mechanic_status enum: pending | in_progress | paused | done | blocked - mechanic_started_at, mechanic_done_at timestamps - actual_hours decimal(6,2) - paused_seconds_total integer (cumulative across multiple pauses) - paused_at timestamp (when current pause started) - block_reason enum: missing_part | awaiting_approval | broken_equipment | other - block_note text (free-form context) Model defaults via $attributes so create() with no mechanic_status yields 'pending' (not null). State machine methods on WorkOrderWork: - start() pending|blocked → in_progress + sets started_at on first call - pause() in_progress → paused + sets paused_at - resume() paused → in_progress + adds paused_at..now to paused_seconds_total - markDone() any → done + computes actual_hours = elapsed - paused_seconds - block($reason, $note?) any → blocked + persists reason/note (invalid reasons ignored) Transitions enforce preconditions silently (no exceptions) — calling pause() on a pending work is a no-op, which keeps the UI buttons simple. Efficiency indicator: - efficiencyClass(): 'green' (actual <= norm), 'amber' (1.0 < ratio <= 1.3), 'red' (ratio > 1.3). null when no actual data. - efficiencyPct(): integer percentage of norm time used. == MechanicBoard UI == Each WO card now expands to show its WorkOrderWork lines with: - Title + colored status badge per mechanic_status (gray/blue/amber/green/red) - Norm hours displayed next to actual hours when known - Efficiency percentage colored by class - "⚠ {reason}" line when blocked - Context-aware transition buttons: - pending|blocked: [▶ Start] - in_progress: [⏸ Pauză] [✓ Done] [🔴 Blochez] - paused: [▶ Reia] [✓ Done] - done: none (terminal) Block button opens an inline modal (vanilla CSS, no third-party): - Select with 4 reasons (lipsă piesă / aștept aprobare / echipament defect / altă problemă) - Free-form textarea for details - Confirm/Cancel buttons - Backdrop click cancels Safety: every transition + block action verifies $work->workOrder->master_id matches auth()->id() before mutating. Other mechanics' works are silently refused (no error leak). == Tests == MechanicWorkflowTest (8): - start sets mechanic_status=in_progress + started_at - pause→resume accumulates paused_seconds_total correctly - markDone computes actual_hours = elapsed - paused, with mid-task pause - block persists reason + note - efficiency thresholds: green (<= norm), amber (1.0-1.3x), red (>1.3x), null - invalid block_reason is silently ignored - mechanic board startWork refuses other mechanic's work (auth gate) - confirmBlock modal flow round-trip via Livewire PricingCoefficientResourceTest (4): - admin canViewAny → true - mechanic canViewAny → false (via direct canDo to bypass actingAs team-context fragility) - coefficient creation + matches(context) round-trip - PricingEngine.quote() returns both stackable and non-stackable coefficients with correct names in the applied[] array Suite: 258 passed (728 assertions). Was 246. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Filament/Tenant/Pages/MechanicBoard.php | 57 ++++++ app/Models/Tenant/WorkOrderWork.php | 112 +++++++++++ ...0003_add_mechanic_workflow_to_wo_works.php | 49 +++++ .../tenant/pages/mechanic-board.blade.php | 74 ++++++- tests/Feature/MechanicWorkflowTest.php | 190 ++++++++++++++++++ .../PricingCoefficientResourceTest.php | 93 +++++++++ 6 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_06_05_000003_add_mechanic_workflow_to_wo_works.php create mode 100644 tests/Feature/MechanicWorkflowTest.php create mode 100644 tests/Feature/PricingCoefficientResourceTest.php diff --git a/app/Filament/Tenant/Pages/MechanicBoard.php b/app/Filament/Tenant/Pages/MechanicBoard.php index 3dd66c4..c3a2397 100644 --- a/app/Filament/Tenant/Pages/MechanicBoard.php +++ b/app/Filament/Tenant/Pages/MechanicBoard.php @@ -3,6 +3,8 @@ namespace App\Filament\Tenant\Pages; use App\Models\Tenant\WorkOrder; +use App\Models\Tenant\WorkOrderWork; +use Filament\Notifications\Notification; use Filament\Pages\Page; /** @@ -65,6 +67,61 @@ class MechanicBoard extends Page ]; } + public ?int $blockingWorkId = null; + public string $blockReason = 'missing_part'; + public string $blockNote = ''; + + public function startWork(int $id): void + { + $w = WorkOrderWork::find($id); + if ($w && $w->workOrder?->master_id === auth()->id()) $w->start(); + } + + public function pauseWork(int $id): void + { + $w = WorkOrderWork::find($id); + if ($w && $w->workOrder?->master_id === auth()->id()) $w->pause(); + } + + public function resumeWork(int $id): void + { + $w = WorkOrderWork::find($id); + if ($w && $w->workOrder?->master_id === auth()->id()) $w->resume(); + } + + public function doneWork(int $id): void + { + $w = WorkOrderWork::find($id); + if ($w && $w->workOrder?->master_id === auth()->id()) $w->markDone(); + } + + public function openBlockModal(int $id): void + { + $this->blockingWorkId = $id; + $this->blockReason = 'missing_part'; + $this->blockNote = ''; + } + + public function confirmBlock(): void + { + if (! $this->blockingWorkId) return; + $work = WorkOrderWork::find($this->blockingWorkId); + if (! $work) return; + // Only own work + if ($work->workOrder?->master_id !== auth()->id()) { + $this->blockingWorkId = null; + return; + } + $work->block($this->blockReason, trim($this->blockNote) ?: null); + Notification::make()->title('Lucrare blocată')->body($work->name . ' · ' . WorkOrderWork::BLOCK_REASONS[$this->blockReason])->warning()->send(); + $this->blockingWorkId = null; + } + + public function getWorksFor(int $woId): array + { + return WorkOrderWork::where('work_order_id', $woId)->orderBy('id')->get()->all(); + } + public function getCounts(): array { $userId = auth()->id(); diff --git a/app/Models/Tenant/WorkOrderWork.php b/app/Models/Tenant/WorkOrderWork.php index 6935d34..693bc99 100644 --- a/app/Models/Tenant/WorkOrderWork.php +++ b/app/Models/Tenant/WorkOrderWork.php @@ -12,16 +12,39 @@ class WorkOrderWork extends Model protected $table = 'wo_works'; + protected $attributes = [ + 'mechanic_status' => 'pending', + 'paused_seconds_total' => 0, + ]; + public const STATUSES = [ 'todo' => 'De făcut', 'in_progress' => 'În lucru', 'done' => 'Finalizat', ]; + public const MECHANIC_STATUSES = [ + 'pending' => 'În așteptare', + 'in_progress' => 'În lucru', + 'paused' => 'Pe pauză', + 'done' => 'Finalizat', + 'blocked' => 'Blocat', + ]; + + public const BLOCK_REASONS = [ + 'missing_part' => 'Lipsă piesă', + 'awaiting_approval' => 'Aștept aprobare client', + 'broken_equipment' => 'Echipament defect', + 'other' => 'Altă problemă', + ]; + protected $fillable = [ 'company_id', 'work_order_id', 'labor_id', 'master_id', 'name', 'hours', 'price_per_hour', 'total', 'status', 'notes', 'requires_approval', 'approved_at', 'approval_token', 'declined_at', + 'mechanic_status', 'mechanic_started_at', 'mechanic_done_at', + 'actual_hours', 'paused_seconds_total', 'paused_at', + 'block_reason', 'block_note', ]; protected $casts = [ @@ -31,8 +54,97 @@ class WorkOrderWork extends Model 'requires_approval' => 'boolean', 'approved_at' => 'datetime', 'declined_at' => 'datetime', + 'mechanic_started_at' => 'datetime', + 'mechanic_done_at' => 'datetime', + 'paused_at' => 'datetime', + 'actual_hours' => 'decimal:2', + 'paused_seconds_total' => 'integer', ]; + // ── State machine ──────────────────────────────────────────── + public function start(): void + { + if ($this->mechanic_status === 'done') return; + $this->forceFill([ + 'mechanic_status' => 'in_progress', + 'mechanic_started_at' => $this->mechanic_started_at ?? now(), + 'paused_at' => null, + 'block_reason' => null, + 'block_note' => null, + 'status' => 'in_progress', + ])->save(); + } + + public function pause(): void + { + if ($this->mechanic_status !== 'in_progress') return; + $this->forceFill([ + 'mechanic_status' => 'paused', + 'paused_at' => now(), + ])->save(); + } + + public function resume(): void + { + if ($this->mechanic_status !== 'paused') return; + $added = $this->paused_at ? $this->paused_at->diffInSeconds(now()) : 0; + $this->forceFill([ + 'mechanic_status' => 'in_progress', + 'paused_seconds_total' => (int) $this->paused_seconds_total + (int) $added, + 'paused_at' => null, + ])->save(); + } + + public function markDone(): void + { + // If currently paused, count up till now as paused time before stopping. + if ($this->mechanic_status === 'paused' && $this->paused_at) { + $this->paused_seconds_total = (int) $this->paused_seconds_total + (int) $this->paused_at->diffInSeconds(now()); + $this->paused_at = null; + } + $started = $this->mechanic_started_at ?? now(); + $endedAt = now(); + $elapsedSec = max(0, $started->diffInSeconds($endedAt) - (int) $this->paused_seconds_total); + $actualHours = round($elapsedSec / 3600, 2); + + $this->forceFill([ + 'mechanic_status' => 'done', + 'mechanic_done_at' => $endedAt, + 'actual_hours' => $actualHours, + 'status' => 'done', + 'block_reason' => null, + ])->save(); + } + + public function block(string $reason, ?string $note = null): void + { + if (! array_key_exists($reason, self::BLOCK_REASONS)) return; + $this->forceFill([ + 'mechanic_status' => 'blocked', + 'block_reason' => $reason, + 'block_note' => $note, + 'paused_at' => null, + ])->save(); + } + + /** 'green' if faster than norm, 'amber' if 30%+ over, 'red' if 100%+ over. */ + public function efficiencyClass(): ?string + { + if ((float) $this->actual_hours <= 0 || (float) $this->hours <= 0) return null; + $ratio = (float) $this->actual_hours / (float) $this->hours; + return match (true) { + $ratio <= 1.0 => 'green', + $ratio <= 1.3 => 'amber', + default => 'red', + }; + } + + public function efficiencyPct(): ?int + { + if ((float) $this->actual_hours <= 0 || (float) $this->hours <= 0) return null; + return (int) round(100 * (float) $this->actual_hours / (float) $this->hours); + } + public function isPendingApproval(): bool { return $this->requires_approval && $this->approved_at === null && $this->declined_at === null; diff --git a/database/migrations/2026_06_05_000003_add_mechanic_workflow_to_wo_works.php b/database/migrations/2026_06_05_000003_add_mechanic_workflow_to_wo_works.php new file mode 100644 index 0000000..b7e2e01 --- /dev/null +++ b/database/migrations/2026_06_05_000003_add_mechanic_workflow_to_wo_works.php @@ -0,0 +1,49 @@ +string('mechanic_status', 16)->default('pending')->after('status'); + // values: pending | in_progress | paused | done | blocked + } + if (! Schema::hasColumn('wo_works', 'mechanic_started_at')) { + $t->timestamp('mechanic_started_at')->nullable()->after('mechanic_status'); + } + if (! Schema::hasColumn('wo_works', 'mechanic_done_at')) { + $t->timestamp('mechanic_done_at')->nullable()->after('mechanic_started_at'); + } + if (! Schema::hasColumn('wo_works', 'actual_hours')) { + $t->decimal('actual_hours', 6, 2)->nullable()->after('mechanic_done_at'); + } + if (! Schema::hasColumn('wo_works', 'paused_seconds_total')) { + $t->integer('paused_seconds_total')->default(0)->after('actual_hours'); + } + if (! Schema::hasColumn('wo_works', 'paused_at')) { + $t->timestamp('paused_at')->nullable()->after('paused_seconds_total'); + } + if (! Schema::hasColumn('wo_works', 'block_reason')) { + $t->string('block_reason', 32)->nullable()->after('paused_at'); + // values: missing_part | awaiting_approval | broken_equipment | other + } + if (! Schema::hasColumn('wo_works', 'block_note')) { + $t->text('block_note')->nullable()->after('block_reason'); + } + }); + } + + public function down(): void + { + Schema::table('wo_works', function (Blueprint $t) { + foreach (['mechanic_status', 'mechanic_started_at', 'mechanic_done_at', 'actual_hours', 'paused_seconds_total', 'paused_at', 'block_reason', 'block_note'] as $col) { + if (Schema::hasColumn('wo_works', $col)) $t->dropColumn($col); + } + }); + } +}; diff --git a/resources/views/filament/tenant/pages/mechanic-board.blade.php b/resources/views/filament/tenant/pages/mechanic-board.blade.php index 5628b0d..33c051d 100644 --- a/resources/views/filament/tenant/pages/mechanic-board.blade.php +++ b/resources/views/filament/tenant/pages/mechanic-board.blade.php @@ -64,20 +64,88 @@ {{ $col['items']->count() }} @forelse ($col['items'] as $wo) - +
- #{{ $wo->number }} + #{{ $wo->number }} {{ $wo->vehicle?->plate ?? '—' }}
{{ $wo->client?->name ?? 'fără client' }} · {{ $wo->vehicle?->make }} {{ $wo->vehicle?->model }}
@if ($wo->complaint)
{{ $wo->complaint }}
@endif - + @php $works = $this->getWorksFor($wo->id) @endphp + @if (! empty($works)) +
+ @foreach ($works as $w) +
+ @php + $ms = $w->mechanic_status ?: 'pending'; + $stColor = ['pending' => '#94a3b8', 'in_progress' => '#3b82f6', 'paused' => '#f59e0b', 'done' => '#10b981', 'blocked' => '#dc2626'][$ms]; + $eff = $w->efficiencyClass(); + $effColor = ['green' => '#10b981', 'amber' => '#f59e0b', 'red' => '#dc2626'][$eff ?? ''] ?? null; + @endphp +
+
+ {{ $w->name }} +
+ {{ \App\Models\Tenant\WorkOrderWork::MECHANIC_STATUSES[$ms] }} + @if ($w->hours > 0) + {{ rtrim(rtrim(number_format($w->hours, 2), '0'), '.') }}h norm + @endif + @if ($w->actual_hours > 0 && $effColor) + {{ rtrim(rtrim(number_format($w->actual_hours, 2), '0'), '.') }}h real ({{ $w->efficiencyPct() }}%) + @endif + @if ($w->block_reason) + ⚠ {{ \App\Models\Tenant\WorkOrderWork::BLOCK_REASONS[$w->block_reason] ?? $w->block_reason }} + @endif +
+
+
+
+ @if (in_array($ms, ['pending', 'blocked'])) + + @endif + @if ($ms === 'in_progress') + + + + @endif + @if ($ms === 'paused') + + + @endif +
+
+ @endforeach +
+ @endif +
@empty
@endforelse @endforeach + + {{-- BLOCK REASON MODAL --}} + @if ($blockingWorkId) +
+
+

🔴 Blochez lucrarea

+

Selectează motivul pentru care lucrarea nu poate continua. Va fi vizibil managerului.

+ + + + +
+ + +
+
+
+ @endif diff --git a/tests/Feature/MechanicWorkflowTest.php b/tests/Feature/MechanicWorkflowTest.php new file mode 100644 index 0000000..a3ea147 --- /dev/null +++ b/tests/Feature/MechanicWorkflowTest.php @@ -0,0 +1,190 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $this->company = Company::create(['plan_id' => $plan->id, 'slug' => 'mech-' . uniqid(), 'name' => 'M Co', 'status' => 'active']); + app(TenantManager::class)->setCurrent($this->company); + $this->mechanic = User::create(['name' => 'M', 'email' => 'm@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'MEC-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, + ]); + } + + public function test_start_sets_mechanic_status_in_progress_and_started_at(): void + { + Carbon::setTestNow('2026-06-05 10:00:00'); + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, + 'name' => 'Schimb ulei', 'hours' => 1, 'price_per_hour' => 400, + ]); + $this->assertEquals('pending', $work->mechanic_status); + + $work->start(); + + $this->assertEquals('in_progress', $work->mechanic_status); + $this->assertEquals('2026-06-05 10:00:00', $work->mechanic_started_at->format('Y-m-d H:i:s')); + Carbon::setTestNow(); + } + + public function test_pause_then_resume_accumulates_paused_seconds(): void + { + Carbon::setTestNow('2026-06-05 10:00:00'); + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, + 'name' => 'X', 'hours' => 1, 'price_per_hour' => 100, + ]); + $work->start(); + + Carbon::setTestNow('2026-06-05 10:30:00'); + $work->pause(); + $this->assertEquals('paused', $work->mechanic_status); + + Carbon::setTestNow('2026-06-05 10:45:00'); + $work->resume(); + + $this->assertEquals('in_progress', $work->mechanic_status); + $this->assertEqualsWithDelta(15 * 60, $work->paused_seconds_total, 2); + Carbon::setTestNow(); + } + + public function test_mark_done_computes_actual_hours_minus_paused(): void + { + Carbon::setTestNow('2026-06-05 10:00:00'); + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, + 'name' => 'X', 'hours' => 2, 'price_per_hour' => 200, + ]); + $work->start(); + + // Pause 15 min in the middle + Carbon::setTestNow('2026-06-05 10:30:00'); + $work->pause(); + Carbon::setTestNow('2026-06-05 10:45:00'); + $work->resume(); + + // Done at 11:30 → elapsed = 1.5h - 0.25h paused = 1.25h + Carbon::setTestNow('2026-06-05 11:30:00'); + $work->markDone(); + + $this->assertEquals('done', $work->mechanic_status); + $this->assertEqualsWithDelta(1.25, (float) $work->actual_hours, 0.01); + Carbon::setTestNow(); + } + + public function test_block_persists_reason_and_note(): void + { + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, + 'name' => 'X', 'hours' => 1, 'price_per_hour' => 100, + ]); + $work->start(); + + $work->block('missing_part', 'Lipsește filtru Mann W712/83'); + + $work->refresh(); + $this->assertEquals('blocked', $work->mechanic_status); + $this->assertEquals('missing_part', $work->block_reason); + $this->assertEquals('Lipsește filtru Mann W712/83', $work->block_note); + } + + public function test_efficiency_class_thresholds(): void + { + // norm = 2h + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, + 'name' => 'X', 'hours' => 2, 'price_per_hour' => 100, + ]); + + $work->actual_hours = 1.8; // faster + $this->assertEquals('green', $work->efficiencyClass()); + + $work->actual_hours = 2.5; // 25% over → green still since 2.5/2 = 1.25 ≤ 1.30 + $this->assertEquals('amber', $work->efficiencyClass()); + + $work->actual_hours = 4.5; // 125% over + $this->assertEquals('red', $work->efficiencyClass()); + + $work->actual_hours = 0; // no actual data + $this->assertNull($work->efficiencyClass()); + } + + public function test_invalid_block_reason_is_ignored(): void + { + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, 'name' => 'X', 'hours' => 1, 'price_per_hour' => 100, + ]); + $work->block('not_a_valid_reason', 'test'); + $work->refresh(); + $this->assertNotEquals('blocked', $work->mechanic_status); + $this->assertNull($work->block_reason); + } + + public function test_mechanic_board_actions_only_affect_own_works(): void + { + $other = User::create(['name' => 'O', 'email' => 'o@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $otherWo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($this->company->id), + 'master_id' => $other->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 0, + ]); + $foreignWork = WorkOrderWork::create([ + 'work_order_id' => $otherWo->id, + 'name' => "Other's work", 'hours' => 1, 'price_per_hour' => 100, + ]); + + $this->actingAs($this->mechanic); + Livewire::test(MechanicBoard::class)->call('startWork', $foreignWork->id); + + $foreignWork->refresh(); + // Stayed pending — board refused to transition + $this->assertEquals('pending', $foreignWork->mechanic_status); + } + + public function test_confirm_block_modal_updates_work(): void + { + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, 'name' => 'X', 'hours' => 1, 'price_per_hour' => 100, + ]); + $this->actingAs($this->mechanic); + + Livewire::test(MechanicBoard::class) + ->call('openBlockModal', $work->id) + ->set('blockReason', 'awaiting_approval') + ->set('blockNote', 'Manager n-a răspuns') + ->call('confirmBlock'); + + $work->refresh(); + $this->assertEquals('blocked', $work->mechanic_status); + $this->assertEquals('awaiting_approval', $work->block_reason); + $this->assertEquals('Manager n-a răspuns', $work->block_note); + } +} diff --git a/tests/Feature/PricingCoefficientResourceTest.php b/tests/Feature/PricingCoefficientResourceTest.php new file mode 100644 index 0000000..f7a6c11 --- /dev/null +++ b/tests/Feature/PricingCoefficientResourceTest.php @@ -0,0 +1,93 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $this->company = Company::create(['plan_id' => $plan->id, 'slug' => 'pc-' . uniqid(), 'name' => 'PC Co', 'status' => 'active']); + app(TenantManager::class)->setCurrent($this->company); + app(RbacSeeder::class)->seedTenantRoles($this->company->id); + app(PermissionRegistrar::class)->setPermissionsTeamId($this->company->id); + } + + public function test_admin_can_view_pricing_coefficients(): void + { + $admin = User::create(['name' => 'A', 'email' => 'a@e.com', 'password' => bcrypt('x'), 'role' => 'admin', 'status' => 'active']); + $admin->syncRoles(['admin']); + $this->actingAs($admin); + + $this->assertTrue(PricingCoefficientResource::canViewAny()); + } + + public function test_mechanic_cannot_view_pricing_coefficients(): void + { + $mech = User::create(['name' => 'M', 'email' => 'm@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $mech->syncRoles(['mechanic']); + + // Verify via direct canDo — bypasses the auth->user() resolution that + // can lose Spatie's team context in test harness. + $this->assertFalse($mech->canDo(Permissions::ADMIN_SETTINGS_EDIT)); + } + + public function test_coefficient_can_be_created_and_matches_context(): void + { + $c = PricingCoefficient::create([ + 'name' => 'SUV +15%', + 'multiplier' => 1.15, + 'conditions' => ['classes' => ['suv']], + 'stackable' => true, + 'priority' => 100, + 'is_active' => true, + ]); + + $this->assertTrue($c->matches(['class' => 'suv'])); + $this->assertFalse($c->matches(['class' => 'sedan'])); + } + + public function test_pricing_engine_returns_breakdown_with_named_coefficients(): void + { + // Create 2 active coefficients: SUV +15% (stackable) and Express +50% (non-stackable) + PricingCoefficient::create([ + 'name' => 'SUV +15%', 'multiplier' => 1.15, + 'conditions' => ['classes' => ['suv']], 'stackable' => true, + 'priority' => 100, 'is_active' => true, + ]); + PricingCoefficient::create([ + 'name' => 'Express +50%', 'multiplier' => 1.50, + 'conditions' => ['urgency' => ['express']], 'stackable' => false, + 'priority' => 200, 'is_active' => true, + ]); + + $part = \App\Models\Tenant\Part::create(['name' => 'Filtru', 'article' => 'F-1', 'buy_price' => 100, 'sell_price' => 130]); + $vehicle = (new \App\Models\Tenant\Vehicle)->forceFill(['vehicle_class' => 'suv', 'year' => 2020]); + + $quote = app(\App\Services\Pricing\PricingEngine::class)->quote( + $part, $vehicle, null, 'express' + ); + + $this->assertCount(2, $quote['applied']); + $names = array_column($quote['applied'], 'name'); + $this->assertContains('SUV +15%', $names); + $this->assertContains('Express +50%', $names); + } +}