Compare commits

...

5 Commits

Author SHA1 Message Date
573d4fe5e6 Refactor ticket validity handling and improve tests
- Updated tests for Accommodation, Entry, Food, Merchandise, and Sale controllers to use soft deletes for variants and ensure proper inventory counts.
- Enhanced ticket generation logic to resolve validity from soft-deleted catalog sources.
- Introduced a new TicketValidityResolver service to manage ticket validity based on event dates and variant definitions.
- Removed unnecessary database assertions and improved the clarity of validity checks in tests.
- Added comprehensive tests for the new TicketValidityResolver service, ensuring correct handling of event dates and multi-select options.
- Cleaned up unused code and assertions in existing tests for better maintainability.
2026-08-14 09:00:51 -03:00
bfdaf1c38b feat(tenant): add site title and favicon support for tenants and website types 2026-08-14 08:55:59 -03:00
4de08ff2f2 refactor(auth): simplify password reset attempt handling logic and improve code readability 2026-08-14 08:51:31 -03:00
a4a4c9afbc feat(auth): enhance password reset attempt handling with new reasons and update related services 2026-08-13 16:49:19 -03:00
630b49cad7 feat(scanner): implement password reset attempt functionality for scanner users 2026-08-13 16:44:27 -03:00
87 changed files with 1931 additions and 705 deletions

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ScannerCreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateScannerResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(ScannerCreateResetPasswordAttemptRequest $request): JsonResponse
{
$this->resetPasswordAttemptService->createForScannerEmail(
$request->validated('email'),
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -11,6 +11,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
public const REASON_MANUAL = 'manual';
public const REASON_ACCOUNT_LOCKED = 'account_locked';
public const REASON_STAFF_CREATED = 'staff_created';
public const STATUS_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated';

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class ScannerCreateResetPasswordAttemptRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge(['email' => Str::lower(trim($email))]);
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
];
}
}

View File

@@ -4,9 +4,11 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\PasswordResetRequested;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@@ -60,6 +62,8 @@ class PasswordLoginService
$userAgent,
RoleCode::AdminApp,
true,
null,
PasswordResetRequested::CHANNEL_ADMINAPP,
);
}
@@ -84,6 +88,7 @@ class PasswordLoginService
null,
true,
PermissionCode::ScanTickets->value,
PasswordResetRequested::CHANNEL_SCANNER,
);
}
@@ -96,6 +101,7 @@ class PasswordLoginService
?RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false,
?string $requiredPermission = null,
string $passwordResetChannel = PasswordResetRequested::CHANNEL_STOREFRONT,
): User {
$normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now();
@@ -111,6 +117,7 @@ class PasswordLoginService
$requiredRole,
$requiresTenant,
$requiredPermission,
$passwordResetChannel,
): array {
$user = User::query()
->where('email', $normalizedEmail)
@@ -160,7 +167,12 @@ class PasswordLoginService
if ($user === null || ! Hash::check($password, $user->password)) {
if ($user !== null && $attemptTenantCode !== null) {
$this->registerFailure($user, $now, $attemptTenantCode);
$this->registerFailure(
$user,
$now,
$attemptTenantCode,
$passwordResetChannel,
);
}
$outcome = $user?->locked_until?->isFuture()
@@ -219,8 +231,12 @@ class PasswordLoginService
return $result['user'];
}
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
{
private function registerFailure(
User $user,
CarbonImmutable $now,
string $tenantCode,
string $passwordResetChannel,
): void {
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
@@ -243,16 +259,21 @@ class PasswordLoginService
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try {
if ($user->rol_codigo === RoleCode::AdminApp->value) {
if ($passwordResetChannel === PasswordResetRequested::CHANNEL_ADMINAPP) {
$this->resetPasswordAttemptService->createForAdminAppEmail(
$user->email,
'account_locked',
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
);
} elseif ($passwordResetChannel === PasswordResetRequested::CHANNEL_SCANNER) {
$this->resetPasswordAttemptService->createForScannerEmail(
$user->email,
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
);
} else {
$this->resetPasswordAttemptService->createForEmail(
$user->email,
$tenantCode,
'account_locked',
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
);
}
} catch (\Throwable $e) {

View File

@@ -12,8 +12,11 @@ use Throwable;
class ResetPasswordAttemptService
{
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
{
public function createForEmail(
string $email,
string $tenantCode,
string $reason = ResetPasswordAttempt::REASON_MANUAL,
): void {
$emailFingerprint = $this->emailFingerprint($email);
try {
@@ -47,8 +50,10 @@ class ResetPasswordAttemptService
);
}
public function createForAdminAppEmail(string $email, string $reason = 'manual'): void
{
public function createForAdminAppEmail(
string $email,
string $reason = ResetPasswordAttempt::REASON_MANUAL,
): void {
$emailFingerprint = $this->emailFingerprint($email);
try {
@@ -93,6 +98,54 @@ class ResetPasswordAttemptService
);
}
public function createForScannerEmail(
string $email,
string $reason = ResetPasswordAttempt::REASON_MANUAL,
): void {
$emailFingerprint = $this->emailFingerprint($email);
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('email', $email)
->where('rol_codigo', RoleCode::Scanner->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
->first();
$attemptId = $this->createAttemptForUser(
$user,
$reason,
$emailFingerprint,
'Scanner password reset attempt was not created because the user was not found.',
);
if ($user === null || $attemptId === null) {
return null;
}
return [
'attempt_id' => $attemptId,
'tenant_code' => $user->tenant_codigo,
];
});
} catch (Throwable $exception) {
Log::error('Failed to create Scanner password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
$this->dispatchPasswordResetRequested(
$result['attempt_id'] ?? null,
$result['tenant_code'] ?? null,
PasswordResetRequested::CHANNEL_SCANNER,
$emailFingerprint,
);
}
public function validateCode(string $email, string $code): bool
{
$emailFingerprint = $this->emailFingerprint($email);

View File

@@ -1,11 +1,20 @@
<?php
use App\Domains\Auth\Controllers\CreateScannerResetPasswordAttemptController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\ScannerLoginController;
use App\Domains\Auth\Controllers\ScannerMeController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/scanner')->group(function (): void {
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
->middleware('throttle:5,1');
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->middleware('throttle:10,1');
Route::post('password/reset', ResetPasswordController::class)
->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'scanner.tenant'])
->get('me', ScannerMeController::class);
});

View File

@@ -8,9 +8,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -18,6 +16,7 @@ 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\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
#[Fillable([
@@ -34,12 +33,10 @@ use Illuminate\Support\Collection;
'inventory_subject',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
])]
class CatalogItem extends Model
{
use HasFactory;
use HasFactory, SoftDeletes;
public $timestamps = false;
@@ -50,7 +47,6 @@ class CatalogItem extends Model
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false,
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
];
protected function casts(): array
@@ -65,8 +61,6 @@ class CatalogItem extends Model
'inventory_subject' => InventorySubject::class,
'max_units_per_user' => 'integer',
'has_tickets' => 'boolean',
'ticket_generation_policy' => TicketGenerationPolicy::class,
'validity_time_id' => 'integer',
];
}
@@ -118,12 +112,6 @@ class CatalogItem extends Model
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{

View File

@@ -11,6 +11,7 @@ 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\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Str;
@@ -24,7 +25,7 @@ use Illuminate\Support\Str;
])]
class Variant extends Model
{
use HasFactory;
use HasFactory, SoftDeletes;
public $timestamps = false;
@@ -43,7 +44,7 @@ class Variant extends Model
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
return $this->belongsTo(CatalogItem::class)->withTrashed();
}
/** @return BelongsTo<EventDate, $this> */

View File

@@ -6,7 +6,6 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -58,8 +57,6 @@ class StoreCatalogItemRequest extends FormRequest
'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)],
'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],

View File

@@ -7,7 +7,6 @@ use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -31,9 +30,6 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
'validity_time_id' => $catalogItem->validity_time_id,
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
'stock_tecnico' => $catalogItem->availableStock(),
'variants' => $catalogItem->visibleVariants()
->map(fn (Variant $variant): array => [
@@ -67,9 +63,6 @@ class CatalogFeaturedItemResource extends JsonResource
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
'validity_time_id' => $catalogItem->validity_time_id,
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
'image' => $this->firstImageUrl($catalogItem),
];
}

View File

@@ -36,11 +36,6 @@ class CatalogItemDetailResource extends JsonResource
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id,
'validity_time' => $this->validityTime === null
? null
: ValidityTimeResource::make($this->validityTime),
'attributes' => $this->attributesData(),
'stock_tecnico' => $this->when(
$selectedVariant === null,

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -26,12 +25,6 @@ class CatalogItemResource extends JsonResource
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id,
'validity_time' => $this->whenLoaded(
'validityTime',
fn () => ValidityTimeResource::make($this->validityTime),
),
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -26,9 +25,6 @@ class CatalogSearchItemResource extends JsonResource
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->validityTime),
'image' => $attachment?->getTemporaryUrl(1440),
'stock_tecnico' => $this->availableStock(),
'variants' => $this->visibleVariants()

View File

@@ -160,7 +160,6 @@ class CatalogService
'inventory',
'category',
'brand',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
@@ -180,7 +179,6 @@ class CatalogService
'inventory',
'category',
'brand',
'validityTime',
'itemAttributes.attribute.options.validityTime',
'itemAttributes.attribute.eventDates.validityTime',
'variants' => fn ($query) => $query->orderBy('id'),
@@ -241,7 +239,6 @@ class CatalogService
->with([
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
@@ -277,7 +274,6 @@ class CatalogService
->with([
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
@@ -294,32 +290,8 @@ class CatalogService
public function delete(CatalogItem $catalogItem): void
{
DB::transaction(function () use ($catalogItem): void {
$catalogItem->load([
'attachments',
'variants.attachments',
]);
$attachments = $catalogItem->attachments
->merge($catalogItem->variants->flatMap->attachments)
->unique('id');
$inventoryIds = collect([$catalogItem->inventory_id])
->merge($catalogItem->variants->pluck('inventory_id'))
->filter()
->unique();
$catalogItem->attachments()->detach();
foreach ($catalogItem->variants as $variant) {
$variant->attachments()->detach();
}
$catalogItem->variants()->delete();
$catalogItem->delete();
Inventory::query()->whereKey($inventoryIds)->delete();
foreach ($attachments as $attachment) {
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
$this->attachmentService->delete($attachment);
}
}
});
}
@@ -327,32 +299,19 @@ class CatalogService
{
DB::transaction(function () use ($variant): void {
$variant = Variant::query()
->with('attachments')
->lockForUpdate()
->findOrFail($variant->getKey());
$catalogItem = CatalogItem::query()
->lockForUpdate()
->findOrFail($variant->catalog_item_id);
$attachments = $variant->attachments;
$inventoryId = $variant->inventory_id;
$variant->attachments()->detach();
$variant->delete();
Inventory::query()->whereKey($inventoryId)->delete();
$minimumPrice = $catalogItem->variants()->min('precio');
if ($minimumPrice === null) {
if (! $catalogItem->variants()->exists()) {
$this->delete($catalogItem);
} else {
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]);
}
foreach ($attachments as $attachment) {
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
$this->attachmentService->delete($attachment);
}
}
});
}
@@ -457,8 +416,6 @@ class CatalogService
'attribute_codes',
'variants',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
@@ -731,16 +688,6 @@ class CatalogService
]);
}
$validityTimeIds = $resolvedOptions
->pluck('validity_time_id')
->filter()
->unique();
if ($validityTimeIds->count() > 1) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.incompatible_validity_windows')],
]);
}
return $resolvedOptions->pluck('value')->all();
}

