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

@@ -1,15 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum EventProductType: string
{
case Entry = 'entrada';
case Product = 'producto';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -51,4 +52,14 @@ class Attribute extends Model
{
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<EventDate, $this>
*/
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->orderBy('date')
->orderBy('time_start');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'validity_time_id',
'value',
'label',
'sort_order',
@@ -26,6 +28,7 @@ class AttributeOption extends Model
protected function casts(): array
{
return [
'validity_time_id' => 'integer',
'sort_order' => 'integer',
'metadata' => 'array',
];
@@ -38,4 +41,10 @@ class AttributeOption extends Model
{
return $this->belongsTo(Attribute::class, 'attribute_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
}

View File

@@ -4,24 +4,23 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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\Support\Collection;
#[Fillable([
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -31,9 +30,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'descripcion',
'precio',
'inventory_policy',
'max_units_per_user',
'has_tickets',
'maximum_use_date',
'minimum_use_date',
'ticket_generation_policy',
'validity_time_id',
])]
class CatalogItem extends Model
{
@@ -47,6 +47,7 @@ class CatalogItem extends Model
'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
];
protected function casts(): array
@@ -55,14 +56,13 @@ class CatalogItem extends Model
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'event_id' => 'integer',
'event_product_type' => EventProductType::class,
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'max_units_per_user' => 'integer',
'has_tickets' => 'boolean',
'maximum_use_date' => 'datetime',
'minimum_use_date' => 'datetime',
'ticket_generation_policy' => TicketGenerationPolicy::class,
'validity_time_id' => 'integer',
];
}
@@ -72,12 +72,6 @@ class CatalogItem extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
@@ -120,6 +114,12 @@ class CatalogItem extends Model
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{
@@ -173,6 +173,31 @@ class CatalogItem extends Model
return ($this->availableStock() ?? 0) > 0;
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereDoesntHave('variants')
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
}
/** @return Collection<int, Variant> */
public function visibleVariants(?int $includedVariantId = null): Collection
{
return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
->values();
}
public function getPrice(): float
{
return (float) $this->precio;
@@ -183,14 +208,9 @@ class CatalogItem extends Model
return $this->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
public function getDescription(): ?string
{
return $this->minimum_use_date;
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date;
return $this->descripcion;
}
public function isBundle(): bool

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'attribute_id',
'allow_multi_select',
'sort_order',
])]
class ItemAttribute extends Model
{
@@ -18,6 +20,16 @@ class ItemAttribute extends Model
protected $table = 'item_attributes';
protected function casts(): array
{
return [
'catalog_item_id' => 'integer',
'attribute_id' => 'integer',
'allow_multi_select' => 'boolean',
'sort_order' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{

View File

@@ -5,20 +5,20 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
#[Fillable([
'catalog_item_id',
'event_date_id',
'inventory_id',
'minimum_use_date',
'maximum_use_date',
'descripcion',
'precio',
])]
class Variant extends Model
{
@@ -34,8 +34,7 @@ class Variant extends Model
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
'precio' => 'decimal:2',
];
}
@@ -51,6 +50,17 @@ class Variant extends Model
return $this->belongsTo(EventDate::class);
}
/** @return BelongsToMany<EventDate, $this> */
public function eventDates(): BelongsToMany
{
return $this->belongsToMany(
EventDate::class,
'variant_event_dates',
'variant_id',
'event_date_id',
)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
@@ -90,7 +100,12 @@ class Variant extends Model
public function getPrice(): float
{
return $this->catalogItem->getPrice();
return (float) ($this->precio ?? $this->catalogItem->precio);
}
public function getDescription(): ?string
{
return $this->descripcion ?? $this->catalogItem->descripcion;
}
public function getName(): string
@@ -98,17 +113,132 @@ class Variant extends Model
return $this->catalogItem->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
/** @return Collection<string, string|array<int, string>> */
public function selectionValues(): Collection
{
return $this->eventDate?->startsAt()
?? $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
$values = $this->definitions
->groupBy('item_attribute_id')
->mapWithKeys(function (Collection $definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$attributeCode = $itemAttribute?->attribute?->codigo;
if ($attributeCode === null) {
return [];
}
$definitionValues = $definitions->pluck('value')->values();
return [
$attributeCode => $itemAttribute->allow_multi_select
? $definitionValues->all()
: $definitionValues->first(),
];
});
$eventDateIds = $this->selectedEventDates()
->pluck('id')
->map(fn ($id): string => (string) $id)
->values();
if ($eventDateIds->count() === 1) {
$values->put('event_date', $eventDateIds->first());
} elseif ($eventDateIds->isNotEmpty()) {
$values->put('event_date', $eventDateIds->all());
}
return $values;
}
public function getMaximumUseDate(): ?CarbonInterface
/**
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
*/
public function selectionOptions(?Collection $itemAttributes = null): Collection
{
return $this->eventDate?->endsAt()
?? $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
$options = $this->definitions
->groupBy('item_attribute_id')
->mapWithKeys(function (Collection $definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$attribute = $itemAttribute?->attribute;
$attributeCode = $attribute?->codigo;
if ($attributeCode === null) {
return [];
}
$values = $definitions
->pluck('value')
->values()
->map(function (string $value) use ($attribute): array {
$attributeOption = $attribute->options->firstWhere('value', $value);
return [
'value' => $value,
'label' => $attributeOption?->label ?? $value,
];
});
return [
$attributeCode => $itemAttribute->allow_multi_select
? $values->all()
: $values->first(),
];
});
$eventDateOptions = $this->selectedEventDates()
->map(fn (EventDate $eventDate): array => [
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'),
])
->values();
if ($eventDateOptions->count() === 1) {
$options->put('event_date', $eventDateOptions->first());
} elseif ($eventDateOptions->isNotEmpty()) {
$options->put('event_date', $eventDateOptions->all());
}
if ($itemAttributes === null) {
$itemAttributes = $this->definitions
->map(fn (VariantDefinition $definition) => $definition->itemAttribute)
->filter()
->unique('id')
->values();
if ($this->catalogItem !== null) {
$itemAttributes = $itemAttributes
->merge($this->catalogItem->itemAttributes)
->unique('id')
->values();
}
}
$ordering = $itemAttributes->mapWithKeys(function (ItemAttribute $itemAttribute): array {
$attribute = $itemAttribute->attribute;
return $attribute === null
? []
: [$attribute->codigo => [$itemAttribute->sort_order, mb_strtolower($attribute->nombre)]];
});
return $options->sortKeysUsing(function (string $left, string $right) use ($ordering): int {
[$leftOrder, $leftLabel] = $ordering->get($left, [0, mb_strtolower($left)]);
[$rightOrder, $rightLabel] = $ordering->get($right, [0, mb_strtolower($right)]);
return $leftOrder <=> $rightOrder
?: $leftLabel <=> $rightLabel
?: $left <=> $right;
});
}
/** @return Collection<int, EventDate> */
public function selectedEventDates(): Collection
{
$eventDates = $this->eventDates;
if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) {
return collect([$this->eventDate]);
}
return $eventDates;
}
}

