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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public $withinTransaction = false;
|
||||
|
||||
private const FORMULA = 'CAST(real_stock AS SIGNED) - CAST(sold_units AS SIGNED) - CAST(reserved_stock AS SIGNED) - CAST(entry_reserved_stock AS SIGNED) + CAST(refunded_units AS SIGNED)';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$conversion = 'CAST(real_stock AS SIGNED) + CAST(sold_units AS SIGNED) - CAST(refunded_units AS SIGNED)';
|
||||
$this->assertNonNegative($conversion, 'la conversión produciría un stock real negativo');
|
||||
$this->assertNonNegative(
|
||||
"({$conversion}) - CAST(sold_units AS SIGNED) - CAST(reserved_stock AS SIGNED) - CAST(entry_reserved_stock AS SIGNED) + CAST(refunded_units AS SIGNED)",
|
||||
'el stock disponible actual es negativo',
|
||||
);
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
Schema::table('inventories', function (Blueprint $table): void {
|
||||
$table->bigInteger('available_stock')->virtualAs(self::FORMULA);
|
||||
});
|
||||
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
|
||||
} else {
|
||||
Schema::table('inventories', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('real_stock')->default(0)->change();
|
||||
$table->bigInteger('available_stock')->storedAs(self::FORMULA);
|
||||
});
|
||||
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$conversion = 'CAST(real_stock AS SIGNED) - CAST(sold_units AS SIGNED) + CAST(refunded_units AS SIGNED)';
|
||||
$this->assertNonNegative($conversion, 'el rollback produciría un stock real negativo');
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
|
||||
Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('available_stock'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
|
||||
Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('available_stock'));
|
||||
}
|
||||
|
||||
private function assertNonNegative(string $expression, string $reason): void
|
||||
{
|
||||
if (DB::table('inventories')->whereRaw("({$expression}) < 0")->exists()) {
|
||||
throw new RuntimeException("Hay inventarios inconsistentes: {$reason}.");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventory_movements', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('inventory_id')->constrained('inventories')->restrictOnDelete();
|
||||
$table->string('operation', 40);
|
||||
$table->bigInteger('available_stock_delta');
|
||||
$table->bigInteger('available_stock_before');
|
||||
$table->bigInteger('available_stock_after');
|
||||
$table->json('counter_deltas');
|
||||
$table->foreignId('responsible_user_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->uuid('idempotency_key')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['inventory_id', 'idempotency_key']);
|
||||
$table->index(['inventory_id', 'created_at']);
|
||||
$table->index(['operation', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_movements');
|
||||
}
|
||||
};
|
||||
135
tests/Feature/Migrations/InventoryStockMovementMigrationTest.php
Normal file
135
tests/Feature/Migrations/InventoryStockMovementMigrationTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation;
|
||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Commerce\Catalog\Models\InventoryMovement;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InventoryStockMovementMigrationTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->softDeletes();
|
||||
});
|
||||
Schema::create('inventories', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('sold_units')->default(0);
|
||||
$table->unsignedInteger('reserved_stock')->default(0);
|
||||
$table->unsignedInteger('real_stock')->default(0);
|
||||
$table->unsignedBigInteger('refunded_units')->default(0);
|
||||
$table->unsignedInteger('entry_reserved_stock')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('inventory_movements');
|
||||
Schema::dropIfExists('inventories');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_migration_and_rollback_preserve_the_available_balance(): void
|
||||
{
|
||||
DB::table('inventories')->insert([
|
||||
'id' => 17,
|
||||
'real_stock' => 10,
|
||||
'sold_units' => 3,
|
||||
'refunded_units' => 1,
|
||||
'reserved_stock' => 2,
|
||||
'entry_reserved_stock' => 1,
|
||||
]);
|
||||
|
||||
$stockMigration = $this->stockMigration();
|
||||
$movementMigration = $this->movementMigration();
|
||||
$stockMigration->up();
|
||||
$movementMigration->up();
|
||||
|
||||
$inventory = Inventory::findOrFail(17);
|
||||
$this->assertSame(12, $inventory->real_stock);
|
||||
$this->assertSame(7, $inventory->availableStock());
|
||||
$this->assertTrue(Schema::hasTable('inventory_movements'));
|
||||
|
||||
$inventory->adjustAvailableStock(2);
|
||||
$this->assertSame(9, $inventory->availableStock());
|
||||
|
||||
$movementMigration->down();
|
||||
$stockMigration->down();
|
||||
|
||||
$this->assertFalse(Schema::hasColumn('inventories', 'available_stock'));
|
||||
$this->assertFalse(Schema::hasTable('inventory_movements'));
|
||||
$this->assertSame(12, Inventory::findOrFail(17)->real_stock);
|
||||
$this->assertSame(9, Inventory::findOrFail(17)->availableStock());
|
||||
}
|
||||
|
||||
public function test_movements_capture_counters_actor_and_idempotency(): void
|
||||
{
|
||||
$this->stockMigration()->up();
|
||||
$this->movementMigration()->up();
|
||||
DB::table('users')->insert(['id' => 5]);
|
||||
$user = User::query()->findOrFail(5);
|
||||
|
||||
$inventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
$inventory->recordInitialization($user);
|
||||
$inventory->adjustAvailableStock(3, $user, '955d2afb-1e36-46df-a9db-d9ee31ed5eb4');
|
||||
$inventory->adjustAvailableStock(3, $user, '955d2afb-1e36-46df-a9db-d9ee31ed5eb4');
|
||||
$inventory->reserve(2, true);
|
||||
$inventory->buy(2, true);
|
||||
$inventory->refundStock(1, $user);
|
||||
|
||||
$this->assertSame(12, $inventory->availableStock());
|
||||
$this->assertCount(5, $inventory->movements()->get());
|
||||
|
||||
$adjustment = InventoryMovement::query()
|
||||
->where('operation', InventoryMovementOperation::StockIncreased->value)
|
||||
->firstOrFail();
|
||||
$this->assertSame(3, $adjustment->available_stock_delta);
|
||||
$this->assertSame(10, $adjustment->available_stock_before);
|
||||
$this->assertSame(13, $adjustment->available_stock_after);
|
||||
$this->assertSame(['real_stock' => 3], $adjustment->counter_deltas);
|
||||
$this->assertSame(5, $adjustment->responsible_user_id);
|
||||
|
||||
$purchase = InventoryMovement::query()
|
||||
->where('operation', InventoryMovementOperation::PurchaseCommitted->value)
|
||||
->firstOrFail();
|
||||
$this->assertSame(0, $purchase->available_stock_delta);
|
||||
$this->assertSame(['reserved_stock' => -2, 'sold_units' => 2], $purchase->counter_deltas);
|
||||
}
|
||||
|
||||
public function test_migration_rejects_an_inventory_with_negative_available_stock(): void
|
||||
{
|
||||
DB::table('inventories')->insert([
|
||||
'real_stock' => 1,
|
||||
'reserved_stock' => 2,
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('stock disponible actual es negativo');
|
||||
|
||||
$this->stockMigration()->up();
|
||||
}
|
||||
|
||||
private function stockMigration(): Migration
|
||||
{
|
||||
return require database_path('migrations/2026_09_25_000000_generate_available_inventory_stock.php');
|
||||
}
|
||||
|
||||
private function movementMigration(): Migration
|
||||
{
|
||||
return require database_path('migrations/2026_09_25_010000_create_inventory_movements_table.php');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user