From ee906d2d2e7d76cccb12b96274d05f45d8c461ca Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 2 Sep 2026 09:47:04 -0300 Subject: [PATCH] Squashed commit of the following: commit 6ae625d4fdc11989950ef0678db62fdb52be7c8c Author: ncoronel Date: Wed Sep 2 09:46:18 2026 -0300 refactor(tickets): enhance search functionality for tickets by client names and formatted amounts commit 098e525d32be9cb6487bc01a3d120aa0f2386a15 Author: ncoronel Date: Wed Sep 2 09:09:30 2026 -0300 fix(tickets): protect referenced purchase items commit 210db17f2f6a6a4a509297b773a2b5d3e3149a3d Author: ncoronel Date: Wed Sep 2 08:59:24 2026 -0300 test(tickets): cover purchase item references commit 6a004713c4bad622dfa5e26394458925db1cb5a7 Author: ncoronel Date: Wed Sep 2 08:59:02 2026 -0300 refactor(tickets): reference purchase items directly --- .../InvitationPurchaseProvisioner.php | 24 ++-- .../Services/NotificationMailService.php | 3 +- app/Domains/Purchase/Models/Purchase.php | 14 +- app/Domains/Purchase/Models/PurchaseItem.php | 8 ++ .../GenerateTicketsForPaidPurchase.php | 2 +- app/Domains/Ticket/Models/Ticket.php | 12 +- .../Services/AdminAppTicketRowService.php | 21 +-- .../Ticket/Services/AdminAppTicketService.php | 97 ++++++++++---- .../Services/TicketGeneratorService.php | 8 +- app/Domains/Ticket/documentacion/README.md | 3 +- ...add_source_purchase_item_id_to_tickets.php | 94 +++++++++++++ ...remove_source_purchase_id_from_tickets.php | 73 +++++++++++ ...eletion_of_purchase_items_with_tickets.php | 38 ++++++ .../NotificationMailServiceTest.php | 4 +- .../Sale/AdminAppSaleControllerTest.php | 15 ++- .../DesfilePuraTendenciaSeederTest.php | 2 +- .../Ticket/AdminAppTicketControllerTest.php | 123 +++++++++++++++++- .../Ticket/TicketGeneratorServiceTest.php | 48 ++++++- 18 files changed, 504 insertions(+), 85 deletions(-) create mode 100644 database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php create mode 100644 database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php create mode 100644 database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php diff --git a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php index e72f7a1..bc2c986 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -5,6 +5,7 @@ namespace App\Domains\Desfile\Services; use DateTimeInterface; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use RuntimeException; @@ -58,7 +59,7 @@ class InvitationPurchaseProvisioner $allocation['type'], ); - $this->createPurchaseItem( + $purchaseItemId = $this->createPurchaseItem( $purchaseId, $catalogItem, $variant, @@ -70,6 +71,7 @@ class InvitationPurchaseProvisioner ); $this->createTicketAndCommitStock( $purchaseId, + $purchaseItemId, $userId, (int) $catalogItem->id, $variant, @@ -326,12 +328,13 @@ class InvitationPurchaseProvisioner int $seat, string $type, DateTimeInterface $now, - ): void { - if (DB::table('compra_items') + ): int { + $existingId = DB::table('compra_items') ->where('compra_id', $purchaseId) ->where('source_variant_id', $variant->id) - ->exists()) { - return; + ->value('id'); + if ($existingId !== null) { + return (int) $existingId; } $attributes = [ @@ -341,7 +344,7 @@ class InvitationPurchaseProvisioner ['name' => 'Asiento', 'value' => (string) $seat], ]; - DB::table('compra_items')->insert([ + return DB::table('compra_items')->insertGetId([ 'compra_id' => $purchaseId, 'source_catalog_item_id' => $catalogItem->id, 'source_variant_id' => $variant->id, @@ -367,13 +370,18 @@ class InvitationPurchaseProvisioner private function createTicketAndCommitStock( int $purchaseId, + int $purchaseItemId, int $userId, int $catalogItemId, object $variant, DateTimeInterface $now, ): void { + $purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id') + ? ['source_purchase_item_id' => $purchaseItemId] + : ['source_purchase_id' => $purchaseId]; + if (DB::table('tickets') - ->where('source_purchase_id', $purchaseId) + ->where($purchaseReference) ->where('source_variant_id', $variant->id) ->exists()) { return; @@ -420,7 +428,7 @@ class InvitationPurchaseProvisioner 'ticket' => (string) Str::uuid(), 'name' => null, 'description' => null, - 'source_purchase_id' => $purchaseId, + ...$purchaseReference, 'source_catalog_item_id' => $catalogItemId, 'source_variant_id' => $variant->id, 'used_at' => null, diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 6b5382a..9cb54ad 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -138,8 +138,7 @@ class NotificationMailService } /** @var Collection $tickets */ - $tickets = Ticket::query() - ->where('source_purchase_id', $purchase->getKey()) + $tickets = $purchase->tickets() ->where('tenant_code', $purchase->tenant_codigo) ->with(TicketPresentationResolver::RELATIONS) ->get(); diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index b177040..e3af89f 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\DB; @@ -115,12 +116,15 @@ class Purchase extends Model return $this->hasMany(PurchaseItem::class, 'compra_id'); } - /** - * @return HasMany - */ - public function tickets(): HasMany + /** @return HasManyThrough */ + public function tickets(): HasManyThrough { - return $this->hasMany(Ticket::class, 'source_purchase_id'); + return $this->hasManyThrough( + Ticket::class, + PurchaseItem::class, + 'compra_id', + 'source_purchase_item_id', + ); } /** @return BelongsTo */ diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 484ef07..f15bf6b 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -5,10 +5,12 @@ namespace App\Domains\Purchase\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'compra_id', @@ -56,6 +58,12 @@ class PurchaseItem extends Model return $this->belongsTo(Purchase::class, 'compra_id'); } + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'source_purchase_item_id'); + } + /** @return BelongsTo */ public function imageAttachment(): BelongsTo { diff --git a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php index 3c7d823..dfeb404 100644 --- a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php +++ b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php @@ -42,7 +42,7 @@ class GenerateTicketsForPaidPurchase $user, $purchaseItem->cantidad, $purchaseItem->source_variant_id, - $purchase->getKey(), + $purchaseItem->getKey(), ); } } diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 9fe6027..8efc786 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -5,7 +5,7 @@ namespace App\Domains\Ticket\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticket\Services\ResolvedValidityGroup; @@ -21,7 +21,7 @@ use Illuminate\Support\Collection; #[Fillable([ 'tenant_code', 'ticket', - 'source_purchase_id', + 'source_purchase_item_id', 'source_catalog_item_id', 'source_variant_id', 'used_at', @@ -56,7 +56,7 @@ class Ticket extends Model return [ 'source_catalog_item_id' => 'integer', 'source_variant_id' => 'integer', - 'source_purchase_id' => 'integer', + 'source_purchase_item_id' => 'integer', 'used_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', @@ -81,10 +81,10 @@ class Ticket extends Model return $this->belongsTo(User::class, 'scanner_user_id'); } - /** @return BelongsTo */ - public function sourcePurchase(): BelongsTo + /** @return BelongsTo */ + public function sourcePurchaseItem(): BelongsTo { - return $this->belongsTo(Purchase::class, 'source_purchase_id'); + return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id'); } /** @return BelongsTo */ diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index ea831e3..e29b879 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -3,7 +3,6 @@ namespace App\Domains\Ticket\Services; use App\Domains\Catalog\Models\ItemAttribute; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Collection; @@ -23,16 +22,16 @@ class AdminAppTicketRowService /** @return array */ public function details(Ticket $ticket): array { - $purchaseItem = $this->sourcePurchaseItem($ticket); + $purchaseItem = $ticket->sourcePurchaseItem; return [ - 'source_purchase_id' => $ticket->source_purchase_id, - 'order_number' => $ticket->source_purchase_id, + 'source_purchase_item_id' => $ticket->source_purchase_item_id, + 'order_number' => $purchaseItem?->compra_id, 'product' => $purchaseItem?->item_nombre ?? $ticket->sourceCatalogItem?->nombre ?? $ticket->name, 'amount' => $purchaseItem?->precio_unitario, - 'client' => $ticket->sourcePurchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, + 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, 'status' => $ticket->status, 'scanned_by' => $ticket->scannerUser?->nombre_apellido, 'variant_properties' => $this->variantProperties($ticket), @@ -175,18 +174,6 @@ class AdminAppTicketRowService ->implode(', '); } - private function sourcePurchaseItem(Ticket $ticket): ?PurchaseItem - { - return $ticket->sourcePurchase?->items->first(function (PurchaseItem $item) use ($ticket): bool { - if ($ticket->source_variant_id !== null) { - return $item->source_variant_id === $ticket->source_variant_id; - } - - return $item->source_catalog_item_id === $ticket->source_catalog_item_id - && $item->source_variant_id === null; - }); - } - /** @return list}> */ private function variantProperties(Ticket $ticket): array { diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 021c063..2ea0d2d 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -18,7 +18,7 @@ class AdminAppTicketService 'user', 'scannerUser', 'sourceCatalogItem.category', - 'sourcePurchase.items', + 'sourcePurchaseItem.purchase', ]; public function __construct( @@ -86,13 +86,7 @@ class AdminAppTicketService $query = Ticket::query() ->where('tenant_code', $tenant->codigo) ->when($search !== '', function (Builder $query) use ($search): void { - $query->when( - ctype_digit($search), - fn (Builder $searchQuery): Builder => $searchQuery - ->where('tickets.id', (int) $search), - fn (Builder $searchQuery): Builder => $searchQuery - ->where('ticket', 'like', "%{$search}%"), - ); + $this->applySearchFilter($query, $search); }) ->when($filters['category'] ?? null, function (Builder $query, string $category): void { $query->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery @@ -111,7 +105,7 @@ class AdminAppTicketService return; } - $query->whereHas('sourcePurchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + $query->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery ->whereDate('created_at', $date)); }) ->when($filters['size'] ?? null, function (Builder $query, string $size) use ($filters): void { @@ -123,6 +117,67 @@ class AdminAppTicketService return $query; } + /** @param Builder $query */ + private function applySearchFilter(Builder $query, string $search): void + { + $containsPattern = '%'.mb_strtolower($search).'%'; + $amount = $this->searchAmount($search); + + $query->where(function (Builder $searchQuery) use ($search, $containsPattern, $amount): void { + $searchQuery + ->where(function (Builder $clientQuery) use ($containsPattern): void { + $clientQuery + ->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])) + ->orWhere(function (Builder $fallbackClientQuery) use ($containsPattern): void { + $fallbackClientQuery + ->where(function (Builder $missingPurchaseClientQuery): void { + $missingPurchaseClientQuery + ->whereDoesntHave('sourcePurchaseItem.purchase') + ->orWhereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + ->whereNull('nombre_apellido')); + }) + ->whereHas('user', fn (Builder $userQuery): Builder => $userQuery + ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])); + }); + }) + ->orWhereHas('scannerUser', fn (Builder $scannerQuery): Builder => $scannerQuery + ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])); + + if (ctype_digit($search)) { + $searchQuery + ->orWhere('tickets.id', (int) $search) + ->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery + ->where('compra_id', (int) $search)); + } + + if ($amount !== null) { + $searchQuery->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery + ->where('precio_unitario', $amount)); + } + }); + } + + private function searchAmount(string $search): ?string + { + $value = preg_replace('/[\s$]/u', '', trim($search)); + if ($value === null || $value === '') { + return null; + } + + if (preg_match('/^\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/', $value) === 1) { + $value = str_replace(['.', ','], ['', '.'], $value); + } elseif (preg_match('/^\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/', $value) === 1) { + $value = str_replace(',', '', $value); + } elseif (preg_match('/^\d+(?:[.,]\d{1,2})?$/', $value) === 1) { + $value = str_replace(',', '.', $value); + } else { + return null; + } + + return number_format((float) $value, 2, '.', ''); + } + /** @param Builder $query */ private function applyProductFilter(Builder $query, string $category, string $product): void { @@ -234,15 +289,15 @@ class AdminAppTicketService $direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; $sortExpression = match ($sortBy) { - 'order_number' => 'tickets.source_purchase_id', + 'order_number' => $this->purchaseItemColumnQuery('compra_id'), 'id' => 'tickets.id', - 'amount' => $this->purchaseItemSortQuery('precio_unitario'), + 'amount' => $this->purchaseItemColumnQuery('precio_unitario'), 'scanned_by' => User::query() ->select('nombre_apellido') ->whereColumn('users.id', 'tickets.scanner_user_id'), 'product' => $tenant->codigo === 'fiesta_futbol_infantil' ? null - : $this->purchaseItemSortQuery('item_nombre'), + : $this->purchaseItemColumnQuery('item_nombre'), default => null, }; @@ -256,25 +311,11 @@ class AdminAppTicketService } /** @return Builder */ - private function purchaseItemSortQuery(string $column): Builder + private function purchaseItemColumnQuery(string $column): Builder { return PurchaseItem::query() ->select($column) - ->whereColumn('compra_items.compra_id', 'tickets.source_purchase_id') - ->where(function (Builder $query): void { - $query - ->where(function (Builder $variantQuery): void { - $variantQuery - ->whereNotNull('tickets.source_variant_id') - ->whereColumn('compra_items.source_variant_id', 'tickets.source_variant_id'); - }) - ->orWhere(function (Builder $itemQuery): void { - $itemQuery - ->whereNull('tickets.source_variant_id') - ->whereNull('compra_items.source_variant_id') - ->whereColumn('compra_items.source_catalog_item_id', 'tickets.source_catalog_item_id'); - }); - }) + ->whereColumn('compra_items.id', 'tickets.source_purchase_item_id') ->limit(1); } diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index bda45ac..a3d6c7e 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -23,13 +23,13 @@ class TicketGeneratorService User $user, int $quantity = 1, ?int $sourceVariantId = null, - ?int $sourcePurchaseId = null, + ?int $sourcePurchaseItemId = null, ): Collection { if ($quantity < 1) { throw TicketGenerationException::invalidQuantity(); } - return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection { + return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseItemId): Collection { $targets = $this->resolveTargets( $catalogItem, $quantity, @@ -37,7 +37,7 @@ class TicketGeneratorService ); return $targets->map(function (array $target) use ( - $sourcePurchaseId, + $sourcePurchaseItemId, $user, ): Ticket { $item = $target['catalog_item']; @@ -45,7 +45,7 @@ class TicketGeneratorService $ticket = Ticket::query()->create([ 'tenant_code' => $item->tenant_code, 'ticket' => (string) Str::uuid(), - 'source_purchase_id' => $sourcePurchaseId, + 'source_purchase_item_id' => $sourcePurchaseItemId, 'source_catalog_item_id' => $item->getKey(), 'source_variant_id' => $variant?->getKey(), 'used_at' => null, diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index c43a9bd..21d06ae 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -6,7 +6,8 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ ## Modelo -- `Ticket`: pertenece a tenant y usuario, y conserva referencias a compra, producto, variante y usuario escáner. +- `Ticket`: pertenece a tenant y usuario, y conserva referencias al ítem de compra que lo generó, producto, + variante y usuario escáner. La compra se obtiene a través de su ítem. - El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no persisten una copia de esos textos. - `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos. diff --git a/database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php b/database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php new file mode 100644 index 0000000..444cec2 --- /dev/null +++ b/database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php @@ -0,0 +1,94 @@ +foreignId('source_purchase_item_id') + ->nullable() + ->after('source_purchase_id') + ->constrained('compra_items') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + + DB::table('tickets') + ->whereNotNull('source_purchase_id') + ->whereNull('source_purchase_item_id') + ->orderBy('id') + ->chunkById(500, function (Collection $tickets): void { + $purchaseIds = $tickets->pluck('source_purchase_id')->unique()->values(); + $itemsByPurchase = DB::table('compra_items') + ->whereIn('compra_id', $purchaseIds) + ->get(['id', 'compra_id', 'source_catalog_item_id', 'source_variant_id']) + ->groupBy('compra_id'); + $bundleIds = $itemsByPurchase->flatten(1) + ->pluck('source_catalog_item_id') + ->filter() + ->unique() + ->values(); + $componentsByBundle = DB::table('bundle_components') + ->whereIn('bundle_catalog_item_id', $bundleIds) + ->get([ + 'bundle_catalog_item_id', + 'component_catalog_item_id', + 'component_variant_id', + ]) + ->groupBy('bundle_catalog_item_id'); + + foreach ($tickets as $ticket) { + $candidates = collect($itemsByPurchase->get($ticket->source_purchase_id, [])) + ->filter(function (object $item) use ($ticket, $componentsByBundle): bool { + if ($this->sameCatalogTarget($item, $ticket)) { + return true; + } + + return collect($componentsByBundle->get($item->source_catalog_item_id, [])) + ->contains(fn (object $component): bool => $this->sameBundleTarget($component, $ticket)); + }) + ->pluck('id') + ->unique() + ->values(); + + if ($candidates->count() === 1) { + DB::table('tickets')->where('id', $ticket->id)->update([ + 'source_purchase_item_id' => $candidates->sole(), + ]); + } + } + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('source_purchase_item_id'); + }); + } + + private function sameCatalogTarget(object $item, object $ticket): bool + { + return (int) $item->source_catalog_item_id === (int) $ticket->source_catalog_item_id + && $this->sameNullableId($item->source_variant_id, $ticket->source_variant_id); + } + + private function sameBundleTarget(object $component, object $ticket): bool + { + return (int) $component->component_catalog_item_id === (int) $ticket->source_catalog_item_id + && $this->sameNullableId($component->component_variant_id, $ticket->source_variant_id); + } + + private function sameNullableId(mixed $left, mixed $right): bool + { + return $left === null || $right === null + ? $left === null && $right === null + : (int) $left === (int) $right; + } +}; diff --git a/database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php b/database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php new file mode 100644 index 0000000..0c6ec71 --- /dev/null +++ b/database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php @@ -0,0 +1,73 @@ +whereNotNull('source_purchase_id') + ->whereNull('source_purchase_item_id') + ->orderBy('id') + ->limit(20) + ->pluck('id'); + + if ($unresolved->isNotEmpty()) { + throw new RuntimeException( + 'No se puede eliminar tickets.source_purchase_id: hay tickets sin un compra_item inequívoco. ' + .'IDs: '.$unresolved->implode(', ') + ); + } + + $inconsistent = DB::table('tickets') + ->join('compra_items', 'compra_items.id', '=', 'tickets.source_purchase_item_id') + ->whereNotNull('tickets.source_purchase_id') + ->whereColumn('tickets.source_purchase_id', '!=', 'compra_items.compra_id') + ->orderBy('tickets.id') + ->limit(20) + ->pluck('tickets.id'); + + if ($inconsistent->isNotEmpty()) { + throw new RuntimeException( + 'No se puede eliminar tickets.source_purchase_id: hay referencias de compra inconsistentes. ' + .'IDs: '.$inconsistent->implode(', ') + ); + } + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('source_purchase_id'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->foreignId('source_purchase_id') + ->nullable() + ->after('description') + ->constrained('compras') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + + DB::table('tickets') + ->whereNotNull('source_purchase_item_id') + ->orderBy('id') + ->chunkById(500, function (Collection $tickets): void { + $purchaseIdsByItem = DB::table('compra_items') + ->whereIn('id', $tickets->pluck('source_purchase_item_id')) + ->pluck('compra_id', 'id'); + + foreach ($tickets as $ticket) { + DB::table('tickets')->where('id', $ticket->id)->update([ + 'source_purchase_id' => $purchaseIdsByItem->get($ticket->source_purchase_item_id), + ]); + } + }); + } +}; diff --git a/database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php b/database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php new file mode 100644 index 0000000..cf636d3 --- /dev/null +++ b/database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php @@ -0,0 +1,38 @@ +dropForeign(['source_purchase_item_id']); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->foreign('source_purchase_item_id') + ->references('id') + ->on('compra_items') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropForeign(['source_purchase_item_id']); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->foreign('source_purchase_item_id') + ->references('id') + ->on('compra_items') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } +}; diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index bba638e..26dbc29 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -161,7 +161,7 @@ class NotificationMailServiceTest extends TestCase 'precio' => 25, 'has_tickets' => true, ]); - $purchase->items()->create([ + $purchaseItem = $purchase->items()->create([ 'source_catalog_item_id' => $catalogItem->id, 'nombre' => 'Entrada', 'descripcion' => 'Entrada general', @@ -175,7 +175,7 @@ class NotificationMailServiceTest extends TestCase $ticket = Ticket::query()->create([ 'tenant_code' => $this->tenant->codigo, 'ticket' => fake()->uuid(), - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $this->user->id, ]); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 52d4ac1..76eaad9 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -336,17 +336,28 @@ class AdminAppSaleControllerTest extends TestCase 'precio' => 20000, 'has_tickets' => true, ]); + $purchaseItem = $purchase->items()->create([ + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, + 'descripcion' => $catalogItem->descripcion, + 'slug' => $catalogItem->slug, + 'item_nombre' => $catalogItem->nombre, + 'variant_attributes' => [], + 'cantidad' => 2, + 'precio_unitario' => 10000, + 'total' => 20000, + ]); $firstTicket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => '11111111-1111-4111-8111-111111111111', - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $admin->id, ]); $usedTicket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => '22222222-2222-4222-8222-222222222222', - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $catalogItem->id, 'used_at' => now()->subMinute(), 'user_id' => $admin->id, diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index cc49e3b..df0e9b4 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -297,7 +297,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase ->where('total', 0) ->count()); $this->assertSame(49, DB::table('tickets') - ->where('source_purchase_id', $purchase->id) + ->whereIn('source_purchase_item_id', (clone $items)->pluck('id')) ->where('user_id', $user->id) ->where('source_catalog_item_id', $catalogItemId) ->count()); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 5886a2f..0ac1a67 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -79,7 +79,7 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('meta.total', 1); } - public function test_it_supports_id_and_uuid_search(): void + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); @@ -95,9 +95,98 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.id', $matching->id); $this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8)) + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + public function test_it_searches_by_visible_client_and_scanner_names(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $client = User::factory()->create([ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'María Cliente', + ]); + $scanner = $this->createAdminAppUser($tenant); + $scanner->update(['nombre_apellido' => 'Carlos Inspector']); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $clientTicket = $this->createTicket($tenant, $client); + $scannerTicket = $this->createTicket($tenant, $admin, [ + 'scanner_user_id' => $scanner->id, + 'used_at' => now(), + ]); + $this->createTicket($tenant, $admin); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'maría'])) ->assertOk() ->assertJsonCount(1, 'data') - ->assertJsonPath('data.0.ticket', $matching->ticket); + ->assertJsonPath('data.0.id', $clientTicket->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'INSPECTOR'])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $scannerTicket->id); + } + + public function test_it_searches_by_order_number_and_formatted_unit_amount(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + foreach (range(1, 10) as $number) { + $this->createPurchase($tenant, $admin, now()->subMinutes($number)->toDateTimeString()); + } + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'entrada-busqueda', + 'nombre' => 'Entrada búsqueda', + 'precio' => '1234.50', + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => 'Cliente de la compra', + 'total' => '1234.50', + ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $item->id, + 'nombre' => $item->nombre, + 'descripcion' => '', + 'slug' => $item->slug, + 'item_nombre' => $item->nombre, + 'cantidad' => 1, + 'precio_unitario' => '1234.50', + 'total' => '1234.50', + ]); + $matching = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $item->id, + ]); + $this->createTicket($tenant, $admin); + + $this->getJson("/api/v1/adminapp/tenant/tickets?q={$purchase->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'cliente de la compra'])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + foreach (['1234,50', '$1.234,50', '1,234.50'] as $amount) { + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => $amount])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + } } public function test_it_sorts_by_ticket_id_before_paginating(): void @@ -219,7 +308,7 @@ class AdminAppTicketControllerTest extends TestCase 'nombre_apellido' => 'Nombre Apellido', 'total' => '8000.00', ]); - PurchaseItem::query()->create([ + $purchaseItem = PurchaseItem::query()->create([ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, @@ -232,7 +321,7 @@ class AdminAppTicketControllerTest extends TestCase 'total' => '8000.00', ]); $ticket = $this->createTicket($tenant, $admin, [ - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, 'scanner_user_id' => $admin->id, @@ -242,6 +331,7 @@ class AdminAppTicketControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.source_purchase_item_id', $purchaseItem->id) ->assertJsonPath('data.0.order_number', $purchase->id) ->assertJsonPath('data.0.product', 'Remera') ->assertJsonPath('data.0.amount', '8000.00') @@ -289,20 +379,23 @@ class AdminAppTicketControllerTest extends TestCase $matchingPurchase = $this->createPurchase($tenant, $admin, '2026-08-20 10:00:00'); $otherPurchase = $this->createPurchase($tenant, $admin, '2026-08-21 10:00:00'); + $dinnerItem = $this->createPurchaseItem($matchingPurchase, $food, $dinner); + $lunchItem = $this->createPurchaseItem($matchingPurchase, $food, $lunch); + $otherDinnerItem = $this->createPurchaseItem($otherPurchase, $food, $otherDinner); $matching = $this->createTicket($tenant, $admin, [ - 'source_purchase_id' => $matchingPurchase->id, + 'source_purchase_item_id' => $dinnerItem->id, 'source_catalog_item_id' => $food->id, 'source_variant_id' => $dinner->id, 'used_at' => now(), ]); $this->createTicket($tenant, $admin, [ - 'source_purchase_id' => $matchingPurchase->id, + 'source_purchase_item_id' => $lunchItem->id, 'source_catalog_item_id' => $food->id, 'source_variant_id' => $lunch->id, 'used_at' => now(), ]); $this->createTicket($tenant, $admin, [ - 'source_purchase_id' => $otherPurchase->id, + 'source_purchase_item_id' => $otherDinnerItem->id, 'source_catalog_item_id' => $food->id, 'source_variant_id' => $otherDinner->id, 'used_at' => now(), @@ -494,6 +587,22 @@ class AdminAppTicketControllerTest extends TestCase return $purchase; } + private function createPurchaseItem(Purchase $purchase, CatalogItem $item, Variant $variant): PurchaseItem + { + return $purchase->items()->create([ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'nombre' => $item->nombre, + 'descripcion' => $item->descripcion, + 'slug' => $item->slug, + 'item_nombre' => $item->nombre, + 'variant_attributes' => [], + 'cantidad' => 1, + 'precio_unitario' => 0, + 'total' => 0, + ]); + } + private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index e9eda8a..b1415cc 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -19,6 +19,7 @@ use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Services\TicketGeneratorService; use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; @@ -246,14 +247,59 @@ class TicketGeneratorServiceTest extends TestCase $purchase = $this->createPurchase($item, 1, $variant->id); $purchase->markAsPaid(); + $purchaseItem = $purchase->items()->sole(); $this->assertDatabaseHas('tickets', [ - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, ]); } + public function test_bundle_tickets_reference_the_purchase_item_that_generated_them(): void + { + $component = $this->createTicketableItem('bundle-component'); + $bundle = $this->createBundle('purchased-bundle'); + $bundle->bundleComponents()->create([ + 'component_catalog_item_id' => $component->id, + 'quantity' => 2, + ]); + $purchase = $this->createPurchase($bundle, 2); + $purchaseItem = $purchase->items()->sole(); + + $purchase->markAsPaid(); + + $this->assertCount(4, $purchaseItem->tickets); + $this->assertTrue($purchaseItem->tickets->every( + fn ($ticket): bool => $ticket->source_purchase_item_id === $purchaseItem->id + && $ticket->source_catalog_item_id === $component->id + )); + $this->assertCount(4, $purchase->tickets); + } + + public function test_a_purchase_item_that_generated_tickets_cannot_be_deleted(): void + { + $item = $this->createTicketableItem('protected-purchase-item'); + $purchase = $this->createPurchase($item, 1); + $purchase->markAsPaid(); + $purchaseItem = $purchase->items()->sole(); + + $this->expectException(QueryException::class); + + $purchaseItem->delete(); + } + + public function test_a_purchase_with_generated_tickets_cannot_be_deleted(): void + { + $item = $this->createTicketableItem('protected-purchase'); + $purchase = $this->createPurchase($item, 1); + $purchase->markAsPaid(); + + $this->expectException(QueryException::class); + + $purchase->delete(); + } + public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void { $item = $this->createTicketableItem('scheduled-meal');