From 863e911120c216d3f980e2220689d798374aba68 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 10:15:19 -0300 Subject: [PATCH] refactor(inventory): record stock operations consistently --- .../Services/CatalogInventoryService.php | 3 +- .../Catalog/Services/CatalogService.php | 5 ++- .../Services/StockReservationService.php | 2 +- .../Services/VariantReplacementService.php | 32 ++++++++++--------- .../TenantTransactionResetService.php | 16 ++++------ .../InvitationPurchaseProvisioner.php | 19 ++++++++--- .../Ticket/Services/AdminAppTicketService.php | 10 ++---- .../Services/BackfillRefundedUnitsService.php | 15 ++++----- 8 files changed, 54 insertions(+), 48 deletions(-) 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/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php index 81dbfa0f..2dede24a 100644 --- a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php @@ -228,13 +228,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) { @@ -272,11 +272,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, ]; });