refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared

This commit is contained in:
2026-09-18 10:14:02 -03:00
parent 4659c1049d
commit 1241e1f7e8
425 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Desfile\Controllers;
use App\Domains\Desfile\Requests\ReplaceEntryImageRequest;
use App\Domains\Desfile\Requests\SyncEntryRowsRequest;
use App\Domains\Desfile\Requests\UpdateEntryImageRequest;
use App\Domains\Desfile\Resources\EntryResource;
use App\Domains\Desfile\Services\EntryService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EntryController extends Controller
{
public function __construct(private readonly EntryService $entryService) {}
public function show(Request $request): EntryResource
{
return new EntryResource(
$this->entryService->current($request->user()->tenant()->firstOrFail()),
);
}
public function update(SyncEntryRowsRequest $request): EntryResource
{
return new EntryResource($this->entryService->syncRows(
$request->user()->tenant()->firstOrFail(),
$request->validated('rows'),
));
}
public function replaceImage(ReplaceEntryImageRequest $request): JsonResponse
{
return (new EntryResource($this->entryService->replaceImage(
$request->user()->tenant()->firstOrFail(),
$request->file('image'),
$request->boolean('is_enabled', true),
)))->response();
}
public function updateImage(UpdateEntryImageRequest $request): EntryResource
{
return new EntryResource($this->entryService->updateImage(
$request->user()->tenant()->firstOrFail(),
$request->boolean('is_enabled'),
));
}
public function destroyImage(Request $request): Response
{
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
return response()->noContent();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Desfile\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ReplaceEntryImageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'image' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:10240'],
'is_enabled' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Desfile\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class SyncEntryRowsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'rows' => ['required', 'array', 'min:1', 'max:1000'],
'rows.*' => ['required', 'array:type,sector,row,max_seat,price'],
'rows.*.type' => ['required', 'string', 'max:100'],
'rows.*.sector' => ['required', 'string', 'max:100'],
'rows.*.row' => ['required', 'integer', 'between:1,5'],
'rows.*.max_seat' => ['required', 'integer', 'between:1,100'],
'rows.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [function (Validator $validator): void {
$combinations = [];
foreach ($this->input('rows', []) as $index => $row) {
if (! is_array($row)) {
continue;
}
$combination = collect(['type', 'sector', 'row'])
->map(fn (string $field): string => mb_strtolower(trim((string) ($row[$field] ?? ''))))
->implode('|');
if (isset($combinations[$combination])) {
$validator->errors()->add(
"rows.{$index}",
'La combinación de tipo, sector y fila no puede repetirse.',
);
}
$combinations[$combination] = true;
}
}];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Desfile\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEntryImageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'is_enabled' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domains\Desfile\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class EntryResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$image = $this->allAttachments->first();
return [
'id' => $this->id,
'rows' => $this->variants
->map(function ($variant): array {
$values = $variant->selectionValues();
return [
'type' => $values->get('tipo'),
'sector' => $values->get('sector'),
'row' => $values->get('fila'),
'seat' => (int) $values->get('asiento'),
'price' => $variant->getPrice(),
];
})
->groupBy(fn (array $variant): string => implode('|', [
mb_strtolower(trim((string) $variant['type'])),
mb_strtolower(trim((string) $variant['sector'])),
mb_strtolower(trim((string) $variant['row'])),
]))
->map(function ($variants): array {
$first = $variants->first();
return [
'type' => $first['type'],
'sector' => $first['sector'],
'row' => $first['row'],
'max_seat' => $variants->max('seat'),
'price' => number_format($first['price'], 2, '.', ''),
];
})
->values(),
'image' => $image === null ? null : [
'key' => $image->key,
'filename' => $image->filename,
'url' => $image->getTemporaryUrl(1440),
'is_enabled' => (bool) $image->pivot->is_enabled,
],
];
}
}

View File

@@ -0,0 +1,302 @@
<?php
namespace App\Domains\Desfile\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Throwable;
class EntryService
{
private const ATTRIBUTE_MAP = [
'type' => 'tipo',
'sector' => 'sector',
'row' => 'fila',
'seat' => 'asiento',
];
private const ROW_ATTRIBUTE_MAP = [
'type' => 'tipo',
'sector' => 'sector',
'row' => 'fila',
];
public function __construct(private readonly AttachmentService $attachmentService) {}
public function current(Tenant $tenant): CatalogItem
{
return $this->entryQuery($tenant)
->with([
'allAttachments',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.definitions.itemAttribute.attribute',
])
->firstOrFail();
}
/**
* @param array<int, array<string, mixed>> $rows
*/
public function syncRows(Tenant $tenant, array $rows): CatalogItem
{
DB::transaction(function () use ($tenant, $rows): void {
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
$itemAttributes = $this->itemAttributes($entry);
$existingVariants = $entry->variants()
->with(['inventory', 'definitions.itemAttribute.attribute'])
->lockForUpdate()
->get();
$existingBySelection = $existingVariants->keyBy(
fn (Variant $variant): string => $this->selectionKey($variant->selectionValues()->all()),
);
$desiredSelections = collect();
foreach (array_values($rows) as $index => $data) {
$rowValues = $this->resolveRowValues($itemAttributes, $data, $index);
foreach (range(1, (int) $data['max_seat']) as $seat) {
$values = [
...$rowValues,
'asiento' => $this->resolveAttributeValue(
$itemAttributes,
'asiento',
(string) $seat,
"rows.{$index}.max_seat",
),
];
$selectionKey = $this->selectionKey($values);
$desiredSelections->put($selectionKey, true);
$variant = $existingBySelection->get($selectionKey);
if ($variant === null) {
$variant = $entry->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id,
'descripcion' => $this->description($values),
'precio' => $data['price'],
]);
$variant->definitions()->createMany(
collect($values)->map(
fn (string $value, string $code): array => [
'item_attribute_id' => $itemAttributes[$code]->id,
'value' => $value,
],
)->values()->all(),
);
} else {
$variant->update([
'descripcion' => $this->description($values),
'precio' => $data['price'],
]);
}
}
}
foreach ($existingVariants as $variant) {
$selectionKey = $this->selectionKey($variant->selectionValues()->all());
if ($desiredSelections->has($selectionKey)) {
continue;
}
$this->assertVariantCanChangeIdentity($variant, 'rows');
$variant->delete();
}
$minimumPrice = $entry->variants()->min('precio');
if ($minimumPrice !== null) {
$entry->update(['precio' => $minimumPrice]);
}
});
return $this->current($tenant);
}
public function replaceImage(
Tenant $tenant,
UploadedFile $image,
bool $isEnabled,
): CatalogItem {
$attachment = $this->attachmentService->store($image, 'catalog-items');
$previousAttachments = collect();
try {
DB::transaction(function () use ($tenant, $attachment, $isEnabled, &$previousAttachments): void {
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
$previousAttachments = $entry->allAttachments()->get();
$entry->allAttachments()->sync([
$attachment->id => [
'orden' => 0,
'is_enabled' => $isEnabled,
],
]);
});
} catch (Throwable $throwable) {
$this->deleteAttachmentQuietly($attachment);
throw $throwable;
}
$previousAttachments->each(fn (Attachment $previous) => $this->deleteIfUnused($previous));
return $this->current($tenant);
}
public function updateImage(Tenant $tenant, bool $isEnabled): CatalogItem
{
DB::transaction(function () use ($tenant, $isEnabled): void {
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
$entry->allAttachments()->updateExistingPivot($attachment->id, [
'is_enabled' => $isEnabled,
]);
});
return $this->current($tenant);
}
public function deleteImage(Tenant $tenant): void
{
$attachment = DB::transaction(function () use ($tenant): Attachment {
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
$entry->allAttachments()->detach($attachment->id);
return $attachment;
});
$this->deleteIfUnused($attachment);
}
/** @return Collection<string, ItemAttribute> */
private function itemAttributes(CatalogItem $entry): Collection
{
$attributes = $entry->itemAttributes()
->with('attribute.options')
->get()
->filter(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute !== null)
->keyBy(fn (ItemAttribute $itemAttribute): string => $itemAttribute->attribute->codigo);
$missing = collect(self::ATTRIBUTE_MAP)->diff($attributes->keys());
if ($missing->isNotEmpty()) {
throw ValidationException::withMessages([
'rows' => [
'Faltan atributos requeridos para las entradas del desfile: '.$missing->implode(', ').'.',
],
]);
}
return $attributes;
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function resolveRowValues(Collection $itemAttributes, array $data, int $index): array
{
$values = [];
foreach (self::ROW_ATTRIBUTE_MAP as $input => $code) {
$values[$code] = $this->resolveAttributeValue(
$itemAttributes,
$code,
(string) $data[$input],
"rows.{$index}.{$input}",
);
}
return $values;
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function resolveAttributeValue(
Collection $itemAttributes,
string $code,
string $requestedValue,
string $errorKey,
): string {
$requestedValue = trim($requestedValue);
$option = $itemAttributes[$code]->attribute->options->first(
fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue),
);
if ($option === null) {
throw ValidationException::withMessages([
$errorKey => ['La opción seleccionada no es válida.'],
]);
}
return $option->value;
}
/** @param array<string, mixed> $values */
private function selectionKey(array $values): string
{
return collect(self::ATTRIBUTE_MAP)
->map(fn (string $code): string => $this->normalize((string) ($values[$code] ?? '')))
->implode('|');
}
private function assertVariantCanChangeIdentity(Variant $variant, string $key): void
{
$inventory = $variant->inventory;
if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) {
throw ValidationException::withMessages([
$key => [
'No se puede modificar ni eliminar un asiento con ventas o reservas.',
],
]);
}
}
/** @param array<string, string> $values */
private function description(array $values): string
{
return "Sector {$values['sector']} - Fila {$values['fila']} - Asiento {$values['asiento']} - {$values['tipo']}";
}
private function normalize(string $value): string
{
return Str::ascii(mb_strtolower(trim($value)));
}
/** @return Builder<CatalogItem> */
private function entryQuery(Tenant $tenant): Builder
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'entrada');
}
private function deleteIfUnused(Attachment $attachment): void
{
if (DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
return;
}
$this->deleteAttachmentQuietly($attachment);
}
private function deleteAttachmentQuietly(Attachment $attachment): void
{
try {
$this->attachmentService->delete($attachment);
} catch (Throwable $throwable) {
report($throwable);
}
}
}

View File