View File

@@ -47,7 +47,6 @@ class FeaturedGroupService
->with([
'inventory',
'attachments',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',

View File

@@ -43,7 +43,6 @@ class EventDate extends Model
$eventDate->syncTenantDateText();
ValidityTime::query()
->whereKey($eventDate->validity_time_id)
->whereDoesntHave('ticketValidityGroups')
->delete();
});
}

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Event\Services;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EventService
{
@@ -60,7 +61,19 @@ class EventService
}
}
$existingDates->slice(count($dates))->each->delete();
$datesToDelete = $existingDates->slice(count($dates));
if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate
->selectedByVariants()
->whereHas('sourceTickets')
->exists()
|| $eventDate->variants()->whereHas('sourceTickets')->exists())) {
throw ValidationException::withMessages([
'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'],
]);
}
$datesToDelete->each->delete();
$tenant->unsetRelation('eventDates');
}

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -126,18 +125,21 @@ class AccommodationService
'tenant_code' => $tenant->codigo,
'nombre' => 'Alojamientos',
]);
$accommodation = CatalogItem::query()
$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,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
return $accommodation;
@@ -152,7 +154,6 @@ class AccommodationService
'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}

View File

@@ -9,7 +9,6 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -63,7 +62,6 @@ class EntryService
'category_id' => $category->id,
'precio' => $entry['price'],
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'],
@@ -135,7 +133,6 @@ class EntryService
'category_id' => $category->id,
'precio' => $entry['price'],
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
]);
$variant->update([
@@ -163,7 +160,7 @@ class EntryService
while (
in_array($slug, $reservedSlugs, true)
|| CatalogItem::query()
|| CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where('slug', $slug)
->exists()

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -137,18 +136,21 @@ class FoodService
'tenant_code' => $tenant->codigo,
'nombre' => 'Comidas',
]);
$food = CatalogItem::query()
$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,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
return $food;
@@ -163,7 +165,6 @@ class FoodService
'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -75,7 +74,6 @@ class MerchandiseService
'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
$itemAttributes = $this->itemAttributes($item, $attributes);
@@ -200,7 +198,6 @@ class MerchandiseService
'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}
@@ -414,7 +411,7 @@ class MerchandiseService
while (
in_array($slug, $reservedSlugs, true)
|| CatalogItem::query()
|| CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where('slug', $slug)
->exists()

View File

@@ -13,6 +13,8 @@ class PasswordResetRequested
public const CHANNEL_ADMINAPP = 'adminapp';
public const CHANNEL_SCANNER = 'scanner';
public function __construct(
public readonly int $attemptId,
public readonly string $tenantCode,

View File

@@ -4,11 +4,12 @@ namespace App\Domains\Notification\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
@@ -36,8 +37,7 @@ class NotificationMailService
int $attemptId,
string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void
{
): void {
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
@@ -56,12 +56,21 @@ class NotificationMailService
return;
}
$recoveryDomain = $channel === PasswordResetRequested::CHANNEL_ADMINAPP
? $tenant->websiteType?->dominio
: $tenant->dominio;
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email);
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$this->mailService
->forTenant($tenantCode)
@@ -96,6 +105,7 @@ class NotificationMailService
->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
if ($tickets->isEmpty()) {

View File

@@ -7,6 +7,8 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@@ -58,7 +60,7 @@ class AdminAppSaleService
{
return $this->findForTenant($tenant, $saleId)
->tickets()
->with('validityGroups.validityTimes')
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderBy('id')
->get();
}

View File

@@ -2,7 +2,9 @@
namespace App\Domains\Staff\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
@@ -15,6 +17,10 @@ use Illuminate\Validation\ValidationException;
class StaffService
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection
{
@@ -60,6 +66,10 @@ class StaffService
'tenant_codigo' => $tenant->codigo,
]);
$staff->scanCategories()->sync($categoryIds);
$this->resetPasswordAttemptService->createForScannerEmail(
$staff->email,
ResetPasswordAttempt::REASON_STAFF_CREATED,
);
return $staff->load('role', 'scanCategories');
});

View File

@@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'codigo',
'nombre',
'dominio',
'site_title',
'primary_color',
'secondary_color',
'danger_color',
@@ -29,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'footer_bg_color',
'header_logo_id',
'footer_logo_id',
'favicon_id',
'header_bg_image_id',
'footer_bg_image_id',
'website_type_code',
@@ -101,6 +103,14 @@ class Tenant extends Model
return $this->belongsTo(Attachment::class, 'footer_logo_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function favicon(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'favicon_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'nombre',
'dominio',
'scanner_domain',
'site_title',
'primary_color',
'secondary_color',
'danger_color',
@@ -27,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'login_header_footer_color',
'site_logo',
'footer_logo',
'favicon_id',
])]
class WebsiteType extends Model
{
@@ -50,6 +52,14 @@ class WebsiteType extends Model
return $this->belongsTo(Attachment::class, 'footer_logo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function favicon(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'favicon_id');
}
/**
* @return HasMany<WebsiteTypeExtra, $this>
*/

View File

@@ -55,6 +55,7 @@ class StoreTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio'),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
@@ -63,6 +64,7 @@ class StoreTenantRequest extends FormRequest
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],

View File

@@ -65,6 +65,7 @@ class UpdateTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
@@ -73,6 +74,7 @@ class UpdateTenantRequest extends FormRequest
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],

View File

@@ -26,6 +26,11 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'site_title' => $this->site_title
?? $this->websiteType?->site_title
?? 'ShopitFront',
'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
?->getTemporaryUrl(1440),
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,

View File

@@ -13,6 +13,8 @@ class TenantInformationService
private const DEFAULT_RELATIONS = [
'headerLogo',
'footerLogo',
'favicon',
'websiteType.favicon',
'headerBackgroundImage',
'footerBackgroundImage',
'socialMedia',

View File

@@ -25,6 +25,7 @@ class TenantService
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
@@ -33,6 +34,7 @@ class TenantService
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media'],
@@ -63,6 +65,7 @@ class TenantService
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
$data['favicon_id'] = $this->storeTenantImage($favicon);
$data['header_bg_image_id'] = $this->storeTenantImage($headerBackgroundImage);
$data['footer_bg_image_id'] = $this->storeTenantImage($footerBackgroundImage);
@@ -85,11 +88,13 @@ class TenantService
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasFaviconKey = array_key_exists('favicon', $data);
$hasHeaderBackgroundImageKey = array_key_exists('header_bg_image', $data);
$hasFooterBackgroundImageKey = array_key_exists('footer_bg_image', $data);
$hasSocialMediaKey = array_key_exists('social_media', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
@@ -97,6 +102,7 @@ class TenantService
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media']
@@ -136,6 +142,10 @@ class TenantService
}
}
if ($hasFaviconKey) {
$tenant->favicon_id = $this->storeTenantImage($favicon);
}
if ($hasHeaderBackgroundImageKey) {
$tenant->header_bg_image_id = $this->storeTenantImage($headerBackgroundImage);
}

View File

@@ -46,14 +46,20 @@ class WebsiteTypeService
return DB::transaction(function () use ($websiteType, $data): WebsiteType {
$previousLogos = [];
foreach (['site_logo' => 'siteLogo', 'footer_logo' => 'footerLogo'] as $field => $relation) {
$attachmentFields = [
'site_logo' => ['relation' => 'siteLogo', 'column' => 'site_logo'],
'footer_logo' => ['relation' => 'footerLogo', 'column' => 'footer_logo'],
'favicon' => ['relation' => 'favicon', 'column' => 'favicon_id'],
];
foreach ($attachmentFields as $field => $attachmentField) {
if (! array_key_exists($field, $data)) {
continue;
}
$logo = $data[$field];
$previousLogos[$field] = $websiteType->exists
? $websiteType->{$relation}()->first()
? $websiteType->{$attachmentField['relation']}()->first()
: null;
unset($data[$field]);
@@ -65,17 +71,17 @@ class WebsiteTypeService
: $this->attachmentService->store($logo, 'website-types');
}
$data[$field] = $attachment?->id;
$data[$attachmentField['column']] = $attachment?->id;
}
$websiteType->fill($data)->save();
foreach ($previousLogos as $field => $previousLogo) {
foreach ($previousLogos as $previousLogo) {
if (
$previousLogo instanceof Attachment
&& $previousLogo->id !== $websiteType->{$field}
&& $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo
&& $previousLogo->id !== $websiteType->favicon_id
) {
$this->attachmentService->delete($previousLogo);
}

View File

@@ -8,6 +8,8 @@ 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;
@@ -22,7 +24,7 @@ class TicketController extends Controller
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderByDesc('id')
->get();
@@ -36,7 +38,7 @@ class TicketController extends Controller
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds)
->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderByDesc('id')
->get();

View File

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

View File

@@ -3,6 +3,7 @@
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;
@@ -24,9 +25,9 @@ class TicketGenerationException extends RuntimeException
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
}
public static function ambiguousValidityTime(CatalogItem $catalogItem): self
public static function invalidValidityConfiguration(CatalogItem $catalogItem, Variant $variant): self
{
return new self("Catalog item {$catalogItem->id} resolves more than one validity time.");
return new self("Variant {$variant->id} from catalog item {$catalogItem->id} has an invalid validity configuration.");
}
public static function variantNotFound(

View File

@@ -7,20 +7,20 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
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\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
#[Fillable([
'tenant_code',
'ticket',
'name',
'description',
'source_purchase_id',
'source_catalog_item_id',
'source_variant_id',
@@ -32,6 +32,8 @@ class Ticket extends Model
{
use HasFactory;
private ?ResolvedTicketValidity $resolvedValidity = null;
public const STATUS_ACTIVE = 'active';
public const STATUS_EXPIRED = 'expired';
@@ -41,6 +43,8 @@ class Ticket extends Model
public $timestamps = false;
protected $appends = [
'name',
'description',
'is_valid',
'is_expired',
'is_used',
@@ -86,19 +90,13 @@ class Ticket extends Model
/** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id');
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');
}
/** @return HasMany<TicketValidityGroup, $this> */
public function validityGroups(): HasMany
{
return $this->hasMany(TicketValidityGroup::class);
return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed();
}
public function isValid(): bool
@@ -107,12 +105,7 @@ class Ticket extends Model
return false;
}
$validityGroups = $this->resolvedValidityGroups();
return $validityGroups->isEmpty()
|| $validityGroups->contains(
fn (TicketValidityGroup $group): bool => $group->isValid()
);
return $this->resolvedValidity()->isValid();
}
public function getIsValidAttribute(): bool
@@ -122,13 +115,7 @@ class Ticket extends Model
public function getIsExpiredAttribute(): bool
{
$validityGroups = $this->resolvedValidityGroups();
return $this->used_at === null
&& $validityGroups->isNotEmpty()
&& $validityGroups->every(
fn (TicketValidityGroup $group): bool => $group->isExpired()
);
return $this->used_at === null && $this->resolvedValidity()->isExpired();
}
public function getIsUsedAttribute(): bool
@@ -149,52 +136,34 @@ class Ticket extends Model
return self::STATUS_ACTIVE;
}
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->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt())
->filter()
->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
return $this->resolvedValidity()->effectiveStartsAt();
}
public function getEffectiveExpiresAt(): ?CarbonInterface
{
return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt())
->filter()
->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
return $this->resolvedValidity()->effectiveExpiresAt();
}
/** @return Collection<int, ValidityTime> */
public function allValidityTimes(): Collection
/** @return Collection<int, ResolvedValidityGroup> */
public function resolvedValidityGroups(): Collection
{
return $this->resolvedValidityGroups()
->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes())
->unique(
fn (ValidityTime $validityTime): int => $validityTime->getKey()
?? spl_object_id($validityTime)
)
->values();
return $this->resolvedValidity()->groups;
}
/** @return EloquentCollection<int, TicketValidityGroup> */
public function resolvedValidityGroups(): EloquentCollection
public function resolvedValidity(): ResolvedTicketValidity
{
if ($this->relationLoaded('validityGroups')) {
return $this->getRelation('validityGroups');
}
if (! $this->exists) {
return new EloquentCollection;
}
$validityGroups = $this->validityGroups()
->with('validityTimes')
->get();
$this->setRelation('validityGroups', $validityGroups);
return $validityGroups;
return $this->resolvedValidity ??= app(TicketValidityResolver::class)->resolveTicket($this);
}
}