View File

@@ -3,9 +3,9 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -26,20 +26,6 @@ class StoreCatalogItemRequest extends FormRequest
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'event_id' => [
'sometimes',
'nullable',
'required_with:event_product_type',
Rule::exists('events', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'event_product_type' => [
'sometimes',
'nullable',
'required_with:event_id',
Rule::enum(EventProductType::class),
],
'category_id' => [
'sometimes',
'nullable',
@@ -68,9 +54,10 @@ class StoreCatalogItemRequest extends FormRequest
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
'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'],
@@ -84,30 +71,44 @@ class StoreCatalogItemRequest extends FormRequest
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'multi_select_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.descripcion' => ['sometimes', 'nullable', 'string'],
'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('event_id', $this->input('event_id'))
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'],
'variants.*.event_date_ids.*' => [
'required',
'integer',
'distinct',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
'variants.*.maximum_use_date' => [
'sometimes',
'nullable',
'date',
'after_or_equal:variants.*.minimum_use_date',
],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable', 'string'],
'variants.*.values.*' => ['nullable'],
'variants.*.values.*.*' => ['required', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [

View File

@@ -7,6 +7,7 @@ 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;
@@ -30,20 +31,23 @@ 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->variants
'variants' => $catalogItem->visibleVariants()
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $variant->selectionOptions($catalogItem->itemAttributes),
])
->values(),
];
@@ -62,6 +66,9 @@ 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' => $attachment?->getTemporaryUrl(1440),
];
}

View File

