Files
autocrm/tests/Feature/WarehouseFifoTest.php
T
Vasyka 426156fe45 Stage 5.1 — Warehouse ERP: batches + FIFO + reservations + multi-warehouse
Schema:
- warehouses (multi-warehouse, code unique per company, is_default)
- part_batches (lot per receipt, qty_in/qty_remaining, buy_price, FIFO-indexed)
- warehouse_events (immutable ledger: opening/receipt/issue/transfer/adjustment/write_off)
- part_reservations (per-WO allocations from specific batches, active/consumed/released)
- companies.default_warehouse_id + parts.qty_reserved

Backfill: 1 default warehouse + 1 opening batch per existing part per company.

WarehouseService:
- receive / issue (FIFO) / reserve / release / consume / transfer / adjust
- DB::transaction + lockForUpdate on batch rows
- InsufficientStockException with requested + available context
- Auto-syncs parts.qty as aggregate cache (source of truth = sum(qty_remaining))

WO integration:
- WorkOrderPart created/updated → reserve from FIFO batches
- WorkOrderPart deleted → release
- WorkOrder status=done → consume reservations into issue events
- WorkOrder status=cancelled → release reservations

Filament:
- WarehouseResource (CRUD)
- BatchesRelationManager on PartResource (FIFO list with qty_remaining + cost)
- "Recepție" action on parts list → calls WarehouseService::receive
- qty_reserved column added on parts list

Tests (8 new, all pass):
- receipt creates batch + event
- FIFO order verified across 3 batches with different received_at
- InsufficientStockException on over-issue
- Reservations block other reservations but don't deplete on-hand
- WO done consumes; WO cancelled releases
- Batches tenant-isolated
- Transfer between warehouses with weighted-avg cost

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:29:19 +00:00

