Compare commits

...

17 Commits

Author SHA1 Message Date
5197862a62 feat(ticket): implement ticket generation policies and validity time management
- Added `ticket_generation_policy` column to `catalog_items` table to manage ticket generation strategies.
- Created `ticket_validity_times` table to allow multiple validity times per ticket.
- Introduced `validity_time_id` column in `event_dates` table to associate event dates with validity times.
- Established `ticket_validity_groups` and `ticket_validity_group_times` tables to group validity times for tickets.
- Updated seeder to set default ticket generation policy for specific tenant.
- Enhanced tests to cover new functionality, including ticket generation based on event dates and validity times.
- Refactored ticket validity checks to accommodate multiple validity times in a group.
2026-08-11 12:29:32 -03:00
2115d2384d feat(ticket): add tickets endpoint and resource for sales; implement ticket retrieval logic and tests 2026-08-11 11:20:55 -03:00
a458c564ca feat(sale): implement confirm and cancel functionality for sales in AdminApp; update routes and tests 2026-08-11 11:00:09 -03:00
68dd373d61 feat(tenant): add display options for categories and search bar; update requests, migrations, seeder, and tests 2026-08-11 10:04:18 -03:00
8d8e49fcfb feat(event): implement automatic tenant date text synchronization on save and delete; update related tests 2026-08-11 09:49:59 -03:00
19b506a465 feat(catalog): add scope to filter available variants and update related queries; enhance seeder and tests for stock validation 2026-08-11 09:10:26 -03:00
6f63a87af5 feat(test): add unit test for SaleDetailResource serialization 2026-08-10 17:11:45 -03:00
a59b96dd22 feat(sale): add sale detail retrieval functionality; implement controller method and resource, and create corresponding tests 2026-08-10 17:10:55 -03:00
6354e26620 feat(ticket): append variant properties to ticket name in generation process; add corresponding test case 2026-08-10 17:10:46 -03:00
69320c54d4 feat: remove EventProductType references and related logic from catalog and fiesta futbol infantil components 2026-08-10 16:37:28 -03:00
5bfc8b9f70 feat(seeder): update product layout and group layout for Fiesta Futbol Infantil seeder; adjust related test assertions 2026-08-10 15:05:42 -03:00
17f4cb6502 feat: enable ticketing for Fiesta Futbol Infantil catalog items; update services, migrations, seeders, and tests to reflect changes 2026-08-10 14:13:46 -03:00
6844ab7af9 feat(cart): update CartItemResource to use visibleVariants method with included variant ID; enhance variant filtering logic 2026-08-10 13:59:45 -03:00
8542c03466 feat: enhance item attributes management by adding sort order; update related models, resources, and tests for visibility and ordering of variants 2026-08-10 13:58:35 -03:00
e7c8807a67 feat(variant): refactor selection values to expose options with labels; update related resources and tests for consistency 2026-08-10 12:27:00 -03:00
c6351d706f feat(cart): enhance item update functionality to support variant changes and stock management; update related request validation and resource formatting; add tests for variant handling in cart 2026-08-10 12:08:09 -03:00
2693df2cbf feat: implement delete functionality for accommodations, entries, foods, and merchandise; enhance related services and tests 2026-08-10 11:33:05 -03:00
92 changed files with 2790 additions and 244 deletions

View File

@@ -54,16 +54,26 @@ class CartController extends Controller
Tenant $tenant,
CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateItemQuantity(
$this->cartService->updateItem(
$tenant,
$request,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
)
)->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
}

View File

@@ -124,27 +124,85 @@ class Cart extends Model
});
}
public function updateItem(int $cartItemId, int $quantity): CartItem
{
public function updateItem(
int $cartItemId,
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
): CartItem {
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
return DB::transaction(function () use (
$cartItemId,
$quantity,
$variantId,
$updateVariant,
): CartItem {
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$currentSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
$inventoryService = app(CatalogInventoryService::class);
if ($updateVariant && $variantId !== $item->variant_id) {
$nextSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$variantId,
true,
);
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
);
$inventoryService->release($currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
$targetItem = $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $variantId)
->whereKeyNot($item->getKey())
->lockForUpdate()
->first();
$inventoryService->reserve($nextSelection, $quantity);
if ($targetItem !== null) {
$targetItem->cantidad += $quantity;
$targetItem->save();
$item->delete();
return $targetItem->fresh();
}
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
return $item->fresh();
}
$delta = $quantity - $item->cantidad;
if ($delta > 0) {
@@ -153,12 +211,12 @@ class Cart extends Model
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$selectedItem,
$currentSelection,
$otherVariantsQuantity + $quantity,
);
}
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
@@ -171,11 +229,11 @@ class Cart extends Model
$item->save();
if ($delta > 0) {
$inventoryService->reserve($selectedItem, $delta);
$inventoryService->reserve($currentSelection, $delta);
}
if ($delta < 0) {
$inventoryService->release($selectedItem, abs($delta));
$inventoryService->release($currentSelection, abs($delta));
}
return $item->fresh();

View File

@@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest
return [
'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['prohibited'],
'variant_id' => ['sometimes', 'nullable', 'integer'],
];
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -36,6 +38,16 @@ class CartItemResource extends JsonResource
'product' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'imagen' => $imageUrl,
'variants' => $this->catalogItem->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->getPrice()),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectionOptions($this->catalogItem->itemAttributes),
])
->values(),
],
];
}

View File

@@ -51,11 +51,17 @@ class CartService
];
}
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
{
public function updateItem(
Tenant $tenant,
Request $request,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
return $this->loadCart($cart);
}
@@ -101,9 +107,14 @@ class CartService
return $cart->fresh()->load([
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute',
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
]);

View File

@@ -4,22 +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\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;
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_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -31,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'inventory_policy',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
])]
class CatalogItem extends Model
@@ -45,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
@@ -53,12 +56,12 @@ class CatalogItem extends Model
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_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',
'ticket_generation_policy' => TicketGenerationPolicy::class,
'validity_time_id' => 'integer',
];
}
@@ -170,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;

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'catalog_item_id',
'attribute_id',
'allow_multi_select',
'sort_order',
])]
class ItemAttribute extends Model
{
@@ -25,6 +26,7 @@ class ItemAttribute extends Model
'catalog_item_id' => 'integer',
'attribute_id' => 'integer',
'allow_multi_select' => 'boolean',
'sort_order' => 'integer',
];
}

View File

@@ -149,6 +149,87 @@ class Variant extends Model
return $values;
}
/**
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
*/
public function selectionOptions(?Collection $itemAttributes = null): Collection
{
$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
{

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,11 +26,6 @@ class StoreCatalogItemRequest extends FormRequest
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'event_product_type' => [
'sometimes',
'nullable',
Rule::enum(EventProductType::class),
],
'category_id' => [
'sometimes',
'nullable',
@@ -61,6 +56,7 @@ class StoreCatalogItemRequest extends FormRequest
'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'],
'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'],

View File

@@ -31,10 +31,11 @@ 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,
@@ -46,7 +47,7 @@ class CatalogFeaturedItemResource extends JsonResource
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectionValues(),
'values' => $variant->selectionOptions($catalogItem->itemAttributes),
])
->values(),
];
@@ -65,6 +66,7 @@ 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

@@ -24,7 +24,6 @@ class CatalogItemDetailResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -36,6 +35,7 @@ class CatalogItemDetailResource extends JsonResource
'inventory_policy' => $this->inventory_policy?->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
@@ -49,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(
@@ -87,6 +87,7 @@ class CatalogItemDetailResource extends JsonResource
'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,
@@ -104,12 +105,10 @@ class CatalogItemDetailResource extends JsonResource
->map(fn ($eventDate, int $index): array => [
'id' => $eventDate->id,
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y').' · '
.substr($eventDate->time_start, 0, 5).' a '
.substr($eventDate->time_end, 0, 5),
'label' => $eventDate->date->format('d/m/Y'),
'sort_order' => $index,
'validity_time_id' => null,
'validity_time' => null,
'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,
@@ -148,7 +147,10 @@ class CatalogItemDetailResource extends JsonResource
private function attributesData(): Collection
{
return $this->itemAttributes
->sortBy(fn (ItemAttribute $itemAttribute): int => $itemAttribute->attribute->type === FieldType::EventDate ? 0 : 1)
->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();
}
@@ -156,7 +158,7 @@ class CatalogItemDetailResource extends JsonResource
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->selectionValues();
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
return [

View File

@@ -16,7 +16,6 @@ class CatalogItemResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -26,6 +25,7 @@ class CatalogItemResource extends JsonResource
'inventory_policy' => $this->inventory_policy?->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',
@@ -45,7 +45,7 @@ class CatalogItemResource extends JsonResource
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'values' => $variant->selectionValues(),
'values' => $variant->selectionOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),

View File

@@ -26,11 +26,12 @@ 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,
@@ -42,7 +43,7 @@ class CatalogSearchItemResource extends JsonResource
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'values' => $variant->selectionValues(),
'values' => $variant->selectionOptions($this->itemAttributes),
])
->values(),
];

View File

@@ -5,7 +5,6 @@ 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;
@@ -65,7 +64,6 @@ class CatalogService
]);
}
$this->validateEventProductType($data);
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
if ($type === CatalogItemType::Bundle) {
@@ -153,7 +151,7 @@ class CatalogService
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
@@ -169,22 +167,23 @@ class CatalogService
'brand',
'validityTime',
'itemAttributes.attribute.options.validityTime',
'itemAttributes.attribute.eventDates',
'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.');
@@ -208,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])
@@ -227,11 +227,12 @@ class CatalogService
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
@@ -257,15 +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.eventDates',
'variants.definitions.itemAttribute.attribute',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
@@ -305,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([
@@ -406,6 +442,7 @@ class CatalogService
'attribute_codes',
'variants',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
] as $field) {
if (array_key_exists($field, $data)) {
@@ -586,22 +623,6 @@ class CatalogService
return $variant;
}
/** @param array<string, mixed> $data */
private function validateEventProductType(array $data): void
{
$eventProductType = $data['event_product_type'] ?? null;
if ($eventProductType === null) {
return;
}
if (! in_array($eventProductType, EventProductType::values(), true)) {
throw ValidationException::withMessages([
'event_product_type' => [__('api.catalog.invalid_event_product_type')],
]);
}
}
/**
* @param array<int, array<string, mixed>> $variants
* @param array<int, string> $attributeCodes

View File

@@ -37,15 +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.eventDates',
'variants.definitions.itemAttribute.attribute',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);

View File

@@ -3,7 +3,10 @@
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -25,10 +28,31 @@ class EventDate extends Model
public $timestamps = false;
protected static function booted(): void
{
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
static::created(fn (self $eventDate) => $eventDate->syncTenantDateText());
static::updated(function (self $eventDate): void {
if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) {
$eventDate->syncValidityTime();
}
$eventDate->syncTenantDateText();
});
static::deleted(function (self $eventDate): void {
$eventDate->syncTenantDateText();
ValidityTime::query()
->whereKey($eventDate->validity_time_id)
->whereDoesntHave('ticketValidityGroups')
->delete();
});
}
protected function casts(): array
{
return [
'date' => 'date:Y-m-d',
'validity_time_id' => 'integer',
];
}
@@ -38,6 +62,12 @@ class EventDate extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
@@ -64,4 +94,48 @@ class EventDate extends Model
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
}
private function syncTenantDateText(): void
{
$tenant = $this->tenant()->first();
if (! $tenant) {
return;
}
$tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()->pluck('date')
),
]);
}
private function syncValidityTime(): void
{
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
$expiresAt = $expiresAt->addDay();
}
$attributes = [
'type' => ValidityTimeType::FixedWindow,
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
];
if ($this->validity_time_id === null) {
$validityTime = ValidityTime::query()->create($attributes);
$this->validity_time_id = $validityTime->getKey();
$this->setRelation('validityTime', $validityTime);
return;
}
$this->validityTime()->update($attributes);
$this->unsetRelation('validityTime');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Event\Resources;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -20,6 +21,8 @@ class EventResource extends JsonResource
'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
'start_time' => substr($eventDate->time_start, 0, 5),
'end_time' => substr($eventDate->time_end, 0, 5),

View File

@@ -13,11 +13,9 @@ class EventService
'facebook_url' => 'facebook',
];
public function __construct(protected EventDateTextFormatter $eventDateTextFormatter) {}
public function forTenant(Tenant $tenant): Tenant
{
return $tenant->load(['eventDates', 'socialMedia']);
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
}
/** @param array<string, mixed> $data */
@@ -28,9 +26,6 @@ class EventService
$tenant->update([
'event_title' => $data['title'],
'event_location' => $data['location'],
'event_date_text' => $this->eventDateTextFormatter->format(
array_column($data['dates'], 'date')
),
]);
$this->syncDates($tenant, $data['dates']);
@@ -40,7 +35,7 @@ class EventService
$this->syncLegacyContact($tenant, $data['contact']);
}
return $tenant->load(['eventDates', 'socialMedia']);
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
});
}

