diff --git a/app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php b/app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php new file mode 100644 index 00000000..53870d5e --- /dev/null +++ b/app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php @@ -0,0 +1,18 @@ + $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 ); }); }); diff --git a/app/Domains/Commerce/Catalog/Models/Inventory.php b/app/Domains/Commerce/Catalog/Models/Inventory.php index d18af516..137dca39 100644 --- a/app/Domains/Commerce/Catalog/Models/Inventory.php +++ b/app/Domains/Commerce/Catalog/Models/Inventory.php @@ -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 */ + 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 $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 $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, + ]); + } + }); } } diff --git a/app/Domains/Commerce/Catalog/Models/InventoryMovement.php b/app/Domains/Commerce/Catalog/Models/InventoryMovement.php new file mode 100644 index 00000000..d2d65de9 --- /dev/null +++ b/app/Domains/Commerce/Catalog/Models/InventoryMovement.php @@ -0,0 +1,45 @@ + InventoryMovementOperation::class, + 'available_stock_delta' => 'integer', + 'available_stock_before' => 'integer', + 'available_stock_after' => 'integer', + 'counter_deltas' => 'array', + ]; + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** @return BelongsTo */ + public function responsibleUser(): BelongsTo + { + return $this->belongsTo(User::class, 'responsible_user_id'); + } +} diff --git a/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php b/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php index 5819fe73..d79a7638 100644 --- a/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php @@ -64,6 +64,7 @@ class CatalogInventoryService $selection->loadMissing('variants.inventory'); return $selection->variants + ->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $selection)) ->filter(fn (Variant $variant): bool => $variant->isSellable()) ->unique(fn (Variant $variant): string => $variant->inventory_id === null ? 'object:'.spl_object_id($variant->inventory) @@ -153,7 +154,7 @@ class CatalogInventoryService if ($operation === 'commit' && $requirement['tracks_inventory'] - && $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) { + && $inventory->availableStock() < 0) { throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); } } diff --git a/app/Domains/Commerce/Catalog/Services/CatalogService.php b/app/Domains/Commerce/Catalog/Services/CatalogService.php index 5d2c0280..4b87a505 100644 --- a/app/Domains/Commerce/Catalog/Services/CatalogService.php +++ b/app/Domains/Commerce/Catalog/Services/CatalogService.php @@ -370,9 +370,12 @@ class CatalogService private function createInventory(int $realStock): Inventory { - return Inventory::query()->create([ + $inventory = Inventory::query()->create([ 'real_stock' => $realStock, ]); + $inventory->recordInitialization(); + + return $inventory; } /** diff --git a/app/Domains/Commerce/Catalog/Services/StockReservationService.php b/app/Domains/Commerce/Catalog/Services/StockReservationService.php index a9216474..2412ee76 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 - $inventory->entry_reserved_stock < $line->quantity)) { + || ($line->tracks_inventory && $inventory->availableStock() < 0)) { 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 317c4972..ac28cf69 100644 --- a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php @@ -194,6 +194,7 @@ class VariantReplacementService 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock, 'real_stock' => $sourceInventory->real_stock, ]); + $replacementInventory->recordTransferInitialization(); if ($activeLines->isNotEmpty()) { StockReservationLine::query() @@ -202,7 +203,10 @@ class VariantReplacementService } EntryReservation::query()->where('inventory_id', $sourceInventory->id) ->update(['inventory_id' => $replacementInventory->id]); - $sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]); + $sourceInventory->transferCounters([ + 'reserved_stock' => -$sourceInventory->reserved_stock, + 'entry_reserved_stock' => -$sourceInventory->entry_reserved_stock, + ]); return $replacementInventory; } @@ -236,13 +240,14 @@ class VariantReplacementService throw new \LogicException('El inventario reservado de la variante es inconsistente.'); } - $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, - ]); + $transferredCounters = [ + 'real_stock' => $sourceInventory->real_stock, + 'reserved_stock' => $sourceInventory->reserved_stock, + 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock, + 'sold_units' => $sourceInventory->sold_units, + 'refunded_units' => $sourceInventory->refunded_units, + ]; + $destinationInventory->transferCounters($transferredCounters); if ($activeLines->isNotEmpty()) { StockReservationLine::query() ->whereKey($activeLines->modelKeys()) @@ -250,13 +255,10 @@ class VariantReplacementService } 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, - ]); + $sourceInventory->transferCounters(array_map( + fn (int $value): int => -$value, + $transferredCounters, + )); } /** @return list */ diff --git a/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php index 37931061..815b2663 100644 --- a/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php @@ -2,6 +2,7 @@ namespace App\Domains\Commerce\Purchase\Services; +use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Purchase\Models\Purchase; use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; @@ -69,15 +70,12 @@ class TenantTransactionResetService 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), ]; - DB::table('inventories') - ->whereIn('id', $scope['inventory_ids']) - ->update([ - 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), - 'reserved_stock' => 0, - 'entry_reserved_stock' => 0, - 'sold_units' => 0, - 'refunded_units' => 0, - ]); + Inventory::query() + ->whereKey($scope['inventory_ids']) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->each(fn (Inventory $inventory) => $inventory->resetTransactionCounters()); return $summary; }); diff --git a/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php index a2be68ea..2fcb5e29 100644 --- a/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php @@ -2,6 +2,7 @@ namespace App\Domains\Ticketing\Desfile\Services; +use App\Domains\Commerce\Catalog\Models\Inventory; use DateTimeInterface; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -393,14 +394,22 @@ class InvitationPurchaseProvisioner $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first(); - if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) { + $available = $inventory?->available_stock + ?? ($inventory === null ? 0 : $inventory->real_stock - $inventory->reserved_stock - ($inventory->entry_reserved_stock ?? 0)); + if ($available < 1) { throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible."); } - DB::table('inventories')->where('id', $inventory->id)->update([ - 'real_stock' => $inventory->real_stock - 1, - 'sold_units' => $inventory->sold_units + 1, - ]); + if (property_exists($inventory, 'available_stock')) { + Inventory::query()->findOrFail($inventory->id)->commitDirectSale(1); + } else { + // This provisioner is also used by historical migrations that run + // before generated availability and the movement ledger exist. + DB::table('inventories')->where('id', $inventory->id)->update([ + 'real_stock' => $inventory->real_stock - 1, + 'sold_units' => $inventory->sold_units + 1, + ]); + } $reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id'); if ($reservationId === null) { diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/AccommodationController.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/AccommodationController.php index fb9ca451..e21425d3 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/AccommodationController.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/AccommodationController.php @@ -28,6 +28,7 @@ class AccommodationController extends Controller $this->accommodationService->upsertMany( $tenant, $request->validated('variants'), + $request->validated('stock_adjustment_id'), ) ); } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/EntryController.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/EntryController.php index 68b11636..2fa4d1e1 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/EntryController.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/EntryController.php @@ -29,6 +29,7 @@ class EntryController extends Controller $entries = $this->entryService->upsertMany( $tenant, $request->validated('entries'), + $request->validated('stock_adjustment_id'), ); return EntryResource::collection($entries) diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/FoodController.php index e580cc8d..86d5b5f4 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/FoodController.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/FoodController.php @@ -29,6 +29,7 @@ class FoodController extends Controller $this->foodService->upsertMany( $tenant, $request->validated('variants'), + $request->validated('stock_adjustment_id'), ) ); } @@ -39,6 +40,7 @@ class FoodController extends Controller $this->foodService->updateHistoricalStock( $request->user()->tenant()->firstOrFail(), $request->validated('variants'), + $request->validated('stock_adjustment_id'), ) ); } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/MerchandiseController.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/MerchandiseController.php index 10b0307f..dd52f9a0 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/MerchandiseController.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Controllers/MerchandiseController.php @@ -28,6 +28,7 @@ class MerchandiseController extends Controller $items = $this->merchandiseService->upsertMany( $tenant, $request->validated('items'), + $request->validated('stock_adjustment_id'), ); return MerchandiseResource::collection($items) diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php index aa784622..caa5e853 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php @@ -16,9 +16,10 @@ class UpdateHistoricalFoodStockRequest extends FormRequest { return [ 'variants' => ['required', 'array', 'min:1', 'max:500'], - 'variants.*' => ['required', 'array:id,stock'], + 'stock_adjustment_id' => ['nullable', 'uuid'], + 'variants.*' => ['required', 'array:id,stock_difference'], 'variants.*.id' => ['required', 'integer', 'distinct'], - 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.stock_difference' => ['required', 'integer'], ]; } } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php index d275c487..7e2475eb 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class UpsertAccommodationVariantsRequest extends FormRequest { @@ -16,12 +17,42 @@ class UpsertAccommodationVariantsRequest extends FormRequest { return [ 'variants' => ['required', 'array', 'min:1', 'max:500'], - 'variants.*' => ['required', 'array:id,title,description,stock,price'], + 'stock_adjustment_id' => ['nullable', 'uuid'], + 'variants.*' => ['required', 'array:id,title,description,stock,stock_difference,price'], 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], 'variants.*.title' => ['required', 'string', 'max:255'], 'variants.*.description' => ['sometimes', 'nullable', 'string'], - 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.stock_difference' => ['sometimes', 'integer'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + foreach ($this->input('variants', []) as $index => $variant) { + if (! is_array($variant)) { + continue; + } + + if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) { + $validator->errors()->add( + "variants.{$index}.stock_difference", + 'La diferencia de stock es obligatoria al actualizar una variante.', + ); + } + + if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) { + $validator->errors()->add( + "variants.{$index}.stock", + 'El stock inicial es obligatorio al crear una variante.', + ); + } + } + }, + ]; + } } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php index 6827fc4b..2b982850 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php @@ -20,7 +20,8 @@ class UpsertEntriesRequest extends FormRequest return [ 'entries' => ['required', 'array', 'min:1', 'max:100'], - 'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'], + 'stock_adjustment_id' => ['nullable', 'uuid'], + 'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,stock_difference,price'], 'entries.*.id' => [ 'sometimes', 'nullable', @@ -46,7 +47,8 @@ class UpsertEntriesRequest extends FormRequest fn ($query) => $query->where('tenant_code', $tenantCode) ), ], - 'entries.*.stock' => ['required', 'integer', 'min:0'], + 'entries.*.stock' => ['sometimes', 'integer', 'min:0'], + 'entries.*.stock_difference' => ['sometimes', 'integer'], 'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } @@ -61,6 +63,20 @@ class UpsertEntriesRequest extends FormRequest continue; } + if (isset($entry['id']) && ! array_key_exists('stock_difference', $entry)) { + $validator->errors()->add( + "entries.{$index}.stock_difference", + 'La diferencia de stock es obligatoria al actualizar una entrada.', + ); + } + + if (! isset($entry['id']) && ! array_key_exists('stock', $entry)) { + $validator->errors()->add( + "entries.{$index}.stock", + 'El stock inicial es obligatorio al crear una entrada.', + ); + } + $dateIds = $entry['event_date_ids'] ?? []; if (! is_array($dateIds)) { diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php index 164ca981..50b557d5 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php @@ -20,7 +20,8 @@ class UpsertFoodVariantsRequest extends FormRequest return [ 'variants' => ['required', 'array', 'min:1', 'max:500'], - 'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'], + 'stock_adjustment_id' => ['nullable', 'uuid'], + 'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,stock_difference,price'], 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], 'variants.*.event_date_id' => [ 'required', @@ -32,7 +33,8 @@ class UpsertFoodVariantsRequest extends FormRequest 'variants.*.schedule' => ['required', 'string', 'max:255'], 'variants.*.service' => ['required', 'string', 'max:255'], 'variants.*.description' => ['sometimes', 'nullable', 'string'], - 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.stock_difference' => ['sometimes', 'integer'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } @@ -49,6 +51,20 @@ class UpsertFoodVariantsRequest extends FormRequest continue; } + if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) { + $validator->errors()->add( + "variants.{$index}.stock_difference", + 'La diferencia de stock es obligatoria al actualizar una variante.', + ); + } + + if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) { + $validator->errors()->add( + "variants.{$index}.stock", + 'El stock inicial es obligatorio al crear una variante.', + ); + } + $key = implode('|', [ $variant['event_date_id'] ?? '', mb_strtolower(trim((string) ($variant['schedule'] ?? ''))), diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php index 862a59a0..0e683413 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php @@ -4,6 +4,7 @@ namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpsertMerchandiseRequest extends FormRequest { @@ -39,12 +40,49 @@ class UpsertMerchandiseRequest extends FormRequest 'items.*.description' => ['sometimes', 'nullable', 'string'], 'items.*.max_units_per_user' => ['required', 'integer', 'min:1'], 'items.*.variants' => ['required', 'array', 'min:1', 'max:500'], - 'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'], + 'stock_adjustment_id' => ['nullable', 'uuid'], + 'items.*.variants.*' => ['required', 'array:id,color,size,stock,stock_difference,price'], 'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], 'items.*.variants.*.color' => ['required', 'string', 'max:255'], 'items.*.variants.*.size' => ['required', 'string', 'max:255'], - 'items.*.variants.*.stock' => ['required', 'integer', 'min:0'], + 'items.*.variants.*.stock' => ['sometimes', 'integer', 'min:0'], + 'items.*.variants.*.stock_difference' => ['sometimes', 'integer'], 'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + foreach ($this->input('items', []) as $itemIndex => $item) { + if (! is_array($item)) { + continue; + } + + foreach ($item['variants'] ?? [] as $variantIndex => $variant) { + if (! is_array($variant)) { + continue; + } + + $prefix = "items.{$itemIndex}.variants.{$variantIndex}"; + if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) { + $validator->errors()->add( + "{$prefix}.stock_difference", + 'La diferencia de stock es obligatoria al actualizar una variante.', + ); + } + + if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) { + $validator->errors()->add( + "{$prefix}.stock", + 'El stock inicial es obligatorio al crear una variante.', + ); + } + } + } + }, + ]; + } } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/AccommodationResource.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/AccommodationResource.php index b3cb3186..9b3a9d06 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/AccommodationResource.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/AccommodationResource.php @@ -37,7 +37,7 @@ class AccommodationResource extends JsonResource 'title' => $options->get($value)?->label ?? $value, 'value' => $value, 'description' => $variant->descripcion, - 'stock' => $variant->inventory->real_stock, + 'stock' => $variant->inventory->availableStock(), 'price' => number_format($variant->getPrice(), 2, '.', ''), ]; })->values(), diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/EntryResource.php index efce8a91..7da32839 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/EntryResource.php @@ -33,7 +33,7 @@ class EntryResource extends JsonResource 'title' => $this->nombre, 'description' => $this->descripcion, 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), - 'stock' => $variant->inventory->real_stock, + 'stock' => $variant->inventory->availableStock(), 'price' => $this->precio, ]; } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/FoodResource.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/FoodResource.php index 436e62da..34d7abfb 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/FoodResource.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/FoodResource.php @@ -81,7 +81,7 @@ class FoodResource extends JsonResource 'schedule' => $values->get('horario'), 'service' => $values->get('servicio'), 'description' => $variant->descripcion, - 'stock' => $variant->inventory->real_stock, + 'stock' => $variant->inventory->availableStock(), 'price' => number_format($variant->getPrice(), 2, '.', ''), ]; } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/MerchandiseResource.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/MerchandiseResource.php index 2b6f2a26..369e0783 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/MerchandiseResource.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Resources/MerchandiseResource.php @@ -44,7 +44,7 @@ class MerchandiseResource extends JsonResource 'color_value' => $colorValue, 'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue, 'size_value' => $sizeValue, - 'stock' => $variant->inventory->real_stock, + 'stock' => $variant->inventory->availableStock(), 'price' => number_format($variant->getPrice(), 2, '.', ''), ]; })->values(), diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php index 6e5baae2..4e33b50e 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php @@ -39,9 +39,9 @@ class AccommodationService /** * @param array> $variants */ - public function upsertMany(Tenant $tenant, array $variants): CatalogItem + public function upsertMany(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem { - return DB::transaction(function () use ($tenant, $variants): CatalogItem { + return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem { $attribute = $this->attribute($tenant); $accommodation = $this->accommodation($tenant, $variants); $itemAttribute = $accommodation->itemAttributes()->firstOrCreate( @@ -70,7 +70,7 @@ class AccommodationService if ($variant === null) { $this->createVariant($attribute, $accommodation, $itemAttribute, $data); } else { - $this->updateVariant($attribute, $variant, $itemAttribute, $data, $index); + $this->updateVariant($attribute, $variant, $itemAttribute, $data, $index, $stockAdjustmentId); } } @@ -169,7 +169,7 @@ class AccommodationService 'title' => trim($variant['title']), 'value' => $this->valueCode($variant['title']), 'description' => $variant['description'] ?? null, - 'stock' => (int) $variant['stock'], + ...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []), ])->all(); } @@ -212,6 +212,7 @@ class AccommodationService $this->createOption($attribute, $data['value'], $data['title']); $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $inventory->recordInitialization(); $variant = $accommodation->variants()->create([ 'inventory_id' => $inventory->id, 'descripcion' => $data['description'], @@ -230,20 +231,13 @@ class AccommodationService ItemAttribute $itemAttribute, array $data, int $index, + ?string $stockAdjustmentId, ): void { $inventory = Inventory::query() ->whereKey($variant->inventory_id) ->lockForUpdate() ->firstOrFail(); - if ($data['stock'] < $inventory->reserved_stock) { - throw ValidationException::withMessages([ - "variants.{$index}.stock" => [ - 'El stock no puede ser menor que la cantidad actualmente reservada.', - ], - ]); - } - $definition = $variant->definitions ->firstWhere('item_attribute_id', $itemAttribute->id); $option = $definition === null @@ -263,7 +257,10 @@ class AccommodationService 'descripcion' => $data['description'], 'precio' => $data['price'], ]); - $inventory->update(['real_stock' => $data['stock']]); + $inventory->adjustAvailableStock( + (int) $data['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); $variant->definitions()->updateOrCreate( ['item_attribute_id' => $itemAttribute->id], ['value' => $data['value']], diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/EntryService.php index 5d028f5d..06ac4e09 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/EntryService.php @@ -43,18 +43,18 @@ class EntryService * @param array> $entries * @return Collection */ - public function upsertMany(Tenant $tenant, array $entries): Collection + public function upsertMany(Tenant $tenant, array $entries, ?string $stockAdjustmentId = null): Collection { - return DB::transaction(function () use ($tenant, $entries): Collection { + return DB::transaction(function () use ($tenant, $entries, $stockAdjustmentId): Collection { $reservedSlugs = []; $category = Category::query()->firstOrCreate([ 'tenant_code' => $tenant->codigo, 'nombre' => 'Entradas', ]); - return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem { + return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, $stockAdjustmentId, &$reservedSlugs): CatalogItem { if (isset($entry['id'])) { - return $this->update($tenant, $category, $entry, $index); + return $this->update($tenant, $category, $entry, $index, $stockAdjustmentId); } $slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs); @@ -92,7 +92,7 @@ class EntryService } /** @param array $entry */ - private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem + private function update(Tenant $tenant, Category $category, array $entry, int $index, ?string $stockAdjustmentId): CatalogItem { $catalogItem = CatalogItem::query() ->whereKey($entry['id']) @@ -121,14 +121,6 @@ class EntryService ->lockForUpdate() ->firstOrFail(); - if ((int) $entry['stock'] < $inventory->reserved_stock) { - throw ValidationException::withMessages([ - "entries.{$index}.stock" => [ - 'El stock no puede ser menor que la cantidad actualmente reservada.', - ], - ]); - } - $eventDateIds = collect($entry['event_date_ids']) ->map(fn ($id): int => (int) $id) ->unique() @@ -149,7 +141,10 @@ class EntryService $catalogItem->itemAttributes() ->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date')) ->update(['allow_multi_select' => true]); - $inventory->update(['real_stock' => $entry['stock']]); + $inventory->adjustAvailableStock( + (int) $entry['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); return $catalogItem->load([ 'variants.inventory', diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/FoodService.php index 88488541..1e194eaf 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/FoodService.php @@ -11,9 +11,9 @@ use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Services\CatalogService; +use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Enums\EventDateStatus; use App\Domains\Ticketing\Event\Models\EventDate; -use App\Domains\Core\Tenant\Models\Tenant; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -50,9 +50,9 @@ class FoodService /** * @param array> $variants */ - public function upsertMany(Tenant $tenant, array $variants): CatalogItem + public function upsertMany(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem { - return DB::transaction(function () use ($tenant, $variants): CatalogItem { + return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem { $attributes = $this->attributes($tenant); $food = $this->food($tenant, $variants); $itemAttributes = $this->itemAttributes($food, $attributes); @@ -85,7 +85,7 @@ class FoodService if ($variant === null) { $this->createVariant($food, $itemAttributes, $data); } else { - $this->updateVariant($variant, $itemAttributes, $data, $index); + $this->updateVariant($variant, $itemAttributes, $data, $index, $stockAdjustmentId); } } @@ -113,11 +113,11 @@ class FoodService } /** - * @param array $variants + * @param array $variants */ - public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem + public function updateHistoricalStock(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem { - return DB::transaction(function () use ($tenant, $variants): CatalogItem { + return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem { $food = CatalogItem::query() ->forTenantCatalog($tenant) ->where('slug', 'comida') @@ -142,17 +142,11 @@ class FoodService ]); } - $stock = (int) $data['stock']; $inventory = $this->inventoryForHistoricalStockUpdate($variant); - if ($stock < $inventory->reserved_stock) { - throw ValidationException::withMessages([ - "variants.{$index}.stock" => [ - 'El stock no puede ser menor que la cantidad actualmente reservada.', - ], - ]); - } - - $inventory->update(['real_stock' => $stock]); + $inventory->adjustAvailableStock( + (int) $data['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); } return $this->current($tenant) ?? $food; @@ -181,6 +175,7 @@ class FoodService 'reserved_stock' => 0, 'real_stock' => $inventory->real_stock, ]); + $historicalInventory->recordTransferInitialization(); $variant->update(['inventory_id' => $historicalInventory->getKey()]); return $historicalInventory; @@ -301,7 +296,7 @@ class FoodService 'schedule' => $schedule->value, 'service' => $service->value, 'description' => (string) ($variant['description'] ?? ''), - 'stock' => (int) $variant['stock'], + ...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []), ]; })->all(); } @@ -384,6 +379,7 @@ class FoodService private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void { $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $inventory->recordInitialization(); $variant = $food->variants()->create([ 'event_date_id' => $data['event_date_id'], 'inventory_id' => $inventory->id, @@ -400,27 +396,23 @@ class FoodService Collection $itemAttributes, array $data, int $index, + ?string $stockAdjustmentId, ): void { $inventory = Inventory::query() ->whereKey($variant->inventory_id) ->lockForUpdate() ->firstOrFail(); - if ($data['stock'] < $inventory->reserved_stock) { - throw ValidationException::withMessages([ - "variants.{$index}.stock" => [ - 'El stock no puede ser menor que la cantidad actualmente reservada.', - ], - ]); - } - $variant->update([ 'event_date_id' => $data['event_date_id'], 'descripcion' => $data['description'], 'precio' => $data['price'], ]); $variant->eventDates()->sync([$data['event_date_id']]); - $inventory->update(['real_stock' => $data['stock']]); + $inventory->adjustAvailableStock( + (int) $data['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); $this->syncDefinitions($variant, $itemAttributes, $data); } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/MerchandiseService.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/MerchandiseService.php index 3d3183ca..9beaac2d 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/MerchandiseService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/MerchandiseService.php @@ -43,9 +43,9 @@ class MerchandiseService * @param array> $items * @return Collection */ - public function upsertMany(Tenant $tenant, array $items): Collection + public function upsertMany(Tenant $tenant, array $items, ?string $stockAdjustmentId = null): Collection { - return DB::transaction(function () use ($tenant, $items): Collection { + return DB::transaction(function () use ($tenant, $items, $stockAdjustmentId): Collection { $attributes = $this->attributes($tenant); $category = Category::query()->firstOrCreate([ 'tenant_code' => $tenant->codigo, @@ -57,6 +57,7 @@ class MerchandiseService $tenant, $attributes, $category, + $stockAdjustmentId, &$reservedSlugs, ): CatalogItem { $item = isset($data['id']) @@ -101,7 +102,7 @@ class MerchandiseService if ($variant === null) { $this->createVariant($item, $itemAttributes, $variantData); } else { - $this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex); + $this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex, $stockAdjustmentId); } } @@ -244,7 +245,7 @@ class MerchandiseService ...$variant, 'color' => $color->value, 'size' => $size->value, - 'stock' => (int) $variant['stock'], + ...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []), ]; })->all(); } @@ -342,6 +343,7 @@ class MerchandiseService array $data, ): void { $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $inventory->recordInitialization(); $variant = $item->variants()->create([ 'inventory_id' => $inventory->id, 'precio' => $data['price'], @@ -359,22 +361,18 @@ class MerchandiseService array $data, int $itemIndex, int $variantIndex, + ?string $stockAdjustmentId, ): void { $inventory = Inventory::query() ->whereKey($variant->inventory_id) ->lockForUpdate() ->firstOrFail(); - if ($data['stock'] < $inventory->reserved_stock) { - throw ValidationException::withMessages([ - "items.{$itemIndex}.variants.{$variantIndex}.stock" => [ - 'El stock no puede ser menor que la cantidad actualmente reservada.', - ], - ]); - } - $variant->update(['precio' => $data['price']]); - $inventory->update(['real_stock' => $data['stock']]); + $inventory->adjustAvailableStock( + (int) $data['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); $this->syncDefinitions($variant, $itemAttributes, $data); } diff --git a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php index e8dc384d..319b733f 100644 --- a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php @@ -225,13 +225,13 @@ class AdminAppTicketService 'amount' => number_format($refundAmount, 2, '.', ''), ]); - $this->restoreInventory($ticket, $purchaseItem); + $this->restoreInventory($ticket, $purchaseItem, $createdBy); return $ticket->refresh()->load(self::RELATIONS); }); } - private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void + private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem, ?User $createdBy): void { $catalogItem = $ticket->sourceCatalogItem; if ($catalogItem === null) { @@ -269,11 +269,7 @@ class AdminAppTicketService throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']); } - if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) { - $inventory->real_stock++; - } - $inventory->refunded_units++; - $inventory->save(); + $inventory->refundStock(1, $createdBy); } private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float diff --git a/app/Domains/Ticketing/Ticket/Services/BackfillRefundedUnitsService.php b/app/Domains/Ticketing/Ticket/Services/BackfillRefundedUnitsService.php index d403ded8..dd9acdc7 100644 --- a/app/Domains/Ticketing/Ticket/Services/BackfillRefundedUnitsService.php +++ b/app/Domains/Ticketing/Ticket/Services/BackfillRefundedUnitsService.php @@ -2,6 +2,7 @@ namespace App\Domains\Ticketing\Ticket\Services; +use App\Domains\Commerce\Catalog\Models\Inventory; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use RuntimeException; @@ -64,14 +65,9 @@ class BackfillRefundedUnitsService }, 'refunds.id', 'id'); foreach ($counts as $inventoryId => $count) { - $updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])]; - if (($count['stock'] ?? 0) > 0) { - $updates['real_stock'] = DB::raw('real_stock + '.$count['stock']); - } - - if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) { - throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo."); - } + $inventory = Inventory::query()->lockForUpdate()->find($inventoryId) + ?? throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo."); + $inventory->refundStock($count['refunded']); } $refundedUnitsAdded = array_sum(array_column($counts, 'refunded')); @@ -82,7 +78,8 @@ class BackfillRefundedUnitsService 'bundles_skipped' => $bundlesSkipped, 'inventories_updated' => count($counts), 'refunded_units_added' => $refundedUnitsAdded, - 'real_stock_added' => array_sum(array_column($counts, 'stock')), + 'real_stock_added' => 0, + 'available_stock_added' => $refundedUnitsAdded, ]; }); diff --git a/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php b/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php new file mode 100644 index 00000000..533a02bf --- /dev/null +++ b/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php @@ -0,0 +1,89 @@ +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 + { + $invalidInventories = DB::table('inventories') + ->select([ + 'id', + 'real_stock', + 'sold_units', + 'refunded_units', + 'reserved_stock', + 'entry_reserved_stock', + ]) + ->selectRaw("({$expression}) as calculated_stock") + ->whereRaw("({$expression}) < 0") + ->orderBy('id') + ->limit(20) + ->get(); + + if ($invalidInventories->isNotEmpty()) { + $details = $invalidInventories + ->map(static fn (object $inventory): string => sprintf( + 'id=%d [real=%d, vendidas=%d, reintegradas=%d, reservadas=%d, reservas_entradas=%d, resultado=%d]', + $inventory->id, + $inventory->real_stock, + $inventory->sold_units, + $inventory->refunded_units, + $inventory->reserved_stock, + $inventory->entry_reserved_stock, + $inventory->calculated_stock, + )) + ->implode('; '); + + throw new RuntimeException( + "Hay inventarios inconsistentes: {$reason}. Inventarios detectados (máximo 20): {$details}." + ); + } + } +}; diff --git a/database/migrations/2026_09_25_010000_create_inventory_movements_table.php b/database/migrations/2026_09_25_010000_create_inventory_movements_table.php new file mode 100644 index 00000000..b43c57c1 --- /dev/null +++ b/database/migrations/2026_09_25_010000_create_inventory_movements_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php index d04907fb..3050ac09 100644 --- a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php @@ -97,6 +97,8 @@ class AccommodationControllerTest extends TestCase $updated = $this->variantPayload('Casa Rodante Premium', 'Con electricidad', 15, 50000); $updated['id'] = $variantId; + unset($updated['stock']); + $updated['stock_difference'] = -5; $this->postJson('/api/v1/adminapp/tenant/accommodations', [ 'variants' => [ diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index fb3b59fb..b6066fe9 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -194,7 +194,7 @@ class EntryControllerTest extends TestCase 'title' => 'Abono actualizado', 'description' => 'Ahora incluye ambas fechas', 'event_date_ids' => [$firstDate->id, $secondDate->id], - 'stock' => 20, + 'stock_difference' => 10, 'price' => 250, ], [ @@ -268,7 +268,7 @@ class EntryControllerTest extends TestCase 'id' => $entryId, 'title' => 'Abono editado', 'event_date_ids' => [$date->id, $otherDate->id], - 'stock' => 30, + 'stock_difference' => 10, 'price' => 200, ]], ])->assertOk() @@ -329,7 +329,7 @@ class EntryControllerTest extends TestCase 'title' => 'Entrada inválida', 'description' => null, 'event_date_ids' => [$foreignDate->id], - 'stock' => 10, + 'stock_difference' => 0, 'price' => 100, ]], ]) @@ -371,7 +371,7 @@ class EntryControllerTest extends TestCase 'title' => 'Entrada inválida', 'description' => null, 'event_date_ids' => [$eventDate->id], - 'stock' => 10, + 'stock_difference' => 0, 'price' => 100, ]], ]) diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index bef74360..c0339163 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -124,6 +124,8 @@ class FoodControllerTest extends TestCase $updated = $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 80, 8500); $updated['id'] = $variantId; + unset($updated['stock']); + $updated['stock_difference'] = -20; $updated['description'] = 'Cena para llevar'; $this->postJson('/api/v1/adminapp/tenant/foods', [ @@ -240,7 +242,7 @@ class FoodControllerTest extends TestCase Inventory::query()->whereKey($replacementInventoryId)->delete(); $updated = $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ - 'variants' => [['id' => $historicalVariantId, 'stock' => 45]], + 'variants' => [['id' => $historicalVariantId, 'stock_difference' => -55]], ]) ->assertOk() ->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId) @@ -258,7 +260,7 @@ class FoodControllerTest extends TestCase ]); $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ - 'variants' => [['id' => $activeVariantId, 'stock' => 20]], + 'variants' => [['id' => $activeVariantId, 'stock_difference' => -60]], ]) ->assertUnprocessable() ->assertJsonValidationErrors(['variants.0.id']); diff --git a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php index 09752a40..7f8ddcf8 100644 --- a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php @@ -112,6 +112,8 @@ class MerchandiseControllerTest extends TestCase $updatedVariant = $this->variantPayload('Blanco', 'M', 80, 12500); $updatedVariant['id'] = $variantId; + unset($updatedVariant['stock']); + $updatedVariant['stock_difference'] = -20; $updatedItem = $this->itemPayload('Camiseta oficial', 2, [ $updatedVariant, $this->variantPayload('Verde', 'L', 60, 15000), @@ -234,6 +236,8 @@ class MerchandiseControllerTest extends TestCase $secondItemVariantId = $created->json('data.1.variants.0.id'); $foreignVariant = $this->variantPayload('Azul Marino', 'XL', 20, 30000); $foreignVariant['id'] = $secondItemVariantId; + unset($foreignVariant['stock']); + $foreignVariant['stock_difference'] = 0; $firstItem = $this->itemPayload('Camiseta', 3, [$foreignVariant]); $firstItem['id'] = $firstItemId; diff --git a/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php new file mode 100644 index 00000000..8946f515 --- /dev/null +++ b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php @@ -0,0 +1,139 @@ +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([ + 'id' => 23, + 'real_stock' => 1, + 'reserved_stock' => 2, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'stock disponible actual es negativo. Inventarios detectados (máximo 20): ' + .'id=23 [real=1, vendidas=0, reintegradas=0, reservadas=2, reservas_entradas=0, resultado=-1]' + ); + + $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'); + } +}