Compare commits

...

42 Commits

Author SHA1 Message Date
5503559899 feat(refund): implement backfill command for historical ticket refunds reconciliation 2026-09-17 12:29:33 -03:00
830f65c402 feat(refund): add support for event date suspension in cart invalidation process 2026-09-17 11:42:33 -03:00
df6892c662 feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status 2026-09-17 11:34:26 -03:00
de08ee4ac7 feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates 2026-09-17 10:27:27 -03:00
1269ebcda0 feat(ticket): exclude cancelled status from common ticket filter fields 2026-09-17 09:38:57 -03:00
04e54fefad feat(notification): update ticket and order number formatting in event suspension emails 2026-09-16 16:35:51 -03:00
a46bf905e8 feat(refund): enhance refund process by adding backfill for refunded_units and updating inventory restoration logic 2026-09-16 15:00:41 -03:00
353fd34db2 feat(inventory): add refunded_units to inventory model and update related services 2026-09-16 15:00:28 -03:00
f8a3296821 feat(event): add effective date resolution for rescheduled events and update tests 2026-09-16 14:27:35 -03:00
9aa0c1f3b9 feat(entry): enhance variant filtering in all method to exclude replaced and disabled variants 2026-09-16 10:14:54 -03:00
708037677a feat(entry): add validation for single non-replaced variant in EntryResource and update tests 2026-09-16 10:08:39 -03:00
e6b5da366c feat(entry): filter out replaced variants in EntryResource to improve data accuracy 2026-09-16 09:55:28 -03:00
69bea10519 feat(entry): simplify variant data handling in EntryResource by removing replaced variant logic 2026-09-16 09:50:16 -03:00
3d6548ce4a feat(entry): refine variant selection logic to exclude replaced variants and add unit tests for replacement behavior 2026-09-16 09:34:02 -03:00
8ad9b3a4e3 Revert "feat(variants): implement restoration of previously suspended variants with usable dates"
This reverts commit 3a4f6b64d2.
2026-09-16 09:08:43 -03:00
3a4f6b64d2 feat(variants): implement restoration of previously suspended variants with usable dates 2026-09-16 09:05:21 -03:00
44178d72a5 feat(variants): enhance disableForSuspension method to manage variant replacements and inventory merging 2026-09-16 08:56:01 -03:00
d02ad5fce2 feat(event): add per-user date change notices 2026-09-15 17:06:12 -03:00
69f2dbe056 refactor(event): remove date notices from tenant config 2026-09-15 17:06:06 -03:00
24d87623cc feat(event): implement EventDateGroupingService to group historical dates and update related resources and tests 2026-09-15 16:28:39 -03:00
51937ca93f feat(event): add EventDateInfoFormatter to format event date messages and implement related tests 2026-09-15 16:08:42 -03:00
dcf4383fbf feat(variants): implement inventory cloning for historical stock updates and adjust related tests 2026-09-15 15:15:18 -03:00
ff43ba32f3 feat(food): add endpoint to update historical stock and separate current and historical food variants 2026-09-15 15:03:41 -03:00
9fa4da7de2 feat(variants): add filtering for inactive event dates in visibleVariants method 2026-09-14 16:52:34 -03:00
c27d9628fd feat(event): filter event dates to exclude rescheduled and suspended entries 2026-09-14 16:48:06 -03:00
b1e09b71ad feat(migrations): implement backfill for legacy refunds and add migration tests 2026-09-14 15:13:16 -03:00
7d45734ad7 feat(forms): add entry form event dates 2026-09-14 15:00:29 -03:00
91022c5897 fix(forms): hide inactive food event dates 2026-09-14 15:00:23 -03:00
61314d5166 feat(storefront): expose event date notices 2026-09-14 14:49:30 -03:00
0d54887602 feat(event): audit suspended event dates 2026-09-14 14:19:29 -03:00
54827fbc58 feat(event): record date reschedules 2026-09-14 14:13:46 -03:00
18a0d14fa8 feat(event): add date reschedule history 2026-09-14 14:13:39 -03:00
b4a7b80430 test: cover event variant replacements 2026-09-14 14:11:24 -03:00
2bd3bf9dc1 feat(checkout): route carts to replacement variants 2026-09-14 14:09:49 -03:00
e42bc545a5 feat(event): replace variants when dates change 2026-09-14 14:09:42 -03:00
aa14129fe7 feat(catalog): track variant replacements 2026-09-14 14:07:55 -03:00
289ceba3af fix(tests): isolate databases in memory and reject persistent connections 2026-09-14 12:35:34 -03:00
28ab6c9ae4 fix(ticket): guard legacy refund amount removal 2026-09-14 12:31:03 -03:00
67deca095e refactor(ticket): use refunds as amount source 2026-09-14 12:29:13 -03:00
caed44fcc8 feat(ticket): persist and expose refund details 2026-09-14 11:57:31 -03:00
adec5d1725 feat(ticket): add refund transaction model 2026-09-14 11:57:06 -03:00
a62e989bb4 feat(ticket): enhance ticket name resolution to follow event date reschedules 2026-09-14 11:33:20 -03:00
114 changed files with 4794 additions and 161 deletions

View File

@@ -1,5 +1,11 @@
# Project Conventions # Project Conventions
## Test database safety
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
## Architecture ## Architecture
This project uses a domain-oriented structure under `app/Domains`. This project uses a domain-oriented structure under `app/Domains`.

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ScanAttempt; use App\Domains\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
@@ -53,6 +54,12 @@ class User extends Authenticatable
return $this->hasMany(ScanAttempt::class, 'scanner_user_id'); return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
} }
/** @return HasMany<EventDateChangeView, $this> */
public function eventDateChangeViews(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
/** /**
* @return BelongsTo<Role, $this> * @return BelongsTo<Role, $this>
*/ */

View File

@@ -32,6 +32,7 @@ class TenantBootstrapService
return $this->tenantInformationService->load( return $this->tenantInformationService->load(
$tenant, $tenant,
[ [
'eventDateChanges',
'menues' => fn ($query) => $query->whereHas( 'menues' => fn ($query) => $query->whereHas(
'roles', 'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value) fn ($query) => $query->where('codigo', RoleCode::User->value)

View File

@@ -400,6 +400,17 @@ class Cart extends Model
]); ]);
} }
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem; return $catalogItem;
} }
@@ -430,6 +441,12 @@ class Cart extends Model
throw new NotFoundHttpException('Variant not found for catalog item.'); throw new NotFoundHttpException('Variant not found for catalog item.');
} }
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate); $inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory); $variant->setRelation('inventory', $inventory);

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(
Tenant $tenant,
Collection $eventDateIds,
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
): void {
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
$reason,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -59,6 +59,8 @@ class Attribute extends Model
public function eventDates(): HasMany public function eventDates(): HasMany
{ {
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->orderBy('date') ->orderBy('date')
->orderBy('time_start'); ->orderBy('time_start');
} }

View File

@@ -179,11 +179,27 @@ class CatalogItem extends Model
{ {
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) ->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $selectionQuery): void {
$selectionQuery
->whereDoesntHave('variants')
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id'));
});
})
->orWhereHas( ->orWhereHas(
'variants.inventory', 'variants',
fn (Builder $inventoryQuery): Builder => $inventoryQuery fn (Builder $variantQuery): Builder => $variantQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') ->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
)
) )
->orWhere(function (Builder $directItemQuery): void { ->orWhere(function (Builder $directItemQuery): void {
$directItemQuery $directItemQuery
@@ -205,9 +221,12 @@ class CatalogItem extends Model
public function visibleVariants(?int $includedVariantId = null): Collection public function visibleVariants(?int $includedVariantId = null): Collection
{ {
return $this->variants return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId) ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
|| $this->inventory_policy === InventoryPolicy::Unlimited && (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->inventory?->availableStock() ?? 0) > 0) || ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
))))
->values(); ->values();
} }

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([ #[Fillable([
'sold_units', 'sold_units',
'refunded_units',
'reserved_stock', 'reserved_stock',
'real_stock', 'real_stock',
])] ])]
@@ -23,6 +24,7 @@ class Inventory extends Model
protected $attributes = [ protected $attributes = [
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0, 'reserved_stock' => 0,
'real_stock' => 0, 'real_stock' => 0,
]; ];
@@ -31,6 +33,7 @@ class Inventory extends Model
{ {
return [ return [
'sold_units' => 'integer', 'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer', 'reserved_stock' => 'integer',
'real_stock' => 'integer', 'real_stock' => 'integer',
]; ];
@@ -42,10 +45,10 @@ class Inventory extends Model
return $this->hasOne(CatalogItem::class); return $this->hasOne(CatalogItem::class);
} }
/** @return HasOne<Variant, $this> */ /** @return HasMany<Variant, $this> */
public function variant(): HasOne public function variants(): HasMany
{ {
return $this->hasOne(Variant::class); return $this->hasMany(Variant::class);
} }
/** @return HasMany<StockReservationLine, $this> */ /** @return HasMany<StockReservationLine, $this> */

View File