View File

@@ -7,6 +7,7 @@ use App\Domains\FiestaFutbolInfantil\Resources\AccommodationResource;
use App\Domains\FiestaFutbolInfantil\Services\AccommodationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class AccommodationController extends Controller
{
@@ -30,4 +31,14 @@ class AccommodationController extends Controller
)
);
}
public function destroy(Request $request, int $accommodation): Response
{
$this->accommodationService->delete(
$request->user()->tenant()->firstOrFail(),
$accommodation,
);
return response()->noContent();
}
}

View File

@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class EntryController extends Controller
{
@@ -34,4 +35,14 @@ class EntryController extends Controller
->response()
->setStatusCode(200);
}
public function destroy(Request $request, int $entry): Response
{
$this->entryService->delete(
$request->user()->tenant()->firstOrFail(),
$entry,
);
return response()->noContent();
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class FoodController extends Controller
{
@@ -30,4 +31,14 @@ class FoodController extends Controller
)
);
}
public function destroy(Request $request, int $food): Response
{
$this->foodService->delete(
$request->user()->tenant()->firstOrFail(),
$food,
);
return response()->noContent();
}
}

View File

@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class MerchandiseController extends Controller
{
@@ -33,4 +34,14 @@ class MerchandiseController extends Controller
->response()
->setStatusCode(200);
}
public function destroy(Request $request, int $merchandise): Response
{
$this->merchandiseService->delete(
$request->user()->tenant()->firstOrFail(),
$merchandise,
);
return response()->noContent();
}
}

View File

@@ -29,7 +29,11 @@ class UpsertEntriesRequest extends FormRequest
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query
->where('tenant_code', $tenantCode)
->where('event_product_type', 'entrada')
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
->select('id')
->from('categorias')
->where('tenant_code', $tenantCode)
->where('nombre', 'Entradas'))
),
],
'entries.*.title' => ['required', 'string', 'max:255'],

View File

@@ -28,7 +28,11 @@ class UpsertMerchandiseRequest extends FormRequest
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query
->where('tenant_code', $tenantCode)
->where('event_product_type', 'producto')
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
->select('id')
->from('categorias')
->where('tenant_code', $tenantCode)
->where('nombre', 'Merchandising'))
),
],
'items.*.title' => ['required', 'string', 'max:255'],

View File

@@ -2,7 +2,6 @@
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
@@ -11,7 +10,9 @@ 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\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;
@@ -20,6 +21,8 @@ class AccommodationService
{
private const ATTRIBUTE_CODE = 'tipo_alojamiento';
public function __construct(private readonly CatalogService $catalogService) {}
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
@@ -86,6 +89,18 @@ class AccommodationService
});
}
public function delete(Tenant $tenant, int $accommodationId): void
{
$variant = Variant::query()
->whereKey($accommodationId)
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->where('slug', 'alojamiento'))
->firstOrFail();
$this->catalogService->deleteVariant($variant);
}
private function attribute(Tenant $tenant): Attribute
{
$attribute = Attribute::query()
@@ -120,9 +135,9 @@ class AccommodationService
if ($accommodation !== null) {
$accommodation->update([
'category_id' => $category->id,
'event_product_type' => EventProductType::Product->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
return $accommodation;
@@ -135,9 +150,9 @@ class AccommodationService
'descripcion' => 'Alojamiento',
'category_id' => $category->id,
'precio' => collect($variants)->min('price') ?? 0,
'event_product_type' => EventProductType::Product->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
@@ -10,6 +9,7 @@ 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;
@@ -24,7 +24,7 @@ class EntryService
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('event_product_type', EventProductType::Entry->value)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->with([
'variants.inventory',
'variants.eventDate',
@@ -62,8 +62,8 @@ class EntryService
'descripcion' => $entry['description'] ?? null,
'category_id' => $category->id,
'precio' => $entry['price'],
'event_product_type' => EventProductType::Entry->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'],
@@ -76,13 +76,24 @@ class EntryService
});
}
public function delete(Tenant $tenant, int $entryId): void
{
$entry = CatalogItem::query()
->whereKey($entryId)
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->firstOrFail();
$this->catalogService->delete($entry);
}
/** @param array<string, mixed> $entry */
private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem
{
$catalogItem = CatalogItem::query()
->whereKey($entry['id'])
->where('tenant_code', $tenant->codigo)
->where('event_product_type', EventProductType::Entry->value)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->lockForUpdate()
->firstOrFail();
@@ -124,6 +135,7 @@ 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([

View File

@@ -2,7 +2,6 @@
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
@@ -11,7 +10,9 @@ 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\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;
@@ -20,6 +21,14 @@ class FoodService
{
private const ATTRIBUTE_CODES = ['event_date', 'horario', 'servicio'];
private const ATTRIBUTE_SORT_ORDERS = [
'event_date' => 1,
'horario' => 2,
'servicio' => 3,
];
public function __construct(private readonly CatalogService $catalogService) {}
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
@@ -87,6 +96,18 @@ class FoodService
});
}
public function delete(Tenant $tenant, int $foodId): void
{
$variant = Variant::query()
->whereKey($foodId)
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida'))
->firstOrFail();
$this->catalogService->deleteVariant($variant);
}
/** @return Collection<string, Attribute> */
private function attributes(Tenant $tenant): Collection
{
@@ -125,9 +146,9 @@ class FoodService
if ($food !== null) {
$food->update([
'category_id' => $category->id,
'event_product_type' => EventProductType::Product->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
return $food;
@@ -140,9 +161,9 @@ class FoodService
'descripcion' => 'Comida',
'category_id' => $category->id,
'precio' => collect($variants)->min('price') ?? 0,
'event_product_type' => EventProductType::Product->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}
@@ -154,9 +175,12 @@ class FoodService
private function itemAttributes(CatalogItem $food, Collection $attributes): Collection
{
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array {
$itemAttribute = $food->itemAttributes()->firstOrCreate(
$itemAttribute = $food->itemAttributes()->updateOrCreate(
['attribute_id' => $attribute->id],
['allow_multi_select' => false],
[
'allow_multi_select' => false,
'sort_order' => self::ATTRIBUTE_SORT_ORDERS[$code],
],
);
return [$code => $itemAttribute];

View File

@@ -2,7 +2,6 @@
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
@@ -11,7 +10,9 @@ 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\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;
@@ -21,12 +22,13 @@ class MerchandiseService
{
private const ATTRIBUTE_CODES = ['color', 'talle'];
public function __construct(private readonly CatalogService $catalogService) {}
/** @return Collection<int, CatalogItem> */
public function all(Tenant $tenant): Collection
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('event_product_type', EventProductType::Product->value)
->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising'))
->with([
'itemAttributes.attribute.options',
@@ -71,9 +73,9 @@ class MerchandiseService
'descripcion' => $data['description'] ?? null,
'category_id' => $category->id,
'max_units_per_user' => (int) $data['max_units_per_user'],
'event_product_type' => EventProductType::Product->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
$itemAttributes = $this->itemAttributes($item, $attributes);
@@ -120,6 +122,19 @@ class MerchandiseService
});
}
public function delete(Tenant $tenant, int $merchandiseId): void
{
$variant = Variant::query()
->whereKey($merchandiseId)
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($categoryQuery) => $categoryQuery
->where('nombre', 'Merchandising')))
->firstOrFail();
$this->catalogService->deleteVariant($variant);
}
/** @return Collection<string, Attribute> */
private function attributes(Tenant $tenant): Collection
{
@@ -153,7 +168,6 @@ class MerchandiseService
->whereKey($itemId)
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->where('event_product_type', EventProductType::Product->value)
->lockForUpdate()
->first();
@@ -184,9 +198,9 @@ class MerchandiseService
'category_id' => $category->id,
'precio' => collect($data['variants'])->min('price') ?? 0,
'max_units_per_user' => (int) $data['max_units_per_user'],
'event_product_type' => EventProductType::Product->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}

View File

@@ -15,22 +15,34 @@ Route::prefix('v1/adminapp/tenant')
Route::post('entries', [EntryController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.store');
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
Route::get('foods', [FoodController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.index');
Route::post('foods', [FoodController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.store');
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
Route::get('accommodations', [AccommodationController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
Route::post('accommodations', [AccommodationController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
Route::get('merchandise', [MerchandiseController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
Route::post('merchandise', [MerchandiseController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
});

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Forms\Resources;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
@@ -17,6 +18,8 @@ class FoodFormResource extends JsonResource
'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
]
)->values(),

View File

@@ -27,7 +27,7 @@ class FoodFormService
->keyBy('codigo');
return [
'event_dates' => $tenant->eventDates()->get(),
'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection,
];

View File

@@ -127,8 +127,7 @@ class TelepagosWebhookService
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPurchase($compra);
$compra->markAsPaid();
$this->checkoutService->confirmPaidPurchase($compra);
});
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");

View File

@@ -18,12 +18,17 @@ class ReleaseCheckoutService
public function cancel(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED);
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true);
}
public function cancelWithoutRestoringCart(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false);
}
public function expire(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_EXPIRED);
return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: true);
}
public function expireOverdue(): int
@@ -46,9 +51,9 @@ class ReleaseCheckoutService
return $expiredCount;
}
private function release(Purchase $purchase, string $targetStatus): Purchase
private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase
{
return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
@@ -76,7 +81,7 @@ class ReleaseCheckoutService
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
$reservationReturnedToCart = $this->sourceCart->restore($purchase);
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
foreach ($items as $item) {
if (! $reservationReturnedToCart) {

View File

@@ -9,6 +9,7 @@ use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* Stable checkout API used by controllers, commands and integrations.
@@ -64,11 +65,26 @@ class CheckoutService
$this->completer->confirm($purchase);
}
public function confirmPaidPurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$this->completer->confirm($purchase);
$purchase->markAsPaid();
return $purchase->refresh()->load(['items.imageAttachment']);
});
}
public function cancelPurchase(Purchase $purchase): Purchase
{
return $this->releaser->cancel($purchase);
}
public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase
{
return $this->releaser->cancelWithoutRestoringCart($purchase);
}
public function expirePurchase(Purchase $purchase): Purchase
{
return $this->releaser->expire($purchase);

View File

@@ -3,8 +3,10 @@
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
@@ -30,6 +32,36 @@ class SaleController extends Controller
]);
}
public function show(Request $request, int $sale): SaleDetailResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleDetailResource($this->saleService->detail($tenant, $sale));
}
public function tickets(Request $request, int $sale): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale)
);
}
public function confirm(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale));
}
public function cancel(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale));
}
public function modifications(Request $request): AnonymousResourceCollection
{
return SaleModificationResource::collection(

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Purchase */
class SaleDetailResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'items' => $this->items->map(fn (PurchaseItem $item): array => [
'id' => $item->id,
'product' => $item->item_nombre,
'event_dates' => $this->eventDates($item),
'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total),
])->values(),
'total' => $this->formatMoney($this->total),
];
}
/** @return list<string> */
private function eventDates(PurchaseItem $item): array
{
return collect($item->variant_attributes ?? [])
->filter(fn (mixed $attribute): bool => is_array($attribute)
&& mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha')
->flatMap(function (array $attribute): array {
$value = $attribute['value'] ?? [];
return is_array($value) ? $value : [$value];
})
->filter(fn (mixed $date): bool => is_string($date) && $date !== '')
->values()
->all();
}
private function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Ticket */
class SaleTicketResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'product' => $this->name,
'id' => $this->id,
'expires_at' => $this->getEffectiveExpiresAt(),
'status' => $this->status,
];
}
}