@@ -6,6 +6,8 @@ use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
@@ -22,8 +24,6 @@ class CatalogItemDetailResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -33,12 +33,14 @@ class CatalogItemDetailResource extends JsonResource
'category' => $this->category?->nombre,
'brand' => $this->brand?->nombre,
'inventory_policy' => $this->inventory_policy?->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'attributes' => $this->itemAttributes
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values(),
'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,
fn () => $this->availableStock(),
@@ -47,7 +49,7 @@ class CatalogItemDetailResource extends JsonResource
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->variants
'variants' => $this->visibleVariants()
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
'selected_variant' => $this->when(
@@ -80,6 +82,44 @@ class CatalogItemDetailResource extends JsonResource
private function attributeData(ItemAttribute $itemAttribute): array
{
$attribute = $itemAttribute->attribute;
return [
'id' => $attribute->id,
'codigo' => $attribute->codigo,
'nombre' => $attribute->nombre,
'sort_order' => $itemAttribute->sort_order,
'is_required' => $attribute->is_required,
'allow_multi_select' => $itemAttribute->allow_multi_select,
'metadata_schema' => $attribute->metadata_schema,
'type' => $attribute->type->value,
'options' => $attribute->type === FieldType::EventDate
? $this->eventDateOptions($itemAttribute)
: $this->catalogAttributeOptions($itemAttribute),
];
}
/** @return Collection<int, array<string, mixed>> */
private function eventDateOptions(ItemAttribute $itemAttribute): Collection
{
return $itemAttribute->attribute->eventDates
->map(fn ($eventDate, int $index): array => [
'id' => $eventDate->id,
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'),
'sort_order' => $index,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'metadata' => [
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
],
]);
}
/** @return Collection<int, array<string, mixed>> */
private function catalogAttributeOptions(ItemAttribute $itemAttribute): Collection
{
$availableValues = $this->variants
->flatMap->definitions
->where('item_attribute_id', $itemAttribute->id)
@@ -87,47 +127,50 @@ class CatalogItemDetailResource extends JsonResource
->filter()
->unique();
return [
'id' => $attribute->id,
'codigo' => $attribute->codigo,
'nombre' => $attribute->nombre,
'is_required' => $attribute->is_required,
'metadata_schema' => $attribute->metadata_schema,
'type' => $attribute->type->value,
'options' => $attribute->options
->whereIn('value', $availableValues)
->map(fn ($option): array => [
'id' => $option->id,
'value' => $option->value,
'label' => $option->label,
'sort_order' => $option->sort_order,
'metadata' => $option->metadata,
])
->values(),
];
return $itemAttribute->attribute->options
->whereIn('value', $availableValues)
->map(fn ($option): array => [
'id' => $option->id,
'value' => $option->value,
'label' => $option->label,
'sort_order' => $option->sort_order,
'validity_time_id' => $option->validity_time_id,
'validity_time' => $option->validityTime === null
? null
: ValidityTimeResource::make($option->validityTime),
'metadata' => $option->metadata,
])
->values();
}
/** @return Collection<int, array<string, mixed>> */
private function attributesData(): Collection
{
return $this->itemAttributes
->sort(function (ItemAttribute $left, ItemAttribute $right): int {
return $left->sort_order <=> $right->sort_order
?: mb_strtolower($left->attribute->nombre) <=> mb_strtolower($right->attribute->nombre);
})
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values();
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $eventDates->pluck('id')->values(),
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->variantStock($variant),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $values,
];
}

View File

@@ -3,6 +3,7 @@
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;
@@ -15,8 +16,6 @@ class CatalogItemResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -24,9 +23,14 @@ class CatalogItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'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))
@@ -36,20 +40,12 @@ class CatalogItemResource extends JsonResource
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key) => $key !== null),
'values' => $variant->selectionOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),

View File

@@ -5,6 +5,7 @@ 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;
@@ -25,21 +26,24 @@ 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->variants
'variants' => $this->visibleVariants()
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $variant->selectionOptions($this->itemAttributes),
])
->values(),
];

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)));
}
/**

View File

@@ -37,13 +37,17 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereVariantsAvailable()
->with([
'inventory',
'attachments',
'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',
]);

View File

@@ -0,0 +1,33 @@
# Dominio Catalog
## Propósito
Modela y publica la oferta comercial del tenant: productos, variantes, categorías, marcas, atributos, inventario, bundles y grupos destacados.
## Modelo
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
## Servicios
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
## Endpoints de tienda
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
## Endpoints administrativos
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.