@@ -20,6 +20,8 @@ use Illuminate\Support\Str;
'catalog_item_id', 'catalog_item_id',
'event_date_id', 'event_date_id',
'inventory_id', 'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion', 'descripcion',
'precio', 'precio',
])] ])]
@@ -37,6 +39,8 @@ class Variant extends Model
'catalog_item_id' => 'integer', 'catalog_item_id' => 'integer',
'event_date_id' => 'integer', 'event_date_id' => 'integer',
'inventory_id' => 'integer', 'inventory_id' => 'integer',
'replaced_by_variant_id' => 'integer',
'sales_disabled_at' => 'datetime',
'precio' => 'decimal:2', 'precio' => 'decimal:2',
]; ];
} }
@@ -76,6 +80,39 @@ class Variant extends Model
return $this->belongsTo(Inventory::class); return $this->belongsTo(Inventory::class);
} }
/** @return BelongsTo<Variant, $this> */
public function replacement(): BelongsTo
{
return $this->belongsTo(self::class, 'replaced_by_variant_id');
}
/** @return HasMany<Variant, $this> */
public function replacedVariants(): HasMany
{
return $this->hasMany(self::class, 'replaced_by_variant_id');
}
public function isSellable(): bool
{
return $this->sales_disabled_at === null
&& $this->replaced_by_variant_id === null
&& $this->hasOnlyActiveEventDates();
}
public function hasOnlyActiveEventDates(): bool
{
if (! $this->exists
&& $this->event_date_id === null
&& ! $this->relationLoaded('eventDates')) {
return true;
}
return $this->selectedEventDates()->every(
fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
&& $eventDate->suspended_at === null,
);
}
/** @return HasMany<VariantDefinition, $this> */ /** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany public function definitions(): HasMany
{ {

View File

@@ -63,9 +63,14 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory'); $selection->loadMissing('variants.inventory');
return $selection->variants->sum( return $selection->variants
fn (Variant $variant): int => $variant->inventory->availableStock(), ->filter(fn (Variant $variant): bool => $variant->isSellable())
); ->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory)
: 'id:'.$variant->inventory_id)
->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
} }
$requirements = $this->inventoryRequirements($selection); $requirements = $this->inventoryRequirements($selection);

View File

@@ -207,7 +207,8 @@ class CatalogService
$visibleVariants = $catalogItem->visibleVariants(); $visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) { && (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
throw new NotFoundHttpException('Catalog item is out of stock.'); throw new NotFoundHttpException('Catalog item is out of stock.');
} }
@@ -324,9 +325,13 @@ class CatalogService
->findOrFail($variant->catalog_item_id); ->findOrFail($variant->catalog_item_id);
$variant->delete(); $variant->delete();
if (! $catalogItem->variants()->exists()) { $sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
$this->delete($catalogItem); $this->delete($catalogItem);
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) { } elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]); $catalogItem->update(['precio' => $minimumPrice]);
} }
@@ -399,7 +404,11 @@ class CatalogService
]); ]);
} }
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.variant_id" => [ "components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'), __('api.catalog.component_variant_invalid'),

View File

@@ -19,6 +19,10 @@ class StockReservationService
public const REASON_CART_CHANGED = 'cart_changed'; public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($source): void {
$query->where('event_date_id', $source->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $source->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
$destinationDateIds = $variant->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id === (int) $source->getKey()
? (int) $destination->getKey()
: (int) $id)
->unique()
->sort()
->values();
$replacement = $this->findEquivalent($variant, $destinationDateIds)
?? $this->cloneWithDates($variant, $destinationDateIds);
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
return $replacement;
})->values();
}
public function disableForSuspension(EventDate $eventDate): void
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void {
$query->where('event_date_id', $eventDate->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
foreach ($variants as $variant) {
$remainingDateIds = $variant->selectedEventDates()
->filter(fn (EventDate $date): bool => $date->suspended_at === null
&& $date->rescheduled_to_event_date_id === null)
->pluck('id')
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
if ($remainingDateIds->isEmpty()) {
$variant->update(['sales_disabled_at' => now()]);
continue;
}
$replacement = $this->findEquivalent($variant, $remainingDateIds);
if ($replacement === null) {
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
} else {
$this->mergeInventoryInto($variant, $replacement);
}
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
}
}
/** @param Collection<int, int> $eventDateIds */
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
{
$definitionSignature = $this->definitionSignature($source);
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
return Variant::query()
->where('catalog_item_id', $source->catalog_item_id)
->whereKeyNot($source->getKey())
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDates', 'eventDate', 'definitions'])
->orderBy('id')
->lockForUpdate()
->get()
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
&& $candidate->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->sort()
->values()
->all() === $dateSignature
);
}
/** @param Collection<int, int> $eventDateIds */
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
{
$replacementInventory = $this->cloneInventory($source);
$replacement = $source->replicate([
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
]);
$replacement->inventory_id = $replacementInventory->getKey();
$replacement->event_date_id = $eventDateIds->count() === 1
? $eventDateIds->first()
: null;
$replacement->save();
$replacement->eventDates()->sync($eventDateIds->all());
$replacement->definitions()->createMany(
$source->definitions
->map(fn ($definition): array => [
'item_attribute_id' => $definition->item_attribute_id,
'value' => $definition->value,
])
->all(),
);
$attachments = $source->allAttachments
->mapWithKeys(fn ($attachment): array => [
$attachment->getKey() => [
'orden' => $attachment->pivot->orden,
'is_enabled' => $attachment->pivot->is_enabled,
],
])
->all();
$replacement->allAttachments()->sync($attachments);
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
}
private function cloneInventory(Variant $source): Inventory
{
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
$sourceInventory = Inventory::query()
->whereKey($source->inventory_id)
->lockForUpdate()
->firstOrFail();
$reservedStock = (int) $activeLines->sum('quantity');
if ($sourceInventory->reserved_stock !== $reservedStock) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$replacementInventory = Inventory::query()->create([
'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock,
'real_stock' => $sourceInventory->real_stock,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]);
}
$sourceInventory->update(['reserved_stock' => 0]);
return $replacementInventory;
}
private function mergeInventoryInto(Variant $source, Variant $destination): void
{
if ($source->inventory_id === $destination->inventory_id) {
return;
}
$inventories = Inventory::query()
->whereKey([$source->inventory_id, $destination->inventory_id])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$sourceInventory = $inventories->get($source->inventory_id);
$destinationInventory = $inventories->get($destination->inventory_id);
if ($sourceInventory === null || $destinationInventory === null) {
throw new \LogicException('No se encontró el inventario de una variante.');
}
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]);
}
$sourceInventory->update([
'real_stock' => 0,
'reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
}
/** @return list<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}

View File

@@ -52,6 +52,7 @@ class EventController extends Controller
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$eventDate, $eventDate,
$request->validated(), $request->validated(),
$request->user(),
) )
); );
} }
@@ -62,6 +63,7 @@ class EventController extends Controller
$this->eventService->suspendDateForTenant( $this->eventService->suspendDateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$eventDate, $eventDate,
$request->user(),
) )
); );
} }

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -80,12 +81,29 @@ class EventDate extends Model
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id'); return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
} }
public function effectiveDate(): ?self
{
return app(EffectiveEventDateResolver::class)->resolve($this);
}
/** @return HasMany<EventDate, $this> */ /** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany public function rescheduledFrom(): HasMany
{ {
return $this->hasMany(self::class, 'rescheduled_to_event_date_id'); return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
} }
/** @return HasMany<EventDateChange, $this> */
public function changeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'source_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function destinationChangeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'destination_event_date_id');
}
/** @return HasMany<Variant, $this> */ /** @return HasMany<Variant, $this> */
public function variants(): HasMany public function variants(): HasMany
{ {
@@ -110,7 +128,11 @@ class EventDate extends Model
public function endsAt(): CarbonInterface public function endsAt(): CarbonInterface
{ {
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); $endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
return $endsAt->lessThanOrEqualTo($this->startsAt())
? $endsAt->addDay()
: $endsAt;
} }
public function getStatusAttribute(): EventDateStatus public function getStatusAttribute(): EventDateStatus
@@ -157,10 +179,6 @@ class EventDate extends Model
$startsAt = $this->startsAt(); $startsAt = $this->startsAt();
$expiresAt = $this->endsAt(); $expiresAt = $this->endsAt();
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
$expiresAt = $expiresAt->addDay();
}
$attributes = [ $attributes = [
'type' => ValidityTimeType::FixedWindow, 'type' => ValidityTimeType::FixedWindow,
'start_time' => null, 'start_time' => null,

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,9 @@ class EventDateResource extends JsonResource
'status' => $this->status->value, 'status' => $this->status->value,
'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id, 'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id,
'suspended_at' => $this->suspended_at?->toISOString(), 'suspended_at' => $this->suspended_at?->toISOString(),
'rescheduled_dates' => EventDateResource::collection(
$this->whenLoaded('adminRescheduledDates')
),
]; ];
} }
} }

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Event\Resources; namespace App\Domains\Event\Resources;
use App\Domains\Event\Services\EventDateGroupingService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -22,7 +23,9 @@ class EventResource extends JsonResource
'allow_ticket_total_refund' => $this->allow_ticket_total_refund, 'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'dates' => EventDateResource::collection($this->eventDates), 'dates' => EventDateResource::collection(
app(EventDateGroupingService::class)->group($this->eventDates)
),
'social_media' => $this->socialMedia->map(fn ($item): array => [ 'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code, 'code' => $item->code,
'url' => $item->pivot->url, 'url' => $item->pivot->url,

View File

@@ -7,6 +7,14 @@ use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver class EffectiveEventDateResolver
{ {
public function resolve(EventDate $eventDate): ?EventDate public function resolve(EventDate $eventDate): ?EventDate
{
$date = $this->resolveLatest($eventDate);
return $date !== null && $date->suspended_at === null ? $date : null;
}
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
public function resolveLatest(EventDate $eventDate): ?EventDate
{ {
$current = $eventDate; $current = $eventDate;
$visited = []; $visited = [];
@@ -23,7 +31,7 @@ class EffectiveEventDateResolver
$visited[$identity] = true; $visited[$identity] = true;
if ($current->rescheduled_to_event_date_id === null) { if ($current->rescheduled_to_event_date_id === null) {
return $current->suspended_at === null ? $current : null; return $current;
} }
$current->loadMissing('rescheduledTo'); $current->loadMissing('rescheduledTo');

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,21 @@ class EventDateTextFormatter
/** @param iterable<string> $dates */ /** @param iterable<string> $dates */
public function format(iterable $dates): ?string public function format(iterable $dates): ?string
{ {
return $this->formatWithOptions($dates, false, false);
}
/** @param iterable<string> $dates */
public function formatForSentence(iterable $dates): ?string
{
return $this->formatWithOptions($dates, true, true);
}
/** @param iterable<string> $dates */
private function formatWithOptions(
iterable $dates,
bool $padDays,
bool $includeYearPreposition,
): ?string {
$normalizedDates = collect($dates) $normalizedDates = collect($dates)
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date)) ->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d')) ->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
@@ -37,12 +52,14 @@ class EventDateTextFormatter
$years = $normalizedDates $years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y')) ->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year): string { ->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
$months = $yearDates $months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n')) ->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month): string { ->map(function ($monthDates, string $month) use ($padDays): string {
$days = $monthDates $days = $monthDates
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j'))) ->map(fn (DateTimeImmutable $date): string => $padDays
? $date->format('d')
: (string) ((int) $date->format('j')))
->values() ->values()
->all(); ->all();
@@ -51,7 +68,7 @@ class EventDateTextFormatter
->values() ->values()
->all(); ->all();
return $this->join($months).' '.$year; return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
}) })
->values() ->values()
->all(); ->all();

View File

@@ -2,10 +2,16 @@
namespace App\Domains\Event\Services; namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -23,6 +29,8 @@ class EventService
public function __construct( public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver, private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService,
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {} ) {}
public function forTenant(Tenant $tenant): Tenant public function forTenant(Tenant $tenant): Tenant
@@ -73,9 +81,13 @@ class EventService
} }
/** @param array{date: string} $data */ /** @param array{date: string} $data */
public function rescheduleDateForTenant(Tenant $tenant, EventDate $eventDate, array $data): EventDate public function rescheduleDateForTenant(
{ Tenant $tenant,
return DB::transaction(function () use ($tenant, $eventDate, $data): EventDate { EventDate $eventDate,
array $data,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate); $source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) { if ($source->suspended_at !== null) {
@@ -109,17 +121,31 @@ class EventService
]); ]);
} }
if ($this->effectiveEventDateResolver->resolve($destination) === null) { $effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
if ($effectiveDestination === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'date' => ['La fecha de destino no es utilizable.'], 'date' => ['La fecha de destino no es utilizable.'],
]); ]);
} }
$affectedDateIds = $this->affectedDateIds($tenant, $source);
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
$purchaseTickets = $this->affectedPurchaseResolver->resolve( $purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant, $tenant,
$this->affectedDateIds($tenant, $source), $affectedDateIds,
); );
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Rescheduled,
'source_event_date_id' => $source->getKey(),
'destination_event_date_id' => $destination->getKey(),
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $source->date->format('Y-m-d'),
'new_date' => $destination->date->format('Y-m-d'),
]);
EventDateRescheduled::dispatch( EventDateRescheduled::dispatch(
$tenant->codigo, $tenant->codigo,
@@ -134,9 +160,12 @@ class EventService
}); });
} }
public function suspendDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate public function suspendDateForTenant(
{ Tenant $tenant,
return DB::transaction(function () use ($tenant, $eventDate): EventDate { EventDate $eventDate,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate); $date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) { if ($date->rescheduled_to_event_date_id !== null) {
@@ -149,13 +178,27 @@ class EventService
return $date->load('validityTime'); return $date->load('validityTime');
} }
$purchaseTickets = $this->affectedPurchaseResolver->resolve( $affectedDateIds = $this->affectedDateIds($tenant, $date);
$this->invalidateEventDateCarts->invalidate(
$tenant, $tenant,
$this->affectedDateIds($tenant, $date), $affectedDateIds,
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
); );
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
$date->update(['suspended_at' => now()]); $date->update(['suspended_at' => now()]);
$this->variantReplacementService->disableForSuspension($date);
$this->disableTicketsWithoutUsableDates($tenant, $date); $this->disableTicketsWithoutUsableDates($tenant, $date);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Suspended,
'source_event_date_id' => $date->getKey(),
'destination_event_date_id' => null,
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $date->date->format('Y-m-d'),
'new_date' => null,
]);
EventDateSuspended::dispatch( EventDateSuspended::dispatch(
$tenant->codigo, $tenant->codigo,
$date->getKey(), $date->getKey(),

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Domains\FiestaFutbolInfantil\Controllers; namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource; use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
use App\Domains\FiestaFutbolInfantil\Services\FoodService; use App\Domains\FiestaFutbolInfantil\Services\FoodService;
@@ -32,6 +33,16 @@ class FoodController extends Controller
); );
} }
public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource
{
return FoodResource::make(
$this->foodService->updateHistoricalStock(
$request->user()->tenant()->firstOrFail(),
$request->validated('variants'),
)
);
}
public function destroy(Request $request, int $food): Response public function destroy(Request $request, int $food): Response
{ {
$this->foodService->delete( $this->foodService->delete(

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Validation\ValidationException;
/** @mixin CatalogItem */ /** @mixin CatalogItem */
class EntryResource extends JsonResource class EntryResource extends JsonResource
@@ -12,7 +13,20 @@ class EntryResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$variant = $this->variants->sole(); $variants = $this->variants->whereNull('replaced_by_variant_id');
if ($variants->count() !== 1) {
throw ValidationException::withMessages([
'entries' => [sprintf(
'La entrada %s tiene %d variantes sin reemplazar (IDs: %s). Se esperaba una.',
$this->id,
$variants->count(),
$variants->pluck('id')->implode(', '),
)],
]);
}
$variant = $variants->first();
return [ return [
'id' => $this->id, 'id' => $this->id,

View File

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

View File

@@ -24,7 +24,13 @@ class EntryService
return CatalogItem::query() return CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->whereHas('variants', fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'))
->with([ ->with([
'variants' => fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'),
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate',
'variants.eventDates', 'variants.eventDates',
@@ -97,6 +103,7 @@ class EntryService
$variants = Variant::query() $variants = Variant::query()
->where('catalog_item_id', $catalogItem->id) ->where('catalog_item_id', $catalogItem->id)
->whereNull('replaced_by_variant_id')
->lockForUpdate() ->lockForUpdate()
->get(); ->get();

View File

@@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -36,8 +38,10 @@ class FoodService
->with([ ->with([
'variants.catalogItem', 'variants.catalogItem',
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate.rescheduledTo',
'variants.eventDates', 'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.definitions.itemAttribute.attribute', 'variants.definitions.itemAttribute.attribute',
]) ])
->first(); ->first();
@@ -55,11 +59,16 @@ class FoodService
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]); $food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
$existingVariants = $food->variants() $existingVariants = $food->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
->lockForUpdate() ->lockForUpdate()
->get(); ->get()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->values();
$resolvedVariants = $this->resolveVariants($variants, $attributes); $resolvedVariants = $this->resolveVariants($variants, $attributes);
$this->validateCurrentEventDates($tenant, $resolvedVariants);
$this->validateCombinations($resolvedVariants, $existingVariants); $this->validateCombinations($resolvedVariants, $existingVariants);
foreach ($resolvedVariants as $index => $data) { foreach ($resolvedVariants as $index => $data) {
@@ -80,7 +89,13 @@ class FoodService
} }
} }
$minimumPrice = $food->variants()->min('precio'); $minimumPrice = $food->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDate', 'eventDates'])
->get()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->min('precio');
if ($minimumPrice !== null) { if ($minimumPrice !== null) {
$food->update(['precio' => $minimumPrice]); $food->update(['precio' => $minimumPrice]);
} }
@@ -88,22 +103,103 @@ class FoodService
return $food->fresh()->load([ return $food->fresh()->load([
'variants.catalogItem', 'variants.catalogItem',
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate.rescheduledTo',
'variants.eventDates', 'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.definitions.itemAttribute.attribute', 'variants.definitions.itemAttribute.attribute',
]); ]);
}); });
} }
/**
* @param array<int, array{id: int, stock: int}> $variants
*/
public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$food = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->lockForUpdate()
->firstOrFail();
$variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id);
$historicalVariants = $food->variants()
->whereIn('id', $variantIds)
->with(['inventory', 'eventDate', 'eventDates'])
->lockForUpdate()
->get()
->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant))
->keyBy('id');
foreach ($variants as $index => $data) {
$variant = $historicalVariants->get((int) $data['id']);
if ($variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => [
'La variante no pertenece al historial de Comida.',
],
]);
}
$stock = (int) $data['stock'];
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
if ($stock < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$inventory->update(['real_stock' => $stock]);
}
return $this->current($tenant) ?? $food;
});
}
private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory
{
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
$variantsSharingInventory = Variant::query()
->where('inventory_id', $inventory->getKey())
->orderBy('id')
->lockForUpdate()
->get(['id']);
if ($variantsSharingInventory->count() === 1) {
return $inventory;
}
$historicalInventory = Inventory::query()->create([
'sold_units' => $inventory->sold_units,
'refunded_units' => $inventory->refunded_units,
'reserved_stock' => 0,
'real_stock' => $inventory->real_stock,
]);
$variant->update(['inventory_id' => $historicalInventory->getKey()]);
return $historicalInventory;
}
public function delete(Tenant $tenant, int $foodId): void public function delete(Tenant $tenant, int $foodId): void
{ {
$variant = Variant::query() $variant = Variant::query()
->whereKey($foodId) ->whereKey($foodId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDate', 'eventDates'])
->whereHas('catalogItem', fn ($query) => $query ->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')) ->where('slug', 'comida'))
->firstOrFail(); ->firstOrFail();
abort_unless($this->hasCurrentDate($variant), 404);
$this->catalogService->deleteVariant($variant); $this->catalogService->deleteVariant($variant);
} }
@@ -225,6 +321,30 @@ class FoodService
return $option; return $option;
} }
/** @param array<int, array<string, mixed>> $variants */
private function validateCurrentEventDates(Tenant $tenant, array $variants): void
{
$eventDates = EventDate::query()
->where('tenant_code', $tenant->codigo)
->whereIn('id', collect($variants)->pluck('event_date_id')->unique())
->get()
->keyBy('id');
foreach ($variants as $index => $variant) {
$eventDate = $eventDates->get($variant['event_date_id']);
if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) {
continue;
}
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'La fecha seleccionada ya no está disponible.',
],
]);
}
}
/** /**
* @param array<int, array<string, mixed>> $incoming * @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing * @param Collection<int, Variant> $existing
@@ -329,4 +449,25 @@ class FoodService
mb_strtolower(trim($service)), mb_strtolower(trim($service)),
]); ]);
} }
private function hasCurrentDate(Variant $variant): bool
{
$status = $variant->selectedEventDates()->first()?->status;
return $status === null || $this->isCurrentStatus($status);
}
private function hasHistoricalDate(Variant $variant): bool
{
return in_array(
$variant->selectedEventDates()->first()?->status,
[EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed],
true,
);
}
private function isCurrentStatus(EventDateStatus $status): bool
{
return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true);
}
} }

View File

@@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant')
Route::post('foods', [FoodController::class, 'store']) Route::post('foods', [FoodController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.store'); ->name('adminapp.fiesta-futbol-infantil.foods.store');
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
Route::delete('foods/{food}', [FoodController::class, 'destroy']) Route::delete('foods/{food}', [FoodController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.destroy'); ->name('adminapp.fiesta-futbol-infantil.foods.destroy');

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EntryFormResource;
use App\Domains\Forms\Services\EntryFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EntryFormController extends Controller
{
public function __construct(protected EntryFormService $entryFormService) {}
public function __invoke(Request $request): EntryFormResource
{
return EntryFormResource::make(
$this->entryFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EntryFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
]
)->values(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class EntryFormService
{
/** @return array{event_dates: Collection<int, EventDate>} */
public function get(Tenant $tenant): array
{
return [
'event_dates' => $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->with('validityTime')
->get(),
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -27,7 +28,17 @@ class FoodFormService
->keyBy('codigo'); ->keyBy('codigo');
return [ return [
'event_dates' => $tenant->eventDates()->with('validityTime')->get(), 'event_dates' => $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->with('validityTime')
->get()
->filter(fn (EventDate $eventDate): bool => in_array(
$eventDate->status,
[EventDateStatus::Scheduled, EventDateStatus::InProgress],
true,
))
->values(),
'schedules' => $attributes->get('horario')?->options ?? new Collection, 'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection,
]; ];

View File

@@ -124,7 +124,10 @@ class TicketFilterFormService
'required' => false, 'required' => false,
'default' => null, 'default' => null,
'placeholder' => 'Estado', 'placeholder' => 'Estado',
'options' => Ticket::statusOptions(), 'options' => array_values(array_filter(
Ticket::statusOptions(),
fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED,
)),
], ],
]; ];
} }

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Domains\Forms\Controllers\AdminApp\EntryFormController;
use App\Domains\Forms\Controllers\AdminApp\EventFormController; use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\FoodFormController; use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController; use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
@@ -22,6 +23,10 @@ Route::prefix('v1/adminapp/forms')
'fiesta-futbol-infantil/ticket', 'fiesta-futbol-infantil/ticket',
TicketFormController::class TicketFormController::class
); );
Route::get(
'fiesta-futbol-infantil/entry',
EntryFormController::class
);
Route::get( Route::get(
'fiesta-futbol-infantil/merchandise', 'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class MerchandiseFormController::class

View File

@@ -297,7 +297,7 @@ class NotificationMailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Tu evento fue reprogramado - Compra #{$purchase->getKey()}", "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-rescheduled', compact( view('mail.notifications.event-date-rescheduled', compact(
'purchase', 'previousDate', 'newDate', 'tickets' 'purchase', 'previousDate', 'newDate', 'tickets'
))->render(), ))->render(),
@@ -398,7 +398,7 @@ class NotificationMailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Una fecha de tu evento fue suspendida - Compra #{$purchase->getKey()}", "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-suspended', compact( view('mail.notifications.event-date-suspended', compact(
'purchase', 'date', 'disabledTickets', 'activeTickets' 'purchase', 'date', 'disabledTickets', 'activeTickets'
))->render(), ))->render(),

