0620635abb
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>
197 lines
7.1 KiB
PHP
197 lines
7.1 KiB
PHP
<?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');
|
||
}
|
||
}
|