View File

@@ -4,13 +4,19 @@ namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
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 Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class AdminAppSaleService
{
public function __construct(
protected CheckoutService $checkoutService,
) {}
public function confirmedSalesTotal(Tenant $tenant): string
{
$total = Purchase::query()
@@ -39,6 +45,42 @@ class AdminAppSaleService
->withQueryString();
}
public function detail(Tenant $tenant, int $saleId): Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->with('items')
->findOrFail($saleId);
}
/** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, int $saleId): Collection
{
return $this->findForTenant($tenant, $saleId)
->tickets()
->with('validityGroups.validityTimes')
->orderBy('id')
->get();
}
public function confirm(Tenant $tenant, int $saleId): Purchase
{
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->confirmPaidPurchase($sale)
);
}
public function cancel(Tenant $tenant, int $saleId): Purchase
{
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->cancelPurchaseWithoutRestoringCart($sale)
);
}
/**
* @param array<string, mixed> $filters
* @return Collection<int, Purchase>
@@ -117,4 +159,18 @@ class AdminAppSaleService
->orderByDesc('changed_at')
->orderByDesc('id');
}
protected function findForTenant(Tenant $tenant, int $saleId): Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->findOrFail($saleId);
}
protected function saleForResponse(Purchase $sale): Purchase
{
return $sale->refresh()
->loadSum('items as quantity', 'cantidad')
->loadCount('tickets');
}
}

View File

@@ -10,4 +10,8 @@ Route::prefix('v1/adminapp/tenant')
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale');
});

View File

@@ -33,6 +33,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'search_product_layout',
'search_group_layout',
'search_items_per_page',
'display_categories',
'display_seach_bar',
'event_title',
'event_location',
'event_date_text',
@@ -45,6 +47,8 @@ class Tenant extends Model
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12,
'display_categories' => true,
'display_seach_bar' => true,
];
public function getRouteKeyName(): string
@@ -63,6 +67,8 @@ class Tenant extends Model
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
'display_categories' => 'boolean',
'display_seach_bar' => 'boolean',
];
}

View File

@@ -19,6 +19,10 @@ class WebsiteExtra extends Model
protected $table = 'websites_extras';
protected $attributes = [
'is_enabled' => true,
];
private mixed $resolvedConfig = null;
private bool $hasResolvedConfig = false;

View File

@@ -75,6 +75,8 @@ class StoreTenantRequest extends FormRequest
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'website_type_code' => [
'required_with:extras',
'sometimes',

View File

@@ -85,6 +85,8 @@ class UpdateTenantRequest extends FormRequest
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -9,6 +9,7 @@ use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* @mixin Tenant
@@ -61,6 +62,8 @@ class TenantResource extends JsonResource
'search_product_layout' => $this->search_product_layout->value,
'search_group_layout' => $this->search_group_layout->value,
'search_items_per_page' => $this->search_items_per_page,
'display_categories' => $this->display_categories,
'display_seach_bar' => $this->display_seach_bar,
'social_media' => $this->whenLoaded(
'socialMedia',
fn () => $this->socialMedia
@@ -157,6 +160,10 @@ class TenantResource extends JsonResource
$formatted['submenues'] = $childrenByParent
->get($menu->code, collect())
->sortBy(
fn (Menu $submenu) => Str::lower(Str::ascii($submenu->label)),
SORT_NATURAL
)
->map($formatMenu)
->values();
@@ -166,6 +173,10 @@ class TenantResource extends JsonResource
return $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|| ! $menuCodes->has($menu->parent_menu_code))
->sortBy(
fn (Menu $menu) => Str::lower(Str::ascii($menu->label)),
SORT_NATURAL
)
->map($formatMenu)
->values();
}

View File

@@ -22,7 +22,7 @@ class TicketController extends Controller
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->with('validityTime', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();
@@ -36,7 +36,7 @@ class TicketController extends Controller
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds)
->with('validityTime', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Ticket\Enums;
enum EventProductType: string
enum TicketGenerationPolicy: string
{
case Entry = 'entrada';
case Product = 'producto';
case PerEventDate = 'per_event_date';
case OnePerUnit = 'one_per_unit';
/** @return list<string> */
public static function values(): array

View File

@@ -9,9 +9,12 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
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',
@@ -21,7 +24,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'source_purchase_id',
'source_catalog_item_id',
'source_variant_id',
'validity_time_id',
'used_at',
'scanner_user_id',
'user_id',
@@ -30,12 +32,19 @@ class Ticket extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_EXPIRED = 'expired';
public const STATUS_USED = 'used';
public $timestamps = false;
protected $appends = [
'is_valid',
'is_expired',
'is_used',
'status',
];
protected function casts(): array
@@ -44,7 +53,6 @@ class Ticket extends Model
'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer',
'source_purchase_id' => 'integer',
'validity_time_id' => 'integer',
'used_at' => 'datetime',
'scanner_user_id' => 'integer',
'user_id' => 'integer',
@@ -87,18 +95,23 @@ class Ticket extends Model
return $this->belongsTo(Variant::class, 'source_variant_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
/** @return HasMany<TicketValidityGroup, $this> */
public function validityGroups(): HasMany
{
return $this->belongsTo(ValidityTime::class);
return $this->hasMany(TicketValidityGroup::class);
}
public function isValid(): bool
{
return $this->used_at === null
&& (
$this->validityTime === null
|| $this->validityTime->isValid()
if ($this->used_at !== null) {
return false;
}
$validityGroups = $this->resolvedValidityGroups();
return $validityGroups->isEmpty()
|| $validityGroups->contains(
fn (TicketValidityGroup $group): bool => $group->isValid()
);
}
@@ -109,11 +122,13 @@ class Ticket extends Model
public function getIsExpiredAttribute(): bool
{
$expiresAt = $this->getEffectiveExpiresAt();
$validityGroups = $this->resolvedValidityGroups();
return $this->used_at === null
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo(now());
&& $validityGroups->isNotEmpty()
&& $validityGroups->every(
fn (TicketValidityGroup $group): bool => $group->isExpired()
);
}
public function getIsUsedAttribute(): bool
@@ -121,13 +136,65 @@ class Ticket extends Model
return $this->used_at !== null;
}
public function getStatusAttribute(): string
{
if ($this->is_used) {
return self::STATUS_USED;
}
if ($this->is_expired) {
return self::STATUS_EXPIRED;
}
return self::STATUS_ACTIVE;
}
public function getEffectiveStartsAt(): ?CarbonInterface
{
return $this->validityTime?->startsAt();
return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt())
->filter()
->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
public function getEffectiveExpiresAt(): ?CarbonInterface
{
return $this->validityTime?->expiresAt();
return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt())
->filter()
->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/** @return Collection<int, ValidityTime> */
public function allValidityTimes(): Collection
{
return $this->resolvedValidityGroups()
->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes())
->unique(
fn (ValidityTime $validityTime): int => $validityTime->getKey()
?? spl_object_id($validityTime)
)
->values();
}
/** @return EloquentCollection<int, TicketValidityGroup> */
public function resolvedValidityGroups(): EloquentCollection
{
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;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['ticket_id'])]
class TicketValidityGroup extends Model
{
public $timestamps = false;
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsToMany<ValidityTime, $this> */
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<int, ValidityTime> */
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();
}
}

View File

@@ -4,13 +4,16 @@ 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;
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;
#[Fillable([
'type',
@@ -44,10 +47,21 @@ class ValidityTime extends Model
return $this->hasMany(AttributeOption::class);
}
/** @return HasMany<Ticket, $this> */
public function tickets(): HasMany
/** @return HasOne<EventDate, $this> */
public function eventDate(): HasOne
{
return $this->hasMany(Ticket::class);
return $this->hasOne(EventDate::class);
}
/** @return BelongsToMany<TicketValidityGroup, $this> */
public function ticketValidityGroups(): BelongsToMany
{
return $this->belongsToMany(
TicketValidityGroup::class,
'ticket_validity_group_times',
'validity_time_id',
'ticket_validity_group_id',
);
}
public function startsAt(

View File

@@ -20,8 +20,10 @@ class TicketResource extends JsonResource
'description' => $this->description,
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->validityTime),
'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,

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Models\TicketValidityGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin TicketValidityGroup */
class TicketValidityGroupResource extends JsonResource
{
/** @return array<string, mixed> */
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(),
];
}
}

View File

