Files
autocrm/tests/Feature/Audit/WorkOrderTotalsTest.php
T
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

190 lines
6.5 KiB
PHP
Raw 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\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,
]);
}
}