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
+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,
]);
}
}