refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Domains/Ticketing/Desfile/Resources/EntryResource.php
Normal file
56
app/Domains/Ticketing/Desfile/Resources/EntryResource.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
302
app/Domains/Ticketing/Desfile/Services/EntryService.php
Normal file
302
app/Domains/Ticketing/Desfile/Services/EntryService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
app/Domains/Ticketing/Desfile/routes/api.php
Normal file
19
app/Domains/Ticketing/Desfile/routes/api.php
Normal 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');
|
||||
});
|
||||
@@ -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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Enums;
|
||||
|
||||
enum EventDateChangeType: string
|
||||
{
|
||||
case Rescheduled = 'rescheduled';
|
||||
case Suspended = 'suspended';
|
||||
}
|
||||
12
app/Domains/Ticketing/Event/Enums/EventDateStatus.php
Normal file
12
app/Domains/Ticketing/Event/Enums/EventDateStatus.php
Normal 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';
|
||||
}
|
||||
22
app/Domains/Ticketing/Event/Events/EventDateRescheduled.php
Normal file
22
app/Domains/Ticketing/Event/Events/EventDateRescheduled.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
20
app/Domains/Ticketing/Event/Events/EventDateSuspended.php
Normal file
20
app/Domains/Ticketing/Event/Events/EventDateSuspended.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
201
app/Domains/Ticketing/Event/Models/EventDate.php
Normal file
201
app/Domains/Ticketing/Event/Models/EventDate.php
Normal 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');
|
||||
}
|
||||
}
|
||||
68
app/Domains/Ticketing/Event/Models/EventDateChange.php
Normal file
68
app/Domains/Ticketing/Event/Models/EventDateChange.php
Normal 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);
|
||||
}
|
||||
}
|
||||
41
app/Domains/Ticketing/Event/Models/EventDateChangeView.php
Normal file
41
app/Domains/Ticketing/Event/Models/EventDateChangeView.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
100
app/Domains/Ticketing/Event/Requests/UpdateEventRequest.php
Normal file
100
app/Domains/Ticketing/Event/Requests/UpdateEventRequest.php
Normal 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.'
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Domains/Ticketing/Event/Resources/EventDateResource.php
Normal file
31
app/Domains/Ticketing/Event/Resources/EventDateResource.php
Normal 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')
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Domains/Ticketing/Event/Resources/EventResource.php
Normal file
41
app/Domains/Ticketing/Event/Resources/EventResource.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
350
app/Domains/Ticketing/Event/Services/EventService.php
Normal file
350
app/Domains/Ticketing/Event/Services/EventService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
37
app/Domains/Ticketing/Event/documentacion/README.md
Normal file
37
app/Domains/Ticketing/Event/documentacion/README.md
Normal 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.
|
||||
14
app/Domains/Ticketing/Event/routes/adminapp.php
Normal file
14
app/Domains/Ticketing/Event/routes/adminapp.php
Normal 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']);
|
||||
});
|
||||
11
app/Domains/Ticketing/Event/routes/api.php
Normal file
11
app/Domains/Ticketing/Event/routes/api.php
Normal 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'],
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
66
app/Domains/Ticketing/FiestaFutbolInfantil/routes/api.php
Normal file
66
app/Domains/Ticketing/FiestaFutbolInfantil/routes/api.php
Normal 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');
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
app/Domains/Ticketing/Ticket/Enums/ScanAttemptResult.php
Normal file
16
app/Domains/Ticketing/Ticket/Enums/ScanAttemptResult.php
Normal 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';
|
||||
}
|
||||
15
app/Domains/Ticketing/Ticket/Enums/ValidityTimeType.php
Normal file
15
app/Domains/Ticketing/Ticket/Enums/ValidityTimeType.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TicketNotAvailableException extends NotFoundHttpException {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
52
app/Domains/Ticketing/Ticket/Models/ScanAttempt.php
Normal file
52
app/Domains/Ticketing/Ticket/Models/ScanAttempt.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
426
app/Domains/Ticketing/Ticket/Models/Ticket.php
Normal file
426
app/Domains/Ticketing/Ticket/Models/Ticket.php
Normal 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);
|
||||
}
|
||||
}
|
||||
66
app/Domains/Ticketing/Ticket/Models/TicketRefund.php
Normal file
66
app/Domains/Ticketing/Ticket/Models/TicketRefund.php
Normal 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();
|
||||
}
|
||||
}
|
||||
93
app/Domains/Ticketing/Ticket/Models/ValidityTime.php
Normal file
93
app/Domains/Ticketing/Ticket/Models/ValidityTime.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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())],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Domains/Ticketing/Ticket/Resources/TicketResource.php
Normal file
42
app/Domains/Ticketing/Ticket/Resources/TicketResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
694
app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php
Normal file
694
app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
335
app/Domains/Ticketing/Ticket/Services/ScannerTicketService.php
Normal file
335
app/Domains/Ticketing/Ticket/Services/ScannerTicketService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
153
app/Domains/Ticketing/Ticket/Services/TicketGeneratorService.php
Normal file
153
app/Domains/Ticketing/Ticket/Services/TicketGeneratorService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
app/Domains/Ticketing/Ticket/Services/TicketPdfService.php
Normal file
109
app/Domains/Ticketing/Ticket/Services/TicketPdfService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
?? '');
|
||||
}
|
||||
}
|
||||
155
app/Domains/Ticketing/Ticket/Services/TicketValidityResolver.php
Normal file
155
app/Domains/Ticketing/Ticket/Services/TicketValidityResolver.php
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Domains/Ticketing/Ticket/documentacion/README.md
Normal file
71
app/Domains/Ticketing/Ticket/documentacion/README.md
Normal 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.
|
||||
30
app/Domains/Ticketing/Ticket/routes/adminapp.php
Normal file
30
app/Domains/Ticketing/Ticket/routes/adminapp.php
Normal 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
Reference in New Issue
Block a user