View File

@@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -27,7 +28,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'discount_total', 'discount_total',
'tax_total', 'tax_total',
'total', 'total',
'refunded_amount',
])] ])]
class PurchaseItem extends Model class PurchaseItem extends Model
{ {
@@ -48,7 +48,6 @@ class PurchaseItem extends Model
'discount_total' => 'decimal:2', 'discount_total' => 'decimal:2',
'tax_total' => 'decimal:2', 'tax_total' => 'decimal:2',
'total' => 'decimal:2', 'total' => 'decimal:2',
'refunded_amount' => 'decimal:2',
]; ];
} }
@@ -66,6 +65,12 @@ class PurchaseItem extends Model
return $this->hasMany(Ticket::class, 'source_purchase_item_id'); return $this->hasMany(Ticket::class, 'source_purchase_item_id');
} }
/** @return HasMany<TicketRefund, $this> */
public function ticketRefunds(): HasMany
{
return $this->hasMany(TicketRefund::class);
}
/** @return BelongsTo<Attachment, $this> */ /** @return BelongsTo<Attachment, $this> */
public function imageAttachment(): BelongsTo public function imageAttachment(): BelongsTo
{ {

View File

@@ -25,7 +25,6 @@ class PurchaseItemResource extends JsonResource
'quantity' => (int) $this->cantidad, 'quantity' => (int) $this->cantidad,
'unit_price' => $this->formatMoney($this->precio_unitario), 'unit_price' => $this->formatMoney($this->precio_unitario),
'line_total' => $this->formatMoney($this->total), 'line_total' => $this->formatMoney($this->total),
'refunded_amount' => $this->formatMoney($this->refunded_amount),
'source_catalog_item_id' => $this->source_catalog_item_id, 'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id, 'source_variant_id' => $this->source_variant_id,
'item_details' => [ 'item_details' => [

View File

@@ -41,6 +41,17 @@ class CatalogSelectionResolver
]); ]);
} }
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem; return $catalogItem;
} }
@@ -70,6 +81,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Variant not found for catalog item.'); throw new NotFoundHttpException('Variant not found for catalog item.');
} }
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')],
]);
}
$variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation( $variant->setRelation(
'inventory', 'inventory',

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Purchase\Services\Checkout; namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
@@ -91,7 +92,16 @@ class ReleaseCheckoutService
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
], true)) { ], true)) {
$this->reservations->returnToCart($purchase, $cart); if ($this->hasUnavailableVariants($cart)) {
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$cart->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
} else {
$this->reservations->returnToCart($purchase, $cart);
}
$purchase->update(['status' => Purchase::STATUS_CANCELLED]); $purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
@@ -115,6 +125,17 @@ class ReleaseCheckoutService
}); });
} }
private function hasUnavailableVariants(Cart $cart): bool
{
return $cart->items()
->whereNotNull('variant_id')
->with(['variant.eventDate', 'variant.eventDates'])
->lockForUpdate()
->get()
->contains(fn (CartItem $item): bool => $item->variant === null
|| ! $item->variant->isSellable());
}
private function releasePurchaseReservations( private function releasePurchaseReservations(
Purchase $purchase, Purchase $purchase,
string $targetStatus, string $targetStatus,

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Services\CartVariantReplacementService;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
@@ -29,6 +30,7 @@ class StartCheckoutService
private readonly InsufficientStockMessageBuilder $stockMessages, private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses, private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly CartVariantReplacementService $variantReplacements,
) {} ) {}
/** @param array<string, mixed> $purchaseData */ /** @param array<string, mixed> $purchaseData */
@@ -250,6 +252,7 @@ class StartCheckoutService
int $cartId, int $cartId,
): Purchase { ): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId); $cart = $this->resolveCart($tenant, $userId, $cartId);
$this->variantReplacements->replaceHistoricalVariants($cart);
$cartItems = $cart->items()->lockForUpdate()->get(); $cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) { if ($cartItems->isEmpty()) {

View File

@@ -2,20 +2,20 @@
namespace App\Domains\Purchase\Services; namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService class PurchaseRefundSummaryService
{ {
public function totalForTenant(Tenant $tenant): string public function totalForTenant(Tenant $tenant): string
{ {
$total = PurchaseItem::query() $total = TicketRefund::query()
->whereHas( ->whereHas(
'purchase', 'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
) )
->sum('refunded_amount'); ->sum('amount');
return number_format((float) $total, 2, '.', ''); return number_format((float) $total, 2, '.', '');
} }

View File

@@ -65,9 +65,10 @@ class TenantTransactionResetService
DB::table('inventories') DB::table('inventories')
->whereIn('id', $scope['inventory_ids']) ->whereIn('id', $scope['inventory_ids'])
->update([ ->update([
'real_stock' => DB::raw('real_stock + sold_units'), 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
'reserved_stock' => 0, 'reserved_stock' => 0,
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0,
]); ]);
return $summary; return $summary;

View File

@@ -22,7 +22,6 @@ class SaleDetailResource extends JsonResource
'quantity' => (int) $item->cantidad, 'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($item->precio_unitario), 'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total), 'total' => $this->formatMoney($item->total),
'refunded_amount' => $this->formatMoney($item->refunded_amount),
])->values(), ])->values(),
'total' => $this->formatMoney($this->total), 'total' => $this->formatMoney($this->total),
]; ];

View File

@@ -67,7 +67,7 @@ class AdminAppSaleService
{ {
return $this->findForTenant($tenant, $saleId) return $this->findForTenant($tenant, $saleId)
->tickets() ->tickets()
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund'])
->orderBy('id') ->orderBy('id')
->get(); ->get();
} }

View File

@@ -9,6 +9,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Client\Models\Client; use App\Domains\Client\Models\Client;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu; use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Enums\CartEditingPolicy; use App\Domains\Tenant\Enums\CartEditingPolicy;
@@ -229,6 +230,14 @@ class Tenant extends Model
->orderBy('time_start'); ->orderBy('time_start');
} }
/** @return HasMany<EventDateChange, $this> */
public function eventDateChanges(): HasMany
{
return $this->hasMany(EventDateChange::class, 'tenant_code', 'codigo')
->orderBy('created_at')
->orderBy('id');
}
/** /**
* @return HasMany<Category, $this> * @return HasMany<Category, $this>
*/ */

View File

@@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EventDateInfoFormatter;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -23,6 +24,10 @@ class TenantResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$eventDateChanges = $this->relationLoaded('eventDateChanges')
? $this->eventDateChanges
: collect();
return [ return [
'id' => $this->id, 'id' => $this->id,
'client_id' => $this->client_id, 'client_id' => $this->client_id,
@@ -52,14 +57,17 @@ class TenantResource extends JsonResource
'title' => $this->event_title, 'title' => $this->event_title,
'location' => $this->event_location, 'location' => $this->event_location,
'dates' => $this->eventDates 'dates' => $this->eventDates
->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null ->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null)
&& $eventDate->suspended_at === null
)
->map(fn (EventDate $eventDate): array => [ ->map(fn (EventDate $eventDate): array => [
'id' => $eventDate->id, 'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'), 'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start, 'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end, 'time_end' => $eventDate->time_end,
'info_text' => app(EventDateInfoFormatter::class)->format(
$eventDate,
$eventDateChanges,
),
'isCanceled' => $eventDate->suspended_at !== null,
])->values(), ])->values(),
]), ]),
'extras' => $this->whenLoaded( 'extras' => $this->whenLoaded(

View File

@@ -54,7 +54,12 @@ class TicketController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource( return new AdminAppTicketResource(
$this->ticketService->refund($tenant, $ticket, $request->validated('refund_type')) $this->ticketService->refund(
$tenant,
$ticket,
$request->validated('refund_type'),
$request->user(),
)
); );
} }

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -219,6 +220,12 @@ class Ticket extends Model
return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id'); return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id');
} }
/** @return HasOne<TicketRefund, $this> */
public function refund(): HasOne
{
return $this->hasOne(TicketRefund::class);
}
/** @return BelongsTo<CatalogItem, $this> */ /** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo public function sourceCatalogItem(): BelongsTo
{ {
@@ -284,6 +291,14 @@ class Ticket extends Model
public function getStatusLabelAttribute(): string public function getStatusLabelAttribute(): string
{ {
if ($this->status === self::STATUS_REFUNDED && $this->relationLoaded('refund')) {
$refund = $this->getRelation('refund');
if ($refund instanceof TicketRefund) {
return $refund->typeLabel();
}
}
return self::statusLabel($this->status); return self::statusLabel($this->status);
} }

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticket\Requests; namespace App\Domains\Ticket\Requests;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -16,7 +17,7 @@ class AdminAppTicketRefundRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])], 'refund_type' => ['required', 'string', Rule::in(TicketRefund::types())],
]; ];
} }
} }

View File

@@ -23,6 +23,13 @@ class AdminAppTicketResource extends TicketResource
'is_active' => $this->resource->is_active(), 'is_active' => $this->resource->is_active(),
'can_cancel' => $this->resource->can_cancel(), 'can_cancel' => $this->resource->can_cancel(),
'can_refund' => $this->resource->can_refund(), 'can_refund' => $this->resource->can_refund(),
'refund' => $this->resource->refund === null ? null : [
'type' => $this->resource->refund->type,
'type_label' => $this->resource->refund->typeLabel(),
'amount' => $this->resource->refund->amount,
'created_at' => $this->resource->refund->created_at,
'created_by' => $this->resource->refund->createdBy?->nombre_apellido,
],
'values' => $rowService->values($this->resource, $details), 'values' => $rowService->values($this->resource, $details),
]; ];
} }

View File

@@ -18,6 +18,12 @@ class TicketResource extends JsonResource
'ticket' => $this->ticket, 'ticket' => $this->ticket,
'status' => $this->status, 'status' => $this->status,
'status_label' => $this->status_label, 'status_label' => $this->status_label,
'refund' => $this->whenLoaded('refund', fn (): ?array => $this->refund === null ? null : [
'type' => $this->refund->type,
'type_label' => $this->refund->typeLabel(),
'amount' => $this->refund->amount,
'created_at' => $this->refund->created_at,
]),
'name' => $this->name, 'name' => $this->name,
'description' => $this->description, 'description' => $this->description,
'client' => $this->user?->nombre_apellido, 'client' => $this->user?->nombre_apellido,

View File

@@ -41,7 +41,9 @@ class AdminAppTicketExcelService
$row = $index + 2; $row = $index + 2;
foreach ($columns as $columnIndex => $column) { foreach ($columns as $columnIndex => $column) {
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row; $coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row;
$value = $ticket[$column['key']] ?? null; $value = $column['type'] === 'status'
? ($ticket['status_label'] ?? $ticket[$column['key']] ?? null)
: ($ticket[$column['key']] ?? null);
if ($column['type'] === 'currency' && $value !== null) { if ($column['type'] === 'currency' && $value !== null) {
$sheet->setCellValue($coordinate, (float) $value); $sheet->setCellValue($coordinate, (float) $value);

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -11,18 +12,19 @@ class AdminAppTicketRowService
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const CATEGORY_PRESENTATIONS = [ private const CATEGORY_PRESENTATIONS = [
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null], 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'size' => null],
'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'], 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'size' => 'talle'],
]; ];
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function details(Ticket $ticket): array public function details(Ticket $ticket): array
{ {
$purchaseItem = $ticket->sourcePurchaseItem; $purchaseItem = $ticket->sourcePurchaseItem;
$refund = $ticket->refund;
return [ return [
'source_purchase_item_id' => $ticket->source_purchase_item_id, 'source_purchase_item_id' => $ticket->source_purchase_item_id,
@@ -31,7 +33,8 @@ class AdminAppTicketRowService
?? $ticket->sourceCatalogItem?->nombre ?? $ticket->sourceCatalogItem?->nombre
?? $ticket->name, ?? $ticket->name,
'amount' => $purchaseItem?->precio_unitario, 'amount' => $purchaseItem?->precio_unitario,
'refunded_amount' => $purchaseItem?->refunded_amount, 'refund_type' => $refund?->type,
'refund_type_label' => $refund?->typeLabel(),
'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,
'status' => $ticket->status, 'status' => $ticket->status,
'scanned_by' => $ticket->scannerUser?->nombre_apellido, 'scanned_by' => $ticket->scannerUser?->nombre_apellido,
@@ -57,6 +60,7 @@ class AdminAppTicketRowService
'client' => $details['client'] ?? 'Sin nombre', 'client' => $details['client'] ?? 'Sin nombre',
'id' => $ticket->id, 'id' => $ticket->id,
'status' => $details['status'], 'status' => $details['status'],
'status_label' => $ticket->status_label,
'scanned_by' => $details['scanned_by'] ?? '-', 'scanned_by' => $details['scanned_by'] ?? '-',
]; ];
} }
@@ -80,7 +84,9 @@ class AdminAppTicketRowService
return $rows->map(fn (array $row): array => collect($columns) return $rows->map(fn (array $row): array => collect($columns)
->mapWithKeys(fn (array $column): array => [ ->mapWithKeys(fn (array $column): array => [
$column['key'] => $this->displayValue( $column['key'] => $this->displayValue(
$row[$column['key']] ?? null, $column['type'] === 'status'
? ($row['status_label'] ?? $row[$column['key']] ?? null)
: ($row[$column['key']] ?? null),
$column['type'], $column['type'],
$timeZone, $timeZone,
), ),
@@ -106,13 +112,14 @@ class AdminAppTicketRowService
private function presentation(Ticket $ticket, array $details): array private function presentation(Ticket $ticket, array $details): array
{ {
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-'; $sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
$effectiveDates = $this->effectiveEventDateLabels($ticket) ?: '-';
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) { if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
return [ return [
'category' => $sourceCategory, 'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-', 'type' => $this->allPropertyLabels($details) ?: '-',
'date' => '-', 'date' => $effectiveDates,
'size' => '-', 'size' => '-',
]; ];
} }
@@ -123,7 +130,7 @@ class AdminAppTicketRowService
'category' => $sourceCategory, 'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-', 'type' => $this->allPropertyLabels($details) ?: '-',
'date' => '-', 'date' => $effectiveDates,
'size' => '-', 'size' => '-',
]; ];
} }
@@ -136,29 +143,30 @@ class AdminAppTicketRowService
'type' => $configuration['type'] === null 'type' => $configuration['type'] === null
? '-' ? '-'
: ($this->propertyLabels($details, $configuration['type']) ?: '-'), : ($this->propertyLabels($details, $configuration['type']) ?: '-'),
'date' => $configuration['date'] === null 'date' => $effectiveDates,
? '-'
: ($this->propertyLabels($details, $configuration['date']) ?: '-'),
'size' => $configuration['size'] === null 'size' => $configuration['size'] === null
? '-' ? '-'
: ($this->propertyLabels($details, $configuration['size']) ?: '-'), : ($this->propertyLabels($details, $configuration['size']) ?: '-'),
]; ];
} }
private function effectiveEventDateLabels(Ticket $ticket): string
{
return $ticket->sourceVariant?->selectedEventDates()
->map(fn (EventDate $date): ?EventDate => $date->effectiveDate())
->filter()
->unique(fn (EventDate $date): int => $date->getKey())
->sortBy(fn (EventDate $date): string => $date->date->format('Y-m-d'))
->map(fn (EventDate $date): string => $date->date->format('d/m'))
->implode(', ') ?? '';
}
/** @param array<string, mixed> $details */ /** @param array<string, mixed> $details */
private function propertyLabels(array $details, string $code): string private function propertyLabels(array $details, string $code): string
{ {
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code); $property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
$labels = collect($property['values'] ?? [])->pluck('label')->filter(); $labels = collect($property['values'] ?? [])->pluck('label')->filter();
if ($code === 'event_date') {
$labels = $labels->map(function (string $label): string {
[$day, $month] = array_pad(explode('/', $label), 2, null);
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
});
}
return $labels->implode(', '); return $labels->implode(', ');
} }