View File

@@ -1,117 +0,0 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['ticket_id'])]
class TicketValidityGroup extends Model
{
public $timestamps = false;
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsToMany<ValidityTime, $this> */
public function validityTimes(): BelongsToMany
{
return $this->belongsToMany(
ValidityTime::class,
'ticket_validity_group_times',
'ticket_validity_group_id',
'validity_time_id',
);
}
public function isValid(?CarbonInterface $at = null): bool
{
$at ??= now();
$validityTimes = $this->resolvedValidityTimes();
return $validityTimes->isNotEmpty()
&& $validityTimes->every(
fn (ValidityTime $validityTime): bool => $validityTime->isValid($at)
);
}
public function isExpired(?CarbonInterface $at = null): bool
{
$at ??= now();
$expiresAt = $this->effectiveExpiresAt($at);
return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at);
}
public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->resolvedValidityTimes()
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor))
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->resolvedValidityTimes()
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$startsAt = $validityTime->startsAt($anchor);
$expiresAt = $validityTime->expiresAt($anchor);
if (
$startsAt !== null
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo($startsAt)
) {
return $expiresAt->addDay();
}
return $expiresAt;
})
->filter()
->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/** @return EloquentCollection<int, ValidityTime> */
public function resolvedValidityTimes(): EloquentCollection
{
if ($this->relationLoaded('validityTimes')) {
return $this->getRelation('validityTimes');
}
if (! $this->exists) {
return new EloquentCollection;
}
$validityTimes = $this->validityTimes()->get();
$this->setRelation('validityTimes', $validityTimes);
return $validityTimes;
}
private function dateAnchor(): ?CarbonInterface
{
return $this->resolvedValidityTimes()
->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow)
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at)
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Ticket\Models;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonImmutable;
@@ -11,7 +10,6 @@ use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
@@ -35,12 +33,6 @@ class ValidityTime extends Model
];
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<AttributeOption, $this> */
public function attributeOptions(): HasMany
{
@@ -53,17 +45,6 @@ class ValidityTime extends Model
return $this->hasOne(EventDate::class);
}
/** @return BelongsToMany<TicketValidityGroup, $this> */
public function ticketValidityGroups(): BelongsToMany
{
return $this->belongsToMany(
TicketValidityGroup::class,
'ticket_validity_group_times',
'validity_time_id',
'ticket_validity_group_id',
);
}
public function startsAt(
?CarbonInterface $at = null,
): ?CarbonInterface {

View File

@@ -22,10 +22,6 @@ class TicketResource extends JsonResource
'category' => $this->sourceCatalogItem?->category?->nombre,
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()),
'validity_groups' => TicketValidityGroupResource::collection(
$this->resolvedValidityGroups()
),
'starts_at' => $this->getEffectiveStartsAt(),
'expires_at' => $this->getEffectiveExpiresAt(),
'used_at' => $this->used_at,

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Models\TicketValidityGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin TicketValidityGroup */
class TicketValidityGroupResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_times' => ValidityTimeResource::collection($this->resolvedValidityTimes()),
'starts_at' => $this->effectiveStartsAt(),
'expires_at' => $this->effectiveExpiresAt(),
'is_valid' => $this->isValid(),
'is_expired' => $this->isExpired(),
];
}
}

View File

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

View File

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

View File

