refactor(inventory): record stock operations consistently

This commit is contained in:
2026-09-25 10:15:19 -03:00
parent 6c56b338c1
commit 863e911120
8 changed files with 54 additions and 48 deletions

View File

@@ -64,6 +64,7 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory'); $selection->loadMissing('variants.inventory');
return $selection->variants return $selection->variants
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $selection))
->filter(fn (Variant $variant): bool => $variant->isSellable()) ->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null ->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory) ? 'object:'.spl_object_id($variant->inventory)
@@ -153,7 +154,7 @@ class CatalogInventoryService
if ($operation === 'commit' if ($operation === 'commit'
&& $requirement['tracks_inventory'] && $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.'); throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
} }
} }

View File

@@ -370,9 +370,12 @@ class CatalogService
private function createInventory(int $realStock): Inventory private function createInventory(int $realStock): Inventory
{ {
return Inventory::query()->create([ $inventory = Inventory::query()->create([
'real_stock' => $realStock, 'real_stock' => $realStock,
]); ]);
$inventory->recordInitialization();
return $inventory;
} }
/** /**

View File

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

View File

@@ -194,6 +194,7 @@ class VariantReplacementService
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock, 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'real_stock' => $sourceInventory->real_stock, 'real_stock' => $sourceInventory->real_stock,
]); ]);
$replacementInventory->recordTransferInitialization();
if ($activeLines->isNotEmpty()) { if ($activeLines->isNotEmpty()) {
StockReservationLine::query() StockReservationLine::query()
@@ -202,7 +203,10 @@ class VariantReplacementService
} }
EntryReservation::query()->where('inventory_id', $sourceInventory->id) EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $replacementInventory->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; return $replacementInventory;
} }
@@ -236,13 +240,14 @@ class VariantReplacementService
throw new \LogicException('El inventario reservado de la variante es inconsistente.'); throw new \LogicException('El inventario reservado de la variante es inconsistente.');
} }
$destinationInventory->update([ $transferredCounters = [
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, 'real_stock' => $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, 'reserved_stock' => $sourceInventory->reserved_stock,
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock, 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, 'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units, 'refunded_units' => $sourceInventory->refunded_units,
]); ];
$destinationInventory->transferCounters($transferredCounters);
if ($activeLines->isNotEmpty()) { if ($activeLines->isNotEmpty()) {
StockReservationLine::query() StockReservationLine::query()
->whereKey($activeLines->modelKeys()) ->whereKey($activeLines->modelKeys())
@@ -250,13 +255,10 @@ class VariantReplacementService
} }
EntryReservation::query()->where('inventory_id', $sourceInventory->id) EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $destinationInventory->id]); ->update(['inventory_id' => $destinationInventory->id]);
$sourceInventory->update([ $sourceInventory->transferCounters(array_map(
'real_stock' => 0, fn (int $value): int => -$value,
'reserved_stock' => 0, $transferredCounters,
'entry_reserved_stock' => 0, ));
'sold_units' => 0,
'refunded_units' => 0,
]);
} }
/** @return list<string> */ /** @return list<string> */

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Commerce\Purchase\Services; namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -69,15 +70,12 @@ class TenantTransactionResetService
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
]; ];
DB::table('inventories') Inventory::query()
->whereIn('id', $scope['inventory_ids']) ->whereKey($scope['inventory_ids'])
->update([ ->orderBy('id')
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), ->lockForUpdate()
'reserved_stock' => 0, ->get()
'entry_reserved_stock' => 0, ->each(fn (Inventory $inventory) => $inventory->resetTransactionCounters());
'sold_units' => 0,
'refunded_units' => 0,
]);
return $summary; return $summary;
}); });

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticketing\Desfile\Services; namespace App\Domains\Ticketing\Desfile\Services;
use App\Domains\Commerce\Catalog\Models\Inventory;
use DateTimeInterface; use DateTimeInterface;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -393,14 +394,22 @@ class InvitationPurchaseProvisioner
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first(); $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."); throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
} }
DB::table('inventories')->where('id', $inventory->id)->update([ if (property_exists($inventory, 'available_stock')) {
'real_stock' => $inventory->real_stock - 1, Inventory::query()->findOrFail($inventory->id)->commitDirectSale(1);
'sold_units' => $inventory->sold_units + 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'); $reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
if ($reservationId === null) { if ($reservationId === null) {

View File

@@ -228,13 +228,13 @@ class AdminAppTicketService
'amount' => number_format($refundAmount, 2, '.', ''), 'amount' => number_format($refundAmount, 2, '.', ''),
]); ]);
$this->restoreInventory($ticket, $purchaseItem); $this->restoreInventory($ticket, $purchaseItem, $createdBy);
return $ticket->refresh()->load(self::RELATIONS); 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; $catalogItem = $ticket->sourceCatalogItem;
if ($catalogItem === null) { if ($catalogItem === null) {
@@ -272,11 +272,7 @@ class AdminAppTicketService
throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']); throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']);
} }
if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) { $inventory->refundStock(1, $createdBy);
$inventory->real_stock++;
}
$inventory->refunded_units++;
$inventory->save();
} }
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Commerce\Catalog\Models\Inventory;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
@@ -64,14 +65,9 @@ class BackfillRefundedUnitsService
}, 'refunds.id', 'id'); }, 'refunds.id', 'id');
foreach ($counts as $inventoryId => $count) { foreach ($counts as $inventoryId => $count) {
$updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])]; $inventory = Inventory::query()->lockForUpdate()->find($inventoryId)
if (($count['stock'] ?? 0) > 0) { ?? throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo.");
$updates['real_stock'] = DB::raw('real_stock + '.$count['stock']); $inventory->refundStock($count['refunded']);
}
if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) {
throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo.");
}
} }
$refundedUnitsAdded = array_sum(array_column($counts, 'refunded')); $refundedUnitsAdded = array_sum(array_column($counts, 'refunded'));
@@ -82,7 +78,8 @@ class BackfillRefundedUnitsService
'bundles_skipped' => $bundlesSkipped, 'bundles_skipped' => $bundlesSkipped,
'inventories_updated' => count($counts), 'inventories_updated' => count($counts),
'refunded_units_added' => $refundedUnitsAdded, 'refunded_units_added' => $refundedUnitsAdded,
'real_stock_added' => array_sum(array_column($counts, 'stock')), 'real_stock_added' => 0,
'available_stock_added' => $refundedUnitsAdded,
]; ];
}); });