@@ -0,0 +1,443 @@
<?php
namespace App\Domains\Desfile\Services;
use DateTimeInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
class InvitationPurchaseProvisioner
{
public const TENANT_CODE = 'desfile_pura_tendencia';
public const USER_EMAIL = 'invitados@puratendencia.com';
private const PAYMENT_METHOD = 'invitation';
/**
* @var list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>
*/
private const ALLOCATIONS = [
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
];
/**
* @param list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>|null $allocations
*/
public function provision(?array $allocations = null): void
{
if (! $this->prerequisitesExist()) {
return;
}
DB::transaction(function () use ($allocations): void {
$now = now();
$userId = $this->userId($now);
$purchaseId = $this->purchaseId($userId, $now);
$catalogItem = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->where('slug', 'entrada')
->first();
if ($catalogItem === null) {
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
}
foreach ($allocations ?? self::ALLOCATIONS as $allocation) {
foreach (range($allocation['first_seat'], $allocation['last_seat']) as $seat) {
$variant = $this->variant(
(int) $catalogItem->id,
$allocation['sector'],
$allocation['row'],
$seat,
$allocation['type'],
);
$purchaseItemId = $this->createPurchaseItem(
$purchaseId,
$catalogItem,
$variant,
$allocation['sector'],
$allocation['row'],
$seat,
$allocation['type'],
$now,
);
$this->createTicketAndCommitStock(
$purchaseId,
$purchaseItemId,
$userId,
(int) $catalogItem->id,
$variant,
$now,
);
}
}
});
}
private function prerequisitesExist(): bool
{
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
return false;
}
if (! DB::table('roles')->where('codigo', 'user')->exists()) {
throw new RuntimeException('No se encontró el rol de usuario común.');
}
if (! DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->where('slug', 'entrada')
->exists()) {
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
}
return true;
}
private function userId(DateTimeInterface $now): int
{
$user = DB::table('users')
->where('active_email', self::USER_EMAIL)
->where('rol_codigo', 'user')
->whereNull('deleted_at')
->first();
if ($user !== null) {
if ($user->tenant_codigo !== self::TENANT_CODE) {
throw new RuntimeException('El email de invitados ya pertenece a otro tenant.');
}
DB::table('users')->where('id', $user->id)->update([
'rol_codigo' => 'user',
'nombre_apellido' => 'Invitados Pura Tendencia',
'updated_at' => $now,
]);
return (int) $user->id;
}
return DB::table('users')->insertGetId([
'rol_codigo' => 'user',
'tenant_codigo' => self::TENANT_CODE,
'nombre_apellido' => 'Invitados Pura Tendencia',
'email' => self::USER_EMAIL,
'email_verified_at' => $now,
'password' => Hash::make(Str::random(64)),
'dni' => null,
'telefono' => null,
'remember_token' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function purchaseId(int $userId, DateTimeInterface $now): int
{
$purchaseId = DB::table('compras')
->where('tenant_codigo', self::TENANT_CODE)
->where('user_id', $userId)
->where('payment_method', self::PAYMENT_METHOD)
->value('id');
if ($purchaseId !== null) {
DB::table('compras')->where('id', $purchaseId)->update([
'status' => 'paid',
'total' => 0,
'updated_at' => $now,
]);
return (int) $purchaseId;
}
return DB::table('compras')->insertGetId([
'tenant_codigo' => self::TENANT_CODE,
'user_id' => $userId,
'cart_id' => null,
'status' => 'paid',
'payment_method' => self::PAYMENT_METHOD,
'total' => 0,
'dni' => null,
'transfer_payer_dni' => null,
'telefono' => null,
'nombre_apellido' => 'Invitados Pura Tendencia',
'email' => self::USER_EMAIL,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function variant(
int $catalogItemId,
string $sector,
int $row,
int $seat,
string $type,
): object {
$variant = $this->findVariant($catalogItemId, $sector, $row, $seat);
if ($variant === null) {
$variant = $this->createVariant($catalogItemId, $sector, $row, $seat, $type);
}
$this->ensureAttributeOptions($catalogItemId, [
'tipo' => $type,
'sector' => $sector,
'fila' => (string) $row,
'asiento' => (string) $seat,
]);
$typeValueId = DB::table('variant_values as variant_value')
->join('item_attributes as item_attribute', 'item_attribute.id', '=', 'variant_value.item_attribute_id')
->join('attribute', 'attribute.id', '=', 'item_attribute.attribute_id')
->where('variant_value.variant_id', $variant->id)
->where('attribute.codigo', 'tipo')
->value('variant_value.id');
if ($typeValueId === null) {
throw new RuntimeException("La variante {$variant->id} no tiene definido el tipo de entrada.");
}
DB::table('variant_values')->where('id', $typeValueId)->update(['value' => $type]);
DB::table('variantes')->where('id', $variant->id)->update([
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
]);
return DB::table('variantes')->where('id', $variant->id)->first();
}
/** @param array<string, string> $values */
private function ensureAttributeOptions(int $catalogItemId, array $values): void
{
$attributes = DB::table('item_attributes')
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
->where('item_attributes.catalog_item_id', $catalogItemId)
->pluck('attribute.id', 'attribute.codigo');
foreach ($values as $code => $value) {
$attributeId = $attributes[$code] ?? null;
if ($attributeId === null) {
throw new RuntimeException("Falta el atributo {$code} en el catálogo de entradas.");
}
if (DB::table('attribute_options')
->where('attribute_id', $attributeId)
->where('value', $value)
->exists()) {
continue;
}
$lastSortOrder = (int) DB::table('attribute_options')
->where('attribute_id', $attributeId)
->max('sort_order');
DB::table('attribute_options')->insert([
'attribute_id' => $attributeId,
'validity_time_id' => null,
'value' => $value,
'label' => $value,
'sort_order' => $lastSortOrder + 1,
'metadata' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
private function findVariant(int $catalogItemId, string $sector, int $row, int $seat): ?object
{
$query = DB::table('variantes')->where('catalog_item_id', $catalogItemId);
foreach (['sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) {
$query->whereExists(fn ($subquery) => $subquery
->selectRaw('1')
->from('variant_values as selected_value')
->join('item_attributes as selected_item_attribute', 'selected_item_attribute.id', '=', 'selected_value.item_attribute_id')
->join('attribute as selected_attribute', 'selected_attribute.id', '=', 'selected_item_attribute.attribute_id')
->whereColumn('selected_value.variant_id', 'variantes.id')
->where('selected_attribute.codigo', $code)
->where('selected_value.value', $value));
}
return $query->first();
}
private function createVariant(
int $catalogItemId,
string $sector,
int $row,
int $seat,
string $type,
): object {
$itemAttributes = DB::table('item_attributes')
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
->where('item_attributes.catalog_item_id', $catalogItemId)
->pluck('item_attributes.id', 'attribute.codigo');
foreach (['tipo', 'sector', 'fila', 'asiento'] as $requiredCode) {
if (! isset($itemAttributes[$requiredCode])) {
throw new RuntimeException("Falta el atributo {$requiredCode} en el catálogo de entradas.");
}
}
$inventoryId = DB::table('inventories')->insertGetId([
'sold_units' => 0,
'reserved_stock' => 0,
'real_stock' => 1,
]);
$variantId = DB::table('variantes')->insertGetId([
'catalog_item_id' => $catalogItemId,
'event_date_id' => null,
'inventory_id' => $inventoryId,
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
'precio' => $this->catalogPrice($sector, $row),
]);
foreach (['tipo' => $type, 'sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) {
DB::table('variant_values')->insert([
'variant_id' => $variantId,
'item_attribute_id' => $itemAttributes[$code],
'value' => $value,
'created_at' => now(),
'updated_at' => now(),
]);
}
return DB::table('variantes')->where('id', $variantId)->first();
}
private function catalogPrice(string $sector, int $row): int
{
$prices = in_array($sector, ['A', 'C'], true)
? [1 => 250000, 2 => 200000, 3 => 100000, 4 => 75000, 5 => 50000]
: [1 => 240000, 2 => 190000, 3 => 90000, 4 => 65000, 5 => 40000];
return $prices[$row];
}
private function createPurchaseItem(
int $purchaseId,
object $catalogItem,
object $variant,
string $sector,
int $row,
int $seat,
string $type,
DateTimeInterface $now,
): int {
$existingId = DB::table('compra_items')
->where('compra_id', $purchaseId)
->where('source_variant_id', $variant->id)
->value('id');
if ($existingId !== null) {
return (int) $existingId;
}
$attributes = [
['name' => 'Tipo', 'value' => $type],
['name' => 'Sector', 'value' => $sector],
['name' => 'Fila', 'value' => (string) $row],
['name' => 'Asiento', 'value' => (string) $seat],
];
return DB::table('compra_items')->insertGetId([
'compra_id' => $purchaseId,
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => $variant->id,
'image_attachment_id' => DB::table('catalog_items_attachments')
->where('catalog_item_id', $catalogItem->id)
->whereNull('variant_id')
->orderBy('orden')
->value('attachment_id'),
'nombre' => $catalogItem->nombre,
'descripcion' => $variant->descripcion,
'slug' => $catalogItem->slug,
'item_nombre' => "Entrada (Sector {$sector}, Fila {$row}, Asiento {$seat})",
'variant_attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'cantidad' => 1,
'precio_unitario' => 0,
'discount_total' => 0,
'tax_total' => 0,
'total' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function createTicketAndCommitStock(
int $purchaseId,
int $purchaseItemId,
int $userId,
int $catalogItemId,
object $variant,
DateTimeInterface $now,
): void {
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
? ['source_purchase_item_id' => $purchaseItemId]
: ['source_purchase_id' => $purchaseId];
if (DB::table('tickets')
->where($purchaseReference)
->where('source_variant_id', $variant->id)
->exists()) {
return;
}
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) {
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
}
DB::table('inventories')->where('id', $inventory->id)->update([
'real_stock' => $inventory->real_stock - 1,
'sold_units' => $inventory->sold_units + 1,
]);
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed',
'committed_at' => $now,
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
}
DB::table('stock_reservation_lines')->insert([
'stock_reservation_id' => $reservationId,
'inventory_id' => $inventory->id,
'quantity' => 1,
'tracks_inventory' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('tickets')->insert([
'tenant_code' => self::TENANT_CODE,
'ticket' => (string) Str::uuid(),
'name' => null,
'description' => null,
...$purchaseReference,
'source_catalog_item_id' => $catalogItemId,
'source_variant_id' => $variant->id,
'used_at' => null,
'scanner_user_id' => null,
'user_id' => $userId,
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
use App\Domains\Desfile\Controllers\EntryController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant/desfile')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
->group(function (): void {
Route::get('entries', [EntryController::class, 'show'])
->name('adminapp.desfile.entries.show');
Route::put('entries', [EntryController::class, 'update'])
->name('adminapp.desfile.entries.update');
Route::post('entries/image', [EntryController::class, 'replaceImage'])
->name('adminapp.desfile.entries.image.replace');
Route::patch('entries/image', [EntryController::class, 'updateImage'])
->name('adminapp.desfile.entries.image.update');
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
->name('adminapp.desfile.entries.image.destroy');
});

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Event\Requests\StoreEventDateRequest;
use App\Domains\Event\Requests\UpdateEventRequest;
use App\Domains\Event\Resources\EventDateResource;
use App\Domains\Event\Resources\EventResource;
use App\Domains\Event\Services\EventService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EventController extends Controller
{
public function __construct(protected EventService $eventService) {}
public function show(Request $request): EventResource
{
return EventResource::make(
$this->eventService->forTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)
);
}
public function storeDate(StoreEventDateRequest $request): EventDateResource
{
return EventDateResource::make(
$this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
)
);
}
public function rescheduleDate(
RescheduleEventDateRequest $request,
EventDate $eventDate,
): EventDateResource {
return EventDateResource::make(
$this->eventService->rescheduleDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->validated(),
$request->user(),
)
);
}
public function suspendDate(Request $request, EventDate $eventDate): EventDateResource
{
return EventDateResource::make(
$this->eventService->suspendDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->user(),
)
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Resources\EventDateNoticeResource;
use App\Domains\Event\Services\EventDateNoticeService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventDateNoticeController extends Controller
{
public function __construct(private readonly EventDateNoticeService $noticeService) {}
public function claim(Request $request, Tenant $tenant): AnonymousResourceCollection
{
return EventDateNoticeResource::collection(
$this->noticeService->claimFor($request->user(), $tenant)
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateChangeType: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateStatus: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
case Scheduled = 'scheduled';
case InProgress = 'in_progress';
case Completed = 'completed';
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateRescheduled
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $sourceEventDateId,
public readonly int $destinationEventDateId,
public readonly string $previousDate,
public readonly string $newDate,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateSuspended
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $eventDateId,
public readonly string $date,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EffectiveEventDateResolver;
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;
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\Carbon;
#[Fillable([
'tenant_code',
'date',
'time_start',
'time_end',
'rescheduled_to_event_date_id',
'suspended_at',
])]
class EventDate extends Model
{
use HasFactory;
public $timestamps = false;
protected $appends = ['status'];
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)
->delete();
});
}
protected function casts(): array
{
return [
'date' => 'date:Y-m-d',
'validity_time_id' => 'integer',
'rescheduled_to_event_date_id' => 'integer',
'suspended_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsTo<EventDate, $this> */
public function rescheduledTo(): BelongsTo
{
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
}
public function effectiveDate(): ?self
{
return app(EffectiveEventDateResolver::class)->resolve($this);
}
/** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany
{
return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function changeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'source_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function destinationChangeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'destination_event_date_id');
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** @return BelongsToMany<Variant, $this> */
public function selectedByVariants(): BelongsToMany
{
return $this->belongsToMany(
Variant::class,
'variant_event_dates',
'event_date_id',
'variant_id',
);
}
public function startsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
}
public function endsAt(): CarbonInterface
{
$endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
return $endsAt->lessThanOrEqualTo($this->startsAt())
? $endsAt->addDay()
: $endsAt;
}
public function getStatusAttribute(): EventDateStatus
{
if ($this->rescheduled_to_event_date_id !== null) {
return EventDateStatus::Rescheduled;
}
if ($this->suspended_at !== null) {
return EventDateStatus::Suspended;
}
if (now()->lt($this->startsAt())) {
return EventDateStatus::Scheduled;
}
if (now()->lt($this->endsAt())) {
return EventDateStatus::InProgress;
}
return EventDateStatus::Completed;
}
private function syncTenantDateText(): void
{
$tenant = $this->tenant()->first();
if (! $tenant) {
return;
}
$tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->pluck('date')
),
]);
}
private function syncValidityTime(): void
{
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
$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

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'change_type',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
])]
class EventDateChange extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'change_type' => EventDateChangeType::class,
'source_event_date_id' => 'integer',
'destination_event_date_id' => 'integer',
'created_by_user_id' => 'integer',
'previous_date' => 'date:Y-m-d',
'new_date' => 'date:Y-m-d',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<EventDate, $this> */
public function sourceEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'source_event_date_id');
}
/** @return BelongsTo<EventDate, $this> */
public function destinationEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'destination_event_date_id');
}
/** @return BelongsTo<User, $this> */
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed();
}
/** @return HasMany<EventDateChangeView, $this> */
public function views(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'event_date_change_id',
'display_count',
'last_displayed_at',
])]
class EventDateChangeView extends Model
{
protected $table = 'user_event_date_change_views';
protected function casts(): array
{
return [
'user_id' => 'integer',
'event_date_change_id' => 'integer',
'display_count' => 'integer',
'last_displayed_at' => 'datetime',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<EventDateChange, $this> */
public function eventDateChange(): BelongsTo
{
return $this->belongsTo(EventDateChange::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RescheduleEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'social_media' => ['sometimes', 'array'],
'social_media.*' => ['required', 'array:code,url,orden'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'contact' => ['sometimes', 'array:whatsapp_url,instagram_url,facebook_url'],
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
$input = $this->all();
if (! array_key_exists('social_media', $input) && ! array_key_exists('contact', $input)) {
$validator->errors()->add(
'social_media',
'The social media field is required.'
);
}
if (! array_key_exists('allow_ticket_refund', $input)) {
return;
}
foreach ([
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
] as $field) {
if (! array_key_exists($field, $input)) {
$validator->errors()->add($field, 'El campo es obligatorio.');
}
}
$totalEnabled = $this->boolean('allow_ticket_total_refund');
$partialEnabled = $this->boolean('allow_ticket_partial_refund');
$refundEnabled = $this->boolean('allow_ticket_refund');
if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) {
$validator->errors()->add(
'allow_ticket_refund',
'Seleccioná al menos un tipo de reembolso.'
);
}
if ($refundEnabled
&& $partialEnabled
&& (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) {
$validator->errors()->add(
'ticket_partial_refund_percentage',
'Ingresá un porcentaje mayor que cero para el reembolso parcial.'
);
}
},
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDateChange */
class EventDateChangeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'type' => $this->change_type->value,
'source_event_date_id' => $this->source_event_date_id,
'destination_event_date_id' => $this->destination_event_date_id,
'previous_date' => $this->previous_date->format('Y-m-d'),
'new_date' => $this->new_date?->format('Y-m-d'),
'occurred_at' => $this->created_at->toISOString(),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Event\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventDateNoticeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'type' => $this->resource['type'],
'change_ids' => $this->resource['change_ids'],
'title' => $this->resource['title'],
'message' => $this->resource['message'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDate */
class EventDateResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')),
'date' => $this->date->format('Y-m-d'),
'start_time' => substr($this->time_start, 0, 5),
'end_time' => substr($this->time_end, 0, 5),
'status' => $this->status->value,
'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id,
'suspended_at' => $this->suspended_at?->toISOString(),
'rescheduled_dates' => EventDateResource::collection(
$this->whenLoaded('adminRescheduledDates')
),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Services\EventDateGroupingService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Tenant */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$socialMedia = $this->socialMedia->keyBy('code');
return [
'id' => $this->id,
'title' => $this->event_title,
'location' => $this->event_location,
'allow_ticket_refund' => $this->allow_ticket_refund,
'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'dates' => EventDateResource::collection(
app(EventDateGroupingService::class)->group($this->eventDates)
),
'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code,
'url' => $item->pivot->url,
'orden' => $item->pivot->orden,
])->values(),
'contact' => [
'whatsapp_url' => $socialMedia->get('whatsapp')?->pivot->url,
'instagram_url' => $socialMedia->get('instagram')?->pivot->url,
'facebook_url' => $socialMedia->get('facebook')?->pivot->url,
],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class AffectedEventDatePurchaseResolver
{
/**
* Finds active tickets belonging to paid purchases before an event-date mutation.
*
* @param Collection<int, int>|list<int> $eventDateIds
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
*/
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
{
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
if ($eventDateIds->isEmpty()) {
return [];
}
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
->where('status', Purchase::STATUS_PAID))
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
$query->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
->whereIn('event_dates.id', $eventDateIds));
})
->with([
...TicketValidityResolver::RELATIONS,
'sourcePurchaseItem.purchase',
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
return $tickets
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
->map(function (Collection $purchaseTickets): array {
return [
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
'ticket_ids' => $purchaseTickets->modelKeys(),
];
})
->values()
->all();
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver
{
public function resolve(EventDate $eventDate): ?EventDate
{
$date = $this->resolveLatest($eventDate);
return $date !== null && $date->suspended_at === null ? $date : null;
}
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
public function resolveLatest(EventDate $eventDate): ?EventDate
{
$current = $eventDate;
$visited = [];
while (true) {
$identity = $current->getKey() === null
? 'object:'.spl_object_id($current)
: 'key:'.$current->getKey();
if (isset($visited[$identity])) {
return null;
}
$visited[$identity] = true;
if ($current->rescheduled_to_event_date_id === null) {
return $current;
}
$current->loadMissing('rescheduledTo');
$current = $current->rescheduledTo;
if ($current === null) {
return null;
}
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class EventDateGroupingService
{
/**
* Groups every historical date under its final active destination.
*
* @param Collection<int, EventDate> $dates
* @return Collection<int, EventDate>
*/
public function group(Collection $dates): Collection
{
$byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey());
$groups = collect();
foreach ($dates as $date) {
$destination = $this->finalDestination($date, $byId);
$key = (int) $destination->getKey();
if (! $groups->has($key)) {
$groups->put($key, [
'destination' => $destination,
'rescheduled' => collect(),
]);
}
if (! $date->is($destination)) {
$group = $groups->get($key);
$historicalDate = clone $date;
$historicalDate->setAttribute(
'rescheduled_to_event_date_id',
$destination->getKey(),
);
$group['rescheduled']->push($historicalDate);
$groups->put($key, $group);
}
}
return $groups
->map(function (array $group): EventDate {
/** @var EventDate $destination */
$destination = clone $group['destination'];
/** @var Collection<int, EventDate> $rescheduled */
$rescheduled = $group['rescheduled'];
$destination->setRelation(
'adminRescheduledDates',
new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()),
);
return $destination;
})
->sort($this->dateSorter())
->values();
}
/** @param Collection<int, EventDate> $byId */
private function finalDestination(EventDate $date, Collection $byId): EventDate
{
$current = $date;
$visited = collect();
while ($current->rescheduled_to_event_date_id !== null) {
$currentId = (int) $current->getKey();
if ($visited->contains($currentId)) {
break;
}
$visited->push($currentId);
$destination = $byId->get((int) $current->rescheduled_to_event_date_id);
if (! $destination instanceof EventDate) {
break;
}
$current = $destination;
}
return $current;
}
/** @return callable(EventDate, EventDate): int */
private function dateSorter(): callable
{
return fn (EventDate $left, EventDate $right): int => [
$left->date->format('Y-m-d'),
$left->time_start,
$left->getKey(),
] <=> [
$right->date->format('Y-m-d'),
$right->time_start,
$right->getKey(),
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateInfoFormatter
{
/** @param Collection<int, EventDateChange> $changes */
public function format(EventDate $eventDate, Collection $changes): ?string
{
$messages = collect();
if ($eventDate->suspended_at !== null) {
$messages->push('Esta fecha fue cancelada.');
}
$sourceDates = $this->reschedulesEndingAt($eventDate, $changes)
->pluck('previous_date')
->filter()
->map(fn ($date): string => $date->format('d/m/Y'))
->unique()
->values();
if ($sourceDates->isNotEmpty()) {
$verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron';
$messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día.");
}
return $messages->isEmpty() ? null : $messages->join(' ');
}
/**
* Includes direct and intermediate reschedules that ultimately end at the
* displayed event date, while preserving the original change order.
*
* @param Collection<int, EventDateChange> $changes
* @return Collection<int, EventDateChange>
*/
private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection
{
$eventDateId = $eventDate->getKey();
$reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled);
if ($eventDateId === null) {
return $reschedules->where('destination_event_date_id', null);
}
$destinationIds = [(int) $eventDateId => true];
do {
$foundAncestor = false;
foreach ($reschedules as $change) {
$destinationId = $change->destination_event_date_id;
$sourceId = $change->source_event_date_id;
if ($destinationId === null || $sourceId === null) {
continue;
}
if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) {
$destinationIds[(int) $sourceId] = true;
$foundAncestor = true;
}
}
} while ($foundAncestor);
return $reschedules
->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null
&& isset($destinationIds[(int) $change->destination_event_date_id]));
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateNoticeFormatter
{
public function __construct(private readonly EventDateTextFormatter $dateTextFormatter) {}
/**
* @param Collection<int, EventDateChange> $changes
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
public function format(Collection $changes): array
{
return collect([
$this->suspensionNotice(
$changes->where('change_type', EventDateChangeType::Suspended)
),
$this->rescheduleNotice(
$changes->where('change_type', EventDateChangeType::Rescheduled)
),
])->filter()->values()->all();
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function suspensionNotice(Collection $changes): ?array
{
$dates = $this->formatDates($changes, 'previous_date');
if ($dates === null) {
return null;
}
$plural = $changes->count() > 1;
return [
'type' => EventDateChangeType::Suspended->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!',
'message' => [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $dates, 'bold' => true],
['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false],
],
];
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function rescheduleNotice(Collection $changes): ?array
{
$changes = $changes->whereNotNull('new_date');
$sourceDates = $this->formatDates($changes, 'previous_date');
$destinationDates = $this->formatDates($changes, 'new_date');
if ($sourceDates === null || $destinationDates === null) {
return null;
}
$plural = $changes->count() > 1;
$message = [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $sourceDates, 'bold' => true],
[
'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ',
'bold' => false,
],
['text' => $destinationDates, 'bold' => true],
];
if ($plural) {
$message[] = ['text' => ', ', 'bold' => false];
$message[] = ['text' => 'respectivamente', 'bold' => true];
}
$message[] = ['text' => '.', 'bold' => false];
return [
'type' => EventDateChangeType::Rescheduled->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
'message' => $message,
];
}
/**
* @param Collection<int, EventDateChange> $changes
*/
private function formatDates(Collection $changes, string $attribute): ?string
{
return $this->dateTextFormatter->formatForSentence(
$changes
->pluck($attribute)
->filter()
->map(fn ($date): string => $date->format('Y-m-d'))
);
}
/**
* @param Collection<int, EventDateChange> $changes
* @return list<int>
*/
private function changeIds(Collection $changes): array
{
return $changes
->pluck('id')
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->values()
->all();
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventDateNoticeService
{
public const MAX_DISPLAYS = 3;
public function __construct(private readonly EventDateNoticeFormatter $formatter) {}
/**
* Claims one display of every pending change and returns them grouped by type.
*
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
public function claimFor(User $user, Tenant $tenant): array
{
return DB::transaction(function () use ($user, $tenant): array {
$lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail();
$changes = EventDateChange::query()
->where('tenant_code', $tenant->codigo)
->whereDoesntHave('views', fn ($query) => $query
->where('user_id', $lockedUser->getKey())
->where('display_count', '>=', self::MAX_DISPLAYS))
->orderBy('created_at')
->orderBy('id')
->get();
$notices = $this->formatter->format($changes);
$claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique();
foreach ($claimedChangeIds as $changeId) {
$view = EventDateChangeView::query()->firstOrNew([
'user_id' => $lockedUser->getKey(),
'event_date_change_id' => $changeId,
]);
$view->display_count = min(
self::MAX_DISPLAYS,
((int) $view->display_count) + 1,
);
$view->last_displayed_at = now();
$view->save();
}
return $notices;
});
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Domains\Event\Services;
use DateTimeImmutable;
class EventDateTextFormatter
{
/** @var array<int, string> */
private const MONTHS = [
1 => 'Enero',
2 => 'Febrero',
3 => 'Marzo',
4 => 'Abril',
5 => 'Mayo',
6 => 'Junio',
7 => 'Julio',
8 => 'Agosto',
9 => 'Septiembre',
10 => 'Octubre',
11 => 'Noviembre',
12 => 'Diciembre',
];
/** @param iterable<string> $dates */
public function format(iterable $dates): ?string
{
return $this->formatWithOptions($dates, false, false);
}
/** @param iterable<string> $dates */
public function formatForSentence(iterable $dates): ?string
{
return $this->formatWithOptions($dates, true, true);
}
/** @param iterable<string> $dates */
private function formatWithOptions(
iterable $dates,
bool $padDays,
bool $includeYearPreposition,
): ?string {
$normalizedDates = collect($dates)
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
->sortBy(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
->values();
if ($normalizedDates->isEmpty()) {
return null;
}
$years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
$months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month) use ($padDays): string {
$days = $monthDates
->map(fn (DateTimeImmutable $date): string => $padDays
? $date->format('d')
: (string) ((int) $date->format('j')))
->values()
->all();
return $this->join($days).' de '.self::MONTHS[(int) $month];
})
->values()
->all();
return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
})
->values()
->all();
return $this->join($years);
}
/** @param array<int, string> $parts */
private function join(array $parts): string
{
if (count($parts) <= 1) {
return $parts[0] ?? '';
}
$last = array_pop($parts);
return implode(', ', $parts).' y '.$last;
}
}

View File

@@ -0,0 +1,350 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EventService
{
private const CONTACT_CODES = [
'whatsapp_url' => 'whatsapp',
'instagram_url' => 'instagram',
'facebook_url' => 'facebook',
];
public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService,
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {}
public function forTenant(Tenant $tenant): Tenant
{
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
}
/** @param array<string, mixed> $data */
public function updateForTenant(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$tenant->update([
'event_title' => $data['title'],
'event_location' => $data['location'],
...array_intersect_key($data, array_flip([
'allow_ticket_refund',
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
])),
]);
if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']);
} else {
$this->syncLegacyContact($tenant, $data['contact']);
}
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
});
}
/** @param array{date: string, start_time: string, end_time: string} $data */
public function createDateForTenant(Tenant $tenant, array $data): EventDate
{
return DB::transaction(function () use ($tenant, $data): EventDate {
$attributes = $this->dateAttributes($data);
if ($tenant->eventDates()->where($attributes)->exists()) {
throw ValidationException::withMessages([
'date' => ['La fecha y el horario ya existen.'],
]);
}
return $tenant->eventDates()->create($attributes)->load('validityTime');
});
}
/** @param array{date: string} $data */
public function rescheduleDateForTenant(
Tenant $tenant,
EventDate $eventDate,
array $data,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede reprogramar una fecha suspendida.'],
]);
}
if ($source->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['La fecha ya fue reprogramada.'],
]);
}
$destination = $tenant->eventDates()
->whereDate('date', $data['date'])
->lockForUpdate()
->first();
if ($destination === null) {
$destination = $tenant->eventDates()->create([
'date' => $data['date'],
'time_start' => $source->time_start,
'time_end' => $source->time_end,
]);
}
if ($destination->is($source) || $this->chainContains($destination, $source)) {
throw ValidationException::withMessages([
'date' => ['La reprogramación generaría una referencia circular.'],
]);
}
$effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
if ($effectiveDestination === null) {
throw ValidationException::withMessages([
'date' => ['La fecha de destino no es utilizable.'],
]);
}
$affectedDateIds = $this->affectedDateIds($tenant, $source);
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant,
$affectedDateIds,
);
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Rescheduled,
'source_event_date_id' => $source->getKey(),
'destination_event_date_id' => $destination->getKey(),
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $source->date->format('Y-m-d'),
'new_date' => $destination->date->format('Y-m-d'),
]);
EventDateRescheduled::dispatch(
$tenant->codigo,
$source->getKey(),
$destination->getKey(),
$source->date->format('d/m/Y'),
$destination->date->format('d/m/Y'),
$purchaseTickets,
);
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
});
}
public function suspendDateForTenant(
Tenant $tenant,
EventDate $eventDate,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'],
]);
}
if ($date->suspended_at !== null) {
return $date->load('validityTime');
}
$affectedDateIds = $this->affectedDateIds($tenant, $date);
$this->invalidateEventDateCarts->invalidate(
$tenant,
$affectedDateIds,
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
);
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
$date->update(['suspended_at' => now()]);
$this->variantReplacementService->disableForSuspension($date);
$this->disableTicketsWithoutUsableDates($tenant, $date);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Suspended,
'source_event_date_id' => $date->getKey(),
'destination_event_date_id' => null,
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $date->date->format('Y-m-d'),
'new_date' => null,
]);
EventDateSuspended::dispatch(
$tenant->codigo,
$date->getKey(),
$date->date->format('d/m/Y'),
$purchaseTickets,
);
return $date->fresh('validityTime');
});
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return $tenant->eventDates()
->whereKey($eventDate->getKey())
->lockForUpdate()
->firstOrFail();
}
private function chainContains(EventDate $start, EventDate $expected): bool
{
$current = $start;
$visited = [];
while ($current->rescheduled_to_event_date_id !== null) {
if ($current->is($expected)) {
return true;
}
if (isset($visited[$current->getKey()])) {
return true;
}
$visited[$current->getKey()] = true;
$current = $current->rescheduledTo()->lockForUpdate()->first();
if ($current === null) {
return false;
}
}
return $current->is($expected);
}
/** @return Collection<int, int> */
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
{
$affectedDateIds = collect([$eventDate->getKey()]);
$frontier = $affectedDateIds;
while ($frontier->isNotEmpty()) {
$predecessors = $tenant->eventDates()
->whereIn('rescheduled_to_event_date_id', $frontier)
->pluck('id')
->diff($affectedDateIds)
->values();
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
$frontier = $predecessors;
}
return $affectedDateIds;
}
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
{
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
$variants = Variant::withTrashed()
->where(function ($query) use ($affectedDateIds): void {
$query->whereIn('event_date_id', $affectedDateIds)
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->whereIn('event_dates.id', $affectedDateIds));
})
->with(['eventDates', 'eventDate'])
->get();
foreach ($variants as $variant) {
$hasUsableDate = $variant->selectedEventDates()->contains(
fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null
);
if ($hasUsableDate) {
continue;
}
Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('source_variant_id', $variant->getKey())
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->lockForUpdate()
->get()
->each(function (Ticket $ticket): void {
$ticket->markAsDisabled();
$ticket->save();
});
}
}
/**
* @param array{date: string, start_time: string, end_time: string} $data
* @return array{date: string, time_start: string, time_end: string}
*/
private function dateAttributes(array $data): array
{
return [
'date' => $data['date'],
'time_start' => $data['start_time'].':00',
'time_end' => $data['end_time'].':00',
];
}
/** @param array<string, string|null> $contact */
private function syncLegacyContact(Tenant $tenant, array $contact): void
{
foreach (self::CONTACT_CODES as $field => $code) {
$url = $contact[$field] ?? null;
if ($url === null || $url === '') {
$tenant->socialMedia()->detach($code);
continue;
}
$tenant->socialMedia()->syncWithoutDetaching([
$code => ['url' => $url],
]);
}
$tenant->unsetRelation('socialMedia');
}
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
}