@@ -138,7 +138,8 @@ class ScannerTicketService
private function relations(): array
{
return [
'validityGroups.validityTimes',
...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS,
'sourceCatalogItem.category',
'sourceVariant.eventDate',
'sourceVariant.catalogItem',

View File

@@ -5,19 +5,16 @@ 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\Event\Models\EventDate;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TicketGeneratorService
{
public function __construct(private readonly TicketValidityResolver $validityResolver) {}
/**
* @return Collection<int, Ticket>
*/
@@ -45,19 +42,9 @@ class TicketGeneratorService
): Ticket {
$item = $target['catalog_item'];
$variant = $target['variant'];
$eventDate = $target['event_date'];
$validityGroups = $this->buildTicketValidityGroups(
$item,
$variant,
$eventDate,
$this->resolveValidityTime($item, $variant),
);
$ticket = Ticket::query()->create([
'tenant_code' => $item->tenant_code,
'ticket' => (string) Str::uuid(),
'name' => $this->ticketName($item, $variant, $eventDate),
'description' => (string) ($item->descripcion ?? ''),
'source_purchase_id' => $sourcePurchaseId,
'source_catalog_item_id' => $item->getKey(),
'source_variant_id' => $variant?->getKey(),
@@ -65,31 +52,12 @@ class TicketGeneratorService
'user_id' => $user->getKey(),
]);
$groups = $validityGroups->map(function (Collection $validityTimes) use ($ticket): TicketValidityGroup {
$group = $ticket->validityGroups()->create();
$group->validityTimes()->attach(
$validityTimes
->map(fn (ValidityTime $validityTime): int => $validityTime->getKey())
->all()
);
$group->setRelation(
'validityTimes',
new EloquentCollection($validityTimes->all()),
);
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
return $ticket;
});
});
}
/**
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null, event_date: EventDate|null}>
*/
/** @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}> */
private function resolveTargets(
CatalogItem $catalogItem,
int $quantity,
@@ -126,9 +94,7 @@ class TicketGeneratorService
->values();
}
/**
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null, event_date: EventDate|null}>
*/
/** @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}> */
private function targetsForVariant(
CatalogItem $catalogItem,
?Variant $variant,
@@ -138,36 +104,18 @@ class TicketGeneratorService
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => null,
'event_date' => null,
]);
}
$variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates();
if ($catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit) {
$eventDate = $selectedEventDates->count() === 1
? $selectedEventDates->first()
: null;
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
'event_date' => $eventDate,
]);
if (! $this->validityResolver->resolveVariant($variant)->isResolvable) {
throw TicketGenerationException::invalidValidityConfiguration($catalogItem, $variant);
}
$eventDates = $selectedEventDates->isEmpty()
? collect([null])
: $selectedEventDates;
return Collection::times($quantity)
->flatMap(fn () => $eventDates->map(fn (?EventDate $eventDate): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
'event_date' => $eventDate,
]))
->values();
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
]);
}
private function resolveVariant(
@@ -201,112 +149,5 @@ class TicketGeneratorService
if (! $catalogItem->has_tickets) {
throw TicketGenerationException::ticketsDisabled($catalogItem);
}
}
private function resolveValidityTime(
CatalogItem $catalogItem,
?Variant $variant,
): ?ValidityTime {
if ($catalogItem->validity_time_id !== null) {
return $catalogItem->validityTime;
}
if ($variant === null) {
return null;
}
$variant->loadMissing('definitions.itemAttribute.attribute.options.validityTime');
$validityTimes = $variant->definitions
->map(function ($definition): ?ValidityTime {
$option = $definition->itemAttribute?->attribute?->options
->firstWhere('value', $definition->value);
return $option?->validityTime;
})
->filter()
->unique(fn (ValidityTime $validityTime): int => $validityTime->getKey())
->values();
if ($validityTimes->count() > 1) {
throw TicketGenerationException::ambiguousValidityTime($catalogItem);
}
return $validityTimes->first();
}
private function ticketName(
CatalogItem $catalogItem,
?Variant $variant,
?EventDate $eventDate,
): string {
if ($variant === null) {
return $catalogItem->nombre;
}
$options = $variant->selectionOptions();
if ($eventDate !== null) {
$options->put('event_date', [
'value' => (string) $eventDate->getKey(),
'label' => $eventDate->date->format('d/m/Y'),
]);
}
$properties = $options
->flatMap(function (array $option): array {
if (array_is_list($option)) {
return collect($option)
->pluck('label')
->filter(fn ($label): bool => is_string($label) && $label !== '')
->all();
}
$label = $option['label'] ?? null;
return is_string($label) && $label !== '' ? [$label] : [];
})
->values();
if ($properties->isEmpty()) {
return $catalogItem->nombre;
}
return $catalogItem->nombre.' ('.$properties->implode(', ').')';
}
/** @return Collection<int, Collection<int, ValidityTime>> */
private function buildTicketValidityGroups(
CatalogItem $catalogItem,
?Variant $variant,
?EventDate $eventDate,
?ValidityTime $validityTime,
): Collection {
$eventDates = collect([$eventDate]);
if (
$catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit
&& $variant !== null
) {
$variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates();
if ($selectedEventDates->isNotEmpty()) {
$eventDates = $selectedEventDates;
}
}
return $eventDates
->map(function (?EventDate $date) use ($validityTime): Collection {
$date?->loadMissing('validityTime');
return collect([$date?->validityTime, $validityTime])
->filter()
->unique(fn (ValidityTime $time): int => $time->getKey())
->values();
})
->filter(fn (Collection $group): bool => $group->isNotEmpty())
->values();
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Ticket\Models\Ticket;
class TicketPresentationResolver
{
/** 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;
}
$properties = $variant->selectionOptions()
->flatMap(function (array $option): array {
if (array_is_list($option)) {
return collect($option)
->pluck('label')
->filter(fn ($label): bool => is_string($label) && $label !== '')
->all();
}
$label = $option['label'] ?? null;
return is_string($label) && $label !== '' ? [$label] : [];
})
->values();
return $properties->isEmpty()
? $catalogItem->nombre
: $catalogItem->nombre.' ('.$properties->implode(', ').')';
}
public function description(Ticket $ticket): string
{
$ticket->loadMissing(self::RELATIONS);
return (string) ($ticket->sourceVariant?->getDescription()
?? $ticket->sourceCatalogItem?->descripcion
?? '');
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
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
{
/** 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();
$eventDates = $variant->selectedEventDates();
if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) {
return ResolvedTicketValidity::unresolvable();
}
if ($eventDates->isNotEmpty()) {
// Todas las fechas pertenecen a una misma dimensión alternativa:
// fecha 1 OR fecha 2 OR fecha 3.
$dimensions->push(
$eventDates->map(fn ($eventDate): Collection => collect([$eventDate->validityTime]))
);
}
foreach ($variant->definitions->groupBy('item_attribute_id') as $definitions) {
$itemAttribute = $definitions->first()?->itemAttribute;
$attribute = $itemAttribute?->attribute;
if ($itemAttribute === null || $attribute === null) {
return ResolvedTicketValidity::unresolvable();
}
if (! $attribute->type->supportsOptions() || $attribute->type->usesDynamicOptions()) {
// Texto, números y demás atributos no temporales no restringen
// la vigencia. EventDate se procesó arriba mediante su relación.
continue;
}
if (! $itemAttribute->allow_multi_select && $definitions->count() > 1) {
return ResolvedTicketValidity::unresolvable();
}
$alternatives = $definitions->map(function (VariantDefinition $definition) use ($attribute): ?Collection {
$option = $attribute->options->firstWhere('value', $definition->value);
if ($option === null) {
return null;
}
return collect([$option->validityTime])->filter()->values();
});
if ($alternatives->contains(null)) {
return ResolvedTicketValidity::unresolvable();
}
if ($alternatives->contains(fn (Collection $alternative): bool => $alternative->isNotEmpty())) {
// Las opciones elegidas del mismo atributo son alternativas OR.
$dimensions->push($alternatives->values());
}
}
if ($dimensions->isEmpty()) {
return ResolvedTicketValidity::unrestricted();
}
$groups = collect([collect()]);
foreach ($dimensions as $alternatives) {
// El producto cartesiano agrega cada dimensión como una condición
// AND y conserva sus opciones internas como alternativas OR.
$groups = $groups->flatMap(
fn (Collection $group): Collection => $alternatives->map(
fn (Collection $alternative): Collection => $group
->merge($alternative)
->unique(fn (ValidityTime $time): int => $time->getKey() ?? spl_object_id($time))
->values()
)
)->values();
}
return new ResolvedTicketValidity(
$groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times))
);
}
}

View File

@@ -6,11 +6,15 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ
## Modelo
- `Ticket`: pertenece a tenant y usuario, conserva referencias a compra, producto, variante, validez y usuario escáner.
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para productos, opciones y tickets.
- `Ticket`: pertenece a tenant y usuario, y conserva referencias a compra, producto, variante y usuario escáner.
- 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.
El modelo calcula si un ticket está vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin.
`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

View File

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

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::table('catalog_items')
->join('event_dates', function ($join): void {
$join->on('event_dates.tenant_code', '=', 'catalog_items.tenant_code')
->on('event_dates.validity_time_id', '=', 'catalog_items.validity_time_id');
})
->whereNotNull('catalog_items.validity_time_id')
->select([
'catalog_items.id as catalog_item_id',
'event_dates.id as event_date_id',
])
->orderBy('catalog_items.id')
->orderBy('event_dates.id')
->each(function (object $association): void {
$variantIds = DB::table('variantes')
->where('catalog_item_id', $association->catalog_item_id)
->pluck('id');
if ($variantIds->isEmpty()) {
return;
}
DB::table('variantes')
->whereIn('id', $variantIds)
->whereNull('event_date_id')
->update(['event_date_id' => $association->event_date_id]);
DB::table('variant_event_dates')->insertOrIgnore(
$variantIds->map(fn (int $variantId): array => [
'variant_id' => $variantId,
'event_date_id' => $association->event_date_id,
])->all()
);
});
Schema::table('catalog_items', function (Blueprint $table): void {
if (DB::getDriverName() !== 'sqlite') {
$table->dropForeign(['validity_time_id']);
}
$table->dropColumn('validity_time_id');
});
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->foreignId('validity_time_id')
->nullable()
->after('has_tickets')
->constrained('validity_times')
->cascadeOnUpdate()
->nullOnDelete();
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('ticket_validity_group_times');
Schema::dropIfExists('ticket_validity_groups');
}
public function down(): void
{
Schema::create('ticket_validity_groups', function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_id')
->constrained('tickets')
->cascadeOnUpdate()
->cascadeOnDelete();
});
Schema::create('ticket_validity_group_times', function (Blueprint $table): void {
$table->foreignId('ticket_validity_group_id')
->constrained('ticket_validity_groups')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreignId('validity_time_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
$table->primary(['ticket_validity_group_id', 'validity_time_id']);
});
}
};

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (DB::getDriverName() === 'sqlite') {
return;
}
Schema::table('tickets', function (Blueprint $table): void {
$table->dropForeign(['source_catalog_item_id']);
$table->dropForeign(['source_variant_id']);
$table->foreign('source_catalog_item_id')
->references('id')
->on('catalog_items')
->cascadeOnUpdate()
->restrictOnDelete();
$table->foreign('source_variant_id')
->references('id')
->on('variantes')
->cascadeOnUpdate()
->restrictOnDelete();
});
}
public function down(): void
{
if (DB::getDriverName() === 'sqlite') {
return;
}
Schema::table('tickets', function (Blueprint $table): void {
$table->dropForeign(['source_catalog_item_id']);
$table->dropForeign(['source_variant_id']);
$table->foreign('source_catalog_item_id')
->references('id')
->on('catalog_items')
->cascadeOnUpdate()
->nullOnDelete();
$table->foreign('source_variant_id')
->references('id')
->on('variantes')
->cascadeOnUpdate()
->nullOnDelete();
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('ticket_generation_policy');
});
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->enum('ticket_generation_policy', ['one_per_unit'])
->default('one_per_unit')
->after('has_tickets');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tickets', function (Blueprint $table): void {
$table->string('name')->nullable()->change();
$table->text('description')->nullable()->change();
});
}
public function down(): void
{
DB::table('tickets')->whereNull('name')->update(['name' => '']);
DB::table('tickets')->whereNull('description')->update(['description' => '']);
Schema::table('tickets', function (Blueprint $table): void {
$table->string('name')->nullable(false)->change();
$table->text('description')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->softDeletes();
});
Schema::table('variantes', function (Blueprint $table): void {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('variantes', function (Blueprint $table): void {
$table->dropSoftDeletes();
});
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('website_type', function (Blueprint $table): void {
$table->string('site_title')->nullable()->after('scanner_domain');
$table->unsignedBigInteger('favicon_id')->nullable()->after('footer_logo');
$table->foreign('favicon_id')
->references('id')
->on('attachments')
->nullOnDelete();
});
Schema::table('tenants', function (Blueprint $table): void {
$table->string('site_title')->nullable()->after('dominio');
$table->unsignedBigInteger('favicon_id')->nullable()->after('footer_logo_id');
$table->foreign('favicon_id')
->references('id')
->on('attachments')
->nullOnDelete();
});
DB::table('website_type')
->whereNull('site_title')
->update(['site_title' => DB::raw('nombre')]);
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['favicon_id']);
}
$table->dropColumn(['site_title', 'favicon_id']);
});
Schema::table('website_type', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['favicon_id']);
}
$table->dropColumn(['site_title', 'favicon_id']);
});
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
/** @var array<string, array{title: string, image: string, filename: string}> */
private const BRANDING = [
'fiesta_futbol_infantil' => [
'title' => 'Fiesta Fútbol Infantil',
'image' => 'images/tennants/fiesta_futbol_infantil/futbol_infantil_favicon.png',
'filename' => 'futbol_infantil_favicon.png',
],
'desfile_pura_tendencia' => [
'title' => 'Desfile Pura Tendencia',
'image' => 'images/tennants/desfile_pura_tendencia/pura_tendencia_favicon.png',
'filename' => 'pura_tendencia_favicon.png',
],
];
/** @var list<string> */
private array $storedPaths = [];
public function up(): void
{
try {
DB::transaction(function (): void {
foreach (self::BRANDING as $tenantCode => $branding) {
$tenant = DB::table('tenants')
->where('codigo', $tenantCode)
->first(['id', 'favicon_id']);
if ($tenant === null) {
continue;
}
$faviconId = $tenant->favicon_id;
$currentFilename = $faviconId === null
? null
: DB::table('attachments')->where('id', $faviconId)->value('filename');
if ($currentFilename !== $branding['filename']) {
$faviconId = $this->storeFavicon(
$tenantCode,
$branding['image'],
$branding['filename'],
);
}
DB::table('tenants')
->where('id', $tenant->id)
->update([
'site_title' => $branding['title'],
'favicon_id' => $faviconId,
'updated_at' => now(),
]);
}
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($this->storedPaths);
throw $throwable;
}
}
public function down(): void
{
foreach (self::BRANDING as $tenantCode => $branding) {
$tenant = DB::table('tenants')
->where('codigo', $tenantCode)
->first(['id', 'site_title', 'favicon_id']);
if ($tenant === null) {
continue;
}
$favicon = $tenant->favicon_id === null
? null
: DB::table('attachments')->where('id', $tenant->favicon_id)->first();
DB::table('tenants')
->where('id', $tenant->id)
->update([
'site_title' => $tenant->site_title === $branding['title'] ? null : $tenant->site_title,
'favicon_id' => $favicon?->filename === $branding['filename'] ? null : $tenant->favicon_id,
'updated_at' => now(),
]);
if ($favicon?->filename === $branding['filename']) {
Storage::disk('s3')->delete($favicon->path);
DB::table('attachments')->where('id', $favicon->id)->delete();
}
}
}
private function storeFavicon(string $tenantCode, string $relativePath, string $filename): int
{
$sourcePath = public_path($relativePath);
if (! is_file($sourcePath)) {
throw new RuntimeException("Favicon not found at path: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if ($contents === false) {
throw new RuntimeException("Could not read favicon at path: {$sourcePath}");
}
$key = (string) Str::uuid();
$storedPath = "tenants/{$tenantCode}/{$key}.png";
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
}
$this->storedPaths[] = $storedPath;
return DB::table('attachments')->insertGetId([
'key' => $key,
'path' => $storedPath,
'filename' => $filename,
'type' => 'image',
'mime_type' => 'image/png',
'extension' => 'png',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
}
};

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Seeder;
@@ -155,6 +154,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->orderByRaw("CASE WHEN type = 'bundle' THEN 0 ELSE 1 END")
->orderBy('id')
->each(fn (CatalogItem $item) => $this->catalogService->delete($item));
}
@@ -167,7 +167,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'inventory_policy' => InventoryPolicy::Tracked->value,
...$data,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
}

View File

@@ -241,14 +241,17 @@ class MenuSeeder extends Seeder
Role::query()
->whereIn('codigo', [RoleCode::Admin->value, RoleCode::AdminApp->value])
->orderBy('codigo')
->each(fn (Role $role) => $role->menus()->sync($allRoleMenuCodes));
Role::query()
->where('codigo', RoleCode::User->value)
->orderBy('codigo')
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
Role::query()
->where('codigo', RoleCode::Scanner->value)
->orderBy('codigo')
->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes));
$tenants = Tenant::all();

View File

@@ -104,6 +104,7 @@ class TenantSeeder extends Seeder
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => $fiestaDomain,
'site_title' => 'Fiesta Fútbol Infantil',
'primary_color' => '#00973F',
'secondary_color' => '#A0A0A0',
'danger_color' => '#FF8888',
@@ -121,6 +122,10 @@ class TenantSeeder extends Seeder
'images/tennants/fiesta_futbol_infantil/futbol_infantil_footer.png',
'futbol_infantil_footer.png',
),
'favicon' => $this->uploadedImage(
'images/tennants/fiesta_futbol_infantil/futbol_infantil_favicon.png',
'futbol_infantil_favicon.png',
),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'onticket',
'extras' => [
@@ -229,6 +234,18 @@ class TenantSeeder extends Seeder
}
}
if (
$tenant->favicon
&& $tenant->favicon_id !== $tenant->header_logo_id
&& $tenant->favicon_id !== $tenant->footer_logo_id
) {
try {
$attachmentService->delete($tenant->favicon);
} catch (Throwable) {
// Ignore cleanup errors while recreating demo data.
}
}
$tenant->delete();
}