@@ -6,10 +6,12 @@ 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\ValidityTimeType;
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;
@@ -36,36 +38,51 @@ class TicketGeneratorService
$quantity,
$sourceVariantId,
);
$fixedValidityTimes = [];
return $targets->map(function (array $target) use (
&$fixedValidityTimes,
$sourcePurchaseId,
$user,
): Ticket {
$item = $target['catalog_item'];
$variant = $target['variant'];
$eventDate = $target['event_date'];
$validityTime = $this->resolveValidityTime($item, $variant);
$validityTime = $this->materializeEventDateValidityTime(
$validityGroups = $this->buildTicketValidityGroups(
$item,
$variant,
$eventDate,
$validityTime,
$fixedValidityTimes,
$this->resolveValidityTime($item, $variant),
);
return Ticket::query()->create([
$ticket = Ticket::query()->create([
'tenant_code' => $item->tenant_code,
'ticket' => (string) Str::uuid(),
'name' => $item->nombre,
'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(),
'validity_time_id' => $validityTime?->getKey(),
'used_at' => null,
'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;
});
});
}
@@ -125,8 +142,21 @@ class TicketGeneratorService
]);
}
$variant->loadMissing(['eventDates', 'eventDate']);
$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,
]);
}
$eventDates = $selectedEventDates->isEmpty()
? collect([null])
: $selectedEventDates;
@@ -206,52 +236,77 @@ class TicketGeneratorService
return $validityTimes->first();
}
/**
* Convert a recurring time window into the fixed date and time purchased by
* the customer. Equal tickets generated together share the same snapshot.
*
* @param array<string, ValidityTime> $fixedValidityTimes
*/
private function materializeEventDateValidityTime(
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<int, Collection<int, ValidityTime>> */
private function buildTicketValidityGroups(
CatalogItem $catalogItem,
?Variant $variant,
?EventDate $eventDate,
?ValidityTime $validityTime,
array &$fixedValidityTimes,
): ?ValidityTime {
if (
$variant === null
|| $eventDate === null
) {
return $validityTime;
}
if ($validityTime !== null && $validityTime->type !== ValidityTimeType::TimeWindow) {
return $validityTime;
}
$cacheKey = $eventDate->getKey().':'.($validityTime?->getKey() ?? 'event');
if (isset($fixedValidityTimes[$cacheKey])) {
return $fixedValidityTimes[$cacheKey];
}
$startsAt = $validityTime?->startsAt($eventDate->date) ?? $eventDate->startsAt();
$expiresAt = $validityTime?->expiresAt($eventDate->date) ?? $eventDate->endsAt();
): Collection {
$eventDates = collect([$eventDate]);
if (
$startsAt !== null
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo($startsAt)
$catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit
&& $variant !== null
) {
$expiresAt = $expiresAt->addDay();
$variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates();
if ($selectedEventDates->isNotEmpty()) {
$eventDates = $selectedEventDates;
}
}
return $fixedValidityTimes[$cacheKey] = ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
]);
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();
}
}

View File

@@ -1,6 +1,5 @@
<?php
use App\Domains\Catalog\Enums\EventProductType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -43,9 +42,6 @@ return new class extends Migration
->after('tenant_code')
->constrained('events')
->nullOnDelete();
$table->enum('event_product_type', EventProductType::values())
->nullable()
->after('event_id');
});
Schema::table('variantes', function (Blueprint $table): void {
@@ -71,7 +67,6 @@ return new class extends Migration
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
$table->dropColumn('event_product_type');
});
Schema::dropIfExists('event_dates');

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('item_attributes', function (Blueprint $table): void {
$table->unsignedInteger('sort_order')->default(0)->after('allow_multi_select');
});
$foodItemAttributes = DB::table('item_attributes')
->join('catalog_items', 'catalog_items.id', '=', 'item_attributes.catalog_item_id')
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
->where('catalog_items.slug', 'comida')
->whereIn('attribute.codigo', ['event_date', 'horario', 'servicio'])
->select('item_attributes.id', 'attribute.codigo')
->get();
$sortOrders = [
'event_date' => 1,
'horario' => 2,
'servicio' => 3,
];
foreach ($foodItemAttributes as $itemAttribute) {
DB::table('item_attributes')
->where('id', $itemAttribute->id)
->update(['sort_order' => $sortOrders[$itemAttribute->codigo]]);
}
}
public function down(): void
{
Schema::table('item_attributes', function (Blueprint $table): void {
$table->dropColumn('sort_order');
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('catalog_items')
->where('tenant_code', 'fiesta_futbol_infantil')
->update(['has_tickets' => true]);
}
public function down(): void
{
$entryCategoryIds = DB::table('categorias')
->select('id')
->where('tenant_code', 'fiesta_futbol_infantil')
->where('nombre', 'Entradas');
DB::table('catalog_items')
->where('tenant_code', 'fiesta_futbol_infantil')
->whereNotIn('category_id', $entryCategoryIds)
->update(['has_tickets' => false]);
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('catalog_items', 'event_product_type')) {
return;
}
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('event_product_type');
});
}
public function down(): void
{
if (Schema::hasColumn('catalog_items', 'event_product_type')) {
return;
}
Schema::table('catalog_items', function (Blueprint $table): void {
$table->enum('event_product_type', ['entrada', 'producto'])
->nullable()
->after('tenant_code');
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->boolean('display_categories')->default(true)->after('search_items_per_page');
$table->boolean('display_seach_bar')->default(true)->after('display_categories');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropColumn(['display_categories', 'display_seach_bar']);
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('tenants')
->where('codigo', 'fiesta_futbol_infantil')
->update([
'display_categories' => false,
'display_seach_bar' => false,
]);
DB::table('tenants')
->where('codigo', 'sonder')
->update([
'display_categories' => true,
'display_seach_bar' => true,
]);
}
public function down(): void {}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::table('websites_extras')
->whereNull('is_enabled')
->update(['is_enabled' => true]);
Schema::table('websites_extras', function (Blueprint $table): void {
$table->boolean('is_enabled')->default(true)->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('websites_extras', function (Blueprint $table): void {
$table->boolean('is_enabled')->nullable()->default(null)->change();
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->enum('ticket_generation_policy', TicketGenerationPolicy::values())
->default(TicketGenerationPolicy::PerEventDate->value)
->after('has_tickets');
});
DB::table('catalog_items')
->where('tenant_code', 'fiesta_futbol_infantil')
->update([
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('ticket_generation_policy');
});
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_validity_times', function (Blueprint $table): void {
$table->foreignId('ticket_id')
->constrained('tickets')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreignId('validity_time_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
$table->primary(['ticket_id', 'validity_time_id']);
});
DB::table('ticket_validity_times')->insertUsing(
['ticket_id', 'validity_time_id'],
DB::table('tickets')
->select(['id', 'validity_time_id'])
->whereNotNull('validity_time_id'),
);
Schema::table('tickets', function (Blueprint $table): void {
$table->dropConstrainedForeignId('validity_time_id');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table): void {
$table->foreignId('validity_time_id')
->nullable()
->after('source_variant_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
});
DB::table('ticket_validity_times')
->orderBy('ticket_id')
->orderBy('validity_time_id')
->get()
->groupBy('ticket_id')
->each(function ($validityTimes, int|string $ticketId): void {
DB::table('tickets')
->where('id', $ticketId)
->update([
'validity_time_id' => $validityTimes->first()->validity_time_id,
]);
});
Schema::dropIfExists('ticket_validity_times');
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('event_dates', function (Blueprint $table): void {
$table->unsignedBigInteger('validity_time_id')
->nullable()
->unique()
->after('id');
});
DB::table('event_dates')
->orderBy('id')
->each(function (object $eventDate): void {
$startsAt = $eventDate->date.' '.$eventDate->time_start;
$expiresAt = $eventDate->date.' '.$eventDate->time_end;
if (strtotime($expiresAt) <= strtotime($startsAt)) {
$expiresAt = date('Y-m-d H:i:s', strtotime($expiresAt.' +1 day'));
}
$validityTimeId = DB::table('validity_times')->insertGetId([
'type' => 'fixed_window',
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('event_dates')
->where('id', $eventDate->id)
->update(['validity_time_id' => $validityTimeId]);
});
Schema::table('event_dates', function (Blueprint $table): void {
$table->unsignedBigInteger('validity_time_id')->nullable(false)->change();
$table->foreign('validity_time_id')
->references('id')
->on('validity_times')
->restrictOnDelete();
});
}
public function down(): void
{
$validityTimeIds = DB::table('event_dates')
->pluck('validity_time_id')
->filter()
->all();
Schema::table('event_dates', function (Blueprint $table): void {
$table->dropForeign(['validity_time_id']);
$table->dropUnique(['validity_time_id']);
$table->dropColumn('validity_time_id');
});
DB::table('validity_times')->whereIn('id', $validityTimeIds)->delete();
}
};

View File

@@ -0,0 +1,88 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_validity_groups', function (Blueprint $table): void {
$table->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']);
});
// Every former pivot row was an OR alternative, so each one becomes
// an independent group to preserve existing ticket behavior.
DB::table('ticket_validity_times')
->orderBy('ticket_id')
->orderBy('validity_time_id')
->each(function (object $association): void {
$groupId = DB::table('ticket_validity_groups')->insertGetId([
'ticket_id' => $association->ticket_id,
]);
DB::table('ticket_validity_group_times')->insert([
'ticket_validity_group_id' => $groupId,
'validity_time_id' => $association->validity_time_id,
]);
});
Schema::dropIfExists('ticket_validity_times');
}
public function down(): void
{
Schema::create('ticket_validity_times', function (Blueprint $table): void {
$table->foreignId('ticket_id')
->constrained('tickets')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreignId('validity_time_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
$table->primary(['ticket_id', 'validity_time_id']);
});
DB::table('ticket_validity_group_times')
->join(
'ticket_validity_groups',
'ticket_validity_groups.id',
'=',
'ticket_validity_group_times.ticket_validity_group_id',
)
->select([
'ticket_validity_groups.ticket_id',
'ticket_validity_group_times.validity_time_id',
])
->distinct()
->orderBy('ticket_validity_groups.ticket_id')
->each(fn (object $association) => DB::table('ticket_validity_times')->insert([
'ticket_id' => $association->ticket_id,
'validity_time_id' => $association->validity_time_id,
]));
Schema::dropIfExists('ticket_validity_group_times');
Schema::dropIfExists('ticket_validity_groups');
}
};

View File

@@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\InventoryPolicy;
@@ -12,6 +11,7 @@ use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Database\Seeder;
use RuntimeException;
@@ -64,8 +64,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'attribute_codes' => ['color', 'talle'],
'variants' => collect(['Verde', 'Blanco'])
->crossJoin(['14', 'S', 'M', 'L', 'XL', 'XXL'])
->map(fn (array $values): array => [
'real_stock' => 0,
->map(fn (array $values, int $index): array => [
'real_stock' => [24, 3, 18, 0, 12, 2, 25, 0, 14, 1, 19, 8][$index],
'values' => ['color' => $values[0], 'talle' => $values[1]],
])->all(),
]);
@@ -76,8 +76,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'category_id' => $categories['alojamientos']->id,
'precio' => 35000,
'attribute_codes' => ['tipo_alojamiento'],
'variants' => collect(['Carpa', 'Motorhome'])->map(fn (string $type): array => [
'real_stock' => 0,
'variants' => collect(['Carpa', 'Motorhome'])->map(fn (string $type, int $index): array => [
'real_stock' => [0, 3][$index],
'values' => ['tipo_alojamiento' => $type],
])->all(),
]);
@@ -90,8 +90,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'attribute_codes' => ['event_date', 'horario', 'servicio'],
'variants' => $eventDates
->crossJoin(['Desayuno', 'Almuerzo', 'Cena'], ['Comedor', 'Vianda'])
->map(fn (array $values): array => [
'real_stock' => 0,
->map(fn (array $values, int $index): array => [
'real_stock' => [80, 3, 65, 0, 42, 2, 70, 18, 0, 5, 55, 40, 90, 1, 35, 0, 60, 8, 75, 22, 0, 4, 50, 30][$index],
'event_date_ids' => [(int) $values[0]->id],
'descripcion' => sprintf(
'%s del %s - %s',
@@ -109,12 +109,11 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'nombre' => 'Abono',
'category_id' => $categories['entradas']->id,
'precio' => 40000,
'event_product_type' => EventProductType::Entry->value,
'has_tickets' => true,
'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'],
'variants' => [[
'real_stock' => 0,
'real_stock' => 120,
'event_date_ids' => $dateIds->all(),
]],
]);
@@ -123,8 +122,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'tenant_code' => $tenant->codigo,
'source_type' => FeaturedGroupSource::All,
'category_id' => null,
'product_layout' => ProductLayout::ColumnWithCart,
'group_layout' => GroupLayout::Simple,
'product_layout' => ProductLayout::Row,
'group_layout' => GroupLayout::SimpleVertical,
'group_name' => 'Productos',
'group_order' => 0,
]);
@@ -143,10 +142,11 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
{
return $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'event_product_type' => EventProductType::Product->value,
'descripcion' => $data['nombre'],
'inventory_policy' => InventoryPolicy::Unlimited->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
...$data,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
}

View File

@@ -252,6 +252,12 @@ class MenuSeeder extends Seeder
'adminapp.fiesta-futbol-infantil.merchandising',
'adminapp.fiesta-futbol-infantil.comida',
];
$fiestaExcludedAdminMenuCodes = [
'adminapp.inicio',
'adminapp.catalog',
'adminapp.categories',
'adminapp.combos',
];
$frequentlyAskedQuestions = [
[
'pregunta' => '¿Hay algún límite de compra?',
@@ -311,6 +317,8 @@ class MenuSeeder extends Seeder
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes);
} else {
$menuCodes = array_diff($menuCodes, $fiestaExcludedAdminMenuCodes);
}
// Usar sync para asociar los menues al tenant