View File

@@ -0,0 +1,37 @@
# Dominio Event
## Propósito
Administra la configuración temporal de un tenant orientado a eventos y sus fechas disponibles.
## Componentes
- `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas.
- `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant.
- `Services/EventDateNoticeService.php`: reclama y agrupa los cambios pendientes de cada usuario.
- `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp.
- `UpdateEventRequest`: valida datos y reglas cruzadas de fechas.
- `EventResource`: serializa la configuración de salida.
## Endpoints
Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `GET`: obtiene la configuración.
- `PUT`: actualiza la configuración.
Para el storefront autenticado:
- `POST /tenants/{tenant}/event-date-notices/claim`: devuelve hasta un aviso de suspensiones y otro de
reprogramaciones. Cada cambio se muestra como máximo tres veces por usuario.
## Dependencias
Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets.
## Consideraciones
Los avisos se construyen dinámicamente después de excluir los cambios que el usuario ya vio
tres veces. Al reclamar los avisos se incrementa una vez cada cambio incluido, aunque varios
cambios aparezcan agrupados en el mismo mensaje. El reclamo bloquea al usuario durante la
transacción para impedir que pestañas concurrentes superen el máximo.

View File

@@ -0,0 +1,14 @@
<?php
use App\Domains\Event\Controllers\AdminApp\EventController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']);
Route::post('event-dates', [EventController::class, 'storeDate']);
Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']);
Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']);
});

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Event\Controllers\EventDateNoticeController;
use Illuminate\Support\Facades\Route;
require __DIR__.'/adminapp.php';
Route::middleware('auth:sanctum')->post(
'tenants/{tenant:codigo}/event-date-notices/claim',
[EventDateNoticeController::class, 'claim'],
);

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertAccommodationVariantsRequest;
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
{
public function __construct(private readonly AccommodationService $accommodationService) {}
public function index(Request $request): AccommodationResource
{
return AccommodationResource::make(
$this->accommodationService->current($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertAccommodationVariantsRequest $request): AccommodationResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return AccommodationResource::make(
$this->accommodationService->upsertMany(
$tenant,
$request->validated('variants'),
)
);
}
public function destroy(Request $request, int $accommodation): Response
{
$this->accommodationService->delete(
$request->user()->tenant()->firstOrFail(),
$accommodation,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateCategoryVisibilityRequest;
use App\Domains\FiestaFutbolInfantil\Services\CategoryVisibilityService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategoryVisibilityController extends Controller
{
public function __construct(private readonly CategoryVisibilityService $visibilityService) {}
public function show(Request $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
return response()->json([
'data' => [
'is_enabled' => $this->visibilityService->isEnabled($tenant, $category),
],
]);
}
public function update(UpdateCategoryVisibilityRequest $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
$model = $this->visibilityService->update(
$tenant,
$category,
$request->boolean('is_enabled'),
);
return response()->json([
'data' => [
'is_enabled' => $model->is_enabled,
],
'message' => $model->is_enabled
? 'Mostrar en sitio web se activo correctamente'
: 'Mostrar en sitio web se desactivo correctamente',
]);
}
private function category(Request $request): FiestaCategory
{
return FiestaCategory::from((string) $request->route('category'));
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertEntriesRequest;
use App\Domains\FiestaFutbolInfantil\Resources\EntryResource;
use App\Domains\FiestaFutbolInfantil\Services\EntryService;
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
{
public function __construct(private readonly EntryService $entryService) {}
public function index(Request $request): AnonymousResourceCollection
{
return EntryResource::collection(
$this->entryService->all($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertEntriesRequest $request): JsonResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
$entries = $this->entryService->upsertMany(
$tenant,
$request->validated('entries'),
);
return EntryResource::collection($entries)
->response()
->setStatusCode(200);
}
public function destroy(Request $request, int $entry): Response
{
$this->entryService->delete(
$request->user()->tenant()->firstOrFail(),
$entry,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
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
{
public function __construct(private readonly FoodService $foodService) {}
public function index(Request $request): FoodResource
{
return FoodResource::make(
$this->foodService->current($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertFoodVariantsRequest $request): FoodResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return FoodResource::make(
$this->foodService->upsertMany(
$tenant,
$request->validated('variants'),
)
);
}
public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource
{
return FoodResource::make(
$this->foodService->updateHistoricalStock(
$request->user()->tenant()->firstOrFail(),
$request->validated('variants'),
)
);
}
public function destroy(Request $request, int $food): Response
{
$this->foodService->delete(
$request->user()->tenant()->firstOrFail(),
$food,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertMerchandiseRequest;
use App\Domains\FiestaFutbolInfantil\Resources\MerchandiseResource;
use App\Domains\FiestaFutbolInfantil\Services\MerchandiseService;
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
{
public function __construct(private readonly MerchandiseService $merchandiseService) {}
public function index(Request $request): AnonymousResourceCollection
{
return MerchandiseResource::collection(
$this->merchandiseService->all($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertMerchandiseRequest $request): JsonResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
$items = $this->merchandiseService->upsertMany(
$tenant,
$request->validated('items'),
);
return MerchandiseResource::collection($items)
->response()
->setStatusCode(200);
}
public function destroy(Request $request, int $merchandise): Response
{
$this->merchandiseService->delete(
$request->user()->tenant()->firstOrFail(),
$merchandise,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Enums;
enum FiestaCategory: string
{
case Entries = 'entries';
case Foods = 'foods';
case Accommodations = 'accommodations';
case Merchandise = 'merchandise';
public function categoryName(): string
{
return match ($this) {
self::Entries => 'Entradas',
self::Foods => 'Comidas',
self::Accommodations => 'Alojamientos',
self::Merchandise => 'Merchandising',
};
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCategoryVisibilityRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'is_enabled' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHistoricalFoodStockRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,stock'],
'variants.*.id' => ['required', 'integer', 'distinct'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpsertAccommodationVariantsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,title,description,stock,price'],
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'variants.*.title' => ['required', 'string', 'max:255'],
'variants.*.description' => ['sometimes', 'nullable', 'string'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpsertEntriesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->user()?->tenant_codigo;
return [
'entries' => ['required', 'array', 'min:1', 'max:100'],
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'],
'entries.*.id' => [
'sometimes',
'nullable',
'integer',
'distinct',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query
->where('tenant_code', $tenantCode)
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
->select('id')
->from('categorias')
->where('tenant_code', $tenantCode)
->where('nombre', 'Entradas'))
),
],
'entries.*.title' => ['required', 'string', 'max:255'],
'entries.*.description' => ['sometimes', 'nullable', 'string'],
'entries.*.event_date_ids' => ['required', 'array', 'min:1'],
'entries.*.event_date_ids.*' => [
'required',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'entries.*.stock' => ['required', 'integer', 'min:0'],
'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
foreach ($this->input('entries', []) as $index => $entry) {
if (! is_array($entry)) {
continue;
}
$dateIds = $entry['event_date_ids'] ?? [];
if (! is_array($dateIds)) {
continue;
}
if (count($dateIds) !== count(array_unique($dateIds))) {
$validator->errors()->add(
"entries.{$index}.event_date_ids",
'Las fechas de una entrada no pueden repetirse.',
);
}
}
},
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpsertFoodVariantsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->user()?->tenant_codigo;
return [
'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'],
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'variants.*.event_date_id' => [
'required',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.schedule' => ['required', 'string', 'max:255'],
'variants.*.service' => ['required', 'string', 'max:255'],
'variants.*.description' => ['sometimes', 'nullable', 'string'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
$seen = [];
foreach ($this->input('variants', []) as $index => $variant) {
if (! is_array($variant)) {
continue;
}
$key = implode('|', [
$variant['event_date_id'] ?? '',
mb_strtolower(trim((string) ($variant['schedule'] ?? ''))),
mb_strtolower(trim((string) ($variant['service'] ?? ''))),
]);
if (isset($seen[$key])) {
$validator->errors()->add(
"variants.{$index}",
'La combinación de fecha, horario y servicio no puede repetirse.',
);
}
$seen[$key] = true;
}
},
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertMerchandiseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->user()?->tenant_codigo;
return [
'items' => ['required', 'array', 'min:1', 'max:100'],
'items.*' => ['required', 'array:id,title,description,max_units_per_user,variants'],
'items.*.id' => [
'sometimes',
'nullable',
'integer',
'distinct',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query
->where('tenant_code', $tenantCode)
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
->select('id')
->from('categorias')
->where('tenant_code', $tenantCode)
->where('nombre', 'Merchandising'))
),
],
'items.*.title' => ['required', 'string', 'max:255'],
'items.*.description' => ['sometimes', 'nullable', 'string'],
'items.*.max_units_per_user' => ['required', 'integer', 'min:1'],
'items.*.variants' => ['required', 'array', 'min:1', 'max:500'],
'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'],
'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'items.*.variants.*.color' => ['required', 'string', 'max:255'],
'items.*.variants.*.size' => ['required', 'string', 'max:255'],
'items.*.variants.*.stock' => ['required', 'integer', 'min:0'],
'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class AccommodationResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
if ($this->resource === null) {
return [
'id' => null,
'name' => 'Alojamiento',
'variants' => [],
];
}
$typeAttribute = $this->itemAttributes
->first(fn ($itemAttribute) => $itemAttribute->attribute?->codigo === 'tipo_alojamiento');
$options = $typeAttribute?->attribute?->options?->keyBy('value') ?? collect();
return [
'id' => $this->id,
'name' => $this->nombre,
'variants' => $this->variants->map(function ($variant) use ($typeAttribute, $options): array {
$value = $variant->definitions
->firstWhere('item_attribute_id', $typeAttribute?->id)
?->value;
return [
'id' => $variant->id,
'title' => $options->get($value)?->label ?? $value,
'value' => $value,
'description' => $variant->descripcion,
'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''),
];
})->values(),
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Validation\ValidationException;
/** @mixin CatalogItem */
class EntryResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$variants = $this->variants->whereNull('replaced_by_variant_id');
if ($variants->count() !== 1) {
throw ValidationException::withMessages([
'entries' => [sprintf(
'La entrada %s tiene %d variantes sin reemplazar (IDs: %s). Se esperaba una.',
$this->id,
$variants->count(),
$variants->pluck('id')->implode(', '),
)],
]);
}
$variant = $variants->first();
return [
'id' => $this->id,
'title' => $this->nombre,
'description' => $this->descripcion,
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'stock' => $variant->inventory->real_stock,
'price' => $this->precio,
];
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** @mixin CatalogItem */
class FoodResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
if ($this->resource === null) {
return [
'id' => null,
'name' => 'Comida',
'variants' => [],
'history' => [],
];
}
$currentVariants = $this->variants
->filter(fn (Variant $variant): bool => $this->isCurrent($variant));
$historicalVariants = $this->variants
->filter(fn (Variant $variant): bool => $this->isHistorical($variant));
return [
'id' => $this->id,
'name' => $this->nombre,
'variants' => $currentVariants->map($this->variantData(...))->values(),
'history' => $this->historyData($historicalVariants),
];
}
private function isCurrent(Variant $variant): bool
{
if ($variant->sales_disabled_at !== null || $variant->replaced_by_variant_id !== null) {
return false;
}
$status = $variant->selectedEventDates()->first()?->status;
return $status === null || in_array(
$status,
[EventDateStatus::Scheduled, EventDateStatus::InProgress],
true,
);
}
private function isHistorical(Variant $variant): bool
{
return in_array(
$variant->selectedEventDates()->first()?->status,
[
EventDateStatus::Rescheduled,
EventDateStatus::Suspended,
EventDateStatus::Completed,
],
true,
);
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->selectionValues();
$eventDate = $variant->selectedEventDates()->first();
return [
'id' => $variant->id,
'event_date_id' => $eventDate?->id,
'event_date' => $eventDate?->date?->format('Y-m-d'),
'schedule' => $values->get('horario'),
'service' => $values->get('servicio'),
'description' => $variant->descripcion,
'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''),
];
}
/**
* @param Collection<int, Variant> $variants
* @return Collection<int, array<string, mixed>>
*/
private function historyData(Collection $variants): Collection
{
return $variants
->filter(fn (Variant $variant): bool => $variant->selectedEventDates()->first() !== null)
->groupBy(fn (Variant $variant): int => (int) $variant->selectedEventDates()->first()->id)
->map(function (Collection $dateVariants): array {
/** @var EventDate $eventDate */
$eventDate = $dateVariants->first()->selectedEventDates()->first();
$status = $this->historicalStatus($eventDate);
$change = $this->changeForStatus($eventDate, $status);
return [
'id' => $eventDate->id,
'change_id' => $change?->id,
'status' => $status->value,
'status_text' => match ($status) {
EventDateStatus::Rescheduled => 'REPROGRAMADA',
EventDateStatus::Suspended => 'CANCELADA',
EventDateStatus::Completed => 'FINALIZADA',
default => '',
},
'event_date_id' => $eventDate->id,
'event_date' => $eventDate->date->format('Y-m-d'),
'replacement_event_date_id' => $status === EventDateStatus::Rescheduled
? ($change?->destination_event_date_id
?? $eventDate->rescheduled_to_event_date_id)
: null,
'replacement_event_date' => $status === EventDateStatus::Rescheduled
? ($change?->new_date?->format('Y-m-d')
?? $eventDate->rescheduledTo?->date?->format('Y-m-d'))
: null,
'occurred_at' => ($change?->created_at ?? $eventDate->endsAt())->toISOString(),
'variants' => $dateVariants->map($this->variantData(...))->values(),
];
})
->sortByDesc('occurred_at')
->values();
}
private function historicalStatus(EventDate $eventDate): EventDateStatus
{
return match ($eventDate->status) {
EventDateStatus::Rescheduled => EventDateStatus::Rescheduled,
EventDateStatus::Suspended => EventDateStatus::Suspended,
default => EventDateStatus::Completed,
};
}
private function changeForStatus(
EventDate $eventDate,
EventDateStatus $status,
): ?EventDateChange {
$changeType = match ($status) {
EventDateStatus::Rescheduled => EventDateChangeType::Rescheduled,
EventDateStatus::Suspended => EventDateChangeType::Suspended,
default => null,
};
return $changeType === null
? null
: $eventDate->changeHistory
->first(fn (EventDateChange $change): bool => $change->change_type === $changeType);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class MerchandiseResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$itemAttributes = $this->itemAttributes->keyBy(
fn ($itemAttribute) => $itemAttribute->attribute?->codigo
);
$colorAttribute = $itemAttributes->get('color');
$sizeAttribute = $itemAttributes->get('talle');
$colorOptions = $colorAttribute?->attribute?->options?->keyBy('value') ?? collect();
$sizeOptions = $sizeAttribute?->attribute?->options?->keyBy('value') ?? collect();
return [
'id' => $this->id,
'title' => $this->nombre,
'description' => $this->descripcion,
'max_units_per_user' => $this->max_units_per_user,
'variants' => $this->variants->map(function ($variant) use (
$colorAttribute,
$sizeAttribute,
$colorOptions,
$sizeOptions,
): array {
$colorValue = $variant->definitions
->firstWhere('item_attribute_id', $colorAttribute?->id)
?->value;
$sizeValue = $variant->definitions
->firstWhere('item_attribute_id', $sizeAttribute?->id)
?->value;
return [
'id' => $variant->id,
'color' => $colorOptions->get($colorValue)?->label ?? $colorValue,
'color_value' => $colorValue,
'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue,
'size_value' => $sizeValue,
'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''),
];
})->values(),
];
}
}

View File

@@ -0,0 +1,299 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AccommodationService
{
private const ATTRIBUTE_CODE = 'tipo_alojamiento';
public function __construct(private readonly CatalogService $catalogService) {}
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'alojamiento')
->with([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
])
->first();
}
/**
* @param array<int, array<string, mixed>> $variants
*/
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$attribute = $this->attribute($tenant);
$accommodation = $this->accommodation($tenant, $variants);
$itemAttribute = $accommodation->itemAttributes()->firstOrCreate(
['attribute_id' => $attribute->id],
['allow_multi_select' => false],
);
$existingVariants = $accommodation->variants()
->with(['inventory', 'definitions'])
->lockForUpdate()
->get();
$resolvedVariants = $this->resolveVariants($variants);
$this->validateValues($resolvedVariants, $existingVariants, $itemAttribute);
foreach ($resolvedVariants as $index => $data) {
$variant = isset($data['id'])
? $existingVariants->firstWhere('id', (int) $data['id'])
: null;
if (isset($data['id']) && $variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => ['La variante no pertenece al producto Alojamiento.'],
]);
}
if ($variant === null) {
$this->createVariant($attribute, $accommodation, $itemAttribute, $data);
} else {
$this->updateVariant($attribute, $variant, $itemAttribute, $data, $index);
}
}
$minimumPrice = $accommodation->variants()->min('precio');
if ($minimumPrice !== null) {
$accommodation->update(['precio' => $minimumPrice]);
}
return $accommodation->fresh()->load([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
]);
});
}
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()
->where('tenant_codigo', $tenant->codigo)
->where('codigo', self::ATTRIBUTE_CODE)
->with('options')
->lockForUpdate()
->first();
if ($attribute === null) {
throw ValidationException::withMessages([
'variants' => ['Falta el atributo requerido tipo_alojamiento.'],
]);
}
return $attribute;
}
/** @param array<int, array<string, mixed>> $variants */
private function accommodation(Tenant $tenant, array $variants): CatalogItem
{
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Alojamientos',
]);
$accommodation = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where('slug', 'alojamiento')
->lockForUpdate()
->first();
if ($accommodation !== null) {
if ($accommodation->trashed()) {
$accommodation->restore();
}
$accommodation->update([
'category_id' => $category->id,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
]);
return $accommodation;
}
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'alojamiento',
'nombre' => 'Alojamiento',
'descripcion' => 'Alojamiento',
'category_id' => $category->id,
'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'inventory_id' => null,
]);
}
/**
* @param array<int, array<string, mixed>> $variants
* @return array<int, array<string, mixed>>
*/
private function resolveVariants(array $variants): array
{
return collect($variants)->map(fn (array $variant): array => [
...$variant,
'title' => trim($variant['title']),
'value' => $this->valueCode($variant['title']),
'description' => $variant['description'] ?? null,
'stock' => (int) $variant['stock'],
])->all();
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing
*/
private function validateValues(array $incoming, Collection $existing, ItemAttribute $itemAttribute): void
{
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
$seen = [];
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
$value = $variant->definitions->firstWhere('item_attribute_id', $itemAttribute->id)?->value;
if ($value !== null) {
$seen[mb_strtolower(trim($value))] = true;
}
}
foreach ($incoming as $index => $variant) {
$value = $variant['value'];
if (isset($seen[$value])) {
throw ValidationException::withMessages([
"variants.{$index}.title" => ['Ya existe un tipo de alojamiento con ese título.'],
]);
}
$seen[$value] = true;
}
}
/** @param array<string, mixed> $data */
private function createVariant(
Attribute $attribute,
CatalogItem $accommodation,
ItemAttribute $itemAttribute,
array $data,
): void {
$this->createOption($attribute, $data['value'], $data['title']);
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$variant = $accommodation->variants()->create([
'inventory_id' => $inventory->id,
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $data['value'],
]);
}
/** @param array<string, mixed> $data */
private function updateVariant(
Attribute $attribute,
Variant $variant,
ItemAttribute $itemAttribute,
array $data,
int $index,
): void {
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$definition = $variant->definitions
->firstWhere('item_attribute_id', $itemAttribute->id);
$option = $definition === null
? null
: $attribute->options->firstWhere('value', $definition->value);
if ($option === null) {
$this->createOption($attribute, $data['value'], $data['title']);
} else {
$option->update([
'value' => $data['value'],
'label' => $data['title'],
]);
}
$variant->update([
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$inventory->update(['real_stock' => $data['stock']]);
$variant->definitions()->updateOrCreate(
['item_attribute_id' => $itemAttribute->id],
['value' => $data['value']],
);
}
private function createOption(Attribute $attribute, string $value, string $label): AttributeOption
{
$existing = $attribute->options->first(
fn (AttributeOption $option): bool => mb_strtolower($option->value) === $value
);
if ($existing !== null) {
$existing->update(['label' => $label]);
return $existing;
}
$option = $attribute->options()->create([
'value' => $value,
'label' => $label,
'sort_order' => ((int) $attribute->options->max('sort_order')) + 1,
]);
$attribute->options->push($option);
return $option;
}
private function valueCode(string $title): string
{
return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($title)));
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Models\Category;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\Tenant\Models\Tenant;
class CategoryVisibilityService
{
public function isEnabled(Tenant $tenant, FiestaCategory $category): bool
{
return Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', $category->categoryName())
->value('is_enabled') ?? true;
}
public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category
{
$model = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => $category->categoryName(),
]);
$model->update(['is_enabled' => $isEnabled]);
return $model;
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
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 Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class EntryService
{
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)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->whereHas('variants', fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'))
->with([
'variants' => fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'),
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
])
->orderBy('id')
->get();
}
/**
* @param array<int, array<string, mixed>> $entries
* @return Collection<int, CatalogItem>
*/
public function upsertMany(Tenant $tenant, array $entries): Collection
{
return DB::transaction(function () use ($tenant, $entries): Collection {
$reservedSlugs = [];
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Entradas',
]);
return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem {
if (isset($entry['id'])) {
return $this->update($tenant, $category, $entry, $index);
}
$slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs);
$reservedSlugs[] = $slug;
return $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'slug' => $slug,
'nombre' => $entry['title'],
'descripcion' => $entry['description'] ?? null,
'category_id' => $category->id,
'precio' => $entry['price'],
'has_tickets' => true,
'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'],
'variants' => [[
'real_stock' => $entry['stock'],
'event_date_ids' => array_values($entry['event_date_ids']),
]],
]);
})->values();
});
}
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)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->lockForUpdate()
->firstOrFail();
$variants = Variant::query()
->where('catalog_item_id', $catalogItem->id)
->whereNull('replaced_by_variant_id')
->lockForUpdate()
->get();
if ($variants->count() !== 1) {
throw ValidationException::withMessages([
"entries.{$index}.id" => [
'La entrada no posee una única variante editable.',
],
]);
}
$variant = $variants->first();
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ((int) $entry['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"entries.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$eventDateIds = collect($entry['event_date_ids'])
->map(fn ($id): int => (int) $id)
->unique()
->values();
$catalogItem->update([
'nombre' => $entry['title'],
'descripcion' => $entry['description'] ?? null,
'category_id' => $category->id,
'precio' => $entry['price'],
'has_tickets' => true,
'inventory_policy' => InventoryPolicy::Tracked->value,
]);
$variant->update([
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
]);
$variant->eventDates()->sync($eventDateIds->all());
$catalogItem->itemAttributes()
->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date'))
->update(['allow_multi_select' => true]);
$inventory->update(['real_stock' => $entry['stock']]);
return $catalogItem->load([
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
]);
}
/** @param array<int, string> $reservedSlugs */
private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string
{
$baseSlug = Str::slug($title) ?: 'entrada';
$slug = $baseSlug;
$suffix = 2;
while (
in_array($slug, $reservedSlugs, true)
|| CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where('slug', $slug)
->exists()
) {
$slug = "{$baseSlug}-{$suffix}";
$suffix++;
}
return $slug;
}
}

View File

@@ -0,0 +1,473 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
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()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->with([
'variants.catalogItem',
'variants.inventory',
'variants.eventDate.rescheduledTo',
'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.definitions.itemAttribute.attribute',
])
->first();
}
/**
* @param array<int, array<string, mixed>> $variants
*/
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$attributes = $this->attributes($tenant);
$food = $this->food($tenant, $variants);
$itemAttributes = $this->itemAttributes($food, $attributes);
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
$existingVariants = $food->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
->lockForUpdate()
->get()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->values();
$resolvedVariants = $this->resolveVariants($variants, $attributes);
$this->validateCurrentEventDates($tenant, $resolvedVariants);
$this->validateCombinations($resolvedVariants, $existingVariants);
foreach ($resolvedVariants as $index => $data) {
$variant = isset($data['id'])
? $existingVariants->firstWhere('id', (int) $data['id'])
: null;
if (isset($data['id']) && $variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => ['La variante no pertenece al producto Comida.'],
]);
}
if ($variant === null) {
$this->createVariant($food, $itemAttributes, $data);
} else {
$this->updateVariant($variant, $itemAttributes, $data, $index);
}
}
$minimumPrice = $food->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDate', 'eventDates'])
->get()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->min('precio');
if ($minimumPrice !== null) {
$food->update(['precio' => $minimumPrice]);
}
return $food->fresh()->load([
'variants.catalogItem',
'variants.inventory',
'variants.eventDate.rescheduledTo',
'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.definitions.itemAttribute.attribute',
]);
});
}
/**
* @param array<int, array{id: int, stock: int}> $variants
*/
public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$food = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->lockForUpdate()
->firstOrFail();
$variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id);
$historicalVariants = $food->variants()
->whereIn('id', $variantIds)
->with(['inventory', 'eventDate', 'eventDates'])
->lockForUpdate()
->get()
->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant))
->keyBy('id');
foreach ($variants as $index => $data) {
$variant = $historicalVariants->get((int) $data['id']);
if ($variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => [
'La variante no pertenece al historial de Comida.',
],
]);
}
$stock = (int) $data['stock'];
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
if ($stock < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$inventory->update(['real_stock' => $stock]);
}
return $this->current($tenant) ?? $food;
});
}
private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory
{
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
$variantsSharingInventory = Variant::query()
->where('inventory_id', $inventory->getKey())
->orderBy('id')
->lockForUpdate()
->get(['id']);
if ($variantsSharingInventory->count() === 1) {
return $inventory;
}
$historicalInventory = Inventory::query()->create([
'sold_units' => $inventory->sold_units,
'refunded_units' => $inventory->refunded_units,
'reserved_stock' => 0,
'real_stock' => $inventory->real_stock,
]);
$variant->update(['inventory_id' => $historicalInventory->getKey()]);
return $historicalInventory;
}
public function delete(Tenant $tenant, int $foodId): void
{
$variant = Variant::query()
->whereKey($foodId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDate', 'eventDates'])
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida'))
->firstOrFail();
abort_unless($this->hasCurrentDate($variant), 404);
$this->catalogService->deleteVariant($variant);
}
/** @return Collection<string, Attribute> */
private function attributes(Tenant $tenant): Collection
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', self::ATTRIBUTE_CODES)
->with('options')
->get()
->keyBy('codigo');
$missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys());
if ($missingCodes->isNotEmpty()) {
throw ValidationException::withMessages([
'variants' => [
'Faltan atributos requeridos para Comida: '.$missingCodes->implode(', ').'.',
],
]);
}
return $attributes;
}
/** @param array<int, array<string, mixed>> $variants */
private function food(Tenant $tenant, array $variants): CatalogItem
{
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Comidas',
]);
$food = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->lockForUpdate()
->first();
if ($food !== null) {
if ($food->trashed()) {
$food->restore();
}
$food->update([
'category_id' => $category->id,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
]);
return $food;
}
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'comida',
'nombre' => 'Comida',
'descripcion' => 'Comida',
'category_id' => $category->id,
'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'inventory_id' => null,
]);
}
/**
* @param Collection<string, Attribute> $attributes
* @return Collection<string, ItemAttribute>
*/
private function itemAttributes(CatalogItem $food, Collection $attributes): Collection
{
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array {
$itemAttribute = $food->itemAttributes()->updateOrCreate(
['attribute_id' => $attribute->id],
[
'allow_multi_select' => false,
'sort_order' => self::ATTRIBUTE_SORT_ORDERS[$code],
],
);
return [$code => $itemAttribute];
});
}
/**
* @param array<int, array<string, mixed>> $variants
* @param Collection<string, Attribute> $attributes
* @return array<int, array<string, mixed>>
*/
private function resolveVariants(array $variants, Collection $attributes): array
{
return collect($variants)->map(function (array $variant, int $index) use ($attributes): array {
$schedule = $this->option($attributes['horario'], $variant['schedule'], "variants.{$index}.schedule");
$service = $this->option($attributes['servicio'], $variant['service'], "variants.{$index}.service");
return [
...$variant,
'event_date_id' => (int) $variant['event_date_id'],
'schedule' => $schedule->value,
'service' => $service->value,
'description' => (string) ($variant['description'] ?? ''),
'stock' => (int) $variant['stock'],
];
})->all();
}
private function option(Attribute $attribute, string $value, string $validationKey): AttributeOption
{
$option = $attribute->options->first(
fn (AttributeOption $option): bool => mb_strtolower(trim($option->value)) === mb_strtolower(trim($value))
);
if ($option === null) {
throw ValidationException::withMessages([
$validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."],
]);
}
return $option;
}
/** @param array<int, array<string, mixed>> $variants */
private function validateCurrentEventDates(Tenant $tenant, array $variants): void
{
$eventDates = EventDate::query()
->where('tenant_code', $tenant->codigo)
->whereIn('id', collect($variants)->pluck('event_date_id')->unique())
->get()
->keyBy('id');
foreach ($variants as $index => $variant) {
$eventDate = $eventDates->get($variant['event_date_id']);
if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) {
continue;
}
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'La fecha seleccionada ya no está disponible.',
],
]);
}
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing
*/
private function validateCombinations(array $incoming, Collection $existing): void
{
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
$seen = [];
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
$values = $variant->selectionValues();
$seen[$this->combinationKey(
(int) $variant->selectedEventDates()->first()?->id,
(string) $values->get('horario'),
(string) $values->get('servicio'),
)] = true;
}
foreach ($incoming as $index => $variant) {
$key = $this->combinationKey(
$variant['event_date_id'],
$variant['schedule'],
$variant['service'],
);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"variants.{$index}" => ['La combinación de fecha, horario y servicio ya existe.'],
]);
}
$seen[$key] = true;
}
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void
{
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$variant = $food->variants()->create([
'event_date_id' => $data['event_date_id'],
'inventory_id' => $inventory->id,
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$variant->eventDates()->sync([$data['event_date_id']]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function updateVariant(
Variant $variant,
Collection $itemAttributes,
array $data,
int $index,
): void {
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$variant->update([
'event_date_id' => $data['event_date_id'],
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$variant->eventDates()->sync([$data['event_date_id']]);
$inventory->update(['real_stock' => $data['stock']]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function syncDefinitions(Variant $variant, Collection $itemAttributes, array $data): void
{
$definitionAttributes = $itemAttributes->toBase()->only(['horario', 'servicio']);
$variant->definitions()->whereIn('item_attribute_id', $definitionAttributes->pluck('id'))->delete();
$variant->definitions()->createMany([
[
'item_attribute_id' => $definitionAttributes['horario']->id,
'value' => $data['schedule'],
],
[
'item_attribute_id' => $definitionAttributes['servicio']->id,
'value' => $data['service'],
],
]);
}
private function combinationKey(int $eventDateId, string $schedule, string $service): string
{
return implode('|', [
$eventDateId,
mb_strtolower(trim($schedule)),
mb_strtolower(trim($service)),
]);
}
private function hasCurrentDate(Variant $variant): bool
{
$status = $variant->selectedEventDates()->first()?->status;
return $status === null || $this->isCurrentStatus($status);
}
private function hasHistoricalDate(Variant $variant): bool
{
return in_array(
$variant->selectedEventDates()->first()?->status,
[EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed],
true,
);
}
private function isCurrentStatus(EventDateStatus $status): bool
{
return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true);
}
}

View File

@@ -0,0 +1,440 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
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)
->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising'))
->with([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
])
->orderBy('id')
->get();
}
/**
* @param array<int, array<string, mixed>> $items
* @return Collection<int, CatalogItem>
*/
public function upsertMany(Tenant $tenant, array $items): Collection
{
return DB::transaction(function () use ($tenant, $items): Collection {
$attributes = $this->attributes($tenant);
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Merchandising',
]);
$reservedSlugs = [];
return collect($items)->map(function (array $data, int $index) use (
$tenant,
$attributes,
$category,
&$reservedSlugs,
): CatalogItem {
$item = isset($data['id'])
? $this->existingItem($tenant, $category, (int) $data['id'], $index)
: $this->createItem($tenant, $category, $data, $reservedSlugs);
if (! isset($data['id'])) {
$reservedSlugs[] = $item->slug;
}
$item->update([
'nombre' => trim($data['title']),
'descripcion' => $data['description'] ?? null,
'category_id' => $category->id,
'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
]);
$itemAttributes = $this->itemAttributes($item, $attributes);
$existingVariants = $item->variants()
->with(['inventory', 'definitions'])
->lockForUpdate()
->get();
$variants = $this->resolveVariants($data['variants'], $attributes, $index);
$this->validateCombinations($variants, $existingVariants, $itemAttributes, $index);
foreach ($variants as $variantIndex => $variantData) {
$variant = isset($variantData['id'])
? $existingVariants->firstWhere('id', (int) $variantData['id'])
: null;
if (isset($variantData['id']) && $variant === null) {
throw ValidationException::withMessages([
"items.{$index}.variants.{$variantIndex}.id" => [
'La variante no pertenece al artículo de merchandising.',
],
]);
}
if ($variant === null) {
$this->createVariant($item, $itemAttributes, $variantData);
} else {
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex);
}
}
$minimumPrice = $item->variants()->min('precio');
if ($minimumPrice !== null) {
$item->update(['precio' => $minimumPrice]);
}
return $item->fresh()->load([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
]);
})->values();
});
}
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
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', self::ATTRIBUTE_CODES)
->with('options')
->lockForUpdate()
->get()
->keyBy('codigo');
$missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys());
if ($missingCodes->isNotEmpty()) {
throw ValidationException::withMessages([
'items' => [
'Faltan atributos requeridos para merchandising: '.$missingCodes->implode(', ').'.',
],
]);
}
return $attributes;
}
private function existingItem(
Tenant $tenant,
Category $category,
int $itemId,
int $index,
): CatalogItem {
$item = CatalogItem::query()
->whereKey($itemId)
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->lockForUpdate()
->first();
if ($item === null) {
throw ValidationException::withMessages([
"items.{$index}.id" => ['El artículo no pertenece al merchandising del tenant.'],
]);
}
return $item;
}
/**
* @param array<string, mixed> $data
* @param array<int, string> $reservedSlugs
*/
private function createItem(
Tenant $tenant,
Category $category,
array $data,
array $reservedSlugs,
): CatalogItem {
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => $this->uniqueSlug($tenant, $data['title'], $reservedSlugs),
'nombre' => trim($data['title']),
'descripcion' => $data['description'] ?? null,
'category_id' => $category->id,
'precio' => collect($data['variants'])->min('price') ?? 0,
'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'inventory_id' => null,
]);
}
/**
* @param Collection<string, Attribute> $attributes
* @return Collection<string, ItemAttribute>
*/
private function itemAttributes(CatalogItem $item, Collection $attributes): Collection
{
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($item): array {
$itemAttribute = $item->itemAttributes()->firstOrCreate(
['attribute_id' => $attribute->id],
['allow_multi_select' => false],
);
if ($itemAttribute->allow_multi_select) {
$itemAttribute->update(['allow_multi_select' => false]);
}
return [$code => $itemAttribute];
});
}
/**
* @param array<int, array<string, mixed>> $variants
* @param Collection<string, Attribute> $attributes
* @return array<int, array<string, mixed>>
*/
private function resolveVariants(array $variants, Collection $attributes, int $itemIndex): array
{
return collect($variants)->map(function (array $variant, int $variantIndex) use (
$attributes,
$itemIndex,
): array {
$color = $this->resolveColor($attributes['color'], $variant['color']);
$size = $this->existingOption(
$attributes['talle'],
$variant['size'],
"items.{$itemIndex}.variants.{$variantIndex}.size",
);
return [
...$variant,
'color' => $color->value,
'size' => $size->value,
'stock' => (int) $variant['stock'],
];
})->all();
}
private function resolveColor(Attribute $attribute, string $color): AttributeOption
{
$option = $this->findOption($attribute, $color);
if ($option !== null) {
return $option;
}
$label = trim($color);
$option = $attribute->options()->create([
'value' => $this->valueCode($label),
'label' => $label,
'sort_order' => ((int) $attribute->options->max('sort_order')) + 1,
]);
$attribute->options->push($option);
return $option;
}
private function existingOption(
Attribute $attribute,
string $value,
string $validationKey,
): AttributeOption {
$option = $this->findOption($attribute, $value);
if ($option === null) {
throw ValidationException::withMessages([
$validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."],
]);
}
return $option;
}
private function findOption(Attribute $attribute, string $value): ?AttributeOption
{
$key = $this->optionKey($value);
return $attribute->options->first(
fn (AttributeOption $option): bool => $this->optionKey($option->value) === $key
|| $this->optionKey($option->label) === $key
);
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing
* @param Collection<string, ItemAttribute> $itemAttributes
*/
private function validateCombinations(
array $incoming,
Collection $existing,
Collection $itemAttributes,
int $itemIndex,
): void {
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
$seen = [];
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
$values = $variant->definitions->keyBy('item_attribute_id');
$color = $values->get($itemAttributes['color']->id)?->value;
$size = $values->get($itemAttributes['talle']->id)?->value;
if ($color !== null && $size !== null) {
$seen[$this->combinationKey($color, $size)] = true;
}
}
foreach ($incoming as $variantIndex => $variant) {
$key = $this->combinationKey($variant['color'], $variant['size']);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"items.{$itemIndex}.variants.{$variantIndex}" => [
'La combinación de color y talle ya existe para el artículo.',
],
]);
}
$seen[$key] = true;
}
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
*/
private function createVariant(
CatalogItem $item,
Collection $itemAttributes,
array $data,
): void {
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$variant = $item->variants()->create([
'inventory_id' => $inventory->id,
'precio' => $data['price'],
]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
*/
private function updateVariant(
Variant $variant,
Collection $itemAttributes,
array $data,
int $itemIndex,
int $variantIndex,
): void {
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"items.{$itemIndex}.variants.{$variantIndex}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$variant->update(['precio' => $data['price']]);
$inventory->update(['real_stock' => $data['stock']]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
*/
private function syncDefinitions(
Variant $variant,
Collection $itemAttributes,
array $data,
): void {
$variant->definitions()
->whereIn('item_attribute_id', $itemAttributes->pluck('id'))
->delete();
$variant->definitions()->createMany([
[
'item_attribute_id' => $itemAttributes['color']->id,
'value' => $data['color'],
],
[
'item_attribute_id' => $itemAttributes['talle']->id,
'value' => $data['size'],
],
]);
}
/** @param array<int, string> $reservedSlugs */
private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string
{
$baseSlug = Str::slug($title) ?: 'merchandising';
$slug = $baseSlug;
$suffix = 2;
while (
in_array($slug, $reservedSlugs, true)
|| CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where('slug', $slug)
->exists()
) {
$slug = "{$baseSlug}-{$suffix}";
$suffix++;
}
return $slug;
}
private function combinationKey(string $color, string $size): string
{
return $this->optionKey($color).'|'.$this->optionKey($size);
}
private function optionKey(string $value): string
{
return Str::ascii(mb_strtolower((string) preg_replace('/[_\s]+/u', ' ', trim($value))));
}
private function valueCode(string $value): string
{
return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($value)));
}
}

View File

@@ -0,0 +1,66 @@
<?php
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
use App\Domains\FiestaFutbolInfantil\Controllers\CategoryVisibilityController;
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
$visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void {
Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
};
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index');
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::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
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

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Ticket\Controllers\AdminApp;
use App\Domains\Ticket\Requests\AdminAppTicketExportRequest;
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
use App\Domains\Ticket\Requests\AdminAppTicketRefundRequest;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketRefundCalculationResource;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource;
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
use App\Domains\Ticket\Services\AdminAppTicketPdfService;
use App\Domains\Ticket\Services\AdminAppTicketService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class TicketController extends Controller
{
public function __construct(
private readonly AdminAppTicketService $ticketService,
private readonly AdminAppTicketPdfService $ticketPdfService,
private readonly AdminAppTicketExcelService $ticketExcelService,
) {}
public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketCollection(
$this->ticketService->search($tenant, $request->validated())
);
}
public function cancel(Request $request, int $ticket): AdminAppTicketResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
}
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketRefundCalculationResource(
$this->ticketService->calculateRefund($tenant, $ticket)
);
}
public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource(
$this->ticketService->refund(
$tenant,
$ticket,
$request->validated('refund_type'),
$request->user(),
)
);
}
public function downloadPdf(AdminAppTicketExportRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->ticketPdfService->download(
$tenant,
$this->ticketService->ticketsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadExcel(AdminAppTicketExportRequest $request): StreamedResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->ticketExcelService->download(
$tenant,
$this->ticketService->ticketsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Ticket\Controllers\Scanner;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Requests\ScanAttemptIndexRequest;
use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource;
use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource;
use App\Domains\Ticket\Services\ScannerTicketService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ScanAttemptController extends Controller
{
public function __construct(private readonly ScannerTicketService $ticketService) {}
public function __invoke(ScanAttemptIndexRequest $request): AnonymousResourceCollection
{
/** @var User $scanner */
$scanner = $request->user();
return ScanAttemptResource::collection(
$this->ticketService->attemptsBy($scanner, $request->validated())
);
}
public function show(Request $request, int $scanAttempt): ScannerScanResultResource
{
/** @var User $scanner */
$scanner = $request->user();
return ScannerScanResultResource::make(
$this->ticketService->scanAttemptDetail($scanner, $scanAttempt)
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Ticket\Controllers\Scanner;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource;
use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\ScannerTicketService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TicketController extends Controller
{
public function __construct(private readonly ScannerTicketService $ticketService) {}
public function show(Request $request, string $ticketUuid): TicketResource
{
/** @var User $scanner */
$scanner = $request->user();
return TicketResource::make(
$this->ticketService->detail($scanner, $ticketUuid)
);
}
public function scan(Request $request): JsonResponse
{
/** @var User $scanner */
$scanner = $request->user();
return ScannerScanResultResource::make(
$this->ticketService->scan($scanner, $request->input('data'))
)->response()->setStatusCode(Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Ticket\Controllers;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TicketController extends Controller
{
public function __construct(private readonly TicketPdfService $ticketPdfService) {}
public function index(Request $request, Tenant $tenant): JsonResponse
{
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderByDesc('id')
->get();
return TicketResource::collection($tickets)->response();
}
public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response
{
$ticketIds = $request->validated('ticket_ids');
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds)
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderByDesc('id')
->get();
if ($tickets->count() !== count($ticketIds)) {
throw new TicketNotAvailableException(__('api.ticket.not_available'));
}
return $this->ticketPdfService->download($tenant, $tickets);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Ticket\Enums;
enum ScanAttemptResult: string
{
case Processing = 'processing';
case Accepted = 'accepted';
case InvalidQr = 'invalid_qr';
case TicketNotFound = 'ticket_not_found';
case CategoryForbidden = 'category_forbidden';
case AlreadyScanned = 'already_scanned';
case Expired = 'expired';
case NotValid = 'not_valid';
case UnexpectedError = 'unexpected_error';
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Ticket\Enums;
enum ValidityTimeType: string
{
case TimeWindow = 'time_window';
case FixedWindow = 'fixed_window';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Ticket\Exceptions;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use RuntimeException;
class TicketGenerationException extends RuntimeException
{
public static function invalidQuantity(): self
{
return new self(__('api.ticket.positive_quantity'));
}
public static function emptyBundle(CatalogItem $bundle): self
{
return new self(__('api.ticket.empty_bundle', ['bundle' => $bundle->id]));
}
public static function ticketsDisabled(CatalogItem $catalogItem): self
{
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
}
public static function invalidValidityConfiguration(CatalogItem $catalogItem, Variant $variant): self
{
return new self("Variant {$variant->id} from catalog item {$catalogItem->id} has an invalid validity configuration.");
}
public static function variantNotFound(
CatalogItem $catalogItem,
int $variantId,
): self {
return new self(
__('api.ticket.invalid_variant', [
'variant' => $variantId,
'product' => $catalogItem->id,
]),
);
}
public static function purchaseWithoutUser(Purchase $purchase): self
{
return new self(__('api.ticket.purchase_without_user', ['purchase' => $purchase->id]));
}
public static function catalogItemNotFound(PurchaseItem $purchaseItem): self
{
return new self(__('api.ticket.product_not_found', ['purchase_item' => $purchaseItem->id]));
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domains\Ticket\Exceptions;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TicketNotAvailableException extends NotFoundHttpException {}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Domains\Ticket\Listeners;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Services\TicketGeneratorService;
class GenerateTicketsForPaidPurchase
{
public function __construct(
private readonly TicketGeneratorService $ticketGenerator,
) {}
public function handle(PurchasePaid $event): void
{
$purchase = Purchase::query()
->with(['user', 'items'])
->findOrFail($event->purchaseId);
$user = $purchase->user;
foreach ($purchase->items as $purchaseItem) {
$catalogItem = CatalogItem::query()
->where('tenant_code', $purchase->tenant_codigo)
->find($purchaseItem->source_catalog_item_id);
if ($catalogItem === null) {
throw TicketGenerationException::catalogItemNotFound($purchaseItem);
}
if (! $this->requiresTickets($catalogItem)) {
continue;
}
if ($user === null) {
throw TicketGenerationException::purchaseWithoutUser($purchase);
}
$this->ticketGenerator->generate(
$catalogItem,
$user,
$purchaseItem->cantidad,
$purchaseItem->source_variant_id,
$purchaseItem->getKey(),
);
}
}
private function requiresTickets(CatalogItem $catalogItem): bool
{
if (! $catalogItem->isBundle()) {
return $catalogItem->has_tickets;
}
return $catalogItem->bundleComponents()
->whereHas(
'catalogItem',
fn ($query) => $query->where('has_tickets', true),
)
->exists();
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'tenant_code',
'scanner_user_id',
'ticket_id',
'data',
'result',
'resolved_at',
])]
class ScanAttempt extends Model
{
public const UPDATED_AT = null;
/** @return BelongsTo<User, $this> */
public function scanner(): BelongsTo
{
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
}
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
protected function casts(): array
{
return [
'scanner_user_id' => 'integer',
'ticket_id' => 'integer',
'result' => ScanAttemptResult::class,
'created_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,426 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Services\ResolvedTicketValidity;
use App\Domains\Ticket\Services\ResolvedValidityGroup;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
#[Fillable([
'tenant_code',
'ticket',
'source_purchase_item_id',
'source_catalog_item_id',
'source_variant_id',
'used_at',
'disabled_at',
'cancelled_at',
'refunded_at',
'scanner_user_id',
'user_id',
])]
class Ticket extends Model
{
use HasFactory, LogsValueChanges;
private ?ResolvedTicketValidity $resolvedValidity = null;
public const STATUS_ACTIVE = 'active';
public const STATUS_EXPIRED = 'expired';
public const STATUS_USED = 'used';
public const STATUS_DISABLED = 'disabled';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REFUNDED = 'refunded';
public $timestamps = false;
/** @var list<string> */
protected array $loggedAttributes = [
'used_at',
'disabled_at',
'cancelled_at',
'refunded_at',
];
protected $appends = [
'name',
'description',
'is_valid',
'is_expired',
'is_used',
'status',
];
protected function casts(): array
{
return [
'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer',
'source_purchase_item_id' => 'integer',
'used_at' => 'datetime',
'disabled_at' => 'datetime',
'cancelled_at' => 'datetime',
'refunded_at' => 'datetime',
'scanner_user_id' => 'integer',
'user_id' => 'integer',
];
}
/** @return list<string> */
public static function statuses(): array
{
return array_keys(self::statusLabels());
}
/** @return array<string, string> */
public static function statusLabels(): array
{
return [
self::STATUS_ACTIVE => 'Activo',
self::STATUS_USED => 'Usado',
self::STATUS_EXPIRED => 'Vencido',
self::STATUS_DISABLED => 'Inhabilitado',
self::STATUS_CANCELLED => 'Cancelado',
self::STATUS_REFUNDED => 'Reembolsado',
];
}
/** @return list<array{value: string, label: string}> */
public static function statusOptions(): array
{
return collect(self::statusLabels())
->map(fn (string $label, string $status): array => [
'value' => $status,
'label' => $label,
])
->values()
->all();
}
public static function statusLabel(string $status): string
{
return self::statusLabels()[$status] ?? $status;
}
protected static function booted(): void
{
static::saving(function (self $ticket): void {
$ticket->ensureTerminalStatusTransitionIsAllowed();
});
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
public function allow_refund(): bool
{
return $this->tenant?->allow_refund() ?? false;
}
public function allowRefund(): bool
{
return $this->allow_refund();
}
public function getAllowRefundAttribute(): bool
{
return $this->allow_refund();
}
public function is_active(): bool
{
return $this->status === self::STATUS_ACTIVE;
}
public function isActive(): bool
{
return $this->is_active();
}
public function getIsActiveAttribute(): bool
{
return $this->is_active();
}
public function can_cancel(): bool
{
return $this->is_active();
}
public function canCancel(): bool
{
return $this->can_cancel();
}
public function getCanCancelAttribute(): bool
{
return $this->can_cancel();
}
public function can_refund(): bool
{
return $this->is_active() && $this->allow_refund();
}
public function canRefund(): bool
{
return $this->can_refund();
}
public function getCanRefundAttribute(): bool
{
return $this->can_refund();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<User, $this> */
public function scannerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
}
/** @return HasMany<ScanAttempt, $this> */
public function scanAttempts(): HasMany
{
return $this->hasMany(ScanAttempt::class);
}
/** @return BelongsTo<PurchaseItem, $this> */
public function sourcePurchaseItem(): BelongsTo
{
return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id');
}
/** @return HasOne<TicketRefund, $this> */
public function refund(): HasOne
{
return $this->hasOne(TicketRefund::class);
}
/** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id')->withTrashed();
}
/** @return BelongsTo<Variant, $this> */
public function sourceVariant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed();
}
public function isValid(): bool
{
if ($this->hasTerminalStatus() || $this->used_at !== null) {
return false;
}
return $this->resolvedValidity()->isValid();
}
public function getIsValidAttribute(): bool
{
return $this->isValid();
}
public function getIsExpiredAttribute(): bool
{
return ! $this->hasTerminalStatus()
&& $this->used_at === null
&& $this->resolvedValidity()->isExpired();
}
public function getIsUsedAttribute(): bool
{
return $this->used_at !== null;
}
public function getStatusAttribute(): string
{
if ($this->refunded_at !== null) {
return self::STATUS_REFUNDED;
}
if ($this->cancelled_at !== null) {
return self::STATUS_CANCELLED;
}
if ($this->disabled_at !== null) {
return self::STATUS_DISABLED;
}
if ($this->is_used) {
return self::STATUS_USED;
}
if ($this->is_expired) {
return self::STATUS_EXPIRED;
}
return self::STATUS_ACTIVE;
}
public function getStatusLabelAttribute(): string
{
if ($this->status === self::STATUS_REFUNDED && $this->relationLoaded('refund')) {
$refund = $this->getRelation('refund');
if ($refund instanceof TicketRefund) {
return $refund->typeLabel();
}
}
return self::statusLabel($this->status);
}
public function markAsDisabled(): void
{
$this->markAsTerminalStatus(self::STATUS_DISABLED);
}
public function markAsCancelled(): void
{
$this->markAsTerminalStatus(self::STATUS_CANCELLED);
}
public function markAsRefunded(): void
{
$this->markAsTerminalStatus(self::STATUS_REFUNDED);
}
protected function valueChangeTenantCode(): string
{
return $this->tenant_code;
}
private function hasTerminalStatus(): bool
{
return $this->terminalStatus() !== null;
}
private function markAsTerminalStatus(string $status): void
{
$currentStatus = $this->terminalStatus();
if ($currentStatus === $status) {
return;
}
if ($currentStatus !== null) {
$this->throwTerminalStatusTransitionException();
}
$this->ensureTerminalStatusTransitionIsAllowed($status);
$this->{self::terminalStatusTimestampColumn($status)} = now();
}
private function ensureTerminalStatusTransitionIsAllowed(?string $targetStatus = null): void
{
$currentStatus = $this->terminalStatusFromAttributes($this->getRawOriginal());
$nextStatus = $targetStatus ?? $this->terminalStatus();
if ($currentStatus === null || $nextStatus === null || $currentStatus === $nextStatus) {
return;
}
$this->throwTerminalStatusTransitionException();
}
private function throwTerminalStatusTransitionException(): never
{
throw ValidationException::withMessages([
'status' => 'No se puede cambiar un ticket con estado terminal a otro estado terminal.',
]);
}
private function terminalStatus(): ?string
{
return $this->terminalStatusFromAttributes($this->getAttributes());
}
/** @param array<string, mixed> $attributes */
private function terminalStatusFromAttributes(array $attributes): ?string
{
foreach ([
self::STATUS_REFUNDED,
self::STATUS_CANCELLED,
self::STATUS_DISABLED,
] as $status) {
if (($attributes[self::terminalStatusTimestampColumn($status)] ?? null) !== null) {
return $status;
}
}
return null;
}
private static function terminalStatusTimestampColumn(string $status): string
{
return match ($status) {
self::STATUS_DISABLED => 'disabled_at',
self::STATUS_CANCELLED => 'cancelled_at',
self::STATUS_REFUNDED => 'refunded_at',
};
}
public function getNameAttribute(): string
{
return app(TicketPresentationResolver::class)->name($this);
}
public function getDescriptionAttribute(): string
{
return app(TicketPresentationResolver::class)->description($this);
}
public function getEffectiveStartsAt(): ?CarbonInterface
{
return $this->resolvedValidity()->effectiveStartsAt();
}
public function getEffectiveExpiresAt(): ?CarbonInterface
{
return $this->resolvedValidity()->effectiveExpiresAt();
}
/** @return Collection<int, ResolvedValidityGroup> */
public function resolvedValidityGroups(): Collection
{
return $this->resolvedValidity()->groups;
}
public function resolvedValidity(): ResolvedTicketValidity
{
return $this->resolvedValidity ??= app(TicketValidityResolver::class)->resolveTicket($this);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'ticket_id',
'purchase_item_id',
'created_by_user_id',
'type',
'amount',
])]
class TicketRefund extends Model
{
public const TYPE_PARTIAL = 'partial';
public const TYPE_TOTAL = 'total';
protected function casts(): array
{
return [
'ticket_id' => 'integer',
'purchase_item_id' => 'integer',
'created_by_user_id' => 'integer',
'amount' => 'decimal:2',
];
}
/** @return list<string> */
public static function types(): array
{
return [self::TYPE_PARTIAL, self::TYPE_TOTAL];
}
public function typeLabel(): string
{
return match ($this->type) {
self::TYPE_PARTIAL => 'Reembolso parcial',
self::TYPE_TOTAL => 'Reembolso total',
default => 'Reembolsado',
};
}
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsTo<PurchaseItem, $this> */
public function purchaseItem(): BelongsTo
{
return $this->belongsTo(PurchaseItem::class);
}
/** @return BelongsTo<User, $this> */
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed();
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Catalog\Models\AttributeOption;
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\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'type',
'start_time',
'end_time',
'fixed_starts_at',
'fixed_expires_at',
])]
class ValidityTime extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'type' => ValidityTimeType::class,
'fixed_starts_at' => 'datetime',
'fixed_expires_at' => 'datetime',
];
}
/** @return HasMany<AttributeOption, $this> */
public function attributeOptions(): HasMany
{
return $this->hasMany(AttributeOption::class);
}
/** @return HasOne<EventDate, $this> */
public function eventDate(): HasOne
{
return $this->hasOne(EventDate::class);
}
public function startsAt(
?CarbonInterface $at = null,
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_starts_at;
}
return $this->atCurrentDate($this->start_time, $at);
}
public function expiresAt(
?CarbonInterface $at = null,
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_expires_at;
}
return $this->atCurrentDate($this->end_time, $at);
}
public function isValid(
?CarbonInterface $at = null,
): bool {
$at ??= now();
$startsAt = $this->startsAt($at);
$expiresAt = $this->expiresAt($at);
return ($startsAt === null || $startsAt->lessThanOrEqualTo($at))
&& ($expiresAt === null || $expiresAt->greaterThan($at));
}
private function atCurrentDate(
?string $time,
?CarbonInterface $at,
): ?CarbonInterface {
if ($time === null) {
return null;
}
$at ??= now();
$localDate = CarbonImmutable::instance($at)
->format('Y-m-d');
return CarbonImmutable::parse($localDate.' '.$time);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Ticket\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppTicketExportRequest extends AdminAppTicketIndexRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Ticket\Requests;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppTicketIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
$tenant = $this->user()?->tenant()->first();
$sortableKeys = $tenant === null
? []
: app(AdminAppTicketColumnService::class)->sortableKeys($tenant);
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'category' => ['sometimes', 'nullable', 'string', 'max:255'],
'product' => ['sometimes', 'nullable', 'string', 'max:255'],
'type' => ['sometimes', 'nullable', 'string', 'max:255'],
'date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'size' => ['sometimes', 'nullable', 'string', 'max:255'],
'status' => [
'sometimes',
'nullable',
Rule::in(Ticket::statuses()),
],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)],
'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Ticket\Requests;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppTicketRefundRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string|object>> */
public function rules(): array
{
return [
'refund_type' => ['required', 'string', Rule::in(TicketRefund::types())],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DownloadTicketsPdfRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'ticket_ids' => ['required', 'array', 'min:1', 'max:25'],
'ticket_ids.*' => ['required', 'integer', 'distinct'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ScanAttemptIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Ticket\Resources\AdminApp;
use App\Domains\Ticket\Services\AdminAppTicketResult;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class AdminAppTicketCollection extends ResourceCollection
{
/** @var class-string<AdminAppTicketResource> */
public $collects = AdminAppTicketResource::class;
private readonly int $scannedTickets;
private readonly int $totalTickets;
private readonly string $refundedTotal;
public function __construct(AdminAppTicketResult $result)
{
parent::__construct($result->tickets);
$this->scannedTickets = $result->scannedTickets;
$this->totalTickets = $result->totalTickets;
$this->refundedTotal = $result->refundedTotal;
}
/** @return array{scanned_tickets: int, total_tickets: int, refunded_total: string} */
public function with(Request $request): array
{
return [
'scanned_tickets' => $this->scannedTickets,
'total_tickets' => $this->totalTickets,
'refunded_total' => $this->refundedTotal,
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Ticket\Resources\AdminApp;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property-read array{
* total: string|null,
* partial: string|null,
* } $resource
*/
class AdminAppTicketRefundCalculationResource extends JsonResource
{
/**
* @return array{total: string|null, partial: string|null}
*/
public function toArray(Request $request): array
{
return [
'total' => $this->resource['total'],
'partial' => $this->resource['partial'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Ticket\Resources\AdminApp;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\AdminAppTicketRowService;
use Illuminate\Http\Request;
/** @mixin Ticket */
class AdminAppTicketResource extends TicketResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$rowService = app(AdminAppTicketRowService::class);
$details = $rowService->details($this->resource);
return [
...parent::toArray($request),
...$details,
'allow_refund' => $this->resource->allow_refund(),
'is_active' => $this->resource->is_active(),
'can_cancel' => $this->resource->can_cancel(),
'can_refund' => $this->resource->can_refund(),
'refund' => $this->resource->refund === null ? null : [
'type' => $this->resource->refund->type,
'type_label' => $this->resource->refund->typeLabel(),
'amount' => $this->resource->refund->amount,
'created_at' => $this->resource->refund->created_at,
'created_by' => $this->resource->refund->createdBy?->nombre_apellido,
],
'values' => $rowService->values($this->resource, $details),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Ticket\Resources\Scanner;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticket\Models\ScanAttempt;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ScanAttempt */
class ScanAttemptResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'data' => $this->data,
'ticket_id' => $this->ticket_id,
'ticket' => $this->ticket?->ticket,
'category' => $this->ticket?->sourceCatalogItem?->category?->nombre,
'attempted_at' => $this->created_at,
'resolved_at' => $this->resolved_at,
'result' => $this->result->value,
'result_label' => match ($this->result) {
ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido',
default => 'Error',
},
'result_detail_label' => match ($this->result) {
ScanAttemptResult::Processing => 'Error',
ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::InvalidQr => 'QR no pertenece al evento',
ScanAttemptResult::TicketNotFound => 'Error',
ScanAttemptResult::CategoryForbidden => 'Error',
ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido',
ScanAttemptResult::NotValid => 'No válido',
ScanAttemptResult::UnexpectedError => 'Error',
},
'can_view_ticket' => $this->ticket !== null
&& $this->result !== ScanAttemptResult::CategoryForbidden,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Ticket\Resources\Scanner;
use App\Domains\Ticket\Models\ScanAttempt;
use App\Domains\Ticket\Resources\TicketResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ScanAttempt */
class ScannerScanResultResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$ticket = $this->ticket;
$client = $ticket?->user;
return [
'scan_attempt' => ScanAttemptResource::make($this->resource),
'ticket' => $ticket === null ? null : TicketResource::make($ticket),
'client' => $client === null ? null : [
'id' => $client->id,
'nombre_apellido' => $client->nombre_apellido,
],
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Ticket */
class TicketResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_code' => $this->tenant_code,
'ticket' => $this->ticket,
'status' => $this->status,
'status_label' => $this->status_label,
'refund' => $this->whenLoaded('refund', fn (): ?array => $this->refund === null ? null : [
'type' => $this->refund->type,
'type_label' => $this->refund->typeLabel(),
'amount' => $this->refund->amount,
'created_at' => $this->refund->created_at,
]),
'name' => $this->name,
'description' => $this->description,
'client' => $this->user?->nombre_apellido,
'category' => $this->sourceCatalogItem?->category?->nombre,
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'starts_at' => $this->getEffectiveStartsAt(),
'expires_at' => $this->getEffectiveExpiresAt(),
'used_at' => $this->used_at,
'scanner_user_id' => $this->scanner_user_id,
'is_valid' => $this->is_valid,
'is_expired' => $this->is_expired,
'is_used' => $this->is_used,
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValidityTime */
class ValidityTimeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$fields = match ($this->type) {
ValidityTimeType::TimeWindow => [
'start_time' => $this->start_time,
'end_time' => $this->end_time,
],
ValidityTimeType::FixedWindow => [
'fixed_starts_at' => $this->fixed_starts_at,
'fixed_expires_at' => $this->fixed_expires_at,
],
};
return [
'id' => $this->id,
'type' => $this->type->value,
'is_valid' => $this->isValid(),
...array_filter($fields, fn (mixed $value): bool => $value !== null),
];
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
class AdminAppTicketColumnService
{
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
public function columns(Tenant $tenant): array
{
$keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL
? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by']
: ['order_number', 'product', 'amount', 'client', 'id', 'status', 'scanned_by'];
$columns = array_map(fn (string $key): array => $this->definitions()[$key], $keys);
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
$columns = array_map(function (array $column): array {
if (in_array($column['key'], ['product', 'type', 'date', 'size'], true)) {
$column['sortable'] = false;
}
return $column;
}, $columns);
} else {
$widths = [
'order_number' => '11%',
'product' => '15%',
'amount' => '10%',
'client' => '15%',
'id' => '8%',
'status' => '8%',
'scanned_by' => '11%',
];
$columns = array_map(function (array $column) use ($widths): array {
$column['width'] = $widths[$column['key']];
return $column;
}, $columns);
}
return $columns;
}
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */
public function publicColumns(Tenant $tenant): array
{
return array_map(function (array $column): array {
unset($column['excel_width']);
return $column;
}, $this->columns($tenant));
}
/** @return list<string> */
public function sortableKeys(Tenant $tenant): array
{
return array_values(array_map(
fn (array $column): string => $column['sort_param'],
array_filter($this->columns($tenant), fn (array $column): bool => $column['sortable']),
));
}
/** @return array<string, array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
private function definitions(): array
{
return [
'order_number' => $this->column('order_number', 'N° de orden', 'order_number', '10.5%', 14),
'category' => $this->column('category', 'Categoría', 'text', '11%', 18),
'product' => $this->column('product', 'Producto', 'text', '11%', 22),
'type' => $this->column('type', 'Tipo', 'text', '8%', 18),
'date' => $this->column('date', 'Fecha', 'text', '7%', 14),
'size' => $this->column('size', 'Talle', 'text', '6%', 12),
'amount' => $this->column('amount', 'Importe', 'currency', '8%', 15),
'client' => $this->column('client', 'Cliente', 'text', '11%', 30),
'id' => $this->column('id', 'ID', 'text', '6%', 12),
'status' => $this->column('status', 'Estado', 'status', '7%', 13),
'scanned_by' => $this->column('scanned_by', 'Escaneado por', 'text', '8%', 28),
];
}
/** @return array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int} */
private function column(
string $key,
string $label,
string $type,
string $width,
int $excelWidth,
): array {
return [
'key' => $key,
'label' => $label,
'type' => $type,
'sortable' => true,
'sort_param' => $key,
'width' => $width,
'excel_width' => $excelWidth,
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminAppTicketExcelService
{
public function __construct(
private readonly AdminAppTicketReportService $reportService,
private readonly AdminAppTicketColumnService $columnService,
) {}
/** @param Collection<int, Ticket> $tickets */
public function download(Tenant $tenant, Collection $tickets, string $timeZone): StreamedResponse
{
$generatedAt = now();
$rows = $this->reportService->rows($tickets);
$columns = $this->columnService->columns($tenant);
$spreadsheet = new Spreadsheet;
$spreadsheet->getProperties()
->setCreator('Shopit')
->setTitle('Listado de tickets')
->setSubject($tenant->nombre);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Tickets');
$sheet->fromArray([array_column($columns, 'label')], null, 'A1');
foreach ($rows as $index => $ticket) {
$row = $index + 2;
foreach ($columns as $columnIndex => $column) {
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row;
$value = $column['type'] === 'status'
? ($ticket['status_label'] ?? $ticket[$column['key']] ?? null)
: ($ticket[$column['key']] ?? null);
if ($column['type'] === 'currency' && $value !== null) {
$sheet->setCellValue($coordinate, (float) $value);
continue;
}
if ($column['type'] === 'date' && $value instanceof CarbonInterface) {
$sheet->setCellValue(
$coordinate,
Date::dateTimeToExcel($value->copy()->timezone($timeZone)),
);
continue;
}
$sheet->setCellValueExplicit(
$coordinate,
$this->reportService->displayValue($value, $column['type'], $timeZone),
DataType::TYPE_STRING,
);
}
}
$lastRow = max(2, $rows->count() + 1);
$lastColumn = Coordinate::stringFromColumnIndex(count($columns));
foreach ($columns as $columnIndex => $column) {
$letter = Coordinate::stringFromColumnIndex($columnIndex + 1);
if ($column['type'] === 'currency') {
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
->getNumberFormat()->setFormatCode('$ #,##0.00');
}
if ($column['type'] === 'date') {
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
}
$sheet->getColumnDimension($letter)->setWidth($column['excel_width']);
}
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '26382E'],
],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}{$lastRow}");
$filename = 'tickets_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx';
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class AdminAppTicketPdfService
{
public function __construct(
private readonly AdminAppTicketReportService $reportService,
private readonly AdminAppTicketColumnService $columnService,
) {}
/** @param Collection<int, Ticket> $tickets */
public function download(Tenant $tenant, Collection $tickets, string $timeZone): Response
{
$generatedAt = now();
$columns = $this->columnService->columns($tenant);
$rows = $this->reportService->rows($tickets);
$pdf = Pdf::loadView('pdf.adminapp.tickets', [
'tenant' => $tenant,
'columns' => $columns,
'tickets' => $this->reportService->displayRows($rows, $columns, $timeZone),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a3', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'tickets_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}
private function addPageNumbers(DomPdf $pdf): void
{
$pdf->render();
$domPdf = $pdf->getDomPDF();
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
$domPdf->getCanvas()->page_text(
565,
805,
'Página {PAGE_NUM} de {PAGE_COUNT}',
$font,
7,
[0.48, 0.52, 0.49],
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
class AdminAppTicketReportService
{
public function __construct(private readonly AdminAppTicketRowService $rowService) {}
/**
* @param Collection<int, Ticket> $tickets
* @return Collection<int, array<string, mixed>>
*/
public function rows(Collection $tickets): Collection
{
return $this->rowService->rows($tickets);
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @param list<array<string, mixed>> $columns
* @return Collection<int, array<string, string>>
*/
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
{
return $this->rowService->displayRows($rows, $columns, $timeZone);
}
public function displayValue(mixed $value, string $type, string $timeZone): string
{
return $this->rowService->displayValue($value, $type, $timeZone);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Pagination\LengthAwarePaginator;
final readonly class AdminAppTicketResult
{
/** @param LengthAwarePaginator<Ticket> $tickets */
public function __construct(
public LengthAwarePaginator $tickets,
public int $scannedTickets,
public int $totalTickets,
public string $refundedTotal,
) {}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
class AdminAppTicketRowService
{
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const CATEGORY_PRESENTATIONS = [
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'size' => null],
'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'size' => 'talle'],
];
/** @return array<string, mixed> */
public function details(Ticket $ticket): array
{
$purchaseItem = $ticket->sourcePurchaseItem;
$refund = $ticket->refund;
return [
'source_purchase_item_id' => $ticket->source_purchase_item_id,
'order_number' => $purchaseItem?->compra_id,
'product' => $purchaseItem?->item_nombre
?? $ticket->sourceCatalogItem?->nombre
?? $ticket->name,
'amount' => $purchaseItem?->precio_unitario,
'refund_type' => $refund?->type,
'refund_type_label' => $refund?->typeLabel(),
'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,
'status' => $ticket->status,
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
'variant_properties' => $this->variantProperties($ticket),
'allow_refund' => $ticket->allow_refund(),
];
}
/** @param array<string, mixed>|null $details */
public function values(Ticket $ticket, ?array $details = null): array
{
$details ??= $this->details($ticket);
$presentation = $this->presentation($ticket, $details);
return [
'order_number' => $details['order_number'],
'category' => $presentation['category'],
'product' => $presentation['product'],
'type' => $presentation['type'],
'date' => $presentation['date'],
'size' => $presentation['size'],
'amount' => $details['amount'] === null ? null : (float) $details['amount'],
'client' => $details['client'] ?? 'Sin nombre',
'id' => $ticket->id,
'status' => $details['status'],
'status_label' => $ticket->status_label,
'scanned_by' => $details['scanned_by'] ?? '-',
];
}
/**
* @param Collection<int, Ticket> $tickets
* @return Collection<int, array<string, mixed>>
*/
public function rows(Collection $tickets): Collection
{
return $tickets->values()->map(fn (Ticket $ticket): array => $this->values($ticket));
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @param list<array<string, mixed>> $columns
* @return Collection<int, array<string, string>>
*/
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
{
return $rows->map(fn (array $row): array => collect($columns)
->mapWithKeys(fn (array $column): array => [
$column['key'] => $this->displayValue(
$column['type'] === 'status'
? ($row['status_label'] ?? $row[$column['key']] ?? null)
: ($row[$column['key']] ?? null),
$column['type'],
$timeZone,
),
])
->all());
}
public function displayValue(mixed $value, string $type, string $timeZone): string
{
if ($value === null || $value === '') {
return '-';
}
return match ($type) {
'order_number' => '#'.$value,
'currency' => '$'.number_format((float) $value, 2, ',', '.'),
'status' => Ticket::statusLabel((string) $value),
default => (string) $value,
};
}
/** @param array<string, mixed> $details */
private function presentation(Ticket $ticket, array $details): array
{
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
$effectiveDates = $this->effectiveEventDateLabels($ticket) ?: '-';
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
return [
'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-',
'date' => $effectiveDates,
'size' => '-',
];
}
$configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null;
if ($configuration === null) {
return [
'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-',
'date' => $effectiveDates,
'size' => '-',
];
}
return [
'category' => $configuration['category'] ?? $sourceCategory,
'product' => $configuration['product'] === 'product'
? (string) ($details['product'] ?: $ticket->name ?: '-')
: ($this->propertyLabels($details, $configuration['product']) ?: '-'),
'type' => $configuration['type'] === null
? '-'
: ($this->propertyLabels($details, $configuration['type']) ?: '-'),
'date' => $effectiveDates,
'size' => $configuration['size'] === null
? '-'
: ($this->propertyLabels($details, $configuration['size']) ?: '-'),
];
}
private function effectiveEventDateLabels(Ticket $ticket): string
{
return $ticket->sourceVariant?->selectedEventDates()
->map(fn (EventDate $date): ?EventDate => $date->effectiveDate())
->filter()
->unique(fn (EventDate $date): int => $date->getKey())
->sortBy(fn (EventDate $date): string => $date->date->format('Y-m-d'))
->map(fn (EventDate $date): string => $date->date->format('d/m'))
->implode(', ') ?? '';
}
/** @param array<string, mixed> $details */
private function propertyLabels(array $details, string $code): string
{
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
$labels = collect($property['values'] ?? [])->pluck('label')->filter();
return $labels->implode(', ');
}
/** @param array<string, mixed> $details */
private function allPropertyLabels(array $details): string
{
return collect($details['variant_properties'] ?? [])
->flatMap(fn (array $property): array => $property['values'] ?? [])
->pluck('label')
->filter()
->implode(', ');
}
/** @return list<array{code: string, label: string, values: list<array{value: string, label: string}>}> */
private function variantProperties(Ticket $ticket): array
{
$variant = $ticket->sourceVariant;
if ($variant === null) {
return [];
}
$itemAttributes = $variant->definitions
->map(fn ($definition) => $definition->itemAttribute)
->filter()
->merge($variant->catalogItem?->itemAttributes ?? collect())
->unique('id')
->values();
return $variant->selectionOptions($itemAttributes)
->map(function (array $selection, string $attributeCode) use ($itemAttributes): array {
$itemAttribute = $itemAttributes->first(
fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo
=== $attributeCode,
);
$values = array_is_list($selection) ? $selection : [$selection];
return [
'code' => $attributeCode,
'label' => $itemAttribute?->attribute?->nombre
?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode),
'values' => array_values($values),
];
})
->values()
->all();
}
}

View File

@@ -0,0 +1,694 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\PurchaseRefundSummaryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AdminAppTicketService
{
private const RELATIONS = [
...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS,
'tenant',
'user',
'scannerUser',
'sourceCatalogItem.category',
'sourcePurchaseItem.purchase',
'refund.createdBy',
];
public function __construct(
private readonly AdminAppTicketColumnService $columnService,
private readonly AdminAppTicketRowService $rowService,
private readonly PurchaseRefundSummaryService $refundSummaryService,
) {}
/**
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
*/
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
{
$query = $this->baseQuery($tenant, $filters);
$countQuery = clone $query;
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
if (($filters['sort_by'] ?? null) && ! $databaseSorted) {
$matchingTickets = (clone $query)
->with(self::RELATIONS)
->get();
$matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters);
$tickets = $this->paginate($matchingTickets, $filters);
$scannedTickets = $matchingTickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_USED)
->count();
$activeTickets = $matchingTickets
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->count();
$totalTickets = $activeTickets + $scannedTickets;
} else {
$tickets = (clone $query)
->with(self::RELATIONS)
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
->paginateFromRequest()
->withQueryString();
$counts = $this->calculateTicketCounts($countQuery);
$scannedTickets = $counts['scanned'];
$totalTickets = $counts['total'];
}
return new AdminAppTicketResult(
tickets: $tickets,
scannedTickets: $scannedTickets,
totalTickets: $totalTickets,
refundedTotal: $this->refundSummaryService->totalForTenant($tenant),
);
}
/**
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
* @return Collection<int, Ticket>
*/
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection
{
$query = $this->baseQuery($tenant, $filters);
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
$tickets = $query
->with(self::RELATIONS)
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
->get();
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
}
public function cancel(Tenant $tenant, int $ticketId): Ticket
{
return DB::transaction(function () use ($tenant, $ticketId): Ticket {
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->findOrFail($ticketId);
if (! $ticket->can_cancel()) {
throw ValidationException::withMessages([
'status' => 'El ticket debe estar activo para poder cancelarlo.',
]);
}
$ticket->markAsCancelled();
$ticket->save();
return $ticket->refresh()->load(self::RELATIONS);
});
}
/**
* @return array{
* total: string|null,
* partial: string|null,
* }
*/
public function calculateRefund(Tenant $tenant, int $ticketId): array
{
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->findOrFail($ticketId);
if (! $ticket->can_refund()) {
throw ValidationException::withMessages([
'refund' => 'El reembolso no está disponible para este ticket.',
]);
}
$purchaseItem = PurchaseItem::query()
->find($ticket->source_purchase_item_id);
if ($purchaseItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.',
]);
}
$unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null;
if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) {
$total = number_format($unitPrice, 2, '.', '');
}
$partial = null;
if ($tenant->allow_refund() && $tenant->allow_partial_refund()) {
$partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial');
if ($partialAmount <= $remainingItemAmount) {
$partial = number_format($partialAmount, 2, '.', '');
}
}
return [
'total' => $total,
'partial' => $partial,
];
}
public function refund(
Tenant $tenant,
int $ticketId,
string $refundType,
?User $createdBy = null,
): Ticket {
$this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket {
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->findOrFail($ticketId);
if (! $ticket->can_refund()) {
if ($ticket->status !== Ticket::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'status' => 'El ticket debe estar activo para poder reembolsarlo.',
]);
}
throw ValidationException::withMessages([
'refund' => 'El reembolso no está disponible para este ticket.',
]);
}
$purchaseItem = PurchaseItem::query()
->lockForUpdate()
->find($ticket->source_purchase_item_id);
if ($purchaseItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.',
]);
}
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType);
$refundedAmount = round(
$this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount,
2,
);
if ($refundedAmount > (float) $purchaseItem->total) {
throw ValidationException::withMessages([
'refund_type' => 'El importe reembolsado no puede superar el total del ítem de compra.',
]);
}
$ticket->markAsRefunded();
$ticket->save();
TicketRefund::query()->create([
'ticket_id' => $ticket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $createdBy?->id,
'type' => $refundType,
'amount' => number_format($refundAmount, 2, '.', ''),
]);
$this->restoreInventory($ticket, $purchaseItem);
return $ticket->refresh()->load(self::RELATIONS);
});
}
private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void
{
$catalogItem = $ticket->sourceCatalogItem;
if ($catalogItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un producto con inventario reponible.',
]);
}
// Bundle components need a per-ticket allocation before they can be restored.
if ($catalogItem->isBundle() || $purchaseItem->sourceCatalogItem?->isBundle()) {
return;
}
$inventoryId = $catalogItem->inventory_id;
if ($ticket->source_variant_id !== null) {
$variant = Variant::withTrashed()->find($ticket->source_variant_id);
if ($variant === null) {
throw ValidationException::withMessages(['ticket' => 'No se encontró la variante del ticket.']);
}
// A replacement can move the sellable inventory to a newer variant.
$visited = [];
while ($variant->replaced_by_variant_id !== null) {
if (isset($visited[$variant->id])) {
throw new \LogicException('La cadena de reemplazos de variantes es circular.');
}
$visited[$variant->id] = true;
$variant = Variant::withTrashed()->findOrFail($variant->replaced_by_variant_id);
}
$inventoryId = $variant->inventory_id;
}
$inventory = Inventory::query()->lockForUpdate()->find($inventoryId);
if ($inventory === null) {
throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']);
}
if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) {
$inventory->real_stock++;
}
$inventory->refunded_units++;
$inventory->save();
}
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
{
return round((float) TicketRefund::query()
->where('purchase_item_id', $purchaseItem->id)
->sum('amount'), 2);
}
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void
{
$isAllowed = match ($refundType) {
TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(),
TicketRefund::TYPE_TOTAL => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund,
};
if (! $isAllowed) {
throw ValidationException::withMessages([
'refund_type' => 'El tipo de reembolso solicitado no está habilitado para este tenant.',
]);
}
}
private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float
{
$ticketAmount = (float) $purchaseItem->precio_unitario;
return match ($refundType) {
TicketRefund::TYPE_PARTIAL => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2),
TicketRefund::TYPE_TOTAL => $ticketAmount,
};
}
/**
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
* @return Builder<Ticket>
*/
private function baseQuery(Tenant $tenant, array $filters): Builder
{
$search = trim((string) ($filters['q'] ?? ''));
$query = Ticket::query()
->where('tenant_code', $tenant->codigo)
->when($search !== '', function (Builder $query) use ($search): void {
$this->applySearchFilter($query, $search);
})
->when($filters['category'] ?? null, function (Builder $query, string $category): void {
$query->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery
->whereRaw('LOWER(nombre) = ?', [mb_strtolower(trim($category))]));
})
->when($filters['product'] ?? null, function (Builder $query, string $product) use ($filters): void {
$this->applyProductFilter($query, (string) ($filters['category'] ?? ''), $product);
})
->when($filters['type'] ?? null, function (Builder $query, string $type) use ($filters): void {
$this->applyTypeFilter($query, (string) ($filters['category'] ?? ''), $type);
})
->when($filters['date'] ?? null, function (Builder $query, string $date) use ($tenant): void {
if ($tenant->codigo === 'fiesta_futbol_infantil') {
$this->applyEventDateFilter($query, $date);
return;
}
$query->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
->whereDate('created_at', $date));
})
->when($filters['size'] ?? null, function (Builder $query, string $size) use ($filters): void {
$this->applySizeFilter($query, (string) ($filters['category'] ?? ''), $size);
});
$this->applyStatusFilter($query, $filters['status'] ?? null);
return $query;
}
/** @param Builder<Ticket> $query */
private function applySearchFilter(Builder $query, string $search): void
{
$containsPattern = '%'.mb_strtolower($search).'%';
$amount = $this->searchAmount($search);
$query->where(function (Builder $searchQuery) use ($search, $containsPattern, $amount): void {
$searchQuery
->where(function (Builder $clientQuery) use ($containsPattern): void {
$clientQuery
->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]))
->orWhere(function (Builder $fallbackClientQuery) use ($containsPattern): void {
$fallbackClientQuery
->where(function (Builder $missingPurchaseClientQuery): void {
$missingPurchaseClientQuery
->whereDoesntHave('sourcePurchaseItem.purchase')
->orWhereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
->whereNull('nombre_apellido'));
})
->whereHas('user', fn (Builder $userQuery): Builder => $userQuery
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]));
});
})
->orWhereHas('scannerUser', fn (Builder $scannerQuery): Builder => $scannerQuery
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]));
if (ctype_digit($search)) {
$searchQuery
->orWhere('tickets.id', (int) $search)
->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery
->where('compra_id', (int) $search));
}
if ($amount !== null) {
$searchQuery->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery
->where('precio_unitario', $amount));
}
});
}
private function searchAmount(string $search): ?string
{
$value = preg_replace('/[\s$]/u', '', trim($search));
if ($value === null || $value === '') {
return null;
}
if (preg_match('/^\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/', $value) === 1) {
$value = str_replace(['.', ','], ['', '.'], $value);
} elseif (preg_match('/^\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/', $value) === 1) {
$value = str_replace(',', '', $value);
} elseif (preg_match('/^\d+(?:[.,]\d{1,2})?$/', $value) === 1) {
$value = str_replace(',', '.', $value);
} else {
return null;
}
return number_format((float) $value, 2, '.', '');
}
/** @param Builder<Ticket> $query */
private function applyProductFilter(Builder $query, string $category, string $product): void
{
$category = $this->normalizedCategory($category);
if (in_array($category, ['alojamientos', 'camping'], true)) {
$this->whereVariantDefinition($query, 'tipo_alojamiento', $product);
return;
}
if (in_array($category, ['comidas', 'comida'], true)) {
$this->whereVariantDefinition($query, 'horario', $product);
return;
}
$query->whereHas('sourceCatalogItem', fn (Builder $itemQuery): Builder => $itemQuery
->where('slug', $product));
}
/** @param Builder<Ticket> $query */
private function applyTypeFilter(Builder $query, string $category, string $type): void
{
$attribute = match ($this->normalizedCategory($category)) {
'comidas', 'comida' => 'servicio',
'merchandising' => 'color',
default => null,
};
if ($attribute !== null) {
$this->whereVariantDefinition($query, $attribute, $type);
}
}
/** @param Builder<Ticket> $query */
private function applyEventDateFilter(Builder $query, string $date): void
{
$query
->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery
->whereRaw('LOWER(nombre) IN (?, ?)', ['comidas', 'comida']))
->whereHas('sourceVariant', function (Builder $variantQuery) use ($date): void {
$variantQuery->where(function (Builder $dateQuery) use ($date): void {
$dateQuery
->whereHas('eventDate', fn (Builder $eventDateQuery): Builder => $eventDateQuery
->whereDate('date', $date))
->orWhereHas('eventDates', fn (Builder $eventDateQuery): Builder => $eventDateQuery
->whereDate('date', $date));
});
});
}
/** @param Builder<Ticket> $query */
private function applySizeFilter(Builder $query, string $category, string $size): void
{
if ($this->normalizedCategory($category) === 'merchandising') {
$this->whereVariantDefinition($query, 'talle', $size);
}
}
/** @param Builder<Ticket> $query */
private function whereVariantDefinition(Builder $query, string $attribute, string $value): void
{
$query->whereHas('sourceVariant.definitions', fn (Builder $definitionQuery): Builder => $definitionQuery
->where('value', $value)
->whereHas('itemAttribute.attribute', fn (Builder $attributeQuery): Builder => $attributeQuery
->where('codigo', $attribute)));
}
/** @param Builder<Ticket> $query */
private function applyStatusFilter(Builder $query, ?string $status): void
{
if ($status === null || $status === '') {
return;
}
if ($status === Ticket::STATUS_USED) {
$query
->whereNotNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at');
return;
}
$timestampColumn = match ($status) {
Ticket::STATUS_DISABLED => 'disabled_at',
Ticket::STATUS_CANCELLED => 'cancelled_at',
Ticket::STATUS_REFUNDED => 'refunded_at',
default => null,
};
if ($timestampColumn !== null) {
$query->whereNotNull($timestampColumn);
if ($status === Ticket::STATUS_DISABLED) {
$query->whereNull('cancelled_at')->whereNull('refunded_at');
}
if ($status === Ticket::STATUS_CANCELLED) {
$query->whereNull('refunded_at');
}
return;
}
$matchingIds = (clone $query)
->whereNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->with(TicketValidityResolver::RELATIONS)
->get()
->filter(fn (Ticket $ticket): bool => $ticket->status === $status)
->pluck('id');
$query->whereIn('tickets.id', $matchingIds);
}
/**
* @param Builder<Ticket> $countQuery
* @return array{scanned: int, total: int}
*/
private function calculateTicketCounts(Builder $countQuery): array
{
$scannedTickets = (clone $countQuery)
->whereNotNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->count();
$activeTickets = (clone $countQuery)
->whereNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->with(TicketValidityResolver::RELATIONS)
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->count();
return [
'scanned' => $scannedTickets,
'total' => $activeTickets + $scannedTickets,
];
}
private function normalizedCategory(string $category): string
{
return mb_strtolower(trim($category));
}
/**
* @param Builder<Ticket> $query
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
*/
private function applyDatabaseSort(Builder $query, Tenant $tenant, array $filters): bool
{
$sortBy = (string) ($filters['sort_by'] ?? '');
if ($sortBy === '') {
return false;
}
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
$sortExpression = match ($sortBy) {
'order_number' => $this->purchaseItemColumnQuery('compra_id'),
'id' => 'tickets.id',
'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
'scanned_by' => User::query()
->withTrashed()
->select('nombre_apellido')
->whereColumn('users.id', 'tickets.scanner_user_id'),
'product' => $tenant->codigo === 'fiesta_futbol_infantil'
? null
: $this->purchaseItemColumnQuery('item_nombre'),
default => null,
};
if ($sortExpression === null) {
return false;
}
$query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id');
return true;
}
/** @return Builder<PurchaseItem> */
private function purchaseItemColumnQuery(string $column): Builder
{
return PurchaseItem::query()
->select($column)
->whereColumn('compra_items.id', 'tickets.source_purchase_item_id')
->limit(1);
}
/**
* @param Collection<int, Ticket> $tickets
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
* @return Collection<int, Ticket>
*/
private function sortTickets(Collection $tickets, Tenant $tenant, array $filters): Collection
{
$sortBy = (string) ($filters['sort_by'] ?? '');
if ($sortBy === '') {
return $tickets;
}
$column = collect($this->columnService->columns($tenant))
->firstWhere('sort_param', $sortBy);
if ($column === null) {
return $tickets;
}
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? -1 : 1;
$values = $tickets->mapWithKeys(fn (Ticket $ticket): array => [
$ticket->getKey() => $this->rowService->values($ticket)[$column['key']] ?? null,
]);
return $tickets->sort(function (Ticket $left, Ticket $right) use ($column, $direction, $values): int {
$leftValue = $values->get($left->getKey());
$rightValue = $values->get($right->getKey());
if ($leftValue === null || $leftValue === '') {
return $rightValue === null || $rightValue === '' ? $right->id <=> $left->id : 1;
}
if ($rightValue === null || $rightValue === '') {
return -1;
}
$comparison = $this->compareValues($leftValue, $rightValue, $column['type']);
return $comparison === 0
? $right->id <=> $left->id
: $comparison * $direction;
})->values();
}
private function compareValues(mixed $left, mixed $right, string $type): int
{
if (in_array($type, ['currency', 'order_number'], true)) {
return (float) $left <=> (float) $right;
}
if ($type === 'date') {
$leftTimestamp = $left instanceof \DateTimeInterface ? $left->getTimestamp() : strtotime((string) $left);
$rightTimestamp = $right instanceof \DateTimeInterface ? $right->getTimestamp() : strtotime((string) $right);
return $leftTimestamp <=> $rightTimestamp;
}
if ($type === 'status') {
$left = $this->rowService->displayValue($left, $type, 'UTC');
$right = $this->rowService->displayValue($right, $type, 'UTC');
}
return strnatcasecmp((string) $left, (string) $right);
}
/**
* @param Collection<int, Ticket> $tickets
* @param array{page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<Ticket>
*/
private function paginate(Collection $tickets, array $filters): LengthAwarePaginator
{
$page = (int) ($filters['page'] ?? 1);
$perPage = (int) ($filters['per_page'] ?? 15);
return (new LengthAwarePaginator(
$tickets->forPage($page, $perPage)->values(),
$tickets->count(),
$perPage,
$page,
['path' => request()->url()],
))->withQueryString();
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Domains\Ticket\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class BackfillRefundedUnitsService
{
/** @return array<string, int> */
public function run(): array
{
$summary = DB::transaction(function (): array {
if (DB::table('inventories')->where('refunded_units', '>', 0)->exists()) {
throw new RuntimeException('El backfill requiere que refunded_units sea cero en todos los inventarios.');
}
$counts = [];
$variants = [];
$refundsSeen = 0;
$bundlesSkipped = 0;
DB::table('ticket_refunds as refunds')
->join('tickets', 'tickets.id', '=', 'refunds.ticket_id')
->join('compra_items as purchase_items', 'purchase_items.id', '=', 'refunds.purchase_item_id')
->leftJoin('catalog_items as purchase_catalog', 'purchase_catalog.id', '=', 'purchase_items.source_catalog_item_id')
->leftJoin('catalog_items as ticket_catalog', 'ticket_catalog.id', '=', 'tickets.source_catalog_item_id')
->select([
'refunds.id',
'refunds.ticket_id',
'tickets.source_variant_id',
'ticket_catalog.inventory_id',
'ticket_catalog.inventory_policy',
'ticket_catalog.type as ticket_catalog_type',
'purchase_catalog.type as purchase_catalog_type',
])
->chunkById(500, function ($refunds) use (&$counts, &$variants, &$refundsSeen, &$bundlesSkipped): void {
foreach ($refunds as $refund) {
$refundsSeen++;
if ($refund->ticket_catalog_type === 'bundle' || $refund->purchase_catalog_type === 'bundle') {
$bundlesSkipped++;
continue;
}
if ($refund->inventory_policy === null) {
throw new RuntimeException("El reembolso {$refund->id} no tiene un producto de catálogo asociado.");
}
$inventoryId = $refund->source_variant_id === null
? $refund->inventory_id
: $this->currentVariantInventoryId((int) $refund->source_variant_id, $variants);
if ($inventoryId === null) {
throw new RuntimeException("El reembolso {$refund->id} no tiene un inventario asociado.");
}
$counts[$inventoryId]['refunded'] = ($counts[$inventoryId]['refunded'] ?? 0) + 1;
if ($refund->inventory_policy === 'tracked') {
$counts[$inventoryId]['stock'] = ($counts[$inventoryId]['stock'] ?? 0) + 1;
}
}
}, 'refunds.id', 'id');
foreach ($counts as $inventoryId => $count) {
$updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])];
if (($count['stock'] ?? 0) > 0) {
$updates['real_stock'] = DB::raw('real_stock + '.$count['stock']);
}
if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) {
throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo.");
}
}
$refundedUnitsAdded = array_sum(array_column($counts, 'refunded'));
return [
'refunds_seen' => $refundsSeen,
'refunds_applied' => $refundedUnitsAdded,
'bundles_skipped' => $bundlesSkipped,
'inventories_updated' => count($counts),
'refunded_units_added' => $refundedUnitsAdded,
'real_stock_added' => array_sum(array_column($counts, 'stock')),
];
});
Log::info('inventory.refunded_units_backfill.completed', $summary);
return $summary;
}
/** @param array<int, object|null> $variants */
private function currentVariantInventoryId(int $variantId, array &$variants): ?int
{
$visited = [];
while (true) {
if (isset($visited[$variantId])) {
throw new RuntimeException("La cadena de reemplazos de la variante {$variantId} es circular.");
}
$visited[$variantId] = true;
if (! array_key_exists($variantId, $variants)) {
$variants[$variantId] = DB::table('variantes')
->where('id', $variantId)
->first(['inventory_id', 'replaced_by_variant_id']);
}
$variant = $variants[$variantId];
if ($variant === null) {
throw new RuntimeException("No se encontró la variante {$variantId} de un ticket reembolsado.");
}
if ($variant->replaced_by_variant_id === null) {
return $variant->inventory_id === null ? null : (int) $variant->inventory_id;
}
$variantId = (int) $variant->replaced_by_variant_id;
}
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
class LoadTestTicketDatasetService
{
public function __construct(
private readonly TicketGeneratorService $ticketGenerator,
private readonly TicketValidityResolver $validityResolver,
) {}
/**
* @return array{
* run_id: string,
* tenant_code: string,
* catalog_item_id: int,
* variant_id: int|null,
* tickets: int,
* scanners: int,
* owners: int,
* rows: array<int, array{scanner_token: string, ticket_uuid: string, expected_status: int}>
* }
*/
public function prepare(
string $tenantCode,
int $ticketCount,
int $scannerCount,
int $ownerCount,
?int $catalogItemId = null,
?int $variantId = null,
?string $runId = null,
): array {
$this->validateInput($tenantCode, $ticketCount, $scannerCount, $ownerCount);
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$catalogItem = $this->resolveCatalogItem($tenant, $catalogItemId);
$variant = $this->resolveVariant($catalogItem, $variantId);
$runId ??= now()->format('Ymd-His').'-'.Str::lower(Str::random(6));
if (preg_match('/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/', $runId) !== 1) {
throw new InvalidArgumentException('run_id contiene caracteres inválidos o es demasiado largo.');
}
if ($variant !== null && ! $this->validityResolver->resolveVariant($variant)->isValid()) {
throw new InvalidArgumentException(
'La variante seleccionada no tiene una vigencia activa y resoluble.'
);
}
$scanners = $this->scanners($tenant, $catalogItem, $scannerCount);
$owners = $this->owners($tenant, $ownerCount);
$tokens = $scanners->map(function (User $scanner): string {
$scanner->tokens()->where('name', 'load-test-scanner')->delete();
return $scanner->createToken(
'load-test-scanner',
['scanner'],
now()->addMinutes((int) config('sanctum.expiration', 720)),
)->plainTextToken;
})->values();
$rows = [];
$remaining = $ticketCount;
$ownerIndex = 0;
$scannerIndex = 0;
$batchSize = min(500, max(1, (int) ceil($ticketCount / $ownerCount)));
while ($remaining > 0) {
$quantity = min($batchSize, $remaining);
$owner = $owners[$ownerIndex % $owners->count()];
$tickets = $this->ticketGenerator->generate(
$catalogItem,
$owner,
$quantity,
$variant?->getKey(),
);
foreach ($tickets as $ticket) {
if (! $ticket->is_valid) {
throw new InvalidArgumentException(
'La configuración seleccionada genera tickets que no están vigentes.'
);
}
$rows[] = [
'scanner_token' => $tokens[$scannerIndex % $tokens->count()],
'ticket_uuid' => $ticket->ticket,
'expected_status' => 200,
];
$scannerIndex++;
}
$remaining -= $quantity;
$ownerIndex++;
}
return [
'run_id' => $runId,
'tenant_code' => $tenant->codigo,
'catalog_item_id' => $catalogItem->getKey(),
'variant_id' => $variant?->getKey(),
'tickets' => count($rows),
'scanners' => $scanners->count(),
'owners' => $owners->count(),
'rows' => $rows,
];
}
private function validateInput(
string $tenantCode,
int $ticketCount,
int $scannerCount,
int $ownerCount,
): void {
foreach ([
'tickets' => [$ticketCount, 100_000],
'scanners' => [$scannerCount, 10_000],
'owners' => [$ownerCount, 100_000],
] as $name => [$value, $maximum]) {
if ($value < 1 || $value > $maximum) {
throw new InvalidArgumentException("{$name} debe estar entre 1 y {$maximum}.");
}
}
if ($scannerCount > $ticketCount || $ownerCount > $ticketCount) {
throw new InvalidArgumentException(
'La cantidad de scanners y propietarios no puede superar la cantidad de tickets.'
);
}
}
private function resolveCatalogItem(Tenant $tenant, ?int $catalogItemId): CatalogItem
{
$query = $tenant->catalogItems()
->where('has_tickets', true)
->where('type', CatalogItemType::Standard->value);
if ($catalogItemId !== null) {
$query->whereKey($catalogItemId);
}
$catalogItem = $query->first();
if ($catalogItem === null) {
throw new InvalidArgumentException(
'No se encontró un producto estándar con tickets habilitados para el tenant.'
);
}
if ($tenant->requiresScannerCategoryValidation() && $catalogItem->category_id === null) {
throw new InvalidArgumentException(
'El producto debe tener una categoría para autorizar a los scanners.'
);
}
return $catalogItem;
}
private function resolveVariant(CatalogItem $catalogItem, ?int $variantId): ?Variant
{
if ($variantId === null) {
return null;
}
$variant = $catalogItem->variants()->whereKey($variantId)->first();
if ($variant === null) {
throw new InvalidArgumentException('La variante no pertenece al producto seleccionado.');
}
return $variant;
}
/** @return Collection<int, User> */
private function scanners(Tenant $tenant, CatalogItem $catalogItem, int $count): Collection
{
return Collection::times($count, function (int $number) use ($tenant, $catalogItem): User {
$scanner = User::query()->updateOrCreate(
['email' => $this->email($tenant, 'scanner', $number)],
[
'nombre_apellido' => "Load test scanner {$number}",
'password' => Str::password(32),
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo,
],
);
if ($tenant->requiresScannerCategoryValidation()) {
$scanner->scanCategories()->syncWithoutDetaching([$catalogItem->category_id]);
}
return $scanner;
});
}
/** @return Collection<int, User> */
private function owners(Tenant $tenant, int $count): Collection
{
return Collection::times($count, function (int $number) use ($tenant): User {
$owner = User::query()->updateOrCreate(
['email' => $this->email($tenant, 'owner', $number)],
[
'nombre_apellido' => "Load test owner {$number}",
'password' => Str::password(32),
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => $tenant->codigo,
],
);
return $owner;
});
}
private function email(Tenant $tenant, string $kind, int $number): string
{
$tenantSlug = Str::lower(preg_replace('/[^a-z0-9]+/i', '-', $tenant->codigo));
return "loadtest+{$tenantSlug}.{$kind}.{$number}@shopit.test";
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Domains\Ticket\Services;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
/**
* Resultado completo de resolver la vigencia de un ticket.
*
* Cada ResolvedValidityGroup contiene condiciones AND. Entre los grupos se
* aplica OR, por lo que alcanza con que uno de ellos esté activo.
*/
final readonly class ResolvedTicketValidity
{
/** @param Collection<int, ResolvedValidityGroup> $groups */
public function __construct(
public Collection $groups,
public bool $isResolvable = true,
public bool $isUnrestricted = false,
) {}
/** No existe ninguna restricción temporal configurada. */
public static function unrestricted(): self
{
return new self(collect(), isUnrestricted: true);
}
/** La configuración fuente está incompleta o es inconsistente. */
public static function unresolvable(): self
{
return new self(collect(), isResolvable: false);
}
/** Es válido cuando no tiene restricciones o algún grupo OR está activo. */
public function isValid(?CarbonInterface $at = null): bool
{
if (! $this->isResolvable) {
return false;
}
return $this->isUnrestricted || $this->groups->contains(
fn (ResolvedValidityGroup $group): bool => $group->isValid($at)
);
}
/** Sólo está vencido cuando todos los grupos OR ya vencieron. */
public function isExpired(?CarbonInterface $at = null): bool
{
return $this->isResolvable
&& ! $this->isUnrestricted
&& $this->groups->isNotEmpty()
&& $this->groups->every(
fn (ResolvedValidityGroup $group): bool => $group->isExpired($at)
);
}
/** Inicio más temprano de todas las alternativas, usado como resumen. */
public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface
{
return $this->groups
->map(fn (ResolvedValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt($at))
->filter()
->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
/** Vencimiento más tardío de todas las alternativas, usado como resumen. */
public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface
{
return $this->groups
->map(fn (ResolvedValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt($at))
->filter()
->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
/**
* Representa una intersección de vigencias: todos los ValidityTime del grupo
* deben cumplirse simultáneamente (AND).
*
* Ejemplo: [fecha del evento, horario de almuerzo] significa que el ticket
* solamente es válido durante la intersección de ambas ventanas.
*/
final readonly class ResolvedValidityGroup
{
/** @param Collection<int, ValidityTime> $validityTimes */
public function __construct(public Collection $validityTimes) {}
/** Comprueba si el instante pertenece a la intersección efectiva del grupo. */
public function isValid(?CarbonInterface $at = null): bool
{
$at ??= now();
$startsAt = $this->effectiveStartsAt($at);
$expiresAt = $this->effectiveExpiresAt($at);
return $this->validityTimes->isNotEmpty()
&& ($startsAt === null || $startsAt->lessThanOrEqualTo($at))
&& ($expiresAt === null || $expiresAt->greaterThan($at));
}
/** Un grupo vence cuando termina su intersección efectiva. */
public function isExpired(?CarbonInterface $at = null): bool
{
$at ??= now();
$expiresAt = $this->effectiveExpiresAt($at);
return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at);
}
/**
* En un AND, la intersección comienza en el inicio más tardío.
* Por ejemplo, fecha 00:00 + horario 12:00 comienza a las 12:00.
*/
public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->validityTimes
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor))
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
/**
* En un AND, la intersección termina en el vencimiento más temprano.
* Los horarios cuyo fin no supera al inicio se interpretan como nocturnos.
*/
public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->validityTimes
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$startsAt = $validityTime->startsAt($anchor);
$expiresAt = $validityTime->expiresAt($anchor);
if ($startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) {
return $expiresAt->addDay();
}
return $expiresAt;
})
->filter()
->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/**
* Usa la fecha de un fixed_window como ancla para convertir ventanas que
* sólo contienen horas (time_window) en instantes concretos.
*/
private function dateAnchor(): ?CarbonInterface
{
return $this->validityTimes
->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow)
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at)
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
}

View File

@@ -0,0 +1,335 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticket\Models\ScanAttempt;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class ScannerTicketService
{
/**
* @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<ScanAttempt>
*/
public function attemptsBy(User $scanner, array $filters = []): LengthAwarePaginator
{
$search = trim((string) ($filters['q'] ?? ''));
return ScanAttempt::query()
->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey())
->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search);
$query->where(function (Builder $searchQuery) use ($search, $attemptedAtDate): void {
$searchQuery->where('data', 'like', "%{$search}%");
if (ctype_digit($search)) {
$searchQuery->orWhere('id', (int) $search);
}
if ($attemptedAtDate !== null) {
$searchQuery->orWhereDate('created_at', $attemptedAtDate);
}
});
})
->orderByDesc('created_at')
->orderByDesc('id')
->paginateFromRequest()
->withQueryString();
}
/**
* @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<ScanAttempt>
*/
public function attemptsByStaff(User $scanner, array $filters = []): LengthAwarePaginator
{
$search = trim((string) ($filters['q'] ?? ''));
return ScanAttempt::query()
->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey())
->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search);
$attemptedAtDayMonth = $this->parseSearchDayMonth($search);
$query->where(function (Builder $searchQuery) use (
$search,
$attemptedAtDate,
$attemptedAtDayMonth,
): void {
$searchQuery
->whereHas(
'ticket.sourceCatalogItem.category',
fn (Builder $categoryQuery): Builder => $categoryQuery
->where('nombre', 'like', "%{$search}%")
)
->orWhere('created_at', 'like', "%{$search}%");
if (ctype_digit($search)) {
$searchQuery->orWhere('ticket_id', (int) $search);
}
if ($attemptedAtDate !== null) {
$searchQuery->orWhereDate('created_at', $attemptedAtDate);
}
if ($attemptedAtDayMonth !== null) {
$searchQuery->orWhere(function (Builder $dateQuery) use ($attemptedAtDayMonth): void {
$dateQuery
->whereDay('created_at', $attemptedAtDayMonth['day'])
->whereMonth('created_at', $attemptedAtDayMonth['month']);
});
}
});
})
->orderByDesc('created_at')
->orderByDesc('id')
->paginateFromRequest()
->withQueryString();
}
public function scanAttemptDetail(User $scanner, int $scanAttemptId): ScanAttempt
{
$scanAttempt = ScanAttempt::query()
->with('ticket')
->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey())
->findOrFail($scanAttemptId);
$scanAttempt->ticket?->loadMissing($this->relations());
return $scanAttempt;
}
private function parseSearchDate(string $search): ?string
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
[$year, $month, $day] = array_map('intval', array_slice($matches, 1));
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/', $search, $matches) === 1) {
$day = (int) $matches[1];
$month = (int) $matches[2];
$year = (int) $matches[3];
$year = strlen($matches[3]) === 2 ? 2000 + $year : $year;
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
return null;
}
/** @return array{day: int, month: int}|null */
private function parseSearchDayMonth(string $search): ?array
{
if (preg_match('/^(\d{1,2})\/(\d{1,2})$/', $search, $matches) !== 1) {
return null;
}
$day = (int) $matches[1];
$month = (int) $matches[2];
return checkdate($month, $day, 2000) ? compact('day', 'month') : null;
}
public function detail(User $scanner, string $ticketUuid): Ticket
{
$query = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid);
if ($this->requiresCategoryValidation($scanner)) {
$categoryIds = $this->scannerCategoryIds($scanner);
$query->where(function (Builder $query) use ($scanner, $categoryIds): void {
$query
->where('scanner_user_id', $scanner->getKey())
->orWhereHas(
'sourceCatalogItem',
fn (Builder $catalogItemQuery): Builder => $catalogItemQuery
->whereIn('category_id', $categoryIds)
);
});
}
return $query->firstOrFail();
}
public function scan(User $scanner, mixed $scannedData): ScanAttempt
{
$scanAttempt = ScanAttempt::query()->create([
'tenant_code' => $scanner->tenant_codigo,
'scanner_user_id' => $scanner->getKey(),
'data' => $this->serializeScannedData($scannedData),
'result' => ScanAttemptResult::Processing,
]);
if (! is_string($scannedData) || ! Str::isUuid($scannedData)) {
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::InvalidQr);
return $scanAttempt->refresh();
}
$ticketId = null;
try {
return DB::transaction(function () use (
$scanner,
$scannedData,
$scanAttempt,
&$ticketId,
): ScanAttempt {
$ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $scannedData)
->lockForUpdate()
->firstOrFail();
$ticketId = (int) $ticket->getKey();
if (! $this->scannerCanScan($scanner, $ticket)) {
$this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::CategoryForbidden,
$ticketId,
);
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
}
if ($ticket->is_used) {
$this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::AlreadyScanned,
$ticketId,
);
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
}
if (! $ticket->is_valid) {
$result = $ticket->is_expired
? ScanAttemptResult::Expired
: ScanAttemptResult::NotValid;
$this->resolveScanAttempt($scanAttempt, $result, $ticketId);
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
}
$ticket->forceFill([
'used_at' => now(),
'scanner_user_id' => $scanner->getKey(),
])->save();
$this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::Accepted,
$ticketId,
);
$ticket = $ticket->refresh()->load($this->relations());
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
});
} catch (ModelNotFoundException) {
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::TicketNotFound);
return $scanAttempt->refresh();
} catch (Throwable $exception) {
report($exception);
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::UnexpectedError, $ticketId);
return $scanAttempt->refresh();
}
}
private function resolveScanAttempt(
ScanAttempt $scanAttempt,
ScanAttemptResult $result,
?int $ticketId = null,
): void {
$scanAttempt->forceFill([
'ticket_id' => $ticketId,
'result' => $result,
'resolved_at' => now(),
])->save();
}
private function serializeScannedData(mixed $scannedData): ?string
{
if ($scannedData === null || is_string($scannedData)) {
return $scannedData;
}
$encoded = json_encode(
$scannedData,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE,
);
return $encoded === false ? get_debug_type($scannedData) : $encoded;
}
/** @return Builder<Ticket> */
private function baseQuery(): Builder
{
return Ticket::query()->with($this->relations());
}
/** @return array<int, string> */
private function relations(): array
{
return [
...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS,
'sourceCatalogItem.category',
'sourceVariant.eventDate',
'sourceVariant.catalogItem',
'user',
];
}
/** @return array<int, int> */
private function scannerCategoryIds(User $scanner): array
{
return $scanner->scanCategories()
->pluck('categorias.id')
->map(fn (mixed $id): int => (int) $id)
->all();
}
private function scannerCanScan(User $scanner, Ticket $ticket): bool
{
if (! $this->requiresCategoryValidation($scanner)) {
return true;
}
$categoryId = $ticket->sourceCatalogItem?->category_id;
return $categoryId !== null
&& $scanner->scanCategories()
->where('categorias.id', $categoryId)
->exists();
}
private function requiresCategoryValidation(User $scanner): bool
{
return $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation();
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TicketGeneratorService
{
public function __construct(private readonly TicketValidityResolver $validityResolver) {}
/**
* @return Collection<int, Ticket>
*/
public function generate(
CatalogItem $catalogItem,
User $user,
int $quantity = 1,
?int $sourceVariantId = null,
?int $sourcePurchaseItemId = null,
): Collection {
if ($quantity < 1) {
throw TicketGenerationException::invalidQuantity();
}
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseItemId): Collection {
$targets = $this->resolveTargets(
$catalogItem,
$quantity,
$sourceVariantId,
);
return $targets->map(function (array $target) use (
$sourcePurchaseItemId,
$user,
): Ticket {
$item = $target['catalog_item'];
$variant = $target['variant'];
$ticket = Ticket::query()->create([
'tenant_code' => $item->tenant_code,
'ticket' => (string) Str::uuid(),
'source_purchase_item_id' => $sourcePurchaseItemId,
'source_catalog_item_id' => $item->getKey(),
'source_variant_id' => $variant?->getKey(),
'used_at' => null,
'user_id' => $user->getKey(),
]);
return $ticket;
});
});
}
/** @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}> */
private function resolveTargets(
CatalogItem $catalogItem,
int $quantity,
?int $sourceVariantId,
): Collection {
if (! $catalogItem->isBundle()) {
$variant = $this->resolveVariant($catalogItem, $sourceVariantId);
$this->validateTarget($catalogItem, $variant);
return $this->targetsForVariant($catalogItem, $variant, $quantity);
}
$catalogItem->loadMissing([
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
if ($catalogItem->bundleComponents->isEmpty()) {
throw TicketGenerationException::emptyBundle($catalogItem);
}
return $catalogItem->bundleComponents
->flatMap(function ($component) use ($quantity): Collection {
$componentItem = $component->catalogItem;
$variant = $component->variant;
$this->validateTarget($componentItem, $variant);
return $this->targetsForVariant(
$componentItem,
$variant,
$quantity * $component->quantity,
);
})
->values();
}
/** @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}> */
private function targetsForVariant(
CatalogItem $catalogItem,
?Variant $variant,
int $quantity,
): Collection {
if ($variant === null) {
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => null,
]);
}
$variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
if (! $this->validityResolver->resolveVariant($variant)->isResolvable) {
throw TicketGenerationException::invalidValidityConfiguration($catalogItem, $variant);
}
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
]);
}
private function resolveVariant(
CatalogItem $catalogItem,
?int $sourceVariantId,
): ?Variant {
if ($sourceVariantId === null) {
return null;
}
$variant = $catalogItem->variants()
->whereKey($sourceVariantId)
->first();
if ($variant === null) {
throw TicketGenerationException::variantNotFound(
$catalogItem,
$sourceVariantId,
);
}
$variant->setRelation('catalogItem', $catalogItem);
return $variant;
}
private function validateTarget(
CatalogItem $catalogItem,
?Variant $variant,
): void {
if (! $catalogItem->has_tickets) {
throw TicketGenerationException::ticketsDisabled($catalogItem);
}
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Throwable;
class TicketPdfService
{
/**
* @param Collection<int, Ticket> $tickets
*/
public function download(Tenant $tenant, Collection $tickets): Response
{
return $this->pdf($tenant, $tickets)->download($this->filename($tickets));
}
/**
* @param Collection<int, Ticket> $tickets
*/
public function contents(Tenant $tenant, Collection $tickets): string
{
return $this->pdf($tenant, $tickets)->output();
}
/**
* @param Collection<int, Ticket> $tickets
*/
public function filename(Collection $tickets): string
{
return 'tickets_'.$tickets->pluck('id')->implode('_').'.pdf';
}
/**
* @param Collection<int, Ticket> $tickets
*/
private function pdf(Tenant $tenant, Collection $tickets): DomPdf
{
$tenant->loadMissing('headerLogo');
$primaryColor = $this->color($tenant->primary_color, '#009933');
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
return Pdf::loadView('pdf.tickets', [
'tenant' => $tenant,
'tickets' => $tickets,
'logoDataUri' => $this->logoDataUri($tenant),
'primaryColor' => $primaryColor,
'headerBackgroundColor' => $headerBackgroundColor,
'headerTextColor' => $this->contrastingTextColor($headerBackgroundColor),
'qrCodes' => $tickets->mapWithKeys(
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
),
])->setPaper('a4');
}
private function qrCodeDataUri(string $value): string
{
$qrCode = new QrCode(
data: $value,
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 700,
margin: 10,
);
return (new PngWriter)->write($qrCode)->getDataUri();
}
private function logoDataUri(Tenant $tenant): ?string
{
$logo = $tenant->headerLogo;
if ($logo === null) {
return null;
}
try {
$contents = Storage::disk('s3')->get($logo->path);
} catch (Throwable) {
return null;
}
return 'data:'.($logo->mime_type ?: 'image/png').';base64,'.base64_encode($contents);
}
private function color(?string $color, string $fallback): string
{
return is_string($color) && preg_match('/^#[0-9A-Fa-f]{6}$/', $color)
? $color
: $fallback;
}
private function contrastingTextColor(string $backgroundColor): string
{
$red = hexdec(substr($backgroundColor, 1, 2));
$green = hexdec(substr($backgroundColor, 3, 2));
$blue = hexdec(substr($backgroundColor, 5, 2));
$luminance = ($red * 299 + $green * 587 + $blue * 114) / 1000;
return $luminance > 160 ? '#17211b' : '#ffffff';
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Ticket\Models\Ticket;
class TicketPresentationResolver
{
public function __construct(private readonly EffectiveEventDateResolver $effectiveEventDateResolver) {}
/** Relaciones necesarias para calcular nombre y descripción sin consultas N+1. */
public const RELATIONS = [
'sourceCatalogItem',
'sourceVariant.catalogItem.itemAttributes.attribute.options',
'sourceVariant.definitions.itemAttribute.attribute.options',
'sourceVariant.eventDates',
'sourceVariant.eventDate',
];
public function name(Ticket $ticket): string
{
$ticket->loadMissing(self::RELATIONS);
$catalogItem = $ticket->sourceCatalogItem;
if ($catalogItem === null) {
return '';
}
$variant = $ticket->sourceVariant;
if ($variant === null) {
return $catalogItem->nombre;
}
$itemAttributes = $variant->catalogItem->itemAttributes;
$eventDateLabels = $variant->selectedEventDates()
->map(fn (EventDate $date): EventDate => $this->effectiveEventDateResolver->resolveLatest($date) ?? $date)
->unique(fn (EventDate $date): int => $date->getKey())
->map(fn (EventDate $date): string => $date->date->format('d/m/Y'))
->implode(', ');
$properties = $variant->selectionOptions($itemAttributes)
->map(function (array $option, string $attributeCode) use ($itemAttributes, $eventDateLabels): ?string {
$labels = $attributeCode === 'event_date' ? $eventDateLabels : collect(array_is_list($option) ? $option : [$option])
->pluck('label')
->filter(fn ($label): bool => is_string($label) && $label !== '')
->implode(', ');
if ($labels === '') {
return null;
}
$ticketLabel = $itemAttributes->first(
fn ($itemAttribute): bool => $itemAttribute->attribute?->codigo === $attributeCode,
)?->ticket_label;
return is_string($ticketLabel) && trim($ticketLabel) !== ''
? trim($ticketLabel).' '.$labels
: $labels;
})
->filter()
->values();
return $properties->isEmpty()
? $catalogItem->nombre
: $catalogItem->nombre.' ('.$properties->implode(', ').')';
}
public function description(Ticket $ticket): string
{
$ticket->loadMissing(self::RELATIONS);
return (string) ($ticket->sourceVariant?->getDescription()
?? $ticket->sourceCatalogItem?->descripcion
?? '');
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Support\Collection;
/**
* Deriva la expresión temporal de un ticket desde su variante.
*
* Las selecciones alternativas de una misma dimensión (varias fechas u opciones
* multiselección) se interpretan como OR. Las dimensiones diferentes se combinan
* mediante AND usando un producto cartesiano.
*/
class TicketValidityResolver
{
private readonly EffectiveEventDateResolver $effectiveEventDateResolver;
public function __construct(?EffectiveEventDateResolver $effectiveEventDateResolver = null)
{
$this->effectiveEventDateResolver = $effectiveEventDateResolver
?? new EffectiveEventDateResolver;
}
/** Relaciones necesarias para resolver tickets sin consultas N+1. */
public const RELATIONS = [
'sourceVariant.eventDates.validityTime',
'sourceVariant.eventDate.validityTime',
'sourceVariant.definitions.itemAttribute.attribute.options.validityTime',
];
/**
* Resuelve la variante fuente del ticket. Un ticket creado legítimamente sin
* variante es irrestricto; una referencia esperada pero rota es irresoluble.
*/
public function resolveTicket(Ticket $ticket): ResolvedTicketValidity
{
if ($ticket->source_variant_id === null) {
return ResolvedTicketValidity::unrestricted();
}
$ticket->loadMissing(self::RELATIONS);
if ($ticket->sourceVariant === null) {
return ResolvedTicketValidity::unresolvable();
}
return $this->resolveVariant($ticket->sourceVariant);
}
/**
* Convierte las fechas y definiciones temporales de la variante en grupos
* normalizados: AND dentro de cada grupo y OR entre grupos.
*/
public function resolveVariant(Variant $variant): ResolvedTicketValidity
{
$variant->loadMissing([
'eventDates.validityTime',
'eventDate.validityTime',
'definitions.itemAttribute.attribute.options.validityTime',
]);
$dimensions = collect();
$selectedEventDates = $variant->selectedEventDates();
$eventDates = $selectedEventDates
->map(fn (EventDate $eventDate): ?EventDate => $this->effectiveEventDateResolver->resolve($eventDate))
->filter()
->unique(fn (EventDate $eventDate): int => $eventDate->getKey() ?? spl_object_id($eventDate))
->values();
if ($selectedEventDates->isNotEmpty() && $eventDates->isEmpty()) {
return ResolvedTicketValidity::unresolvable();
}
$eventDates->each->loadMissing('validityTime');
if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) {
return ResolvedTicketValidity::unresolvable();
}
if ($eventDates->isNotEmpty()) {
// Todas las fechas pertenecen a una misma dimensión alternativa:
// fecha 1 OR fecha 2 OR fecha 3.
$dimensions->push(
$eventDates->map(fn ($eventDate): Collection => collect([$eventDate->validityTime]))
);
}
foreach ($variant->definitions->groupBy('item_attribute_id') as $definitions) {
$itemAttribute = $definitions->first()?->itemAttribute;
$attribute = $itemAttribute?->attribute;
if ($itemAttribute === null || $attribute === null) {
return ResolvedTicketValidity::unresolvable();
}
if (! $attribute->type->supportsOptions() || $attribute->type->usesDynamicOptions()) {
// Texto, números y demás atributos no temporales no restringen
// la vigencia. EventDate se procesó arriba mediante su relación.
continue;
}
if (! $itemAttribute->allow_multi_select && $definitions->count() > 1) {
return ResolvedTicketValidity::unresolvable();
}
$alternatives = $definitions->map(function (VariantDefinition $definition) use ($attribute): ?Collection {
$option = $attribute->options->firstWhere('value', $definition->value);
if ($option === null) {
return null;
}
return collect([$option->validityTime])->filter()->values();
});
if ($alternatives->contains(null)) {
return ResolvedTicketValidity::unresolvable();
}
if ($alternatives->contains(fn (Collection $alternative): bool => $alternative->isNotEmpty())) {
// Las opciones elegidas del mismo atributo son alternativas OR.
$dimensions->push($alternatives->values());
}
}
if ($dimensions->isEmpty()) {
return ResolvedTicketValidity::unrestricted();
}
$groups = collect([collect()]);
foreach ($dimensions as $alternatives) {
// El producto cartesiano agrega cada dimensión como una condición
// AND y conserva sus opciones internas como alternativas OR.
$groups = $groups->flatMap(
fn (Collection $group): Collection => $alternatives->map(
fn (Collection $alternative): Collection => $group
->merge($alternative)
->unique(fn (ValidityTime $time): int => $time->getKey() ?? spl_object_id($time))
->values()
)
)->values();
}
return new ResolvedTicketValidity(
$groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times))
);
}
}

View File

@@ -0,0 +1,71 @@
# Dominio Ticket
## Propósito
Genera, valida, consulta y exporta entradas asociadas a compras pagadas de productos o variantes ticketables.
## Modelo
- `Ticket`: pertenece a tenant y usuario, y conserva referencias al ítem de compra que lo generó, producto,
variante y usuario escáner. La compra se obtiene a través de su ítem.
- El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no
persisten una copia de esos textos.
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos.
- `ValidityTimeType`: enum de estrategias de vigencia.
`TicketValidityResolver` deriva la vigencia desde la variante asociada. Las alternativas de un mismo atributo
se combinan con OR y las dimensiones diferentes se combinan con AND. El modelo calcula si un ticket está
vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin persistir vigencias en el ticket.
## Flujo de generación
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
2. `GenerateTicketsForPaidPurchase` atiende el evento.
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
## Datos descartables para pruebas de carga
En ambientes `local`, `testing`, `staging`, `homo` u `homologation`, el comando siguiente crea tickets
válidos, identidades scanner con tokens Sanctum y un dataset JSON importable por Postman:
```bash
php artisan load-test:tickets:prepare loadtest-evento \
--tickets=40000 \
--scanners=100 \
--owners=1000 \
--catalog-item=123 \
--run=evento-001
```
El tenant debe existir y se recomienda que sea exclusivo para carga. Si no se indica `--catalog-item`,
se usa el primer producto estándar del tenant con tickets habilitados. `--variant`
es opcional; al indicarlo, su configuración de vigencia debe estar activa y ser resoluble. Sin variante,
los tickets tienen vigencia irrestricta.
El archivo se escribe por defecto en `storage/app/private/load-tests/` y contiene tokens secretos, por
lo que no debe versionarse. Para limpiar el tenant después de la ejecución:
```bash
php artisan tenants:reset-transactions loadtest-evento --dry-run
php artisan tenants:reset-transactions loadtest-evento
```
## Endpoints
Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
- `GET /tickets`.
- `POST /tickets/pdf`.
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
`adminapp.tickets`:
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
`scanned_tickets` y `total_tickets` para el tenant autenticado.
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
## Dependencias y reglas
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.

View File

@@ -0,0 +1,30 @@
<?php
use App\Domains\Ticket\Controllers\AdminApp\TicketController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('tickets', [TicketController::class, 'index'])
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.index');
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.cancel');
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.calculate-refund');
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.refund');
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.pdf');
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.excel');
});

Some files were not shown because too many files have changed in this diff Show More