View File

@@ -32,6 +32,7 @@ class WebsiteTypeSeeder extends Seeder
'nombre' => 'ShopIt',
'dominio' => 'localhost',
'scanner_domain' => 'scanner.localhost',
'site_title' => 'ShopIt',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
@@ -65,6 +66,7 @@ class WebsiteTypeSeeder extends Seeder
'nombre' => 'OnTicket',
'dominio' => 'onticket.localhost',
'scanner_domain' => 'scanner.onticket.localhost',
'site_title' => 'OnTicket',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -2,7 +2,11 @@
Recuperá tu contraseña
</h1>
@if($attempt->reason === 'account_locked')
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
<p>
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $tenant->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
</p>
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
<p>
Hola {{ $attempt->user->nombre_apellido }}, registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, hemos bloqueado el acceso temporalmente. Puedes utilizar este código para cambiar tu contraseña y desbloquearla inmediatamente.
</p>
@@ -25,14 +29,16 @@
<div style="text-align: center; margin-bottom: 28px;">
<a href="{{ $recoveryUrl }}"
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
Ingresar código ahora
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
</a>
</div>
@endif
<p style="color: #64748b; font-size: 14px;">
@if($attempt->reason === 'account_locked')
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
Si no esperabas recibir una cuenta de scanner, podés ignorar este mensaje.
@else
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
@endif

View File

@@ -3,13 +3,16 @@
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
@@ -113,4 +116,51 @@ class ScannerLoginControllerTest extends TestCase
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
]);
}
public function test_locking_a_scanner_sends_the_scanner_password_reset_flow(): void
{
Event::fake([PasswordResetRequested::class]);
config([
'login-security.max_attempts' => 3,
'login-security.rate_limit_per_minute' => 100,
'login-security.ip_rate_limit_per_minute' => 100,
]);
$role = Role::query()->create([
'codigo' => RoleCode::Scanner->value,
'nombre' => 'Scanner',
]);
$permission = Permission::query()->create([
'codigo' => PermissionCode::ScanTickets->value,
'nombre' => 'Escanear tickets',
]);
$role->permissions()->attach($permission->codigo);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
]);
$user = User::factory()->create([
'email' => 'scanner@example.com',
'password' => Hash::make('correct-password'),
'rol_codigo' => $role->codigo,
'tenant_codigo' => $tenant->codigo,
]);
$payload = [
'email' => $user->email,
'password' => 'wrong-password',
];
$this->postJson('/api/v1/scanner/login', $payload)->assertUnprocessable();
$this->postJson('/api/v1/scanner/login', $payload)->assertUnprocessable();
$this->postJson('/api/v1/scanner/login', $payload)->assertTooManyRequests();
$attempt = $user->resetPasswordAttempts()->sole();
$this->assertSame(ResetPasswordAttempt::REASON_ACCOUNT_LOCKED, $attempt->reason);
Event::assertDispatched(
PasswordResetRequested::class,
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
&& $event->tenantCode === $tenant->codigo
&& $event->channel === PasswordResetRequested::CHANNEL_SCANNER,
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class ScannerResetPasswordControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_an_attempt_for_a_tenant_bound_scanner_user(): void
{
Event::fake([PasswordResetRequested::class]);
Role::query()->create([
'codigo' => RoleCode::Scanner->value,
'nombre' => 'Scanner',
]);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'store.acme.test',
]);
$user = User::factory()->create([
'email' => 'scanner@example.com',
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo,
]);
$this->postJson('/api/v1/scanner/password/reset-attempts', [
'email' => ' SCANNER@EXAMPLE.COM ',
])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
$attempt = $user->resetPasswordAttempts()->sole();
Event::assertDispatched(
PasswordResetRequested::class,
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
&& $event->tenantCode === $tenant->codigo
&& $event->channel === PasswordResetRequested::CHANNEL_SCANNER,
);
}
public function test_it_does_not_create_an_attempt_for_a_non_scanner_user(): void
{
Event::fake([PasswordResetRequested::class]);
$this->postJson('/api/v1/scanner/password/reset-attempts', [
'email' => User::factory()->create()->email,
])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
$this->assertDatabaseCount('reset_password_attempts', 0);
Event::assertNotDispatched(PasswordResetRequested::class);
}
public function test_scanner_can_complete_the_password_reset_flow(): void
{
Event::fake([PasswordResetRequested::class]);
Role::query()->create([
'codigo' => RoleCode::Scanner->value,
'nombre' => 'Scanner',
]);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'store.acme.test',
]);
$user = User::factory()->create([
'email' => 'scanner@example.com',
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo,
]);
$this->postJson('/api/v1/scanner/password/reset-attempts', [
'email' => $user->email,
])->assertAccepted();
$attempt = $user->resetPasswordAttempts()->sole();
$this->postJson('/api/v1/scanner/password/reset-attempts/validate', [
'email' => $user->email,
'codigo' => $attempt->codigo,
])->assertOk()->assertJsonPath('status', ResetPasswordAttempt::STATUS_VALIDATED);
$this->postJson('/api/v1/scanner/password/reset', [
'email' => $user->email,
'codigo' => $attempt->codigo,
'password' => 'NewScanner!123',
'password_confirmation' => 'NewScanner!123',
])->assertOk()->assertJsonPath('status', ResetPasswordAttempt::STATUS_USED);
$this->assertTrue(Hash::check('NewScanner!123', $user->fresh()->password));
}
}

View File