View File

@@ -3,10 +3,14 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Purchase\Services\PurchaseRefundSummaryService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -23,6 +27,7 @@ class AdminAppTicketService
'scannerUser', 'scannerUser',
'sourceCatalogItem.category', 'sourceCatalogItem.category',
'sourcePurchaseItem.purchase', 'sourcePurchaseItem.purchase',
'refund.createdBy',
]; ];
public function __construct( public function __construct(
@@ -140,7 +145,7 @@ class AdminAppTicketService
$unitPrice = (float) $purchaseItem->precio_unitario; $unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total; $itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = (float) ($purchaseItem->refunded_amount ?? 0); $itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null; $total = null;
@@ -162,11 +167,15 @@ class AdminAppTicketService
]; ];
} }
public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket public function refund(
{ Tenant $tenant,
int $ticketId,
string $refundType,
?User $createdBy = null,
): Ticket {
$this->ensureRefundIsAllowed($tenant, $refundType); $this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType): Ticket { return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket {
$ticket = Ticket::query() $ticket = Ticket::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->lockForUpdate() ->lockForUpdate()
@@ -195,7 +204,10 @@ class AdminAppTicketService
} }
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType); $refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType);
$refundedAmount = round((float) $purchaseItem->refunded_amount + $refundAmount, 2); $refundedAmount = round(
$this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount,
2,
);
if ($refundedAmount > (float) $purchaseItem->total) { if ($refundedAmount > (float) $purchaseItem->total) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -206,19 +218,77 @@ class AdminAppTicketService
$ticket->markAsRefunded(); $ticket->markAsRefunded();
$ticket->save(); $ticket->save();
$purchaseItem->update([ TicketRefund::query()->create([
'refunded_amount' => number_format($refundedAmount, 2, '.', ''), 'ticket_id' => $ticket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $createdBy?->id,
'type' => $refundType,
'amount' => number_format($refundAmount, 2, '.', ''),
]); ]);
$this->restoreInventory($ticket, $purchaseItem);
return $ticket->refresh()->load(self::RELATIONS); return $ticket->refresh()->load(self::RELATIONS);
}); });
} }
private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void
{
$catalogItem = $ticket->sourceCatalogItem;
if ($catalogItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un producto con inventario reponible.',
]);
}
// Bundle components need a per-ticket allocation before they can be restored.
if ($catalogItem->isBundle() || $purchaseItem->sourceCatalogItem?->isBundle()) {
return;
}
$inventoryId = $catalogItem->inventory_id;
if ($ticket->source_variant_id !== null) {
$variant = Variant::withTrashed()->find($ticket->source_variant_id);
if ($variant === null) {
throw ValidationException::withMessages(['ticket' => 'No se encontró la variante del ticket.']);
}
// A replacement can move the sellable inventory to a newer variant.
$visited = [];
while ($variant->replaced_by_variant_id !== null) {
if (isset($visited[$variant->id])) {
throw new \LogicException('La cadena de reemplazos de variantes es circular.');
}
$visited[$variant->id] = true;
$variant = Variant::withTrashed()->findOrFail($variant->replaced_by_variant_id);
}
$inventoryId = $variant->inventory_id;
}
$inventory = Inventory::query()->lockForUpdate()->find($inventoryId);
if ($inventory === null) {
throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']);
}
if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) {
$inventory->real_stock++;
}
$inventory->refunded_units++;
$inventory->save();
}
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
{
return round((float) TicketRefund::query()
->where('purchase_item_id', $purchaseItem->id)
->sum('amount'), 2);
}
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void
{ {
$isAllowed = match ($refundType) { $isAllowed = match ($refundType) {
'partial' => $tenant->allow_refund() && $tenant->allow_partial_refund(), TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(),
'total' => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, TicketRefund::TYPE_TOTAL => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund,
}; };
if (! $isAllowed) { if (! $isAllowed) {
@@ -233,8 +303,8 @@ class AdminAppTicketService
$ticketAmount = (float) $purchaseItem->precio_unitario; $ticketAmount = (float) $purchaseItem->precio_unitario;
return match ($refundType) { return match ($refundType) {
'partial' => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), TicketRefund::TYPE_PARTIAL => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2),
'total' => $ticketAmount, TicketRefund::TYPE_TOTAL => $ticketAmount,
}; };
} }

View File

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

View File

@@ -2,10 +2,14 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
class TicketPresentationResolver class TicketPresentationResolver
{ {
public function __construct(private readonly EffectiveEventDateResolver $effectiveEventDateResolver) {}
/** Relaciones necesarias para calcular nombre y descripción sin consultas N+1. */ /** Relaciones necesarias para calcular nombre y descripción sin consultas N+1. */
public const RELATIONS = [ public const RELATIONS = [
'sourceCatalogItem', 'sourceCatalogItem',
@@ -30,9 +34,15 @@ class TicketPresentationResolver
} }
$itemAttributes = $variant->catalogItem->itemAttributes; $itemAttributes = $variant->catalogItem->itemAttributes;
$eventDateLabels = $variant->selectedEventDates()
->map(fn (EventDate $date): EventDate => $this->effectiveEventDateResolver->resolveLatest($date) ?? $date)
->unique(fn (EventDate $date): int => $date->getKey())
->map(fn (EventDate $date): string => $date->date->format('d/m/Y'))
->implode(', ');
$properties = $variant->selectionOptions($itemAttributes) $properties = $variant->selectionOptions($itemAttributes)
->map(function (array $option, string $attributeCode) use ($itemAttributes): ?string { ->map(function (array $option, string $attributeCode) use ($itemAttributes, $eventDateLabels): ?string {
$labels = collect(array_is_list($option) ? $option : [$option]) $labels = $attributeCode === 'event_date' ? $eventDateLabels : collect(array_is_list($option) ? $option : [$option])
->pluck('label') ->pluck('label')
->filter(fn ($label): bool => is_string($label) && $label !== '') ->filter(fn ($label): bool => is_string($label) && $label !== '')
->implode(', '); ->implode(', ');

View File

@@ -52,8 +52,7 @@
"npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others"
], ],
"test": [ "test": [
"@php artisan config:clear --ansi @no_additional_args", "@php vendor/phpunit/phpunit/phpunit"
"@php artisan test"
], ],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",

View File

@@ -0,0 +1,28 @@
<?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::create('ticket_refunds', function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_id')->unique()->constrained('tickets')->cascadeOnDelete();
$table->foreignId('purchase_item_id')->constrained('compra_items')->restrictOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('type', 16);
$table->decimal('amount', 10, 2);
$table->timestamps();
$table->index(['purchase_item_id', 'type']);
});
}
public function down(): void
{
Schema::dropIfExists('ticket_refunds');
}
};

View File

@@ -0,0 +1,34 @@
<?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::create('event_date_reschedules', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->date('previous_date');
$table->date('new_date');
$table->timestamp('created_at')->useCurrent();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->index(['tenant_code', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('event_date_reschedules');
}
};

View File

@@ -0,0 +1,86 @@
<?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
{
$requiresForeignKeyRecreation = in_array(DB::getDriverName(), ['mysql', 'mariadb'], true);
Schema::table('variantes', function (Blueprint $table) use ($requiresForeignKeyRecreation): void {
if ($requiresForeignKeyRecreation) {
$table->dropForeign(['inventory_id']);
}
$table->dropUnique('variantes_inventory_id_unique');
$table->index('inventory_id');
if ($requiresForeignKeyRecreation) {
$table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete();
}
$table->foreignId('replaced_by_variant_id')
->nullable()
->after('inventory_id')
->constrained('variantes')
->nullOnDelete();
$table->timestamp('sales_disabled_at')
->nullable()
->after('replaced_by_variant_id');
$table->index(
['sales_disabled_at', 'replaced_by_variant_id'],
'variants_sellable_index',
);
});
}
public function down(): void
{
$requiresForeignKeyRecreation = in_array(DB::getDriverName(), ['mysql', 'mariadb'], true);
DB::table('variantes')
->orderBy('id')
->get()
->groupBy('inventory_id')
->each(function ($variants): void {
$variants->skip(1)->each(function (object $variant): void {
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->first();
if ($inventory === null) {
return;
}
$inventoryId = DB::table('inventories')->insertGetId([
'sold_units' => $inventory->sold_units,
'reserved_stock' => 0,
'real_stock' => $inventory->real_stock,
]);
DB::table('variantes')->where('id', $variant->id)->update([
'inventory_id' => $inventoryId,
]);
});
});
Schema::table('variantes', function (Blueprint $table) use ($requiresForeignKeyRecreation): void {
$table->dropIndex('variants_sellable_index');
$table->dropConstrainedForeignId('replaced_by_variant_id');
$table->dropColumn('sales_disabled_at');
if ($requiresForeignKeyRecreation) {
$table->dropForeign(['inventory_id']);
}
$table->dropIndex(['inventory_id']);
$table->unique('inventory_id');
if ($requiresForeignKeyRecreation) {
$table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete();
}
});
}
};

View File

@@ -0,0 +1,115 @@
<?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
{
$this->createEventDateChangesTable();
DB::table('event_date_changes')->insertUsing(
[
'tenant_code',
'change_type',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
],
DB::table('event_date_reschedules')->select([
'tenant_code',
DB::raw("'rescheduled'"),
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
]),
);
Schema::drop('event_date_reschedules');
}
public function down(): void
{
$this->createEventDateReschedulesTable();
DB::table('event_date_reschedules')->insertUsing(
[
'tenant_code',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
],
DB::table('event_date_changes')
->where('change_type', 'rescheduled')
->whereNotNull('destination_event_date_id')
->whereNotNull('new_date')
->select([
'tenant_code',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
]),
);
Schema::drop('event_date_changes');
}
private function createEventDateChangesTable(): void
{
Schema::create('event_date_changes', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('change_type', 16);
$table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->date('previous_date');
$table->date('new_date')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->index(['tenant_code', 'created_at']);
$table->index(['source_event_date_id', 'change_type']);
});
}
private function createEventDateReschedulesTable(): void
{
Schema::create('event_date_reschedules', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->date('previous_date');
$table->date('new_date');
$table->timestamp('created_at')->useCurrent();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->index(['tenant_code', 'created_at']);
});
}
};

View File

@@ -0,0 +1,128 @@
<?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
{
$this->backfillLegacyRefunds();
$mismatchedItem = DB::table('compra_items as purchase_items')
->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id')
->where('purchase_items.refunded_amount', '>', 0)
->groupBy('purchase_items.id', 'purchase_items.refunded_amount')
->selectRaw(
'purchase_items.id, purchase_items.refunded_amount, COALESCE(SUM(refunds.amount), 0) as refund_total'
)
->get()
->first(fn (object $item): bool => abs(
(float) $item->refunded_amount - (float) $item->refund_total
) > 0.005);
if ($mismatchedItem !== null) {
throw new RuntimeException(
"No se puede eliminar compra_items.refunded_amount: el ítem {$mismatchedItem->id} "
.'contiene un importe histórico que no se pudo respaldar con ticket_refunds.'
);
}
Schema::table('compra_items', function (Blueprint $table): void {
$table->dropColumn('refunded_amount');
});
}
public function down(): void
{
Schema::table('compra_items', function (Blueprint $table): void {
$table->decimal('refunded_amount', 10, 2)->default(0)->after('total');
});
DB::table('ticket_refunds')
->selectRaw('purchase_item_id, SUM(amount) as refund_total')
->groupBy('purchase_item_id')
->orderBy('purchase_item_id')
->eachById(function (object $refund): void {
DB::table('compra_items')
->where('id', $refund->purchase_item_id)
->update(['refunded_amount' => $refund->refund_total]);
}, column: 'purchase_item_id');
}
private function backfillLegacyRefunds(): void
{
$items = DB::table('compra_items as purchase_items')
->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id')
->where('purchase_items.refunded_amount', '>', 0)
->groupBy(
'purchase_items.id',
'purchase_items.refunded_amount',
'purchase_items.precio_unitario',
)
->selectRaw(
'purchase_items.id, purchase_items.refunded_amount, purchase_items.precio_unitario, '
.'COALESCE(SUM(refunds.amount), 0) as refund_total'
)
->orderBy('purchase_items.id')
->get();
foreach ($items as $item) {
$legacyAmountInCents = (int) round(
((float) $item->refunded_amount - (float) $item->refund_total) * 100
);
if ($legacyAmountInCents <= 0) {
continue;
}
$tickets = DB::table('tickets as tickets')
->leftJoin('ticket_refunds as refunds', 'refunds.ticket_id', '=', 'tickets.id')
->where('tickets.source_purchase_item_id', $item->id)
->whereNotNull('tickets.refunded_at')
->whereNull('refunds.id')
->orderBy('tickets.refunded_at')
->orderBy('tickets.id')
->get(['tickets.id', 'tickets.refunded_at']);
$ticketCount = $tickets->count();
$unitPriceInCents = (int) round((float) $item->precio_unitario * 100);
if ($ticketCount === 0
|| $unitPriceInCents <= 0
|| $legacyAmountInCents > $ticketCount * $unitPriceInCents) {
continue;
}
$baseAmountInCents = intdiv($legacyAmountInCents, $ticketCount);
$remainderInCents = $legacyAmountInCents % $ticketCount;
if ($baseAmountInCents === 0) {
continue;
}
$refunds = $tickets->values()->map(function (object $ticket, int $index) use (
$item,
$baseAmountInCents,
$remainderInCents,
$unitPriceInCents,
): array {
$amountInCents = $baseAmountInCents + ($index < $remainderInCents ? 1 : 0);
return [
'ticket_id' => $ticket->id,
'purchase_item_id' => $item->id,
'created_by_user_id' => null,
'type' => $amountInCents === $unitPriceInCents ? 'total' : 'partial',
'amount' => number_format($amountInCents / 100, 2, '.', ''),
'created_at' => $ticket->refunded_at,
'updated_at' => $ticket->refunded_at,
];
})->all();
DB::table('ticket_refunds')->insert($refunds);
}
}
};

View File

@@ -0,0 +1,27 @@
<?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::create('user_event_date_change_views', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('event_date_change_id')->constrained('event_date_changes')->cascadeOnDelete();
$table->unsignedTinyInteger('display_count')->default(0);
$table->timestamp('last_displayed_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'event_date_change_id']);
});
}
public function down(): void
{
Schema::dropIfExists('user_event_date_change_views');
}
};

View File

@@ -0,0 +1,22 @@
<?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('inventories', function (Blueprint $table): void {
$table->unsignedBigInteger('refunded_units')->default(0);
});
}
public function down(): void
{
Schema::table('inventories', function (Blueprint $table): void {
$table->dropColumn('refunded_units');
});
}
};

View File

@@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Historical reconciliation runs explicitly via tickets:backfill-refunded-units.
}
public function down(): void
{
// Reconciliation cannot be reversed without auditing stock.
}
};

View File

