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