@@ -287,7 +287,7 @@ class BundleCatalogItemTest extends TestCase
->assertJsonValidationErrors(['real_stock']);
}
public function test_bundle_components_are_deleted_with_the_bundle(): void
public function test_bundle_components_are_preserved_when_the_bundle_is_soft_deleted(): void
{
$component = $this->createStandardItem('deletion-component', 5);
$bundle = $this->createBundle('deletable-bundle', [
@@ -295,8 +295,8 @@ class BundleCatalogItemTest extends TestCase
]);
$this->catalogService->delete($bundle);
$this->assertDatabaseMissing('catalog_items', ['id' => $bundle->id]);
$this->assertDatabaseMissing('bundle_components', [
$this->assertSoftDeleted('catalog_items', ['id' => $bundle->id]);
$this->assertDatabaseHas('bundle_components', [
'bundle_catalog_item_id' => $bundle->id,
]);
$this->assertDatabaseHas('catalog_items', ['id' => $component->id]);

View File

@@ -33,7 +33,6 @@ class CatalogSchemaTest extends TestCase
{
$this->assertEqualsCanonicalizing([
'id',
'validity_time_id',
'tenant_code',
'category_id',
'brand_id',
@@ -46,7 +45,6 @@ class CatalogSchemaTest extends TestCase
'inventory_policy',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
], Schema::getColumnListing('catalog_items'));
}

View File

@@ -93,6 +93,89 @@ class CatalogServiceTest extends TestCase
}
}
public function test_it_soft_deletes_an_item_and_its_variants_without_removing_their_sources(): void
{
$attribute = $this->createAttribute('size');
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'archived-item',
'nombre' => 'Archived item',
'precio' => 100,
'attribute_codes' => [$attribute->codigo],
'variants' => [[
'real_stock' => 5,
'values' => [$attribute->codigo => 'M'],
]],
]);
$variant = $item->variants->sole();
$this->service->delete($item);
$this->assertSoftDeleted('catalog_items', ['id' => $item->id]);
$this->assertSoftDeleted('variantes', ['id' => $variant->id]);
$this->assertDatabaseHas('inventories', ['id' => $variant->inventory_id]);
$this->assertDatabaseHas('variant_values', [
'variant_id' => $variant->id,
'value' => 'M',
]);
$this->assertNull(CatalogItem::query()->find($item->id));
$this->assertNull(Variant::query()->find($variant->id));
$this->assertNotNull(CatalogItem::withTrashed()->find($item->id));
$this->assertNotNull(Variant::withTrashed()->find($variant->id));
}
public function test_deleting_the_last_variant_soft_deletes_its_catalog_item(): void
{
$attribute = $this->createAttribute('size');
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'last-variant',
'nombre' => 'Last variant',
'precio' => 100,
'attribute_codes' => [$attribute->codigo],
'variants' => [[
'real_stock' => 5,
'values' => [$attribute->codigo => 'M'],
]],
]);
$variant = $item->variants->sole();
$this->service->deleteVariant($variant);
$this->assertSoftDeleted('variantes', ['id' => $variant->id]);
$this->assertSoftDeleted('catalog_items', ['id' => $item->id]);
$this->assertDatabaseHas('inventories', ['id' => $variant->inventory_id]);
}
public function test_deleting_a_variant_keeps_the_item_active_when_an_inherited_price_variant_remains(): void
{
$attribute = $this->createAttribute('size');
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'remaining-variant',
'nombre' => 'Remaining variant',
'precio' => 100,
'attribute_codes' => [$attribute->codigo],
'variants' => [
[
'real_stock' => 5,
'values' => [$attribute->codigo => 'M'],
],
[
'real_stock' => 5,
'values' => [$attribute->codigo => 'L'],
],
],
]);
$deletedVariant = $item->variants->first();
$this->service->deleteVariant($deletedVariant);
$this->assertSoftDeleted('variantes', ['id' => $deletedVariant->id]);
$this->assertFalse($item->fresh()->trashed());
$this->assertSame(1, $item->variants()->count());
}
public function test_it_can_hide_an_item_attribute_from_the_product_selector(): void
{
$attribute = $this->createAttribute('internal_type');

View File

@@ -8,6 +8,7 @@ use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -141,9 +142,10 @@ class AccommodationControllerTest extends TestCase
$this->deleteJson("/api/v1/adminapp/tenant/accommodations/{$accommodationId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
$this->assertSame(0, CatalogItem::query()->count());
$this->assertSoftDeleted('variantes', ['id' => $accommodationId]);
$this->assertSame(0, Variant::query()->count());
$this->assertDatabaseCount('inventories', 1);
}
public function test_it_rejects_duplicate_normalized_titles(): void

View File

@@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -97,7 +98,7 @@ class EntryControllerTest extends TestCase
->where('nombre', 'Entradas')
->sole();
CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void {
CatalogItem::query()->orderBy('id')->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void {
$this->assertSame($tenant->codigo, $entry->tenant_code);
$this->assertSame($entryCategory->id, $entry->category_id);
$this->assertSame('tracked', $entry->inventory_policy->value);
@@ -191,9 +192,10 @@ class EntryControllerTest extends TestCase
$this->deleteJson("/api/v1/adminapp/tenant/entries/{$entryId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
$this->assertSoftDeleted('catalog_items', ['id' => $entryId]);
$this->assertSame(0, CatalogItem::query()->count());
$this->assertSame(0, Variant::query()->count());
$this->assertDatabaseCount('inventories', 1);
}
public function test_dates_must_belong_to_the_authenticated_tenant(): void

View File

@@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -163,15 +164,17 @@ class FoodControllerTest extends TestCase
'slug' => 'comida',
'precio' => 10000,
]);
$this->assertDatabaseCount('variantes', 1);
$this->assertDatabaseCount('inventories', 1);
$this->assertSoftDeleted('variantes', ['id' => $secondId]);
$this->assertSame(1, Variant::query()->count());
$this->assertDatabaseCount('inventories', 2);
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$firstId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
$this->assertSoftDeleted('variantes', ['id' => $firstId]);
$this->assertSame(0, CatalogItem::query()->count());
$this->assertSame(0, Variant::query()->count());
$this->assertDatabaseCount('inventories', 2);
}
public function test_it_rejects_duplicate_combinations(): void

View File

@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -164,14 +165,16 @@ class MerchandiseControllerTest extends TestCase
'id' => $itemId,
'precio' => 15000,
]);
$this->assertDatabaseCount('variantes', 1);
$this->assertSoftDeleted('variantes', ['id' => $firstId]);
$this->assertSame(1, Variant::query()->count());
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$secondId}")
->assertNoContent();
$this->assertDatabaseMissing('catalog_items', ['id' => $itemId]);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
$this->assertSoftDeleted('catalog_items', ['id' => $itemId]);
$this->assertSoftDeleted('variantes', ['id' => $secondId]);
$this->assertSame(0, Variant::query()->count());
$this->assertDatabaseCount('inventories', 2);
}
public function test_it_rejects_duplicate_color_and_size_combinations(): void

View File

@@ -36,6 +36,11 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
);
$footerBackgroundMigration->up();
$browserBrandingMigration = require database_path(
'migrations/2026_08_14_010000_set_seeded_tenant_browser_branding.php'
);
$browserBrandingMigration->up();
$this->assertDatabaseHas('tenants', [
'codigo' => 'desfile_pura_tendencia',
'nombre' => 'Desfile Pura Tendencia',
@@ -52,8 +57,25 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
'footer_bg_color' => '#D4441C',
'display_categories' => false,
'display_seach_bar' => false,
'site_title' => 'Desfile Pura Tendencia',
]);
$this->assertDatabaseHas('tenants', [
'codigo' => 'fiesta_futbol_infantil',
'site_title' => 'Fiesta Fútbol Infantil',
]);
foreach ([
'fiesta_futbol_infantil' => 'futbol_infantil_favicon.png',
'desfile_pura_tendencia' => 'pura_tendencia_favicon.png',
] as $tenantCode => $faviconFilename) {
$faviconId = DB::table('tenants')->where('codigo', $tenantCode)->value('favicon_id');
$favicon = DB::table('attachments')->where('id', $faviconId)->sole();
$this->assertSame($faviconFilename, $favicon->filename);
Storage::disk('s3')->assertExists($favicon->path);
}
$eventDate = DB::table('event_dates')
->where('tenant_code', 'desfile_pura_tendencia')
->sole();
@@ -104,6 +126,7 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
'desfile_pura_tendencia_footer.png',
'desfile_pura_tendencia_hero.png',
'desfile_pura_tendencia_footer_background.png',
'pura_tendencia_favicon.png',
'entrada_pasarela.png',
] as $filename) {
$path = DB::table('attachments')->where('filename', $filename)->value('path');
@@ -138,8 +161,9 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
$this->assertSame('Entrada', $catalogItem->nombre);
$this->assertSame('tracked', $catalogItem->inventory_policy);
$this->assertSame(1, $catalogItem->has_tickets);
$this->assertSame('one_per_unit', $catalogItem->ticket_generation_policy);
$this->assertSame($eventDate->validity_time_id, $catalogItem->validity_time_id);
$this->assertDatabaseHas('variant_event_dates', [
'event_date_id' => $eventDate->id,
]);
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $catalogItem->id,
'variant_id' => null,

View File

@@ -7,9 +7,11 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Integration\Models\Integration;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Mail\Mailable;
@@ -103,6 +105,36 @@ class NotificationMailServiceTest extends TestCase
});
}
public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void
{
$websiteType = WebsiteType::query()->create([
'codigo' => 'scanner-mail',
'nombre' => 'Scanner Mail',
'dominio' => 'admin.mail.local',
'scanner_domain' => 'scanner.mail.local',
]);
$this->tenant->update(['website_type_code' => $websiteType->codigo]);
$attempt = $this->user->resetPasswordAttempts()->create([
'codigo' => '0123',
'reason' => ResetPasswordAttempt::REASON_STAFF_CREATED,
]);
app(NotificationMailService::class)->sendPasswordResetCode(
$attempt->id,
$this->tenant->codigo,
PasswordResetRequested::CHANNEL_SCANNER,
);
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$rendered = $mail->render();
return str_contains($rendered, 'https://scanner.mail.local/recuperar-contrasena/codigo')
&& str_contains($rendered, 'email=ada%40example.com')
&& str_contains($rendered, 'code=0123')
&& str_contains($rendered, 'Crear mi');
});
}
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
{
$purchase = Purchase::query()->create([
@@ -135,8 +167,7 @@ class NotificationMailServiceTest extends TestCase
$ticket = Ticket::query()->create([
'tenant_code' => $this->tenant->codigo,
'ticket' => fake()->uuid(),
'name' => 'Entrada general',
'description' => 'Entrada general',
'source_catalog_item_id' => $catalogItem->id,
'user_id' => $this->user->id,
]);
$service = app(NotificationMailService::class);

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Sale;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
@@ -97,20 +98,26 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PAID,
'total' => '20000.00',
]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'abono-general',
'nombre' => 'Abono general',
'descripcion' => 'Acceso general',
'precio' => 20000,
'has_tickets' => true,
]);
$firstTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => '11111111-1111-4111-8111-111111111111',
'name' => 'Abono general',
'description' => 'Acceso general',
'source_purchase_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'user_id' => $admin->id,
]);
$usedTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => '22222222-2222-4222-8222-222222222222',
'name' => 'Abono general',
'description' => 'Acceso general',
'source_purchase_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'used_at' => now()->subMinute(),
'user_id' => $admin->id,
]);

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Database\Seeders\AttributeSeeder;
@@ -77,12 +76,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->get()
->every(fn (CatalogItem $item): bool => $item->has_tickets),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->get()
->every(fn (CatalogItem $item): bool => $item->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit),
);
$expectedVariantCounts = [
'camiseta' => 12,

View File

@@ -2,13 +2,16 @@
namespace Tests\Feature\Staff;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Category;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
@@ -24,6 +27,7 @@ class StaffControllerTest extends TestCase
{
parent::setUp();
Event::fake([PasswordResetRequested::class]);
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
$this->tenant = Tenant::query()->create([
@@ -59,6 +63,16 @@ class StaffControllerTest extends TestCase
'user_id' => $staffId,
'categoria_id' => $firstCategory->id,
]);
$this->assertDatabaseHas('reset_password_attempts', [
'user_id' => $staffId,
'reason' => ResetPasswordAttempt::REASON_STAFF_CREATED,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
Event::assertDispatched(
PasswordResetRequested::class,
fn (PasswordResetRequested $event): bool => $event->tenantCode === $this->tenant->codigo
&& $event->channel === PasswordResetRequested::CHANNEL_SCANNER,
);
$this->getJson('/api/v1/adminapp/tenant/staff?search=ada')
->assertOk()

View File

@@ -127,6 +127,62 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.dominio', 'acme.com');
}
public function test_it_resolves_browser_branding_from_the_tenant_before_the_website_type(): void
{
$typeFavicon = Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'website-types/type-favicon.png',
'filename' => 'type-favicon.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenantFavicon = Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tenants/tenant-favicon.png',
'filename' => 'tenant-favicon.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$websiteType = WebsiteType::query()->create([
'codigo' => 'store',
'nombre' => 'Tienda',
'site_title' => 'Website type title',
'favicon_id' => $typeFavicon->id,
]);
$tenant = $this->createTenant([
'website_type_code' => $websiteType->codigo,
]);
$typeResponse = $this->getJson('/api/tenants/bootstrap/acme.com');
$typeResponse
->assertOk()
->assertJsonPath('data.site_title', 'Website type title');
$this->assertStringContainsString($typeFavicon->key, $typeResponse->json('data.favicon'));
$tenant->update([
'site_title' => 'Tenant title',
'favicon_id' => $tenantFavicon->id,
]);
$tenantResponse = $this->getJson('/api/tenants/bootstrap/acme.com');
$tenantResponse
->assertOk()
->assertJsonPath('data.site_title', 'Tenant title');
$this->assertStringContainsString($tenantFavicon->key, $tenantResponse->json('data.favicon'));
}
public function test_it_uses_default_browser_branding_when_none_is_configured(): void
{
$this->createTenant();
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.site_title', 'ShopitFront')
->assertJsonPath('data.favicon', null);
}
public function test_it_returns_only_the_tenant_categories_in_the_bootstrap(): void
{
$tenant = $this->createTenant();
@@ -755,21 +811,28 @@ class BootstrapTenantControllerTest extends TestCase
'footer_bg_color' => '#444444',
'header_logo' => $base64Image,
'footer_logo' => $ftrUuid,
'site_title' => 'Acme Store',
'favicon' => $base64Image,
]);
$response->assertCreated();
$tenant = Tenant::query()->with('headerLogo')->where('codigo', 'acme')->firstOrFail();
$tenant = Tenant::query()->with(['headerLogo', 'favicon'])->where('codigo', 'acme')->firstOrFail();
$this->assertNotNull($tenant->header_logo_id);
$this->assertNotNull($tenant->favicon_id);
$headerUrl = $response->json('data.header_logo');
$faviconUrl = $response->json('data.favicon');
$response->assertJsonPath('data.site_title', 'Acme Store');
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
$this->assertStringContainsString($tenant->favicon->key, $faviconUrl);
$this->assertTrue(
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
);
$this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]);
$this->assertDatabaseHas('attachments', ['key' => $tenant->favicon->key]);
}
}