View File

@@ -56,6 +56,8 @@ class TenantSeeder extends Seeder
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#313131',
'display_categories' => true,
'display_seach_bar' => true,
'header_logo' => $this->uploadedImage('images/tennants/sonder/sonder_header.png', 'sonder_header.png'),
'footer_logo' => $this->uploadedImage('images/tennants/sonder/sonder_footer.png', 'sonder_footer.png'),
'social_media' => self::SOCIAL_MEDIA,
@@ -107,6 +109,8 @@ class TenantSeeder extends Seeder
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#015327',
'display_categories' => false,
'display_seach_bar' => false,
'header_logo' => $this->uploadedImage(
'images/tennants/fiesta_futbol_infantil/futbol_infantil_header.png',
'futbol_infantil_header.png',

View File

@@ -24,6 +24,7 @@ return [
'cart' => [
'item_added' => 'Product added to cart.',
'quantity_updated' => 'Product quantity updated.',
'item_updated' => 'Product updated.',
'item_removed' => 'Product removed from cart.',
'positive_quantity' => 'The quantity must be greater than zero.',
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
@@ -97,7 +98,6 @@ return [
'event_date_selection_required' => 'At least one event date must be selected.',
'single_event_date_required' => 'Exactly one event date must be selected.',
'event_date_wrong_tenant' => 'Every event date must belong to the catalog item tenant.',
'invalid_event_product_type' => 'The event product type is invalid.',
'duplicate_variant_combination' => 'The variant combination must be unique.',
'multi_value_required' => 'At least one value must be selected.',
'single_value_required' => 'Exactly one value must be selected.',

View File

@@ -24,6 +24,7 @@ return [
'cart' => [
'item_added' => 'Producto agregado al carrito.',
'quantity_updated' => 'Cantidad de producto actualizada.',
'item_updated' => 'Producto actualizado.',
'item_removed' => 'Producto eliminado del carrito.',
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
@@ -97,7 +98,6 @@ return [
'event_date_selection_required' => 'Debe seleccionar al menos una fecha de evento.',
'single_event_date_required' => 'Debe seleccionar exactamente una fecha de evento.',
'event_date_wrong_tenant' => 'Todas las fechas del evento deben pertenecer al tenant del ítem de catálogo.',
'invalid_event_product_type' => 'El tipo de producto de evento no es válido.',
'duplicate_variant_combination' => 'La combinación de la variante debe ser única.',
'multi_value_required' => 'Debe seleccionar al menos un valor.',
'single_value_required' => 'Debe seleccionar exactamente un valor.',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -256,6 +256,110 @@ class CartControllerTest extends TestCase
]);
}
public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void
{
$tenant = $this->createTenant('acme');
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00');
$secondInventory = Inventory::query()->create(['real_stock' => 2]);
$secondVariant = $item->variants()->create([
'inventory_id' => $secondInventory->id,
'precio' => '20.00',
]);
$unavailableInventory = Inventory::query()->create(['real_stock' => 0]);
$unavailableVariant = $item->variants()->create([
'inventory_id' => $unavailableInventory->id,
'precio' => '25.00',
]);
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
]);
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
$cartItemId = $createResponse->json('data.items.0.id');
$this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]),
)
->assertOk()
->assertJsonPath('data.items.0.variant_id', $secondVariant->id)
->assertJsonPath('data.items.0.precio_unitario', '20.00')
->assertJsonCount(2, 'data.items.0.product.variants')
->assertJsonPath('data.items.0.product.variants.0.id', $firstVariant->id)
->assertJsonPath('data.items.0.product.variants.1.id', $secondVariant->id)
->assertJsonPath('data.items.0.product.variants.1.stock_tecnico', 0)
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 2,
]);
}
public function test_changing_to_a_variant_already_in_the_cart_merges_both_rows(): void
{
$tenant = $this->createTenant('acme');
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
]);
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
$firstCartItemId = $firstResponse->json('data.items.0.id');
$this->call(
'POST',
'/api/tenants/acme/cart/items',
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'catalog_item_id' => $item->id,
'variant_id' => $secondVariant->id,
'cantidad' => 3,
]),
)->assertOk();
$this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$firstCartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]),
)
->assertOk()
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.variant_id', $secondVariant->id)
->assertJsonPath('data.items.0.cantidad', 5);
$this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 5,
]);
}
public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void
{
$tenant = $this->createTenant('acme');

View File

@@ -48,8 +48,15 @@ class CatalogControllerTest extends TestCase
'real_stock' => 4,
'reserved_stock' => 1,
]);
$unavailableInventory = Inventory::query()->create([
'real_stock' => 2,
'reserved_stock' => 2,
]);
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
$unavailableVariant = $variantItem->variants()->create([
'inventory_id' => $unavailableInventory->id,
]);
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog");
@@ -67,11 +74,45 @@ class CatalogControllerTest extends TestCase
->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4)
->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3)
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0])
->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.stock_tecnico', 8)
->assertJsonCount(0, '1.items.data.0.variants');
}
public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void
{
$tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup(
$tenant,
ProductLayout::ColumnWithCart,
'Available variants',
groupLayout: GroupLayout::SimpleVertical,
);
$unavailableItem = $this->createItem($tenant, 'Unavailable');
$unavailableInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 4,
]);
$unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]);
$group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]);
$availableItem = $this->createItem($tenant, 'Available');
$availableInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 3,
]);
$availableItem->variants()->create(['inventory_id' => $availableInventory->id]);
$group->featuredItems()->create(['catalog_item_id' => $availableItem->id]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonMissing(['nombre' => 'Unavailable']);
}
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
{
Storage::fake('s3');

View File

@@ -45,7 +45,7 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
}
public function test_it_selects_the_first_variant_and_returns_its_images_by_default(): void
public function test_it_filters_unavailable_variants_and_selects_the_first_available_one(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-default');
@@ -66,14 +66,21 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertOk()
->assertJsonPath('data.selected_variant.id', $firstVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 0)
->assertJsonCount(1, 'data.variants')
->assertJsonPath('data.variants.0.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 6)
->assertJsonCount(1, 'data.selected_variant.images');
$response
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images');
$this->assertStringContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$firstVariant->id}"
)->assertNotFound();
}
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
@@ -117,17 +124,20 @@ class CatalogItemDetailControllerTest extends TestCase
->assertOk()
->assertJsonPath('data.variants.0.id', $firstVariant->id)
->assertJsonPath('data.variants.0.stock_tecnico', 4)
->assertJsonPath('data.variants.0.values.size', 'S')
->assertJsonPath('data.variants.0.values.size.value', 'S')
->assertJsonPath('data.variants.0.values.size.label', 'Small')
->assertJsonPath('data.variants.1.id', $secondVariant->id)
->assertJsonPath('data.variants.1.stock_tecnico', 7)
->assertJsonPath('data.variants.1.values.size', 'M')
->assertJsonPath('data.variants.1.values.size.value', 'M')
->assertJsonPath('data.variants.1.values.size.label', 'Medium')
->assertJsonPath('data.attributes.0.codigo', 'size')
->assertJsonPath('data.attributes.0.options.0.value', 'S')
->assertJsonPath('data.attributes.0.options.1.value', 'M')
->assertJsonCount(2, 'data.attributes.0.options')
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 7)
->assertJsonPath('data.selected_variant.values.size', 'M')
->assertJsonPath('data.selected_variant.values.size.value', 'M')
->assertJsonPath('data.selected_variant.values.size.label', 'Medium')
->assertJsonCount(1, 'data.selected_variant.images');
$response
->assertJsonMissingPath('data.stock_tecnico')
@@ -207,14 +217,65 @@ class CatalogItemDetailControllerTest extends TestCase
->assertJsonCount(2, 'data.attributes.0.options')
->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026 · 09:00 a 18:00')
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026')
->assertJsonPath('data.attributes.0.options.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id)
->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id)
->assertJsonPath('data.variants.0.values.event_date', (string) $eventDate->id);
->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id)
->assertJsonPath(
'data.variants.0.values.event_date.label',
'09/10/2026',
);
$this->assertDatabaseCount('attribute_options', 0);
}
public function test_it_orders_item_attributes_by_sort_order_and_then_attribute_label(): void
{
$tenant = $this->createTenant('detail-attribute-order');
$item = $this->createItem($tenant, 'Ordered attributes');
$variant = $this->createVariant($item, 10, 0);
$attributes = collect([
['code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2],
['code' => 'priority', 'label' => 'Priority', 'sort_order' => 1],
['code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2],
])->mapWithKeys(function (array $data) use ($tenant, $item): array {
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => $data['code'],
'nombre' => $data['label'],
'type' => FieldType::String,
]);
$itemAttribute = $item->itemAttributes()->create([
'attribute_id' => $attribute->id,
'sort_order' => $data['sort_order'],
]);
return [$data['code'] => $itemAttribute];
});
foreach (['zeta', 'priority', 'alpha'] as $code) {
$variant->definitions()->create([
'item_attribute_id' => $attributes[$code]->id,
'value' => $code,
]);
}
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertOk()
->assertJsonPath('data.attributes.0.codigo', 'priority')
->assertJsonPath('data.attributes.0.sort_order', 1)
->assertJsonPath('data.attributes.1.codigo', 'alpha')
->assertJsonPath('data.attributes.2.codigo', 'zeta');
$this->assertSame(
['priority', 'alpha', 'zeta'],
array_keys($response->json('data.variants.0.values')),
);
}
private function createItem(
Tenant $tenant,
string $name,

View File

@@ -24,6 +24,7 @@ class CatalogSchemaTest extends TestCase
$this->assertFalse(Schema::hasTable('bundle_items'));
$this->assertTrue(Schema::hasTable('variantes'));
$this->assertTrue(Schema::hasTable('item_attributes'));
$this->assertTrue(Schema::hasColumn('item_attributes', 'sort_order'));
$this->assertTrue(Schema::hasTable('variant_values'));
}
@@ -31,8 +32,8 @@ class CatalogSchemaTest extends TestCase
{
$this->assertEqualsCanonicalizing([
'id',
'validity_time_id',
'tenant_code',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -44,6 +45,7 @@ class CatalogSchemaTest extends TestCase
'inventory_policy',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
], Schema::getColumnListing('catalog_items'));
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -45,6 +46,7 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.title', 'Festival Acme')
->assertJsonPath('data.location', 'Predio Ferial, Rosario')
->assertJsonPath('data.dates.0.date', '2026-10-09')
->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.dates.0.start_time', '09:00')
->assertJsonPath('data.dates.0.end_time', '18:30')
->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101')
@@ -64,6 +66,17 @@ class AdminAppEventControllerTest extends TestCase
'time_start' => '09:00:00',
'time_end' => '18:30:00',
]);
$eventDate = $tenant->eventDates()->with('validityTime')->sole();
$this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id'));
$this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type);
$this->assertSame(
'2026-10-09 09:00:00',
$eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-10-09 18:30:00',
$eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
);
$this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'whatsapp',
@@ -110,6 +123,8 @@ class AdminAppEventControllerTest extends TestCase
'time_start' => '08:00',
'time_end' => '12:00',
]);
$firstValidityTimeId = $firstDate->validity_time_id;
$removedValidityTimeId = $removedDate->validity_time_id;
$tenant->socialMedia()->attach('facebook', [
'url' => 'https://facebook.com/old',
'orden' => 2,
@@ -135,9 +150,17 @@ class AdminAppEventControllerTest extends TestCase
$this->assertDatabaseHas('event_dates', [
'id' => $firstDate->id,
'validity_time_id' => $firstValidityTimeId,
'date' => '2026-11-15',
]);
$this->assertDatabaseHas('validity_times', [
'id' => $firstValidityTimeId,
'type' => ValidityTimeType::FixedWindow->value,
'fixed_starts_at' => '2026-11-15 10:00:00',
'fixed_expires_at' => '2026-11-15 20:00:00',
]);
$this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]);
$this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]);
$this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text);
$this->assertDatabaseMissing('tenant_social_media', [
'tenant_code' => $tenant->codigo,

View File

@@ -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\CatalogItem;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -75,6 +76,7 @@ class AccommodationControllerTest extends TestCase
'label' => 'Carpa',
]);
$this->assertDatabaseCount('catalog_items', 1);
$this->assertTrue(CatalogItem::query()->sole()->has_tickets);
$this->assertDatabaseCount('variantes', 2);
$this->assertDatabaseCount('inventories', 2);
$this->assertDatabaseCount('variant_values', 2);
@@ -125,6 +127,25 @@ class AccommodationControllerTest extends TestCase
$this->assertDatabaseCount('variantes', 2);
}
public function test_it_deletes_an_accommodation_and_its_empty_product(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$accommodationId = $this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Carpa', null, 100, 35000),
],
])->assertOk()->json('data.variants.0.id');
$this->deleteJson("/api/v1/adminapp/tenant/accommodations/{$accommodationId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_it_rejects_duplicate_normalized_titles(): void
{
[$tenant] = $this->configuredTenant();

View File

@@ -100,7 +100,6 @@ class EntryControllerTest extends TestCase
CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void {
$this->assertSame($tenant->codigo, $entry->tenant_code);
$this->assertSame($entryCategory->id, $entry->category_id);
$this->assertSame('entrada', $entry->event_product_type->value);
$this->assertSame('tracked', $entry->inventory_policy->value);
$this->assertTrue($entry->has_tickets);
$this->assertNull($entry->inventory_id);
@@ -168,6 +167,35 @@ class EntryControllerTest extends TestCase
]);
}
public function test_it_deletes_an_entry_and_its_inventory(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'title' => 'Entrada diaria',
'description' => null,
'event_date_ids' => [$eventDate->id],
'stock' => 100,
'price' => 5000,
]],
])->assertOk()->json('data.0.id');
$this->deleteJson("/api/v1/adminapp/tenant/entries/{$entryId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_dates_must_belong_to_the_authenticated_tenant(): void
{
$tenant = $this->createFiestaTenant();
@@ -195,6 +223,46 @@ class EntryControllerTest extends TestCase
$this->assertDatabaseCount('catalog_items', 0);
}
public function test_entries_are_identified_by_the_entries_category(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$merchandisingCategory = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Merchandising',
]);
$merchandise = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'category_id' => $merchandisingCategory->id,
'slug' => 'camiseta',
'nombre' => 'Camiseta',
'precio' => 100,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/entries')
->assertOk()
->assertJsonCount(0, 'data');
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'id' => $merchandise->id,
'title' => 'Entrada inválida',
'description' => null,
'event_date_ids' => [$eventDate->id],
'stock' => 10,
'price' => 100,
]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['entries.0.id']);
}
public function test_the_endpoint_is_only_available_for_fiesta_futbol_infantil(): void
{
$tenant = $this->createTenant('other');

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\FiestaFutbolInfantil;
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\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
@@ -68,10 +69,27 @@ class FoodControllerTest extends TestCase
$this->assertDatabaseCount('inventories', 2);
$this->assertDatabaseCount('variant_event_dates', 2);
$this->assertDatabaseCount('variant_values', 4);
$food = CatalogItem::query()->where('slug', 'comida')->sole();
$this->assertDatabaseHas('item_attributes', [
'catalog_item_id' => $food->id,
'attribute_id' => Attribute::query()->where('codigo', 'event_date')->sole()->id,
'sort_order' => 1,
]);
$this->assertDatabaseHas('item_attributes', [
'catalog_item_id' => $food->id,
'attribute_id' => Attribute::query()->where('codigo', 'horario')->sole()->id,
'sort_order' => 2,
]);
$this->assertDatabaseHas('item_attributes', [
'catalog_item_id' => $food->id,
'attribute_id' => Attribute::query()->where('codigo', 'servicio')->sole()->id,
'sort_order' => 3,
]);
$this->assertDatabaseHas('catalog_items', [
'tenant_code' => $tenant->codigo,
'slug' => 'comida',
'nombre' => 'Comida',
'has_tickets' => true,
'category_id' => Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Comidas')
@@ -123,6 +141,39 @@ class FoodControllerTest extends TestCase
]);
}
public function test_it_deletes_food_records_and_removes_the_empty_product(): void
{
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000),
$this->variantPayload($secondDate->id, 'Cena', 'Vianda', 50, 8000),
],
])->assertOk();
$firstId = $created->json('data.variants.0.id');
$secondId = $created->json('data.variants.1.id');
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$secondId}")
->assertNoContent();
$this->assertDatabaseHas('catalog_items', [
'tenant_code' => $tenant->codigo,
'slug' => 'comida',
'precio' => 10000,
]);
$this->assertDatabaseCount('variantes', 1);
$this->assertDatabaseCount('inventories', 1);
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$firstId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_it_rejects_duplicate_combinations(): void
{
[$tenant, $firstDate] = $this->configuredTenant();

View File

@@ -90,6 +90,7 @@ class MerchandiseControllerTest extends TestCase
'precio' => 95000,
'max_units_per_user' => 3,
'inventory_policy' => 'tracked',
'has_tickets' => true,
]);
}
@@ -139,6 +140,40 @@ class MerchandiseControllerTest extends TestCase
]);
}
public function test_it_deletes_merchandise_records_and_removes_the_empty_item(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Camiseta', 3, [
$this->variantPayload('Verde', 'S', 100, 10000),
$this->variantPayload('Blanco', 'M', 50, 15000),
]),
],
])->assertOk();
$itemId = $created->json('data.0.id');
$firstId = $created->json('data.0.variants.0.id');
$secondId = $created->json('data.0.variants.1.id');
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$firstId}")
->assertNoContent();
$this->assertDatabaseHas('catalog_items', [
'id' => $itemId,
'precio' => 15000,
]);
$this->assertDatabaseCount('variantes', 1);
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$secondId}")
->assertNoContent();
$this->assertDatabaseMissing('catalog_items', ['id' => $itemId]);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_it_rejects_duplicate_color_and_size_combinations(): void
{
[$tenant] = $this->configuredTenant();

View File

@@ -38,7 +38,7 @@ class AdminAppFoodFormControllerTest extends TestCase
'dominio' => 'fiesta.test',
'website_type_code' => 'onticket',
]);
$tenant->eventDates()->create([
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
@@ -60,6 +60,8 @@ class AdminAppFoodFormControllerTest extends TestCase
->assertOk()
->assertJsonCount(1, 'data.event_dates')
->assertJsonPath('data.event_dates.0.date', '2026-10-09')
->assertJsonPath('data.event_dates.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.event_dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.schedules.0.value', 'Almuerzo')
->assertJsonPath('data.schedules.1.value', 'Cena')
->assertJsonPath('data.services.0.value', 'Comedor');

View File

@@ -0,0 +1,265 @@
<?php
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\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppSaleControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_authentication_is_required_to_read_a_sale_detail(): void
{
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized();
}
public function test_an_adminapp_user_can_read_a_sale_detail_from_its_tenant(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '40000.00',
'nombre_apellido' => 'Ada Lovelace',
]);
PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => 10,
'nombre' => 'Comida',
'item_nombre' => 'Comida',
'variant_attributes' => [
['name' => 'Fecha', 'value' => ['2026-10-12']],
['name' => 'Servicio', 'value' => 'Almuerzo'],
],
'cantidad' => 2,
'precio_unitario' => '10000.00',
'total' => '20000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}")
->assertOk()
->assertJsonPath('data.id', $purchase->id)
->assertJsonPath('data.items.0.product', 'Comida')
->assertJsonPath('data.items.0.event_dates.0', '2026-10-12')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '10000.00')
->assertJsonPath('data.items.0.total', '20000.00')
->assertJsonPath('data.total', '40000.00');
}
public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}")
->assertNotFound();
}
public function test_an_adminapp_user_can_read_all_tickets_from_a_sale_in_its_tenant(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '20000.00',
]);
$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,
'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,
'used_at' => now()->subMinute(),
'user_id' => $admin->id,
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/tickets")
->assertOk()
->assertExactJson([
'data' => [
[
'product' => 'Abono general',
'id' => $firstTicket->id,
'expires_at' => null,
'status' => Ticket::STATUS_ACTIVE,
],
[
'product' => 'Abono general',
'id' => $usedTicket->id,
'expires_at' => null,
'status' => Ticket::STATUS_USED,
],
],
]);
}
public function test_an_adminapp_user_cannot_read_tickets_from_another_tenant_sale(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/tickets")
->assertNotFound();
}
public function test_an_adminapp_user_can_confirm_a_pending_sale(): void
{
Event::fake([PurchasePaid::class]);
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID);
$this->assertSame(Purchase::STATUS_PAID, $purchase->fresh()->status);
$this->assertDatabaseHas('value_changes', [
'trackable_id' => $purchase->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'user_id' => $admin->id,
]);
Event::assertDispatchedTimes(PurchasePaid::class, 1);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID);
Event::assertDispatchedTimes(PurchasePaid::class, 1);
}
public function test_adminapp_cancellation_does_not_restore_the_source_cart(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $admin->id,
'status' => 'converted',
]);
$cart->delete();
$purchase = Purchase::query()->create([
'cart_id' => $cart->id,
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertSame(Purchase::STATUS_CANCELLED, $purchase->fresh()->status);
$this->assertSoftDeleted('carritos', ['id' => $cart->id]);
$this->assertSame('converted', Cart::withTrashed()->findOrFail($cart->id)->status);
}
public function test_an_adminapp_user_cannot_change_a_sale_from_another_tenant(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/confirm")
->assertNotFound();
$this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/cancel")
->assertNotFound();
}
public function test_a_paid_sale_cannot_be_cancelled_from_adminapp(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertUnprocessable()
->assertJsonValidationErrors('purchase');
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -5,11 +5,14 @@ namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Attribute;
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 Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -40,6 +43,8 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
$this->assertSame('9, 10, 11 y 12 de Octubre 2026', $tenant->fresh()->event_date_text);
$this->assertSame(
['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'],
Attribute::query()->where('tenant_codigo', $tenant->codigo)->orderBy('codigo')->pluck('codigo')->all(),
@@ -56,6 +61,18 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
['abono', 'alojamiento', 'camiseta', 'comida'],
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->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,
@@ -68,6 +85,16 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->assertCount($count, $item->variants);
}
$variantStocks = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->with('variants.inventory')
->get()
->flatMap(fn (CatalogItem $item) => $item->variants)
->map(fn ($variant): int => $variant->inventory->real_stock);
$this->assertGreaterThan($variantStocks->count() / 2, $variantStocks->filter(fn (int $stock): bool => $stock > 5)->count());
$this->assertNotEmpty($variantStocks->filter(fn (int $stock): bool => $stock === 0));
$this->assertNotEmpty($variantStocks->filter(fn (int $stock): bool => $stock >= 1 && $stock <= 5));
$this->assertSame(
[
'abono' => 'Entradas',
@@ -113,6 +140,8 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);
$this->assertNull($featuredGroup->category_id);
}

View File

@@ -25,6 +25,7 @@ class MenuSeederTest extends TestCase
$this->seed(MenuSeeder::class);
$expectedMenus = [
'adminapp.inicio' => ['Inicio', '/admin/inicio'],
'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
'adminapp.categories' => ['Categorías', '/admin/categories'],
'adminapp.combos' => ['Combos', '/admin/combos'],
@@ -81,6 +82,17 @@ class MenuSeederTest extends TestCase
$this->assertTrue($fiestaTenant->menues()->where('menues.code', $code)->exists());
}
foreach ([
'adminapp.inicio',
'adminapp.catalog',
'adminapp.categories',
'adminapp.combos',
] as $code) {
$this->assertFalse(
$fiestaTenant->menues()->where('menues.code', $code)->exists()
);
}
$this->assertFalse(
Menu::query()->whereIn('code', [
'admin.event',

View File

@@ -53,6 +53,8 @@ class BootstrapTenantControllerTest extends TestCase
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
'event_date_text' => '9, 10, 11 y 12 de Octubre 2026',
'display_categories' => false,
'display_seach_bar' => false,
]);
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
@@ -66,6 +68,8 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.danger_color', '#0000ff')
->assertJsonPath('data.success_color', '#00ff00')
->assertJsonPath('data.event_date_text', '9, 10, 11 y 12 de Octubre 2026')
->assertJsonPath('data.display_categories', false)
->assertJsonPath('data.display_seach_bar', false)
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');
$headerUrl = $response->json('data.header_logo');
@@ -244,10 +248,10 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.menues.0.code', 'help')
->assertJsonPath('data.menues.0.label', 'Ayuda')
->assertJsonPath('data.menues.0.parent_menu_code', null)
->assertJsonPath('data.menues.0.submenues.0.code', 'help.faq')
->assertJsonPath('data.menues.0.submenues.0.label', 'Preguntas frecuentes')
->assertJsonPath('data.menues.0.submenues.0.code', 'help.shipping')
->assertJsonPath('data.menues.0.submenues.0.label', 'Envíos')
->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help')
->assertJsonPath('data.menues.0.submenues.1.code', 'help.shipping');
->assertJsonPath('data.menues.0.submenues.1.code', 'help.faq');
$assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void {
$this->assertArrayNotHasKey('created_at', $menu);
@@ -261,10 +265,10 @@ class BootstrapTenantControllerTest extends TestCase
};
$menus = $response->json('data.menues');
$this->assertEquals($staticContent, $menus[0]['submenues'][0]['static_content']);
$this->assertEquals($staticContent, $menus[0]['submenues'][1]['static_content']);
$assertCleanMenu($menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]);
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][0]);
}
public function test_it_returns_only_menus_assigned_to_the_user_role(): void
@@ -299,6 +303,49 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonMissing(['code' => 'admin.catalog']);
}
public function test_it_orders_parent_menus_and_their_submenus_by_label(): void
{
$tenant = $this->createTenant();
$userRole = Role::query()->create([
'codigo' => RoleCode::User->value,
'nombre' => 'Usuario',
]);
$zeta = Menu::query()->create([
'code' => 'zeta',
'label' => 'Zeta',
'route' => '/zeta',
]);
$tree = Menu::query()->create([
'code' => 'tree',
'label' => 'Árbol',
'route' => '/arbol',
]);
$fox = Menu::query()->create([
'code' => 'tree.fox',
'label' => 'Zorro',
'parent_menu_code' => $tree->code,
'route' => '/arbol/zorro',
]);
$eagle = Menu::query()->create([
'code' => 'tree.eagle',
'label' => 'Águila',
'parent_menu_code' => $tree->code,
'route' => '/arbol/aguila',
]);
$menuCodes = [$zeta->code, $tree->code, $fox->code, $eagle->code];
$tenant->menues()->sync($menuCodes);
$userRole->menus()->sync($menuCodes);
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.menues.0.code', 'tree')
->assertJsonPath('data.menues.1.code', 'zeta')
->assertJsonPath('data.menues.0.submenues.0.code', 'tree.eagle')
->assertJsonPath('data.menues.0.submenues.1.code', 'tree.fox');
}
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
{
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

View File

@@ -6,7 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
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;
@@ -21,6 +23,22 @@ 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(),
]),
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
]),
]);
$validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void {
$group = $newerTicket->validityGroups()->create();
$group->validityTimes()->attach($validityTime);
});
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets")
@@ -31,6 +49,10 @@ class TicketControllerTest extends TestCase
->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_time')
->assertJsonPath('data.1.id', $olderTicket->id)
->assertJsonMissingPath('meta')
->assertJsonMissingPath('links');

