feat(inventory): add calculated availability and movement ledger
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Enums;
|
||||
|
||||
enum InventoryMovementOperation: string
|
||||
{
|
||||
case StockInitialized = 'stock_initialized';
|
||||
case StockIncreased = 'stock_increased';
|
||||
case StockDecreased = 'stock_decreased';
|
||||
case StockReserved = 'stock_reserved';
|
||||
case StockReleased = 'stock_released';
|
||||
case PurchaseCommitted = 'purchase_committed';
|
||||
case StockRefunded = 'stock_refunded';
|
||||
case EntryStockReserved = 'entry_stock_reserved';
|
||||
case EntryStockReleased = 'entry_stock_released';
|
||||
case InventoryReset = 'inventory_reset';
|
||||
case InventoryTransferred = 'inventory_transferred';
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
@@ -213,10 +214,14 @@ class CatalogItem extends Model
|
||||
/** @param Builder<CatalogItem> $query */
|
||||
public function scopeWhereAvailable(Builder $query): Builder
|
||||
{
|
||||
$availableInventory = static fn (Builder $inventoryQuery): Builder => Schema::hasColumn('inventories', 'available_stock')
|
||||
? $inventoryQuery->where('inventories.available_stock', '>', 0)
|
||||
: $inventoryQuery->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock');
|
||||
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query->whereNull('catalog_items.sales_end_at')
|
||||
->orWhere('catalog_items.sales_end_at', '>', now());
|
||||
})->where(function (Builder $query): void {
|
||||
})->where(function (Builder $query) use ($availableInventory): void {
|
||||
$query
|
||||
->where(function (Builder $unlimitedQuery): void {
|
||||
$unlimitedQuery
|
||||
@@ -236,20 +241,18 @@ class CatalogItem extends Model
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->whereHas(
|
||||
'inventory',
|
||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
|
||||
$availableInventory
|
||||
)
|
||||
)
|
||||
->orWhere(function (Builder $directItemQuery): void {
|
||||
->orWhere(function (Builder $directItemQuery) use ($availableInventory): void {
|
||||
$directItemQuery
|
||||
->whereDoesntHave('variants')
|
||||
->where(function (Builder $inventoryQuery): void {
|
||||
->where(function (Builder $inventoryQuery) use ($availableInventory): void {
|
||||
$inventoryQuery
|
||||
->whereNull('catalog_items.inventory_id')
|
||||
->orWhereHas(
|
||||
'inventory',
|
||||
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
|
||||
$availableInventory
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Models;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
#[Fillable([
|
||||
'sold_units',
|
||||
@@ -38,6 +44,7 @@ class Inventory extends Model
|
||||
'reserved_stock' => 'integer',
|
||||
'entry_reserved_stock' => 'integer',
|
||||
'real_stock' => 'integer',
|
||||
'available_stock' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -59,70 +66,295 @@ class Inventory extends Model
|
||||
return $this->hasMany(StockReservationLine::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<InventoryMovement, $this> */
|
||||
public function movements(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryMovement::class);
|
||||
}
|
||||
|
||||
public function availableStock(): int
|
||||
{
|
||||
return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock);
|
||||
if (! array_key_exists('available_stock', $this->attributes)) {
|
||||
return max(0, $this->real_stock - $this->reserved_stock - ($this->entry_reserved_stock ?? 0));
|
||||
}
|
||||
|
||||
return (int) $this->getAttribute('available_stock');
|
||||
}
|
||||
|
||||
public function reserveEntry(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) {
|
||||
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
|
||||
}
|
||||
$this->entry_reserved_stock += $amount;
|
||||
$this->save();
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::EntryStockReserved,
|
||||
['entry_reserved_stock' => $amount],
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
|
||||
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function releaseEntry(int $amount): void
|
||||
{
|
||||
if ($amount < 1 || $this->entry_reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
|
||||
}
|
||||
|
||||
$this->entry_reserved_stock -= $amount;
|
||||
$this->save();
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::EntryStockReleased,
|
||||
['entry_reserved_stock' => -$amount],
|
||||
function (self $inventory) use ($amount): void {
|
||||
if ($amount < 1 || $inventory->entry_reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function reserve(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
||||
}
|
||||
|
||||
if ($tracksInventory && $this->availableStock() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
$this->reserved_stock += $amount;
|
||||
$this->save();
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::StockReserved,
|
||||
['reserved_stock' => $amount],
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
||||
}
|
||||
if ($tracksInventory && $inventory->availableStock() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function release(int $amount): void
|
||||
{
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
||||
}
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->save();
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::StockReleased,
|
||||
['reserved_stock' => -$amount],
|
||||
function (self $inventory) use ($amount): void {
|
||||
if ($amount < 0 || $inventory->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function buy(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
|
||||
$counterDeltas = ['reserved_stock' => -$amount, 'sold_units' => $amount];
|
||||
if (! array_key_exists('available_stock', $this->attributes) && $tracksInventory) {
|
||||
$counterDeltas['real_stock'] = -$amount;
|
||||
}
|
||||
|
||||
if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::PurchaseCommitted,
|
||||
$counterDeltas,
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 0 || $inventory->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
|
||||
}
|
||||
if ($tracksInventory && $inventory->availableStock() < 0) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function adjustAvailableStock(
|
||||
int $delta,
|
||||
?User $responsibleUser = null,
|
||||
?string $idempotencyKey = null,
|
||||
): void {
|
||||
if ($delta === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tracksInventory) {
|
||||
$this->real_stock -= $amount;
|
||||
$operation = $delta > 0
|
||||
? InventoryMovementOperation::StockIncreased
|
||||
: InventoryMovementOperation::StockDecreased;
|
||||
|
||||
$this->mutate(
|
||||
$operation,
|
||||
['real_stock' => $delta],
|
||||
function (self $inventory) use ($delta): void {
|
||||
if ($inventory->real_stock + $delta < 0 || $inventory->availableStock() + $delta < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'stock_difference' => ['El ajuste dejaría el stock en un valor inválido.'],
|
||||
]);
|
||||
}
|
||||
},
|
||||
$responsibleUser,
|
||||
$idempotencyKey,
|
||||
);
|
||||
}
|
||||
|
||||
public function refundStock(int $amount, ?User $responsibleUser = null): void
|
||||
{
|
||||
$counterDeltas = ['refunded_units' => $amount];
|
||||
if (! array_key_exists('available_stock', $this->attributes)) {
|
||||
$counterDeltas['real_stock'] = $amount;
|
||||
}
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->sold_units += $amount;
|
||||
$this->save();
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::StockRefunded,
|
||||
$counterDeltas,
|
||||
function () use ($amount): void {
|
||||
if ($amount < 1) {
|
||||
throw new \InvalidArgumentException('La cantidad devuelta debe ser positiva.');
|
||||
}
|
||||
},
|
||||
$responsibleUser,
|
||||
);
|
||||
}
|
||||
|
||||
public function commitDirectSale(int $amount, bool $tracksInventory = true): void
|
||||
{
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::PurchaseCommitted,
|
||||
['sold_units' => $amount],
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock para confirmar la compra.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function recordInitialization(?User $responsibleUser = null): void
|
||||
{
|
||||
$this->recordCurrentState(InventoryMovementOperation::StockInitialized, $responsibleUser);
|
||||
}
|
||||
|
||||
public function recordTransferInitialization(?User $responsibleUser = null): void
|
||||
{
|
||||
$this->recordCurrentState(InventoryMovementOperation::InventoryTransferred, $responsibleUser);
|
||||
}
|
||||
|
||||
/** @param array<string, int> $counterDeltas */
|
||||
public function transferCounters(array $counterDeltas, ?User $responsibleUser = null): void
|
||||
{
|
||||
$counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0);
|
||||
if ($counterDeltas === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::InventoryTransferred,
|
||||
$counterDeltas,
|
||||
function (self $inventory) use ($counterDeltas): void {
|
||||
foreach ($counterDeltas as $counter => $delta) {
|
||||
if ((int) $inventory->getAttribute($counter) + $delta < 0) {
|
||||
throw new \LogicException("El traslado dejaría {$counter} en un valor negativo.");
|
||||
}
|
||||
}
|
||||
},
|
||||
$responsibleUser,
|
||||
);
|
||||
}
|
||||
|
||||
private function recordCurrentState(
|
||||
InventoryMovementOperation $operation,
|
||||
?User $responsibleUser,
|
||||
): void {
|
||||
if (! Schema::hasTable('inventory_movements')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refresh();
|
||||
$counterDeltas = array_filter([
|
||||
'real_stock' => $this->real_stock,
|
||||
'sold_units' => $this->sold_units,
|
||||
'reserved_stock' => $this->reserved_stock,
|
||||
'entry_reserved_stock' => $this->entry_reserved_stock,
|
||||
'refunded_units' => $this->refunded_units,
|
||||
], fn (int $delta): bool => $delta !== 0);
|
||||
|
||||
$this->movements()->create([
|
||||
'operation' => $operation,
|
||||
'available_stock_delta' => $this->availableStock(),
|
||||
'available_stock_before' => 0,
|
||||
'available_stock_after' => $this->availableStock(),
|
||||
'counter_deltas' => $counterDeltas,
|
||||
'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetTransactionCounters(?User $responsibleUser = null): void
|
||||
{
|
||||
$this->refresh();
|
||||
$counterDeltas = array_filter([
|
||||
'sold_units' => -$this->sold_units,
|
||||
'reserved_stock' => -$this->reserved_stock,
|
||||
'entry_reserved_stock' => -$this->entry_reserved_stock,
|
||||
'refunded_units' => -$this->refunded_units,
|
||||
], fn (int $delta): bool => $delta !== 0);
|
||||
|
||||
if ($counterDeltas === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::InventoryReset,
|
||||
$counterDeltas,
|
||||
static function (): void {},
|
||||
$responsibleUser,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counterDeltas
|
||||
* @param callable(self): void $validate
|
||||
*/
|
||||
private function mutate(
|
||||
InventoryMovementOperation $operation,
|
||||
array $counterDeltas,
|
||||
callable $validate,
|
||||
?User $responsibleUser = null,
|
||||
?string $idempotencyKey = null,
|
||||
): void {
|
||||
DB::transaction(function () use ($operation, $counterDeltas, $validate, $responsibleUser, $idempotencyKey): void {
|
||||
/** @var self $inventory */
|
||||
$inventory = self::query()->lockForUpdate()->findOrFail($this->getKey());
|
||||
$this->setRawAttributes($inventory->getAttributes(), true);
|
||||
|
||||
$recordsMovements = Schema::hasTable('inventory_movements');
|
||||
if ($recordsMovements && $idempotencyKey !== null) {
|
||||
$existing = $this->movements()
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
if ($existing !== null) {
|
||||
if ($existing->counter_deltas !== $counterDeltas) {
|
||||
throw ValidationException::withMessages([
|
||||
'stock_adjustment_id' => ['La operación ya fue utilizada con otro ajuste.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$validate($this);
|
||||
$counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0);
|
||||
if ($counterDeltas === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$availableBefore = $this->availableStock();
|
||||
foreach ($counterDeltas as $counter => $delta) {
|
||||
$this->setAttribute($counter, (int) $this->getAttribute($counter) + $delta);
|
||||
}
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
$availableAfter = $this->availableStock();
|
||||
if ($recordsMovements) {
|
||||
$this->movements()->create([
|
||||
'operation' => $operation,
|
||||
'available_stock_delta' => $availableAfter - $availableBefore,
|
||||
'available_stock_before' => $availableBefore,
|
||||
'available_stock_after' => $availableAfter,
|
||||
'counter_deltas' => $counterDeltas,
|
||||
'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Domains/Commerce/Catalog/Models/InventoryMovement.php
Normal file
45
app/Domains/Commerce/Catalog/Models/InventoryMovement.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Models;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'inventory_id',
|
||||
'operation',
|
||||
'available_stock_delta',
|
||||
'available_stock_before',
|
||||
'available_stock_after',
|
||||
'counter_deltas',
|
||||
'responsible_user_id',
|
||||
'idempotency_key',
|
||||
])]
|
||||
class InventoryMovement extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'operation' => InventoryMovementOperation::class,
|
||||
'available_stock_delta' => 'integer',
|
||||
'available_stock_before' => 'integer',
|
||||
'available_stock_after' => 'integer',
|
||||
'counter_deltas' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function responsibleUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'responsible_user_id');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user