From 884da2c89aa8ac2c0b9e4c60de8f74bc86b6ce3a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 24 Sep 2026 09:34:42 -0300 Subject: [PATCH] feat(inventory): track administrative entry reservations --- .../Commerce/Catalog/Models/CatalogItem.php | 4 +- .../Commerce/Catalog/Models/Inventory.php | 15 ++++++- .../Services/CatalogInventoryService.php | 2 +- .../Services/StockReservationService.php | 2 +- .../Services/VariantReplacementService.php | 10 ++++- .../Desfile/Services/EntryService.php | 6 +-- .../InvitationPurchaseProvisioner.php | 2 +- ...administrative_entry_reservation_stock.php | 43 +++++++++++++++++++ 8 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php diff --git a/app/Domains/Commerce/Catalog/Models/CatalogItem.php b/app/Domains/Commerce/Catalog/Models/CatalogItem.php index a808198..9fe5d1c 100644 --- a/app/Domains/Commerce/Catalog/Models/CatalogItem.php +++ b/app/Domains/Commerce/Catalog/Models/CatalogItem.php @@ -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') ); }); }); diff --git a/app/Domains/Commerce/Catalog/Models/Inventory.php b/app/Domains/Commerce/Catalog/Models/Inventory.php index cc7d6a3..047a611 100644 --- a/app/Domains/Commerce/Catalog/Models/Inventory.php +++ b/app/Domains/Commerce/Catalog/Models/Inventory.php @@ -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.'); } diff --git a/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php b/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php index 0f396b6..5819fe7 100644 --- a/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php @@ -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.'); } } diff --git a/app/Domains/Commerce/Catalog/Services/StockReservationService.php b/app/Domains/Commerce/Catalog/Services/StockReservationService.php index 163f42b..a921647 100644 --- a/app/Domains/Commerce/Catalog/Services/StockReservationService.php +++ b/app/Domains/Commerce/Catalog/Services/StockReservationService.php @@ -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.'); } } diff --git a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php index 0a210ba..317c497 100644 --- a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php @@ -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, ]); diff --git a/app/Domains/Ticketing/Desfile/Services/EntryService.php b/app/Domains/Ticketing/Desfile/Services/EntryService.php index 1266177..5c8d389 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryService.php @@ -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.', diff --git a/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php index 382fe2d..a2be68e 100644 --- a/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php @@ -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."); } diff --git a/database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php b/database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php new file mode 100644 index 0000000..f1f038b --- /dev/null +++ b/database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php @@ -0,0 +1,43 @@ +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')); + } +};