From 7996d53e12857b6bff670bd3db1d40bd439f5b47 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:13:36 +0000 Subject: [PATCH 1/7] feat(events): associate purchases and integrations with events --- .../Commerce/Purchase/Models/Purchase.php | 13 ++++- .../Purchase/Resources/PurchaseResource.php | 5 +- .../Checkout/PurchaseResponseLoader.php | 1 + .../Checkout/StartCheckoutService.php | 4 ++ app/Domains/Core/Client/Models/Client.php | 9 ++- app/Domains/Ticketing/Event/Models/Event.php | 16 +++++- ...6_09_28_000000_add_client_id_to_events.php | 26 +++++++++ ...6_09_28_000100_add_event_id_to_compras.php | 44 ++++++++++++++ ...sign_arrufo_event_to_pyme_rural_client.php | 42 ++++++++++++++ .../seeders/FiestaTradicionArrufoSeeder.php | 8 +++ .../Migrations/AddClientIdToEventsTest.php | 57 +++++++++++++++++++ .../Migrations/AddEventIdToPurchasesTest.php | 44 ++++++++++++++ ...AssignArrufoEventToPymeRuralClientTest.php | 50 ++++++++++++++++ 13 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_09_28_000000_add_client_id_to_events.php create mode 100644 database/migrations/2026_09_28_000100_add_event_id_to_compras.php create mode 100644 database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php create mode 100644 tests/Feature/Migrations/AddClientIdToEventsTest.php create mode 100644 tests/Feature/Migrations/AddEventIdToPurchasesTest.php create mode 100644 tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php diff --git a/app/Domains/Commerce/Purchase/Models/Purchase.php b/app/Domains/Commerce/Purchase/Models/Purchase.php index 5c75fa7f..68fec2f3 100644 --- a/app/Domains/Commerce/Purchase/Models/Purchase.php +++ b/app/Domains/Commerce/Purchase/Models/Purchase.php @@ -2,13 +2,14 @@ namespace App\Domains\Commerce\Purchase\Models; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Models\StockReservation; -use App\Shared\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Commerce\Purchase\Events\PurchasePaid; +use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Logging\Models\Concerns\LogsValueChanges; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB; #[Fillable([ 'cart_id', 'stock_reservation_id', + 'event_id', 'tenant_codigo', 'user_id', 'status', @@ -149,6 +151,7 @@ class Purchase extends Model return [ 'cart_id' => 'integer', 'stock_reservation_id' => 'integer', + 'event_id' => 'integer', 'user_id' => 'integer', 'total' => 'decimal:2', ]; @@ -162,6 +165,12 @@ class Purchase extends Model return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + /** * @return BelongsTo */ diff --git a/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php b/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php index 2e98a2e8..d5624cf5 100644 --- a/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php @@ -36,11 +36,11 @@ class PurchaseResource extends JsonResource === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; /** @var Event|null $checkoutEvent */ $checkoutEvent = $usesEventSummary - ? $items + ? ($this->event ?? $items ->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event) ->filter() ->unique('id') - ->first() + ->first()) : null; $subtotal = $items->isNotEmpty() @@ -62,6 +62,7 @@ class PurchaseResource extends JsonResource return [ 'id' => $this->id, 'cart_id' => $this->cart_id, + 'event_id' => $this->event_id, 'tenant_codigo' => $this->tenant_codigo, 'user_id' => $this->user_id, 'created_at' => $this->created_at, diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php index ed82d2d6..88dfb5f3 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -14,6 +14,7 @@ class PurchaseResponseLoader === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; $relations = [ 'tenant', + 'event.attachment', 'items.sourceCatalogItem.event.attachment', 'stockReservation', ]; diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php index e2a1bf14..e21ab889 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php @@ -206,6 +206,7 @@ class StartCheckoutService fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], ), $cart->getKey(), + $cartItems->first()?->catalogItem?->event_id, ); $cart->update(['current_purchase_id' => $purchase->getKey()]); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); @@ -273,6 +274,7 @@ class StartCheckoutService $purchaseData, $cart->getTotalAmount(), $cart->getKey(), + $cartItems->first()?->catalogItem?->event_id, ); $cart->update(['current_purchase_id' => $purchase->getKey()]); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); @@ -410,10 +412,12 @@ class StartCheckoutService array $purchaseData, float $total, ?int $cartId, + ?int $eventId, ): Purchase { return Purchase::query()->create([ ...$purchaseData, 'cart_id' => $cartId, + 'event_id' => $eventId, 'tenant_codigo' => $tenant->codigo, 'user_id' => $userId, 'status' => Purchase::STATUS_CREATED, diff --git a/app/Domains/Core/Client/Models/Client.php b/app/Domains/Core/Client/Models/Client.php index f52054ff..e7381320 100644 --- a/app/Domains/Core/Client/Models/Client.php +++ b/app/Domains/Core/Client/Models/Client.php @@ -2,8 +2,9 @@ namespace App\Domains\Core\Client\Models; -use App\Shared\Integration\Models\ClientIntegration; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Integration\Models\ClientIntegration; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -22,6 +23,12 @@ class Client extends Model return $this->hasMany(Tenant::class); } + /** @return HasMany */ + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + /** @return HasMany */ public function integrations(): HasMany { diff --git a/app/Domains/Ticketing/Event/Models/Event.php b/app/Domains/Ticketing/Event/Models/Event.php index d27905f1..ca3e042d 100644 --- a/app/Domains/Ticketing/Event/Models/Event.php +++ b/app/Domains/Ticketing/Event/Models/Event.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Event\Models; use App\Domains\Commerce\Catalog\Models\CatalogItem; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; @@ -14,14 +15,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] +#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] class Event extends Model { use HasFactory; protected function casts(): array { - return ['published_at' => 'datetime', 'exact_location' => 'array']; + return ['client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array']; } /** @return BelongsTo */ @@ -30,6 +31,17 @@ class Event extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + /** @return BelongsTo */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function effectiveClient(): Client + { + return $this->client ?? $this->tenant->client; + } + /** @return BelongsTo */ public function eventCategory(): BelongsTo { diff --git a/database/migrations/2026_09_28_000000_add_client_id_to_events.php b/database/migrations/2026_09_28_000000_add_client_id_to_events.php new file mode 100644 index 00000000..039f6804 --- /dev/null +++ b/database/migrations/2026_09_28_000000_add_client_id_to_events.php @@ -0,0 +1,26 @@ +foreignId('client_id') + ->nullable() + ->after('id') + ->constrained('clients') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table): void { + $table->dropConstrainedForeignId('client_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_000100_add_event_id_to_compras.php b/database/migrations/2026_09_28_000100_add_event_id_to_compras.php new file mode 100644 index 00000000..0dad3a5b --- /dev/null +++ b/database/migrations/2026_09_28_000100_add_event_id_to_compras.php @@ -0,0 +1,44 @@ +foreignId('event_id') + ->nullable() + ->after('tenant_codigo') + ->constrained('events') + ->nullOnDelete(); + }); + + DB::table('compras') + ->orderBy('id') + ->each(function (object $purchase): void { + $eventIds = DB::table('compra_items') + ->join('catalog_items', 'catalog_items.id', '=', 'compra_items.source_catalog_item_id') + ->where('compra_items.compra_id', $purchase->id) + ->whereNotNull('catalog_items.event_id') + ->distinct() + ->pluck('catalog_items.event_id'); + + if ($eventIds->count() === 1) { + DB::table('compras') + ->where('id', $purchase->id) + ->update(['event_id' => $eventIds->first()]); + } + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php b/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php new file mode 100644 index 00000000..601f22e8 --- /dev/null +++ b/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php @@ -0,0 +1,42 @@ +where('code', 'pyme_rural') + ->value('id'); + + if ($clientId === null) { + return; + } + + DB::table('events') + ->where('tenant_code', 'onticket') + ->where('title', self::EVENT_TITLE) + ->update(['client_id' => $clientId]); + } + + public function down(): void + { + $clientId = DB::table('clients') + ->where('code', 'pyme_rural') + ->value('id'); + + if ($clientId === null) { + return; + } + + DB::table('events') + ->where('tenant_code', 'onticket') + ->where('title', self::EVENT_TITLE) + ->where('client_id', $clientId) + ->update(['client_id' => null]); + } +}; diff --git a/database/seeders/FiestaTradicionArrufoSeeder.php b/database/seeders/FiestaTradicionArrufoSeeder.php index 79b0dbbd..69263cb7 100644 --- a/database/seeders/FiestaTradicionArrufoSeeder.php +++ b/database/seeders/FiestaTradicionArrufoSeeder.php @@ -6,6 +6,7 @@ use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Services\CatalogService; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Models\Event; use App\Shared\Attachable\Services\AttachmentService; @@ -35,6 +36,12 @@ class FiestaTradicionArrufoSeeder extends Seeder throw new RuntimeException("Tenant 'onticket' no encontrado."); } + $client = Client::query()->where('code', 'pyme_rural')->first(); + + if ($client === null) { + throw new RuntimeException("Client 'pyme_rural' no encontrado."); + } + Attribute::query()->firstOrCreate( ['tenant_codigo' => $tenant->codigo, 'codigo' => 'event_date'], ['nombre' => 'Fecha', 'type' => FieldType::EventDate, 'is_required' => true], @@ -45,6 +52,7 @@ class FiestaTradicionArrufoSeeder extends Seeder ['published_at' => now()], ); $event->update([ + 'client_id' => $client->id, 'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.', 'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.' ."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.' diff --git a/tests/Feature/Migrations/AddClientIdToEventsTest.php b/tests/Feature/Migrations/AddClientIdToEventsTest.php new file mode 100644 index 00000000..1fdb6e0d --- /dev/null +++ b/tests/Feature/Migrations/AddClientIdToEventsTest.php @@ -0,0 +1,57 @@ +id(); + $table->string('code'); + $table->string('name'); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id'); + $table->string('codigo'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('title'); + $table->timestamps(); + }); + + DB::table('clients')->insert([ + ['id' => 10, 'code' => 'tenant-client', 'name' => 'Tenant Client'], + ['id' => 11, 'code' => 'event-client', 'name' => 'Event Client'], + ]); + DB::table('tenants')->insert(['id' => 20, 'client_id' => 10, 'codigo' => 'tenant']); + DB::table('events')->insert([ + 'id' => 30, + 'tenant_code' => 'tenant', + 'title' => 'Event', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_09_28_000000_add_client_id_to_events.php'); + $migration->up(); + + $event = Event::query()->findOrFail(30); + $this->assertSame(10, $event->effectiveClient()->id); + + $event->update(['client_id' => 11]); + + $this->assertSame(11, $event->fresh()->effectiveClient()->id); + $this->assertTrue(Client::query()->findOrFail(11)->events()->whereKey(30)->exists()); + } +} diff --git a/tests/Feature/Migrations/AddEventIdToPurchasesTest.php b/tests/Feature/Migrations/AddEventIdToPurchasesTest.php new file mode 100644 index 00000000..2c6d24e3 --- /dev/null +++ b/tests/Feature/Migrations/AddEventIdToPurchasesTest.php @@ -0,0 +1,44 @@ + $table->id()); + Schema::create('compras', fn (Blueprint $table) => $table->id()); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('event_id')->nullable(); + }); + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('compra_id'); + $table->foreignId('source_catalog_item_id'); + }); + + DB::table('events')->insert([['id' => 10], ['id' => 11]]); + DB::table('compras')->insert([['id' => 20], ['id' => 21]]); + DB::table('catalog_items')->insert([ + ['id' => 30, 'event_id' => 10], + ['id' => 31, 'event_id' => 11], + ]); + DB::table('compra_items')->insert([ + ['compra_id' => 20, 'source_catalog_item_id' => 30], + ['compra_id' => 21, 'source_catalog_item_id' => 30], + ['compra_id' => 21, 'source_catalog_item_id' => 31], + ]); + + $migration = require database_path('migrations/2026_09_28_000100_add_event_id_to_compras.php'); + $migration->up(); + + $this->assertDatabaseHas('compras', ['id' => 20, 'event_id' => 10]); + $this->assertDatabaseHas('compras', ['id' => 21, 'event_id' => null]); + } +} diff --git a/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php b/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php new file mode 100644 index 00000000..91a80fd2 --- /dev/null +++ b/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php @@ -0,0 +1,50 @@ +id(); + $table->string('code'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id')->nullable(); + $table->string('tenant_code'); + $table->string('title'); + }); + + DB::table('clients')->insert([ + ['id' => 10, 'code' => 'pyme_rural'], + ['id' => 11, 'code' => 'onticket'], + ]); + DB::table('events')->insert([ + [ + 'id' => 20, + 'client_id' => null, + 'tenant_code' => 'onticket', + 'title' => '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas', + ], + [ + 'id' => 21, + 'client_id' => 11, + 'tenant_code' => 'onticket', + 'title' => 'Otro evento', + ], + ]); + + $migration = require database_path('migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php'); + $migration->up(); + + $this->assertDatabaseHas('events', ['id' => 20, 'client_id' => 10]); + $this->assertDatabaseHas('events', ['id' => 21, 'client_id' => 11]); + } +} From 3f9b7733671cbde5f4b91c8d0781536091d7c888 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:14:23 +0000 Subject: [PATCH 2/7] feat(integrations): resolve payment context from events --- .../Controllers/PurchaseController.php | 11 ++- .../Services/BaseIntegrationService.php | 16 +++- .../Integration/Services/MailService.php | 38 +++++---- .../Services/TelepagosIntegrationService.php | 2 +- .../Services/TelepagosWebhookService.php | 39 ++++++++- .../Services/NotificationMailService.php | 28 ++++--- .../Integration/TelepagosWebhookTest.php | 81 ++++++++++++++++++- 7 files changed, 173 insertions(+), 42 deletions(-) diff --git a/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php b/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php index f2036131..19a94804 100644 --- a/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Purchase\Controllers; -use App\Shared\Integration\Services\TelepagosIntegrationService; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest; use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest; @@ -13,6 +12,7 @@ use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard; use App\Domains\Core\Tenant\Models\Tenant; use App\Http\Controllers\Controller; +use App\Shared\Integration\Services\TelepagosIntegrationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -151,11 +151,14 @@ class PurchaseController extends Controller } $compra->refresh(); + $compra->loadMissing('event'); $totalAmount = (float) $compra->total; if ($method === 'transfer') { $telepagosService = new TelepagosIntegrationService; - $telepagosService->forTenant($tenant->codigo); + $compra->event + ? $telepagosService->forEvent($compra->event) + : $telepagosService->forTenant($tenant->codigo); try { $accountInfo = $telepagosService->getAccountInfo(); @@ -191,7 +194,9 @@ class PurchaseController extends Controller if ($method === 'qr') { $telepagosService = new TelepagosIntegrationService; - $telepagosService->forTenant($tenant->codigo); + $compra->event + ? $telepagosService->forEvent($compra->event) + : $telepagosService->forTenant($tenant->codigo); try { Log::channel('telepagos')->info('Generating Telepagos QR.', [ diff --git a/app/Shared/Integration/Services/BaseIntegrationService.php b/app/Shared/Integration/Services/BaseIntegrationService.php index a0244730..09f16b1b 100644 --- a/app/Shared/Integration/Services/BaseIntegrationService.php +++ b/app/Shared/Integration/Services/BaseIntegrationService.php @@ -3,11 +3,12 @@ namespace App\Shared\Integration\Services; use App\Domains\Core\Client\Models\Client; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Integration\Models\AdminWebsiteTypeIntegration; use App\Shared\Integration\Models\ClientIntegration; use App\Shared\Integration\Models\Integration; use App\Shared\Integration\Models\IntegrationInstance; -use App\Shared\Integration\Models\AdminWebsiteTypeIntegration; -use App\Domains\Core\Tenant\Models\Tenant; use Exception; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; @@ -86,6 +87,17 @@ abstract class BaseIntegrationService return $this; } + public function forEvent(Event $event): self + { + $event->loadMissing(['client', 'tenant.client']); + $this->tenant = $event->tenant; + $this->tenantCode = $event->tenant_code; + $this->clientContext = $event->effectiveClient(); + $this->loadIntegration(); + + return $this; + } + /** * Load the integration definition and its effective instance configuration. * diff --git a/app/Shared/Integration/Services/MailService.php b/app/Shared/Integration/Services/MailService.php index 71682e0a..cadffb0b 100644 --- a/app/Shared/Integration/Services/MailService.php +++ b/app/Shared/Integration/Services/MailService.php @@ -3,8 +3,9 @@ namespace App\Shared\Integration\Services; use App\Domains\Core\Client\Models\Client; -use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\AdminWebsiteType; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use Exception; use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailer; @@ -39,14 +40,7 @@ class MailService extends BaseIntegrationService public function forTenant(string $tenantCode): self { parent::forTenant($tenantCode); - - if ($this->integrationInstance) { - $this->mailer = $this->resolveMailer(); - $this->usesInstanceMailer = true; - } else { - $this->mailer = $this->mailFactory->mailer(); - $this->usesInstanceMailer = false; - } + $this->configureMailer(); return $this; } @@ -55,16 +49,30 @@ class MailService extends BaseIntegrationService { parent::forClient($client); $this->tenant = $this->clientContext?->tenants()->first(); + $this->configureMailer(); + return $this; + } + + public function forEvent(Event $event): self + { + parent::forEvent($event); + $this->configureMailer(); + + return $this; + } + + private function configureMailer(): void + { if ($this->integrationInstance) { $this->mailer = $this->resolveMailer(); $this->usesInstanceMailer = true; - } else { - $this->mailer = $this->mailFactory->mailer(); - $this->usesInstanceMailer = false; + + return; } - return $this; + $this->mailer = $this->mailFactory->mailer(); + $this->usesInstanceMailer = false; } public function getHeaders(): array @@ -83,7 +91,7 @@ class MailService extends BaseIntegrationService array $attachments = [], ): void { if (! $this->mailer || ! $this->tenant) { - throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); + throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); } $brand ??= $this->tenant; @@ -160,7 +168,7 @@ class MailService extends BaseIntegrationService public function onSetup(): void { if (! $this->mailer || ! $this->clientContext) { - throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); + throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); } $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); diff --git a/app/Shared/Integration/Services/TelepagosIntegrationService.php b/app/Shared/Integration/Services/TelepagosIntegrationService.php index 83ac8101..424d02b5 100644 --- a/app/Shared/Integration/Services/TelepagosIntegrationService.php +++ b/app/Shared/Integration/Services/TelepagosIntegrationService.php @@ -46,7 +46,7 @@ class TelepagosIntegrationService extends BaseIntegrationService public function getToken(): string { if (! $this->integrationInstance) { - throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); + throw new Exception('Client integration is not loaded. Call forTenant(), forClient(), or forEvent() first.'); } $cacheKey = $this->integrationInstance->tokenCacheKey(); diff --git a/app/Shared/Integration/Services/TelepagosWebhookService.php b/app/Shared/Integration/Services/TelepagosWebhookService.php index 8c120cb7..20699e8a 100644 --- a/app/Shared/Integration/Services/TelepagosWebhookService.php +++ b/app/Shared/Integration/Services/TelepagosWebhookService.php @@ -2,12 +2,12 @@ namespace App\Shared\Integration\Services; -use App\Domains\Core\Client\Models\Client; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosQr; use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\DniDistanceService; +use App\Domains\Core\Client\Models\Client; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -75,9 +75,30 @@ class TelepagosWebhookService $dni = substr($cuit, 2, -1); - $tenantCodes = $client->tenants()->pluck('codigo'); $eligiblePurchases = Purchase::query() - ->whereIn('tenant_codigo', $tenantCodes) + ->where(function (Builder $purchases) use ($client): void { + $purchases + ->whereHas('event', function (Builder $events) use ($client): void { + $events + ->where('client_id', $client->id) + ->orWhere(function (Builder $fallback) use ($client): void { + $fallback + ->whereNull('client_id') + ->whereHas( + 'tenant', + fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id), + ); + }); + }) + ->orWhere(function (Builder $legacy) use ($client): void { + $legacy + ->whereNull('event_id') + ->whereHas( + 'tenant', + fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id), + ); + }); + }) ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, @@ -170,7 +191,7 @@ class TelepagosWebhookService return; } - if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) { + if (! $this->purchaseBelongsToClient($compra, $client)) { Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, @@ -244,6 +265,16 @@ class TelepagosWebhookService return number_format((float) $amount, 2, '.', ''); } + private function purchaseBelongsToClient(Purchase $purchase, Client $client): bool + { + $purchase->loadMissing(['event.client', 'event.tenant.client', 'tenant.client']); + + $effectiveClient = $purchase->event?->effectiveClient() + ?? $purchase->tenant?->client; + + return $effectiveClient?->is($client) ?? false; + } + /** * @param Builder $eligiblePurchases * @return Collection diff --git a/app/Shared/Notification/Services/NotificationMailService.php b/app/Shared/Notification/Services/NotificationMailService.php index 707436d3..00866957 100644 --- a/app/Shared/Notification/Services/NotificationMailService.php +++ b/app/Shared/Notification/Services/NotificationMailService.php @@ -2,16 +2,16 @@ namespace App\Shared\Notification\Services; +use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\User; -use App\Shared\Integration\Services\MailService; -use App\Shared\Notification\Events\PasswordResetRequested; -use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Integration\Services\MailService; +use App\Shared\Notification\Events\PasswordResetRequested; use Closure; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -158,7 +158,7 @@ class NotificationMailService $context = ['purchase_id' => $purchaseId]; $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) + ->with(['event', 'tenant', 'user', 'items']) ->find($purchaseId); if ($purchase === null) { @@ -197,8 +197,7 @@ class NotificationMailService 'mime' => 'application/pdf', ]]; - $this->mailService - ->forTenant($purchase->tenant_codigo) + $this->mailForPurchase($purchase) ->send( $recipient, "Compra confirmada - Compra #{$purchase->getKey()}", @@ -286,7 +285,6 @@ class NotificationMailService $context, $recipient, function () use ( - $tenantCode, $purchase, $recipient, $previousDate, @@ -295,8 +293,7 @@ class NotificationMailService ): array { $brand = $purchase->tenant; - $this->mailService - ->forTenant($tenantCode) + $this->mailForPurchase($purchase) ->send( $recipient, "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", @@ -387,7 +384,6 @@ class NotificationMailService $context, $recipient, function () use ( - $tenantCode, $purchase, $recipient, $date, @@ -396,8 +392,7 @@ class NotificationMailService ): array { $brand = $purchase->tenant; - $this->mailService - ->forTenant($tenantCode) + $this->mailForPurchase($purchase) ->send( $recipient, "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", @@ -421,10 +416,17 @@ class NotificationMailService { return Purchase::query() ->where('tenant_codigo', $tenantCode) - ->with(['tenant', 'user']) + ->with(['event', 'tenant', 'user']) ->find($purchaseId); } + private function mailForPurchase(Purchase $purchase): MailService + { + return $purchase->event + ? $this->mailService->forEvent($purchase->event) + : $this->mailService->forTenant($purchase->tenant_codigo); + } + private function recipientFor(Purchase $purchase): string { return (string) ($purchase->email ?: $purchase->user?->email); diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 17518dce..4c823273 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -2,20 +2,22 @@ namespace Tests\Feature\Integration; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Variant; -use App\Shared\Integration\Models\Integration; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Services\CheckoutService; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; +use App\Shared\Integration\Models\Integration; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -227,6 +229,77 @@ class TelepagosWebhookTest extends TestCase ]); } + public function test_transfer_webhook_matches_the_client_configured_on_the_event(): void + { + $tenant = $this->createTenant('multi-event', 'Multi Event', 'multi-event.com.ar'); + $eventClient = Client::query()->create([ + 'code' => 'event-client', + 'name' => 'Event Client', + ]); + Integration::query()->create([ + 'integration_code' => 'telepagos_homo', + 'name' => 'Telepagos', + 'url' => 'https://api.telepagos.com.ar', + 'integration_data_schema' => [ + 'username' => 'required|string', + 'password' => 'required|string', + ], + ]); + $this->createClientIntegration([ + 'client_id' => $eventClient->id, + 'integration_code' => 'telepagos_homo', + 'integration_data' => ['username' => 'event-user', 'password' => 'event-password'], + ]); + $event = Event::query()->create([ + 'client_id' => $eventClient->id, + 'tenant_code' => $tenant->codigo, + 'title' => 'Event with its own client', + ]); + $tenant->update(['active_event_id' => $event->id]); + + $user = User::factory()->create(); + $variant = $this->createVariantForTenant($tenant->codigo, 10, '50.00'); + $purchase = $this->createPendingTransferPurchase( + $tenant, + $user->id, + $variant->id, + 1, + '12345678', + ); + + Http::fake([ + 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ + 'status' => 'ok', + 'token' => 'event-client-token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'https://api.telepagos.com.ar/v2/payment/cashin/event-client-payment' => Http::response([ + 'status' => 'ok', + 'data' => [ + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-event-client', + 'buyer' => ['cuit' => '20123456789'], + ], + ]), + ]); + + $this->postJson('/api/webhooks/telepagos/event-client', [ + 'id' => 'event-client-payment', + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertSame($event->id, $purchase->event_id); + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'event_id' => $event->id, + 'status' => Purchase::STATUS_PAID, + ]); + $this->assertDatabaseHas('telepagos_payments', [ + 'compra_id' => $purchase->id, + 'transaction_id' => 'tx-event-client', + ]); + } + public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void { $tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar'); From 068d5d7f4f9c7c871bde2cb556ca07ca0631b9a6 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 10:15:08 -0300 Subject: [PATCH 3/7] feat(inventory): add calculated availability and movement ledger --- .../Enums/InventoryMovementOperation.php | 18 ++ .../Commerce/Catalog/Models/CatalogItem.php | 17 +- .../Commerce/Catalog/Models/Inventory.php | 306 +++++++++++++++--- .../Catalog/Models/InventoryMovement.php | 45 +++ ...000_generate_available_inventory_stock.php | 59 ++++ ...10000_create_inventory_movements_table.php | 33 ++ .../InventoryStockMovementMigrationTest.php | 135 ++++++++ 7 files changed, 569 insertions(+), 44 deletions(-) create mode 100644 app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php create mode 100644 app/Domains/Commerce/Catalog/Models/InventoryMovement.php create mode 100644 database/migrations/2026_09_25_000000_generate_available_inventory_stock.php create mode 100644 database/migrations/2026_09_25_010000_create_inventory_movements_table.php create mode 100644 tests/Feature/Migrations/InventoryStockMovementMigrationTest.php 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/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..fe0694f0 --- /dev/null +++ b/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php @@ -0,0 +1,59 @@ +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 + { + if (DB::table('inventories')->whereRaw("({$expression}) < 0")->exists()) { + throw new RuntimeException("Hay inventarios inconsistentes: {$reason}."); + } + } +}; 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/Migrations/InventoryStockMovementMigrationTest.php b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php new file mode 100644 index 00000000..c70c29ee --- /dev/null +++ b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php @@ -0,0 +1,135 @@ +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([ + 'real_stock' => 1, + 'reserved_stock' => 2, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('stock disponible actual es negativo'); + + $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'); + } +} From bc60f1caa1b33038d57e5227b35f21acd56492f7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 10:15:19 -0300 Subject: [PATCH 4/7] 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, ]; }); From e988e55b77be21ef43e9473cadf88839d3037495 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 10:15:29 -0300 Subject: [PATCH 5/7] feat(admin-stock): apply idempotent availability deltas --- .../Controllers/AccommodationController.php | 1 + .../Controllers/EntryController.php | 1 + .../Controllers/FoodController.php | 2 + .../Controllers/MerchandiseController.php | 1 + .../UpdateHistoricalFoodStockRequest.php | 4 +- .../UpsertAccommodationVariantsRequest.php | 4 +- .../Requests/UpsertEntriesRequest.php | 4 +- .../Requests/UpsertFoodVariantsRequest.php | 4 +- .../Requests/UpsertMerchandiseRequest.php | 4 +- .../Resources/AccommodationResource.php | 2 +- .../Resources/EntryResource.php | 2 +- .../Resources/FoodResource.php | 2 +- .../Resources/MerchandiseResource.php | 2 +- .../Services/AccommodationService.php | 19 ++++------ .../Services/EntryService.php | 21 ++++------ .../Services/FoodService.php | 38 +++++++------------ .../Services/MerchandiseService.php | 20 ++++------ 17 files changed, 59 insertions(+), 72 deletions(-) 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..d137b168 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php @@ -16,9 +16,11 @@ 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,stock_difference'], 'variants.*.id' => ['required', 'integer', 'distinct'], 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.stock_difference' => ['nullable', 'integer'], ]; } } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php index d275c487..11ff365c 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php @@ -16,11 +16,13 @@ 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_difference' => ['nullable', 'integer'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php index 6827fc4b..b18cf1f3 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', @@ -47,6 +48,7 @@ class UpsertEntriesRequest extends FormRequest ), ], 'entries.*.stock' => ['required', 'integer', 'min:0'], + 'entries.*.stock_difference' => ['nullable', 'integer'], 'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php index 164ca981..1f6872ec 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', @@ -33,6 +34,7 @@ class UpsertFoodVariantsRequest extends FormRequest 'variants.*.service' => ['required', 'string', 'max:255'], 'variants.*.description' => ['sometimes', 'nullable', 'string'], 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.stock_difference' => ['nullable', 'integer'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php index 862a59a0..15b05967 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php @@ -39,11 +39,13 @@ 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_difference' => ['nullable', 'integer'], 'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } 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..20e44ee6 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); } } @@ -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,8 @@ class AccommodationService 'descripcion' => $data['description'], 'precio' => $data['price'], ]); - $inventory->update(['real_stock' => $data['stock']]); + $stockDifference = (int) ($data['stock_difference'] ?? ($data['stock'] - $inventory->availableStock())); + $inventory->adjustAvailableStock($stockDifference, 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..5c486029 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,8 @@ class EntryService $catalogItem->itemAttributes() ->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date')) ->update(['allow_multi_select' => true]); - $inventory->update(['real_stock' => $entry['stock']]); + $stockDifference = (int) ($entry['stock_difference'] ?? ((int) $entry['stock'] - $inventory->availableStock())); + $inventory->adjustAvailableStock($stockDifference, 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..8b9f4c05 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); } } @@ -115,9 +115,9 @@ class FoodService /** * @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,9 @@ 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]); + $stockDifference = (int) ($data['stock_difference'] ?? ((int) $data['stock'] - $inventory->availableStock())); + $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); } return $this->current($tenant) ?? $food; @@ -181,6 +173,7 @@ class FoodService 'reserved_stock' => 0, 'real_stock' => $inventory->real_stock, ]); + $historicalInventory->recordTransferInitialization(); $variant->update(['inventory_id' => $historicalInventory->getKey()]); return $historicalInventory; @@ -384,6 +377,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 +394,21 @@ 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']]); + $stockDifference = (int) ($data['stock_difference'] ?? ($data['stock'] - $inventory->availableStock())); + $inventory->adjustAvailableStock($stockDifference, 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..735406df 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); } } @@ -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,16 @@ 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']]); + $stockDifference = (int) ($data['stock_difference'] ?? ($data['stock'] - $inventory->availableStock())); + $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); $this->syncDefinitions($variant, $itemAttributes, $data); } From 9db955bdcf26ca43da914cce0b3bd7f0c6868109 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 15:28:49 -0300 Subject: [PATCH 6/7] refactor(inventory): update stock validation rules and enforce stock_difference requirement --- .../UpdateHistoricalFoodStockRequest.php | 5 +-- .../UpsertAccommodationVariantsRequest.php | 33 ++++++++++++++- .../Requests/UpsertEntriesRequest.php | 18 ++++++++- .../Requests/UpsertFoodVariantsRequest.php | 18 ++++++++- .../Requests/UpsertMerchandiseRequest.php | 40 ++++++++++++++++++- .../Services/AccommodationService.php | 8 ++-- .../Services/EntryService.php | 6 ++- .../Services/FoodService.php | 16 +++++--- .../Services/MerchandiseService.php | 8 ++-- .../AccommodationControllerTest.php | 2 + .../EntryControllerTest.php | 8 ++-- .../FoodControllerTest.php | 6 ++- .../MerchandiseControllerTest.php | 4 ++ 13 files changed, 141 insertions(+), 31 deletions(-) diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php index d137b168..caa5e853 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php @@ -17,10 +17,9 @@ class UpdateHistoricalFoodStockRequest extends FormRequest return [ 'variants' => ['required', 'array', 'min:1', 'max:500'], 'stock_adjustment_id' => ['nullable', 'uuid'], - 'variants.*' => ['required', 'array:id,stock,stock_difference'], + 'variants.*' => ['required', 'array:id,stock_difference'], 'variants.*.id' => ['required', 'integer', 'distinct'], - 'variants.*.stock' => ['required', 'integer', 'min:0'], - 'variants.*.stock_difference' => ['nullable', 'integer'], + 'variants.*.stock_difference' => ['required', 'integer'], ]; } } diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php index 11ff365c..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 { @@ -21,9 +22,37 @@ class UpsertAccommodationVariantsRequest extends FormRequest '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_difference' => ['nullable', 'integer'], + '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 b18cf1f3..2b982850 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php @@ -47,8 +47,8 @@ class UpsertEntriesRequest extends FormRequest fn ($query) => $query->where('tenant_code', $tenantCode) ), ], - 'entries.*.stock' => ['required', 'integer', 'min:0'], - 'entries.*.stock_difference' => ['nullable', 'integer'], + 'entries.*.stock' => ['sometimes', 'integer', 'min:0'], + 'entries.*.stock_difference' => ['sometimes', 'integer'], 'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } @@ -63,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 1f6872ec..50b557d5 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php @@ -33,8 +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_difference' => ['nullable', 'integer'], + 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.stock_difference' => ['sometimes', 'integer'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], ]; } @@ -51,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 15b05967..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 { @@ -44,9 +45,44 @@ class UpsertMerchandiseRequest extends FormRequest '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_difference' => ['nullable', 'integer'], + '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/Services/AccommodationService.php b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php index 20e44ee6..4e33b50e 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/AccommodationService.php @@ -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(); } @@ -257,8 +257,10 @@ class AccommodationService 'descripcion' => $data['description'], 'precio' => $data['price'], ]); - $stockDifference = (int) ($data['stock_difference'] ?? ($data['stock'] - $inventory->availableStock())); - $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); + $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 5c486029..06ac4e09 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/EntryService.php @@ -141,8 +141,10 @@ class EntryService $catalogItem->itemAttributes() ->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date')) ->update(['allow_multi_select' => true]); - $stockDifference = (int) ($entry['stock_difference'] ?? ((int) $entry['stock'] - $inventory->availableStock())); - $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); + $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 8b9f4c05..1e194eaf 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/FoodService.php @@ -113,7 +113,7 @@ class FoodService } /** - * @param array $variants + * @param array $variants */ public function updateHistoricalStock(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem { @@ -143,8 +143,10 @@ class FoodService } $inventory = $this->inventoryForHistoricalStockUpdate($variant); - $stockDifference = (int) ($data['stock_difference'] ?? ((int) $data['stock'] - $inventory->availableStock())); - $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); + $inventory->adjustAvailableStock( + (int) $data['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); } return $this->current($tenant) ?? $food; @@ -294,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(); } @@ -407,8 +409,10 @@ class FoodService 'precio' => $data['price'], ]); $variant->eventDates()->sync([$data['event_date_id']]); - $stockDifference = (int) ($data['stock_difference'] ?? ($data['stock'] - $inventory->availableStock())); - $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); + $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 735406df..9beaac2d 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/Services/MerchandiseService.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/Services/MerchandiseService.php @@ -245,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(); } @@ -369,8 +369,10 @@ class MerchandiseService ->firstOrFail(); $variant->update(['precio' => $data['price']]); - $stockDifference = (int) ($data['stock_difference'] ?? ($data['stock'] - $inventory->availableStock())); - $inventory->adjustAvailableStock($stockDifference, idempotencyKey: $stockAdjustmentId); + $inventory->adjustAvailableStock( + (int) $data['stock_difference'], + idempotencyKey: $stockAdjustmentId, + ); $this->syncDefinitions($variant, $itemAttributes, $data); } 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; From c5aeb197358c00f24f3d5d4c1975cdb7430770c5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 15:31:43 -0300 Subject: [PATCH 7/7] refactor(inventory): enhance error reporting for negative stock validation --- ...000_generate_available_inventory_stock.php | 34 +++++++++++++++++-- .../InventoryStockMovementMigrationTest.php | 6 +++- 2 files changed, 37 insertions(+), 3 deletions(-) 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 index fe0694f0..533a02bf 100644 --- a/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php +++ b/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php @@ -52,8 +52,38 @@ return new class extends Migration private function assertNonNegative(string $expression, string $reason): void { - if (DB::table('inventories')->whereRaw("({$expression}) < 0")->exists()) { - throw new RuntimeException("Hay inventarios inconsistentes: {$reason}."); + $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/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php index c70c29ee..8946f515 100644 --- a/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php +++ b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php @@ -113,12 +113,16 @@ class InventoryStockMovementMigrationTest extends TestCase 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'); + $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(); }