Squashed commit of the following:

commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
This commit is contained in:
2026-08-26 10:30:16 -03:00
parent d0424c5589
commit 414df97e9d
40 changed files with 1839 additions and 764 deletions

View File

@@ -0,0 +1,180 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::rename('stock_reservations', 'stock_reservation_lines');
Schema::create('stock_reservations', function (Blueprint $table): void {
$table->id();
$table->string('status')->default('active');
$table->dateTime('expires_at')->nullable();
$table->dateTime('committed_at')->nullable();
$table->dateTime('released_at')->nullable();
$table->dateTime('expired_at')->nullable();
$table->string('release_reason')->nullable();
$table->timestamps();
$table->index(['status', 'expires_at']);
});
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->foreignId('stock_reservation_id')->nullable()->after('id');
$table->boolean('tracks_inventory')->default(true)->after('quantity');
});
Schema::table('carritos', function (Blueprint $table): void {
$table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id');
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('stock_reservation_id')->nullable()->after('cart_id');
});
$cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id');
$unlimitedInventoryIds = DB::table('catalog_items')
->where('inventory_policy', 'unlimited')
->whereNotNull('inventory_id')
->pluck('inventory_id')
->merge(
DB::table('variantes')
->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id')
->where('catalog_items.inventory_policy', 'unlimited')
->pluck('variantes.inventory_id'),
)
->map(fn ($id): int => (int) $id)
->unique();
$legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get();
$groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string {
if ($row->purchase_id !== null) {
return 'purchase:'.$row->purchase_id;
}
$cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id);
return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId;
});
foreach ($groups as $key => $rows) {
$statuses = $rows->pluck('status');
$status = $statuses->contains('active')
? 'active'
: ($statuses->contains('committed')
? 'committed'
: ($statuses->contains('expired') ? 'expired' : 'released'));
$first = $rows->first();
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => $status,
'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null,
'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null,
'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null,
'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null,
'release_reason' => null,
'created_at' => $first->created_at,
'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at,
]);
foreach ($rows->groupBy('inventory_id') as $inventoryRows) {
$line = $inventoryRows->first();
DB::table('stock_reservation_lines')->where('id', $line->id)->update([
'stock_reservation_id' => $reservationId,
'quantity' => $inventoryRows->sum('quantity'),
'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id),
]);
DB::table('stock_reservation_lines')
->whereIn('id', $inventoryRows->pluck('id')->skip(1))
->delete();
}
if (str_starts_with($key, 'purchase:')) {
$purchaseId = (int) substr($key, strlen('purchase:'));
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
$cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id');
$isCurrent = $cartId !== null
&& (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId;
if ($status === 'active' && $isCurrent) {
DB::table('carritos')->where('id', $cartId)->update([
'current_stock_reservation_id' => $reservationId,
]);
}
} elseif (str_starts_with($key, 'cart:') && $status === 'active') {
DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([
'current_stock_reservation_id' => $reservationId,
]);
}
}
if (DB::getDriverName() !== 'sqlite') {
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->dropForeign('stock_reservations_cart_item_id_foreign');
$table->dropForeign('stock_reservations_purchase_id_foreign');
$table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique');
$table->dropIndex('stock_reservations_purchase_id_status_index');
$table->dropIndex('stock_reservations_status_expires_at_index');
});
}
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change();
$table->dropColumn([
'cart_item_id',
'purchase_id',
'status',
'expires_at',
'committed_at',
'released_at',
]);
$table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk')
->references('id')
->on('stock_reservations')
->cascadeOnDelete();
$table->unique(
['stock_reservation_id', 'inventory_id'],
'reservation_lines_reservation_inventory_unique',
);
});
Schema::table('carritos', function (Blueprint $table): void {
$table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk')
->references('id')
->on('stock_reservations')
->nullOnDelete();
$table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique');
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk')
->references('id')
->on('stock_reservations')
->nullOnDelete();
$table->unique('stock_reservation_id', 'purchases_stock_reservation_unique');
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropUnique('purchases_stock_reservation_unique');
$table->dropForeign('purchases_stock_reservation_fk');
$table->dropColumn('stock_reservation_id');
});
Schema::table('carritos', function (Blueprint $table): void {
$table->dropUnique('carts_current_stock_reservation_unique');
$table->dropForeign('carts_current_stock_reservation_fk');
$table->dropColumn('current_stock_reservation_id');
});
Schema::dropIfExists('stock_reservation_lines');
Schema::dropIfExists('stock_reservations');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::table('compras')
->whereNotNull('stock_reservation_id')
->orderBy('id')
->chunkById(500, function ($purchases): void {
foreach ($purchases as $purchase) {
DB::table('stock_reservations')
->where('id', $purchase->stock_reservation_id)
->where('status', 'active')
->update(['expires_at' => $purchase->expires_at]);
}
});
Schema::table('compras', function (Blueprint $table): void {
$table->dropIndex(['expires_at']);
$table->dropColumn('expires_at');
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->timestamp('expires_at')->nullable()->index()->after('payment_method');
});
DB::table('compras')
->whereNotNull('stock_reservation_id')
->orderBy('id')
->chunkById(500, function ($purchases): void {
foreach ($purchases as $purchase) {
DB::table('compras')
->where('id', $purchase->id)
->update([
'expires_at' => DB::table('stock_reservations')
->where('id', $purchase->stock_reservation_id)
->value('expires_at'),
]);
}
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$expiredReservationIds = fn ($query) => $query
->select('id')
->from('stock_reservations')
->where('status', 'expired');
DB::table('compras')
->whereIn('status', ['created', 'pending_payment'])
->whereIn('stock_reservation_id', $expiredReservationIds)
->update(['status' => 'expired']);
DB::table('carritos')
->whereIn('status', ['active', 'checkout'])
->whereIn('current_stock_reservation_id', $expiredReservationIds)
->update(['status' => 'expired']);
}
public function down(): void
{
// Terminal business states cannot be reversed without inventing their prior state.
}
};