View File

@@ -14,6 +14,7 @@ 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;
@@ -69,7 +70,7 @@ 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->assertNull($ticket->validity_time_id);
$this->assertTrue($ticket->validityGroups->isEmpty());
}
}
@@ -84,6 +85,52 @@ class TicketGeneratorServiceTest extends TestCase
$this->service->generate($item->fresh(), $this->user);
}
public function test_variant_properties_are_appended_to_the_ticket_name(): void
{
$item = $this->createTicketableItem('shirt');
$color = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'color',
'nombre' => 'Color',
'type' => FieldType::Select,
]);
$color->options()->create([
'value' => 'black',
'label' => 'Negro',
]);
$size = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'size',
'nombre' => 'Talle',
'type' => FieldType::Select,
]);
$size->options()->create([
'value' => 'xl',
'label' => 'XL',
]);
$itemColor = $item->itemAttributes()->create([
'attribute_id' => $color->id,
'sort_order' => 1,
]);
$itemSize = $item->itemAttributes()->create([
'attribute_id' => $size->id,
'sort_order' => 2,
]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->definitions()->createMany([
['item_attribute_id' => $itemColor->id, 'value' => 'black'],
['item_attribute_id' => $itemSize->id, 'value' => 'xl'],
]);
$ticket = $this->service
->generate($item, $this->user, 1, $variant->id)
->sole();
$this->assertSame('Shirt (Negro, XL)', $ticket->name);
}
public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void
{
$first = $this->createTicketableItem('first');
@@ -185,7 +232,7 @@ class TicketGeneratorServiceTest extends TestCase
]);
}
public function test_event_date_and_time_window_are_snapshotted_as_a_fixed_window(): void
public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void
{
$item = $this->createTicketableItem('scheduled-meal');
$eventDate = EventDate::query()->create([
@@ -226,13 +273,22 @@ class TicketGeneratorServiceTest extends TestCase
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$this->assertCount(2, $tickets);
$this->assertSame(1, $tickets->pluck('validity_time_id')->unique()->count());
$this->assertNotSame($timeWindow->id, $tickets->first()->validity_time_id);
$fixedWindow = $tickets->first()->validityTime;
$this->assertSame(ValidityTimeType::FixedWindow, $fixedWindow->type);
$this->assertSame('2026-08-20 22:00:00', $fixedWindow->fixed_starts_at->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-21 02:00:00', $fixedWindow->fixed_expires_at->format('Y-m-d H:i:s'));
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes
->pluck('id')
->sort()
->values()
->all() === collect([$eventDate->validity_time_id, $timeWindow->id])->sort()->values()->all()
));
$this->assertSame(
'2026-08-20 22:00:00',
$tickets->first()->getEffectiveStartsAt()->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-08-20 23:59:59',
$tickets->first()->getEffectiveExpiresAt()->format('Y-m-d H:i:s'),
);
}
public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void
@@ -250,16 +306,134 @@ class TicketGeneratorServiceTest extends TestCase
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 1, $variant->id);
$tickets->each->loadMissing('validityTime');
$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->validityTime->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
$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->validityGroups->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(),
);
$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()
->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'),
])
->all() === [
['2026-08-20 00:00:00', '2026-08-20 23:59:59'],
['2026-08-21 00:00:00', '2026-08-21 23:59:59'],
]
));
}
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,
'time_start' => '09:00:00',
'time_end' => '18:00:00',
]));
$lunch = ValidityTime::query()->create([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]);
$schedule = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'schedule',
'nombre' => 'Schedule',
'type' => FieldType::Select,
]);
$schedule->options()->create([
'value' => 'lunch',
'label' => 'Lunch',
'validity_time_id' => $lunch->id,
]);
$itemSchedule = $item->itemAttributes()->create(['attribute_id' => $schedule->id]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$variant->definitions()->create([
'item_attribute_id' => $itemSchedule->id,
'value' => 'lunch',
]);
$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(
fn ($group): bool => $group->validityTimes->count() === 2
&& $group->validityTimes->contains($lunch)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$ticket->validityGroups
->flatMap->validityTimes
->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch))
->pluck('id')
->all(),
);
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
Carbon::setTestNow('2026-08-20 13:00:00');
$this->assertTrue($ticket->isValid());
Carbon::setTestNow('2026-08-20 16:00:00');
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
}
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
{
Event::fake([TicketsAvailable::class]);

View File

@@ -23,9 +23,16 @@ class TicketValiditySchemaTest extends TestCase
'updated_at',
], Schema::getColumnListing('validity_times'));
$this->assertTrue(Schema::hasColumns('tickets', [
$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::hasColumn('tickets', 'service_date'));
$this->assertFalse(Schema::hasColumn('tickets', 'starts_at'));
$this->assertFalse(Schema::hasColumn('tickets', 'expires_at'));
@@ -34,6 +41,7 @@ class TicketValiditySchemaTest extends TestCase
$this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date'));
$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'));

View File

@@ -4,7 +4,6 @@ namespace Tests\Unit\Catalog;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\InventoryPolicy;
@@ -76,7 +75,6 @@ class CatalogModelsTest extends TestCase
'category_id' => '10',
'brand_id' => '20',
'inventory_id' => '30',
'event_product_type' => EventProductType::Entry->value,
'type' => CatalogItemType::Standard->value,
'precio' => '12.50',
'inventory_policy' => InventoryPolicy::Tracked->value,
@@ -88,7 +86,6 @@ class CatalogModelsTest extends TestCase
$this->assertSame(10, $item->category_id);
$this->assertSame(20, $item->brand_id);
$this->assertSame(30, $item->inventory_id);
$this->assertSame(EventProductType::Entry, $item->event_product_type);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
@@ -186,6 +183,80 @@ class CatalogModelsTest extends TestCase
$this->assertSame('2026-10-09', $variant->eventDate->date->format('Y-m-d'));
}
public function test_variant_exposes_selection_values_with_api_labels(): void
{
$attribute = new Attribute;
$attribute->codigo = 'size';
$attribute->setRelation('options', new EloquentCollection([
new AttributeOption(['value' => 'M', 'label' => 'Medium']),
]));
$itemAttribute = new ItemAttribute;
$itemAttribute->allow_multi_select = false;
$itemAttribute->setRelation('attribute', $attribute);
$definition = new VariantDefinition(['value' => 'M']);
$definition->item_attribute_id = 1;
$definition->setRelation('itemAttribute', $itemAttribute);
$eventDate = new EventDate([
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:00:00',
]);
$eventDate->id = 20;
$variant = new Variant;
$variant->setRelation('definitions', new EloquentCollection([$definition]));
$variant->setRelation('eventDates', new EloquentCollection([$eventDate]));
$this->assertSame(
['value' => 'M', 'label' => 'Medium'],
$variant->selectionOptions()->get('size'),
);
$this->assertSame(
['value' => '20', 'label' => '09/10/2026'],
$variant->selectionOptions()->get('event_date'),
);
}
public function test_variant_orders_selection_options_by_item_order_and_attribute_label(): void
{
$definitions = collect([
['id' => 1, 'code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2],
['id' => 2, 'code' => 'priority', 'label' => 'Priority', 'sort_order' => 1],
['id' => 3, 'code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2],
])->map(function (array $data): VariantDefinition {
$attribute = new Attribute([
'codigo' => $data['code'],
'nombre' => $data['label'],
]);
$attribute->setRelation('options', new EloquentCollection);
$itemAttribute = new ItemAttribute([
'sort_order' => $data['sort_order'],
'allow_multi_select' => false,
]);
$itemAttribute->id = $data['id'];
$itemAttribute->setRelation('attribute', $attribute);
$definition = new VariantDefinition(['value' => $data['code']]);
$definition->item_attribute_id = $itemAttribute->id;
$definition->setRelation('itemAttribute', $itemAttribute);
return $definition;
});
$variant = new Variant;
$variant->setRelation('definitions', new EloquentCollection($definitions));
$variant->setRelation('eventDates', new EloquentCollection);
$this->assertSame(
['priority', 'alpha', 'zeta'],
$variant->selectionOptions()->keys()->all(),
);
}
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
{
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);
@@ -212,6 +283,27 @@ class CatalogModelsTest extends TestCase
$this->assertTrue($item->isAvailable());
}
public function test_catalog_item_only_exposes_available_tracked_variants(): void
{
$unavailable = (new Variant)->setRelation('inventory', $this->trackedInventory(3, 3));
$available = (new Variant)->setRelation('inventory', $this->trackedInventory(5, 2));
$unavailable->id = 10;
$available->id = 20;
$item = new CatalogItem;
$item->inventory_policy = InventoryPolicy::Tracked;
$item->setRelation('variants', new EloquentCollection([$unavailable, $available]));
$this->assertSame([$available], $item->visibleVariants()->all());
$this->assertSame(
[$unavailable, $available],
$item->visibleVariants($unavailable->id)->all(),
);
$item->inventory_policy = InventoryPolicy::Unlimited;
$this->assertSame([$unavailable, $available], $item->visibleVariants()->all());
}
public function test_catalog_item_prioritizes_its_inventory_over_variants(): void
{
$item = new CatalogItem;

View File

@@ -5,6 +5,7 @@ namespace Tests\Unit\Event;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ValidityTime;
use Tests\TestCase;
class EventModelsTest extends TestCase
@@ -14,6 +15,7 @@ class EventModelsTest extends TestCase
$eventDate = new EventDate;
$eventDate->setRawAttributes([
'tenant_code' => 'acme',
'validity_time_id' => '12',
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:30:00',
@@ -21,9 +23,12 @@ class EventModelsTest extends TestCase
$this->assertFalse($eventDate->usesTimestamps());
$this->assertSame('acme', $eventDate->tenant_code);
$this->assertSame(12, $eventDate->validity_time_id);
$this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated());
$this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated());
$this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated());
$this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated());
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use Illuminate\Http\Request;
use Tests\TestCase;
class SaleDetailResourceTest extends TestCase
{
public function test_it_serializes_the_purchased_item_snapshot(): void
{
$purchase = (new Purchase)->forceFill([
'id' => 15,
'total' => '30000.00',
]);
$item = (new PurchaseItem)->forceFill([
'id' => 8,
'item_nombre' => 'Comida',
'variant_attributes' => [
['name' => 'Fecha', 'value' => ['2026-10-12']],
],
'cantidad' => 3,
'precio_unitario' => '10000.00',
'total' => '30000.00',
]);
$purchase->setRelation('items', collect([$item]));
$data = (new SaleDetailResource($purchase))->resolve(Request::create('/'));
$this->assertSame(15, $data['id']);
$this->assertSame('Comida', $data['items'][0]['product']);
$this->assertSame(['2026-10-12'], $data['items'][0]['event_dates']);
$this->assertSame(3, $data['items'][0]['quantity']);
$this->assertSame('10000.00', $data['items'][0]['unit_price']);
$this->assertSame('30000.00', $data['items'][0]['total']);
$this->assertSame('30000.00', $data['total']);
}
}

View File

@@ -8,7 +8,9 @@ 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 Illuminate\Support\Carbon;
use Tests\TestCase;
@@ -27,7 +29,6 @@ class TicketTest extends TestCase
$ticket->setRawAttributes([
'source_catalog_item_id' => '20',
'source_variant_id' => '30',
'validity_time_id' => '40',
'used_at' => null,
'scanner_user_id' => '15',
'user_id' => '10',
@@ -37,7 +38,6 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->usesTimestamps());
$this->assertSame(20, $ticket->source_catalog_item_id);
$this->assertSame(30, $ticket->source_variant_id);
$this->assertSame(40, $ticket->validity_time_id);
$this->assertNull($ticket->used_at);
$this->assertSame(15, $ticket->scanner_user_id);
$this->assertSame(10, $ticket->user_id);
@@ -46,12 +46,16 @@ 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(ValidityTime::class, $ticket->validityTime()->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
{
$this->assertTrue((new Ticket)->isValid());
$ticket = new Ticket;
$this->assertTrue($ticket->isValid());
$this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status);
}
public function test_fixed_window_controls_ticket_validity(): void
@@ -70,12 +74,11 @@ class TicketTest extends TestCase
public function test_time_window_is_resolved_for_current_date(): void
{
Carbon::setTestNow('2026-07-21 12:00:00');
$ticket = new Ticket;
$ticket->setRelation('validityTime', new ValidityTime([
$ticket = $this->ticketWithValidityGroups([[new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '10:00:00',
'end_time' => '14:00:00',
]));
])]]);
$this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
@@ -92,6 +95,50 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->is_expired);
$this->assertSame(Ticket::STATUS_EXPIRED, $ticket->status);
}
public function test_ticket_is_valid_when_any_validity_time_is_active(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = $this->ticketWithValidityGroups([
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subDays(2),
'fixed_expires_at' => now()->subDay(),
])],
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
])],
]);
$this->assertTrue($ticket->isValid());
$this->assertFalse($ticket->is_expired);
$this->assertSame('2026-07-19 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-07-21 11:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
}
public function test_ticket_is_invalid_but_not_expired_between_validity_times(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = $this->ticketWithValidityGroups([
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subDays(2),
'fixed_expires_at' => now()->subDay(),
])],
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
])],
]);
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
$this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status);
}
public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void
@@ -105,12 +152,51 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->is_valid);
$this->assertFalse($ticket->is_expired);
$this->assertTrue($ticket->is_used);
$this->assertSame(Ticket::STATUS_USED, $ticket->status);
}
public function test_all_validity_times_in_the_same_group_must_be_active(): void
{
Carbon::setTestNow('2026-08-20 13:00:00');
$ticket = $this->ticketWithValidityGroups([[
new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => '2026-08-20 09:00:00',
'fixed_expires_at' => '2026-08-20 18:00:00',
]),
new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]),
]]);
$this->assertTrue($ticket->isValid());
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-20 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
Carbon::setTestNow('2026-08-20 16:00:00');
$this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->is_expired);
}
private function ticketWithValidityTime(ValidityTime $validityTime): Ticket
{
return $this->ticketWithValidityGroups([[$validityTime]]);
}
/** @param array<int, array<int, ValidityTime>> $validityGroups */
private function ticketWithValidityGroups(array $validityGroups): Ticket
{
$ticket = new Ticket;
$ticket->setRelation('validityTime', $validityTime);
$groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup {
$group = new TicketValidityGroup;
$group->setRelation('validityTimes', new EloquentCollection($validityTimes));
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
return $ticket;
}

View File

@@ -5,7 +5,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\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Support\Carbon;
use Tests\TestCase;
@@ -26,7 +26,10 @@ class ValidityTimeTest extends TestCase
$this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at);
$this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated());
$this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated());
$this->assertInstanceOf(Ticket::class, $validityTime->tickets()->getRelated());
$this->assertInstanceOf(
TicketValidityGroup::class,
$validityTime->ticketValidityGroups()->getRelated(),
);
}
public function test_it_exposes_supported_type_values(): void