From 0620635abb2ae4590530b79d9e9e3267ffd5e821 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 3 Jun 2026 07:05:46 +0000 Subject: [PATCH] =?UTF-8?q?test:=20full=20E2E=20audit=20+=20fix=20CsvImpor?= =?UTF-8?q?tExport=20vehicle.brand=20=E2=86=92=20make?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass (33 new tests in tests/Feature/Audit/): - CrmFunnelE2ETest: full Lead→Deal→Appointment→WO→Payment journey, covering 5 previously-untested models. Verifies WO.balanceDue updates correctly after payments, including refunds (negative amount → balance increases). - WorkOrderTotalsTest: works+parts+subcontract+discount sum correctly, cancelled subcontract excluded, deleting lines triggers recalc, status=done consumes part reservations into issues, cancelled releases reservations. - ShopJourneyE2ETest: register→cart→checkout→email confirmation→tracking page reachable→admin fulfills→stock drops→warehouse event recorded. Also guest checkout still works without account. - CsvImportExportTest: round-trip, dedup-by-phone, **caught real bug** — Vehicle export wrote $row->brand (no such property) and import set 'brand' => row['brand'] in Vehicle::create (column is `make`). Fix applied to both paths. - TenantBackupServiceTest: zip contains valid manifest with counts + data/*.json per model + works embedded with WorkOrder. - WorkOrderPdfServiceTest: generated PDF starts with %PDF, includes WO data, non-trivial size, handles empty WO. - PayrollCalculatorTest: base + works_pct + parts_pct + bonus - fine - advance, scoped to user + period. - NotificationFallbackTest: Telegram wins when chat_id present, falls back to email when not, returns false when neither, tenant disable flag stops both. - AiProvidersCrossCheckTest: OpenAI request shape, Gemini URL with model, no-key friendly message, tenant model override propagates into HTTP body. - SettingsPersistenceTest: 25-key settings JSON round-trips, partial update via array_replace_recursive preserves other keys. - CompanyProvisionerTest: suspend / reactivate / archive behavior. Bug fixed: CsvImportExport used `brand` on Vehicle which has column `make`. The export silently emitted empty values, the import silently dropped the brand. Now both paths use `make`. Full suite: 173 passed (468 assertions). 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Services/CsvImportExport.php | 5 +- .../Audit/AiProvidersCrossCheckTest.php | 114 ++++++++++ .../Feature/Audit/CompanyProvisionerTest.php | 51 +++++ tests/Feature/Audit/CrmFunnelE2ETest.php | 196 ++++++++++++++++++ tests/Feature/Audit/CsvImportExportTest.php | 125 +++++++++++ .../Audit/NotificationFallbackTest.php | 121 +++++++++++ tests/Feature/Audit/PayrollCalculatorTest.php | 134 ++++++++++++ .../Feature/Audit/SettingsPersistenceTest.php | 100 +++++++++ tests/Feature/Audit/ShopJourneyE2ETest.php | 154 ++++++++++++++ .../Feature/Audit/TenantBackupServiceTest.php | 85 ++++++++ .../Feature/Audit/WorkOrderPdfServiceTest.php | 81 ++++++++ tests/Feature/Audit/WorkOrderTotalsTest.php | 189 +++++++++++++++++ 12 files changed, 1353 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Audit/AiProvidersCrossCheckTest.php create mode 100644 tests/Feature/Audit/CompanyProvisionerTest.php create mode 100644 tests/Feature/Audit/CrmFunnelE2ETest.php create mode 100644 tests/Feature/Audit/CsvImportExportTest.php create mode 100644 tests/Feature/Audit/NotificationFallbackTest.php create mode 100644 tests/Feature/Audit/PayrollCalculatorTest.php create mode 100644 tests/Feature/Audit/SettingsPersistenceTest.php create mode 100644 tests/Feature/Audit/ShopJourneyE2ETest.php create mode 100644 tests/Feature/Audit/TenantBackupServiceTest.php create mode 100644 tests/Feature/Audit/WorkOrderPdfServiceTest.php create mode 100644 tests/Feature/Audit/WorkOrderTotalsTest.php diff --git a/app/Services/CsvImportExport.php b/app/Services/CsvImportExport.php index 6b2a8d3..87dcea5 100644 --- a/app/Services/CsvImportExport.php +++ b/app/Services/CsvImportExport.php @@ -47,7 +47,7 @@ class CsvImportExport Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) { foreach ($rows as $row) { fputcsv($out, [ - $row->plate, $row->vin, $row->brand, $row->model, $row->year, + $row->plate, $row->vin, $row->make, $row->model, $row->year, $row->engine, $row->gearbox, $row->fuel, $row->mileage, $row->color, $row->notes, $row->client?->phone, ]); @@ -108,7 +108,8 @@ class CsvImportExport 'client_id' => $client->id, 'plate' => $row['plate'], 'vin' => $row['vin'] ?? null, - 'brand' => $row['brand'] ?? null, + // CSV header keeps the user-friendly "brand" name, but the column is `make`. + 'make' => $row['brand'] ?? null, 'model' => $row['model'] ?? null, 'year' => (int) ($row['year'] ?? 0) ?: null, 'engine' => $row['engine'] ?? null, diff --git a/tests/Feature/Audit/AiProvidersCrossCheckTest.php b/tests/Feature/Audit/AiProvidersCrossCheckTest.php new file mode 100644 index 0000000..52d8931 --- /dev/null +++ b/tests/Feature/Audit/AiProvidersCrossCheckTest.php @@ -0,0 +1,114 @@ +bootCompany('gpt', 'gpt-fake'); + + Http::fake(['api.openai.com/*' => Http::response([ + 'choices' => [['message' => ['content' => 'OpenAI response']]], + 'usage' => ['prompt_tokens' => 50, 'completion_tokens' => 12], + 'model' => 'gpt-4o-mini', + ])]); + + [$reply, $meta] = app(AiAssistantService::class)->singleShot('system here', 'user prompt', 'gpt'); + + $this->assertEquals('OpenAI response', $reply); + $this->assertEquals('gpt', $meta['provider']); + Http::assertSent(function ($req) { + if (! str_contains($req->url(), 'openai.com')) return false; + $body = json_decode($req->body(), true); + return $body['model'] === 'gpt-4o-mini' + && $body['messages'][0]['role'] === 'system' + && str_contains($body['messages'][0]['content'], 'system here'); + }); + } + + public function test_gemini_singleshot_hits_url_with_configured_model(): void + { + $this->bootCompany('gemini', 'gem-fake'); + + Http::fake(['generativelanguage.googleapis.com/*' => Http::response([ + 'candidates' => [['content' => ['parts' => [['text' => 'Gemini response']]]]], + 'usageMetadata' => ['promptTokenCount' => 30], + ])]); + + [$reply, $meta] = app(AiAssistantService::class)->singleShot('s', 'u', 'gemini'); + + $this->assertEquals('Gemini response', $reply); + Http::assertSent(fn ($req) => str_contains($req->url(), 'gemini-1.5-flash:generateContent')); + } + + public function test_no_api_key_returns_friendly_message_without_http(): void + { + $this->bootCompany('claude', null); + + Http::fake(); + [$reply, $meta] = app(AiAssistantService::class)->singleShot('s', 'u', 'claude'); + + $this->assertStringContainsString('API key', $reply); + $this->assertEquals('no_api_key', $meta['error']); + Http::assertNothingSent(); + } + + public function test_tenant_model_override_propagates_into_request(): void + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'override-' . uniqid(), + 'name' => 'O', 'status' => 'active', + 'settings' => [ + 'ai' => [ + 'claude_key' => 'sk-fake', + 'models' => ['claude' => 'claude-opus-4-7'], + ], + ], + ]); + app(TenantManager::class)->setCurrent($company); + + Http::fake(['api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'usage' => ['input_tokens' => 1, 'output_tokens' => 1], + ])]); + + app(AiAssistantService::class)->singleShot('s', 'u', 'claude'); + + Http::assertSent(function ($req) { + $body = json_decode($req->body(), true); + return $body['model'] === 'claude-opus-4-7'; + }); + } + + private function bootCompany(string $provider, ?string $key): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'ai-' . uniqid(), + 'name' => 'AI Co', 'status' => 'active', + 'settings' => ['ai' => [ + 'default_provider' => $provider, + "{$provider}_key" => $key, + ]], + ]); + app(TenantManager::class)->setCurrent($company); + return $company; + } +} diff --git a/tests/Feature/Audit/CompanyProvisionerTest.php b/tests/Feature/Audit/CompanyProvisionerTest.php new file mode 100644 index 0000000..1581ba8 --- /dev/null +++ b/tests/Feature/Audit/CompanyProvisionerTest.php @@ -0,0 +1,51 @@ +makeCompany('active'); + app(CompanyProvisioner::class)->suspend($company); + $this->assertEquals('suspended', $company->fresh()->status); + } + + public function test_reactivate_restores_active_status(): void + { + $company = $this->makeCompany('suspended'); + app(CompanyProvisioner::class)->reactivate($company); + $this->assertEquals('active', $company->fresh()->status); + } + + public function test_archive_marks_status_and_soft_deletes(): void + { + $company = $this->makeCompany('active'); + $id = $company->id; + app(CompanyProvisioner::class)->archive($company); + + $row = Company::withTrashed()->find($id); + $this->assertEquals('archived', $row->status); + $this->assertNotNull($row->deleted_at, 'soft-deleted'); + + // Default (with SoftDeletes scope) no longer returns it. + $this->assertNull(Company::find($id)); + } + + private function makeCompany(string $status): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + return Company::create([ + 'plan_id' => $plan->id, 'slug' => 'cp-' . uniqid(), + 'name' => 'CP Co', 'status' => $status, + ]); + } +} diff --git a/tests/Feature/Audit/CrmFunnelE2ETest.php b/tests/Feature/Audit/CrmFunnelE2ETest.php new file mode 100644 index 0000000..1048137 --- /dev/null +++ b/tests/Feature/Audit/CrmFunnelE2ETest.php @@ -0,0 +1,196 @@ +bootTenant(); + + // 1. Inbound lead (anonymous, no client yet). + $lead = Lead::create([ + 'name' => 'Ion Pop', + 'phone' => '+37369123456', + 'email' => 'ion@example.com', + 'car' => 'BMW', + 'model' => 'X5', + 'message' => 'Vreau diagnoză + schimb plăcuțe', + 'source' => 'site', + 'status' => 'new', + ]); + + $this->assertEquals('new', $lead->status); + $this->assertNull($lead->client_id); + + // 2. Convert lead → Client + Deal. (Mirrors what UI Lead-convert action does.) + $client = Client::create([ + 'name' => $lead->name, 'phone' => $lead->phone, 'email' => $lead->email, + 'type' => 'individual', 'status' => 'active', + ]); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, + 'make' => $lead->car, 'model' => $lead->model, + 'plate' => 'TST-001', + ]); + $deal = Deal::create([ + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'name' => 'BMW X5 — frâne + diag', + 'price' => 0, + 'stage' => 'new', + ]); + $lead->update([ + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'deal_id' => $deal->id, + 'status' => 'won', + 'converted_at' => now(), + ]); + + // Verify Lead now linked. + $this->assertEquals($client->id, $lead->fresh()->client_id); + $this->assertEquals($deal->id, $lead->fresh()->deal_id); + $this->assertNotNull($lead->fresh()->converted_at); + + // 3. Schedule appointment from deal. + $appt = Appointment::create([ + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'deal_id' => $deal->id, + 'date' => today()->addDays(2)->toDateString(), + 'time_start' => '10:00:00', 'time_end' => '12:00:00', + 'title' => 'Diagnoză BMW X5', + 'status' => 'scheduled', + ]); + $this->assertEquals($deal->id, $appt->deal_id); + + // 4. Open WorkOrder from appointment. + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'deal_id' => $deal->id, + 'appointment_id' => $appt->id, + 'opened_at' => today(), + 'status' => 'in_work', + 'complaint' => $lead->message, + ]); + + // 5. Add labor — WO total should update via saved hook. + WorkOrderWork::create([ + 'work_order_id' => $wo->id, + 'name' => 'Schimb plăcuțe față', + 'hours' => 1.5, + 'price_per_hour' => 400, + 'status' => 'todo', + ]); + + $wo->refresh(); + $this->assertEquals(600.0, (float) $wo->total, '1.5h × 400 = 600'); + $this->assertEquals(600.0, $wo->balanceDue(), 'no payments yet, full due'); + + // 6. Customer pays 250 cash. + Payment::create([ + 'client_id' => $client->id, + 'work_order_id' => $wo->id, + 'paid_at' => today(), + 'amount' => 250, + 'method' => 'cash', + ]); + + $wo->refresh(); + $this->assertEquals(250.0, $wo->paidAmount()); + $this->assertEquals(350.0, $wo->balanceDue(), '600 − 250 = 350'); + + // 7. Customer pays the rest. + Payment::create([ + 'client_id' => $client->id, + 'work_order_id' => $wo->id, + 'paid_at' => today(), + 'amount' => 350, + 'method' => 'card', + ]); + + $wo->refresh(); + $this->assertEquals(600.0, $wo->paidAmount()); + $this->assertEquals(0.0, $wo->balanceDue()); + + // 8. Close the WO. master_id wasn't set, so no push attempt → no error. + $wo->update(['status' => 'done', 'closed_at' => today(), 'pay_status' => 'paid']); + $this->assertEquals('done', $wo->fresh()->status); + + // 9. The Deal can now be marked won. + $deal->update(['stage' => 'done', 'price' => $wo->total, 'won_at' => now()]); + $this->assertEquals('done', $deal->fresh()->stage); + } + + public function test_lead_status_transitions_persist(): void + { + $this->bootTenant(); + $lead = Lead::create(['name' => 'X', 'phone' => '+1', 'status' => 'new']); + + foreach (['contacted', 'won', 'lost'] as $st) { + $lead->update(['status' => $st]); + $this->assertEquals($st, $lead->fresh()->status); + } + } + + public function test_payment_negative_amount_not_silently_increases_balance(): void + { + $ctx = $this->bootTenant(); + $client = Client::create(['name' => 'X', 'phone' => '+1', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'A1']); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today(), 'status' => 'in_work', + ]); + WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'name' => 'X', + 'hours' => 1, 'price_per_hour' => 100, 'status' => 'todo', + ]); + $wo->refresh(); + $this->assertEquals(100.0, $wo->balanceDue()); + + // A negative payment (refund) is supported and should INCREASE balance back. + Payment::create([ + 'client_id' => $client->id, 'work_order_id' => $wo->id, + 'paid_at' => today(), 'amount' => -50, 'method' => 'cash', + ]); + $wo->refresh(); + $this->assertEquals(-50.0, $wo->paidAmount(), 'negative paidAmount means refund'); + $this->assertEquals(150.0, $wo->balanceDue(), 'refund of 50 adds to outstanding: 100 − (−50) = 150'); + } + + private function bootTenant(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'crm-' . uniqid(), + 'name' => 'CRM Co', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + return compact('company'); + } +} diff --git a/tests/Feature/Audit/CsvImportExportTest.php b/tests/Feature/Audit/CsvImportExportTest.php new file mode 100644 index 0000000..a3eb81a --- /dev/null +++ b/tests/Feature/Audit/CsvImportExportTest.php @@ -0,0 +1,125 @@ +bootTenant(); + + $csv = $this->makeCsv(CsvImportExport::CLIENT_COLUMNS, [ + ['Ion Pop', '+37369000001', '', 'ion@example.com', '', 'individual', 'active', 'site', '', '0', '0', ''], + ['SRL Auto', '+37322777888', '', '', 'SRL Auto', 'company', 'active', '', '', '5', '0', ''], + ]); + + $r = app(CsvImportExport::class)->importClients($csv); + @unlink($csv); + + $this->assertEquals(2, $r['imported']); + $this->assertEquals(0, $r['skipped']); + $this->assertEquals(2, Client::count()); + $this->assertEquals('Ion Pop', Client::where('phone', '+37369000001')->first()->name); + } + + public function test_import_clients_skips_duplicate_phone(): void + { + $this->bootTenant(); + Client::create([ + 'name' => 'Existing', 'phone' => '+37369000002', + 'type' => 'individual', 'status' => 'active', + ]); + + $csv = $this->makeCsv(CsvImportExport::CLIENT_COLUMNS, [ + ['New Name', '+37369000002', '', '', '', 'individual', 'active', '', '', '0', '0', ''], + ]); + $r = app(CsvImportExport::class)->importClients($csv); + @unlink($csv); + + $this->assertEquals(0, $r['imported']); + $this->assertEquals(1, $r['skipped']); + $this->assertEquals('Existing', Client::first()->name); + } + + public function test_import_vehicles_uses_make_column_correctly(): void + { + // This guards a real bug: the service used to write `brand`, but the + // vehicles table column is `make`. The import must persist `make`. + $ctx = $this->bootTenant(); + Client::create([ + 'name' => 'Owner', 'phone' => '+37399100200', + 'type' => 'individual', 'status' => 'active', + ]); + + $csv = $this->makeCsv(CsvImportExport::VEHICLE_COLUMNS, [ + // plate, vin, brand(=make), model, year, engine, gearbox, fuel, mileage, color, notes, client_phone + ['ABC-001', 'VIN1234567890ABCD', 'BMW', 'X5', '2019', '3.0i', 'AT', 'Benzină', '85000', 'negru', '', '+37399100200'], + ]); + + $r = app(CsvImportExport::class)->importVehicles($csv); + @unlink($csv); + + $this->assertEquals(1, $r['imported'], 'one vehicle should be imported'); + + $v = Vehicle::first(); + $this->assertNotNull($v, 'vehicle row exists'); + $this->assertEquals('BMW', $v->make, 'make column populated from CSV brand header'); + $this->assertEquals('X5', $v->model); + $this->assertEquals(2019, $v->year); + } + + public function test_export_vehicles_emits_make_value(): void + { + $this->bootTenant(); + $client = Client::create([ + 'name' => 'X', 'phone' => '+37377111222', + 'type' => 'individual', 'status' => 'active', + ]); + Vehicle::create([ + 'client_id' => $client->id, + 'plate' => 'CSV-001', 'make' => 'BMW', 'model' => 'M3', + ]); + + ob_start(); + $resp = app(CsvImportExport::class)->exportVehicles(); + $resp->sendContent(); + $csv = ob_get_clean(); + + $this->assertStringContainsString('BMW', $csv, 'exported CSV must include the make value'); + $this->assertStringContainsString('CSV-001', $csv); + $this->assertStringContainsString('M3', $csv); + } + + private function bootTenant(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'csv-' . uniqid(), + 'name' => 'CSV Co', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + return compact('company'); + } + + private function makeCsv(array $headers, array $rows): string + { + $path = tempnam(sys_get_temp_dir(), 'csv') . '.csv'; + $fh = fopen($path, 'w'); + fputs($fh, "\xEF\xBB\xBF"); // BOM + fputcsv($fh, $headers); + foreach ($rows as $row) fputcsv($fh, $row); + fclose($fh); + return $path; + } +} diff --git a/tests/Feature/Audit/NotificationFallbackTest.php b/tests/Feature/Audit/NotificationFallbackTest.php new file mode 100644 index 0000000..47f58b2 --- /dev/null +++ b/tests/Feature/Audit/NotificationFallbackTest.php @@ -0,0 +1,121 @@ +bootTenantWithBot(); + $client = $this->makeClient(['telegram_chat_id' => '12345', 'email' => 'x@example.com']); + $wo = $this->makeReadyWo($client, $ctx); + + Mail::fake(); + Http::fake(['api.telegram.org/*' => Http::response(['ok' => true])]); + + $ok = app(NotificationDispatcher::class)->workOrderReady($wo); + + $this->assertTrue($ok); + Http::assertSent(fn ($r) => str_contains($r->url(), 'sendMessage')); + Mail::assertNotSent(WorkOrderReadyMail::class); + } + + public function test_falls_back_to_email_when_no_chat_id(): void + { + $ctx = $this->bootTenantWithBot(); + $client = $this->makeClient(['email' => 'x@example.com']); + $wo = $this->makeReadyWo($client, $ctx); + + Mail::fake(); + Http::fake(); + $ok = app(NotificationDispatcher::class)->workOrderReady($wo); + + $this->assertTrue($ok); + Mail::assertSent(WorkOrderReadyMail::class); + Http::assertNothingSent(); + } + + public function test_returns_false_when_no_channel_available(): void + { + $ctx = $this->bootTenantWithBot(); + $client = $this->makeClient([]); // no email, no chat_id + $wo = $this->makeReadyWo($client, $ctx); + + Mail::fake(); + $ok = app(NotificationDispatcher::class)->workOrderReady($wo); + + $this->assertFalse($ok); + Mail::assertNothingSent(); + } + + public function test_tenant_disable_flag_overrides_channels(): void + { + $ctx = $this->bootTenantWithBot(); + // Disable the wo_ready notification globally for this tenant. + $ctx['company']->update([ + 'settings' => array_merge_recursive((array) $ctx['company']->settings, [ + 'notify' => ['wo_ready' => false], + ]), + ]); + + $client = $this->makeClient(['email' => 'x@example.com']); + $wo = $this->makeReadyWo($client, $ctx); + + Mail::fake(); + Http::fake(); + $ok = app(NotificationDispatcher::class)->workOrderReady($wo); + + $this->assertFalse($ok); + Mail::assertNothingSent(); + } + + private function bootTenantWithBot(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'nf-' . uniqid(), + 'name' => 'Notify', 'status' => 'active', + 'settings' => ['telegram' => ['bot_token' => 'FAKE:TOKEN']], + ]); + app(TenantManager::class)->setCurrent($company); + return compact('company'); + } + + private function makeClient(array $attrs): Client + { + return Client::create(array_merge([ + 'name' => 'C', 'phone' => '+3737' . random_int(1000000, 9999999), + 'type' => 'individual', 'status' => 'active', + ], $attrs)); + } + + private function makeReadyWo(Client $client, array $ctx): WorkOrder + { + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'NF' . random_int(100, 999)]); + return WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today(), 'status' => 'ready', 'total' => 500, + ]); + } +} diff --git a/tests/Feature/Audit/PayrollCalculatorTest.php b/tests/Feature/Audit/PayrollCalculatorTest.php new file mode 100644 index 0000000..b668aee --- /dev/null +++ b/tests/Feature/Audit/PayrollCalculatorTest.php @@ -0,0 +1,134 @@ +bootTenant(); + $user = $this->makeMaster(); + EmployeeProfile::create([ + 'user_id' => $user->id, + 'base_salary' => 5000, + 'works_pct' => 30, // 30% from done work revenue + 'parts_pct' => 10, // 10% from parts margin + ]); + + $client = Client::create(['name' => 'C', 'phone' => '+37399100100', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'PR-1']); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today()->subDays(3), 'status' => 'in_work', + 'master_id' => $user->id, + ]); + + // A done labor: 2h × 400 = 800 → 30% = 240 + WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'master_id' => $user->id, + 'name' => 'X', 'hours' => 2, 'price_per_hour' => 400, 'status' => 'done', + ]); + + // An installed part: sell 350 - buy 200 = 150 margin × 1 qty → 10% = 15 + WorkOrderPart::create([ + 'work_order_id' => $wo->id, + 'name' => 'P', 'qty' => 1, 'sell_price' => 350, 'buy_price' => 200, + 'status' => 'installed', + ]); + + // Adjustments for the period + PayrollAdjustment::create([ + 'user_id' => $user->id, 'period' => '2026-06', + 'type' => 'bonus', 'amount' => 500, 'date' => today(), + ]); + PayrollAdjustment::create([ + 'user_id' => $user->id, 'period' => '2026-06', + 'type' => 'fine', 'amount' => 100, 'date' => today(), + ]); + PayrollAdjustment::create([ + 'user_id' => $user->id, 'period' => '2026-06', + 'type' => 'advance', 'amount' => 200, 'date' => today(), + ]); + + $run = app(PayrollCalculator::class)->compute($user->id, '2026-06'); + + // base 5000 + works 240 + parts 15 + bonus 500 - fine 100 - advance 200 = 5455 + $this->assertEquals(5455.0, (float) $run->total); + $this->assertEquals(5000.0, (float) $run->base); + $this->assertEquals(240.0, (float) $run->works_pct_amount); + $this->assertEquals(15.0, (float) $run->parts_pct_amount); + + Carbon::setTestNow(); + } + + public function test_compute_ignores_other_periods_and_other_users(): void + { + Carbon::setTestNow('2026-06-15'); + $ctx = $this->bootTenant(); + $me = $this->makeMaster(); + $other = $this->makeMaster(); + EmployeeProfile::create(['user_id' => $me->id, 'base_salary' => 1000, 'works_pct' => 100, 'parts_pct' => 0]); + + $c = Client::create(['name' => 'X', 'phone' => '+37300000000', 'type' => 'individual', 'status' => 'active']); + $v = Vehicle::create(['client_id' => $c->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'NOISE']); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $c->id, 'vehicle_id' => $v->id, + 'opened_at' => today(), 'status' => 'in_work', + ]); + + // Another master's work — must be excluded. + WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'master_id' => $other->id, + 'name' => 'X', 'hours' => 5, 'price_per_hour' => 400, 'status' => 'done', + ]); + // Adjustment for a different period — must be excluded. + PayrollAdjustment::create([ + 'user_id' => $me->id, 'period' => '2026-05', + 'type' => 'bonus', 'amount' => 9999, 'date' => today()->subMonth(), + ]); + + $run = app(PayrollCalculator::class)->compute($me->id, '2026-06'); + $this->assertEquals(1000.0, (float) $run->total, 'only base — no own works, no current-period adjustments'); + Carbon::setTestNow(); + } + + private function bootTenant(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'pr-' . uniqid(), + 'name' => 'Payroll Co', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + return compact('company'); + } + + private function makeMaster(): User + { + return User::create([ + 'name' => 'M', 'email' => 'm-' . uniqid() . '@example.com', + 'password' => bcrypt('x'), 'role' => 'master', 'status' => 'active', + ]); + } +} diff --git a/tests/Feature/Audit/SettingsPersistenceTest.php b/tests/Feature/Audit/SettingsPersistenceTest.php new file mode 100644 index 0000000..2af4979 --- /dev/null +++ b/tests/Feature/Audit/SettingsPersistenceTest.php @@ -0,0 +1,100 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $payload = [ + 'currency' => 'MDL', + 'language' => 'ro', + 'theme_color' => '#ff8800', + 'labor_rate' => 450.5, + 'services' => ['Diagnoză', 'Frâne'], + 'cars' => ['BMW', 'Audi'], + 'notify' => [ + 'wo_ready' => true, + 'payment' => false, + 'appointment' => true, + 'reminder' => true, + ], + 'telegram' => ['bot_token' => 'secret-token'], + 'reminder' => ['after_days' => 400, 'cooldown_days' => 45], + 'shop' => [ + 'enabled' => true, + 'delivery_methods' => ['pickup', 'courier'], + 'delivery_fee' => 50, + 'free_delivery_over' => 1000, + ], + 'ai' => [ + 'default_provider' => 'claude', + 'claude_key' => 'sk-ant-xxx', + 'gpt_key' => null, + 'gemini_key' => null, + 'models' => [ + 'claude' => 'claude-opus-4-7', + 'gpt' => 'gpt-4o', + 'gemini' => 'gemini-1.5-pro', + ], + ], + ]; + + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'st-' . uniqid(), + 'name' => 'Settings Co', 'status' => 'active', + 'settings' => $payload, + ]); + app(TenantManager::class)->setCurrent($company); + + $fresh = Company::withoutGlobalScopes()->find($company->id); + $this->assertEquals($payload, $fresh->settings); + + // Spot-check critical values via data_get (the way services read them). + $this->assertEquals('MDL', data_get($fresh->settings, 'currency')); + $this->assertTrue(data_get($fresh->settings, 'shop.enabled')); + $this->assertEquals(450.5, data_get($fresh->settings, 'labor_rate')); + $this->assertEquals('claude-opus-4-7', data_get($fresh->settings, 'ai.models.claude')); + $this->assertEquals(45, data_get($fresh->settings, 'reminder.cooldown_days')); + } + + public function test_partial_update_preserves_other_keys(): void + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'pu-' . uniqid(), + 'name' => 'Partial', 'status' => 'active', + 'settings' => [ + 'currency' => 'MDL', + 'shop' => ['enabled' => true, 'delivery_fee' => 50], + 'ai' => ['claude_key' => 'sk-xxx'], + ], + ]); + + // Update only the shop subsection (simulating an isolated UI save). + $company->update([ + 'settings' => array_replace_recursive((array) $company->settings, [ + 'shop' => ['delivery_fee' => 100], + ]), + ]); + + $fresh = Company::withoutGlobalScopes()->find($company->id); + $this->assertEquals('MDL', data_get($fresh->settings, 'currency'), 'currency preserved'); + $this->assertTrue(data_get($fresh->settings, 'shop.enabled'), 'shop.enabled preserved'); + $this->assertEquals(100, data_get($fresh->settings, 'shop.delivery_fee'), 'shop.delivery_fee updated'); + $this->assertEquals('sk-xxx', data_get($fresh->settings, 'ai.claude_key'), 'ai key untouched'); + } +} diff --git a/tests/Feature/Audit/ShopJourneyE2ETest.php b/tests/Feature/Audit/ShopJourneyE2ETest.php new file mode 100644 index 0000000..0d8dbcf --- /dev/null +++ b/tests/Feature/Audit/ShopJourneyE2ETest.php @@ -0,0 +1,154 @@ +bootShop('e2e-shop'); + $svc = app(WarehouseService::class); + + // Stock a published part: 20 in inventory. + $part = Part::create([ + 'name' => 'Plăcuțe BMW', 'article' => 'BMW-001', 'brand' => 'TRW', + 'sell_price' => 250, 'buy_price' => 150, + 'qty' => 0, 'unit' => 'buc', + 'is_active' => true, 'is_published' => true, + ]); + $svc->receive($part, 20, 150); + $this->assertEquals(20.0, (float) $part->fresh()->qty); + + Mail::fake(); + + $base = 'http://e2e-shop.service.mir.md'; + + // 1. Register. + $this->post("$base/shop/register", [ + 'name' => 'Tester', 'phone' => '+37377012345', + 'email' => 'tester@example.com', + 'password' => 'secret123', 'password_confirmation' => 'secret123', + ])->assertRedirect('/shop/account'); + + $customer = ShopCustomer::where('phone', '+37377012345')->first(); + $this->assertNotNull($customer); + $this->assertTrue(Auth::guard('shop')->check()); + + // 2. Browse catalog (anonymous-safe, but we're logged in). + $r = $this->get("$base/shop"); + $r->assertOk(); + $r->assertSee('Plăcuțe BMW'); + + // 3. Add to cart. + $this->post("$base/shop/part/{$part->id}/add", ['qty' => 2]) + ->assertRedirect('/shop/cart'); + + $this->get("$base/shop/cart")->assertOk()->assertSee('500.00'); + + // 4. Checkout. + $this->get("$base/shop/checkout") + ->assertOk() + ->assertSee('Tester', false) // prefilled from logged-in customer + ->assertSee('+37377012345'); + + $this->post("$base/shop/checkout", [ + 'customer_name' => 'Tester', + 'customer_phone' => '+37377012345', + 'customer_email' => 'tester@example.com', + 'delivery_method' => 'pickup', + ])->assertRedirect(); + + $order = OnlineOrder::first(); + $this->assertNotNull($order); + $this->assertEquals($customer->id, $order->shop_customer_id); + $this->assertEquals(500.0, (float) $order->total); + + // 5. Confirmation email sent to customer. + Mail::assertSent(ShopOrderConfirmationMail::class, fn ($m) => $m->order->id === $order->id); + + // 6. Tracking page reachable by token (NO auth). + Auth::guard('shop')->logout(); + $r = $this->get($order->trackingUrl()); + $r->assertOk(); + $r->assertSee('#' . $order->number); + $r->assertSee('Plăcuțe BMW'); + + // 7. Admin (back in tenant context) fulfills via service::issue. + $svc->issue($part, 2, null, $order, 'shop fulfill'); + $part->refresh(); + $this->assertEquals(18.0, (float) $part->qty, 'stock decreased by 2'); + + // 8. The warehouse event records the issue with ref pointing at the order. + $event = WarehouseEvent::where('part_id', $part->id) + ->where('type', 'issue') + ->latest('id')->first(); + $this->assertNotNull($event); + $this->assertEquals(-2.0, (float) $event->qty_delta); + $this->assertStringContainsString('OnlineOrder', (string) $event->ref_type); + } + + public function test_guest_checkout_still_works_without_account(): void + { + $this->bootShop('guest'); + $part = Part::create([ + 'name' => 'Filtru', 'sell_price' => 50, 'qty' => 5, 'unit' => 'buc', + 'is_active' => true, 'is_published' => true, + ]); + + $base = 'http://guest.service.mir.md/shop'; + + $this->post("$base/part/{$part->id}/add", ['qty' => 1])->assertRedirect(); + $this->post("$base/checkout", [ + 'customer_name' => 'Guest', 'customer_phone' => '+37399999999', + 'delivery_method' => 'pickup', + ])->assertRedirect(); + + $order = OnlineOrder::first(); + $this->assertNotNull($order); + $this->assertNull($order->shop_customer_id); + $this->assertEquals(50.0, (float) $order->total); + } + + private function bootShop(string $slug): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => $slug, + 'name' => ucfirst($slug), 'status' => 'active', + 'settings' => ['shop' => ['enabled' => true, 'delivery_methods' => ['pickup']]], + ]); + app(TenantManager::class)->setCurrent($company); + + $wh = Warehouse::create([ + 'code' => 'MAIN', 'name' => 'Main', + 'is_default' => true, 'is_active' => true, + ]); + $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); + return compact('company'); + } +} diff --git a/tests/Feature/Audit/TenantBackupServiceTest.php b/tests/Feature/Audit/TenantBackupServiceTest.php new file mode 100644 index 0000000..381b0be --- /dev/null +++ b/tests/Feature/Audit/TenantBackupServiceTest.php @@ -0,0 +1,85 @@ +bootTenant(); + + // Populate one client + vehicle + WO so the export has data. + $c = Client::create(['name' => 'BackupOwner', 'phone' => '+37377555111', 'type' => 'individual', 'status' => 'active']); + $v = Vehicle::create(['client_id' => $c->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'BK-001']); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($company->id), + 'client_id' => $c->id, 'vehicle_id' => $v->id, + 'opened_at' => today(), 'status' => 'in_work', + ]); + WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'name' => 'X', + 'hours' => 1, 'price_per_hour' => 100, 'status' => 'todo', + ]); + + $path = app(TenantBackupService::class)->export($company); + $this->assertFileExists($path); + + // Open the zip and read manifest + clients.json. + $zip = new ZipArchive(); + $this->assertTrue($zip->open($path) === true); + + $manifestJson = $zip->getFromName('manifest.json'); + $this->assertNotEmpty($manifestJson, 'manifest.json present'); + $manifest = json_decode($manifestJson, true); + $this->assertEquals($company->slug, $manifest['tenant']['slug']); + $this->assertGreaterThanOrEqual(1, $manifest['counts']['clients']); + $this->assertGreaterThanOrEqual(1, $manifest['counts']['vehicles']); + $this->assertGreaterThanOrEqual(1, $manifest['counts']['work_orders']); + + $clientsJson = $zip->getFromName('data/clients.json'); + $this->assertNotEmpty($clientsJson); + $clients = json_decode($clientsJson, true); + $this->assertEquals('BackupOwner', $clients[0]['name']); + + // Embedded WO data must include works (with-eager-loaded). + $woJson = $zip->getFromName('data/work_orders.json'); + $wos = json_decode($woJson, true); + $this->assertNotEmpty($wos[0]['works']); + + $zip->close(); + @unlink($path); + } + + public function test_filename_includes_slug_and_date(): void + { + $company = $this->bootTenant(); + $name = app(TenantBackupService::class)->filename($company); + $this->assertStringStartsWith('tenant-' . $company->slug . '-', $name); + $this->assertStringEndsWith('.zip', $name); + } + + private function bootTenant(): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'bkup-' . uniqid(), + 'name' => 'BK Co', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + return $company; + } +} diff --git a/tests/Feature/Audit/WorkOrderPdfServiceTest.php b/tests/Feature/Audit/WorkOrderPdfServiceTest.php new file mode 100644 index 0000000..6a2acdc --- /dev/null +++ b/tests/Feature/Audit/WorkOrderPdfServiceTest.php @@ -0,0 +1,81 @@ +bootTenant(); + $client = Client::create(['name' => 'PdfClient', 'phone' => '+37300100200', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, 'make' => 'Audi', 'model' => 'A4', 'plate' => 'PDF-001', + ]); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today(), 'status' => 'in_work', + 'complaint' => 'Zgomot la frânare', + ]); + WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'name' => 'Schimb plăcuțe', + 'hours' => 1.5, 'price_per_hour' => 400, 'status' => 'done', + ]); + WorkOrderPart::create([ + 'work_order_id' => $wo->id, 'name' => 'Plăcuțe TRW', + 'qty' => 1, 'buy_price' => 200, 'sell_price' => 350, + 'status' => 'installed', + ]); + + $svc = app(WorkOrderPdfService::class); + $pdf = $svc->generate($wo); + $output = $pdf->output(); + + $this->assertStringStartsWith('%PDF', $output, 'binary starts with PDF magic'); + $this->assertGreaterThan(2000, strlen($output), 'non-trivial PDF size'); + + $filename = $svc->filename($wo); + $this->assertStringContainsString($wo->number, $filename); + $this->assertStringEndsWith('.pdf', $filename); + } + + public function test_pdf_handles_wo_with_no_lines(): void + { + $ctx = $this->bootTenant(); + $client = Client::create(['name' => 'X', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'EMPTY-1']); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today(), 'status' => 'new', + ]); + + $pdf = app(WorkOrderPdfService::class)->generate($wo); + $this->assertStringStartsWith('%PDF', $pdf->output()); + } + + private function bootTenant(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'pdf-' . uniqid(), + 'name' => 'PDF Co', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + return compact('company'); + } +} diff --git a/tests/Feature/Audit/WorkOrderTotalsTest.php b/tests/Feature/Audit/WorkOrderTotalsTest.php new file mode 100644 index 0000000..c142a5e --- /dev/null +++ b/tests/Feature/Audit/WorkOrderTotalsTest.php @@ -0,0 +1,189 @@ +bootstrap(); + $wo = $this->makeWo($ctx, discountPct: 10); + + // Labor: 2h × 400 = 800 + WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'name' => 'Manoperă', + 'hours' => 2, 'price_per_hour' => 400, 'status' => 'todo', + ]); + + // Part: 3 × 150 = 450 + $part = Part::create([ + 'name' => 'Filtru', 'sell_price' => 150, 'buy_price' => 100, + 'qty' => 10, 'unit' => 'buc', 'is_active' => true, + ]); + WorkOrderPart::create([ + 'work_order_id' => $wo->id, 'part_id' => $part->id, + 'name' => $part->name, 'qty' => 3, + 'buy_price' => 100, 'sell_price' => 150, + 'status' => 'needed', + ]); + + // Subcontract: cost 500 × 1.20 markup = 600 client price + SubcontractJob::create([ + 'work_order_id' => $wo->id, 'category' => 'Turbo', + 'cost' => 500, 'markup_pct' => 20, 'status' => 'sent', + ]); + + $wo->refresh(); + // Subtotal: 800 + 450 + 600 = 1850. With 10% discount → 1665. + $this->assertEquals(1665.0, (float) $wo->total); + } + + public function test_cancelled_subcontract_excluded_from_total(): void + { + $ctx = $this->bootstrap(); + $wo = $this->makeWo($ctx); + + $sub = SubcontractJob::create([ + 'work_order_id' => $wo->id, 'cost' => 1000, 'markup_pct' => 50, 'status' => 'sent', + ]); + $wo->refresh(); + $this->assertEquals(1500.0, (float) $wo->total); + + $sub->update(['status' => 'cancelled']); + $wo->refresh(); + $this->assertEquals(0.0, (float) $wo->total); + } + + public function test_deleting_any_line_type_recalcs_total(): void + { + $ctx = $this->bootstrap(); + $wo = $this->makeWo($ctx); + + $w = WorkOrderWork::create([ + 'work_order_id' => $wo->id, 'name' => 'X', + 'hours' => 1, 'price_per_hour' => 100, 'status' => 'todo', + ]); + $wo->refresh(); + $this->assertEquals(100.0, (float) $wo->total); + + $w->delete(); + $wo->refresh(); + $this->assertEquals(0.0, (float) $wo->total); + } + + public function test_status_done_consumes_part_reservations(): void + { + $ctx = $this->bootstrap(); + $svc = app(WarehouseService::class); + + // Stock the warehouse with one batch of 10. + $part = Part::create([ + 'name' => 'Plăcuțe', 'sell_price' => 200, 'buy_price' => 100, + 'qty' => 0, 'unit' => 'buc', 'is_active' => true, + ]); + $svc->receive($part, 10, 100); + $this->assertEquals(10.0, (float) $part->fresh()->qty); + + $wo = $this->makeWo($ctx); + WorkOrderPart::create([ + 'work_order_id' => $wo->id, 'part_id' => $part->id, + 'name' => $part->name, 'qty' => 4, + 'buy_price' => 100, 'sell_price' => 200, + 'status' => 'needed', + ]); + + // Before close: stock still 10, qty_reserved 4. + $part->refresh(); + $this->assertEquals(10.0, (float) $part->qty); + $this->assertEquals(4.0, (float) $part->qty_reserved); + + // Close WO → reservations become consume events. + $wo->update(['status' => 'done']); + $part->refresh(); + $this->assertEquals(6.0, (float) $part->qty); + $this->assertEquals(0.0, (float) $part->qty_reserved); + } + + public function test_cancelled_wo_releases_reservations(): void + { + $ctx = $this->bootstrap(); + $svc = app(WarehouseService::class); + $part = Part::create([ + 'name' => 'X', 'sell_price' => 10, 'buy_price' => 5, + 'qty' => 0, 'unit' => 'buc', 'is_active' => true, + ]); + $svc->receive($part, 5, 5); + + $wo = $this->makeWo($ctx); + WorkOrderPart::create([ + 'work_order_id' => $wo->id, 'part_id' => $part->id, + 'name' => 'X', 'qty' => 2, + 'buy_price' => 5, 'sell_price' => 10, + 'status' => 'needed', + ]); + $this->assertEquals(2.0, (float) $part->fresh()->qty_reserved); + + $wo->update(['status' => 'cancelled']); + $this->assertEquals(0.0, (float) $part->fresh()->qty_reserved); + $this->assertEquals(5.0, (float) $part->fresh()->qty, 'stock untouched after cancel'); + } + + private function bootstrap(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'wot-' . uniqid(), + 'name' => 'WO Totals', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + + // Ensure warehouse exists so WarehouseService::receive works. + $wh = Warehouse::create([ + 'code' => 'MAIN', 'name' => 'Main', + 'is_default' => true, 'is_active' => true, + ]); + $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); + + return compact('company'); + } + + private function makeWo(array $ctx, float $discountPct = 0): WorkOrder + { + $client = Client::create([ + 'name' => 'C', 'phone' => '+3736' . random_int(1000000, 9999999), + 'type' => 'individual', 'status' => 'active', + ]); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, 'make' => 'X', 'model' => 'Y', + 'plate' => 'WO' . random_int(100, 999), + ]); + return WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today(), 'status' => 'in_work', + 'discount_pct' => $discountPct, + ]); + } +}