1018 lines
41 KiB
PHP
1018 lines
41 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use Core\Mod\Commerce\Models\Entity;
|
||
|
|
use Core\Mod\Commerce\Models\Inventory;
|
||
|
|
use Core\Mod\Commerce\Models\InventoryMovement;
|
||
|
|
use Core\Mod\Commerce\Models\Product;
|
||
|
|
use Core\Mod\Commerce\Models\Warehouse;
|
||
|
|
use Core\Mod\Commerce\Services\WarehouseService;
|
||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
|
use Illuminate\Support\Facades\Schema;
|
||
|
|
|
||
|
|
uses(RefreshDatabase::class);
|
||
|
|
|
||
|
|
beforeEach(function () {
|
||
|
|
// Ensure warehouse table has all columns the model expects
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'is_primary')) {
|
||
|
|
Schema::table('commerce_warehouses', function ($table) {
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'description')) {
|
||
|
|
$table->string('description')->nullable()->after('name');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'county')) {
|
||
|
|
$table->string('county')->nullable()->after('city');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'postcode')) {
|
||
|
|
$table->string('postcode')->nullable()->after('county');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'contact_name')) {
|
||
|
|
$table->string('contact_name')->nullable()->after('country');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'contact_email')) {
|
||
|
|
$table->string('contact_email')->nullable()->after('contact_name');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'contact_phone')) {
|
||
|
|
$table->string('contact_phone')->nullable()->after('contact_email');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'type')) {
|
||
|
|
$table->string('type')->default(Warehouse::TYPE_OWNED)->after('contact_phone');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'can_ship')) {
|
||
|
|
$table->boolean('can_ship')->default(true)->after('type');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'can_pickup')) {
|
||
|
|
$table->boolean('can_pickup')->default(false)->after('can_ship');
|
||
|
|
}
|
||
|
|
$table->boolean('is_primary')->default(false)->after('can_pickup');
|
||
|
|
if (! Schema::hasColumn('commerce_warehouses', 'operating_hours')) {
|
||
|
|
$table->json('operating_hours')->nullable()->after('is_primary');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure inventory table has all columns the model expects
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'reserved_quantity')) {
|
||
|
|
Schema::table('commerce_inventory', function ($table) {
|
||
|
|
$table->renameColumn('reserved', 'reserved_quantity');
|
||
|
|
});
|
||
|
|
Schema::table('commerce_inventory', function ($table) {
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'incoming_quantity')) {
|
||
|
|
$table->integer('incoming_quantity')->default(0)->after('reserved_quantity');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'bin_location')) {
|
||
|
|
$table->string('bin_location')->nullable()->after('low_stock_threshold');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'zone')) {
|
||
|
|
$table->string('zone')->nullable()->after('bin_location');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'last_restocked_at')) {
|
||
|
|
$table->timestamp('last_restocked_at')->nullable()->after('last_counted_at');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'unit_cost')) {
|
||
|
|
$table->integer('unit_cost')->nullable()->after('last_restocked_at');
|
||
|
|
}
|
||
|
|
if (! Schema::hasColumn('commerce_inventory', 'metadata')) {
|
||
|
|
$table->json('metadata')->nullable()->after('unit_cost');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create inventory movements table if it does not exist
|
||
|
|
if (! Schema::hasTable('commerce_inventory_movements')) {
|
||
|
|
Schema::create('commerce_inventory_movements', function ($table) {
|
||
|
|
$table->id();
|
||
|
|
$table->foreignId('inventory_id')->nullable()->constrained('commerce_inventory')->nullOnDelete();
|
||
|
|
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
|
||
|
|
$table->foreignId('warehouse_id')->constrained('commerce_warehouses')->cascadeOnDelete();
|
||
|
|
$table->string('type', 32);
|
||
|
|
$table->integer('quantity');
|
||
|
|
$table->integer('balance_after')->default(0);
|
||
|
|
$table->string('reference')->nullable();
|
||
|
|
$table->text('notes')->nullable();
|
||
|
|
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||
|
|
$table->integer('unit_cost')->nullable();
|
||
|
|
$table->timestamp('created_at')->nullable();
|
||
|
|
|
||
|
|
$table->index(['product_id', 'type']);
|
||
|
|
$table->index(['warehouse_id', 'type']);
|
||
|
|
$table->index(['reference']);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create M1 entity
|
||
|
|
$this->entity = Entity::createMaster('WH', 'Warehouse Test Co');
|
||
|
|
|
||
|
|
// Create a non-M1 entity for negative testing
|
||
|
|
$this->m2Entity = $this->entity->createFacade('SHOP', 'Shop Front');
|
||
|
|
|
||
|
|
// Create product owned by M1
|
||
|
|
$this->product = Product::create([
|
||
|
|
'sku' => 'WH-TEST-001',
|
||
|
|
'owner_entity_id' => $this->entity->id,
|
||
|
|
'name' => 'Test Widget',
|
||
|
|
'price' => 1500,
|
||
|
|
'currency' => 'GBP',
|
||
|
|
'type' => Product::TYPE_SIMPLE,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Create a second product for multi-product tests
|
||
|
|
$this->productB = Product::create([
|
||
|
|
'sku' => 'WH-TEST-002',
|
||
|
|
'owner_entity_id' => $this->entity->id,
|
||
|
|
'name' => 'Test Gadget',
|
||
|
|
'price' => 2500,
|
||
|
|
'currency' => 'GBP',
|
||
|
|
'type' => Product::TYPE_SIMPLE,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service = new WarehouseService;
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Warehouse Management
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('WarehouseService', function () {
|
||
|
|
describe('createWarehouse()', function () {
|
||
|
|
it('creates a warehouse for an M1 entity', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'main',
|
||
|
|
'name' => 'Main Warehouse',
|
||
|
|
'country' => 'GB',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect($warehouse)->toBeInstanceOf(Warehouse::class)
|
||
|
|
->and($warehouse->entity_id)->toBe($this->entity->id)
|
||
|
|
->and($warehouse->code)->toBe('MAIN')
|
||
|
|
->and($warehouse->name)->toBe('Main Warehouse')
|
||
|
|
->and($warehouse->country)->toBe('GB')
|
||
|
|
->and($warehouse->is_active)->toBeTrue();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uppercases the warehouse code', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'london-south',
|
||
|
|
'name' => 'London South',
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect($warehouse->code)->toBe('LONDON-SOUTH');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws exception for non-M1 entity', function () {
|
||
|
|
expect(fn () => $this->service->createWarehouse($this->m2Entity, [
|
||
|
|
'code' => 'nope',
|
||
|
|
'name' => 'Should Fail',
|
||
|
|
]))->toThrow(InvalidArgumentException::class, 'Only M1 (Master) entities can own warehouses.');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getWarehousesForEntity()', function () {
|
||
|
|
it('returns active warehouses ordered by primary first', function () {
|
||
|
|
$warehouseA = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'wh-a',
|
||
|
|
'name' => 'Warehouse A',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => false,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouseB = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'wh-b',
|
||
|
|
'name' => 'Warehouse B',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Inactive warehouse should not appear
|
||
|
|
$this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'wh-c',
|
||
|
|
'name' => 'Inactive Warehouse',
|
||
|
|
'is_active' => false,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouses = $this->service->getWarehousesForEntity($this->entity);
|
||
|
|
|
||
|
|
expect($warehouses)->toHaveCount(2)
|
||
|
|
->and($warehouses->first()->id)->toBe($warehouseB->id)
|
||
|
|
->and($warehouses->last()->id)->toBe($warehouseA->id);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns empty collection when no warehouses exist', function () {
|
||
|
|
$warehouses = $this->service->getWarehousesForEntity($this->entity);
|
||
|
|
|
||
|
|
expect($warehouses)->toHaveCount(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getPrimaryWarehouse()', function () {
|
||
|
|
it('returns the primary warehouse', function () {
|
||
|
|
$this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'secondary',
|
||
|
|
'name' => 'Secondary',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => false,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$primary = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'primary',
|
||
|
|
'name' => 'Primary HQ',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$result = $this->service->getPrimaryWarehouse($this->entity);
|
||
|
|
|
||
|
|
expect($result)->not->toBeNull()
|
||
|
|
->and($result->id)->toBe($primary->id);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns null when no primary warehouse is set', function () {
|
||
|
|
$this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'noprimary',
|
||
|
|
'name' => 'No Primary',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => false,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$result = $this->service->getPrimaryWarehouse($this->entity);
|
||
|
|
|
||
|
|
expect($result)->toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('setPrimaryWarehouse()', function () {
|
||
|
|
it('sets a warehouse as primary and unsets others', function () {
|
||
|
|
$warehouseA = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'wh-a',
|
||
|
|
'name' => 'Warehouse A',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouseB = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'wh-b',
|
||
|
|
'name' => 'Warehouse B',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => false,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->setPrimaryWarehouse($warehouseB);
|
||
|
|
|
||
|
|
$warehouseA->refresh();
|
||
|
|
$warehouseB->refresh();
|
||
|
|
|
||
|
|
expect($warehouseB->is_primary)->toBeTrue()
|
||
|
|
->and($warehouseA->is_primary)->toBeFalse();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Inventory Operations
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('getOrCreateInventory()', function () {
|
||
|
|
it('creates new inventory record with zero quantities', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'inv',
|
||
|
|
'name' => 'Inventory Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
|
||
|
|
expect($inventory)->toBeInstanceOf(Inventory::class)
|
||
|
|
->and($inventory->product_id)->toBe($this->product->id)
|
||
|
|
->and($inventory->warehouse_id)->toBe($warehouse->id)
|
||
|
|
->and($inventory->quantity)->toBe(0)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(0)
|
||
|
|
->and($inventory->incoming_quantity)->toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns existing inventory record on second call', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'inv2',
|
||
|
|
'name' => 'Inventory Warehouse 2',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$first = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
$second = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
|
||
|
|
expect($first->id)->toBe($second->id);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('addStock()', function () {
|
||
|
|
it('adds stock and records a movement', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'add',
|
||
|
|
'name' => 'Add Stock Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$inventory = $this->service->addStock(
|
||
|
|
$this->product,
|
||
|
|
$warehouse,
|
||
|
|
50,
|
||
|
|
InventoryMovement::TYPE_PURCHASE,
|
||
|
|
'PO-001',
|
||
|
|
'Initial stock purchase'
|
||
|
|
);
|
||
|
|
|
||
|
|
expect($inventory->quantity)->toBe(50)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(0);
|
||
|
|
|
||
|
|
// Verify movement was recorded
|
||
|
|
$movement = InventoryMovement::where('inventory_id', $inventory->id)->first();
|
||
|
|
expect($movement)->not->toBeNull()
|
||
|
|
->and($movement->type)->toBe(InventoryMovement::TYPE_PURCHASE)
|
||
|
|
->and($movement->quantity)->toBe(50)
|
||
|
|
->and($movement->reference)->toBe('PO-001')
|
||
|
|
->and($movement->notes)->toBe('Initial stock purchase');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('accumulates stock across multiple additions', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'accum',
|
||
|
|
'name' => 'Accumulate Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 30);
|
||
|
|
$inventory = $this->service->addStock($this->product, $warehouse, 20);
|
||
|
|
|
||
|
|
expect($inventory->quantity)->toBe(50);
|
||
|
|
|
||
|
|
// Two movements should exist
|
||
|
|
$movements = InventoryMovement::where('inventory_id', $inventory->id)->count();
|
||
|
|
expect($movements)->toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stores unit cost when provided', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'cost',
|
||
|
|
'name' => 'Cost Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$inventory = $this->service->addStock(
|
||
|
|
$this->product,
|
||
|
|
$warehouse,
|
||
|
|
10,
|
||
|
|
InventoryMovement::TYPE_PURCHASE,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
750
|
||
|
|
);
|
||
|
|
|
||
|
|
expect($inventory->unit_cost)->toBe(750);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('removeStock()', function () {
|
||
|
|
it('removes stock when available quantity is sufficient', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'rem',
|
||
|
|
'name' => 'Remove Stock Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 100);
|
||
|
|
|
||
|
|
$result = $this->service->removeStock(
|
||
|
|
$this->product,
|
||
|
|
$warehouse,
|
||
|
|
30,
|
||
|
|
InventoryMovement::TYPE_SALE,
|
||
|
|
'ORD-001'
|
||
|
|
);
|
||
|
|
|
||
|
|
expect($result)->toBeTrue();
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->quantity)->toBe(70);
|
||
|
|
|
||
|
|
// Verify outbound movement
|
||
|
|
$movement = InventoryMovement::where('reference', 'ORD-001')->first();
|
||
|
|
expect($movement)->not->toBeNull()
|
||
|
|
->and($movement->quantity)->toBe(-30)
|
||
|
|
->and($movement->type)->toBe(InventoryMovement::TYPE_SALE);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false when insufficient stock', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'insuf',
|
||
|
|
'name' => 'Insufficient Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 10);
|
||
|
|
|
||
|
|
$result = $this->service->removeStock($this->product, $warehouse, 20);
|
||
|
|
|
||
|
|
expect($result)->toBeFalse();
|
||
|
|
|
||
|
|
// Quantity should remain unchanged
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->quantity)->toBe(10);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('accounts for reserved quantity in available check', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'resavail',
|
||
|
|
'name' => 'Reserved Availability Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 20);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 15, 'ORD-RES');
|
||
|
|
|
||
|
|
// Available = 20 - 15 = 5, so removing 10 should fail
|
||
|
|
$result = $this->service->removeStock($this->product, $warehouse, 10);
|
||
|
|
expect($result)->toBeFalse();
|
||
|
|
|
||
|
|
// Removing 5 should succeed
|
||
|
|
$result = $this->service->removeStock($this->product, $warehouse, 5);
|
||
|
|
expect($result)->toBeTrue();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Stock Reservation & Fulfilment
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('reserveStock()', function () {
|
||
|
|
it('reserves stock for an order', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'res',
|
||
|
|
'name' => 'Reserve Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 50);
|
||
|
|
|
||
|
|
$result = $this->service->reserveStock($this->product, $warehouse, 10, 'ORD-100');
|
||
|
|
|
||
|
|
expect($result)->toBeTrue();
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->reserved_quantity)->toBe(10)
|
||
|
|
->and($inventory->getAvailableQuantity())->toBe(40);
|
||
|
|
|
||
|
|
// Verify reservation movement
|
||
|
|
$movement = InventoryMovement::where('reference', 'ORD-100')
|
||
|
|
->where('type', InventoryMovement::TYPE_RESERVED)
|
||
|
|
->first();
|
||
|
|
expect($movement)->not->toBeNull()
|
||
|
|
->and($movement->quantity)->toBe(-10)
|
||
|
|
->and($movement->notes)->toContain('Reserved for order ORD-100');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false when insufficient available stock', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'res2',
|
||
|
|
'name' => 'Reserve Warehouse 2',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 5);
|
||
|
|
|
||
|
|
$result = $this->service->reserveStock($this->product, $warehouse, 10, 'ORD-BIG');
|
||
|
|
|
||
|
|
expect($result)->toBeFalse();
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->reserved_quantity)->toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('allows multiple reservations up to available stock', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'multi',
|
||
|
|
'name' => 'Multi Reserve Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 30);
|
||
|
|
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-A');
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-B');
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->reserved_quantity)->toBe(20)
|
||
|
|
->and($inventory->getAvailableQuantity())->toBe(10);
|
||
|
|
|
||
|
|
// Third reservation for 15 should fail (only 10 available)
|
||
|
|
$result = $this->service->reserveStock($this->product, $warehouse, 15, 'ORD-C');
|
||
|
|
expect($result)->toBeFalse();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('releaseStock()', function () {
|
||
|
|
it('releases reserved stock', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'rel',
|
||
|
|
'name' => 'Release Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 50);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 20, 'ORD-REL');
|
||
|
|
|
||
|
|
$this->service->releaseStock($this->product, $warehouse, 20, 'ORD-REL');
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->reserved_quantity)->toBe(0)
|
||
|
|
->and($inventory->getAvailableQuantity())->toBe(50);
|
||
|
|
|
||
|
|
// Verify release movement
|
||
|
|
$movement = InventoryMovement::where('reference', 'ORD-REL')
|
||
|
|
->where('type', InventoryMovement::TYPE_RELEASED)
|
||
|
|
->first();
|
||
|
|
expect($movement)->not->toBeNull()
|
||
|
|
->and($movement->quantity)->toBe(20)
|
||
|
|
->and($movement->notes)->toContain('Released from order ORD-REL');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('fulfillStock()', function () {
|
||
|
|
it('converts reserved stock to a sale', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'ful',
|
||
|
|
'name' => 'Fulfil Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 100);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 25, 'ORD-FUL');
|
||
|
|
|
||
|
|
$result = $this->service->fulfillStock($this->product, $warehouse, 25, 'ORD-FUL');
|
||
|
|
|
||
|
|
expect($result)->toBeTrue();
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
// quantity should decrease by 25, reserved should decrease by 25
|
||
|
|
expect($inventory->quantity)->toBe(75)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(0);
|
||
|
|
|
||
|
|
// Verify fulfilment movement
|
||
|
|
$movement = InventoryMovement::where('reference', 'ORD-FUL')
|
||
|
|
->where('type', InventoryMovement::TYPE_SALE)
|
||
|
|
->first();
|
||
|
|
expect($movement)->not->toBeNull()
|
||
|
|
->and($movement->quantity)->toBe(-25);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false when reserved quantity is insufficient', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'ful2',
|
||
|
|
'name' => 'Fulfil Warehouse 2',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 50);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-SMALL');
|
||
|
|
|
||
|
|
$result = $this->service->fulfillStock($this->product, $warehouse, 20, 'ORD-SMALL');
|
||
|
|
|
||
|
|
expect($result)->toBeFalse();
|
||
|
|
|
||
|
|
// Nothing should change
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->quantity)->toBe(50)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(10);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Stock Transfer
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('transferStock()', function () {
|
||
|
|
it('transfers stock between warehouses', function () {
|
||
|
|
$warehouseA = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'src',
|
||
|
|
'name' => 'Source Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouseB = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'dst',
|
||
|
|
'name' => 'Destination Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouseA, 100);
|
||
|
|
|
||
|
|
$result = $this->service->transferStock(
|
||
|
|
$this->product,
|
||
|
|
$warehouseA,
|
||
|
|
$warehouseB,
|
||
|
|
40,
|
||
|
|
'Redistribution to new warehouse'
|
||
|
|
);
|
||
|
|
|
||
|
|
expect($result)->toBeTrue();
|
||
|
|
|
||
|
|
$fromInventory = $this->service->getOrCreateInventory($this->product, $warehouseA);
|
||
|
|
$toInventory = $this->service->getOrCreateInventory($this->product, $warehouseB);
|
||
|
|
|
||
|
|
expect($fromInventory->quantity)->toBe(60)
|
||
|
|
->and($toInventory->quantity)->toBe(40);
|
||
|
|
|
||
|
|
// Verify transfer movements
|
||
|
|
$outMovement = InventoryMovement::where('warehouse_id', $warehouseA->id)
|
||
|
|
->where('type', InventoryMovement::TYPE_TRANSFER_OUT)
|
||
|
|
->first();
|
||
|
|
$inMovement = InventoryMovement::where('warehouse_id', $warehouseB->id)
|
||
|
|
->where('type', InventoryMovement::TYPE_TRANSFER_IN)
|
||
|
|
->first();
|
||
|
|
|
||
|
|
expect($outMovement)->not->toBeNull()
|
||
|
|
->and($outMovement->quantity)->toBe(-40)
|
||
|
|
->and($outMovement->notes)->toBe('Redistribution to new warehouse')
|
||
|
|
->and($inMovement)->not->toBeNull()
|
||
|
|
->and($inMovement->quantity)->toBe(40)
|
||
|
|
->and($inMovement->notes)->toBe('Redistribution to new warehouse');
|
||
|
|
|
||
|
|
// Both should share the same TRANSFER reference
|
||
|
|
expect($outMovement->reference)->toStartWith('TRANSFER-')
|
||
|
|
->and($outMovement->reference)->toBe($inMovement->reference);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false when source has insufficient stock', function () {
|
||
|
|
$warehouseA = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'src2',
|
||
|
|
'name' => 'Source 2',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouseB = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'dst2',
|
||
|
|
'name' => 'Destination 2',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouseA, 10);
|
||
|
|
|
||
|
|
$result = $this->service->transferStock($this->product, $warehouseA, $warehouseB, 50);
|
||
|
|
|
||
|
|
expect($result)->toBeFalse();
|
||
|
|
|
||
|
|
// Source stock should be unchanged
|
||
|
|
$fromInventory = $this->service->getOrCreateInventory($this->product, $warehouseA);
|
||
|
|
expect($fromInventory->quantity)->toBe(10);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Stock Count Adjustment
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('adjustStock()', function () {
|
||
|
|
it('adjusts stock upward and returns positive difference', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'adj',
|
||
|
|
'name' => 'Adjustment Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 40);
|
||
|
|
|
||
|
|
$difference = $this->service->adjustStock($this->product, $warehouse, 55, 'Physical count +15');
|
||
|
|
|
||
|
|
expect($difference)->toBe(15);
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->quantity)->toBe(55);
|
||
|
|
|
||
|
|
// Verify count movement
|
||
|
|
$movement = InventoryMovement::where('type', InventoryMovement::TYPE_COUNT)
|
||
|
|
->where('inventory_id', $inventory->id)
|
||
|
|
->first();
|
||
|
|
expect($movement)->not->toBeNull()
|
||
|
|
->and($movement->quantity)->toBe(15)
|
||
|
|
->and($movement->reference)->toStartWith('COUNT-');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('adjusts stock downward and returns negative difference', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'adj2',
|
||
|
|
'name' => 'Adjustment Warehouse 2',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 50);
|
||
|
|
|
||
|
|
$difference = $this->service->adjustStock($this->product, $warehouse, 42);
|
||
|
|
|
||
|
|
expect($difference)->toBe(-8);
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->quantity)->toBe(42);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses default notes when none provided', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'adj3',
|
||
|
|
'name' => 'Adjustment Warehouse 3',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 10);
|
||
|
|
$this->service->adjustStock($this->product, $warehouse, 15);
|
||
|
|
|
||
|
|
$movement = InventoryMovement::where('type', InventoryMovement::TYPE_COUNT)->first();
|
||
|
|
expect($movement->notes)->toBe('Physical count adjustment');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Query Methods
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('getTotalStock()', function () {
|
||
|
|
it('sums stock across all warehouses', function () {
|
||
|
|
$warehouseA = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'ts-a',
|
||
|
|
'name' => 'Total Stock A',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouseB = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'ts-b',
|
||
|
|
'name' => 'Total Stock B',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouseA, 30);
|
||
|
|
$this->service->addStock($this->product, $warehouseB, 70);
|
||
|
|
|
||
|
|
$total = $this->service->getTotalStock($this->product);
|
||
|
|
|
||
|
|
expect($total)->toBe(100);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns zero when no inventory exists', function () {
|
||
|
|
$total = $this->service->getTotalStock($this->product);
|
||
|
|
|
||
|
|
expect($total)->toBe(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getTotalAvailableStock()', function () {
|
||
|
|
it('subtracts reserved quantities from total', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'avail',
|
||
|
|
'name' => 'Available Stock Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 80);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 30, 'ORD-AVL');
|
||
|
|
|
||
|
|
$available = $this->service->getTotalAvailableStock($this->product);
|
||
|
|
|
||
|
|
expect($available)->toBe(50);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns zero when all stock is reserved', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'allres',
|
||
|
|
'name' => 'All Reserved Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 20);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 20, 'ORD-ALL');
|
||
|
|
|
||
|
|
$available = $this->service->getTotalAvailableStock($this->product);
|
||
|
|
|
||
|
|
expect($available)->toBe(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getStockByWarehouse()', function () {
|
||
|
|
it('returns inventory records with warehouse relation', function () {
|
||
|
|
$warehouseA = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'sbw-a',
|
||
|
|
'name' => 'Stock By WH A',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$warehouseB = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'sbw-b',
|
||
|
|
'name' => 'Stock By WH B',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouseA, 25);
|
||
|
|
$this->service->addStock($this->product, $warehouseB, 75);
|
||
|
|
|
||
|
|
$stock = $this->service->getStockByWarehouse($this->product);
|
||
|
|
|
||
|
|
expect($stock)->toHaveCount(2);
|
||
|
|
|
||
|
|
// Each record should have warehouse loaded
|
||
|
|
$stock->each(function ($inv) {
|
||
|
|
expect($inv->relationLoaded('warehouse'))->toBeTrue()
|
||
|
|
->and($inv->warehouse)->toBeInstanceOf(Warehouse::class);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('findBestWarehouse()', function () {
|
||
|
|
it('returns primary warehouse when it has sufficient stock', function () {
|
||
|
|
$primary = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'best-p',
|
||
|
|
'name' => 'Primary Best',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => true,
|
||
|
|
'can_ship' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$secondary = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'best-s',
|
||
|
|
'name' => 'Secondary Best',
|
||
|
|
'is_active' => true,
|
||
|
|
'is_primary' => false,
|
||
|
|
'can_ship' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $primary, 50);
|
||
|
|
$this->service->addStock($this->product, $secondary, 50);
|
||
|
|
|
||
|
|
$best = $this->service->findBestWarehouse($this->product, 10);
|
||
|
|
|
||
|
|
expect($best)->not->toBeNull()
|
||
|
|
->and($best->id)->toBe($primary->id);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns null when no warehouse can fulfil the quantity', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'best-n',
|
||
|
|
'name' => 'Not Enough',
|
||
|
|
'is_active' => true,
|
||
|
|
'can_ship' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 5);
|
||
|
|
|
||
|
|
$best = $this->service->findBestWarehouse($this->product, 50);
|
||
|
|
|
||
|
|
expect($best)->toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('excludes warehouses that cannot ship', function () {
|
||
|
|
$noShip = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'nosh',
|
||
|
|
'name' => 'No Ship Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
'can_ship' => false,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $noShip, 100);
|
||
|
|
|
||
|
|
$best = $this->service->findBestWarehouse($this->product, 10);
|
||
|
|
|
||
|
|
expect($best)->toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getLowStockProducts()', function () {
|
||
|
|
it('returns inventory records below threshold', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'low',
|
||
|
|
'name' => 'Low Stock Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Product with 3 units (default threshold is 5)
|
||
|
|
$this->service->addStock($this->product, $warehouse, 3);
|
||
|
|
|
||
|
|
// Product with plenty of stock
|
||
|
|
$this->service->addStock($this->productB, $warehouse, 100);
|
||
|
|
|
||
|
|
$lowStock = $this->service->getLowStockProducts($this->entity);
|
||
|
|
|
||
|
|
expect($lowStock)->toHaveCount(1)
|
||
|
|
->and($lowStock->first()->product_id)->toBe($this->product->id);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getOutOfStockProducts()', function () {
|
||
|
|
it('returns inventory records with zero or negative available quantity', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'oos',
|
||
|
|
'name' => 'Out of Stock Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Product with zero stock
|
||
|
|
$this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
|
||
|
|
// Product with stock
|
||
|
|
$this->service->addStock($this->productB, $warehouse, 50);
|
||
|
|
|
||
|
|
$outOfStock = $this->service->getOutOfStockProducts($this->entity);
|
||
|
|
|
||
|
|
expect($outOfStock)->toHaveCount(1)
|
||
|
|
->and($outOfStock->first()->product_id)->toBe($this->product->id);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getMovementHistory()', function () {
|
||
|
|
it('returns movements ordered by newest first', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'hist',
|
||
|
|
'name' => 'History Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 100);
|
||
|
|
$this->service->removeStock($this->product, $warehouse, 20, InventoryMovement::TYPE_SALE, 'ORD-H1');
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-H2');
|
||
|
|
|
||
|
|
$history = $this->service->getMovementHistory($this->product);
|
||
|
|
|
||
|
|
expect($history)->toHaveCount(3)
|
||
|
|
->and($history->first()->created_at->gte($history->last()->created_at))->toBeTrue();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('respects the limit parameter', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'lim',
|
||
|
|
'name' => 'Limit Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Create several movements
|
||
|
|
$this->service->addStock($this->product, $warehouse, 100);
|
||
|
|
$this->service->removeStock($this->product, $warehouse, 10);
|
||
|
|
$this->service->removeStock($this->product, $warehouse, 10);
|
||
|
|
$this->service->removeStock($this->product, $warehouse, 10);
|
||
|
|
|
||
|
|
$history = $this->service->getMovementHistory($this->product, 2);
|
||
|
|
|
||
|
|
expect($history)->toHaveCount(2);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
// Full Lifecycle Integration
|
||
|
|
// ──────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('full order lifecycle', function () {
|
||
|
|
it('handles reserve -> fulfil workflow', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'life',
|
||
|
|
'name' => 'Lifecycle Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Receive stock
|
||
|
|
$this->service->addStock($this->product, $warehouse, 50);
|
||
|
|
|
||
|
|
// Customer places order: reserve
|
||
|
|
$reserved = $this->service->reserveStock($this->product, $warehouse, 5, 'ORD-LIFE');
|
||
|
|
expect($reserved)->toBeTrue();
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->quantity)->toBe(50)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(5)
|
||
|
|
->and($inventory->getAvailableQuantity())->toBe(45);
|
||
|
|
|
||
|
|
// Ship order: fulfil
|
||
|
|
$fulfilled = $this->service->fulfillStock($this->product, $warehouse, 5, 'ORD-LIFE');
|
||
|
|
expect($fulfilled)->toBeTrue();
|
||
|
|
|
||
|
|
$inventory->refresh();
|
||
|
|
expect($inventory->quantity)->toBe(45)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(0)
|
||
|
|
->and($inventory->getAvailableQuantity())->toBe(45);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles reserve -> release workflow (cancelled order)', function () {
|
||
|
|
$warehouse = $this->service->createWarehouse($this->entity, [
|
||
|
|
'code' => 'canc',
|
||
|
|
'name' => 'Cancel Warehouse',
|
||
|
|
'is_active' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->service->addStock($this->product, $warehouse, 30);
|
||
|
|
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-CANC');
|
||
|
|
|
||
|
|
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
|
||
|
|
expect($inventory->getAvailableQuantity())->toBe(20);
|
||
|
|
|
||
|
|
// Order cancelled: release
|
||
|
|
$this->service->releaseStock($this->product, $warehouse, 10, 'ORD-CANC');
|
||
|
|
|
||
|
|
$inventory->refresh();
|
||
|
|
expect($inventory->quantity)->toBe(30)
|
||
|
|
->and($inventory->reserved_quantity)->toBe(0)
|
||
|
|
->and($inventory->getAvailableQuantity())->toBe(30);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|