Add tests for ticket validity and event date formatting

- Create TicketValiditySchemaTest to verify database schema for ticket validity.
- Update CatalogModelsTest to include tests for event date attributes and selection options.
- Introduce EventDateTextFormatterTest for formatting event dates in Spanish.
- Refactor EventModelsTest to include validity time relationships.
- Add SaleDetailResourceTest to ensure correct serialization of purchase items.
- Enhance TicketTest with validity time checks and status management.
- Implement ValidityTimeResourceTest to validate resource output for different validity types.
- Add ValidityTimeTest to verify casting and validity checks for validity time types.
This commit is contained in:
2026-08-11 12:41:35 -03:00
parent b294e5c46e
commit 02cf3f3773
166 changed files with 10203 additions and 1849 deletions

View File

@@ -5,14 +5,12 @@ namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
@@ -38,16 +36,35 @@ class CatalogService
$variants = $data['variants'] ?? [];
$images = $data['images'] ?? [];
$attributeCodes = $data['attribute_codes'] ?? [];
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
$components = $data['components'] ?? [];
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
fn (array $variant): bool => ! empty($variant['event_date_id'])
|| ! empty($variant['event_date_ids'])
);
if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) {
throw ValidationException::withMessages([
'attribute_codes' => [
__('api.catalog.event_date_attribute_required'),
],
]);
}
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
$this->validateEventData($data);
if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) {
throw ValidationException::withMessages([
'multi_select_attribute_codes' => [
__('api.catalog.multi_select_attribute_not_on_item'),
],
]);
}
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
@@ -70,6 +87,7 @@ class CatalogService
$data['variants'],
$data['images'],
$data['attribute_codes'],
$data['multi_select_attribute_codes'],
$data['components'],
$data['real_stock'],
$data['reserved_stock'],
@@ -83,8 +101,6 @@ class CatalogService
$data['inventory_id'] = null;
$data['inventory_policy'] = null;
$data['has_tickets'] = false;
$data['minimum_use_date'] = null;
$data['maximum_use_date'] = null;
} elseif ($hasVariants) {
$data['inventory_id'] = null;
} else {
@@ -93,7 +109,7 @@ class CatalogService
$catalogItem = CatalogItem::query()->create($data);
$itemAttributes = $type === CatalogItemType::Standard
? $this->createItemAttributes($catalogItem, $attributeCodes)
? $this->createItemAttributes($catalogItem, $attributeCodes, $multiSelectAttributeCodes)
: [];
if ($type === CatalogItemType::Bundle) {
@@ -129,12 +145,13 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
@@ -148,22 +165,25 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'itemAttributes.attribute.options',
'validityTime',
'itemAttributes.attribute.options.validityTime',
'itemAttributes.attribute.eventDates.validityTime',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem.inventory',
'bundleComponents.variant.inventory',
'bundleComponents.variant.definitions.itemAttribute.attribute',
]);
$visibleVariants = $catalogItem->visibleVariants();
$selectedVariant = $variantId === null
? $catalogItem->variants->first()
: $catalogItem->variants->firstWhere('id', $variantId);
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
@@ -187,6 +207,7 @@ class CatalogService
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereVariantsAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -205,10 +226,13 @@ class CatalogService
->with([
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
@@ -234,13 +258,17 @@ class CatalogService
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereVariantsAvailable()
->with([
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
@@ -280,6 +308,39 @@ class CatalogService
});
}
public function deleteVariant(Variant $variant): void
{
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) {
$this->delete($catalogItem);
} else {
$catalogItem->update(['precio' => $minimumPrice]);
}
foreach ($attachments as $attachment) {
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
$this->attachmentService->delete($attachment);
}
}
});
}
private function createInventory(int $realStock): Inventory
{
return Inventory::query()->create([
@@ -381,8 +442,8 @@ class CatalogService
'attribute_codes',
'variants',
'has_tickets',
'minimum_use_date',
'maximum_use_date',
'ticket_generation_policy',
'validity_time_id',
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
@@ -426,11 +487,13 @@ class CatalogService
/**
* @param array<int, string> $attributeCodes
* @param array<int, string> $multiSelectAttributeCodes
* @return array<string, ItemAttribute>
*/
private function createItemAttributes(
CatalogItem $catalogItem,
array $attributeCodes,
array $multiSelectAttributeCodes = [],
): array {
$itemAttributes = [];
$attributeCodes = array_values(array_unique($attributeCodes));
@@ -453,6 +516,7 @@ class CatalogService
$itemAttribute = $catalogItem->itemAttributes()->create([
'attribute_id' => $attribute->id,
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
]);
$itemAttributes[$attributeCode] = $itemAttribute;
@@ -486,35 +550,53 @@ class CatalogService
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$eventDateId = $data['event_date_id'] ?? null;
if (
$eventDateId !== null
&& (
$catalogItem->event_id === null
|| ! EventDate::query()
->whereKey($eventDateId)
->where('event_id', $catalogItem->event_id)
->exists()
$eventDateIds = collect($data['event_date_ids'] ?? [])
->when(
isset($data['event_date_id']),
fn ($ids) => $ids->push($data['event_date_id']),
)
) {
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$eventDateItemAttribute = $itemAttributes['event_date'] ?? null;
if ($eventDateItemAttribute !== null && (
$eventDateIds->isEmpty()
|| (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1)
)) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'The event date must belong to the catalog item event.',
"variants.{$index}.event_date_ids" => [
$eventDateItemAttribute->allow_multi_select
? __('api.catalog.event_date_selection_required')
: __('api.catalog.single_event_date_required'),
],
]);
}
$validEventDateCount = EventDate::query()
->whereKey($eventDateIds)
->where('tenant_code', $catalogItem->tenant_code)
->count();
if ($validEventDateCount !== $eventDateIds->count()) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_ids" => [
__('api.catalog.event_date_wrong_tenant'),
],
]);
}
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'event_date_id' => $eventDateId,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
'descripcion' => $data['descripcion'] ?? null,
'precio' => $data['precio'] ?? null,
]);
$variant->eventDates()->sync($eventDateIds->all());
$variant->setRelation('catalogItem', $catalogItem);
$this->validateVariantUseDates($variant, $index);
foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
@@ -526,63 +608,130 @@ class CatalogService
]);
}
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $value,
]);
foreach ($this->validatedVariantValues(
$itemAttribute,
$value,
"variants.{$index}.values.{$attributeCode}",
) as $validatedValue) {
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $validatedValue,
]);
}
}
return $variant;
}
/** @param array<string, mixed> $data */
private function validateEventData(array $data): void
/**
* @param array<int, array<string, mixed>> $variants
* @param array<int, string> $attributeCodes
*/
private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void
{
$eventId = $data['event_id'] ?? null;
$eventProductType = $data['event_product_type'] ?? null;
$seen = [];
$attributeCodes = array_values(array_unique($attributeCodes));
sort($attributeCodes);
if (($eventId === null) !== ($eventProductType === null)) {
throw ValidationException::withMessages([
'event_id' => ['Event and event product type must be provided together.'],
]);
}
foreach (array_values($variants) as $index => $variant) {
$eventDateIds = collect($variant['event_date_ids'] ?? [])
->when(
isset($variant['event_date_id']),
fn ($ids) => $ids->push($variant['event_date_id']),
)
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->implode(',');
$combination = [$eventDateIds];
if ($eventId === null) {
return;
}
foreach ($attributeCodes as $attributeCode) {
$values = $variant['values'][$attributeCode] ?? '';
$normalizedValues = collect(is_array($values) ? $values : [$values])
->map(fn ($value): string => $this->normalizeVariantValue((string) $value))
->unique()
->sort()
->values()
->implode(',');
$combination[] = $normalizedValues;
}
if (! Event::query()
->whereKey($eventId)
->where('tenant_code', $data['tenant_code'] ?? null)
->exists()) {
throw ValidationException::withMessages([
'event_id' => ['The event must belong to the catalog item tenant.'],
]);
}
$key = implode('|', $combination);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"variants.{$index}" => [__('api.catalog.duplicate_variant_combination')],
]);
}
if (! in_array($eventProductType, EventProductType::values(), true)) {
throw ValidationException::withMessages([
'event_product_type' => ['The event product type is invalid.'],
]);
$seen[$key] = true;
}
}
private function validateVariantUseDates(Variant $variant, int $index): void
{
$minimumUseDate = $variant->getMinimumUseDate();
$maximumUseDate = $variant->getMaximumUseDate();
/** @return array<int, string> */
private function validatedVariantValues(
ItemAttribute $itemAttribute,
mixed $value,
string $validationKey,
): array {
$values = is_array($value) ? array_values($value) : [$value];
if (
$minimumUseDate !== null
&& $maximumUseDate !== null
&& $maximumUseDate->lessThan($minimumUseDate)
) {
if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) {
throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [
__('api.catalog.invalid_effective_date_range'),
$validationKey => [
$itemAttribute->allow_multi_select
? __('api.catalog.multi_value_required')
: __('api.catalog.single_value_required'),
],
]);
}
if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.selected_values_non_empty')],
]);
}
$normalizedValues = collect($values)
->map(fn (string $item): string => $this->normalizeVariantValue($item));
if ($normalizedValues->unique()->count() !== count($values)) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.selected_values_distinct')],
]);
}
$attribute = $itemAttribute->attribute;
if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) {
$optionsByNormalizedValue = $attribute->options
->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value));
$resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue));
if ($resolvedOptions->contains(null)) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.invalid_attribute_options')],
]);
}
$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();
}
return collect($values)->map(fn (string $item): string => trim($item))->all();
}
private function normalizeVariantValue(string $value): string
{
return Str::ascii(mb_strtolower(trim($value)));
}
/**