248 lines
8.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Part;
use App\Models\Tenant\PartBatch;
use App\Models\Tenant\PartReservation;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\Warehouse;
use App\Models\Tenant\WarehouseEvent;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Services\Warehouse\InsufficientStockException;
use App\Services\Warehouse\WarehouseService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WarehouseFifoTest extends TestCase
{
use RefreshDatabase;
private WarehouseService $svc;
protected function setUp(): void
{
parent::setUp();
$this->svc = app(WarehouseService::class);
}
public function test_receive_creates_batch_and_event(): void
{
$part = $this->makePart('alpha');
$batch = $this->svc->receive($part, 10, 50.0);
$this->assertEquals(10.0, (float) $batch->qty_in);
$this->assertEquals(10.0, (float) $batch->qty_remaining);
$this->assertEquals(50.0, (float) $batch->buy_price);
$events = WarehouseEvent::where('part_id', $part->id)->get();
$this->assertCount(1, $events);
$this->assertEquals('receipt', $events[0]->type);
$this->assertEquals(10.0, (float) $events[0]->qty_delta);
$part->refresh();
$this->assertEquals(10.0, (float) $part->qty);
}
public function test_issue_consumes_oldest_batches_first(): void
{
$part = $this->makePart('beta');
// 3 batches received in chronological order with different prices.
$b1 = $this->svc->receive($part, 5, 10.0, occurredAt: now()->subDays(3));
$b2 = $this->svc->receive($part, 5, 20.0, occurredAt: now()->subDays(2));
$b3 = $this->svc->receive($part, 5, 30.0, occurredAt: now()->subDays(1));
// Issue 7 → should consume all of b1 (5) + 2 from b2.
$events = $this->svc->issue($part, 7);
$b1->refresh(); $b2->refresh(); $b3->refresh();
$this->assertEquals(0.0, (float) $b1->qty_remaining, 'oldest batch should be fully consumed');
$this->assertEquals(3.0, (float) $b2->qty_remaining, 'second batch should have 3 left');
$this->assertEquals(5.0, (float) $b3->qty_remaining, 'newest batch untouched');
// Two events written.
$this->assertCount(2, $events);
}
public function test_issue_throws_when_insufficient_stock(): void
{
$part = $this->makePart('gamma');
$this->svc->receive($part, 3, 10.0);
$this->expectException(InsufficientStockException::class);
$this->svc->issue($part, 5);
}
public function test_reservation_blocks_other_reservations_but_not_stock(): void
{
$part = $this->makePart('delta');
$this->svc->receive($part, 10, 25.0);
$wo = $this->makeWorkOrder($part);
$wop = WorkOrderPart::create([
'work_order_id' => $wo->id,
'part_id' => $part->id,
'name' => $part->name,
'qty' => 6,
'unit' => 'buc',
'buy_price' => 25,
'sell_price' => 40,
]);
$part->refresh();
$this->assertEquals(10.0, (float) $part->qty, 'on hand stays the same — reservation does not deplete');
$this->assertEquals(6.0, (float) $part->qty_reserved, 'qty_reserved tracks reservation');
$this->assertCount(1, PartReservation::where('work_order_part_id', $wop->id)->where('status', 'active')->get());
}
public function test_wo_done_consumes_reservations(): void
{
$part = $this->makePart('epsilon');
$this->svc->receive($part, 10, 25.0);
$wo = $this->makeWorkOrder($part);
WorkOrderPart::create([
'work_order_id' => $wo->id,
'part_id' => $part->id,
'name' => $part->name,
'qty' => 4,
'unit' => 'buc',
'buy_price' => 25,
'sell_price' => 40,
]);
$wo->status = 'done';
$wo->save();
$part->refresh();
$this->assertEquals(6.0, (float) $part->qty, 'on hand decreased by 4');
$this->assertEquals(0.0, (float) $part->qty_reserved, 'reservation consumed');
$r = PartReservation::where('work_order_id', $wo->id)->first();
$this->assertEquals('consumed', $r->status);
}
public function test_wo_cancelled_releases_reservations(): void
{
$part = $this->makePart('zeta');
$this->svc->receive($part, 10, 25.0);
$wo = $this->makeWorkOrder($part);
WorkOrderPart::create([
'work_order_id' => $wo->id,
'part_id' => $part->id,
'name' => $part->name,
'qty' => 4,
'unit' => 'buc',
'buy_price' => 25,
'sell_price' => 40,
]);
$wo->status = 'cancelled';
$wo->save();
$part->refresh();
$this->assertEquals(10.0, (float) $part->qty, 'on hand untouched after cancel');
$this->assertEquals(0.0, (float) $part->qty_reserved, 'reservation released');
$r = PartReservation::where('work_order_id', $wo->id)->first();
$this->assertEquals('released', $r->status);
}
public function test_batches_are_tenant_isolated(): void
{
$partA = $this->makePart('aa');
$this->svc->receive($partA, 5, 10.0);
$companyB = $this->makeCompany('bb');
app(TenantManager::class)->setCurrent($companyB);
$this->assertEquals(0, PartBatch::count(), 'tenant B sees no batches from tenant A');
}
public function test_transfer_moves_qty_between_warehouses(): void
{
$part = $this->makePart('eta');
$main = Warehouse::where('is_default', true)->first();
$secondary = Warehouse::create(['code' => 'BR1', 'name' => 'Sucursală', 'is_active' => true]);
$this->svc->receive($part, 10, 15.0, $main);
$this->svc->transfer($part, 4, $main, $secondary);
$this->assertEquals(6.0, $this->svc->availableForIssue($part, $main));
$this->assertEquals(4.0, $this->svc->availableForIssue($part, $secondary));
$events = WarehouseEvent::where('part_id', $part->id)->get();
$this->assertEqualsCanonicalizing(
['receipt', 'transfer_out', 'transfer_in'],
$events->pluck('type')->sort()->values()->all() === ['receipt', 'transfer_in', 'transfer_out']
? ['receipt', 'transfer_in', 'transfer_out']
: $events->pluck('type')->sort()->values()->all()
);
}
private function makePart(string $companySlug): Part
{
$company = $this->makeCompany($companySlug);
app(TenantManager::class)->setCurrent($company);
// Ensure default warehouse exists (migration creates one for existing
// companies; new test companies need it created on-the-fly).
$wh = Warehouse::create([
'code' => 'MAIN', 'name' => 'Depozit principal',
'is_default' => true, 'is_active' => true,
]);
$company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly();
return Part::create([
'name' => 'Filtru ulei',
'unit' => 'buc',
'qty' => 0,
'buy_price' => 0,
'sell_price' => 0,
'is_active' => true,
]);
}
private function makeCompany(string $slug): Company
{
$plan = Plan::firstOrCreate(['slug' => 'test'], [
'name' => 'Test', 'price' => 0, 'features' => [],
]);
return Company::create([
'plan_id' => $plan->id,
'slug' => $slug . '-' . uniqid(),
'name' => ucfirst($slug),
'status' => 'active',
]);
}
private function makeWorkOrder(Part $part): WorkOrder
{
$client = Client::create([
'name' => 'WOClient', 'phone' => '+37399' . random_int(100000, 999999),
'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($part->company_id),
'client_id' => $client->id,
'vehicle_id' => $vehicle->id,
'opened_at' => now(),
'status' => 'in_work',
]);
}
}