diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 32b9daa..d8c0627 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -8,9 +8,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventorySubject; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Models\Ticket; -use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,6 +16,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; #[Fillable([ @@ -34,12 +33,10 @@ use Illuminate\Support\Collection; 'inventory_subject', 'max_units_per_user', 'has_tickets', - 'ticket_generation_policy', - 'validity_time_id', ])] class CatalogItem extends Model { - use HasFactory; + use HasFactory, SoftDeletes; public $timestamps = false; @@ -50,7 +47,6 @@ class CatalogItem extends Model 'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_subject' => InventorySubject::Product->value, 'has_tickets' => false, - 'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value, ]; protected function casts(): array @@ -65,8 +61,6 @@ class CatalogItem extends Model 'inventory_subject' => InventorySubject::class, 'max_units_per_user' => 'integer', 'has_tickets' => 'boolean', - 'ticket_generation_policy' => TicketGenerationPolicy::class, - 'validity_time_id' => 'integer', ]; } @@ -118,12 +112,6 @@ class CatalogItem extends Model return $this->hasMany(Ticket::class, 'source_catalog_item_id'); } - /** @return BelongsTo */ - public function validityTime(): BelongsTo - { - return $this->belongsTo(ValidityTime::class); - } - /** @return BelongsToMany */ public function attributes(): BelongsToMany { diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index a66d5bc..49990da 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Lang; use Illuminate\Support\Str; @@ -24,7 +25,7 @@ use Illuminate\Support\Str; ])] class Variant extends Model { - use HasFactory; + use HasFactory, SoftDeletes; public $timestamps = false; @@ -43,7 +44,7 @@ class Variant extends Model /** @return BelongsTo */ public function catalogItem(): BelongsTo { - return $this->belongsTo(CatalogItem::class); + return $this->belongsTo(CatalogItem::class)->withTrashed(); } /** @return BelongsTo */ diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index bb5f8c2..9a39f5c 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -6,7 +6,6 @@ use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventorySubject; use App\Domains\Shared\Rules\ImageOrBase64Rule; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -58,8 +57,6 @@ class StoreCatalogItemRequest extends FormRequest 'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)], 'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], - 'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)], - 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], 'inventory_id' => ['prohibited'], 'reserved_stock' => ['prohibited'], diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index d9d06d6..9231357 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -7,7 +7,6 @@ use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\Variant; -use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -31,9 +30,6 @@ class CatalogFeaturedItemResource extends JsonResource 'nombre' => $catalogItem->nombre, 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, - 'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value, - 'validity_time_id' => $catalogItem->validity_time_id, - 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'stock_tecnico' => $catalogItem->availableStock(), 'variants' => $catalogItem->visibleVariants() ->map(fn (Variant $variant): array => [ @@ -67,9 +63,6 @@ class CatalogFeaturedItemResource extends JsonResource 'type' => $catalogItem->type->value, 'nombre' => $catalogItem->nombre, 'precio' => $catalogItem->precio, - 'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value, - 'validity_time_id' => $catalogItem->validity_time_id, - 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'image' => $this->firstImageUrl($catalogItem), ]; } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 8e7f17a..6f74c8a 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -36,11 +36,6 @@ class CatalogItemDetailResource extends JsonResource 'inventory_subject' => $this->inventory_subject->value, 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, - 'ticket_generation_policy' => $this->ticket_generation_policy->value, - 'validity_time_id' => $this->validity_time_id, - 'validity_time' => $this->validityTime === null - ? null - : ValidityTimeResource::make($this->validityTime), 'attributes' => $this->attributesData(), 'stock_tecnico' => $this->when( $selectedVariant === null, diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index fa21c54..0012c76 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -3,7 +3,6 @@ namespace App\Domains\Catalog\Resources; use App\Domains\Catalog\Models\CatalogItem; -use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -26,12 +25,6 @@ class CatalogItemResource extends JsonResource 'inventory_subject' => $this->inventory_subject->value, 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, - 'ticket_generation_policy' => $this->ticket_generation_policy->value, - 'validity_time_id' => $this->validity_time_id, - 'validity_time' => $this->whenLoaded( - 'validityTime', - fn () => ValidityTimeResource::make($this->validityTime), - ), 'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock), 'images' => $this->whenLoaded('attachments', fn () => $this->attachments ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 1b6e39c..4c6cd72 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -5,7 +5,6 @@ namespace App\Domains\Catalog\Resources; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -26,9 +25,6 @@ class CatalogSearchItemResource extends JsonResource 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, - 'ticket_generation_policy' => $this->ticket_generation_policy->value, - 'validity_time_id' => $this->validity_time_id, - 'validity_time' => ValidityTimeResource::make($this->validityTime), 'image' => $attachment?->getTemporaryUrl(1440), 'stock_tecnico' => $this->availableStock(), 'variants' => $this->visibleVariants() diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index d8afe2f..7c30cbe 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -160,7 +160,6 @@ class CatalogService 'inventory', 'category', 'brand', - 'validityTime', 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', @@ -180,7 +179,6 @@ class CatalogService 'inventory', 'category', 'brand', - 'validityTime', 'itemAttributes.attribute.options.validityTime', 'itemAttributes.attribute.eventDates.validityTime', 'variants' => fn ($query) => $query->orderBy('id'), @@ -241,7 +239,6 @@ class CatalogService ->with([ 'attachments', 'inventory', - 'validityTime', 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', @@ -277,7 +274,6 @@ class CatalogService ->with([ 'attachments', 'inventory', - 'validityTime', 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', @@ -294,32 +290,8 @@ class CatalogService public function delete(CatalogItem $catalogItem): void { DB::transaction(function () use ($catalogItem): void { - $catalogItem->load([ - 'attachments', - 'variants.attachments', - ]); - - $attachments = $catalogItem->attachments - ->merge($catalogItem->variants->flatMap->attachments) - ->unique('id'); - $inventoryIds = collect([$catalogItem->inventory_id]) - ->merge($catalogItem->variants->pluck('inventory_id')) - ->filter() - ->unique(); - - $catalogItem->attachments()->detach(); - foreach ($catalogItem->variants as $variant) { - $variant->attachments()->detach(); - } - + $catalogItem->variants()->delete(); $catalogItem->delete(); - Inventory::query()->whereKey($inventoryIds)->delete(); - - foreach ($attachments as $attachment) { - if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) { - $this->attachmentService->delete($attachment); - } - } }); } @@ -327,32 +299,19 @@ class CatalogService { DB::transaction(function () use ($variant): void { $variant = Variant::query() - ->with('attachments') ->lockForUpdate() ->findOrFail($variant->getKey()); $catalogItem = CatalogItem::query() ->lockForUpdate() ->findOrFail($variant->catalog_item_id); - $attachments = $variant->attachments; - $inventoryId = $variant->inventory_id; - - $variant->attachments()->detach(); $variant->delete(); - Inventory::query()->whereKey($inventoryId)->delete(); - $minimumPrice = $catalogItem->variants()->min('precio'); - - if ($minimumPrice === null) { + if (! $catalogItem->variants()->exists()) { $this->delete($catalogItem); - } else { + } elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) { $catalogItem->update(['precio' => $minimumPrice]); } - foreach ($attachments as $attachment) { - if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) { - $this->attachmentService->delete($attachment); - } - } }); } @@ -457,8 +416,6 @@ class CatalogService 'attribute_codes', 'variants', 'has_tickets', - 'ticket_generation_policy', - 'validity_time_id', ] as $field) { if (array_key_exists($field, $data)) { throw ValidationException::withMessages([ @@ -731,16 +688,6 @@ class CatalogService ]); } - $validityTimeIds = $resolvedOptions - ->pluck('validity_time_id') - ->filter() - ->unique(); - if ($validityTimeIds->count() > 1) { - throw ValidationException::withMessages([ - $validationKey => [__('api.catalog.incompatible_validity_windows')], - ]); - } - return $resolvedOptions->pluck('value')->all(); } diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 00b21e4..970e063 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -47,7 +47,6 @@ class FeaturedGroupService ->with([ 'inventory', 'attachments', - 'validityTime', 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index 665bacd..ef7838b 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -43,7 +43,6 @@ class EventDate extends Model $eventDate->syncTenantDateText(); ValidityTime::query() ->whereKey($eventDate->validity_time_id) - ->whereDoesntHave('ticketValidityGroups') ->delete(); }); } diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 308814e..3425eae 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -4,6 +4,7 @@ namespace App\Domains\Event\Services; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class EventService { @@ -60,7 +61,19 @@ class EventService } } - $existingDates->slice(count($dates))->each->delete(); + $datesToDelete = $existingDates->slice(count($dates)); + + if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate + ->selectedByVariants() + ->whereHas('sourceTickets') + ->exists() + || $eventDate->variants()->whereHas('sourceTickets')->exists())) { + throw ValidationException::withMessages([ + 'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'], + ]); + } + + $datesToDelete->each->delete(); $tenant->unsetRelation('eventDates'); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php b/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php index 7d68d98..4acae3f 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php @@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -126,18 +125,21 @@ class AccommodationService 'tenant_code' => $tenant->codigo, 'nombre' => 'Alojamientos', ]); - $accommodation = CatalogItem::query() + $accommodation = CatalogItem::withTrashed() ->where('tenant_code', $tenant->codigo) ->where('slug', 'alojamiento') ->lockForUpdate() ->first(); if ($accommodation !== null) { + if ($accommodation->trashed()) { + $accommodation->restore(); + } + $accommodation->update([ 'category_id' => $category->id, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); return $accommodation; @@ -152,7 +154,6 @@ class AccommodationService 'precio' => collect($variants)->min('price') ?? 0, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_id' => null, ]); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index e1d4af1..0c44363 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -9,7 +9,6 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -63,7 +62,6 @@ class EntryService 'category_id' => $category->id, 'precio' => $entry['price'], 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'attribute_codes' => ['event_date'], 'multi_select_attribute_codes' => ['event_date'], @@ -135,7 +133,6 @@ class EntryService 'category_id' => $category->id, 'precio' => $entry['price'], 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_policy' => InventoryPolicy::Tracked->value, ]); $variant->update([ @@ -163,7 +160,7 @@ class EntryService while ( in_array($slug, $reservedSlugs, true) - || CatalogItem::query() + || CatalogItem::withTrashed() ->where('tenant_code', $tenant->codigo) ->where('slug', $slug) ->exists() diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 09430ef..7551b69 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -137,18 +136,21 @@ class FoodService 'tenant_code' => $tenant->codigo, 'nombre' => 'Comidas', ]); - $food = CatalogItem::query() + $food = CatalogItem::withTrashed() ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida') ->lockForUpdate() ->first(); if ($food !== null) { + if ($food->trashed()) { + $food->restore(); + } + $food->update([ 'category_id' => $category->id, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); return $food; @@ -163,7 +165,6 @@ class FoodService 'precio' => collect($variants)->min('price') ?? 0, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_id' => null, ]); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php b/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php index bbbb152..29111c8 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php @@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -75,7 +74,6 @@ class MerchandiseService 'max_units_per_user' => (int) $data['max_units_per_user'], 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); $itemAttributes = $this->itemAttributes($item, $attributes); @@ -200,7 +198,6 @@ class MerchandiseService 'max_units_per_user' => (int) $data['max_units_per_user'], 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_id' => null, ]); } @@ -414,7 +411,7 @@ class MerchandiseService while ( in_array($slug, $reservedSlugs, true) - || CatalogItem::query() + || CatalogItem::withTrashed() ->where('tenant_code', $tenant->codigo) ->where('slug', $slug) ->exists() diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 4bb364f..4217366 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -9,6 +9,7 @@ use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Services\TicketPresentationResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -104,6 +105,7 @@ class NotificationMailService ->where('tenant_code', $purchase->tenant_codigo) ->where('user_id', $purchase->user_id) ->whereKey($ticketIds) + ->with(TicketPresentationResolver::RELATIONS) ->get(); if ($tickets->isEmpty()) { diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index d52d83a..0a99f9b 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -7,6 +7,8 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Services\TicketPresentationResolver; +use App\Domains\Ticket\Services\TicketValidityResolver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -58,7 +60,7 @@ class AdminAppSaleService { return $this->findForTenant($tenant, $saleId) ->tickets() - ->with('validityGroups.validityTimes') + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) ->orderBy('id') ->get(); } diff --git a/app/Domains/Ticket/Controllers/TicketController.php b/app/Domains/Ticket/Controllers/TicketController.php index 85b7456..508a924 100644 --- a/app/Domains/Ticket/Controllers/TicketController.php +++ b/app/Domains/Ticket/Controllers/TicketController.php @@ -8,6 +8,8 @@ use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest; use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Services\TicketPdfService; +use App\Domains\Ticket\Services\TicketPresentationResolver; +use App\Domains\Ticket\Services\TicketValidityResolver; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -22,7 +24,7 @@ class TicketController extends Controller $tickets = Ticket::query() ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) - ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) ->orderByDesc('id') ->get(); @@ -36,7 +38,7 @@ class TicketController extends Controller ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) ->whereIn('id', $ticketIds) - ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) ->orderByDesc('id') ->get(); diff --git a/app/Domains/Ticket/Enums/TicketGenerationPolicy.php b/app/Domains/Ticket/Enums/TicketGenerationPolicy.php deleted file mode 100644 index c26a177..0000000 --- a/app/Domains/Ticket/Enums/TicketGenerationPolicy.php +++ /dev/null @@ -1,15 +0,0 @@ - */ - public static function values(): array - { - return array_column(self::cases(), 'value'); - } -} diff --git a/app/Domains/Ticket/Exceptions/TicketGenerationException.php b/app/Domains/Ticket/Exceptions/TicketGenerationException.php index bc543cc..77ff60c 100644 --- a/app/Domains/Ticket/Exceptions/TicketGenerationException.php +++ b/app/Domains/Ticket/Exceptions/TicketGenerationException.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Exceptions; 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 RuntimeException; @@ -24,9 +25,9 @@ class TicketGenerationException extends RuntimeException return new self(__('api.ticket.disabled', ['product' => $catalogItem->id])); } - public static function ambiguousValidityTime(CatalogItem $catalogItem): self + public static function invalidValidityConfiguration(CatalogItem $catalogItem, Variant $variant): self { - return new self("Catalog item {$catalogItem->id} resolves more than one validity time."); + return new self("Variant {$variant->id} from catalog item {$catalogItem->id} has an invalid validity configuration."); } public static function variantNotFound( diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 7c278cd..9fe6027 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -7,20 +7,20 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Services\ResolvedTicketValidity; +use App\Domains\Ticket\Services\ResolvedValidityGroup; +use App\Domains\Ticket\Services\TicketPresentationResolver; +use App\Domains\Ticket\Services\TicketValidityResolver; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; 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\Support\Collection; #[Fillable([ 'tenant_code', 'ticket', - 'name', - 'description', 'source_purchase_id', 'source_catalog_item_id', 'source_variant_id', @@ -32,6 +32,8 @@ class Ticket extends Model { use HasFactory; + private ?ResolvedTicketValidity $resolvedValidity = null; + public const STATUS_ACTIVE = 'active'; public const STATUS_EXPIRED = 'expired'; @@ -41,6 +43,8 @@ class Ticket extends Model public $timestamps = false; protected $appends = [ + 'name', + 'description', 'is_valid', 'is_expired', 'is_used', @@ -86,19 +90,13 @@ class Ticket extends Model /** @return BelongsTo */ public function sourceCatalogItem(): BelongsTo { - return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id'); + return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id')->withTrashed(); } /** @return BelongsTo */ public function sourceVariant(): BelongsTo { - return $this->belongsTo(Variant::class, 'source_variant_id'); - } - - /** @return HasMany */ - public function validityGroups(): HasMany - { - return $this->hasMany(TicketValidityGroup::class); + return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed(); } public function isValid(): bool @@ -107,12 +105,7 @@ class Ticket extends Model return false; } - $validityGroups = $this->resolvedValidityGroups(); - - return $validityGroups->isEmpty() - || $validityGroups->contains( - fn (TicketValidityGroup $group): bool => $group->isValid() - ); + return $this->resolvedValidity()->isValid(); } public function getIsValidAttribute(): bool @@ -122,13 +115,7 @@ class Ticket extends Model public function getIsExpiredAttribute(): bool { - $validityGroups = $this->resolvedValidityGroups(); - - return $this->used_at === null - && $validityGroups->isNotEmpty() - && $validityGroups->every( - fn (TicketValidityGroup $group): bool => $group->isExpired() - ); + return $this->used_at === null && $this->resolvedValidity()->isExpired(); } public function getIsUsedAttribute(): bool @@ -149,52 +136,34 @@ class Ticket extends Model return self::STATUS_ACTIVE; } + public function getNameAttribute(): string + { + return app(TicketPresentationResolver::class)->name($this); + } + + public function getDescriptionAttribute(): string + { + return app(TicketPresentationResolver::class)->description($this); + } + public function getEffectiveStartsAt(): ?CarbonInterface { - return $this->resolvedValidityGroups() - ->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt()) - ->filter() - ->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) - ->first(); + return $this->resolvedValidity()->effectiveStartsAt(); } public function getEffectiveExpiresAt(): ?CarbonInterface { - return $this->resolvedValidityGroups() - ->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt()) - ->filter() - ->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) - ->first(); + return $this->resolvedValidity()->effectiveExpiresAt(); } - /** @return Collection */ - public function allValidityTimes(): Collection + /** @return Collection */ + public function resolvedValidityGroups(): Collection { - return $this->resolvedValidityGroups() - ->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes()) - ->unique( - fn (ValidityTime $validityTime): int => $validityTime->getKey() - ?? spl_object_id($validityTime) - ) - ->values(); + return $this->resolvedValidity()->groups; } - /** @return EloquentCollection */ - public function resolvedValidityGroups(): EloquentCollection + public function resolvedValidity(): ResolvedTicketValidity { - if ($this->relationLoaded('validityGroups')) { - return $this->getRelation('validityGroups'); - } - - if (! $this->exists) { - return new EloquentCollection; - } - - $validityGroups = $this->validityGroups() - ->with('validityTimes') - ->get(); - $this->setRelation('validityGroups', $validityGroups); - - return $validityGroups; + return $this->resolvedValidity ??= app(TicketValidityResolver::class)->resolveTicket($this); } } diff --git a/app/Domains/Ticket/Models/TicketValidityGroup.php b/app/Domains/Ticket/Models/TicketValidityGroup.php deleted file mode 100644 index 8e14d4f..0000000 --- a/app/Domains/Ticket/Models/TicketValidityGroup.php +++ /dev/null @@ -1,117 +0,0 @@ - */ - public function ticket(): BelongsTo - { - return $this->belongsTo(Ticket::class); - } - - /** @return BelongsToMany */ - public function validityTimes(): BelongsToMany - { - return $this->belongsToMany( - ValidityTime::class, - 'ticket_validity_group_times', - 'ticket_validity_group_id', - 'validity_time_id', - ); - } - - public function isValid(?CarbonInterface $at = null): bool - { - $at ??= now(); - $validityTimes = $this->resolvedValidityTimes(); - - return $validityTimes->isNotEmpty() - && $validityTimes->every( - fn (ValidityTime $validityTime): bool => $validityTime->isValid($at) - ); - } - - public function isExpired(?CarbonInterface $at = null): bool - { - $at ??= now(); - $expiresAt = $this->effectiveExpiresAt($at); - - return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at); - } - - public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface - { - $at ??= now(); - $anchor = $this->dateAnchor() ?? $at; - - return $this->resolvedValidityTimes() - ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor)) - ->filter() - ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) - ->first(); - } - - public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface - { - $at ??= now(); - $anchor = $this->dateAnchor() ?? $at; - - return $this->resolvedValidityTimes() - ->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface { - $startsAt = $validityTime->startsAt($anchor); - $expiresAt = $validityTime->expiresAt($anchor); - - if ( - $startsAt !== null - && $expiresAt !== null - && $expiresAt->lessThanOrEqualTo($startsAt) - ) { - return $expiresAt->addDay(); - } - - return $expiresAt; - }) - ->filter() - ->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) - ->first(); - } - - /** @return EloquentCollection */ - public function resolvedValidityTimes(): EloquentCollection - { - if ($this->relationLoaded('validityTimes')) { - return $this->getRelation('validityTimes'); - } - - if (! $this->exists) { - return new EloquentCollection; - } - - $validityTimes = $this->validityTimes()->get(); - $this->setRelation('validityTimes', $validityTimes); - - return $validityTimes; - } - - private function dateAnchor(): ?CarbonInterface - { - return $this->resolvedValidityTimes() - ->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow) - ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at) - ->filter() - ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) - ->first(); - } -} diff --git a/app/Domains/Ticket/Models/ValidityTime.php b/app/Domains/Ticket/Models/ValidityTime.php index 5210091..9da996e 100644 --- a/app/Domains/Ticket/Models/ValidityTime.php +++ b/app/Domains/Ticket/Models/ValidityTime.php @@ -3,7 +3,6 @@ namespace App\Domains\Ticket\Models; use App\Domains\Catalog\Models\AttributeOption; -use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Enums\ValidityTimeType; use Carbon\CarbonImmutable; @@ -11,7 +10,6 @@ use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -35,12 +33,6 @@ class ValidityTime extends Model ]; } - /** @return HasMany */ - public function catalogItems(): HasMany - { - return $this->hasMany(CatalogItem::class); - } - /** @return HasMany */ public function attributeOptions(): HasMany { @@ -53,17 +45,6 @@ class ValidityTime extends Model return $this->hasOne(EventDate::class); } - /** @return BelongsToMany */ - public function ticketValidityGroups(): BelongsToMany - { - return $this->belongsToMany( - TicketValidityGroup::class, - 'ticket_validity_group_times', - 'validity_time_id', - 'ticket_validity_group_id', - ); - } - public function startsAt( ?CarbonInterface $at = null, ): ?CarbonInterface { diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index 368d884..8e4c0e8 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -22,10 +22,6 @@ class TicketResource extends JsonResource 'category' => $this->sourceCatalogItem?->category?->nombre, 'source_catalog_item_id' => $this->source_catalog_item_id, 'source_variant_id' => $this->source_variant_id, - 'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()), - 'validity_groups' => TicketValidityGroupResource::collection( - $this->resolvedValidityGroups() - ), 'starts_at' => $this->getEffectiveStartsAt(), 'expires_at' => $this->getEffectiveExpiresAt(), 'used_at' => $this->used_at, diff --git a/app/Domains/Ticket/Resources/TicketValidityGroupResource.php b/app/Domains/Ticket/Resources/TicketValidityGroupResource.php deleted file mode 100644 index c56383c..0000000 --- a/app/Domains/Ticket/Resources/TicketValidityGroupResource.php +++ /dev/null @@ -1,24 +0,0 @@ - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'validity_times' => ValidityTimeResource::collection($this->resolvedValidityTimes()), - 'starts_at' => $this->effectiveStartsAt(), - 'expires_at' => $this->effectiveExpiresAt(), - 'is_valid' => $this->isValid(), - 'is_expired' => $this->isExpired(), - ]; - } -} diff --git a/app/Domains/Ticket/Services/ResolvedTicketValidity.php b/app/Domains/Ticket/Services/ResolvedTicketValidity.php new file mode 100644 index 0000000..ce6e3ca --- /dev/null +++ b/app/Domains/Ticket/Services/ResolvedTicketValidity.php @@ -0,0 +1,77 @@ + $groups */ + public function __construct( + public Collection $groups, + public bool $isResolvable = true, + public bool $isUnrestricted = false, + ) {} + + /** No existe ninguna restricción temporal configurada. */ + public static function unrestricted(): self + { + return new self(collect(), isUnrestricted: true); + } + + /** La configuración fuente está incompleta o es inconsistente. */ + public static function unresolvable(): self + { + return new self(collect(), isResolvable: false); + } + + /** Es válido cuando no tiene restricciones o algún grupo OR está activo. */ + public function isValid(?CarbonInterface $at = null): bool + { + if (! $this->isResolvable) { + return false; + } + + return $this->isUnrestricted || $this->groups->contains( + fn (ResolvedValidityGroup $group): bool => $group->isValid($at) + ); + } + + /** Sólo está vencido cuando todos los grupos OR ya vencieron. */ + public function isExpired(?CarbonInterface $at = null): bool + { + return $this->isResolvable + && ! $this->isUnrestricted + && $this->groups->isNotEmpty() + && $this->groups->every( + fn (ResolvedValidityGroup $group): bool => $group->isExpired($at) + ); + } + + /** Inicio más temprano de todas las alternativas, usado como resumen. */ + public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface + { + return $this->groups + ->map(fn (ResolvedValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt($at)) + ->filter() + ->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } + + /** Vencimiento más tardío de todas las alternativas, usado como resumen. */ + public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface + { + return $this->groups + ->map(fn (ResolvedValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt($at)) + ->filter() + ->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) + ->first(); + } +} diff --git a/app/Domains/Ticket/Services/ResolvedValidityGroup.php b/app/Domains/Ticket/Services/ResolvedValidityGroup.php new file mode 100644 index 0000000..fad6d97 --- /dev/null +++ b/app/Domains/Ticket/Services/ResolvedValidityGroup.php @@ -0,0 +1,97 @@ + $validityTimes */ + public function __construct(public Collection $validityTimes) {} + + /** Comprueba si el instante pertenece a la intersección efectiva del grupo. */ + public function isValid(?CarbonInterface $at = null): bool + { + $at ??= now(); + $startsAt = $this->effectiveStartsAt($at); + $expiresAt = $this->effectiveExpiresAt($at); + + return $this->validityTimes->isNotEmpty() + && ($startsAt === null || $startsAt->lessThanOrEqualTo($at)) + && ($expiresAt === null || $expiresAt->greaterThan($at)); + } + + /** Un grupo vence cuando termina su intersección efectiva. */ + public function isExpired(?CarbonInterface $at = null): bool + { + $at ??= now(); + $expiresAt = $this->effectiveExpiresAt($at); + + return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at); + } + + /** + * En un AND, la intersección comienza en el inicio más tardío. + * Por ejemplo, fecha 00:00 + horario 12:00 comienza a las 12:00. + */ + public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface + { + $at ??= now(); + $anchor = $this->dateAnchor() ?? $at; + + return $this->validityTimes + ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor)) + ->filter() + ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } + + /** + * En un AND, la intersección termina en el vencimiento más temprano. + * Los horarios cuyo fin no supera al inicio se interpretan como nocturnos. + */ + public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface + { + $at ??= now(); + $anchor = $this->dateAnchor() ?? $at; + + return $this->validityTimes + ->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface { + $startsAt = $validityTime->startsAt($anchor); + $expiresAt = $validityTime->expiresAt($anchor); + + if ($startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) { + return $expiresAt->addDay(); + } + + return $expiresAt; + }) + ->filter() + ->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) + ->first(); + } + + /** + * Usa la fecha de un fixed_window como ancla para convertir ventanas que + * sólo contienen horas (time_window) en instantes concretos. + */ + private function dateAnchor(): ?CarbonInterface + { + return $this->validityTimes + ->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow) + ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at) + ->filter() + ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } +} diff --git a/app/Domains/Ticket/Services/ScannerTicketService.php b/app/Domains/Ticket/Services/ScannerTicketService.php index 23c15c4..ccaf189 100644 --- a/app/Domains/Ticket/Services/ScannerTicketService.php +++ b/app/Domains/Ticket/Services/ScannerTicketService.php @@ -138,7 +138,8 @@ class ScannerTicketService private function relations(): array { return [ - 'validityGroups.validityTimes', + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, 'sourceCatalogItem.category', 'sourceVariant.eventDate', 'sourceVariant.catalogItem', diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index a4bc4ca..bda45ac 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -5,19 +5,16 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Event\Models\EventDate; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\Ticket; -use App\Domains\Ticket\Models\TicketValidityGroup; -use App\Domains\Ticket\Models\ValidityTime; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; class TicketGeneratorService { + public function __construct(private readonly TicketValidityResolver $validityResolver) {} + /** * @return Collection */ @@ -45,19 +42,9 @@ class TicketGeneratorService ): Ticket { $item = $target['catalog_item']; $variant = $target['variant']; - $eventDate = $target['event_date']; - $validityGroups = $this->buildTicketValidityGroups( - $item, - $variant, - $eventDate, - $this->resolveValidityTime($item, $variant), - ); - $ticket = Ticket::query()->create([ 'tenant_code' => $item->tenant_code, 'ticket' => (string) Str::uuid(), - 'name' => $this->ticketName($item, $variant, $eventDate), - 'description' => (string) ($item->descripcion ?? ''), 'source_purchase_id' => $sourcePurchaseId, 'source_catalog_item_id' => $item->getKey(), 'source_variant_id' => $variant?->getKey(), @@ -65,31 +52,12 @@ class TicketGeneratorService 'user_id' => $user->getKey(), ]); - $groups = $validityGroups->map(function (Collection $validityTimes) use ($ticket): TicketValidityGroup { - $group = $ticket->validityGroups()->create(); - $group->validityTimes()->attach( - $validityTimes - ->map(fn (ValidityTime $validityTime): int => $validityTime->getKey()) - ->all() - ); - $group->setRelation( - 'validityTimes', - new EloquentCollection($validityTimes->all()), - ); - - return $group; - }); - - $ticket->setRelation('validityGroups', new EloquentCollection($groups->all())); - return $ticket; }); }); } - /** - * @return Collection - */ + /** @return Collection */ private function resolveTargets( CatalogItem $catalogItem, int $quantity, @@ -126,9 +94,7 @@ class TicketGeneratorService ->values(); } - /** - * @return Collection - */ + /** @return Collection */ private function targetsForVariant( CatalogItem $catalogItem, ?Variant $variant, @@ -138,36 +104,18 @@ class TicketGeneratorService return Collection::times($quantity, fn (): array => [ 'catalog_item' => $catalogItem, 'variant' => null, - 'event_date' => null, ]); } $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']); - $selectedEventDates = $variant->selectedEventDates(); - - if ($catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit) { - $eventDate = $selectedEventDates->count() === 1 - ? $selectedEventDates->first() - : null; - - return Collection::times($quantity, fn (): array => [ - 'catalog_item' => $catalogItem, - 'variant' => $variant, - 'event_date' => $eventDate, - ]); + if (! $this->validityResolver->resolveVariant($variant)->isResolvable) { + throw TicketGenerationException::invalidValidityConfiguration($catalogItem, $variant); } - $eventDates = $selectedEventDates->isEmpty() - ? collect([null]) - : $selectedEventDates; - - return Collection::times($quantity) - ->flatMap(fn () => $eventDates->map(fn (?EventDate $eventDate): array => [ - 'catalog_item' => $catalogItem, - 'variant' => $variant, - 'event_date' => $eventDate, - ])) - ->values(); + return Collection::times($quantity, fn (): array => [ + 'catalog_item' => $catalogItem, + 'variant' => $variant, + ]); } private function resolveVariant( @@ -201,112 +149,5 @@ class TicketGeneratorService if (! $catalogItem->has_tickets) { throw TicketGenerationException::ticketsDisabled($catalogItem); } - - } - - private function resolveValidityTime( - CatalogItem $catalogItem, - ?Variant $variant, - ): ?ValidityTime { - if ($catalogItem->validity_time_id !== null) { - return $catalogItem->validityTime; - } - - if ($variant === null) { - return null; - } - - $variant->loadMissing('definitions.itemAttribute.attribute.options.validityTime'); - - $validityTimes = $variant->definitions - ->map(function ($definition): ?ValidityTime { - $option = $definition->itemAttribute?->attribute?->options - ->firstWhere('value', $definition->value); - - return $option?->validityTime; - }) - ->filter() - ->unique(fn (ValidityTime $validityTime): int => $validityTime->getKey()) - ->values(); - - if ($validityTimes->count() > 1) { - throw TicketGenerationException::ambiguousValidityTime($catalogItem); - } - - return $validityTimes->first(); - } - - private function ticketName( - CatalogItem $catalogItem, - ?Variant $variant, - ?EventDate $eventDate, - ): string { - if ($variant === null) { - return $catalogItem->nombre; - } - - $options = $variant->selectionOptions(); - - if ($eventDate !== null) { - $options->put('event_date', [ - 'value' => (string) $eventDate->getKey(), - 'label' => $eventDate->date->format('d/m/Y'), - ]); - } - - $properties = $options - ->flatMap(function (array $option): array { - if (array_is_list($option)) { - return collect($option) - ->pluck('label') - ->filter(fn ($label): bool => is_string($label) && $label !== '') - ->all(); - } - - $label = $option['label'] ?? null; - - return is_string($label) && $label !== '' ? [$label] : []; - }) - ->values(); - - if ($properties->isEmpty()) { - return $catalogItem->nombre; - } - - return $catalogItem->nombre.' ('.$properties->implode(', ').')'; - } - - /** @return Collection> */ - private function buildTicketValidityGroups( - CatalogItem $catalogItem, - ?Variant $variant, - ?EventDate $eventDate, - ?ValidityTime $validityTime, - ): Collection { - $eventDates = collect([$eventDate]); - - if ( - $catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit - && $variant !== null - ) { - $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']); - $selectedEventDates = $variant->selectedEventDates(); - - if ($selectedEventDates->isNotEmpty()) { - $eventDates = $selectedEventDates; - } - } - - return $eventDates - ->map(function (?EventDate $date) use ($validityTime): Collection { - $date?->loadMissing('validityTime'); - - return collect([$date?->validityTime, $validityTime]) - ->filter() - ->unique(fn (ValidityTime $time): int => $time->getKey()) - ->values(); - }) - ->filter(fn (Collection $group): bool => $group->isNotEmpty()) - ->values(); } } diff --git a/app/Domains/Ticket/Services/TicketPresentationResolver.php b/app/Domains/Ticket/Services/TicketPresentationResolver.php new file mode 100644 index 0000000..ec6c85e --- /dev/null +++ b/app/Domains/Ticket/Services/TicketPresentationResolver.php @@ -0,0 +1,60 @@ +loadMissing(self::RELATIONS); + $catalogItem = $ticket->sourceCatalogItem; + + if ($catalogItem === null) { + return ''; + } + + $variant = $ticket->sourceVariant; + if ($variant === null) { + return $catalogItem->nombre; + } + + $properties = $variant->selectionOptions() + ->flatMap(function (array $option): array { + if (array_is_list($option)) { + return collect($option) + ->pluck('label') + ->filter(fn ($label): bool => is_string($label) && $label !== '') + ->all(); + } + + $label = $option['label'] ?? null; + + return is_string($label) && $label !== '' ? [$label] : []; + }) + ->values(); + + return $properties->isEmpty() + ? $catalogItem->nombre + : $catalogItem->nombre.' ('.$properties->implode(', ').')'; + } + + public function description(Ticket $ticket): string + { + $ticket->loadMissing(self::RELATIONS); + + return (string) ($ticket->sourceVariant?->getDescription() + ?? $ticket->sourceCatalogItem?->descripcion + ?? ''); + } +} diff --git a/app/Domains/Ticket/Services/TicketValidityResolver.php b/app/Domains/Ticket/Services/TicketValidityResolver.php new file mode 100644 index 0000000..0c0c34d --- /dev/null +++ b/app/Domains/Ticket/Services/TicketValidityResolver.php @@ -0,0 +1,134 @@ +source_variant_id === null) { + return ResolvedTicketValidity::unrestricted(); + } + + $ticket->loadMissing(self::RELATIONS); + + if ($ticket->sourceVariant === null) { + return ResolvedTicketValidity::unresolvable(); + } + + return $this->resolveVariant($ticket->sourceVariant); + } + + /** + * Convierte las fechas y definiciones temporales de la variante en grupos + * normalizados: AND dentro de cada grupo y OR entre grupos. + */ + public function resolveVariant(Variant $variant): ResolvedTicketValidity + { + $variant->loadMissing([ + 'eventDates.validityTime', + 'eventDate.validityTime', + 'definitions.itemAttribute.attribute.options.validityTime', + ]); + + $dimensions = collect(); + $eventDates = $variant->selectedEventDates(); + + if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) { + return ResolvedTicketValidity::unresolvable(); + } + + if ($eventDates->isNotEmpty()) { + // Todas las fechas pertenecen a una misma dimensión alternativa: + // fecha 1 OR fecha 2 OR fecha 3. + $dimensions->push( + $eventDates->map(fn ($eventDate): Collection => collect([$eventDate->validityTime])) + ); + } + + foreach ($variant->definitions->groupBy('item_attribute_id') as $definitions) { + $itemAttribute = $definitions->first()?->itemAttribute; + $attribute = $itemAttribute?->attribute; + + if ($itemAttribute === null || $attribute === null) { + return ResolvedTicketValidity::unresolvable(); + } + + if (! $attribute->type->supportsOptions() || $attribute->type->usesDynamicOptions()) { + // Texto, números y demás atributos no temporales no restringen + // la vigencia. EventDate se procesó arriba mediante su relación. + continue; + } + + if (! $itemAttribute->allow_multi_select && $definitions->count() > 1) { + return ResolvedTicketValidity::unresolvable(); + } + + $alternatives = $definitions->map(function (VariantDefinition $definition) use ($attribute): ?Collection { + $option = $attribute->options->firstWhere('value', $definition->value); + + if ($option === null) { + return null; + } + + return collect([$option->validityTime])->filter()->values(); + }); + + if ($alternatives->contains(null)) { + return ResolvedTicketValidity::unresolvable(); + } + + if ($alternatives->contains(fn (Collection $alternative): bool => $alternative->isNotEmpty())) { + // Las opciones elegidas del mismo atributo son alternativas OR. + $dimensions->push($alternatives->values()); + } + } + + if ($dimensions->isEmpty()) { + return ResolvedTicketValidity::unrestricted(); + } + + $groups = collect([collect()]); + + foreach ($dimensions as $alternatives) { + // El producto cartesiano agrega cada dimensión como una condición + // AND y conserva sus opciones internas como alternativas OR. + $groups = $groups->flatMap( + fn (Collection $group): Collection => $alternatives->map( + fn (Collection $alternative): Collection => $group + ->merge($alternative) + ->unique(fn (ValidityTime $time): int => $time->getKey() ?? spl_object_id($time)) + ->values() + ) + )->values(); + } + + return new ResolvedTicketValidity( + $groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times)) + ); + } +} diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index 60b1e27..c07caf4 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -6,11 +6,15 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ ## Modelo -- `Ticket`: pertenece a tenant y usuario, conserva referencias a compra, producto, variante, validez y usuario escáner. -- `ValidityTime`: define ventanas absolutas o relativas de vigencia para productos, opciones y tickets. +- `Ticket`: pertenece a tenant y usuario, y conserva referencias a compra, producto, variante y usuario escáner. +- 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. - `ValidityTimeType`: enum de estrategias de vigencia. -El modelo calcula si un ticket está vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin. +`TicketValidityResolver` deriva la vigencia desde la variante asociada. Las alternativas de un mismo atributo +se combinan con OR y las dimensiones diferentes se combinan con AND. El modelo calcula si un ticket está +vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin persistir vigencias en el ticket. ## Flujo de generación diff --git a/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php b/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php index 5a5d18f..976004f 100644 --- a/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php +++ b/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php @@ -1,6 +1,5 @@ enum('ticket_generation_policy', TicketGenerationPolicy::values()) - ->default(TicketGenerationPolicy::PerEventDate->value) + $table->enum('ticket_generation_policy', ['per_event_date', 'one_per_unit']) + ->default('per_event_date') ->after('has_tickets'); }); DB::table('catalog_items') ->where('tenant_code', 'fiesta_futbol_infantil') ->update([ - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + 'ticket_generation_policy' => 'one_per_unit', ]); } diff --git a/database/migrations/2026_08_13_000100_remove_validity_time_from_catalog_items.php b/database/migrations/2026_08_13_000100_remove_validity_time_from_catalog_items.php new file mode 100644 index 0000000..fc272ce --- /dev/null +++ b/database/migrations/2026_08_13_000100_remove_validity_time_from_catalog_items.php @@ -0,0 +1,66 @@ +join('event_dates', function ($join): void { + $join->on('event_dates.tenant_code', '=', 'catalog_items.tenant_code') + ->on('event_dates.validity_time_id', '=', 'catalog_items.validity_time_id'); + }) + ->whereNotNull('catalog_items.validity_time_id') + ->select([ + 'catalog_items.id as catalog_item_id', + 'event_dates.id as event_date_id', + ]) + ->orderBy('catalog_items.id') + ->orderBy('event_dates.id') + ->each(function (object $association): void { + $variantIds = DB::table('variantes') + ->where('catalog_item_id', $association->catalog_item_id) + ->pluck('id'); + + if ($variantIds->isEmpty()) { + return; + } + + DB::table('variantes') + ->whereIn('id', $variantIds) + ->whereNull('event_date_id') + ->update(['event_date_id' => $association->event_date_id]); + + DB::table('variant_event_dates')->insertOrIgnore( + $variantIds->map(fn (int $variantId): array => [ + 'variant_id' => $variantId, + 'event_date_id' => $association->event_date_id, + ])->all() + ); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + if (DB::getDriverName() !== 'sqlite') { + $table->dropForeign(['validity_time_id']); + } + + $table->dropColumn('validity_time_id'); + }); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->foreignId('validity_time_id') + ->nullable() + ->after('has_tickets') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } +}; diff --git a/database/migrations/2026_08_13_000200_drop_ticket_validity_groups.php b/database/migrations/2026_08_13_000200_drop_ticket_validity_groups.php new file mode 100644 index 0000000..4e39e5f --- /dev/null +++ b/database/migrations/2026_08_13_000200_drop_ticket_validity_groups.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + }); + + Schema::create('ticket_validity_group_times', function (Blueprint $table): void { + $table->foreignId('ticket_validity_group_id') + ->constrained('ticket_validity_groups') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + $table->primary(['ticket_validity_group_id', 'validity_time_id']); + }); + } +}; diff --git a/database/migrations/2026_08_13_000300_protect_ticket_catalog_sources.php b/database/migrations/2026_08_13_000300_protect_ticket_catalog_sources.php new file mode 100644 index 0000000..626765c --- /dev/null +++ b/database/migrations/2026_08_13_000300_protect_ticket_catalog_sources.php @@ -0,0 +1,55 @@ +dropForeign(['source_catalog_item_id']); + $table->dropForeign(['source_variant_id']); + + $table->foreign('source_catalog_item_id') + ->references('id') + ->on('catalog_items') + ->cascadeOnUpdate() + ->restrictOnDelete(); + $table->foreign('source_variant_id') + ->references('id') + ->on('variantes') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + if (DB::getDriverName() === 'sqlite') { + return; + } + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropForeign(['source_catalog_item_id']); + $table->dropForeign(['source_variant_id']); + + $table->foreign('source_catalog_item_id') + ->references('id') + ->on('catalog_items') + ->cascadeOnUpdate() + ->nullOnDelete(); + $table->foreign('source_variant_id') + ->references('id') + ->on('variantes') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } +}; diff --git a/database/migrations/2026_08_13_000400_remove_ticket_generation_policy.php b/database/migrations/2026_08_13_000400_remove_ticket_generation_policy.php new file mode 100644 index 0000000..6a418b2 --- /dev/null +++ b/database/migrations/2026_08_13_000400_remove_ticket_generation_policy.php @@ -0,0 +1,24 @@ +dropColumn('ticket_generation_policy'); + }); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->enum('ticket_generation_policy', ['one_per_unit']) + ->default('one_per_unit') + ->after('has_tickets'); + }); + } +}; diff --git a/database/migrations/2026_08_13_010000_stop_persisting_ticket_presentation.php b/database/migrations/2026_08_13_010000_stop_persisting_ticket_presentation.php new file mode 100644 index 0000000..a370405 --- /dev/null +++ b/database/migrations/2026_08_13_010000_stop_persisting_ticket_presentation.php @@ -0,0 +1,28 @@ +string('name')->nullable()->change(); + $table->text('description')->nullable()->change(); + }); + } + + public function down(): void + { + DB::table('tickets')->whereNull('name')->update(['name' => '']); + DB::table('tickets')->whereNull('description')->update(['description' => '']); + + Schema::table('tickets', function (Blueprint $table): void { + $table->string('name')->nullable(false)->change(); + $table->text('description')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_08_13_030000_add_soft_deletes_to_catalog_sources.php b/database/migrations/2026_08_13_030000_add_soft_deletes_to_catalog_sources.php new file mode 100644 index 0000000..83d3f55 --- /dev/null +++ b/database/migrations/2026_08_13_030000_add_soft_deletes_to_catalog_sources.php @@ -0,0 +1,30 @@ +softDeletes(); + }); + + Schema::table('variantes', function (Blueprint $table): void { + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::table('variantes', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 1095fac..5a65a8c 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Seeder; @@ -155,6 +154,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->orderByRaw("CASE WHEN type = 'bundle' THEN 0 ELSE 1 END") + ->orderBy('id') ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); } @@ -167,7 +167,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder 'inventory_policy' => InventoryPolicy::Tracked->value, ...$data, 'has_tickets' => true, - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); } diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index ff99a95..dbab420 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -241,14 +241,17 @@ class MenuSeeder extends Seeder Role::query() ->whereIn('codigo', [RoleCode::Admin->value, RoleCode::AdminApp->value]) + ->orderBy('codigo') ->each(fn (Role $role) => $role->menus()->sync($allRoleMenuCodes)); Role::query() ->where('codigo', RoleCode::User->value) + ->orderBy('codigo') ->each(fn (Role $role) => $role->menus()->sync($userMenuCodes)); Role::query() ->where('codigo', RoleCode::Scanner->value) + ->orderBy('codigo') ->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes)); $tenants = Tenant::all(); diff --git a/tests/Feature/Catalog/BundleCatalogItemTest.php b/tests/Feature/Catalog/BundleCatalogItemTest.php index d0b404d..cc1c288 100644 --- a/tests/Feature/Catalog/BundleCatalogItemTest.php +++ b/tests/Feature/Catalog/BundleCatalogItemTest.php @@ -287,7 +287,7 @@ class BundleCatalogItemTest extends TestCase ->assertJsonValidationErrors(['real_stock']); } - public function test_bundle_components_are_deleted_with_the_bundle(): void + public function test_bundle_components_are_preserved_when_the_bundle_is_soft_deleted(): void { $component = $this->createStandardItem('deletion-component', 5); $bundle = $this->createBundle('deletable-bundle', [ @@ -295,8 +295,8 @@ class BundleCatalogItemTest extends TestCase ]); $this->catalogService->delete($bundle); - $this->assertDatabaseMissing('catalog_items', ['id' => $bundle->id]); - $this->assertDatabaseMissing('bundle_components', [ + $this->assertSoftDeleted('catalog_items', ['id' => $bundle->id]); + $this->assertDatabaseHas('bundle_components', [ 'bundle_catalog_item_id' => $bundle->id, ]); $this->assertDatabaseHas('catalog_items', ['id' => $component->id]); diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index eb22b76..6f5122d 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -33,7 +33,6 @@ class CatalogSchemaTest extends TestCase { $this->assertEqualsCanonicalizing([ 'id', - 'validity_time_id', 'tenant_code', 'category_id', 'brand_id', @@ -46,7 +45,6 @@ class CatalogSchemaTest extends TestCase 'inventory_policy', 'max_units_per_user', 'has_tickets', - 'ticket_generation_policy', 'validity_time_id', ], Schema::getColumnListing('catalog_items')); } diff --git a/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php index b984854..cb4e8ec 100644 --- a/tests/Feature/Catalog/CatalogServiceTest.php +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -93,6 +93,89 @@ class CatalogServiceTest extends TestCase } } + public function test_it_soft_deletes_an_item_and_its_variants_without_removing_their_sources(): void + { + $attribute = $this->createAttribute('size'); + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'archived-item', + 'nombre' => 'Archived item', + 'precio' => 100, + 'attribute_codes' => [$attribute->codigo], + 'variants' => [[ + 'real_stock' => 5, + 'values' => [$attribute->codigo => 'M'], + ]], + ]); + $variant = $item->variants->sole(); + + $this->service->delete($item); + + $this->assertSoftDeleted('catalog_items', ['id' => $item->id]); + $this->assertSoftDeleted('variantes', ['id' => $variant->id]); + $this->assertDatabaseHas('inventories', ['id' => $variant->inventory_id]); + $this->assertDatabaseHas('variant_values', [ + 'variant_id' => $variant->id, + 'value' => 'M', + ]); + $this->assertNull(CatalogItem::query()->find($item->id)); + $this->assertNull(Variant::query()->find($variant->id)); + $this->assertNotNull(CatalogItem::withTrashed()->find($item->id)); + $this->assertNotNull(Variant::withTrashed()->find($variant->id)); + } + + public function test_deleting_the_last_variant_soft_deletes_its_catalog_item(): void + { + $attribute = $this->createAttribute('size'); + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'last-variant', + 'nombre' => 'Last variant', + 'precio' => 100, + 'attribute_codes' => [$attribute->codigo], + 'variants' => [[ + 'real_stock' => 5, + 'values' => [$attribute->codigo => 'M'], + ]], + ]); + $variant = $item->variants->sole(); + + $this->service->deleteVariant($variant); + + $this->assertSoftDeleted('variantes', ['id' => $variant->id]); + $this->assertSoftDeleted('catalog_items', ['id' => $item->id]); + $this->assertDatabaseHas('inventories', ['id' => $variant->inventory_id]); + } + + public function test_deleting_a_variant_keeps_the_item_active_when_an_inherited_price_variant_remains(): void + { + $attribute = $this->createAttribute('size'); + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'remaining-variant', + 'nombre' => 'Remaining variant', + 'precio' => 100, + 'attribute_codes' => [$attribute->codigo], + 'variants' => [ + [ + 'real_stock' => 5, + 'values' => [$attribute->codigo => 'M'], + ], + [ + 'real_stock' => 5, + 'values' => [$attribute->codigo => 'L'], + ], + ], + ]); + $deletedVariant = $item->variants->first(); + + $this->service->deleteVariant($deletedVariant); + + $this->assertSoftDeleted('variantes', ['id' => $deletedVariant->id]); + $this->assertFalse($item->fresh()->trashed()); + $this->assertSame(1, $item->variants()->count()); + } + public function test_it_can_hide_an_item_attribute_from_the_product_selector(): void { $attribute = $this->createAttribute('internal_type'); diff --git a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php index e9ac69c..9a4e755 100644 --- a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php @@ -8,6 +8,7 @@ use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; @@ -141,9 +142,10 @@ class AccommodationControllerTest extends TestCase $this->deleteJson("/api/v1/adminapp/tenant/accommodations/{$accommodationId}") ->assertNoContent(); - $this->assertDatabaseCount('catalog_items', 0); - $this->assertDatabaseCount('variantes', 0); - $this->assertDatabaseCount('inventories', 0); + $this->assertSame(0, CatalogItem::query()->count()); + $this->assertSoftDeleted('variantes', ['id' => $accommodationId]); + $this->assertSame(0, Variant::query()->count()); + $this->assertDatabaseCount('inventories', 1); } public function test_it_rejects_duplicate_normalized_titles(): void diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 9a02329..3a66f36 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; @@ -97,7 +98,7 @@ class EntryControllerTest extends TestCase ->where('nombre', 'Entradas') ->sole(); - CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void { + CatalogItem::query()->orderBy('id')->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void { $this->assertSame($tenant->codigo, $entry->tenant_code); $this->assertSame($entryCategory->id, $entry->category_id); $this->assertSame('tracked', $entry->inventory_policy->value); @@ -191,9 +192,10 @@ class EntryControllerTest extends TestCase $this->deleteJson("/api/v1/adminapp/tenant/entries/{$entryId}") ->assertNoContent(); - $this->assertDatabaseCount('catalog_items', 0); - $this->assertDatabaseCount('variantes', 0); - $this->assertDatabaseCount('inventories', 0); + $this->assertSoftDeleted('catalog_items', ['id' => $entryId]); + $this->assertSame(0, CatalogItem::query()->count()); + $this->assertSame(0, Variant::query()->count()); + $this->assertDatabaseCount('inventories', 1); } public function test_dates_must_belong_to_the_authenticated_tenant(): void diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index 581172f..f03dac5 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; @@ -163,15 +164,17 @@ class FoodControllerTest extends TestCase 'slug' => 'comida', 'precio' => 10000, ]); - $this->assertDatabaseCount('variantes', 1); - $this->assertDatabaseCount('inventories', 1); + $this->assertSoftDeleted('variantes', ['id' => $secondId]); + $this->assertSame(1, Variant::query()->count()); + $this->assertDatabaseCount('inventories', 2); $this->deleteJson("/api/v1/adminapp/tenant/foods/{$firstId}") ->assertNoContent(); - $this->assertDatabaseCount('catalog_items', 0); - $this->assertDatabaseCount('variantes', 0); - $this->assertDatabaseCount('inventories', 0); + $this->assertSoftDeleted('variantes', ['id' => $firstId]); + $this->assertSame(0, CatalogItem::query()->count()); + $this->assertSame(0, Variant::query()->count()); + $this->assertDatabaseCount('inventories', 2); } public function test_it_rejects_duplicate_combinations(): void diff --git a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php index bab3de6..7af9f72 100644 --- a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php @@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; @@ -164,14 +165,16 @@ class MerchandiseControllerTest extends TestCase 'id' => $itemId, 'precio' => 15000, ]); - $this->assertDatabaseCount('variantes', 1); + $this->assertSoftDeleted('variantes', ['id' => $firstId]); + $this->assertSame(1, Variant::query()->count()); $this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$secondId}") ->assertNoContent(); - $this->assertDatabaseMissing('catalog_items', ['id' => $itemId]); - $this->assertDatabaseCount('variantes', 0); - $this->assertDatabaseCount('inventories', 0); + $this->assertSoftDeleted('catalog_items', ['id' => $itemId]); + $this->assertSoftDeleted('variantes', ['id' => $secondId]); + $this->assertSame(0, Variant::query()->count()); + $this->assertDatabaseCount('inventories', 2); } public function test_it_rejects_duplicate_color_and_size_combinations(): void diff --git a/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php b/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php index 4ed4463..394b79c 100644 --- a/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php +++ b/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php @@ -161,8 +161,9 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase $this->assertSame('Entrada', $catalogItem->nombre); $this->assertSame('tracked', $catalogItem->inventory_policy); $this->assertSame(1, $catalogItem->has_tickets); - $this->assertSame('one_per_unit', $catalogItem->ticket_generation_policy); - $this->assertSame($eventDate->validity_time_id, $catalogItem->validity_time_id); + $this->assertDatabaseHas('variant_event_dates', [ + 'event_date_id' => $eventDate->id, + ]); $this->assertDatabaseHas('catalog_items_attachments', [ 'catalog_item_id' => $catalogItem->id, 'variant_id' => null, diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index 78b303d..11b5fa5 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -167,8 +167,7 @@ class NotificationMailServiceTest extends TestCase $ticket = Ticket::query()->create([ 'tenant_code' => $this->tenant->codigo, 'ticket' => fake()->uuid(), - 'name' => 'Entrada general', - 'description' => 'Entrada general', + 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $this->user->id, ]); $service = app(NotificationMailService::class); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index bf22af0..6d1c414 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Sale; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; @@ -97,20 +98,26 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PAID, 'total' => '20000.00', ]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'abono-general', + 'nombre' => 'Abono general', + 'descripcion' => 'Acceso general', + 'precio' => 20000, + 'has_tickets' => true, + ]); $firstTicket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => '11111111-1111-4111-8111-111111111111', - 'name' => 'Abono general', - 'description' => 'Acceso general', 'source_purchase_id' => $purchase->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', - 'name' => 'Abono general', - 'description' => 'Acceso general', 'source_purchase_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, 'used_at' => now()->subMinute(), 'user_id' => $admin->id, ]); diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index f083750..512cf9b 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\ValidityTime; use Database\Seeders\AttributeSeeder; @@ -77,12 +76,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase ->get() ->every(fn (CatalogItem $item): bool => $item->has_tickets), ); - $this->assertTrue( - CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->get() - ->every(fn (CatalogItem $item): bool => $item->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit), - ); $expectedVariantCounts = [ 'camiseta' => 12, diff --git a/tests/Feature/Ticket/ScannerTicketControllerTest.php b/tests/Feature/Ticket/ScannerTicketControllerTest.php index 6054611..8d22d5a 100644 --- a/tests/Feature/Ticket/ScannerTicketControllerTest.php +++ b/tests/Feature/Ticket/ScannerTicketControllerTest.php @@ -322,8 +322,6 @@ class ScannerTicketControllerTest extends TestCase return Ticket::query()->create(array_merge([ 'tenant_code' => $tenant->codigo, 'ticket' => $uuid, - 'name' => 'Entrada general', - 'description' => 'Acceso general', 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $this->ticketOwner->id, ], $attributes)); diff --git a/tests/Feature/Ticket/TicketControllerTest.php b/tests/Feature/Ticket/TicketControllerTest.php index 5653062..588746e 100644 --- a/tests/Feature/Ticket/TicketControllerTest.php +++ b/tests/Feature/Ticket/TicketControllerTest.php @@ -5,10 +5,11 @@ namespace Tests\Feature\Ticket; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\Ticket; -use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Tests\TestCase; @@ -23,35 +24,52 @@ class TicketControllerTest extends TestCase $user = User::factory()->create(); $olderTicket = $this->createTicket($tenant, $user, 'Older ticket'); $newerTicket = $this->createTicket($tenant, $user, 'Newer ticket'); - $validityTimes = collect([ - ValidityTime::query()->create([ - 'type' => ValidityTimeType::FixedWindow, - 'fixed_starts_at' => now()->subHour(), - 'fixed_expires_at' => now()->addHour(), + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'dated-ticket', + 'nombre' => 'Dated ticket', + 'descripcion' => 'Dated ticket', + 'precio' => 10, + 'has_tickets' => true, + ]); + $eventDates = collect([ + EventDate::query()->create([ + 'tenant_code' => $tenant->codigo, + 'date' => now()->format('Y-m-d'), + 'time_start' => now()->subHour()->format('H:i:s'), + 'time_end' => now()->addHour()->format('H:i:s'), ]), - ValidityTime::query()->create([ - 'type' => ValidityTimeType::FixedWindow, - 'fixed_starts_at' => now()->addDay(), - 'fixed_expires_at' => now()->addDays(2), + EventDate::query()->create([ + 'tenant_code' => $tenant->codigo, + 'date' => now()->addDay()->format('Y-m-d'), + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', ]), ]); - $validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void { - $group = $newerTicket->validityGroups()->create(); - $group->validityTimes()->attach($validityTime); - }); + $variant = $catalogItem->variants()->create([ + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->eventDates()->sync($eventDates->pluck('id')); + $newerTicket->update([ + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => $variant->id, + ]); + $expectedName = 'Dated ticket ('.$eventDates + ->pluck('date') + ->map(fn ($date): string => $date->format('d/m/Y')) + ->implode(', ').')'; $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/{$tenant->codigo}/tickets") ->assertOk() ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $newerTicket->id) - ->assertJsonPath('data.0.name', 'Newer ticket') + ->assertJsonPath('data.0.name', $expectedName) ->assertJsonPath('data.0.is_valid', true) ->assertJsonPath('data.0.is_expired', false) ->assertJsonPath('data.0.is_used', false) - ->assertJsonCount(2, 'data.0.validity_times') - ->assertJsonCount(2, 'data.0.validity_groups') - ->assertJsonCount(1, 'data.0.validity_groups.0.validity_times') + ->assertJsonMissingPath('data.0.validity_times') + ->assertJsonMissingPath('data.0.validity_groups') ->assertJsonMissingPath('data.0.validity_time') ->assertJsonPath('data.1.id', $olderTicket->id) ->assertJsonMissingPath('meta') @@ -122,11 +140,19 @@ class TicketControllerTest extends TestCase private function createTicket(Tenant $tenant, User $user, string $name): Ticket { + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => Str::slug($name).'-'.Str::lower(Str::random(8)), + 'nombre' => $name, + 'descripcion' => "Description for {$name}", + 'precio' => 10, + 'has_tickets' => true, + ]); + return Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => (string) Str::uuid(), - 'name' => $name, - 'description' => "Description for {$name}", + 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $user->id, ]); } diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index f2082c6..db34009 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -9,12 +9,12 @@ use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Services\CatalogService; use App\Domains\Event\Models\EventDate; use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Purchase\Models\Purchase; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\ValidityTime; @@ -70,8 +70,14 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame($item->descripcion, $ticket->description); $this->assertSame($item->id, $ticket->source_catalog_item_id); $this->assertNull($ticket->source_variant_id); - $this->assertTrue($ticket->validityGroups->isEmpty()); + $this->assertTrue($ticket->resolvedValidityGroups()->isEmpty()); } + + $this->assertDatabaseHas('tickets', [ + 'id' => $tickets->first()->id, + 'name' => null, + 'description' => null, + ]); } public function test_it_rejects_an_item_without_tickets_enabled(): void @@ -98,6 +104,10 @@ class TicketGeneratorServiceTest extends TestCase 'value' => 'black', 'label' => 'Negro', ]); + $color->options()->create([ + 'value' => 'blue', + 'label' => 'Azul', + ]); $size = Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => 'size', @@ -118,17 +128,33 @@ class TicketGeneratorServiceTest extends TestCase ]); $variant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create()->id, + 'descripcion' => 'Descripción de la variante', ]); $variant->definitions()->createMany([ ['item_attribute_id' => $itemColor->id, 'value' => 'black'], ['item_attribute_id' => $itemSize->id, 'value' => 'xl'], ]); + $colorDefinition = $variant->definitions() + ->where('item_attribute_id', $itemColor->id) + ->sole(); $ticket = $this->service ->generate($item, $this->user, 1, $variant->id) ->sole(); $this->assertSame('Shirt (Negro, XL)', $ticket->name); + $this->assertSame('Descripción de la variante', $ticket->description); + + $item->update([ + 'nombre' => 'Remera', + 'descripcion' => 'Descripción actualizada del producto', + ]); + $colorDefinition->update(['value' => 'blue']); + $variant->update(['descripcion' => 'Descripción actualizada de la variante']); + $ticket = $ticket->fresh(); + + $this->assertSame('Remera (Azul, XL)', $ticket->name); + $this->assertSame('Descripción actualizada de la variante', $ticket->description); } public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void @@ -273,9 +299,9 @@ class TicketGeneratorServiceTest extends TestCase $tickets = $this->service->generate($item, $this->user, 2, $variant->id); $this->assertCount(2, $tickets); - $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1)); + $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->resolvedValidityGroups()->count() === 1)); $this->assertTrue($tickets->every( - fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes + fn ($ticket): bool => $ticket->resolvedValidityGroups()->sole()->validityTimes ->pluck('id') ->sort() ->values() @@ -291,7 +317,7 @@ class TicketGeneratorServiceTest extends TestCase ); } - public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void + public function test_it_generates_one_ticket_per_unit_covering_all_selected_dates(): void { $item = $this->createTicketableItem('multi-date-pass'); $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ @@ -305,61 +331,27 @@ class TicketGeneratorServiceTest extends TestCase ]); $variant->eventDates()->sync($dates->pluck('id')); - $tickets = $this->service->generate($item, $this->user, 1, $variant->id); - $tickets->each->loadMissing('validityGroups.validityTimes'); - - $this->assertCount(2, $tickets); - $this->assertSame( - $dates->pluck('validity_time_id')->all(), - $tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(), - ); - $this->assertSame( - ['2026-08-20 00:00:00', '2026-08-21 00:00:00'], - $tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(), - ); - $this->assertSame( - ['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'], - $tickets->pluck('name')->all(), - ); - $this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all()); - } - - public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void - { - $item = $this->createTicketableItem('multi-date-pass'); - $item->update([ - 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit, - ]); - $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ - 'tenant_code' => $this->tenant->codigo, - 'date' => $date, - 'time_start' => '00:00:00', - 'time_end' => '23:59:59', - ])); - $variant = $item->variants()->create([ - 'inventory_id' => Inventory::query()->create()->id, - ]); - $variant->eventDates()->sync($dates->pluck('id')); - $tickets = $this->service->generate($item, $this->user, 2, $variant->id); - $tickets->each->loadMissing('validityGroups.validityTimes'); - $this->assertCount(2, $tickets); - $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2)); + $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->resolvedValidityGroups()->count() === 2)); $this->assertTrue($tickets->every( - fn ($ticket): bool => $ticket->validityGroups->every( + fn ($ticket): bool => $ticket->resolvedValidityGroups()->every( fn ($group): bool => $group->validityTimes->count() === 1 ) )); $this->assertEqualsCanonicalizing( $dates->pluck('validity_time_id')->all(), - $tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(), + $tickets->flatMap( + fn ($ticket) => $ticket->resolvedValidityGroups() + ->flatMap(fn ($group) => $group->validityTimes) + )->pluck('id')->unique()->all(), ); $this->assertTrue($tickets->every( fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)' )); $this->assertTrue($tickets->every( - fn ($ticket): bool => $ticket->allValidityTimes() + fn ($ticket): bool => $ticket->resolvedValidityGroups() + ->flatMap(fn ($group) => $group->validityTimes) ->map(fn (ValidityTime $validityTime): array => [ $validityTime->fixed_starts_at->format('Y-m-d H:i:s'), $validityTime->fixed_expires_at->format('Y-m-d H:i:s'), @@ -371,10 +363,36 @@ class TicketGeneratorServiceTest extends TestCase )); } + public function test_ticket_validity_is_resolved_from_soft_deleted_catalog_sources(): void + { + $item = $this->createTicketableItem('archived-ticket-source'); + $eventDate = EventDate::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'date' => '2026-07-21', + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ]); + $variant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->eventDates()->attach($eventDate); + $ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole(); + + app(CatalogService::class)->delete($item); + $ticket = $ticket->fresh(); + + $this->assertTrue($ticket->sourceCatalogItem->trashed()); + $this->assertTrue($ticket->sourceVariant->trashed()); + $this->assertTrue($ticket->isValid()); + $this->assertSame( + '2026-07-21 23:59:59', + $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'), + ); + } + public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void { $item = $this->createTicketableItem('multi-date-lunch'); - $item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]); $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ 'tenant_code' => $this->tenant->codigo, 'date' => $date, @@ -408,16 +426,14 @@ class TicketGeneratorServiceTest extends TestCase ]); $ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole(); - $ticket->loadMissing('validityGroups.validityTimes'); - - $this->assertCount(2, $ticket->validityGroups); - $this->assertTrue($ticket->validityGroups->every( + $this->assertCount(2, $ticket->resolvedValidityGroups()); + $this->assertTrue($ticket->resolvedValidityGroups()->every( fn ($group): bool => $group->validityTimes->count() === 2 && $group->validityTimes->contains($lunch) )); $this->assertEqualsCanonicalizing( $dates->pluck('validity_time_id')->all(), - $ticket->validityGroups + $ticket->resolvedValidityGroups() ->flatMap->validityTimes ->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch)) ->pluck('id') diff --git a/tests/Feature/Ticket/TicketValiditySchemaTest.php b/tests/Feature/Ticket/TicketValiditySchemaTest.php index c327a72..f10fc30 100644 --- a/tests/Feature/Ticket/TicketValiditySchemaTest.php +++ b/tests/Feature/Ticket/TicketValiditySchemaTest.php @@ -25,25 +25,20 @@ class TicketValiditySchemaTest extends TestCase $this->assertFalse(Schema::hasColumn('tickets', 'validity_time_id')); $this->assertFalse(Schema::hasTable('ticket_validity_times')); - $this->assertEqualsCanonicalizing([ - 'id', - 'ticket_id', - ], Schema::getColumnListing('ticket_validity_groups')); - $this->assertEqualsCanonicalizing([ - 'ticket_validity_group_id', - 'validity_time_id', - ], Schema::getColumnListing('ticket_validity_group_times')); + $this->assertFalse(Schema::hasTable('ticket_validity_groups')); + $this->assertFalse(Schema::hasTable('ticket_validity_group_times')); $this->assertFalse(Schema::hasColumn('tickets', 'service_date')); $this->assertFalse(Schema::hasColumn('tickets', 'starts_at')); $this->assertFalse(Schema::hasColumn('tickets', 'expires_at')); - $this->assertTrue(Schema::hasColumn('catalog_items', 'validity_time_id')); + $this->assertFalse(Schema::hasColumn('catalog_items', 'validity_time_id')); $this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date')); $this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date')); + $this->assertTrue(Schema::hasColumn('catalog_items', 'deleted_at')); $this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id')); $this->assertTrue(Schema::hasColumn('event_dates', 'validity_time_id')); $this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date')); $this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date')); - + $this->assertTrue(Schema::hasColumn('variantes', 'deleted_at')); } } diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index 7163ec8..4f4c099 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -8,9 +8,10 @@ use App\Domains\Catalog\Models\Variant; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\Ticket; -use App\Domains\Ticket\Models\TicketValidityGroup; use App\Domains\Ticket\Models\ValidityTime; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use App\Domains\Ticket\Services\ResolvedTicketValidity; +use App\Domains\Ticket\Services\ResolvedValidityGroup; +use App\Domains\Ticket\Services\TicketValidityResolver; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -46,8 +47,6 @@ class TicketTest extends TestCase $this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated()); $this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated()); - $this->assertInstanceOf(TicketValidityGroup::class, $ticket->validityGroups()->getRelated()); - $this->assertInstanceOf(ValidityTime::class, (new TicketValidityGroup)->validityTimes()->getRelated()); } public function test_unused_ticket_without_validity_time_is_valid(): void @@ -190,13 +189,23 @@ class TicketTest extends TestCase private function ticketWithValidityGroups(array $validityGroups): Ticket { $ticket = new Ticket; - $groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup { - $group = new TicketValidityGroup; - $group->setRelation('validityTimes', new EloquentCollection($validityTimes)); + $resolved = new ResolvedTicketValidity( + collect($validityGroups)->map( + fn (array $validityTimes): ResolvedValidityGroup => new ResolvedValidityGroup(collect($validityTimes)) + ) + ); + $this->app->instance( + TicketValidityResolver::class, + new class($resolved) extends TicketValidityResolver + { + public function __construct(private readonly ResolvedTicketValidity $resolved) {} - return $group; - }); - $ticket->setRelation('validityGroups', new EloquentCollection($groups->all())); + public function resolveTicket(Ticket $ticket): ResolvedTicketValidity + { + return $this->resolved; + } + } + ); return $ticket; } diff --git a/tests/Unit/Ticket/TicketValidityResolverTest.php b/tests/Unit/Ticket/TicketValidityResolverTest.php new file mode 100644 index 0000000..2401523 --- /dev/null +++ b/tests/Unit/Ticket/TicketValidityResolverTest.php @@ -0,0 +1,209 @@ +resolver = new TicketValidityResolver; + } + + public function test_event_dates_and_multi_select_options_expand_to_or_groups(): void + { + $dates = new EloquentCollection([ + $this->eventDate('2026-08-20'), + $this->eventDate('2026-08-21'), + ]); + [$itemAttribute, $definitions] = $this->attributeDimension( + allowMultiSelect: true, + options: [ + 'Desayuno' => $this->timeWindow('07:00:00', '12:00:00'), + 'Cena' => $this->timeWindow('20:00:00', '24:00:00'), + ], + ); + $variant = $this->variant($dates, $definitions); + + $validity = $this->resolver->resolveVariant($variant); + + $this->assertTrue($validity->isResolvable); + $this->assertFalse($validity->isUnrestricted); + $this->assertCount(4, $validity->groups); + $this->assertTrue($validity->groups->every( + fn ($group): bool => $group->validityTimes->count() === 2 + )); + $this->assertSame($itemAttribute, $definitions->first()->itemAttribute); + } + + public function test_different_temporal_attributes_are_anded_in_the_same_group(): void + { + [, $scheduleDefinitions] = $this->attributeDimension( + allowMultiSelect: false, + options: ['Almuerzo' => $this->timeWindow('12:00:00', '15:00:00')], + ); + [, $admissionDefinitions] = $this->attributeDimension( + allowMultiSelect: false, + options: ['Ingreso' => $this->timeWindow('11:00:00', '14:00:00')], + ); + $variant = $this->variant( + new EloquentCollection([$this->eventDate('2026-08-20')]), + new EloquentCollection([ + ...$scheduleDefinitions, + ...$admissionDefinitions, + ]), + ); + + $validity = $this->resolver->resolveVariant($variant); + + $this->assertCount(1, $validity->groups); + $this->assertCount(3, $validity->groups->sole()->validityTimes); + $this->assertSame( + '2026-08-20 12:00:00', + $validity->effectiveStartsAt(Carbon::parse('2026-08-20 13:00:00'))->format('Y-m-d H:i:s'), + ); + $this->assertSame( + '2026-08-20 14:00:00', + $validity->effectiveExpiresAt(Carbon::parse('2026-08-20 13:00:00'))->format('Y-m-d H:i:s'), + ); + $this->assertTrue($validity->isValid(Carbon::parse('2026-08-20 13:00:00'))); + $this->assertFalse($validity->isValid(Carbon::parse('2026-08-20 14:00:00'))); + } + + public function test_variant_without_temporal_dimensions_is_unrestricted(): void + { + $validity = $this->resolver->resolveVariant( + $this->variant(new EloquentCollection, new EloquentCollection) + ); + + $this->assertTrue($validity->isResolvable); + $this->assertTrue($validity->isUnrestricted); + $this->assertTrue($validity->isValid()); + $this->assertFalse($validity->isExpired()); + } + + public function test_non_option_attributes_do_not_affect_validity(): void + { + $attribute = new Attribute(['type' => FieldType::String]); + $attribute->setRelation('options', new EloquentCollection); + $itemAttribute = new ItemAttribute(['allow_multi_select' => false]); + $itemAttribute->setRelation('attribute', $attribute); + $definition = new VariantDefinition(['value' => 'Comedor principal']); + $definition->setAttribute('item_attribute_id', 123); + $definition->setRelation('itemAttribute', $itemAttribute); + + $validity = $this->resolver->resolveVariant( + $this->variant(new EloquentCollection, new EloquentCollection([$definition])) + ); + + $this->assertTrue($validity->isResolvable); + $this->assertTrue($validity->isUnrestricted); + } + + public function test_multiple_values_for_a_single_select_attribute_are_unresolvable(): void + { + [, $definitions] = $this->attributeDimension( + allowMultiSelect: false, + options: [ + 'Desayuno' => $this->timeWindow('07:00:00', '12:00:00'), + 'Cena' => $this->timeWindow('20:00:00', '24:00:00'), + ], + ); + + $validity = $this->resolver->resolveVariant( + $this->variant(new EloquentCollection, $definitions) + ); + + $this->assertFalse($validity->isResolvable); + $this->assertFalse($validity->isValid()); + } + + private function eventDate(string $date): EventDate + { + $validityTime = new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => "{$date} 00:00:00", + 'fixed_expires_at' => "{$date} 23:59:59", + ]); + $eventDate = new EventDate([ + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ]); + $eventDate->setRelation('validityTime', $validityTime); + + return $eventDate; + } + + private function timeWindow(string $start, string $end): ValidityTime + { + return new ValidityTime([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => $start, + 'end_time' => $end, + ]); + } + + /** + * @param array $options + * @return array{ItemAttribute, EloquentCollection} + */ + private function attributeDimension(bool $allowMultiSelect, array $options): array + { + $attribute = new Attribute([ + 'type' => FieldType::Select, + ]); + $attributeOptions = collect($options)->map( + function (?ValidityTime $validityTime, string $value): AttributeOption { + $option = new AttributeOption(['value' => $value, 'label' => $value]); + $option->setRelation('validityTime', $validityTime); + + return $option; + } + ); + $attribute->setRelation('options', new EloquentCollection($attributeOptions)); + + $itemAttribute = new ItemAttribute(['allow_multi_select' => $allowMultiSelect]); + $itemAttribute->setRelation('attribute', $attribute); + $definitions = $attributeOptions->map(function (AttributeOption $option) use ($itemAttribute): VariantDefinition { + $definition = new VariantDefinition(['value' => $option->value]); + $definition->setAttribute('item_attribute_id', spl_object_id($itemAttribute)); + $definition->setRelation('itemAttribute', $itemAttribute); + + return $definition; + }); + + return [$itemAttribute, new EloquentCollection($definitions)]; + } + + /** + * @param EloquentCollection $eventDates + * @param EloquentCollection $definitions + */ + private function variant(EloquentCollection $eventDates, EloquentCollection $definitions): Variant + { + $variant = new Variant; + $variant->setRelation('eventDates', $eventDates); + $variant->setRelation('eventDate', null); + $variant->setRelation('definitions', $definitions); + + return $variant; + } +} diff --git a/tests/Unit/Ticket/ValidityTimeTest.php b/tests/Unit/Ticket/ValidityTimeTest.php index f5d2a25..41766e0 100644 --- a/tests/Unit/Ticket/ValidityTimeTest.php +++ b/tests/Unit/Ticket/ValidityTimeTest.php @@ -3,9 +3,7 @@ namespace Tests\Unit\Ticket; use App\Domains\Catalog\Models\AttributeOption; -use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Ticket\Enums\ValidityTimeType; -use App\Domains\Ticket\Models\TicketValidityGroup; use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -24,12 +22,7 @@ class ValidityTimeTest extends TestCase $this->assertSame(ValidityTimeType::FixedWindow, $validityTime->type); $this->assertInstanceOf(Carbon::class, $validityTime->fixed_starts_at); $this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at); - $this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated()); $this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated()); - $this->assertInstanceOf( - TicketValidityGroup::class, - $validityTime->ticketValidityGroups()->getRelated(), - ); } public function test_it_exposes_supported_type_values(): void