View File

@@ -32,20 +32,26 @@ class WebsiteTypeServiceTest extends TestCase
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_title' => 'Marketplace Central',
'site_logo' => UploadedFile::fake()->image('site-logo.png'),
'footer_logo' => UploadedFile::fake()->image('footer-logo.png'),
'favicon' => UploadedFile::fake()->image('favicon.png', 32, 32),
]);
$siteLogo = $websiteType->siteLogo()->firstOrFail();
$footerLogo = $websiteType->footerLogo()->firstOrFail();
$favicon = $websiteType->favicon()->firstOrFail();
$this->assertSame('marketplace', $websiteType->codigo);
$this->assertSame($siteLogo->id, $websiteType->site_logo);
$this->assertSame($footerLogo->id, $websiteType->footer_logo);
$this->assertSame($favicon->id, $websiteType->favicon_id);
$this->assertStringStartsWith('website-types/', $siteLogo->path);
$this->assertStringStartsWith('website-types/', $footerLogo->path);
$this->assertStringStartsWith('website-types/', $favicon->path);
Storage::disk('s3')->assertExists($siteLogo->path);
Storage::disk('s3')->assertExists($footerLogo->path);
Storage::disk('s3')->assertExists($favicon->path);
$this->assertDatabaseHas('website_type', [
'codigo' => 'marketplace',
'dominio' => 'marketplace.test',
@@ -57,8 +63,10 @@ class WebsiteTypeServiceTest extends TestCase
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_title' => 'Marketplace Central',
'site_logo' => $siteLogo->id,
'footer_logo' => $footerLogo->id,
'favicon_id' => $favicon->id,
]);
}
}

View File

@@ -322,8 +322,6 @@ class ScannerTicketControllerTest extends TestCase
return Ticket::query()->create(array_merge([
'tenant_code' => $tenant->codigo,
'ticket' => $uuid,
'name' => 'Entrada general',
'description' => 'Acceso general',
'source_catalog_item_id' => $catalogItem->id,
'user_id' => $this->ticketOwner->id,
], $attributes));

View File

@@ -5,10 +5,11 @@ namespace Tests\Feature\Ticket;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -23,35 +24,52 @@ class TicketControllerTest extends TestCase
$user = User::factory()->create();
$olderTicket = $this->createTicket($tenant, $user, 'Older ticket');
$newerTicket = $this->createTicket($tenant, $user, 'Newer ticket');
$validityTimes = collect([
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'dated-ticket',
'nombre' => 'Dated ticket',
'descripcion' => 'Dated ticket',
'precio' => 10,
'has_tickets' => true,
]);
$eventDates = collect([
EventDate::query()->create([
'tenant_code' => $tenant->codigo,
'date' => now()->format('Y-m-d'),
'time_start' => now()->subHour()->format('H:i:s'),
'time_end' => now()->addHour()->format('H:i:s'),
]),
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
EventDate::query()->create([
'tenant_code' => $tenant->codigo,
'date' => now()->addDay()->format('Y-m-d'),
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]),
]);
$validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void {
$group = $newerTicket->validityGroups()->create();
$group->validityTimes()->attach($validityTime);
});
$variant = $catalogItem->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($eventDates->pluck('id'));
$newerTicket->update([
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => $variant->id,
]);
$expectedName = 'Dated ticket ('.$eventDates
->pluck('date')
->map(fn ($date): string => $date->format('d/m/Y'))
->implode(', ').')';
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets")
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $newerTicket->id)
->assertJsonPath('data.0.name', 'Newer ticket')
->assertJsonPath('data.0.name', $expectedName)
->assertJsonPath('data.0.is_valid', true)
->assertJsonPath('data.0.is_expired', false)
->assertJsonPath('data.0.is_used', false)
->assertJsonCount(2, 'data.0.validity_times')
->assertJsonCount(2, 'data.0.validity_groups')
->assertJsonCount(1, 'data.0.validity_groups.0.validity_times')
->assertJsonMissingPath('data.0.validity_times')
->assertJsonMissingPath('data.0.validity_groups')
->assertJsonMissingPath('data.0.validity_time')
->assertJsonPath('data.1.id', $olderTicket->id)
->assertJsonMissingPath('meta')
@@ -122,11 +140,19 @@ class TicketControllerTest extends TestCase
private function createTicket(Tenant $tenant, User $user, string $name): Ticket
{
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => Str::slug($name).'-'.Str::lower(Str::random(8)),
'nombre' => $name,
'descripcion' => "Description for {$name}",
'precio' => 10,
'has_tickets' => true,
]);
return Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => (string) Str::uuid(),
'name' => $name,
'description' => "Description for {$name}",
'source_catalog_item_id' => $catalogItem->id,
'user_id' => $user->id,
]);
}

View File