@@ -34,7 +34,10 @@ return [
'max_quantity' => 'You can add a maximum of :max.', 'max_quantity' => 'You can add a maximum of :max.',
'bundle_variant_forbidden' => 'A bundle cannot have a variant.', 'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.', 'empty_bundle' => 'The bundle has no components.',
'bundle_component_unavailable' => 'The bundle contains a variant that is no longer available for sale.',
'variant_required' => 'You must select a variant for this item.', 'variant_required' => 'You must select a variant for this item.',
'variant_unavailable' => 'The selected variant was replaced or is no longer available for sale.',
'cart_variant_unavailable' => 'The cart contains a replaced variant or one that is no longer available for sale.',
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.', 'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
], ],
'purchase' => [ 'purchase' => [

View File

@@ -34,7 +34,10 @@ return [
'max_quantity' => 'El máximo que se puede agregar es :max.', 'max_quantity' => 'El máximo que se puede agregar es :max.',
'bundle_variant_forbidden' => 'Un bundle no admite una variante.', 'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.', 'empty_bundle' => 'El bundle no tiene componentes.',
'bundle_component_unavailable' => 'El bundle contiene una variante que ya no está disponible para la venta.',
'variant_required' => 'Debe seleccionar una variante para este ítem.', 'variant_required' => 'Debe seleccionar una variante para este ítem.',
'variant_unavailable' => 'La variante seleccionada fue reemplazada o ya no está disponible para la venta.',
'cart_variant_unavailable' => 'El carrito contiene una variante reemplazada o que ya no está disponible para la venta.',
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.', 'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
], ],
'purchase' => [ 'purchase' => [

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php" bootstrap="tests/bootstrap.php"
colors="true" colors="true"
> >
<testsuites> <testsuites>
@@ -19,7 +19,9 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing" force="true"/> <env name="APP_ENV" value="testing" force="true"/>
<env name="DB_DATABASE" value="shopit_test" force="true"/> <env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="DB_URL" value="null" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/> <env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/> <env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>

View File

@@ -8,7 +8,7 @@
<p><strong>Tickets afectados</strong></p> <p><strong>Tickets afectados</strong></p>
<ul> <ul>
@foreach ($tickets as $ticket) @foreach ($tickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p> <p style="color: #64748b; font-size: 13px;">N° de Orden #{{ $purchase->id }}</p>

View File

@@ -5,7 +5,7 @@
<p>Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:</p> <p>Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:</p>
<ul> <ul>
@foreach ($disabledTickets as $ticket) @foreach ($disabledTickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
<p>Para conocer las alternativas o condiciones de devolución, comunicate con la organización.</p> <p>Para conocer las alternativas o condiciones de devolución, comunicate con la organización.</p>
@@ -15,9 +15,9 @@
<p>Estos tickets conservan otras fechas disponibles:</p> <p>Estos tickets conservan otras fechas disponibles:</p>
<ul> <ul>
@foreach ($activeTickets as $ticket) @foreach ($activeTickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
@endif @endif
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p> <p style="color: #64748b; font-size: 13px;">N° de Orden #{{ $purchase->id }}</p>

View File

@@ -3,6 +3,7 @@
use App\Domains\Auth\Services\AdminCredentialVerifier; use App\Domains\Auth\Services\AdminCredentialVerifier;
use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Purchase\Services\TenantTransactionResetService; use App\Domains\Purchase\Services\TenantTransactionResetService;
use App\Domains\Ticket\Services\BackfillRefundedUnitsService;
use App\Domains\Ticket\Services\LoadTestTicketDatasetService; use App\Domains\Ticket\Services\LoadTestTicketDatasetService;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
@@ -13,6 +14,27 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Artisan::command('tickets:backfill-refunded-units', function (BackfillRefundedUnitsService $service): int {
try {
$summary = $service->run();
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'Refund backfill: %d applied, %d bundles skipped, %d inventories updated, +%d refunded_units, +%d real_stock',
$summary['refunds_applied'],
$summary['bundles_skipped'],
$summary['inventories_updated'],
$summary['refunded_units_added'],
$summary['real_stock_added'],
));
return self::SUCCESS;
})->purpose('Reconcile historical ticket refunds with inventory after reviewing existing refunded units');
Artisan::command('reservations:expire', function (): void { Artisan::command('reservations:expire', function (): void {
$expired = app(ExpireStockReservationsService::class)->expireOverdue(); $expired = app(ExpireStockReservationsService::class)->expireOverdue();

View File

@@ -0,0 +1,130 @@
<?php
error_reporting(error_reporting() & ~(E_WARNING | E_CORE_WARNING | E_COMPILE_WARNING | E_USER_WARNING | E_DEPRECATED | E_USER_DEPRECATED));
class LspHelper
{
public static function relativePath($path)
{
if (!str_contains($path, base_path())) {
return (string) $path;
}
return ltrim(str_replace(base_path(), '', realpath($path) ?: $path), DIRECTORY_SEPARATOR);
}
public static function isVendor($path)
{
return str_contains($path, base_path('vendor'));
}
public static function propertyDefault(ReflectionProperty $property, ?ReflectionParameter $parameter = null): array
{
if ($property->hasDefaultValue()) {
return ['default' => $property->getDefaultValue()];
}
if ($parameter?->isDefaultValueAvailable()) {
return ['default' => $parameter->getDefaultValue()];
}
return [];
}
public static function formatDefaultValue(mixed $value): mixed
{
return match (true) {
is_array($value) => 'array(...)',
$value instanceof UnitEnum => get_class($value) . '::' . $value->name,
$value instanceof Closure => 'Closure',
is_object($value) => get_class($value),
is_string($value) => var_export($value, true),
is_null($value) => 'null',
is_bool($value) => $value ? 'true' : 'false',
default => $value,
};
}
}
use Pest\Expectation;
use Pest\TestSuite;
$pest = new class
{
public function __construct()
{
if ($this->isInstalled()) {
$this->boot();
}
}
public function isInstalled(): bool
{
return class_exists(TestSuite::class);
}
protected function boot(): void
{
require_once base_path('vendor/pestphp/pest/overrides/Runner/TestSuiteLoader.php');
TestSuite::getInstance(base_path(), 'tests');
if (file_exists($pestFile = base_path('tests/Pest.php'))) {
require_once $pestFile;
}
}
public function config(): ?array
{
if (!$this->isInstalled()) {
return null;
}
return [
'uses' => $this->uses(),
'expectations' => $this->expectations(),
];
}
protected function uses(): array
{
if (is_null($instance = TestSuite::getInstance())) {
return [];
}
$reflection = new ReflectionProperty($instance->tests, 'uses');
$uses = $reflection->getValue($instance->tests);
return collect($uses)->map(function (array $use, string $path) {
[$classOrTraits] = $use;
return [
'path' => LspHelper::relativePath($path),
'classes' => array_values(array_filter($classOrTraits, fn ($c) => class_exists($c))),
'traits' => array_values(array_filter($classOrTraits, fn ($c) => trait_exists($c))),
];
})->values()->all();
}
protected function expectations(): array
{
$reflection = new ReflectionProperty(Expectation::class, 'extends');
$extends = $reflection->getValue();
return collect($extends)->map(function (Closure $closure, string $name) {
$parameters = collect((new ReflectionFunction($closure))->getParameters())
->map(function (ReflectionParameter $param) {
$type = $param->hasType() ? $param->getType() . ' ' : '';
$default = $param->isOptional() && $param->isDefaultValueAvailable()
? ' = ' . var_export($param->getDefaultValue(), true)
: '';
return $type . '$' . $param->getName() . $default;
})
->join(', ');
return compact('name', 'parameters');
})->values()->all();
}
};
echo json_encode($pest->config());

View File

@@ -220,6 +220,18 @@ class CatalogItemDetailControllerTest extends TestCase
'time_start' => '10:00', 'time_start' => '10:00',
'time_end' => '19:00', 'time_end' => '19:00',
]); ]);
$rescheduledEventDate = $tenant->eventDates()->create([
'date' => '2026-10-08',
'time_start' => '10:00',
'time_end' => '19:00',
'rescheduled_to_event_date_id' => $unusedEventDate->id,
]);
$suspendedEventDate = $tenant->eventDates()->create([
'date' => '2026-10-11',
'time_start' => '10:00',
'time_end' => '19:00',
'suspended_at' => now(),
]);
$item = $this->createItem($tenant, 'Entry'); $item = $this->createItem($tenant, 'Entry');
$attribute = Attribute::query()->create([ $attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
@@ -245,6 +257,14 @@ class CatalogItemDetailControllerTest extends TestCase
->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window') ->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id) ->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id)
->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id) ->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id)
->assertJsonMissing([
'id' => $rescheduledEventDate->id,
'value' => (string) $rescheduledEventDate->id,
])
->assertJsonMissing([
'id' => $suspendedEventDate->id,
'value' => (string) $suspendedEventDate->id,
])
->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id) ->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id)
->assertJsonPath( ->assertJsonPath(
'data.variants.0.values.event_date.label', 'data.variants.0.values.event_date.label',

View File

@@ -200,10 +200,12 @@ class CatalogSchemaTest extends TestCase
]); ]);
} }
public function test_variants_can_override_catalog_item_use_dates(): void public function test_variants_support_event_dates_and_commercial_replacements(): void
{ {
$this->assertTrue(Schema::hasColumns('variantes', [ $this->assertTrue(Schema::hasColumns('variantes', [
'event_date_id', 'event_date_id',
'replaced_by_variant_id',
'sales_disabled_at',
])); ]));
} }

View File

