Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
391 lines
10 KiB
PHP
391 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Commerce\Services;
|
|
|
|
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 Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Warehouse Service - Manages warehouses and inventory.
|
|
*
|
|
* Provides operations for:
|
|
* - Warehouse management
|
|
* - Stock tracking across warehouses
|
|
* - Inventory movements and audit trail
|
|
* - Stock allocation and fulfillment
|
|
*/
|
|
class WarehouseService
|
|
{
|
|
/**
|
|
* Create a warehouse for an M1 entity.
|
|
*/
|
|
public function createWarehouse(Entity $entity, array $data): Warehouse
|
|
{
|
|
if (! $entity->isM1()) {
|
|
throw new \InvalidArgumentException(
|
|
'Only M1 (Master) entities can own warehouses.'
|
|
);
|
|
}
|
|
|
|
$data['entity_id'] = $entity->id;
|
|
$data['code'] = strtoupper($data['code']);
|
|
|
|
return Warehouse::create($data);
|
|
}
|
|
|
|
/**
|
|
* Get all warehouses for an entity.
|
|
*/
|
|
public function getWarehousesForEntity(Entity $entity): Collection
|
|
{
|
|
return Warehouse::forEntity($entity->id)
|
|
->active()
|
|
->orderBy('is_primary', 'desc')
|
|
->orderBy('name')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Get primary warehouse for an entity.
|
|
*/
|
|
public function getPrimaryWarehouse(Entity $entity): ?Warehouse
|
|
{
|
|
return Warehouse::forEntity($entity->id)
|
|
->active()
|
|
->primary()
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* Set a warehouse as primary.
|
|
*/
|
|
public function setPrimaryWarehouse(Warehouse $warehouse): void
|
|
{
|
|
DB::transaction(function () use ($warehouse) {
|
|
// Remove primary from all other warehouses for this entity
|
|
Warehouse::forEntity($warehouse->entity_id)
|
|
->where('id', '!=', $warehouse->id)
|
|
->update(['is_primary' => false]);
|
|
|
|
$warehouse->update(['is_primary' => true]);
|
|
});
|
|
}
|
|
|
|
// Inventory operations
|
|
|
|
/**
|
|
* Get or create inventory record for product at warehouse.
|
|
*/
|
|
public function getOrCreateInventory(Product $product, Warehouse $warehouse): Inventory
|
|
{
|
|
return Inventory::firstOrCreate(
|
|
[
|
|
'product_id' => $product->id,
|
|
'warehouse_id' => $warehouse->id,
|
|
],
|
|
[
|
|
'quantity' => 0,
|
|
'reserved_quantity' => 0,
|
|
'incoming_quantity' => 0,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Add stock to a warehouse.
|
|
*/
|
|
public function addStock(
|
|
Product $product,
|
|
Warehouse $warehouse,
|
|
int $quantity,
|
|
string $type = InventoryMovement::TYPE_PURCHASE,
|
|
?string $reference = null,
|
|
?string $notes = null,
|
|
?int $unitCost = null
|
|
): Inventory {
|
|
$inventory = $this->getOrCreateInventory($product, $warehouse);
|
|
|
|
DB::transaction(function () use ($inventory, $quantity, $type, $reference, $notes, $unitCost) {
|
|
$inventory->addStock($quantity);
|
|
|
|
if ($unitCost !== null) {
|
|
$inventory->update(['unit_cost' => $unitCost]);
|
|
}
|
|
|
|
InventoryMovement::record(
|
|
$inventory,
|
|
$type,
|
|
$quantity,
|
|
$reference,
|
|
$notes,
|
|
null,
|
|
$unitCost
|
|
);
|
|
});
|
|
|
|
return $inventory->fresh();
|
|
}
|
|
|
|
/**
|
|
* Remove stock from a warehouse.
|
|
*/
|
|
public function removeStock(
|
|
Product $product,
|
|
Warehouse $warehouse,
|
|
int $quantity,
|
|
string $type = InventoryMovement::TYPE_SALE,
|
|
?string $reference = null,
|
|
?string $notes = null
|
|
): bool {
|
|
$inventory = $this->getOrCreateInventory($product, $warehouse);
|
|
|
|
if ($inventory->getAvailableQuantity() < $quantity) {
|
|
return false;
|
|
}
|
|
|
|
DB::transaction(function () use ($inventory, $quantity, $type, $reference, $notes) {
|
|
$inventory->removeStock($quantity);
|
|
|
|
InventoryMovement::record(
|
|
$inventory,
|
|
$type,
|
|
-$quantity,
|
|
$reference,
|
|
$notes
|
|
);
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Reserve stock for an order.
|
|
*/
|
|
public function reserveStock(
|
|
Product $product,
|
|
Warehouse $warehouse,
|
|
int $quantity,
|
|
string $orderId
|
|
): bool {
|
|
$inventory = $this->getOrCreateInventory($product, $warehouse);
|
|
|
|
if (! $inventory->reserve($quantity)) {
|
|
return false;
|
|
}
|
|
|
|
InventoryMovement::record(
|
|
$inventory,
|
|
InventoryMovement::TYPE_RESERVED,
|
|
-$quantity,
|
|
$orderId,
|
|
"Reserved for order {$orderId}"
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Release reserved stock.
|
|
*/
|
|
public function releaseStock(
|
|
Product $product,
|
|
Warehouse $warehouse,
|
|
int $quantity,
|
|
string $orderId
|
|
): void {
|
|
$inventory = $this->getOrCreateInventory($product, $warehouse);
|
|
|
|
$inventory->release($quantity);
|
|
|
|
InventoryMovement::record(
|
|
$inventory,
|
|
InventoryMovement::TYPE_RELEASED,
|
|
$quantity,
|
|
$orderId,
|
|
"Released from order {$orderId}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fulfill reserved stock (convert to sale).
|
|
*/
|
|
public function fulfillStock(
|
|
Product $product,
|
|
Warehouse $warehouse,
|
|
int $quantity,
|
|
string $orderId
|
|
): bool {
|
|
$inventory = $this->getOrCreateInventory($product, $warehouse);
|
|
|
|
if (! $inventory->fulfill($quantity)) {
|
|
return false;
|
|
}
|
|
|
|
InventoryMovement::record(
|
|
$inventory,
|
|
InventoryMovement::TYPE_SALE,
|
|
-$quantity,
|
|
$orderId,
|
|
"Fulfilled for order {$orderId}"
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Transfer stock between warehouses.
|
|
*/
|
|
public function transferStock(
|
|
Product $product,
|
|
Warehouse $from,
|
|
Warehouse $to,
|
|
int $quantity,
|
|
?string $notes = null
|
|
): bool {
|
|
$fromInventory = $this->getOrCreateInventory($product, $from);
|
|
|
|
if ($fromInventory->getAvailableQuantity() < $quantity) {
|
|
return false;
|
|
}
|
|
|
|
$toInventory = $this->getOrCreateInventory($product, $to);
|
|
$reference = 'TRANSFER-'.now()->format('YmdHis');
|
|
|
|
DB::transaction(function () use ($fromInventory, $toInventory, $quantity, $reference, $notes) {
|
|
$fromInventory->removeStock($quantity);
|
|
$toInventory->addStock($quantity);
|
|
|
|
InventoryMovement::record(
|
|
$fromInventory,
|
|
InventoryMovement::TYPE_TRANSFER_OUT,
|
|
-$quantity,
|
|
$reference,
|
|
$notes
|
|
);
|
|
|
|
InventoryMovement::record(
|
|
$toInventory,
|
|
InventoryMovement::TYPE_TRANSFER_IN,
|
|
$quantity,
|
|
$reference,
|
|
$notes
|
|
);
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Perform stock count adjustment.
|
|
*/
|
|
public function adjustStock(
|
|
Product $product,
|
|
Warehouse $warehouse,
|
|
int $newQuantity,
|
|
?string $notes = null
|
|
): int {
|
|
$inventory = $this->getOrCreateInventory($product, $warehouse);
|
|
|
|
$difference = DB::transaction(function () use ($inventory, $newQuantity, $notes) {
|
|
$difference = $inventory->setCount($newQuantity);
|
|
|
|
InventoryMovement::record(
|
|
$inventory,
|
|
InventoryMovement::TYPE_COUNT,
|
|
$difference,
|
|
'COUNT-'.now()->format('YmdHis'),
|
|
$notes ?? 'Physical count adjustment'
|
|
);
|
|
|
|
return $difference;
|
|
});
|
|
|
|
return $difference;
|
|
}
|
|
|
|
// Query methods
|
|
|
|
/**
|
|
* Get total stock across all warehouses.
|
|
*/
|
|
public function getTotalStock(Product $product): int
|
|
{
|
|
return (int) Inventory::forProduct($product->id)->sum('quantity');
|
|
}
|
|
|
|
/**
|
|
* Get available stock across all warehouses.
|
|
*/
|
|
public function getTotalAvailableStock(Product $product): int
|
|
{
|
|
return (int) (Inventory::forProduct($product->id)
|
|
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
|
->value('available') ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Get stock by warehouse.
|
|
*/
|
|
public function getStockByWarehouse(Product $product): Collection
|
|
{
|
|
return Inventory::forProduct($product->id)
|
|
->with('warehouse')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Find best warehouse to fulfill order.
|
|
*/
|
|
public function findBestWarehouse(Product $product, int $quantity): ?Warehouse
|
|
{
|
|
return Warehouse::active()
|
|
->canShip()
|
|
->whereHas('inventory', function ($query) use ($product, $quantity) {
|
|
$query->forProduct($product->id)
|
|
->whereRaw('(quantity - reserved_quantity) >= ?', [$quantity]);
|
|
})
|
|
->orderBy('is_primary', 'desc')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* Get low stock products.
|
|
*/
|
|
public function getLowStockProducts(Entity $entity): Collection
|
|
{
|
|
return Inventory::with(['product', 'warehouse'])
|
|
->whereHas('warehouse', fn ($q) => $q->forEntity($entity->id))
|
|
->lowStock()
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Get out of stock products.
|
|
*/
|
|
public function getOutOfStockProducts(Entity $entity): Collection
|
|
{
|
|
return Inventory::with(['product', 'warehouse'])
|
|
->whereHas('warehouse', fn ($q) => $q->forEntity($entity->id))
|
|
->outOfStock()
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Get inventory movements for a product.
|
|
*/
|
|
public function getMovementHistory(Product $product, int $limit = 50): Collection
|
|
{
|
|
return InventoryMovement::forProduct($product->id)
|
|
->with(['warehouse', 'user'])
|
|
->orderBy('created_at', 'desc')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
}
|