Files
Vasyka 0620635abb 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>
2026-06-03 07:05:46 +00:00

197 lines
7.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature\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');
}
}