feat(inventory): track administrative entry reservations

This commit is contained in:
2026-09-24 09:34:42 -03:00
parent 3671b95a83
commit 884da2c89a
8 changed files with 73 additions and 11 deletions

View File

@@ -237,7 +237,7 @@ class CatalogItem extends Model
->whereHas(
'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
)
)
->orWhere(function (Builder $directItemQuery): void {
@@ -249,7 +249,7 @@ class CatalogItem extends Model
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
);
});
});

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
'sold_units',
'refunded_units',
'reserved_stock',
'entry_reserved_stock',
'real_stock',
])]
class Inventory extends Model
@@ -35,6 +36,7 @@ class Inventory extends Model
'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer',
'entry_reserved_stock' => 'integer',
'real_stock' => 'integer',
];
}
@@ -59,7 +61,16 @@ class Inventory extends Model
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);
return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_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();
}
public function reserve(int $amount, bool $tracksInventory): void
@@ -92,7 +103,7 @@ class Inventory extends Model
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
}
if ($tracksInventory && $this->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.');
}

View File

@@ -153,7 +153,7 @@ class CatalogInventoryService
if ($operation === 'commit'
&& $requirement['tracks_inventory']
&& $inventory->real_stock < $requiredQuantity) {
&& $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
}

View File

@@ -225,7 +225,7 @@ class StockReservationService
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|| ($line->tracks_inventory && $inventory->real_stock - $inventory->entry_reserved_stock < $line->quantity)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\StockReservationLine;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Event\Models\EventDate;
use Illuminate\Support\Collection;
@@ -190,6 +191,7 @@ class VariantReplacementService
'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock,
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'real_stock' => $sourceInventory->real_stock,
]);
@@ -198,7 +200,9 @@ class VariantReplacementService
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]);
}
$sourceInventory->update(['reserved_stock' => 0]);
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $replacementInventory->id]);
$sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]);
return $replacementInventory;
}
@@ -235,6 +239,7 @@ class VariantReplacementService
$destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]);
@@ -243,9 +248,12 @@ class VariantReplacementService
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]);
}
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $destinationInventory->id]);
$sourceInventory->update([
'real_stock' => 0,
'reserved_stock' => 0,
'entry_reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);

View File

@@ -2,13 +2,13 @@
namespace App\Domains\Ticketing\Desfile\Services;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
@@ -254,7 +254,7 @@ class EntryService
{
$inventory = $variant->inventory;
if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) {
if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->entry_reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) {
throw ValidationException::withMessages([
$key => [
'No se puede modificar ni eliminar un asiento con ventas o reservas.',

View File

@@ -393,7 +393,7 @@ class InvitationPurchaseProvisioner
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) {
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) {
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
}

View File

@@ -0,0 +1,43 @@
<?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::table('inventories', function (Blueprint $table): void {
$table->unsignedInteger('entry_reserved_stock')->default(0);
});
Schema::create('desfile_reservation_batches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
$table->string('tenant_code');
$table->uuid('idempotency_key');
$table->string('request_hash', 64);
$table->timestamps();
$table->unique(['user_id', 'idempotency_key']);
});
Schema::table('desfile_entry_reservations', function (Blueprint $table): void {
$table->foreignId('batch_id')->nullable()->constrained('desfile_reservation_batches')->restrictOnDelete();
$table->foreignId('ticket_id')->nullable()->unique()->constrained('tickets')->restrictOnDelete();
$table->foreignId('inventory_id')->nullable()->constrained('inventories')->restrictOnDelete();
});
// Legacy reservations remain excluded by visibleVariants(). Their past stock
// movements cannot be inferred safely; only new reservations use this counter.
}
public function down(): void
{
Schema::table('desfile_entry_reservations', function (Blueprint $table): void {
$table->dropConstrainedForeignId('batch_id');
$table->dropUnique(['ticket_id']);
$table->dropConstrainedForeignId('ticket_id');
$table->dropConstrainedForeignId('inventory_id');
});
Schema::dropIfExists('desfile_reservation_batches');
Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('entry_reserved_stock'));
}
};