test: full E2E audit + fix CsvImportExport vehicle.brand → make

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 07:05:46 +00:00
parent 439ef605a1
commit 0620635abb
12 changed files with 1353 additions and 2 deletions
+3 -2
View File
@@ -47,7 +47,7 @@ class CsvImportExport
Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) { Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) {
foreach ($rows as $row) { foreach ($rows as $row) {
fputcsv($out, [ 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->engine, $row->gearbox, $row->fuel, $row->mileage,
$row->color, $row->notes, $row->client?->phone, $row->color, $row->notes, $row->client?->phone,
]); ]);
@@ -108,7 +108,8 @@ class CsvImportExport
'client_id' => $client->id, 'client_id' => $client->id,
'plate' => $row['plate'], 'plate' => $row['plate'],
'vin' => $row['vin'] ?? null, '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, 'model' => $row['model'] ?? null,
'year' => (int) ($row['year'] ?? 0) ?: null, 'year' => (int) ($row['year'] ?? 0) ?: null,
'engine' => $row['engine'] ?? null, 'engine' => $row['engine'] ?? null,
@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Services\Ai\AiAssistantService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* The 3 AI providers share infrastructure but each has a different request
* shape. After the model selector refactor, verify each provider still uses
* the configured model + key in the actual HTTP call.
*/
class AiProvidersCrossCheckTest extends TestCase
{
use RefreshDatabase;
public function test_openai_singleshot_uses_configured_model_and_sends_messages(): void
{
$this->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;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Services\CompanyProvisioner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CompanyProvisionerTest extends TestCase
{
use RefreshDatabase;
public function test_suspend_changes_status_to_suspended(): void
{
$company = $this->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,
]);
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Appointment;
use App\Models\Tenant\Client;
use App\Models\Tenant\Deal;
use App\Models\Tenant\Lead;
use App\Models\Tenant\Payment;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderWork;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* End-to-end: a single customer travels through the CRM funnel.
* Exercises 5 previously-untested models (Lead, Deal, Appointment, Payment)
* + their relations to Client / Vehicle / WorkOrder.
*/
class CrmFunnelE2ETest extends TestCase
{
use RefreshDatabase;
public function test_full_journey_lead_to_payment_with_balance_due(): void
{
$ctx = $this->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');
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use App\Services\CsvImportExport;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CsvImportExportTest extends TestCase
{
use RefreshDatabase;
public function test_import_clients_creates_records_with_correct_fields(): void
{
$this->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;
}
}
@@ -0,0 +1,121 @@
<?php
namespace Tests\Feature\Audit;
use App\Mail\WorkOrderReadyMail;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Services\NotificationDispatcher;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* The multi-channel fallback is the most subtle integration in the system:
* Telegram wins if the client has a chat_id + tenant bot configured; otherwise
* fall back to email. If neither, do nothing. Tenant flag must respect both.
*/
class NotificationFallbackTest extends TestCase
{
use RefreshDatabase;
public function test_telegram_wins_when_chat_id_present(): void
{
$ctx = $this->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,
]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\EmployeeProfile;
use App\Models\Tenant\PayrollAdjustment;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use App\Services\PayrollCalculator;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class PayrollCalculatorTest extends TestCase
{
use RefreshDatabase;
public function test_compute_combines_base_works_parts_and_adjustments(): void
{
Carbon::setTestNow('2026-06-15');
$ctx = $this->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',
]);
}
}
@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Settings JSON has grown ~25 keys across 8 sections. Verify the round-trip
* shape is correct: every setting our pages write must come back when read.
*/
class SettingsPersistenceTest extends TestCase
{
use RefreshDatabase;
public function test_settings_round_trip_full_shape(): void
{
$plan = Plan::firstOrCreate(['slug' => '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');
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature\Audit;
use App\Mail\ShopOrderConfirmationMail;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\OnlineOrder;
use App\Models\Tenant\Part;
use App\Models\Tenant\ShopCustomer;
use App\Models\Tenant\Warehouse;
use App\Models\Tenant\WarehouseEvent;
use App\Services\Warehouse\WarehouseService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* End-to-end shop journey: customer registers adds to cart checks out
* receives confirmation email admin fulfills WarehouseService.issue runs
* stock decreases tracking page reachable.
*
* Exercises ShopAuthController, ShopController, OnlineOrder, OnlineOrderItem,
* ShopCustomer, ShopOrderNotifier, WarehouseService, and the tracking route.
*/
class ShopJourneyE2ETest extends TestCase
{
use RefreshDatabase;
public function test_full_shop_journey_registered_customer(): void
{
$ctx = $this->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');
}
}
@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderWork;
use App\Services\TenantBackupService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use ZipArchive;
class TenantBackupServiceTest extends TestCase
{
use RefreshDatabase;
public function test_export_produces_valid_zip_with_manifest_and_tables(): void
{
$company = $this->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;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use App\Services\WorkOrderPdfService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WorkOrderPdfServiceTest extends TestCase
{
use RefreshDatabase;
public function test_pdf_generates_with_works_and_parts(): void
{
$ctx = $this->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');
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Part;
use App\Models\Tenant\SubcontractJob;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\Warehouse;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use App\Services\Warehouse\WarehouseService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* The most fragile invariant in the system: WorkOrder.total. Every
* line type (works + parts + subcontract) feeds into it, plus discount,
* minus excluded statuses. If recalcTotal forgets ONE source, money disappears.
*/
class WorkOrderTotalsTest extends TestCase
{
use RefreshDatabase;
public function test_total_aggregates_all_line_types_and_applies_discount(): void
{
$ctx = $this->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,
]);
}
}