@@ -6,11 +6,15 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Purchase\Services\Checkout\CatalogSelectionResolver;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -20,7 +24,9 @@ use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase; use Tests\TestCase;
class AdminAppEventControllerTest extends TestCase class AdminAppEventControllerTest extends TestCase
@@ -146,6 +152,52 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonMissing(['title' => 'Other Event']); ->assertJsonMissing(['title' => 'Other Event']);
} }
public function test_dates_are_grouped_by_their_final_destination_and_ordered_by_active_date(): void
{
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Acme Event');
$source13 = $tenant->eventDates()->create([
'date' => '2026-10-13', 'time_start' => '00:00', 'time_end' => '23:59',
]);
$source22 = $tenant->eventDates()->create([
'date' => '2026-10-22', 'time_start' => '00:00', 'time_end' => '23:59',
]);
$middle24 = $tenant->eventDates()->create([
'date' => '2026-10-24', 'time_start' => '00:00', 'time_end' => '23:59',
]);
$active25 = $tenant->eventDates()->create([
'date' => '2026-10-25', 'time_start' => '00:00', 'time_end' => '23:59',
]);
$destination30 = $tenant->eventDates()->create([
'date' => '2026-10-30', 'time_start' => '00:00', 'time_end' => '23:59',
]);
$source13->update(['rescheduled_to_event_date_id' => $destination30->id]);
$source22->update(['rescheduled_to_event_date_id' => $middle24->id]);
$middle24->update(['rescheduled_to_event_date_id' => $destination30->id]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/event')
->assertOk()
->assertJsonCount(2, 'data.dates')
->assertJsonPath('data.dates.0.id', $active25->id)
->assertJsonCount(0, 'data.dates.0.rescheduled_dates')
->assertJsonPath('data.dates.1.id', $destination30->id)
->assertJsonPath('data.dates.1.rescheduled_dates.0.id', $source13->id)
->assertJsonPath(
'data.dates.1.rescheduled_dates.0.rescheduled_to_event_date_id',
$destination30->id,
)
->assertJsonPath('data.dates.1.rescheduled_dates.1.id', $source22->id)
->assertJsonPath(
'data.dates.1.rescheduled_dates.1.rescheduled_to_event_date_id',
$destination30->id,
)
->assertJsonPath('data.dates.1.rescheduled_dates.2.id', $middle24->id)
->assertJsonPath(
'data.dates.1.rescheduled_dates.2.rescheduled_to_event_date_id',
$destination30->id,
);
}
public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void
{ {
$tenant = $this->createTenant('acme'); $tenant = $this->createTenant('acme');
@@ -247,6 +299,17 @@ class AdminAppEventControllerTest extends TestCase
'time_end' => '20:00', 'time_end' => '20:00',
]); ]);
$variant = $this->createVariant($tenant, $original->id); $variant = $this->createVariant($tenant, $original->id);
$variant->inventory()->update(['real_stock' => 5, 'reserved_stock' => 2]);
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->addHour(),
]);
$reservationLine = StockReservationLine::query()->create([
'stock_reservation_id' => $reservation->id,
'inventory_id' => $variant->inventory_id,
'quantity' => 2,
'tracks_inventory' => true,
]);
$ticket = $this->createTicket($tenant, $admin, $variant); $ticket = $this->createTicket($tenant, $admin, $variant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
@@ -267,11 +330,54 @@ class AdminAppEventControllerTest extends TestCase
}); });
$this->assertDatabaseCount('event_dates', 2); $this->assertDatabaseCount('event_dates', 2);
$this->assertDatabaseHas('event_date_changes', [
'tenant_code' => $tenant->codigo,
'change_type' => 'rescheduled',
'source_event_date_id' => $original->id,
'destination_event_date_id' => $destination->id,
'created_by_user_id' => $admin->id,
'previous_date' => '2027-10-09',
'new_date' => '2027-10-20',
]);
$variant->refresh();
$replacement = $variant->replacement()->firstOrFail();
$this->assertSame($original->id, $variant->event_date_id);
$this->assertSame($destination->id, $replacement->event_date_id);
$this->assertNotSame($variant->inventory_id, $replacement->inventory_id);
$this->assertSame(5, $variant->inventory->fresh()->real_stock);
$this->assertSame(0, $variant->inventory->fresh()->reserved_stock);
$this->assertSame(5, $replacement->inventory->real_stock);
$this->assertSame(2, $replacement->inventory->reserved_stock);
$this->assertSame($replacement->inventory_id, $reservationLine->fresh()->inventory_id);
$this->assertNotNull($variant->sales_disabled_at);
$this->assertSame($replacement->id, $variant->replaced_by_variant_id);
$this->assertSame(
[$replacement->id],
$variant->catalogItem->fresh(['variants.inventory'])->visibleVariants()->modelKeys(),
);
$this->assertSame($variant->id, $ticket->fresh()->source_variant_id);
try {
app(CatalogSelectionResolver::class)->resolve(
$tenant,
$variant->catalog_item_id,
$variant->id,
'direct_items.0',
);
$this->fail('The historical variant should not be sellable.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('direct_items.0.variant_id', $exception->errors());
}
$this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text); $this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk() ->assertOk()
->assertJsonCount(1, 'data.event.dates') ->assertJsonCount(1, 'data.event.dates')
->assertJsonPath('data.event.dates.0.id', $destination->id) ->assertJsonPath('data.event.dates.0.id', $destination->id)
->assertJsonPath('data.event.dates.0.info_text', '09/10/2027 se reprogramó para este día.')
->assertJsonPath('data.event.dates.0.isCanceled', false)
->assertJsonMissingPath('data.event.date_changes')
->assertJsonMissingPath('data.event.date_notices')
->assertJsonPath('data.event_date_text', '20 de Octubre 2027'); ->assertJsonPath('data.event_date_text', '20 de Octubre 2027');
$this->assertSame( $this->assertSame(
'2027-10-20 11:00:00', '2027-10-20 11:00:00',
@@ -287,11 +393,29 @@ class AdminAppEventControllerTest extends TestCase
])->assertOk()->assertJsonPath('data.status', 'rescheduled'); ])->assertOk()->assertJsonPath('data.status', 'rescheduled');
$this->assertDatabaseCount('event_dates', 3); $this->assertDatabaseCount('event_dates', 3);
$this->assertDatabaseCount('event_date_changes', 2);
$this->assertDatabaseHas('event_date_changes', [
'tenant_code' => $tenant->codigo,
'change_type' => 'rescheduled',
'source_event_date_id' => $destination->id,
'created_by_user_id' => $admin->id,
'previous_date' => '2027-10-20',
'new_date' => '2027-10-25',
]);
$replacement->refresh();
$latestReplacement = $replacement->replacement()->firstOrFail();
$this->assertNotSame($replacement->inventory_id, $latestReplacement->inventory_id);
$this->assertSame(5, $latestReplacement->inventory->real_stock);
$this->assertSame('2027-10-25', $latestReplacement->eventDate->date->format('Y-m-d'));
$this->assertFalse($replacement->isSellable());
$this->assertTrue($latestReplacement->isSellable());
$this->assertSame('25 de Octubre 2027', $tenant->fresh()->event_date_text); $this->assertSame('25 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk() ->assertOk()
->assertJsonCount(1, 'data.event.dates') ->assertJsonCount(1, 'data.event.dates')
->assertJsonPath('data.event.dates.0.date', '2027-10-25') ->assertJsonPath('data.event.dates.0.date', '2027-10-25')
->assertJsonMissingPath('data.event.date_changes')
->assertJsonMissingPath('data.event.date_notices')
->assertJsonPath('data.event_date_text', '25 de Octubre 2027'); ->assertJsonPath('data.event_date_text', '25 de Octubre 2027');
$this->assertSame( $this->assertSame(
'2027-10-25 11:00:00', '2027-10-25 11:00:00',
@@ -299,6 +423,89 @@ class AdminAppEventControllerTest extends TestCase
); );
} }
public static function cartInvalidatingDateChanges(): array
{
return [
'reschedule' => ['reschedule', ['date' => '2027-10-20'], 'event_date_rescheduled'],
'suspend' => ['suspend', [], 'event_date_suspended'],
];
}
#[DataProvider('cartInvalidatingDateChanges')]
public function test_date_changes_invalidate_reserved_carts(string $action, array $payload, string $reason): void
{
Event::fake([EventDateRescheduled::class, EventDateSuspended::class]);
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
$source = $tenant->eventDates()->create([
'date' => '2027-10-09', 'time_start' => '09:00', 'time_end' => '18:30',
]);
$variant = $this->createVariant($tenant, $source->id);
$variant->inventory->update(['real_stock' => 5, 'reserved_stock' => 2]);
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => now()->addHour(),
]);
StockReservationLine::query()->create([
'stock_reservation_id' => $reservation->id, 'inventory_id' => $variant->inventory_id,
'quantity' => 2, 'tracks_inventory' => true,
]);
$admin = $this->createAdminAppUser($tenant);
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo, 'user_id' => $admin->id,
'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER,
'current_stock_reservation_id' => $reservation->id,
]);
$cart->items()->create([
'catalog_item_id' => $variant->catalog_item_id, 'variant_id' => $variant->id, 'cantidad' => 2,
]);
Sanctum::actingAs($admin);
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$source->id}/{$action}", $payload)->assertOk();
$this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status);
$this->assertSame(StockReservation::STATUS_RELEASED, $reservation->fresh()->status);
$this->assertSame($reason, $reservation->fresh()->release_reason);
$this->assertSame(0, $variant->fresh()->inventory->reserved_stock);
if ($action === 'reschedule') {
$this->assertSame(0, $variant->fresh()->replacement->inventory->reserved_stock);
}
$this->getJson('/api/tenants/acme/cart')->assertOk()->assertJsonCount(0, 'data.items');
}
public function test_rescheduling_reuses_an_equivalent_destination_variant(): void
{
Event::fake([EventDateRescheduled::class]);
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
$original = $tenant->eventDates()->create([
'date' => '2027-10-09',
'time_start' => '09:00',
'time_end' => '18:30',
]);
$destination = $tenant->eventDates()->create([
'date' => '2027-10-20',
'time_start' => '09:00',
'time_end' => '18:30',
]);
$historicalVariant = $this->createVariant($tenant, $original->id);
$destinationVariant = Variant::query()->create([
'catalog_item_id' => $historicalVariant->catalog_item_id,
'inventory_id' => Inventory::query()->create(['real_stock' => 5])->id,
'event_date_id' => $destination->id,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$original->id}/reschedule", [
'date' => '2027-10-20',
])->assertOk();
$this->assertSame(2, Variant::query()->count());
$this->assertSame(
$destinationVariant->id,
$historicalVariant->fresh()->replaced_by_variant_id,
);
$this->assertSame($original->id, $historicalVariant->fresh()->event_date_id);
$this->assertTrue($destinationVariant->fresh()->isSellable());
}
public function test_suspending_disables_only_tickets_without_another_usable_date(): void public function test_suspending_disables_only_tickets_without_another_usable_date(): void
{ {
Event::fake([EventDateSuspended::class]); Event::fake([EventDateSuspended::class]);
@@ -317,6 +524,13 @@ class AdminAppEventControllerTest extends TestCase
$singleDateVariant = $this->createVariant($tenant, $suspendedDate->id); $singleDateVariant = $this->createVariant($tenant, $suspendedDate->id);
$multipleDateVariant = $this->createVariant($tenant); $multipleDateVariant = $this->createVariant($tenant);
$multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]); $multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]);
$remainingDateVariant = Variant::query()->create([
'catalog_item_id' => $multipleDateVariant->catalog_item_id,
'inventory_id' => Inventory::query()->create()->id,
'event_date_id' => $otherDate->id,
]);
$singleDateVariant->inventory->update(['real_stock' => 5]);
$multipleDateVariant->inventory->update(['real_stock' => 5]);
$singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant); $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant);
$multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
@@ -333,18 +547,53 @@ class AdminAppEventControllerTest extends TestCase
&& $event->purchaseTickets === []; && $event->purchaseTickets === [];
}); });
$this->assertDatabaseHas('event_date_changes', [
'tenant_code' => $tenant->codigo,
'change_type' => 'suspended',
'source_event_date_id' => $suspendedDate->id,
'destination_event_date_id' => null,
'created_by_user_id' => $admin->id,
'previous_date' => '2027-10-09',
'new_date' => null,
]);
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$suspendedDate->id}/suspend")
->assertOk();
$this->assertDatabaseCount('event_date_changes', 1);
$this->assertNotNull($singleDateTicket->fresh()->disabled_at); $this->assertNotNull($singleDateTicket->fresh()->disabled_at);
$this->assertNull($multipleDateTicket->fresh()->disabled_at); $this->assertNull($multipleDateTicket->fresh()->disabled_at);
$this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at);
$this->assertNotNull($multipleDateVariant->fresh()->sales_disabled_at);
$replacement = $multipleDateVariant->fresh()->replacement;
$this->assertNotNull($replacement);
$this->assertSame($remainingDateVariant->id, $replacement->id);
$this->assertTrue($replacement->isSellable());
$this->assertSame([$otherDate->id], $replacement->selectedEventDates()->pluck('id')->all());
$this->assertSame(5, $replacement->inventory->fresh()->availableStock());
$this->assertTrue(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists());
$this->assertFalse(CatalogItem::query()->whereKey($singleDateVariant->catalog_item_id)->whereAvailable()->exists());
$this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text); $this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk() ->assertOk()
->assertJsonCount(1, 'data.event.dates') ->assertJsonCount(2, 'data.event.dates')
->assertJsonPath('data.event.dates.0.id', $otherDate->id) ->assertJsonPath('data.event.dates.0.id', $suspendedDate->id)
->assertJsonPath('data.event.dates.0.info_text', 'Esta fecha fue cancelada.')
->assertJsonPath('data.event.dates.0.isCanceled', true)
->assertJsonPath('data.event.dates.1.id', $otherDate->id)
->assertJsonPath('data.event.dates.1.info_text', null)
->assertJsonPath('data.event.dates.1.isCanceled', false)
->assertJsonMissingPath('data.event.date_changes')
->assertJsonMissingPath('data.event.date_notices')
->assertJsonPath('data.event_date_text', '10 de Octubre 2027'); ->assertJsonPath('data.event_date_text', '10 de Octubre 2027');
$this->assertSame( $this->assertSame(
'2027-10-10 09:00:00', '2027-10-10 09:00:00',
$multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), $multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'),
); );
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$otherDate->id}/suspend")
->assertOk();
$this->assertFalse(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists());
} }
public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Feature\Event;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EventDateNoticeControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
}
public function test_authentication_is_required_to_claim_notices(): void
{
$tenant = $this->createTenant('acme');
$this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim")
->assertUnauthorized();
}
public function test_changes_are_grouped_dynamically_for_each_users_pending_history(): void
{
$tenant = $this->createTenant('acme');
$userA = $this->createUser($tenant);
$rescheduled = collect([
$this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-01', '2027-10-11'),
$this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-02', '2027-10-12'),
]);
$suspended = collect([
$this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-03'),
$this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-04'),
]);
Sanctum::actingAs($userA);
for ($display = 1; $display <= 3; $display++) {
$this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim")
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.type', 'suspended')
->assertJsonPath('data.0.change_ids', $suspended->modelKeys())
->assertJsonPath('data.1.type', 'rescheduled')
->assertJsonPath('data.1.change_ids', $rescheduled->modelKeys());
}
$this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim")
->assertOk()
->assertJsonCount(0, 'data');
$latestReschedule = $this->createChange(
$tenant,
EventDateChangeType::Rescheduled,
'2027-10-05',
'2027-10-15',
);
$this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.type', 'rescheduled')
->assertJsonPath('data.0.change_ids', [$latestReschedule->id])
->assertJsonPath('data.0.title', 'FECHA REPROGRAMADA!');
$userB = $this->createUser($tenant);
Sanctum::actingAs($userB);
$this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim")
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.type', 'suspended')
->assertJsonPath('data.0.change_ids', $suspended->modelKeys())
->assertJsonPath('data.1.type', 'rescheduled')
->assertJsonPath(
'data.1.change_ids',
[...$rescheduled->modelKeys(), $latestReschedule->id],
)
->assertJsonPath('data.1.title', 'FECHAS REPROGRAMADAS!');
foreach ([...$rescheduled, ...$suspended] as $change) {
$this->assertDatabaseHas('user_event_date_change_views', [
'user_id' => $userA->id,
'event_date_change_id' => $change->id,
'display_count' => 3,
]);
}
$this->assertDatabaseHas('user_event_date_change_views', [
'user_id' => $userA->id,
'event_date_change_id' => $latestReschedule->id,
'display_count' => 1,
]);
$this->assertDatabaseCount('user_event_date_change_views', 9);
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
]);
}
private function createUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => $tenant->codigo,
]);
}
private function createChange(
Tenant $tenant,
EventDateChangeType $type,
string $previousDate,
?string $newDate = null,
): EventDateChange {
return EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => $type,
'previous_date' => $previousDate,
'new_date' => $newDate,
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
@@ -221,6 +222,66 @@ class EntryControllerTest extends TestCase
]); ]);
} }
public function test_it_reads_and_edits_the_latest_replacement_even_when_sales_are_disabled(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$date = $tenant->eventDates()->create([
'date' => '2027-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$otherDate = $tenant->eventDates()->create([
'date' => '2027-10-10',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'title' => 'Abono',
'event_date_ids' => [$date->id],
'stock' => 10,
'price' => 100,
]],
])->assertOk()->json('data.0.id');
$original = Variant::query()->where('catalog_item_id', $entryId)->sole();
$intermediate = $original->replicate();
$intermediate->save();
$replacement = $original->replicate();
$replacement->inventory_id = Inventory::query()->create(['real_stock' => 20])->id;
$replacement->event_date_id = $otherDate->id;
$replacement->sales_disabled_at = now();
$replacement->save();
$replacement->eventDates()->sync([$otherDate->id]);
$original->update(['replaced_by_variant_id' => $intermediate->id, 'sales_disabled_at' => now()]);
$intermediate->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]);
$this->getJson('/api/v1/adminapp/tenant/entries')
->assertOk()
->assertJsonPath('data.0.stock', 20)
->assertJsonPath('data.0.event_date_ids', [$otherDate->id]);
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'id' => $entryId,
'title' => 'Abono editado',
'event_date_ids' => [$date->id, $otherDate->id],
'stock' => 30,
'price' => 200,
]],
])->assertOk()
->assertJsonPath('data.0.stock', 30)
->assertJsonPath('data.0.event_date_ids', [$date->id, $otherDate->id]);
$this->assertSame(10, $original->fresh()->inventory->real_stock);
$this->assertSame([$date->id], $original->fresh()->selectedEventDates()->pluck('id')->all());
$this->assertSame(30, $replacement->fresh()->inventory->real_stock);
$this->assertNotNull($replacement->fresh()->sales_disabled_at);
$this->assertDatabaseCount('variantes', 3);
}
public function test_it_deletes_an_entry_and_its_inventory(): void public function test_it_deletes_an_entry_and_its_inventory(): void
{ {
$tenant = $this->createFiestaTenant(); $tenant = $this->createFiestaTenant();

View File

@@ -7,13 +7,16 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Services\EventService;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -32,6 +35,13 @@ class FoodControllerTest extends TestCase
]); ]);
} }
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_authentication_is_required(): void public function test_authentication_is_required(): void
{ {
$this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []]) $this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []])
@@ -142,6 +152,118 @@ class FoodControllerTest extends TestCase
]); ]);
} }
public function test_it_separates_current_and_historical_food_variants(): void
{
Carbon::setTestNow('2026-09-01 12:00:00');
[$tenant, $rescheduledDate, $suspendedDate] = $this->configuredTenant();
$completedDate = $tenant->eventDates()->create([
'date' => '2026-10-11',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$activeDate = $tenant->eventDates()->create([
'date' => '2026-10-21',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($rescheduledDate->id, 'Almuerzo', 'Comedor', 100, 10000),
$this->variantPayload($suspendedDate->id, 'Cena', 'Vianda', 90, 9000),
$this->variantPayload($completedDate->id, 'Cena', 'Comedor', 80, 8000),
$this->variantPayload($activeDate->id, 'Almuerzo', 'Vianda', 70, 7000),
],
])->assertOk();
$eventService = app(EventService::class);
$eventService->rescheduleDateForTenant(
$tenant,
$rescheduledDate,
['date' => '2026-10-20'],
$admin,
);
$eventService->suspendDateForTenant($tenant, $suspendedDate, $admin);
Carbon::setTestNow('2026-10-15 12:00:00');
$response = $this->getJson('/api/v1/adminapp/tenant/foods')->assertOk();
$response->assertJsonCount(2, 'data.variants')->assertJsonCount(3, 'data.history');
$history = collect($response->json('data.history'));
$rescheduled = $history->firstWhere('status', 'rescheduled');
$this->assertSame('REPROGRAMADA', $rescheduled['status_text']);
$this->assertSame('2026-10-09', $rescheduled['event_date']);
$this->assertSame('2026-10-20', $rescheduled['replacement_event_date']);
$this->assertCount(1, $rescheduled['variants']);
$suspended = $history->firstWhere('status', 'suspended');
$this->assertSame('CANCELADA', $suspended['status_text']);
$this->assertNull($suspended['replacement_event_date']);
$completed = $history->firstWhere('status', 'completed');
$this->assertSame('FINALIZADA', $completed['status_text']);
$this->assertSame('2026-10-11', $completed['event_date']);
$this->assertNull($completed['replacement_event_date']);
}
public function test_it_updates_only_the_stock_of_historical_food_variants(): void
{
[$tenant, $historicalDate, $activeDate] = $this->configuredTenant();
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$created = $this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($historicalDate->id, 'Almuerzo', 'Comedor', 100, 10000),
$this->variantPayload($activeDate->id, 'Cena', 'Vianda', 80, 8000),
],
])->assertOk();
$historicalVariantId = $created->json('data.variants.0.id');
$activeVariantId = $created->json('data.variants.1.id');
app(EventService::class)->rescheduleDateForTenant(
$tenant,
$historicalDate,
['date' => $activeDate->date->format('Y-m-d')],
$admin,
);
$historicalVariant = Variant::query()->findOrFail($historicalVariantId);
$replacementVariant = $historicalVariant->replacement()->firstOrFail();
$replacementInventoryId = $replacementVariant->inventory_id;
// Simula variantes creadas antes de que la reprogramación separara sus inventarios.
$replacementVariant->update(['inventory_id' => $historicalVariant->inventory_id]);
Inventory::query()->whereKey($replacementInventoryId)->delete();
$updated = $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [
'variants' => [['id' => $historicalVariantId, 'stock' => 45]],
])
->assertOk()
->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId)
->assertJsonPath('data.history.0.variants.0.stock', 45);
$replacement = collect($updated->json('data.variants'))
->firstWhere('schedule', 'Almuerzo');
$this->assertSame(100, $replacement['stock']);
$historicalInventoryId = Variant::query()->findOrFail($historicalVariantId)->inventory_id;
$currentInventoryId = $replacementVariant->fresh()->inventory_id;
$this->assertNotSame($historicalInventoryId, $currentInventoryId);
$this->assertDatabaseHas('inventories', [
'id' => $historicalInventoryId,
'real_stock' => 45,
]);
$this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [
'variants' => [['id' => $activeVariantId, 'stock' => 20]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['variants.0.id']);
}
public function test_it_deletes_food_records_and_removes_the_empty_product(): void public function test_it_deletes_food_records_and_removes_the_empty_product(): void
{ {
[$tenant, $firstDate, $secondDate] = $this->configuredTenant(); [$tenant, $firstDate, $secondDate] = $this->configuredTenant();

View File

@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppEntryFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry')
->assertUnauthorized();
}
public function test_it_returns_only_selectable_event_dates_for_the_tenant(): void
{
$tenant = $this->createTenant('fiesta');
$otherTenant = $this->createTenant('other');
$available = $this->createEventDate($tenant, '2026-10-09');
$rescheduled = $this->createEventDate($tenant, '2026-10-10');
$replacement = $this->createEventDate($tenant, '2026-10-11');
$rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]);
$this->createEventDate($tenant, '2026-10-12', ['suspended_at' => now()]);
$this->createEventDate($otherTenant, '2026-10-13');
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry')
->assertOk()
->assertJsonCount(2, 'data.event_dates')
->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09'])
->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11'])
->assertJsonMissing(['date' => '2026-10-10'])
->assertJsonMissing(['date' => '2026-10-12'])
->assertJsonMissing(['date' => '2026-10-13']);
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
/** @param array<string, mixed> $overrides */
private function createEventDate(
Tenant $tenant,
string $date,
array $overrides = []
): EventDate {
return $tenant->eventDates()->create([
'date' => $date,
'time_start' => '00:00',
'time_end' => '23:59',
...$overrides,
]);
}
}

