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>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
<?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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user