@@ -9,12 +9,12 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Models\EventDate;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\ValidityTime;
@@ -70,8 +70,14 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame($item->descripcion, $ticket->description);
$this->assertSame($item->id, $ticket->source_catalog_item_id);
$this->assertNull($ticket->source_variant_id);
$this->assertTrue($ticket->validityGroups->isEmpty());
$this->assertTrue($ticket->resolvedValidityGroups()->isEmpty());
}
$this->assertDatabaseHas('tickets', [
'id' => $tickets->first()->id,
'name' => null,
'description' => null,
]);
}
public function test_it_rejects_an_item_without_tickets_enabled(): void
@@ -98,6 +104,10 @@ class TicketGeneratorServiceTest extends TestCase
'value' => 'black',
'label' => 'Negro',
]);
$color->options()->create([
'value' => 'blue',
'label' => 'Azul',
]);
$size = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'size',
@@ -118,17 +128,33 @@ class TicketGeneratorServiceTest extends TestCase
]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
'descripcion' => 'Descripción de la variante',
]);
$variant->definitions()->createMany([
['item_attribute_id' => $itemColor->id, 'value' => 'black'],
['item_attribute_id' => $itemSize->id, 'value' => 'xl'],
]);
$colorDefinition = $variant->definitions()
->where('item_attribute_id', $itemColor->id)
->sole();
$ticket = $this->service
->generate($item, $this->user, 1, $variant->id)
->sole();
$this->assertSame('Shirt (Negro, XL)', $ticket->name);
$this->assertSame('Descripción de la variante', $ticket->description);
$item->update([
'nombre' => 'Remera',
'descripcion' => 'Descripción actualizada del producto',
]);
$colorDefinition->update(['value' => 'blue']);
$variant->update(['descripcion' => 'Descripción actualizada de la variante']);
$ticket = $ticket->fresh();
$this->assertSame('Remera (Azul, XL)', $ticket->name);
$this->assertSame('Descripción actualizada de la variante', $ticket->description);
}
public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void
@@ -273,9 +299,9 @@ class TicketGeneratorServiceTest extends TestCase
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$this->assertCount(2, $tickets);
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1));
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->resolvedValidityGroups()->count() === 1));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes
fn ($ticket): bool => $ticket->resolvedValidityGroups()->sole()->validityTimes
->pluck('id')
->sort()
->values()
@@ -291,7 +317,7 @@ class TicketGeneratorServiceTest extends TestCase
);
}
public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void
public function test_it_generates_one_ticket_per_unit_covering_all_selected_dates(): void
{
$item = $this->createTicketableItem('multi-date-pass');
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
@@ -305,61 +331,27 @@ class TicketGeneratorServiceTest extends TestCase
]);
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 1, $variant->id);
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertSame(
$dates->pluck('validity_time_id')->all(),
$tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(),
);
$this->assertSame(
['2026-08-20 00:00:00', '2026-08-21 00:00:00'],
$tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
);
$this->assertSame(
['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'],
$tickets->pluck('name')->all(),
);
$this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all());
}
public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void
{
$item = $this->createTicketableItem('multi-date-pass');
$item->update([
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit,
]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]));
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2));
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->resolvedValidityGroups()->count() === 2));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->every(
fn ($ticket): bool => $ticket->resolvedValidityGroups()->every(
fn ($group): bool => $group->validityTimes->count() === 1
)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(),
$tickets->flatMap(
fn ($ticket) => $ticket->resolvedValidityGroups()
->flatMap(fn ($group) => $group->validityTimes)
)->pluck('id')->unique()->all(),
);
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)'
));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->allValidityTimes()
fn ($ticket): bool => $ticket->resolvedValidityGroups()
->flatMap(fn ($group) => $group->validityTimes)
->map(fn (ValidityTime $validityTime): array => [
$validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
$validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
@@ -371,10 +363,36 @@ class TicketGeneratorServiceTest extends TestCase
));
}
public function test_ticket_validity_is_resolved_from_soft_deleted_catalog_sources(): void
{
$item = $this->createTicketableItem('archived-ticket-source');
$eventDate = EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => '2026-07-21',
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->attach($eventDate);
$ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole();
app(CatalogService::class)->delete($item);
$ticket = $ticket->fresh();
$this->assertTrue($ticket->sourceCatalogItem->trashed());
$this->assertTrue($ticket->sourceVariant->trashed());
$this->assertTrue($ticket->isValid());
$this->assertSame(
'2026-07-21 23:59:59',
$ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'),
);
}
public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void
{
$item = $this->createTicketableItem('multi-date-lunch');
$item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
@@ -408,16 +426,14 @@ class TicketGeneratorServiceTest extends TestCase
]);
$ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole();
$ticket->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $ticket->validityGroups);
$this->assertTrue($ticket->validityGroups->every(
$this->assertCount(2, $ticket->resolvedValidityGroups());
$this->assertTrue($ticket->resolvedValidityGroups()->every(
fn ($group): bool => $group->validityTimes->count() === 2
&& $group->validityTimes->contains($lunch)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$ticket->validityGroups
$ticket->resolvedValidityGroups()
->flatMap->validityTimes
->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch))
->pluck('id')

View File

@@ -25,25 +25,20 @@ class TicketValiditySchemaTest extends TestCase
$this->assertFalse(Schema::hasColumn('tickets', 'validity_time_id'));
$this->assertFalse(Schema::hasTable('ticket_validity_times'));
$this->assertEqualsCanonicalizing([
'id',
'ticket_id',
], Schema::getColumnListing('ticket_validity_groups'));
$this->assertEqualsCanonicalizing([
'ticket_validity_group_id',
'validity_time_id',
], Schema::getColumnListing('ticket_validity_group_times'));
$this->assertFalse(Schema::hasTable('ticket_validity_groups'));
$this->assertFalse(Schema::hasTable('ticket_validity_group_times'));
$this->assertFalse(Schema::hasColumn('tickets', 'service_date'));
$this->assertFalse(Schema::hasColumn('tickets', 'starts_at'));
$this->assertFalse(Schema::hasColumn('tickets', 'expires_at'));
$this->assertTrue(Schema::hasColumn('catalog_items', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date'));
$this->assertTrue(Schema::hasColumn('catalog_items', 'deleted_at'));
$this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id'));
$this->assertTrue(Schema::hasColumn('event_dates', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date'));
$this->assertTrue(Schema::hasColumn('variantes', 'deleted_at'));
}
}

View File

@@ -8,9 +8,10 @@ use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use App\Domains\Ticket\Services\ResolvedTicketValidity;
use App\Domains\Ticket\Services\ResolvedValidityGroup;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Support\Carbon;
use Tests\TestCase;
@@ -46,8 +47,6 @@ class TicketTest extends TestCase
$this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
$this->assertInstanceOf(TicketValidityGroup::class, $ticket->validityGroups()->getRelated());
$this->assertInstanceOf(ValidityTime::class, (new TicketValidityGroup)->validityTimes()->getRelated());
}
public function test_unused_ticket_without_validity_time_is_valid(): void
@@ -190,13 +189,23 @@ class TicketTest extends TestCase
private function ticketWithValidityGroups(array $validityGroups): Ticket
{
$ticket = new Ticket;
$groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup {
$group = new TicketValidityGroup;
$group->setRelation('validityTimes', new EloquentCollection($validityTimes));
$resolved = new ResolvedTicketValidity(
collect($validityGroups)->map(
fn (array $validityTimes): ResolvedValidityGroup => new ResolvedValidityGroup(collect($validityTimes))
)
);
$this->app->instance(
TicketValidityResolver::class,
new class($resolved) extends TicketValidityResolver
{
public function __construct(private readonly ResolvedTicketValidity $resolved) {}
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
public function resolveTicket(Ticket $ticket): ResolvedTicketValidity
{
return $this->resolved;
}
}
);
return $ticket;
}

View File

@@ -0,0 +1,209 @@
<?php
namespace Tests\Unit\Ticket;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class TicketValidityResolverTest extends TestCase
{
private TicketValidityResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->resolver = new TicketValidityResolver;
}
public function test_event_dates_and_multi_select_options_expand_to_or_groups(): void
{
$dates = new EloquentCollection([
$this->eventDate('2026-08-20'),
$this->eventDate('2026-08-21'),
]);
[$itemAttribute, $definitions] = $this->attributeDimension(
allowMultiSelect: true,
options: [
'Desayuno' => $this->timeWindow('07:00:00', '12:00:00'),
'Cena' => $this->timeWindow('20:00:00', '24:00:00'),
],
);
$variant = $this->variant($dates, $definitions);
$validity = $this->resolver->resolveVariant($variant);
$this->assertTrue($validity->isResolvable);
$this->assertFalse($validity->isUnrestricted);
$this->assertCount(4, $validity->groups);
$this->assertTrue($validity->groups->every(
fn ($group): bool => $group->validityTimes->count() === 2
));
$this->assertSame($itemAttribute, $definitions->first()->itemAttribute);
}
public function test_different_temporal_attributes_are_anded_in_the_same_group(): void
{
[, $scheduleDefinitions] = $this->attributeDimension(
allowMultiSelect: false,
options: ['Almuerzo' => $this->timeWindow('12:00:00', '15:00:00')],
);
[, $admissionDefinitions] = $this->attributeDimension(
allowMultiSelect: false,
options: ['Ingreso' => $this->timeWindow('11:00:00', '14:00:00')],
);
$variant = $this->variant(
new EloquentCollection([$this->eventDate('2026-08-20')]),
new EloquentCollection([
...$scheduleDefinitions,
...$admissionDefinitions,
]),
);
$validity = $this->resolver->resolveVariant($variant);
$this->assertCount(1, $validity->groups);
$this->assertCount(3, $validity->groups->sole()->validityTimes);
$this->assertSame(
'2026-08-20 12:00:00',
$validity->effectiveStartsAt(Carbon::parse('2026-08-20 13:00:00'))->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-08-20 14:00:00',
$validity->effectiveExpiresAt(Carbon::parse('2026-08-20 13:00:00'))->format('Y-m-d H:i:s'),
);
$this->assertTrue($validity->isValid(Carbon::parse('2026-08-20 13:00:00')));
$this->assertFalse($validity->isValid(Carbon::parse('2026-08-20 14:00:00')));
}
public function test_variant_without_temporal_dimensions_is_unrestricted(): void
{
$validity = $this->resolver->resolveVariant(
$this->variant(new EloquentCollection, new EloquentCollection)
);
$this->assertTrue($validity->isResolvable);
$this->assertTrue($validity->isUnrestricted);
$this->assertTrue($validity->isValid());
$this->assertFalse($validity->isExpired());
}
public function test_non_option_attributes_do_not_affect_validity(): void
{
$attribute = new Attribute(['type' => FieldType::String]);
$attribute->setRelation('options', new EloquentCollection);
$itemAttribute = new ItemAttribute(['allow_multi_select' => false]);
$itemAttribute->setRelation('attribute', $attribute);
$definition = new VariantDefinition(['value' => 'Comedor principal']);
$definition->setAttribute('item_attribute_id', 123);
$definition->setRelation('itemAttribute', $itemAttribute);
$validity = $this->resolver->resolveVariant(
$this->variant(new EloquentCollection, new EloquentCollection([$definition]))
);
$this->assertTrue($validity->isResolvable);
$this->assertTrue($validity->isUnrestricted);
}
public function test_multiple_values_for_a_single_select_attribute_are_unresolvable(): void
{
[, $definitions] = $this->attributeDimension(
allowMultiSelect: false,
options: [
'Desayuno' => $this->timeWindow('07:00:00', '12:00:00'),
'Cena' => $this->timeWindow('20:00:00', '24:00:00'),
],
);
$validity = $this->resolver->resolveVariant(
$this->variant(new EloquentCollection, $definitions)
);
$this->assertFalse($validity->isResolvable);
$this->assertFalse($validity->isValid());
}
private function eventDate(string $date): EventDate
{
$validityTime = new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => "{$date} 00:00:00",
'fixed_expires_at' => "{$date} 23:59:59",
]);
$eventDate = new EventDate([
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]);
$eventDate->setRelation('validityTime', $validityTime);
return $eventDate;
}
private function timeWindow(string $start, string $end): ValidityTime
{
return new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => $start,
'end_time' => $end,
]);
}
/**
* @param array<string, ValidityTime|null> $options
* @return array{ItemAttribute, EloquentCollection<int, VariantDefinition>}
*/
private function attributeDimension(bool $allowMultiSelect, array $options): array
{
$attribute = new Attribute([
'type' => FieldType::Select,
]);
$attributeOptions = collect($options)->map(
function (?ValidityTime $validityTime, string $value): AttributeOption {
$option = new AttributeOption(['value' => $value, 'label' => $value]);
$option->setRelation('validityTime', $validityTime);
return $option;
}
);
$attribute->setRelation('options', new EloquentCollection($attributeOptions));
$itemAttribute = new ItemAttribute(['allow_multi_select' => $allowMultiSelect]);
$itemAttribute->setRelation('attribute', $attribute);
$definitions = $attributeOptions->map(function (AttributeOption $option) use ($itemAttribute): VariantDefinition {
$definition = new VariantDefinition(['value' => $option->value]);
$definition->setAttribute('item_attribute_id', spl_object_id($itemAttribute));
$definition->setRelation('itemAttribute', $itemAttribute);
return $definition;
});
return [$itemAttribute, new EloquentCollection($definitions)];
}
/**
* @param EloquentCollection<int, EventDate> $eventDates
* @param EloquentCollection<int, VariantDefinition> $definitions
*/
private function variant(EloquentCollection $eventDates, EloquentCollection $definitions): Variant
{
$variant = new Variant;
$variant->setRelation('eventDates', $eventDates);
$variant->setRelation('eventDate', null);
$variant->setRelation('definitions', $definitions);
return $variant;
}
}

View File

@@ -3,9 +3,7 @@
namespace Tests\Unit\Ticket;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Support\Carbon;
use Tests\TestCase;
@@ -24,12 +22,7 @@ class ValidityTimeTest extends TestCase
$this->assertSame(ValidityTimeType::FixedWindow, $validityTime->type);
$this->assertInstanceOf(Carbon::class, $validityTime->fixed_starts_at);
$this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at);
$this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated());
$this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated());
$this->assertInstanceOf(
TicketValidityGroup::class,
$validityTime->ticketValidityGroups()->getRelated(),
);
}
public function test_it_exposes_supported_type_values(): void