View File

@@ -67,6 +67,51 @@ class AdminAppFoodFormControllerTest extends TestCase
->assertJsonPath('data.services.0.value', 'Comedor'); ->assertJsonPath('data.services.0.value', 'Comedor');
} }
public function test_it_excludes_rescheduled_and_suspended_event_dates(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'fiesta',
'nombre' => 'Fiesta',
'dominio' => 'fiesta.test',
'website_type_code' => 'onticket',
]);
$available = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$rescheduled = $tenant->eventDates()->create([
'date' => '2026-10-10',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$replacement = $tenant->eventDates()->create([
'date' => '2026-10-11',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]);
$tenant->eventDates()->create([
'date' => '2026-10-12',
'time_start' => '00:00',
'time_end' => '23:59',
'suspended_at' => now(),
]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food')
->assertOk()
->assertJsonCount(2, 'data.event_dates')
->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09'])
->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11'])
->assertJsonMissing(['date' => '2026-10-10'])
->assertJsonMissing(['date' => '2026-10-12']);
}
/** @param array<int, array{value: string, label: string, sort_order: int}> $options */ /** @param array<int, array{value: string, label: string, sort_order: int}> $options */
private function createAttribute(Tenant $tenant, string $code, array $options): void private function createAttribute(Tenant $tenant, string $code, array $options): void
{ {

View File

@@ -79,7 +79,6 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
['value' => 'used', 'label' => 'Usado'], ['value' => 'used', 'label' => 'Usado'],
['value' => 'expired', 'label' => 'Vencido'], ['value' => 'expired', 'label' => 'Vencido'],
['value' => 'disabled', 'label' => 'Inhabilitado'], ['value' => 'disabled', 'label' => 'Inhabilitado'],
['value' => 'cancelled', 'label' => 'Cancelado'],
['value' => 'refunded', 'label' => 'Reembolsado'], ['value' => 'refunded', 'label' => 'Reembolsado'],
], ],
], ],

View File

@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Tests\TestCase;
class RemoveRefundedAmountFromPurchaseItemsTest extends TestCase
{
private string $originalConnection;
protected function setUp(): void
{
parent::setUp();
$this->originalConnection = DB::getDefaultConnection();
config()->set('database.connections.refund_migration_test', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => true,
]);
DB::setDefaultConnection('refund_migration_test');
Schema::create('compra_items', function (Blueprint $table): void {
$table->id();
$table->decimal('precio_unitario', 10, 2);
$table->decimal('total', 10, 2);
$table->decimal('refunded_amount', 10, 2)->default(0);
});
Schema::create('tickets', function (Blueprint $table): void {
$table->id();
$table->foreignId('source_purchase_item_id')->nullable();
$table->dateTime('refunded_at')->nullable();
});
Schema::create('ticket_refunds', function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_id')->unique();
$table->foreignId('purchase_item_id');
$table->foreignId('created_by_user_id')->nullable();
$table->string('type', 16);
$table->decimal('amount', 10, 2);
$table->timestamps();
});
}
protected function tearDown(): void
{
DB::purge('refund_migration_test');
DB::setDefaultConnection($this->originalConnection);
parent::tearDown();
}
public function test_it_backfills_a_refund_created_before_ticket_refunds_existed(): void
{
DB::table('compra_items')->insert([
'id' => 254,
'precio_unitario' => '100.00',
'total' => '100.00',
'refunded_amount' => '40.00',
]);
DB::table('tickets')->insert([
'id' => 501,
'source_purchase_item_id' => 254,
'refunded_at' => '2026-09-13 18:30:00',
]);
$this->migration()->up();
$this->assertFalse(Schema::hasColumn('compra_items', 'refunded_amount'));
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => 501,
'purchase_item_id' => 254,
'created_by_user_id' => null,
'type' => 'partial',
'amount' => 40,
'created_at' => '2026-09-13 18:30:00',
]);
}
public function test_it_only_backfills_the_amount_not_already_in_ticket_refunds(): void
{
DB::table('compra_items')->insert([
'id' => 254,
'precio_unitario' => '100.00',
'total' => '200.00',
'refunded_amount' => '140.00',
]);
DB::table('tickets')->insert([
[
'id' => 501,
'source_purchase_item_id' => 254,
'refunded_at' => '2026-09-13 18:30:00',
],
[
'id' => 502,
'source_purchase_item_id' => 254,
'refunded_at' => '2026-09-14 10:00:00',
],
]);
DB::table('ticket_refunds')->insert([
'ticket_id' => 502,
'purchase_item_id' => 254,
'created_by_user_id' => 7,
'type' => 'total',
'amount' => '100.00',
'created_at' => '2026-09-14 10:00:00',
'updated_at' => '2026-09-14 10:00:00',
]);
$this->migration()->up();
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => 501,
'purchase_item_id' => 254,
'created_by_user_id' => null,
'type' => 'partial',
'amount' => 40,
]);
$this->assertSame(2, DB::table('ticket_refunds')->count());
}
public function test_it_still_refuses_to_drop_an_amount_without_a_refunded_ticket(): void
{
DB::table('compra_items')->insert([
'id' => 254,
'precio_unitario' => '100.00',
'total' => '100.00',
'refunded_amount' => '40.00',
]);
try {
$this->migration()->up();
$this->fail('The migration should preserve an amount that cannot be backfilled.');
} catch (RuntimeException $exception) {
$this->assertStringContainsString('ítem 254', $exception->getMessage());
$this->assertTrue(Schema::hasColumn('compra_items', 'refunded_amount'));
}
}
private function migration(): object
{
return require database_path(
'migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php'
);
}
}

View File

@@ -352,11 +352,12 @@ class NotificationMailServiceTest extends TestCase
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool { Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool {
$mail->assertTo('checkout@example.com'); $mail->assertTo('checkout@example.com');
return $mail->subject === "Tu evento fue reprogramado - Compra #{$purchase->id}" return $mail->subject === "Tu evento fue reprogramado - N° de Orden #{$purchase->id}"
&& str_contains($mail->render(), '09/10/2027') && str_contains($mail->render(), '09/10/2027')
&& str_contains($mail->render(), '20/10/2027') && str_contains($mail->render(), '20/10/2027')
&& str_contains($mail->render(), '#'.$firstTicket->id) && str_contains($mail->render(), 'N° de Ticket #'.$firstTicket->id)
&& str_contains($mail->render(), '#'.$secondTicket->id); && str_contains($mail->render(), 'N° de Ticket #'.$secondTicket->id)
&& str_contains($mail->render(), 'N° de Orden #'.$purchase->id);
}); });
$this->assertDatabaseHas('email_deliveries', [ $this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}",

View File

@@ -11,7 +11,9 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
@@ -20,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase; use Tests\TestCase;
class StorePurchaseTest extends TestCase class StorePurchaseTest extends TestCase
@@ -72,6 +75,79 @@ class StorePurchaseTest extends TestCase
$this->travelBack(); $this->travelBack();
} }
public function test_checkout_replaces_a_historical_cart_variant_without_duplicating_stock(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$historicalVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $user->id,
'status' => Cart::STATUS_ACTIVE,
]);
$cart->addItem($historicalVariant->catalog_item_id, $historicalVariant->id, 2);
$replacement = Variant::query()->create([
'catalog_item_id' => $historicalVariant->catalog_item_id,
'inventory_id' => $historicalVariant->inventory_id,
'precio' => '50.00',
]);
$historicalVariant->update([
'replaced_by_variant_id' => $replacement->id,
'sales_disabled_at' => now(),
]);
$purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [
'cart_id' => $cart->id,
]);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $cart->id,
'variant_id' => $replacement->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id,
'source_variant_id' => $replacement->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $historicalVariant->inventory_id,
'real_stock' => 10,
'reserved_stock' => 2,
]);
$this->assertSame(8, $historicalVariant->catalogItem->fresh()->availableStock());
}
public function test_direct_checkout_rejects_a_historical_variant(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$historicalVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$replacement = Variant::query()->create([
'catalog_item_id' => $historicalVariant->catalog_item_id,
'inventory_id' => $historicalVariant->inventory_id,
'precio' => '50.00',
]);
$historicalVariant->update([
'replaced_by_variant_id' => $replacement->id,
'sales_disabled_at' => now(),
]);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_items' => [[
'catalog_item_id' => $historicalVariant->catalog_item_id,
'variant_id' => $historicalVariant->id,
'cantidad' => 1,
]],
])
->assertUnprocessable()
->assertJsonValidationErrors('direct_items.0.variant_id');
$this->assertDatabaseCount('compras', 0);
}
public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void
{ {
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@@ -569,6 +645,80 @@ class StorePurchaseTest extends TestCase
$this->assertSame(1, $activeCart->items()->count()); $this->assertSame(1, $activeCart->items()->count());
} }
/** @return array<string, array{string, string}> */
public static function unavailableCancellationCases(): array
{
return [
'created with disabled variant' => [Purchase::STATUS_CREATED, 'disabled'],
'pending payment with replaced variant' => [Purchase::STATUS_PENDING_PAYMENT, 'replaced'],
'pending payment with suspended date' => [Purchase::STATUS_PENDING_PAYMENT, 'suspended'],
];
}
#[DataProvider('unavailableCancellationCases')]
public function test_cancelling_a_purchase_with_unavailable_variants_invalidates_the_whole_cart(
string $purchaseStatus,
string $change,
): void {
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$otherVariant = $this->createVariantForTenant('sonder', 10, '25.00', 'other');
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo, 'user_id' => $user->id,
'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER,
]);
$cart->addItem($variant->catalog_item_id, $variant->id, 2);
$cart->addItem($otherVariant->catalog_item_id, $otherVariant->id, 1);
$purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, ['cart_id' => $cart->id]);
$purchase->update(['status' => $purchaseStatus]);
$reservedInventoryId = $variant->inventory_id;
if ($change === 'replaced') {
// A reprogramming can move reservation lines away from the original variant.
$replacementInventory = Inventory::query()->create(['real_stock' => 10, 'reserved_stock' => 2]);
$replacement = Variant::query()->create([
'catalog_item_id' => $variant->catalog_item_id, 'inventory_id' => $replacementInventory->id,
]);
StockReservationLine::query()->where('stock_reservation_id', $purchase->stock_reservation_id)
->where('inventory_id', $variant->inventory_id)
->update(['inventory_id' => $replacementInventory->id]);
$variant->inventory->update(['reserved_stock' => 0]);
$variant->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]);
$reservedInventoryId = $replacementInventory->id;
} elseif ($change === 'suspended') {
$date = EventDate::query()->create([
'tenant_code' => $tenant->codigo, 'date' => '2027-10-09',
'time_start' => '09:00', 'time_end' => '18:00', 'suspended_at' => now(),
]);
$variant->eventDates()->sync([$date->id]);
} else {
$variant->update(['sales_disabled_at' => now()]);
}
$this->actingAs($user, 'sanctum');
for ($attempt = 0; $attempt < 2; $attempt++) {
$this->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
->assertOk()->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
}
$this->assertDatabaseHas('carritos', [
'id' => $cart->id, 'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null, 'current_stock_reservation_id' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id, 'status' => StockReservation::STATUS_RELEASED,
'release_reason' => 'purchase_cancelled',
]);
foreach ([$reservedInventoryId, $otherVariant->inventory_id] as $inventoryId) {
$this->assertDatabaseHas('inventories', [
'id' => $inventoryId, 'real_stock' => 10, 'reserved_stock' => 0,
]);
}
$this->getJson('/api/tenants/sonder/cart')->assertOk()->assertJsonCount(0, 'data.items');
$this->assertSame(Cart::STATUS_ABANDONED, $cart->fresh()->status);
}
public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void
{ {
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');

View File

@@ -20,6 +20,7 @@ use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@@ -131,7 +132,8 @@ class AdminAppSaleControllerTest extends TestCase
public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void
{ {
$tenant = $this->createTenant('acme'); $tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant)); $admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$catalogItem = CatalogItem::query()->create([ $catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
@@ -155,7 +157,7 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_CREATED, 'status' => Purchase::STATUS_CREATED,
'total' => '30000.00', 'total' => '30000.00',
]); ]);
PurchaseItem::query()->create([ $createdPurchaseItem = PurchaseItem::query()->create([
'compra_id' => $createdPurchase->id, 'compra_id' => $createdPurchase->id,
'source_catalog_item_id' => $catalogItem->id, 'source_catalog_item_id' => $catalogItem->id,
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
@@ -163,7 +165,20 @@ class AdminAppSaleControllerTest extends TestCase
'cantidad' => 3, 'cantidad' => 3,
'precio_unitario' => '10000.00', 'precio_unitario' => '10000.00',
'total' => '30000.00', 'total' => '30000.00',
'refunded_amount' => '1250.00', ]);
$createdRefundTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => 'created-purchase-refund',
'user_id' => $admin->id,
'source_purchase_item_id' => $createdPurchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $createdRefundTicket->id,
'purchase_item_id' => $createdPurchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '1250.00',
]); ]);
$pendingCart = Cart::query()->create([ $pendingCart = Cart::query()->create([
@@ -196,7 +211,7 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PAID, 'status' => Purchase::STATUS_PAID,
'total' => '20000.00', 'total' => '20000.00',
]); ]);
PurchaseItem::query()->create([ $paidPurchaseItem = PurchaseItem::query()->create([
'compra_id' => $paidPurchase->id, 'compra_id' => $paidPurchase->id,
'source_catalog_item_id' => $catalogItem->id, 'source_catalog_item_id' => $catalogItem->id,
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
@@ -204,7 +219,20 @@ class AdminAppSaleControllerTest extends TestCase
'cantidad' => 2, 'cantidad' => 2,
'precio_unitario' => '10000.00', 'precio_unitario' => '10000.00',
'total' => '20000.00', 'total' => '20000.00',
'refunded_amount' => '2500.00', ]);
$paidRefundTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => 'paid-purchase-refund',
'user_id' => $admin->id,
'source_purchase_item_id' => $paidPurchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $paidRefundTicket->id,
'purchase_item_id' => $paidPurchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '2500.00',
]); ]);
$supersededPurchase = Purchase::query()->create([ $supersededPurchase = Purchase::query()->create([

View File

@@ -17,11 +17,13 @@ use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider;
use Database\Seeders\AttributeSeeder; use Database\Seeders\AttributeSeeder;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -185,6 +187,7 @@ class AdminAppTicketControllerTest extends TestCase
$this->grantTicketsMenu($tenant); $this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$inventory = $ticket->sourceCatalogItem->inventory;
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'total', 'refund_type' => 'total',
@@ -192,10 +195,31 @@ class AdminAppTicketControllerTest extends TestCase
->assertOk() ->assertOk()
->assertJsonPath('data.id', $ticket->id) ->assertJsonPath('data.id', $ticket->id)
->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED)
->assertJsonPath('data.refunded_amount', '100.00'); ->assertJsonPath('data.status_label', 'Reembolso total')
->assertJsonMissingPath('data.refunded_amount')
->assertJsonPath('data.refund.type', TicketRefund::TYPE_TOTAL)
->assertJsonPath('data.refund.type_label', 'Reembolso total')
->assertJsonPath('data.refund.amount', '100.00')
->assertJsonPath('data.refund.created_by', $admin->nombre_apellido);
$this->assertNotNull($ticket->fresh()->refunded_at); $this->assertNotNull($ticket->fresh()->refunded_at);
$this->assertSame('100.00', $purchaseItem->fresh()->refunded_amount); $this->assertSame(1, $inventory->fresh()->real_stock);
$this->assertSame(1, $inventory->fresh()->sold_units);
$this->assertSame(1, $inventory->fresh()->refunded_units);
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $ticket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_TOTAL,
'amount' => '100.00',
]);
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'total',
])->assertUnprocessable();
$this->assertSame(1, $inventory->fresh()->real_stock);
$this->assertSame(1, $inventory->fresh()->refunded_units);
$this->assertSame(1, $purchaseItem->ticketRefunds()->count());
} }
public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void
@@ -210,15 +234,134 @@ class AdminAppTicketControllerTest extends TestCase
$this->grantTicketsMenu($tenant); $this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$inventory = $ticket->sourceCatalogItem->inventory;
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'partial', 'refund_type' => 'partial',
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED)
->assertJsonPath('data.refunded_amount', '25.50'); ->assertJsonPath('data.status_label', 'Reembolso parcial')
->assertJsonMissingPath('data.refunded_amount')
->assertJsonPath('data.refund.type', TicketRefund::TYPE_PARTIAL)
->assertJsonPath('data.refund.amount', '25.50');
$this->assertSame('25.50', $purchaseItem->fresh()->refunded_amount); $this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $ticket->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '25.50',
]);
$this->assertSame(1, $inventory->fresh()->real_stock);
$this->assertSame(1, $inventory->fresh()->refunded_units);
}
public function test_backfill_restores_existing_refunds_before_new_refunds(): void
{
$tenant = $this->createTenant('ticket-historical-refund');
$tenant->update([
'allow_ticket_refund' => true,
'allow_ticket_total_refund' => true,
]);
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
[$historicalTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$inventory = $historicalTicket->sourceCatalogItem->inventory;
$inventory->update(['sold_units' => 2]);
$purchaseItem->update(['cantidad' => 2, 'total' => '200.00']);
$historicalTicket->markAsRefunded();
$historicalTicket->save();
TicketRefund::query()->create([
'ticket_id' => $historicalTicket->id,
'purchase_item_id' => $purchaseItem->id,
'type' => TicketRefund::TYPE_TOTAL,
'amount' => '100.00',
]);
$this->assertSame(0, $inventory->fresh()->refunded_units);
$this->assertSame(0, $inventory->fresh()->real_stock);
Log::shouldReceive('info')->once()->with('inventory.refunded_units_backfill.completed', [
'refunds_seen' => 1,
'refunds_applied' => 1,
'bundles_skipped' => 0,
'inventories_updated' => 1,
'refunded_units_added' => 1,
'real_stock_added' => 1,
]);
$this->artisan('tickets:backfill-refunded-units')->assertSuccessful();
$this->assertSame(1, $inventory->fresh()->refunded_units);
$this->assertSame(1, $inventory->fresh()->real_stock);
$newTicket = $this->createTicket($tenant, $admin, [
'source_purchase_item_id' => $purchaseItem->id,
'source_catalog_item_id' => $historicalTicket->source_catalog_item_id,
]);
$this->postJson("/api/v1/adminapp/tenant/tickets/{$newTicket->id}/refund", [
'refund_type' => TicketRefund::TYPE_TOTAL,
])->assertOk();
$this->assertSame(2, $inventory->fresh()->sold_units);
$this->assertSame(2, $inventory->fresh()->refunded_units);
$this->assertSame(2, $inventory->fresh()->real_stock);
}
public function test_backfill_command_fails_without_changing_existing_refunded_units(): void
{
$inventory = Inventory::query()->create([
'real_stock' => 3,
'refunded_units' => 1,
]);
$this->artisan('tickets:backfill-refunded-units')->assertFailed();
$this->assertSame(1, $inventory->fresh()->refunded_units);
$this->assertSame(3, $inventory->fresh()->real_stock);
}
public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void
{
$tenant = $this->createTenant('ticket-mixed-refunds');
$tenant->update([
'allow_ticket_refund' => true,
'allow_ticket_total_refund' => true,
'allow_ticket_partial_refund' => true,
'ticket_partial_refund_percentage' => 25.00,
]);
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
[$partialTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$purchaseItem->update(['cantidad' => 2, 'total' => '200.00']);
$purchaseItem->purchase->update(['total' => '200.00']);
$totalTicket = $this->createTicket($tenant, $admin, [
'source_purchase_item_id' => $purchaseItem->id,
'source_catalog_item_id' => $partialTicket->source_catalog_item_id,
]);
$this->postJson("/api/v1/adminapp/tenant/tickets/{$partialTicket->id}/refund", [
'refund_type' => TicketRefund::TYPE_PARTIAL,
])->assertOk()->assertJsonPath('data.status_label', 'Reembolso parcial');
$this->postJson("/api/v1/adminapp/tenant/tickets/{$totalTicket->id}/refund", [
'refund_type' => TicketRefund::TYPE_TOTAL,
])->assertOk()->assertJsonPath('data.status_label', 'Reembolso total');
$this->assertSame(
'125.00',
number_format((float) $purchaseItem->ticketRefunds()->sum('amount'), 2, '.', ''),
);
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $partialTicket->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '25.00',
]);
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $totalTicket->id,
'type' => TicketRefund::TYPE_TOTAL,
'amount' => '100.00',
]);
} }
public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void
@@ -236,7 +379,7 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonValidationErrors('refund_type'); ->assertJsonValidationErrors('refund_type');
$this->assertNull($ticket->fresh()->refunded_at); $this->assertNull($ticket->fresh()->refunded_at);
$this->assertSame('0.00', $purchaseItem->fresh()->refunded_amount); $this->assertSame(0, $purchaseItem->ticketRefunds()->count());
} }
public function test_it_validates_the_refund_type(): void public function test_it_validates_the_refund_type(): void
@@ -307,8 +450,18 @@ class AdminAppTicketControllerTest extends TestCase
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
// Simulate 70 already refunded out of 100 on the item (remaining is 30) // Simulate 70 already refunded out of 100 on the item (remaining is 30).
$purchaseItem->update(['refunded_amount' => '70.00']); $previousTicket = $this->createTicket($tenant, $admin, [
'source_purchase_item_id' => $purchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $previousTicket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '70.00',
]);
$this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund")
->assertOk() ->assertOk()
@@ -537,7 +690,8 @@ class AdminAppTicketControllerTest extends TestCase
$this->createTicket($tenant, $admin)->update(['used_at' => now()]); $this->createTicket($tenant, $admin)->update(['used_at' => now()]);
$this->createTicket($tenant, $admin); $this->createTicket($tenant, $admin);
$this->createTicket($tenant, $admin)->update(['cancelled_at' => now()]); $this->createTicket($tenant, $admin)->update(['cancelled_at' => now()]);
$this->createTicket($tenant, $admin)->update(['refunded_at' => now()]); $refundedTicket = $this->createTicket($tenant, $admin);
$refundedTicket->update(['refunded_at' => now()]);
$this->createTicket($tenant, $admin)->update(['disabled_at' => now()]); $this->createTicket($tenant, $admin)->update(['disabled_at' => now()]);
$this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]);
@@ -552,7 +706,7 @@ class AdminAppTicketControllerTest extends TestCase
'status' => Purchase::STATUS_PAID, 'status' => Purchase::STATUS_PAID,
'total' => '1000.00', 'total' => '1000.00',
]); ]);
PurchaseItem::query()->create([ $purchaseItem = PurchaseItem::query()->create([
'compra_id' => $purchase->id, 'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id, 'source_catalog_item_id' => $catalogItem->id,
'nombre' => 'Entrada', 'nombre' => 'Entrada',
@@ -560,14 +714,21 @@ class AdminAppTicketControllerTest extends TestCase
'cantidad' => 1, 'cantidad' => 1,
'precio_unitario' => '1000.00', 'precio_unitario' => '1000.00',
'total' => '1000.00', 'total' => '1000.00',
'refunded_amount' => '250.00', ]);
$refundedTicket->update(['source_purchase_item_id' => $purchaseItem->id]);
TicketRefund::query()->create([
'ticket_id' => $refundedTicket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '250.00',
]); ]);
$otherPurchase = Purchase::query()->create([ $otherPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo, 'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID, 'status' => Purchase::STATUS_PAID,
'total' => '2000.00', 'total' => '2000.00',
]); ]);
PurchaseItem::query()->create([ $otherPurchaseItem = PurchaseItem::query()->create([
'compra_id' => $otherPurchase->id, 'compra_id' => $otherPurchase->id,
'source_catalog_item_id' => $catalogItem->id, 'source_catalog_item_id' => $catalogItem->id,
'nombre' => 'Otra entrada', 'nombre' => 'Otra entrada',
@@ -575,7 +736,17 @@ class AdminAppTicketControllerTest extends TestCase
'cantidad' => 1, 'cantidad' => 1,
'precio_unitario' => '2000.00', 'precio_unitario' => '2000.00',
'total' => '2000.00', 'total' => '2000.00',
'refunded_amount' => '2000.00', ]);
$otherRefundedTicket = $this->createTicket($otherTenant, $otherUser, [
'source_purchase_item_id' => $otherPurchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $otherRefundedTicket->id,
'purchase_item_id' => $otherPurchaseItem->id,
'created_by_user_id' => $otherUser->id,
'type' => TicketRefund::TYPE_TOTAL,
'amount' => '2000.00',
]); ]);
$this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match')
@@ -660,7 +831,7 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('data.0.order_number', $purchase->id) ->assertJsonPath('data.0.order_number', $purchase->id)
->assertJsonPath('data.0.product', 'Remera') ->assertJsonPath('data.0.product', 'Remera')
->assertJsonPath('data.0.amount', '8000.00') ->assertJsonPath('data.0.amount', '8000.00')
->assertJsonPath('data.0.refunded_amount', '0.00') ->assertJsonMissingPath('data.0.refunded_amount')
->assertJsonPath('data.0.status', Ticket::STATUS_USED) ->assertJsonPath('data.0.status', Ticket::STATUS_USED)
->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido)
->assertJsonPath('data.0.variant_properties.0.code', 'size') ->assertJsonPath('data.0.variant_properties.0.code', 'size')
@@ -744,6 +915,50 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('total_tickets', 1); ->assertJsonPath('total_tickets', 1);
} }
public function test_it_shows_the_effective_date_after_multiple_reschedules_and_suspension(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
Sanctum::actingAs($admin);
$entry = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', 'abono')->firstOrFail();
$variant = $entry->variants()->whereHas('eventDates')->firstOrFail();
$original = $variant->selectedEventDates()->firstOrFail();
$middle = $tenant->eventDates()->create([
'date' => '2026-11-01', 'time_start' => '09:00', 'time_end' => '18:00',
]);
$latest = $tenant->eventDates()->create([
'date' => '2026-11-02', 'time_start' => '09:00', 'time_end' => '18:00',
]);
$original->update(['rescheduled_to_event_date_id' => $middle->id]);
$middle->update(['rescheduled_to_event_date_id' => $latest->id]);
$ticket = $this->createTicket($tenant, $admin, [
'source_catalog_item_id' => $entry->id,
'source_variant_id' => $variant->id,
]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.id', $ticket->id)
->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10, 02/11');
$latest->update(['suspended_at' => now()]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10');
foreach ($variant->selectedEventDates()->skip(1) as $date) {
$date->update(['suspended_at' => now()]);
}
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.values.date', '-');
}
public function test_it_exposes_and_filters_merchandise_color_and_size(): void public function test_it_exposes_and_filters_merchandise_color_and_size(): void
{ {
$tenant = $this->createTenant('fiesta_futbol_infantil'); $tenant = $this->createTenant('fiesta_futbol_infantil');
@@ -954,8 +1169,10 @@ class AdminAppTicketControllerTest extends TestCase
/** @return array{Ticket, PurchaseItem} */ /** @return array{Ticket, PurchaseItem} */
private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array
{ {
$inventory = Inventory::query()->create(['sold_units' => 1]);
$catalogItem = CatalogItem::query()->create([ $catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory->id,
'slug' => 'ticket-reembolsable-'.Str::uuid(), 'slug' => 'ticket-reembolsable-'.Str::uuid(),
'nombre' => 'Ticket reembolsable', 'nombre' => 'Ticket reembolsable',
'precio' => $amount, 'precio' => $amount,

View File

@@ -5,12 +5,17 @@ namespace Tests\Feature\Ticket;
use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EventService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
@@ -18,6 +23,72 @@ class TicketControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_ticket_names_follow_reschedules_without_changing_variant_selections(): void
{
Event::fake([EventDateRescheduled::class, EventDateSuspended::class]);
$tenant = $this->createTenant('rescheduled');
$user = User::factory()->create();
$ticket = $this->createTicket($tenant, $user, 'COMIDA');
$item = $ticket->sourceCatalogItem;
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'service',
'nombre' => 'Servicio',
'type' => 'string',
]);
$itemAttribute = $item->itemAttributes()->create([
'attribute_id' => $attribute->id,
'ticket_label' => 'Servicio',
]);
$source = $tenant->eventDates()->create([
'date' => '2026-10-29',
'time_start' => '08:00:00',
'time_end' => '10:00:00',
]);
$variant = $item->variants()->create([
'event_date_id' => $source->id,
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => 'COMEDOR',
]);
$ticket->update(['source_variant_id' => $variant->id]);
foreach (['29/10/2026', '02/11/2026', '05/11/2026'] as $step => $expectedDate) {
if ($step > 0) {
$previous = $step === 1 ? $source : $tenant->eventDates()->whereDate('date', '2026-11-02')->firstOrFail();
app(EventService::class)->rescheduleDateForTenant($tenant, $previous, [
'date' => $step === 1 ? '2026-11-02' : '2026-11-05',
]);
}
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets")
->assertOk()
->assertJsonPath('data.0.name', "COMIDA ({$expectedDate}, Servicio COMEDOR)")
->assertJsonPath('data.0.ticket', $ticket->ticket);
}
$destination = $tenant->eventDates()->whereDate('date', '2026-11-05')->firstOrFail();
$variant->eventDates()->sync([$source->id, $destination->id]);
$this->assertSame('COMIDA (05/11/2026, Servicio COMEDOR)', $ticket->fresh()->name);
$this->assertSame('29/10/2026', $variant->fresh()->selectionOptions()->get('event_date')[0]['label']);
$this->assertSame($source->id, $variant->fresh()->event_date_id);
app(EventService::class)->suspendDateForTenant($tenant, $destination);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets")
->assertOk()
->assertJsonPath('data.0.name', 'COMIDA (05/11/2026, Servicio COMEDOR)')
->assertJsonPath('data.0.status', 'disabled')
->assertJsonPath('data.0.is_valid', false)
->assertJsonPath('data.0.starts_at', null)
->assertJsonPath('data.0.expires_at', null)
->assertJsonPath('data.0.ticket', $ticket->ticket);
}
public function test_an_authenticated_user_can_list_their_tickets_for_the_tenant(): void public function test_an_authenticated_user_can_list_their_tickets_for_the_tenant(): void
{ {
$tenant = $this->createTenant('current'); $tenant = $this->createTenant('current');

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