Compare commits

..

57 Commits

Author SHA1 Message Date
09faa22920 feat(event-admin): remove menu restrictions for non-admin users and add test for shared menus among event admins 2026-09-30 16:14:47 -03:00
bebf6a7ed5 feat(scanner): scope login tickets scanning and history by event 2026-09-30 15:31:56 -03:00
86ca57a3ad chore(menus): remove deprecated combos and categories admin menus 2026-09-30 15:31:56 -03:00
e64393812c feat(staff): scope scanner management and category assignments by event 2026-09-30 15:31:56 -03:00
655364bfec feat(sales): scope sales actions totals history and exports by event 2026-09-30 15:31:55 -03:00
55c28f33ef feat(events): authorize event editing and date changes by assigned event 2026-09-30 15:31:54 -03:00
5a0ce08adb feat(refunds): persist and apply refund configuration per event 2026-09-30 15:31:54 -03:00
a770d435eb feat(auth): enable event-scoped admin access and backfill staff assignments 2026-09-30 15:31:53 -03:00
44bf67bd12 feat(admin): add admin scope management for users and events
- Introduced AdminScope enum for tenant and event scopes.
- Updated User model to include admin_scope and event_id attributes.
- Enhanced AdminAppAccessService to validate user scopes.
- Modified AdminAppMeResource and UserResource to include event data.
- Implemented middleware to ensure valid tenant access.
- Created migration to add admin_scope and event_id to users table.
- Added tests for event admin functionality and scope validation.
2026-09-30 15:02:10 -03:00
6e1f0d731c Merge pull request 'homo' (#12) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/12
2026-09-30 11:37:24 +00:00
82c18e9e91 Merge branch 'tenant/smep' into homo 2026-09-28 19:27:39 +00:00
dfad6fb4b0 Merge pull request 'fix/events_integrations' (#11) from fix/events_integrations into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/11
2026-09-28 19:15:30 +00:00
3f9b773367 feat(integrations): resolve payment context from events 2026-09-28 19:14:23 +00:00
7996d53e12 feat(events): associate purchases and integrations with events 2026-09-28 19:13:36 +00:00
a40a2065a9 Merge branch 'tenant/smep' of https://gitea.quo.ar/tbianchini/shopit-back into tenant/smep 2026-09-25 11:37:51 -03:00
57677f55e6 feat(category): include products from all descendant categories in paginated response 2026-09-25 11:37:32 -03:00
a8afd92f46 feat(migration): optimize product images size for improved loading speed 2026-09-25 11:37:32 -03:00
1df0d2e2ef optimized storefront page and images 2026-09-25 11:37:32 -03:00
d3cac7a590 feat(migration): add comprehensive migration report for Mutual SMEP WhatsApp catalog to online store 2026-09-25 11:37:31 -03:00
df13a636ba feat(migration): enhance Mutual SMEP catalog seeding with featured groups and highlighted products 2026-09-25 11:37:31 -03:00
d85841742e feat(carousel): add migration and test for Mutual SMEP carousel images 2026-09-25 11:37:31 -03:00
0e3fdc5460 Add new product images and implement SeedMutualSmepCatalogTest for catalog migration
- Added multiple new product images for smart TVs, audio devices, and accessories in WEBP format.
- Created a new test class SeedMutualSmepCatalogTest to validate the catalog migration process.
- Implemented setup and teardown methods to manage database schema and data during tests.
- Verified the integrity of catalog items, inventories, and associated attributes after migration.
2026-09-25 11:37:31 -03:00
0dc2675dae feat(seeder): implement Mutual SMEP attribute seeder and associated tests 2026-09-25 11:37:30 -03:00
28d1de072c feat(seeder): add Mutual SMEP category seeder and test cases 2026-09-25 11:37:30 -03:00
b09e8a4b96 feat(tenant): provision Mutual SMEP tenant with branding and menu setup 2026-09-25 11:37:30 -03:00
7f754037bb feat(ticket): enhance ticket details with entry reservation amount and product naming for desfile 2026-09-25 11:31:54 -03:00
f29c5692b9 feat(ticket): add entry reservation handling to ticket refund logic and tests 2026-09-25 11:20:12 -03:00
57d278336d feat(desfile): associate tickets menu with desfile tenant and add migration test 2026-09-25 11:15:52 -03:00
b309738222 Merge branch 'homo' 2026-09-25 10:03:21 -03:00
b3eb59983a Merge pull request 'homo' (#10) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/10
2026-09-24 19:55:03 +00:00
cf3f0ceb1b feat(category): include products from all descendant categories in paginated response 2026-09-24 16:28:15 -03:00
41c0902b83 feat(migration): optimize product images size for improved loading speed 2026-09-24 14:12:57 -03:00
a2439bf43b optimized storefront page and images 2026-09-24 14:12:49 -03:00
8d5a12f212 feat(migration): add comprehensive migration report for Mutual SMEP WhatsApp catalog to online store 2026-09-24 13:44:21 -03:00
92170b72f2 feat(migration): enhance Mutual SMEP catalog seeding with featured groups and highlighted products 2026-09-24 12:56:23 -03:00
c3def0678f feat(carousel): add migration and test for Mutual SMEP carousel images 2026-09-24 12:41:38 -03:00
74b72a7d4e Add new product images and implement SeedMutualSmepCatalogTest for catalog migration
- Added multiple new product images for smart TVs, audio devices, and accessories in WEBP format.
- Created a new test class SeedMutualSmepCatalogTest to validate the catalog migration process.
- Implemented setup and teardown methods to manage database schema and data during tests.
- Verified the integrity of catalog items, inventories, and associated attributes after migration.
2026-09-24 12:41:24 -03:00
16e2ca679c feat(seeder): implement Mutual SMEP attribute seeder and associated tests 2026-09-24 12:26:05 -03:00
6b0efbc6ee feat(seeder): add Mutual SMEP category seeder and test cases 2026-09-24 12:26:05 -03:00
2db83271fa feat(tenant): provision Mutual SMEP tenant with branding and menu setup 2026-09-24 12:26:05 -03:00
e259bba1d1 test(desfile): cover reservation cancellation 2026-09-24 11:09:43 -03:00
b3cd9f5de5 feat(desfile): cancel entry reservations 2026-09-24 11:09:38 -03:00
64820fe12b feat(desfile): soft delete entry reservations 2026-09-24 11:09:30 -03:00
e72b6cfdde feat(desfile): download reserved ticket PDF 2026-09-24 10:27:15 -03:00
0e870776c4 feat(desfile): export entry reservations 2026-09-24 10:14:56 -03:00
d8d8354070 feat(desfile): list paginated entry reservations 2026-09-24 10:02:26 -03:00
5bb61fc74c fix(reset): clear administrative entry reservations 2026-09-24 09:34:58 -03:00
9e9af70ba8 feat(desfile): add administrative entry reservations 2026-09-24 09:34:50 -03:00
884da2c89a feat(inventory): track administrative entry reservations 2026-09-24 09:34:42 -03:00
3671b95a83 feat(desfile): enhance entry reservation form and service with tenant-specific data 2026-09-23 16:55:35 -03:00
5e78f2bb1e feat(forms): add desfile entry reservation form 2026-09-23 16:28:35 -03:00
5296d595f1 feat(desfile): add entry reservation model 2026-09-23 16:28:24 -03:00
9901d449e1 feat(migration): add ticket reservation menu for admin app 2026-09-23 16:26:20 -03:00
57605a56c4 Merge pull request 'tennant/onticket' (#9) from tennant/onticket into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/9
2026-09-23 18:43:47 +00:00
d9f19b42f1 refactor(storage): centralize temporary URL generation 2026-09-22 13:54:44 -03:00
90b2784859 feat(storage): cache temporary S3 URLs 2026-09-22 13:54:37 -03:00
bd77727f58 Merge pull request 'homo' (#8) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/8
2026-09-17 17:52:21 +00:00
656 changed files with 10565 additions and 226 deletions

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Commerce\Catalog\Models; namespace App\Domains\Commerce\Catalog\Models;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType; use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Enums\InventorySubject; use App\Domains\Commerce\Catalog\Enums\InventorySubject;
@@ -10,6 +9,7 @@ use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -237,7 +237,7 @@ class CatalogItem extends Model
->whereHas( ->whereHas(
'inventory', 'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') ->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
) )
) )
->orWhere(function (Builder $directItemQuery): void { ->orWhere(function (Builder $directItemQuery): void {
@@ -249,7 +249,7 @@ class CatalogItem extends Model
->orWhereHas( ->orWhereHas(
'inventory', 'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') ->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
); );
}); });
}); });
@@ -264,9 +264,15 @@ class CatalogItem extends Model
/** @return Collection<int, Variant> */ /** @return Collection<int, Variant> */
public function visibleVariants(?int $includedVariantId = null): Collection public function visibleVariants(?int $includedVariantId = null): Collection
{ {
$this->variants
->filter(fn (Variant $variant): bool => $variant->exists)
->loadMissing('desfileEntryReservations');
return $this->variants return $this->variants
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this)) ->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this))
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
&& (! $variant->relationLoaded('desfileEntryReservations')
|| $variant->desfileEntryReservations->isEmpty())
&& (($includedVariantId !== null && $variant->id === $includedVariantId) && (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->isSellable() && ( || ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited $this->inventory_policy === InventoryPolicy::Unlimited

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
'sold_units', 'sold_units',
'refunded_units', 'refunded_units',
'reserved_stock', 'reserved_stock',
'entry_reserved_stock',
'real_stock', 'real_stock',
])] ])]
class Inventory extends Model class Inventory extends Model
@@ -35,6 +36,7 @@ class Inventory extends Model
'sold_units' => 'integer', 'sold_units' => 'integer',
'refunded_units' => 'integer', 'refunded_units' => 'integer',
'reserved_stock' => 'integer', 'reserved_stock' => 'integer',
'entry_reserved_stock' => 'integer',
'real_stock' => 'integer', 'real_stock' => 'integer',
]; ];
} }
@@ -59,7 +61,26 @@ class Inventory extends Model
public function availableStock(): int public function availableStock(): int
{ {
return max(0, $this->real_stock - $this->reserved_stock); return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock);
}
public function reserveEntry(int $amount, bool $tracksInventory): void
{
if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) {
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
}
$this->entry_reserved_stock += $amount;
$this->save();
}
public function releaseEntry(int $amount): void
{
if ($amount < 1 || $this->entry_reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
}
$this->entry_reserved_stock -= $amount;
$this->save();
} }
public function reserve(int $amount, bool $tracksInventory): void public function reserve(int $amount, bool $tracksInventory): void
@@ -92,7 +113,7 @@ class Inventory extends Model
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.'); throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
} }
if ($tracksInventory && $this->real_stock < $amount) { if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
} }

View File

@@ -2,9 +2,10 @@
namespace App\Domains\Commerce\Catalog\Models; namespace App\Domains\Commerce\Catalog\Models;
use App\Shared\Attachable\Models\Attachment; use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment;
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;
@@ -74,6 +75,12 @@ class Variant extends Model
return $this->hasMany(Ticket::class, 'source_variant_id'); return $this->hasMany(Ticket::class, 'source_variant_id');
} }
/** @return HasMany<EntryReservation, $this> */
public function desfileEntryReservations(): HasMany
{
return $this->hasMany(EntryReservation::class);
}
/** @return BelongsTo<Inventory, $this> */ /** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo public function inventory(): BelongsTo
{ {

View File

@@ -153,7 +153,7 @@ class CatalogInventoryService
if ($operation === 'commit' if ($operation === 'commit'
&& $requirement['tracks_inventory'] && $requirement['tracks_inventory']
&& $inventory->real_stock < $requiredQuantity) { && $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
} }
} }

View File

@@ -2,8 +2,6 @@
namespace App\Domains\Commerce\Catalog\Services; namespace App\Domains\Commerce\Catalog\Services;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType; use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
@@ -11,8 +9,10 @@ use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -286,9 +286,11 @@ class CatalogService
int $perPage, int $perPage,
int $page, int $page,
): LengthAwarePaginator { ): LengthAwarePaginator {
$categoryIds = $this->categoryAndDescendantIds($tenant, $category);
return CatalogItem::query() return CatalogItem::query()
->forTenantCatalog($tenant) ->forTenantCatalog($tenant)
->where('category_id', $category->id) ->whereIn('category_id', $categoryIds)
->whereAvailable() ->whereAvailable()
->with([ ->with([
'attachments', 'attachments',
@@ -306,6 +308,34 @@ class CatalogService
->paginate(perPage: $perPage, pageName: 'page', page: $page); ->paginate(perPage: $perPage, pageName: 'page', page: $page);
} }
/** @return array<int, int> */
private function categoryAndDescendantIds(Tenant $tenant, Category $category): array
{
$childrenByParent = Category::query()
->where('tenant_code', $tenant->codigo)
->get(['id', 'categoria_id'])
->groupBy('categoria_id');
$categoryIds = [];
$pendingIds = [$category->id];
while ($pendingIds !== []) {
$categoryId = array_shift($pendingIds);
if (isset($categoryIds[$categoryId])) {
continue;
}
$categoryIds[$categoryId] = $categoryId;
foreach ($childrenByParent->get($categoryId, collect()) as $child) {
$pendingIds[] = $child->id;
}
}
return array_values($categoryIds);
}
public function delete(CatalogItem $catalogItem): void public function delete(CatalogItem $catalogItem): void
{ {
DB::transaction(function () use ($catalogItem): void { DB::transaction(function () use ($catalogItem): void {

View File

@@ -225,7 +225,7 @@ class StockReservationService
$inventory = $inventories->get($line->inventory_id) $inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) { || ($line->tracks_inventory && $inventory->real_stock - $inventory->entry_reserved_stock < $line->quantity)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.'); throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
} }
} }

View File

@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation; use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\StockReservationLine; use App\Domains\Commerce\Catalog\Models\StockReservationLine;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -190,6 +191,7 @@ class VariantReplacementService
'sold_units' => $sourceInventory->sold_units, 'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units, 'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock, 'reserved_stock' => $reservedStock,
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'real_stock' => $sourceInventory->real_stock, 'real_stock' => $sourceInventory->real_stock,
]); ]);
@@ -198,7 +200,9 @@ class VariantReplacementService
->whereKey($activeLines->modelKeys()) ->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]); ->update(['inventory_id' => $replacementInventory->getKey()]);
} }
$sourceInventory->update(['reserved_stock' => 0]); EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $replacementInventory->id]);
$sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]);
return $replacementInventory; return $replacementInventory;
} }
@@ -235,6 +239,7 @@ class VariantReplacementService
$destinationInventory->update([ $destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, 'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, 'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, 'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units, 'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]); ]);
@@ -243,9 +248,12 @@ class VariantReplacementService
->whereKey($activeLines->modelKeys()) ->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]); ->update(['inventory_id' => $destinationInventory->getKey()]);
} }
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $destinationInventory->id]);
$sourceInventory->update([ $sourceInventory->update([
'real_stock' => 0, 'real_stock' => 0,
'reserved_stock' => 0, 'reserved_stock' => 0,
'entry_reserved_stock' => 0,
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0, 'refunded_units' => 0,
]); ]);

View File

@@ -34,3 +34,8 @@ Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenan
## Dependencias y reglas ## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad. Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
## Menús deprecados
Los menús `adminapp.combos` y `adminapp.categories` están retirados; las
categorías del catálogo y sus datos comerciales no se eliminan.

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Commerce\Purchase\Controllers; namespace App\Domains\Commerce\Purchase\Controllers;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest; use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest; use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest;
@@ -13,6 +12,7 @@ use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard; use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -151,11 +151,14 @@ class PurchaseController extends Controller
} }
$compra->refresh(); $compra->refresh();
$compra->loadMissing('event');
$totalAmount = (float) $compra->total; $totalAmount = (float) $compra->total;
if ($method === 'transfer') { if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $compra->event
? $telepagosService->forEvent($compra->event)
: $telepagosService->forTenant($tenant->codigo);
try { try {
$accountInfo = $telepagosService->getAccountInfo(); $accountInfo = $telepagosService->getAccountInfo();
@@ -191,7 +194,9 @@ class PurchaseController extends Controller
if ($method === 'qr') { if ($method === 'qr') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $compra->event
? $telepagosService->forEvent($compra->event)
: $telepagosService->forTenant($tenant->codigo);
try { try {
Log::channel('telepagos')->info('Generating Telepagos QR.', [ Log::channel('telepagos')->info('Generating Telepagos QR.', [

View File

@@ -2,13 +2,14 @@
namespace App\Domains\Commerce\Purchase\Models; namespace App\Domains\Commerce\Purchase\Models;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation; use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Commerce\Purchase\Events\PurchasePaid; use App\Domains\Commerce\Purchase\Events\PurchasePaid;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
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;
@@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB;
#[Fillable([ #[Fillable([
'cart_id', 'cart_id',
'stock_reservation_id', 'stock_reservation_id',
'event_id',
'tenant_codigo', 'tenant_codigo',
'user_id', 'user_id',
'status', 'status',
@@ -149,6 +151,7 @@ class Purchase extends Model
return [ return [
'cart_id' => 'integer', 'cart_id' => 'integer',
'stock_reservation_id' => 'integer', 'stock_reservation_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
'total' => 'decimal:2', 'total' => 'decimal:2',
]; ];
@@ -162,6 +165,12 @@ class Purchase extends Model
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
} }
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, $this>
*/ */

View File

@@ -36,11 +36,11 @@ class PurchaseResource extends JsonResource
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
/** @var Event|null $checkoutEvent */ /** @var Event|null $checkoutEvent */
$checkoutEvent = $usesEventSummary $checkoutEvent = $usesEventSummary
? $items ? ($this->event ?? $items
->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event) ->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event)
->filter() ->filter()
->unique('id') ->unique('id')
->first() ->first())
: null; : null;
$subtotal = $items->isNotEmpty() $subtotal = $items->isNotEmpty()
@@ -62,6 +62,7 @@ class PurchaseResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'cart_id' => $this->cart_id, 'cart_id' => $this->cart_id,
'event_id' => $this->event_id,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id, 'user_id' => $this->user_id,
'created_at' => $this->created_at, 'created_at' => $this->created_at,

View File

@@ -14,6 +14,7 @@ class PurchaseResponseLoader
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
$relations = [ $relations = [
'tenant', 'tenant',
'event.attachment',
'items.sourceCatalogItem.event.attachment', 'items.sourceCatalogItem.event.attachment',
'stockReservation', 'stockReservation',
]; ];

View File

@@ -206,6 +206,7 @@ class StartCheckoutService
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
), ),
$cart->getKey(), $cart->getKey(),
$cartItems->first()?->catalogItem?->event_id,
); );
$cart->update(['current_purchase_id' => $purchase->getKey()]); $cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
@@ -273,6 +274,7 @@ class StartCheckoutService
$purchaseData, $purchaseData,
$cart->getTotalAmount(), $cart->getTotalAmount(),
$cart->getKey(), $cart->getKey(),
$cartItems->first()?->catalogItem?->event_id,
); );
$cart->update(['current_purchase_id' => $purchase->getKey()]); $cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
@@ -410,10 +412,12 @@ class StartCheckoutService
array $purchaseData, array $purchaseData,
float $total, float $total,
?int $cartId, ?int $cartId,
?int $eventId,
): Purchase { ): Purchase {
return Purchase::query()->create([ return Purchase::query()->create([
...$purchaseData, ...$purchaseData,
'cart_id' => $cartId, 'cart_id' => $cartId,
'event_id' => $eventId,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'user_id' => $userId, 'user_id' => $userId,
'status' => Purchase::STATUS_CREATED, 'status' => Purchase::STATUS_CREATED,

View File

@@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService class PurchaseRefundSummaryService
{ {
public function totalForTenant(Tenant $tenant): string public function totalForTenant(Tenant $tenant, ?int $eventId = null): string
{ {
$total = TicketRefund::query() $total = TicketRefund::query()
->whereHas( ->whereHas(
'purchaseItem.purchase', 'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $purchase) => $purchase->where('event_id', $eventId))
) )
->sum('amount'); ->sum('amount');

View File

@@ -25,6 +25,9 @@ class TenantTransactionResetService
'carts' => $scope['cart_ids']->count(), 'carts' => $scope['cart_ids']->count(),
'cart_items' => $scope['cart_item_ids']->count(), 'cart_items' => $scope['cart_item_ids']->count(),
'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(), 'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(),
'entry_reservations' => DB::table('desfile_entry_reservations')
->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id',
DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->count(),
'stock_reservations' => $this->reservationQuery($scope)->count(), 'stock_reservations' => $this->reservationQuery($scope)->count(),
'purchase_changes' => DB::table('value_changes') 'purchase_changes' => DB::table('value_changes')
->where('tenant_code', $tenantCode) ->where('tenant_code', $tenantCode)
@@ -47,6 +50,10 @@ class TenantTransactionResetService
$telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(); $telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count();
$summary = [ $summary = [
'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(), 'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(),
'entry_reservations_deleted' => DB::table('desfile_entry_reservations')
->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id',
DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->delete(),
'entry_reservation_batches_deleted' => DB::table('desfile_reservation_batches')->where('tenant_code', $tenantCode)->delete(),
'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(), 'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(),
'purchase_changes_deleted' => DB::table('value_changes') 'purchase_changes_deleted' => DB::table('value_changes')
->where('tenant_code', $tenantCode) ->where('tenant_code', $tenantCode)
@@ -67,6 +74,7 @@ class TenantTransactionResetService
->update([ ->update([
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
'reserved_stock' => 0, 'reserved_stock' => 0,
'entry_reserved_stock' => 0,
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0, 'refunded_units' => 0,
]); ]);

View File

@@ -13,6 +13,7 @@ use App\Domains\Commerce\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService; use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService; use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
use App\Domains\Commerce\Sale\Services\AdminAppSaleService; use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -27,15 +28,20 @@ class SaleController extends Controller
protected AdminAppSaleExcelService $saleExcelService, protected AdminAppSaleExcelService $saleExcelService,
) {} ) {}
private function eventId(Request $request): ?int
{
return app(EventScopeService::class)->eventId($request->user());
}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection( return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated()) $this->saleService->sales($tenant, $request->validated(), $this->eventId($request))
)->additional([ )->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $this->eventId($request)),
'refunded_total' => $this->saleService->refundedTotal($tenant), 'refunded_total' => $this->saleService->refundedTotal($tenant, $this->eventId($request)),
]); ]);
} }
@@ -43,7 +49,7 @@ class SaleController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleDetailResource($this->saleService->detail($tenant, $sale)); return new SaleDetailResource($this->saleService->detail($tenant, $sale, $this->eventId($request)));
} }
public function tickets(Request $request, int $sale): AnonymousResourceCollection public function tickets(Request $request, int $sale): AnonymousResourceCollection
@@ -51,7 +57,7 @@ class SaleController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection( return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale) $this->saleService->tickets($tenant, $sale, $this->eventId($request))
); );
} }
@@ -59,14 +65,14 @@ class SaleController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale)); return new SaleResource($this->saleService->confirm($tenant, $sale, $this->eventId($request)));
} }
public function cancel(Request $request, int $sale): SaleResource public function cancel(Request $request, int $sale): SaleResource
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale)); return new SaleResource($this->saleService->cancel($tenant, $sale, $this->eventId($request)));
} }
public function modifications( public function modifications(
@@ -76,6 +82,7 @@ class SaleController extends Controller
$this->saleService->modifications( $this->saleService->modifications(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$this->eventId($request),
) )
); );
} }
@@ -86,7 +93,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadSales( return $this->salePdfService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated()), $this->saleService->salesForExport($tenant, $request->validated(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -97,7 +104,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadModifications( return $this->salePdfService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()), $this->saleService->modificationsForExport($tenant, $request->validated(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -108,7 +115,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadSales( return $this->saleExcelService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated()), $this->saleService->salesForExport($tenant, $request->validated(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -120,7 +127,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadModifications( return $this->saleExcelService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()), $this->saleService->modificationsForExport($tenant, $request->validated(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Commerce\Sale\Services; namespace App\Domains\Commerce\Sale\Services;
use App\Shared\Logging\Models\ValueChange;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
@@ -10,6 +9,7 @@ use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use App\Shared\Logging\Models\ValueChange;
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;
@@ -21,19 +21,20 @@ class AdminAppSaleService
protected PurchaseRefundSummaryService $refundSummaryService, protected PurchaseRefundSummaryService $refundSummaryService,
) {} ) {}
public function confirmedSalesTotal(Tenant $tenant): string public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string
{ {
$total = Purchase::query() $total = Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->where('status', Purchase::STATUS_PAID) ->where('status', Purchase::STATUS_PAID)
->sum('total'); ->sum('total');
return number_format((float) $total, 2, '.', ''); return number_format((float) $total, 2, '.', '');
} }
public function refundedTotal(Tenant $tenant): string public function refundedTotal(Tenant $tenant, ?int $eventId = null): string
{ {
return $this->refundSummaryService->totalForTenant($tenant); return $this->refundSummaryService->totalForTenant($tenant, $eventId);
} }
/** /**
@@ -47,43 +48,44 @@ class AdminAppSaleService
* } $filters * } $filters
* @return LengthAwarePaginator<Purchase> * @return LengthAwarePaginator<Purchase>
*/ */
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
{ {
return $this->salesQuery($tenant, $filters) return $this->salesQuery($tenant, $filters, $eventId)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
public function detail(Tenant $tenant, int $saleId): Purchase public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->with('items') ->with('items')
->findOrFail($saleId); ->findOrFail($saleId);
} }
/** @return Collection<int, Ticket> */ /** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, int $saleId): Collection public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection
{ {
return $this->findForTenant($tenant, $saleId) return $this->findForTenant($tenant, $saleId, $eventId)
->tickets() ->tickets()
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund'])
->orderBy('id') ->orderBy('id')
->get(); ->get();
} }
public function confirm(Tenant $tenant, int $saleId): Purchase public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
$sale = $this->findForTenant($tenant, $saleId); $sale = $this->findForTenant($tenant, $saleId, $eventId);
return $this->saleForResponse( return $this->saleForResponse(
$this->checkoutService->confirmPaidPurchase($sale) $this->checkoutService->confirmPaidPurchase($sale)
); );
} }
public function cancel(Tenant $tenant, int $saleId): Purchase public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
$sale = $this->findForTenant($tenant, $saleId); $sale = $this->findForTenant($tenant, $saleId, $eventId);
return $this->saleForResponse( return $this->saleForResponse(
$this->checkoutService->cancelPurchaseFromAdmin($sale) $this->checkoutService->cancelPurchaseFromAdmin($sale)
@@ -94,18 +96,18 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, Purchase> * @return Collection<int, Purchase>
*/ */
public function salesForExport(Tenant $tenant, array $filters = []): Collection public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
{ {
return $this->salesQuery($tenant, $filters)->get(); return $this->salesQuery($tenant, $filters, $eventId)->get();
} }
/** /**
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return LengthAwarePaginator<ValueChange> * @return LengthAwarePaginator<ValueChange>
*/ */
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
{ {
return $this->modificationsQuery($tenant, $filters) return $this->modificationsQuery($tenant, $filters, $eventId)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
@@ -114,13 +116,13 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, ValueChange> * @return Collection<int, ValueChange>
*/ */
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
{ {
return $this->modificationsQuery($tenant, $filters)->get(); return $this->modificationsQuery($tenant, $filters, $eventId)->get();
} }
/** @param array<string, mixed> $filters */ /** @param array<string, mixed> $filters */
protected function salesQuery(Tenant $tenant, array $filters): Builder protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
{ {
$sortColumns = [ $sortColumns = [
'id' => 'id', 'id' => 'id',
@@ -139,6 +141,7 @@ class AdminAppSaleService
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -176,11 +179,14 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Builder<ValueChange> * @return Builder<ValueChange>
*/ */
protected function modificationsQuery(Tenant $tenant, array $filters): Builder protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
{ {
return ValueChange::query() return ValueChange::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass()) ->where('trackable_type', (new Purchase)->getMorphClass())
->when($eventId !== null, fn (Builder $query) => $query->whereHasMorph(
'trackable', [Purchase::class],
fn (Builder $sales) => $sales->where('event_id', $eventId)))
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -221,10 +227,11 @@ class AdminAppSaleService
->orderByDesc('id'); ->orderByDesc('id');
} }
protected function findForTenant(Tenant $tenant, int $saleId): Purchase protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->findOrFail($saleId); ->findOrFail($saleId);
} }

View File

@@ -30,3 +30,10 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel. La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.
## Alcance por evento
Las rutas usan `adminapp.tenant:event`. Para admins de evento, listados, totales,
detalles, tickets de compras, confirmación, cancelación, historial y exportaciones
se filtran por `user.event_id` además del tenant. Las compras pertenecen a un
único evento. Un admin de tenant conserva acceso a sus compras de todos los eventos.

View File

@@ -4,7 +4,7 @@ use App\Domains\Commerce\Sale\Controllers\AdminApp\SaleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant:event'])
->group(function (): void { ->group(function (): void {
Route::get('sales', [SaleController::class, 'index']); Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Core\Auth\Enums;
enum AdminScope: string
{
case Tenant = 'tenant';
case Event = 'event';
}

View File

@@ -2,11 +2,13 @@
namespace App\Domains\Core\Auth\Models; namespace App\Domains\Core\Auth\Models;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Authorization\Models\Role;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -20,7 +22,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] #[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'admin_scope', 'event_id'])]
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])] #[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
class User extends Authenticatable class User extends Authenticatable
{ {
@@ -29,6 +31,7 @@ class User extends Authenticatable
protected $attributes = [ protected $attributes = [
'rol_codigo' => RoleCode::User->value, 'rol_codigo' => RoleCode::User->value,
'admin_scope' => AdminScope::Tenant->value,
]; ];
protected static function newFactory(): UserFactory protected static function newFactory(): UserFactory
@@ -86,6 +89,19 @@ class User extends Authenticatable
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
} }
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function isTenantAdministrator(): bool
{
return $this->rol_codigo === RoleCode::AdminApp->value
&& $this->admin_scope === AdminScope::Tenant->value
&& $this->event_id === null;
}
/** @return BelongsToMany<Category, $this> */ /** @return BelongsToMany<Category, $this> */
public function scanCategories(): BelongsToMany public function scanCategories(): BelongsToMany
{ {
@@ -103,6 +119,7 @@ class User extends Authenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'event_id' => 'integer',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'failed_login_attempts' => 'integer', 'failed_login_attempts' => 'integer',

View File

@@ -20,6 +20,11 @@ class AdminAppMeResource extends JsonResource
return [ return [
'user' => UserResource::make($this->resource), 'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant), 'tenant' => TenantResource::make($this->tenant),
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
'id' => $this->event->id,
'title' => $this->event->title,
'tenant_code' => $this->event->tenant_code,
]),
]; ];
} }
} }

View File

@@ -16,6 +16,9 @@ class ScannerMeResource extends JsonResource
return [ return [
'user' => UserResource::make($this->resource), 'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant), 'tenant' => TenantResource::make($this->tenant),
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
'id' => $this->event->id, 'title' => $this->event->title,
]),
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Core\Auth\Resources; namespace App\Domains\Core\Auth\Resources;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -24,6 +25,8 @@ class UserResource extends JsonResource
'telefono' => $this->telefono, 'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'admin_scope' => $this->when($this->rol_codigo === RoleCode::AdminApp->value, $this->admin_scope),
'event_id' => $this->when(in_array($this->rol_codigo, [RoleCode::AdminApp->value, RoleCode::Scanner->value], true), $this->event_id),
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [ ->map(fn ($category) => [
'id' => $category->id, 'id' => $category->id,

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Core\Auth\Services;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
class AdminAppAccessService
{
public function hasValidScope(User $user): bool
{
if ($user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo
|| ! $user->tenant()->exists()) {
return false;
}
if ($user->isTenantAdministrator()) {
return true;
}
return $user->admin_scope === AdminScope::Event->value
&& $user->event_id !== null
&& $user->event()->where('tenant_code', $user->tenant_codigo)->exists();
}
}

View File

@@ -20,6 +20,7 @@ class AdminAppContextService
->firstOrFail(); ->firstOrFail();
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
$user->load('event');
return $user; return $user;
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Core\Auth\Services;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Auth\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
class EventScopeService
{
public function eventId(User $user): ?int
{
if ($user->admin_scope === AdminScope::Event->value || $user->event_id !== null) {
if ($user->event_id === null
|| ! $user->event()->where('tenant_code', $user->tenant_codigo)->exists()) {
throw new AuthorizationException;
}
return $user->event_id;
}
return null;
}
}

View File

@@ -10,6 +10,7 @@ use App\Domains\Core\Authorization\Enums\PermissionCode;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Shared\Notification\Events\PasswordResetRequested; use App\Shared\Notification\Events\PasswordResetRequested;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -19,6 +20,7 @@ class PasswordLoginService
{ {
public function __construct( public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService, private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
private readonly AdminAppAccessService $adminAppAccessService,
) {} ) {}
/** /**
@@ -196,6 +198,26 @@ class PasswordLoginService
]; ];
} }
if ($requiredRole === RoleCode::AdminApp && ! $this->adminAppAccessService->hasValidScope($user)) {
$this->recordAttempt(
$user, $normalizedEmail, $attemptTenantCode,
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent,
);
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
}
if ($requiredRole === RoleCode::Scanner) {
try {
app(EventScopeService::class)->eventId($user);
} catch (AuthorizationException) {
$this->recordAttempt($user, $normalizedEmail, $attemptTenantCode,
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent);
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
}
}
$user->forceFill([ $user->forceFill([
'failed_login_attempts' => 0, 'failed_login_attempts' => 0,
'last_failed_login_at' => null, 'last_failed_login_at' => null,

View File

@@ -18,6 +18,7 @@ class ScannerContextService
->firstOrFail(); ->firstOrFail();
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
$user->load('event');
if ($tenant->requiresScannerCategoryValidation()) { if ($tenant->requiresScannerCategoryValidation()) {
$categories = $user->scanCategories() $categories = $user->scanCategories()

View File

@@ -17,6 +17,6 @@ Route::prefix('v1/adminapp')->group(function (): void {
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'adminapp') ->defaults('reset_role', 'adminapp')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'adminapp.tenant']) Route::middleware(['auth:sanctum', 'adminapp.tenant:context'])
->get('me', AdminAppMeController::class); ->get('me', AdminAppMeController::class);
}); });

View File

@@ -2,8 +2,9 @@
namespace App\Domains\Core\Client\Models; namespace App\Domains\Core\Client\Models;
use App\Shared\Integration\Models\ClientIntegration;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Integration\Models\ClientIntegration;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -22,6 +23,12 @@ class Client extends Model
return $this->hasMany(Tenant::class); return $this->hasMany(Tenant::class);
} }
/** @return HasMany<Event, $this> */
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
/** @return HasMany<ClientIntegration, $this> */ /** @return HasMany<ClientIntegration, $this> */
public function integrations(): HasMany public function integrations(): HasMany
{ {

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Core\Staff\Controllers; namespace App\Domains\Core\Staff\Controllers;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Staff\Requests\StoreStaffRequest; use App\Domains\Core\Staff\Requests\StoreStaffRequest;
use App\Domains\Core\Staff\Requests\UpdateStaffRequest; use App\Domains\Core\Staff\Requests\UpdateStaffRequest;
use App\Domains\Core\Staff\Resources\StaffResource; use App\Domains\Core\Staff\Resources\StaffResource;
@@ -21,11 +22,17 @@ class AdminAppStaffController extends Controller
private readonly ScannerTicketService $scannerTicketService, private readonly ScannerTicketService $scannerTicketService,
) {} ) {}
private function eventId(Request $request): ?int
{
return app(EventScopeService::class)->eventId($request->user());
}
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
return StaffResource::collection($this->staffService->list( return StaffResource::collection($this->staffService->list(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->string('search')->trim()->toString() ?: null, $request->string('search')->trim()->toString() ?: null,
$this->eventId($request),
)); ));
} }
@@ -34,6 +41,7 @@ class AdminAppStaffController extends Controller
return StaffResource::make($this->staffService->create( return StaffResource::make($this->staffService->create(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$this->eventId($request),
)); ));
} }
@@ -43,12 +51,13 @@ class AdminAppStaffController extends Controller
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$request->validated(), $request->validated(),
$this->eventId($request),
)); ));
} }
public function destroy(Request $request, int $staff): Response public function destroy(Request $request, int $staff): Response
{ {
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff); $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $this->eventId($request));
return response()->noContent(); return response()->noContent();
} }
@@ -60,6 +69,7 @@ class AdminAppStaffController extends Controller
$scanner = $this->staffService->find( $scanner = $this->staffService->find(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$this->eventId($request),
); );
return ScanAttemptResource::collection( return ScanAttemptResource::collection(

View File

@@ -14,6 +14,7 @@ class StaffResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'event_id' => $this->event_id,
'nombre_apellido' => $this->nombre_apellido, 'nombre_apellido' => $this->nombre_apellido,
'dni' => $this->dni, 'dni' => $this->dni,
'email' => $this->email, 'email' => $this->email,

View File

@@ -2,12 +2,13 @@
namespace App\Domains\Core\Staff\Services; namespace App\Domains\Core\Staff\Services;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService; use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -22,9 +23,9 @@ class StaffService
) {} ) {}
/** @return Collection<int, User> */ /** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
{ {
return $this->staffQuery($tenant) return $this->staffQuery($tenant, $eventId)
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')]) ->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
->when($search, function (Builder $query, string $search): void { ->when($search, function (Builder $query, string $search): void {
$query->where(function (Builder $query) use ($search): void { $query->where(function (Builder $query) use ($search): void {
@@ -38,7 +39,7 @@ class StaffService
} }
/** @return Collection<int, Category> */ /** @return Collection<int, Category> */
private function assignableCategories(Tenant $tenant): Collection private function assignableCategories(Tenant $tenant, ?int $eventId = null): Collection
{ {
return Category::query() return Category::query()
->whereNull('categoria_id') ->whereNull('categoria_id')
@@ -47,23 +48,30 @@ class StaffService
->orWhereHas('catalogItems', fn (Builder $items) => $items ->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo)); ->where('tenant_code', $tenant->codigo));
}) })
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
->orderBy('nombre') ->orderBy('nombre')
->get(); ->get();
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function create(Tenant $tenant, array $data): User public function create(Tenant $tenant, array $data, ?int $eventId = null): User
{ {
$eventId ??= $tenant->active_event_id;
Event::query()
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds); $this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId);
return DB::transaction(function () use ($tenant, $data, $categoryIds): User { return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User {
$staff = User::query()->create([ $staff = User::query()->create([
...Arr::only($data, ['nombre_apellido', 'dni', 'email']), ...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
'email' => mb_strtolower(trim((string) $data['email'])), 'email' => mb_strtolower(trim((string) $data['email'])),
'password' => Str::random(64), 'password' => Str::random(64),
'rol_codigo' => RoleCode::Scanner->value, 'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'event_id' => $eventId,
'admin_scope' => $eventId === null ? 'tenant' : 'event',
]); ]);
$staff->scanCategories()->sync($categoryIds); $staff->scanCategories()->sync($categoryIds);
$this->resetPasswordAttemptService->createForScannerEmail( $this->resetPasswordAttemptService->createForScannerEmail(
@@ -76,11 +84,11 @@ class StaffService
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function update(Tenant $tenant, int $staffId, array $data): User public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User
{ {
$staff = $this->find($tenant, $staffId); $staff = $this->find($tenant, $staffId, $eventId);
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds); $this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId);
return DB::transaction(function () use ($staff, $data, $categoryIds): User { return DB::transaction(function () use ($staff, $data, $categoryIds): User {
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']); $attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
@@ -92,9 +100,9 @@ class StaffService
}); });
} }
public function delete(Tenant $tenant, int $staffId): void public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void
{ {
$staff = $this->find($tenant, $staffId); $staff = $this->find($tenant, $staffId, $eventId);
DB::transaction(function () use ($staff): void { DB::transaction(function () use ($staff): void {
$staff->tokens()->delete(); $staff->tokens()->delete();
@@ -102,23 +110,24 @@ class StaffService
}); });
} }
public function find(Tenant $tenant, int $staffId): User public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User
{ {
return $this->staffQuery($tenant)->findOrFail($staffId); return $this->staffQuery($tenant, $eventId)->findOrFail($staffId);
} }
private function staffQuery(Tenant $tenant): Builder private function staffQuery(Tenant $tenant, ?int $eventId = null): Builder
{ {
return User::query() return User::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('rol_codigo', RoleCode::Scanner->value); ->where('rol_codigo', RoleCode::Scanner->value)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId));
} }
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return array<int, int> * @return array<int, int>
*/ */
private function categoryIdsFor(Tenant $tenant, array $data): array private function categoryIdsFor(Tenant $tenant, array $data, ?int $eventId = null): array
{ {
if (! $tenant->requiresScannerCategoryValidation()) { if (! $tenant->requiresScannerCategoryValidation()) {
return []; return [];
@@ -128,9 +137,9 @@ class StaffService
} }
/** @param array<int, int> $categoryIds */ /** @param array<int, int> $categoryIds */
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds, ?int $eventId = null): void
{ {
$validIds = $this->assignableCategories($tenant) $validIds = $this->assignableCategories($tenant, $eventId)
->whereIn('id', $categoryIds) ->whereIn('id', $categoryIds)
->pluck('id'); ->pluck('id');

View File

@@ -18,3 +18,13 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido
## Dependencias y reglas ## Dependencias y reglas
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado. Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.
## Alcance por evento
Los endpoints de Staff y su formulario aceptan `adminapp.tenant:event`. Un admin
de evento lista, edita, elimina y consulta el historial solo de scanners de su
evento. El backend asigna el evento al crear un scanner y no acepta cambios de
asignación desde el formulario. Las categorías autorizables se limitan a las
usadas por productos del evento. Los scanners aplican además su evento en
lectura de tickets, escaneo e historial. Los intentos registran `event_id` para
conservar el aislamiento aunque cambie la asignación del scanner.

View File

@@ -4,7 +4,7 @@ use App\Domains\Core\Staff\Controllers\AdminAppStaffController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant:event'])
->group(function (): void { ->group(function (): void {
Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']); Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']);
Route::apiResource('staff', AdminAppStaffController::class)->except('show'); Route::apiResource('staff', AdminAppStaffController::class)->except('show');

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Domains\Ticketing\Desfile\Controllers;
use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest;
use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest;
use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest;
use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource;
use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService;
use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService;
use App\Domains\Ticketing\Desfile\Services\EntryReservationService;
use App\Domains\Ticketing\Ticket\Services\TicketPdfService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EntryReservationController extends Controller
{
public function index(IndexEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection
{
return EntryReservationResource::collection(
$service->reservations($request->user(), $request->validated()),
);
}
public function downloadPdf(
ExportEntryReservationsRequest $request,
EntryReservationService $service,
EntryReservationPdfService $pdf,
): Response {
$user = $request->user();
return $pdf->download(
$user->tenant()->firstOrFail(),
$service->reservationsForExport($user, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadExcel(
ExportEntryReservationsRequest $request,
EntryReservationService $service,
EntryReservationExcelService $excel,
): StreamedResponse {
$user = $request->user();
return $excel->download(
$user->tenant()->firstOrFail(),
$service->reservationsForExport($user, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadTicketPdf(
IndexEntryReservationsRequest $request,
int $reservation,
EntryReservationService $service,
TicketPdfService $pdf,
): Response {
$user = $request->user();
return $pdf->download(
$user->tenant()->firstOrFail(),
collect([$service->reservationTicket($user, $reservation)]),
);
}
public function store(StoreEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection
{
return EntryReservationResource::collection($service->reserve(
$request->user(), $request->validated('idempotency_key'), $request->validated('rows'),
));
}
public function destroy(
IndexEntryReservationsRequest $request,
int $reservation,
EntryReservationService $service,
): Response {
$service->cancel($request->user(), $reservation);
return response()->noContent();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Ticketing\Desfile\Enums;
enum EntryReservationPaymentType: string
{
case Free = 'sin_cargo';
case Other = 'otro_metodo';
public function label(): string
{
return match ($this) {
self::Free => 'Sin cargo',
self::Other => 'Otro método',
};
}
/** @return list<array{value: string, label: string}> */
public static function options(): array
{
return array_map(
fn (self $type): array => [
'value' => $type->value,
'label' => $type->label(),
],
self::cases(),
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Ticketing\Desfile\Models;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'variant_id',
'ticket_id',
'inventory_id',
'batch_id',
'fecha_reserva',
'importe',
'tipo_pago',
])]
class EntryReservation extends Model
{
use SoftDeletes;
protected $table = 'desfile_entry_reservations';
protected function casts(): array
{
return [
'variant_id' => 'integer',
'fecha_reserva' => 'datetime',
'importe' => 'decimal:2',
'tipo_pago' => EntryReservationPaymentType::class,
];
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class)->withTrashed();
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Ticketing\Desfile\Requests;
use App\Shared\Rules\ValidTimezone;
class ExportEntryReservationsRequest extends IndexEntryReservationsRequest
{
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Ticketing\Desfile\Requests;
use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class IndexEntryReservationsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->tenant_codigo === 'desfile_pura_tendencia';
}
public function rules(): array
{
return [
'tipo_pago' => ['sometimes', 'nullable', Rule::enum(EntryReservationPaymentType::class)],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Ticketing\Desfile\Requests;
use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEntryReservationsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->tenant_codigo === 'desfile_pura_tendencia';
}
public function rules(): array
{
return [
'idempotency_key' => ['required', 'uuid'],
'rows' => ['required', 'array', 'min:1', 'max:100'],
'rows.*' => ['required', 'array:variant_id,tipo_pago'],
'rows.*.variant_id' => ['required', 'integer', 'min:1', 'distinct'],
'rows.*.tipo_pago' => ['required', Rule::enum(EntryReservationPaymentType::class)],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Ticketing\Desfile\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EntryReservationResource extends JsonResource
{
public function toArray(Request $request): array
{
$selection = $this->relationLoaded('variant')
? $this->variant->selectionOptions()
: collect();
return [
'id' => $this->id,
'variant_id' => $this->variant_id,
'ticket_id' => $this->ticket_id,
'fecha_reserva' => $this->fecha_reserva->toIso8601String(),
'tipo_pago' => $this->tipo_pago->value,
'tipo_pago_label' => $this->tipo_pago->label(),
'importe' => $this->importe,
'entrada' => [
'tipo' => $this->selectionLabel($selection->get('tipo')),
'sector' => $this->selectionLabel($selection->get('sector')),
'fila' => $this->selectionLabel($selection->get('fila')),
'asiento' => $this->selectionLabel($selection->get('asiento')),
],
];
}
private function selectionLabel(mixed $selection): ?string
{
if (! is_array($selection)) {
return null;
}
if (array_is_list($selection)) {
$labels = collect($selection)->pluck('label')->filter()->implode(', ');
return $labels !== '' ? $labels : null;
}
return isset($selection['label']) ? (string) $selection['label'] : null;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Domains\Ticketing\Desfile\Services;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use Illuminate\Support\Collection;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EntryReservationExcelService
{
public function __construct(private readonly EntryReservationReportService $report) {}
/** @param Collection<int, EntryReservation> $reservations */
public function download(Tenant $tenant, Collection $reservations, string $timeZone): StreamedResponse
{
$generatedAt = now();
$rows = $this->report->rows($reservations);
$spreadsheet = new Spreadsheet;
$spreadsheet->getProperties()
->setCreator('Shopit')
->setTitle('Reservas de entradas')
->setSubject($tenant->nombre);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Reservas');
$sheet->fromArray([
'Tipo',
'Sector',
'Fila',
'Asiento',
'ID',
'Fecha',
'Importe',
'Pago',
], null, 'A1');
foreach ($rows as $index => $reservation) {
$row = $index + 2;
foreach (['A' => 'tipo', 'B' => 'sector', 'C' => 'fila', 'D' => 'asiento'] as $column => $key) {
$sheet->setCellValueExplicit("{$column}{$row}", $reservation[$key], DataType::TYPE_STRING);
}
$sheet->setCellValueExplicit(
"E{$row}",
$reservation['ticket_id'] === null ? '-' : (string) $reservation['ticket_id'],
DataType::TYPE_STRING,
);
$sheet->setCellValue(
"F{$row}",
Date::dateTimeToExcel($reservation['fecha_reserva']->copy()->timezone($timeZone)),
);
if ($reservation['importe'] !== null) {
$sheet->setCellValue("G{$row}", (float) $reservation['importe']);
}
$sheet->setCellValueExplicit("H{$row}", $reservation['pago'], DataType::TYPE_STRING);
}
$lastRow = max(2, $rows->count() + 1);
$sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
$sheet->getStyle("G2:G{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00');
$sheet->getStyle('A1:H1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '26382E'],
],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:H{$lastRow}");
foreach (['A' => 20, 'B' => 22, 'C' => 12, 'D' => 12, 'E' => 16, 'F' => 20, 'G' => 16, 'H' => 18] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$filename = 'reservas_entradas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx';
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Ticketing\Desfile\Services;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class EntryReservationPdfService
{
public function __construct(private readonly EntryReservationReportService $report) {}
/** @param Collection<int, EntryReservation> $reservations */
public function download(Tenant $tenant, Collection $reservations, string $timeZone): Response
{
$generatedAt = now();
$rows = $this->report->rows($reservations);
$pdf = Pdf::loadView('pdf.adminapp.desfile-entry-reservations', [
'tenant' => $tenant,
'reservations' => $rows,
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'reservas_entradas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}
private function addPageNumbers(DomPdf $pdf): void
{
$pdf->render();
$domPdf = $pdf->getDomPDF();
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
$domPdf->getCanvas()->page_text(
385,
575,
'Página {PAGE_NUM} de {PAGE_COUNT}',
$font,
7,
[0.48, 0.52, 0.49],
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Ticketing\Desfile\Services;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use Illuminate\Support\Collection;
class EntryReservationReportService
{
/**
* @param Collection<int, EntryReservation> $reservations
* @return Collection<int, array<string, mixed>>
*/
public function rows(Collection $reservations): Collection
{
return $reservations->values()->map(function (EntryReservation $reservation): array {
$selection = $reservation->variant->selectionOptions();
return [
'tipo' => $this->selectionLabel($selection->get('tipo')),
'sector' => $this->selectionLabel($selection->get('sector')),
'fila' => $this->selectionLabel($selection->get('fila')),
'asiento' => $this->selectionLabel($selection->get('asiento')),
'ticket_id' => $reservation->ticket_id,
'fecha_reserva' => $reservation->fecha_reserva,
'importe' => $reservation->importe,
'pago' => $reservation->tipo_pago->label(),
];
});
}
private function selectionLabel(mixed $selection): string
{
if (! is_array($selection)) {
return '-';
}
if (array_is_list($selection)) {
return collect($selection)->pluck('label')->filter()->implode(', ') ?: '-';
}
return isset($selection['label']) ? (string) $selection['label'] : '-';
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Domains\Ticketing\Desfile\Services;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EntryReservationService
{
public function __construct(private readonly TicketGeneratorService $tickets) {}
/**
* @param array{tipo_pago?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<EntryReservation>
*/
public function reservations(User $user, array $filters = []): LengthAwarePaginator
{
abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403);
return $this->reservationsQuery($user, $filters)
->paginate(
perPage: $filters['per_page'] ?? 15,
pageName: 'page',
page: $filters['page'] ?? 1,
)
->withQueryString();
}
/**
* @param array{tipo_pago?: string|null} $filters
* @return Collection<int, EntryReservation>
*/
public function reservationsForExport(User $user, array $filters = []): Collection
{
abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403);
return $this->reservationsQuery($user, $filters)->get();
}
public function reservationTicket(User $user, int $reservationId): Ticket
{
abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403);
$reservation = EntryReservation::query()
->whereKey($reservationId)
->whereNotNull('ticket_id')
->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query
->where('tenant_code', $user->tenant_codigo)
->where('slug', 'entrada'))
->with(['ticket' => fn ($query) => $query->with([
...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS,
])])
->firstOrFail();
return $reservation->ticket;
}
public function cancel(User $user, int $reservationId): void
{
abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403);
DB::transaction(function () use ($user, $reservationId): void {
$reservation = EntryReservation::query()
->whereKey($reservationId)
->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query
->where('tenant_code', $user->tenant_codigo)
->where('slug', 'entrada'))
->lockForUpdate()
->firstOrFail();
if ($reservation->ticket_id !== null) {
$ticket = Ticket::query()->lockForUpdate()->findOrFail($reservation->ticket_id);
if (! $ticket->can_cancel()) {
throw ValidationException::withMessages([
'status' => 'El ticket debe estar activo para poder cancelar la reserva.',
]);
}
$ticket->markAsCancelled();
$ticket->save();
}
if ($reservation->inventory_id !== null) {
$inventory = Inventory::query()->lockForUpdate()->findOrFail($reservation->inventory_id);
$inventory->releaseEntry(1);
}
$reservation->delete();
}, 3);
}
/** @param array{tipo_pago?: string|null} $filters */
private function reservationsQuery(User $user, array $filters = []): Builder
{
return EntryReservation::query()
->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query
->where('tenant_code', $user->tenant_codigo)
->where('slug', 'entrada'))
->when(
$filters['tipo_pago'] ?? null,
fn (Builder $query, string $paymentType): Builder => $query->where('tipo_pago', $paymentType),
)
->with([
'variant.catalogItem.itemAttributes.attribute.options',
'variant.definitions.itemAttribute.attribute.options',
'variant.eventDates',
'variant.eventDate',
])
->orderByDesc('fecha_reserva')
->orderByDesc('id');
}
/** @param list<array{variant_id: int, tipo_pago: string}> $rows */
public function reserve(User $user, string $key, array $rows): Collection
{
abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403);
$normalized = collect($rows)->map(fn (array $row): array => [
'variant_id' => (int) $row['variant_id'], 'tipo_pago' => $row['tipo_pago'],
])->sortBy('variant_id')->values()->all();
$hash = hash('sha256', json_encode($normalized, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($user, $key, $rows, $hash): Collection {
// Serialize retries by the same administrator, including the first insert.
User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
$batch = DB::table('desfile_reservation_batches')
->where('user_id', $user->id)->where('idempotency_key', $key)->lockForUpdate()->first();
if ($batch !== null) {
abort_unless($batch->tenant_code === $user->tenant_codigo && hash_equals($batch->request_hash, $hash), 409,
'La clave de envío ya fue utilizada con otras entradas.');
return EntryReservation::query()->where('batch_id', $batch->id)->with('ticket')->orderBy('id')->get();
}
$tenant = $user->tenant()->firstOrFail();
$entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada')
->lockForUpdate()->firstOrFail();
$ids = array_column($rows, 'variant_id');
$variants = $entry->variants()->whereKey($ids)->orderBy('id')->lockForUpdate()->get();
$inventories = Inventory::query()->whereKey($variants->pluck('inventory_id')->filter()->unique())
->orderBy('id')->lockForUpdate()->get()->keyBy('id');
$variants->load([
'eventDates', 'eventDate',
'desfileEntryReservations' => fn ($query) => $query->lockForUpdate(),
]);
foreach ($variants as $variant) {
$variant->setRelation('inventory', $inventories->get($variant->inventory_id));
}
$entry->setRelation('variants', $variants);
$available = $entry->visibleVariants()->keyBy('id');
$requirements = [];
$errors = [];
foreach ($rows as $index => $row) {
$variant = $available->get($row['variant_id']);
if ($variant === null || $variant->inventory === null) {
$errors["rows.{$index}.variant_id"] = $this->entryLabel($variants->firstWhere('id', $row['variant_id']), $index).': la entrada ya no está disponible.';
continue;
}
$requirements[$variant->inventory_id] = ($requirements[$variant->inventory_id] ?? 0) + 1;
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
$tracked = $entry->inventory_policy !== InventoryPolicy::Unlimited;
foreach ($requirements as $inventoryId => $quantity) {
if ($tracked && $inventories[$inventoryId]->availableStock() < $quantity) {
foreach ($rows as $index => $row) {
$variant = $available[$row['variant_id']];
if ($variant->inventory_id === $inventoryId) {
$errors["rows.{$index}.variant_id"] = $this->entryLabel($variant, $index).': no hay stock suficiente para reservar las entradas seleccionadas.';
}
}
}
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
$batchId = DB::table('desfile_reservation_batches')->insertGetId([
'user_id' => $user->id, 'tenant_code' => $tenant->codigo,
'idempotency_key' => $key, 'request_hash' => $hash,
'created_at' => now(), 'updated_at' => now(),
]);
foreach ($requirements as $inventoryId => $quantity) {
$inventories[$inventoryId]->reserveEntry($quantity, $tracked);
}
$reservations = collect();
foreach ($rows as $index => $row) {
$variant = $available[$row['variant_id']];
$payment = EntryReservationPaymentType::from($row['tipo_pago']);
try {
$ticket = $this->tickets->generate($entry, $user, 1, $variant->id)->sole();
} catch (TicketGenerationException $exception) {
throw ValidationException::withMessages([
"rows.{$index}.variant_id" => $this->entryLabel($variant, $index).': no se pudo emitir el ticket. '.$exception->getMessage(),
]);
}
$reservation = EntryReservation::query()->create([
'batch_id' => $batchId, 'ticket_id' => $ticket->id,
'variant_id' => $variant->id, 'inventory_id' => $variant->inventory_id,
'fecha_reserva' => now(), 'tipo_pago' => $payment,
'importe' => $payment === EntryReservationPaymentType::Free ? 0 : $variant->getPrice(),
]);
$reservations->push($reservation->setRelation('ticket', $ticket));
}
return $reservations;
}, 3);
}
private function entryLabel(?Variant $variant, int $index): string
{
if ($variant === null) {
return 'Entrada '.($index + 1);
}
$values = $variant->selectionValues();
return collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento'])
->map(fn (string $label, string $key): string => $label.': '.($values->get($key) ?? 'sin especificar'))
->implode(', ');
}
}

View File

@@ -2,13 +2,13 @@
namespace App\Domains\Ticketing\Desfile\Services; namespace App\Domains\Ticketing\Desfile\Services;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -254,7 +254,7 @@ class EntryService
{ {
$inventory = $variant->inventory; $inventory = $variant->inventory;
if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) { if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->entry_reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
$key => [ $key => [
'No se puede modificar ni eliminar un asiento con ventas o reservas.', 'No se puede modificar ni eliminar un asiento con ventas o reservas.',

View File

@@ -393,7 +393,7 @@ class InvitationPurchaseProvisioner
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first(); $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) { if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) {
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible."); throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
} }

View File

@@ -1,8 +1,30 @@
<?php <?php
use App\Domains\Ticketing\Desfile\Controllers\EntryController; use App\Domains\Ticketing\Desfile\Controllers\EntryController;
use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.index');
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.pdf');
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.excel');
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
->whereNumber('reservation')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.ticket.pdf');
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.store');
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
->whereNumber('reservation')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.destroy');
Route::prefix('v1/adminapp/tenant/desfile') Route::prefix('v1/adminapp/tenant/desfile')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
->group(function (): void { ->group(function (): void {

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticketing\Event\Controllers\AdminApp; namespace App\Domains\Ticketing\Event\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest; use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest; use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest;
@@ -19,7 +20,8 @@ class EventController extends Controller
public function show(Request $request): EventResource public function show(Request $request): EventResource
{ {
return EventResource::make( return EventResource::make(
$this->eventService->forTenant($request->user()->tenant()->firstOrFail()) $this->eventService->forTenant($request->user()->tenant()->firstOrFail(),
app(EventScopeService::class)->eventId($request->user()))
); );
} }
@@ -28,7 +30,8 @@ class EventController extends Controller
return EventResource::make( return EventResource::make(
$this->eventService->updateForTenant( $this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated() $request->validated(),
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }
@@ -39,6 +42,7 @@ class EventController extends Controller
$this->eventService->createDateForTenant( $this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
app(EventScopeService::class)->eventId($request->user()),
) )
); );
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticketing\Event\Models; namespace App\Domains\Ticketing\Event\Models;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
@@ -14,14 +15,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] #[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id', 'allow_ticket_refund', 'allow_ticket_total_refund', 'allow_ticket_partial_refund', 'ticket_partial_refund_percentage'])]
class Event extends Model class Event extends Model
{ {
use HasFactory; use HasFactory;
protected function casts(): array protected function casts(): array
{ {
return ['published_at' => 'datetime', 'exact_location' => 'array']; return ['allow_ticket_refund' => 'boolean', 'allow_ticket_total_refund' => 'boolean',
'allow_ticket_partial_refund' => 'boolean', 'ticket_partial_refund_percentage' => 'decimal:2', 'client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
}
public function allow_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund());
}
public function allow_partial_refund(): bool
{
return (bool) $this->allow_ticket_refund && (bool) $this->allow_ticket_partial_refund
&& (float) $this->ticket_partial_refund_percentage > 0;
} }
/** @return BelongsTo<Tenant, $this> */ /** @return BelongsTo<Tenant, $this> */
@@ -30,6 +44,17 @@ class Event extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
} }
/** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function effectiveClient(): Client
{
return $this->client ?? $this->tenant->client;
}
/** @return BelongsTo<EventCategory, $this> */ /** @return BelongsTo<EventCategory, $this> */
public function eventCategory(): BelongsTo public function eventCategory(): BelongsTo
{ {

View File

@@ -26,10 +26,10 @@ class EventResource extends JsonResource
'date_text' => $this->date_text, 'date_text' => $this->date_text,
'published_at' => $this->published_at?->toIso8601String(), 'published_at' => $this->published_at?->toIso8601String(),
'attachment_id' => $this->attachment_id, 'attachment_id' => $this->attachment_id,
'allow_ticket_refund' => $this->tenant->allow_ticket_refund, 'allow_ticket_refund' => $this->allow_ticket_refund,
'allow_ticket_total_refund' => $this->tenant->allow_ticket_total_refund, 'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->tenant->allow_ticket_partial_refund, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->tenant->ticket_partial_refund_percentage, 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'dates' => EventDateResource::collection( 'dates' => EventDateResource::collection(
app(EventDateGroupingService::class)->group($this->dates) app(EventDateGroupingService::class)->group($this->dates)
), ),

View File

@@ -2,18 +2,19 @@
namespace App\Domains\Ticketing\Event\Services; namespace App\Domains\Ticketing\Event\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService; use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\StockReservationService; use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Catalog\Services\VariantReplacementService; use App\Domains\Commerce\Catalog\Services\VariantReplacementService;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateChangeType; use App\Domains\Ticketing\Event\Enums\EventDateChangeType;
use App\Domains\Ticketing\Event\Events\EventDateRescheduled; use App\Domains\Ticketing\Event\Events\EventDateRescheduled;
use App\Domains\Ticketing\Event\Events\EventDateSuspended; use App\Domains\Ticketing\Event\Events\EventDateSuspended;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange; use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -34,22 +35,22 @@ class EventService
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts, private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {} ) {}
public function forTenant(Tenant $tenant): Event public function forTenant(Tenant $tenant, ?int $eventId = null): Event
{ {
return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia']); return $this->resolveEvent($tenant, $eventId)->load(['dates.validityTime', 'socialMedia']);
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function updateForTenant(Tenant $tenant, array $data): Event public function updateForTenant(Tenant $tenant, array $data, ?int $eventId = null): Event
{ {
return DB::transaction(function () use ($tenant, $data): Event { return DB::transaction(function () use ($tenant, $data, $eventId): Event {
$event = $tenant->activeEvent; $event = $this->resolveEvent($tenant, $eventId);
$event->update([ $event->update([
'title' => $data['title'], 'title' => $data['title'],
'location' => $data['location'], 'location' => $data['location'],
...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]), ...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]),
]); ]);
$tenant->update([ $event->update([
...array_intersect_key($data, array_flip([ ...array_intersect_key($data, array_flip([
'allow_ticket_refund', 'allow_ticket_refund',
'allow_ticket_total_refund', 'allow_ticket_total_refund',
@@ -69,10 +70,10 @@ class EventService
} }
/** @param array{date: string, start_time: string, end_time: string} $data */ /** @param array{date: string, start_time: string, end_time: string} $data */
public function createDateForTenant(Tenant $tenant, array $data): EventDate public function createDateForTenant(Tenant $tenant, array $data, ?int $eventId = null): EventDate
{ {
return DB::transaction(function () use ($tenant, $data): EventDate { return DB::transaction(function () use ($tenant, $data, $eventId): EventDate {
$event = $tenant->activeEvent; $event = $this->resolveEvent($tenant, $eventId);
$attributes = $this->dateAttributes($data); $attributes = $this->dateAttributes($data);
if ($event->dates()->where($attributes)->exists()) { if ($event->dates()->where($attributes)->exists()) {
@@ -93,7 +94,7 @@ class EventService
?User $createdBy = null, ?User $createdBy = null,
): EventDate { ): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate); $source = $this->lockedDateForTenant($tenant, $eventDate, $createdBy);
if ($source->suspended_at !== null) { if ($source->suspended_at !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -172,7 +173,7 @@ class EventService
?User $createdBy = null, ?User $createdBy = null,
): EventDate { ): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate); $date = $this->lockedDateForTenant($tenant, $eventDate, $createdBy);
if ($date->rescheduled_to_event_date_id !== null) { if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -216,10 +217,18 @@ class EventService
}); });
} }
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate private function resolveEvent(Tenant $tenant, ?int $eventId): Event
{
return Event::query()->where('tenant_code', $tenant->codigo)
->findOrFail($eventId ?? $tenant->active_event_id);
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate, ?User $actor = null): EventDate
{ {
return $tenant->eventDates() return $tenant->eventDates()
->whereKey($eventDate->getKey()) ->whereKey($eventDate->getKey())
->when($actor !== null && ! $actor->isTenantAdministrator(),
fn ($query) => $query->where('event_id', app(EventScopeService::class)->eventId($actor)))
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
} }

View File

@@ -37,10 +37,10 @@ se guardan en el evento. Sin evento activo se conservan las redes propias del te
## API ## API
Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant`: Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant:event`:
- `GET/PUT /v1/adminapp/tenant/event`: acceso al evento seleccionado para el - `GET/PUT /v1/adminapp/tenant/event`: acceso al evento asociado al usuario con scope de evento;
storefront de evento único. para admins de tenant se conserva el evento activo.
- `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento. - `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento.
- `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y - `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y
`POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha. `POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha.
@@ -53,3 +53,8 @@ una lista de eventos ni existe todavía una pantalla para gestionarlos.
Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar
tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente
después de excluir los cambios que el usuario ya vio tres veces. después de excluir los cambios que el usuario ya vio tres veces.
La configuración de devoluciones se persiste en `events`, se entrega en
`EventResource` y se aplica al evento de cada ticket. La migración inicial copia
los valores anteriores del tenant a sus eventos. Las fechas se autorizan por
tenant y por evento antes de cualquier suspensión o reprogramación.

View File

@@ -4,7 +4,7 @@ use App\Domains\Ticketing\Event\Controllers\AdminApp\EventController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant:event'])
->group(function (): void { ->group(function (): void {
Route::get('event', [EventController::class, 'show']); Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']); Route::put('event', [EventController::class, 'update']);

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
'event_id',
'scanner_user_id', 'scanner_user_id',
'ticket_id', 'ticket_id',
'data', 'data',
@@ -43,6 +44,7 @@ class ScanAttempt extends Model
{ {
return [ return [
'scanner_user_id' => 'integer', 'scanner_user_id' => 'integer',
'event_id' => 'integer',
'ticket_id' => 'integer', 'ticket_id' => 'integer',
'result' => ScanAttemptResult::class, 'result' => ScanAttemptResult::class,
'created_at' => 'datetime', 'created_at' => 'datetime',

View File

@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Purchase\Models\PurchaseItem; use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity;
use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup; use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup;
@@ -147,7 +148,9 @@ class Ticket extends Model
public function allow_refund(): bool public function allow_refund(): bool
{ {
return $this->tenant?->allow_refund() ?? false; return $this->event_id === null
? ($this->tenant?->allow_refund() ?? false)
: ($this->event?->allow_refund() ?? false);
} }
public function allowRefund(): bool public function allowRefund(): bool
@@ -192,7 +195,9 @@ class Ticket extends Model
public function can_refund(): bool public function can_refund(): bool
{ {
return $this->is_active() && $this->allow_refund(); return $this->is_active()
&& $this->allow_refund()
&& ! $this->hasEntryReservation();
} }
public function canRefund(): bool public function canRefund(): bool
@@ -235,6 +240,12 @@ class Ticket extends Model
return $this->hasOne(TicketRefund::class); return $this->hasOne(TicketRefund::class);
} }
/** @return HasOne<EntryReservation, $this> */
public function entryReservation(): HasOne
{
return $this->hasOne(EntryReservation::class);
}
/** @return BelongsTo<CatalogItem, $this> */ /** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo public function sourceCatalogItem(): BelongsTo
{ {
@@ -247,6 +258,15 @@ class Ticket extends Model
return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed(); return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed();
} }
private function hasEntryReservation(): bool
{
if ($this->relationLoaded('entryReservation')) {
return $this->getRelation('entryReservation') instanceof EntryReservation;
}
return $this->exists && $this->entryReservation()->exists();
}
public function isValid(): bool public function isValid(): bool
{ {
if ($this->hasTerminalStatus() || $this->used_at !== null) { if ($this->hasTerminalStatus() || $this->used_at !== null) {

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticketing\Ticket\Requests; namespace App\Domains\Ticketing\Ticket\Requests;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketAttributeService;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -22,7 +23,7 @@ class AdminAppTicketIndexRequest extends FormRequest
? [] ? []
: app(AdminAppTicketColumnService::class)->sortableKeys($tenant); : app(AdminAppTicketColumnService::class)->sortableKeys($tenant);
return [ $rules = [
'q' => ['sometimes', 'nullable', 'string', 'max:255'], 'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'category' => ['sometimes', 'nullable', 'string', 'max:255'], 'category' => ['sometimes', 'nullable', 'string', 'max:255'],
'product' => ['sometimes', 'nullable', 'string', 'max:255'], 'product' => ['sometimes', 'nullable', 'string', 'max:255'],
@@ -39,5 +40,18 @@ class AdminAppTicketIndexRequest extends FormRequest
'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)], 'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)],
'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])], 'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])],
]; ];
if ($tenant !== null) {
foreach (app(AdminAppTicketAttributeService::class)->attributes($tenant) as $attribute) {
$rules[$attribute['code']] = [
'sometimes',
'nullable',
'string',
Rule::in(array_column($attribute['options'], 'value')),
];
}
}
return $rules;
} }
} }

View File

@@ -32,7 +32,7 @@ class ScanAttemptResource extends JsonResource
ScanAttemptResult::Processing => 'Error', ScanAttemptResult::Processing => 'Error',
ScanAttemptResult::Accepted => 'Verificado', ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::InvalidQr => 'QR no pertenece al evento', ScanAttemptResult::InvalidQr => 'QR no pertenece al evento',
ScanAttemptResult::TicketNotFound => 'Error', ScanAttemptResult::TicketNotFound => 'QR no pertenece al evento',
ScanAttemptResult::CategoryForbidden => 'Error', ScanAttemptResult::CategoryForbidden => 'Error',
ScanAttemptResult::AlreadyScanned => 'Usado', ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido', ScanAttemptResult::Expired => 'Vencido',

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
class AdminAppTicketAttributeService
{
private const DESFILE_PURA_TENDENCIA = 'desfile_pura_tendencia';
/**
* @return list<array{
* code: string,
* label: string,
* options: list<array{value: string, label: string}>
* }>
*/
public function attributes(Tenant $tenant): array
{
if ($tenant->codigo !== self::DESFILE_PURA_TENDENCIA) {
return [];
}
$items = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->where(function (Builder $query): void {
$query
->where(function (Builder $activeQuery): void {
$activeQuery
->whereNull('catalog_items.deleted_at')
->where('has_tickets', true);
})
->orWhereHas('sourceTickets');
})
->with([
'itemAttributes' => fn ($query) => $query->orderBy('sort_order')->orderBy('id'),
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query
->withTrashed()
->where(fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('variantes.deleted_at')
->orWhereHas('sourceTickets'))
->orderBy('id'),
'variants.definitions.itemAttribute.attribute.options',
])
->get();
$attributes = [];
foreach ($items as $item) {
foreach ($item->itemAttributes as $itemAttribute) {
$attribute = $itemAttribute->attribute;
if ($attribute === null) {
continue;
}
$code = $attribute->codigo;
$attributes[$code] ??= [
'code' => $code,
'label' => $itemAttribute->ticket_label ?: $attribute->nombre,
'sort_order' => $itemAttribute->sort_order,
'options' => [],
];
foreach ($item->variants as $variant) {
$definition = $variant->definitions->firstWhere('item_attribute_id', $itemAttribute->id);
if ($definition === null) {
continue;
}
$value = (string) $definition->value;
$option = $attribute->options->firstWhere('value', $value);
$attributes[$code]['options'][$value] = [
'value' => $value,
'label' => (string) ($option?->label ?: $value),
];
}
}
}
uasort($attributes, fn (array $left, array $right): int => $left['sort_order'] <=> $right['sort_order']
?: $left['label'] <=> $right['label']);
return array_values(array_map(fn (array $attribute): array => [
'code' => $attribute['code'],
'label' => $attribute['label'],
'options' => array_values($attribute['options']),
], $attributes));
}
}

View File

@@ -8,9 +8,17 @@ class AdminAppTicketColumnService
{ {
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const DESFILE_PURA_TENDENCIA = 'desfile_pura_tendencia';
public function __construct(private readonly AdminAppTicketAttributeService $attributeService) {}
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */ /** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
public function columns(Tenant $tenant): array public function columns(Tenant $tenant): array
{ {
if ($tenant->codigo === self::DESFILE_PURA_TENDENCIA) {
return $this->desfileColumns($tenant);
}
$keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL $keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL
? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by'] ? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by']
: ['order_number', 'product', 'amount', 'client', 'id', 'status', 'scanned_by']; : ['order_number', 'product', 'amount', 'client', 'id', 'status', 'scanned_by'];
@@ -45,6 +53,31 @@ class AdminAppTicketColumnService
return $columns; return $columns;
} }
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
private function desfileColumns(Tenant $tenant): array
{
$attributeColumns = array_map(fn (array $attribute): array => [
'key' => $attribute['code'],
'label' => $attribute['label'],
'type' => 'text',
'sortable' => false,
'sort_param' => $attribute['code'],
'width' => '8%',
'excel_width' => 15,
], $this->attributeService->attributes($tenant));
return [
$this->definitions()['order_number'],
$this->definitions()['product'],
...$attributeColumns,
$this->definitions()['amount'],
$this->definitions()['client'],
$this->definitions()['id'],
$this->definitions()['status'],
$this->definitions()['scanned_by'],
];
}
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */ /** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */
public function publicColumns(Tenant $tenant): array public function publicColumns(Tenant $tenant): array
{ {

View File

@@ -11,6 +11,8 @@ class AdminAppTicketRowService
{ {
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const DESFILE_PURA_TENDENCIA = 'desfile_pura_tendencia';
private const CATEGORY_PRESENTATIONS = [ private const CATEGORY_PRESENTATIONS = [
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null], 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null], 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
@@ -25,31 +27,65 @@ class AdminAppTicketRowService
{ {
$purchaseItem = $ticket->sourcePurchaseItem; $purchaseItem = $ticket->sourcePurchaseItem;
$refund = $ticket->refund; $refund = $ticket->refund;
$variantProperties = $this->variantProperties($ticket);
return [ return [
'source_purchase_item_id' => $ticket->source_purchase_item_id, 'source_purchase_item_id' => $ticket->source_purchase_item_id,
'order_number' => $purchaseItem?->compra_id, 'order_number' => $purchaseItem?->compra_id,
'product' => $purchaseItem?->item_nombre 'product' => $this->productName($ticket, $purchaseItem?->item_nombre, $variantProperties),
?? $ticket->sourceCatalogItem?->nombre 'amount' => $purchaseItem?->precio_unitario ?? $ticket->entryReservation?->importe,
?? $ticket->name,
'amount' => $purchaseItem?->precio_unitario,
'refund_type' => $refund?->type, 'refund_type' => $refund?->type,
'refund_type_label' => $refund?->typeLabel(), '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,
'variant_properties' => $this->variantProperties($ticket), 'variant_properties' => $variantProperties,
'allow_refund' => $ticket->allow_refund(), 'allow_refund' => $ticket->allow_refund(),
]; ];
} }
/**
* @param list<array{code: string, label: string, values: list<array{value: string, label: string}>}> $properties
*/
private function productName(Ticket $ticket, ?string $snapshotName, array $properties): string
{
$baseName = $ticket->sourceCatalogItem?->nombre ?: $ticket->name;
if ($ticket->tenant_code !== self::DESFILE_PURA_TENDENCIA) {
return $snapshotName ?: $baseName;
}
$seatDescription = collect(['sector', 'fila', 'asiento'])
->map(function (string $code) use ($properties): ?string {
$property = collect($properties)->firstWhere('code', $code);
$values = collect($property['values'] ?? [])->pluck('label')->filter()->implode(', ');
return $property === null || $values === ''
? null
: $property['label'].' '.$values;
})
->filter()
->implode(', ');
return $seatDescription === '' ? ($snapshotName ?: $baseName) : "{$baseName} ({$seatDescription})";
}
/** @param array<string, mixed>|null $details */ /** @param array<string, mixed>|null $details */
public function values(Ticket $ticket, ?array $details = null): array public function values(Ticket $ticket, ?array $details = null): array
{ {
$details ??= $this->details($ticket); $details ??= $this->details($ticket);
$presentation = $this->presentation($ticket, $details); $presentation = $this->presentation($ticket, $details);
$attributeValues = collect($details['variant_properties'] ?? [])
->mapWithKeys(fn (array $property): array => [
$property['code'] => collect($property['values'] ?? [])
->pluck('label')
->filter()
->implode(', ') ?: '-',
])
->all();
return [ return [
...$attributeValues,
'order_number' => $details['order_number'], 'order_number' => $details['order_number'],
'category' => $presentation['category'], 'category' => $presentation['category'],
'product' => $presentation['product'], 'product' => $presentation['product'],

View File

@@ -2,13 +2,14 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Purchase\Models\PurchaseItem; use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Models\TicketRefund; use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -23,15 +24,18 @@ class AdminAppTicketService
...TicketValidityResolver::RELATIONS, ...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS,
'tenant', 'tenant',
'event',
'user', 'user',
'scannerUser', 'scannerUser',
'sourceCatalogItem.category', 'sourceCatalogItem.category',
'sourcePurchaseItem.purchase', 'sourcePurchaseItem.purchase',
'entryReservation',
'refund.createdBy', 'refund.createdBy',
]; ];
public function __construct( public function __construct(
private readonly AdminAppTicketColumnService $columnService, private readonly AdminAppTicketColumnService $columnService,
private readonly AdminAppTicketAttributeService $attributeService,
private readonly AdminAppTicketRowService $rowService, private readonly AdminAppTicketRowService $rowService,
private readonly PurchaseRefundSummaryService $refundSummaryService, private readonly PurchaseRefundSummaryService $refundSummaryService,
) {} ) {}
@@ -143,19 +147,20 @@ class AdminAppTicketService
]); ]);
} }
$configuration = $ticket->event_id === null ? $tenant : $ticket->event;
$unitPrice = (float) $purchaseItem->precio_unitario; $unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total; $itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem); $itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null; $total = null;
if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { if ($configuration->allow_refund() && $configuration->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) {
$total = number_format($unitPrice, 2, '.', ''); $total = number_format($unitPrice, 2, '.', '');
} }
$partial = null; $partial = null;
if ($tenant->allow_refund() && $tenant->allow_partial_refund()) { if ($configuration->allow_refund() && $configuration->allow_partial_refund()) {
$partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial'); $partialAmount = $this->refundAmount($purchaseItem, $configuration, 'partial');
if ($partialAmount <= $remainingItemAmount) { if ($partialAmount <= $remainingItemAmount) {
$partial = number_format($partialAmount, 2, '.', ''); $partial = number_format($partialAmount, 2, '.', '');
} }
@@ -173,14 +178,15 @@ class AdminAppTicketService
string $refundType, string $refundType,
?User $createdBy = null, ?User $createdBy = null,
): Ticket { ): Ticket {
$this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): 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()
->findOrFail($ticketId); ->findOrFail($ticketId);
$configuration = $ticket->event_id === null ? $tenant : $ticket->event;
$this->ensureRefundIsAllowed($configuration, $refundType);
if (! $ticket->can_refund()) { if (! $ticket->can_refund()) {
if ($ticket->status !== Ticket::STATUS_ACTIVE) { if ($ticket->status !== Ticket::STATUS_ACTIVE) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -203,7 +209,7 @@ class AdminAppTicketService
]); ]);
} }
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType); $refundAmount = $this->refundAmount($purchaseItem, $configuration, $refundType);
$refundedAmount = round( $refundedAmount = round(
$this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount, $this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount,
2, 2,
@@ -284,7 +290,7 @@ class AdminAppTicketService
->sum('amount'), 2); ->sum('amount'), 2);
} }
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void private function ensureRefundIsAllowed(Tenant|Event $tenant, string $refundType): void
{ {
$isAllowed = match ($refundType) { $isAllowed = match ($refundType) {
TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(), TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(),
@@ -298,7 +304,7 @@ class AdminAppTicketService
} }
} }
private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float private function refundAmount(PurchaseItem $purchaseItem, Tenant|Event $tenant, string $refundType): float
{ {
$ticketAmount = (float) $purchaseItem->precio_unitario; $ticketAmount = (float) $purchaseItem->precio_unitario;
@@ -347,6 +353,13 @@ class AdminAppTicketService
$this->applyStatusFilter($query, $filters['status'] ?? null); $this->applyStatusFilter($query, $filters['status'] ?? null);
foreach ($this->attributeService->attributes($tenant) as $attribute) {
$value = $filters[$attribute['code']] ?? null;
if ($value !== null && $value !== '') {
$this->whereVariantDefinition($query, $attribute['code'], (string) $value);
}
}
return $query; return $query;
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Ticketing\Ticket\Enums\ScanAttemptResult; use App\Domains\Ticketing\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
@@ -27,6 +28,7 @@ class ScannerTicketService
->with('ticket.sourceCatalogItem.category') ->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
@@ -60,6 +62,7 @@ class ScannerTicketService
->with('ticket.sourceCatalogItem.category') ->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
$attemptedAtDayMonth = $this->parseSearchDayMonth($search); $attemptedAtDayMonth = $this->parseSearchDayMonth($search);
@@ -106,6 +109,7 @@ class ScannerTicketService
->with('ticket') ->with('ticket')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->findOrFail($scanAttemptId); ->findOrFail($scanAttemptId);
$scanAttempt->ticket?->loadMissing($this->relations()); $scanAttempt->ticket?->loadMissing($this->relations());
@@ -113,6 +117,11 @@ class ScannerTicketService
return $scanAttempt; return $scanAttempt;
} }
private function eventId(User $scanner): ?int
{
return app(EventScopeService::class)->eventId($scanner);
}
private function parseSearchDate(string $search): ?string private function parseSearchDate(string $search): ?string
{ {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
@@ -154,7 +163,8 @@ class ScannerTicketService
{ {
$query = $this->baseQuery() $query = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid); ->where('ticket', $ticketUuid)
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)));
if ($this->requiresCategoryValidation($scanner)) { if ($this->requiresCategoryValidation($scanner)) {
$categoryIds = $this->scannerCategoryIds($scanner); $categoryIds = $this->scannerCategoryIds($scanner);
@@ -178,6 +188,7 @@ class ScannerTicketService
$scanAttempt = ScanAttempt::query()->create([ $scanAttempt = ScanAttempt::query()->create([
'tenant_code' => $scanner->tenant_codigo, 'tenant_code' => $scanner->tenant_codigo,
'scanner_user_id' => $scanner->getKey(), 'scanner_user_id' => $scanner->getKey(),
'event_id' => $this->eventId($scanner),
'data' => $this->serializeScannedData($scannedData), 'data' => $this->serializeScannedData($scannedData),
'result' => ScanAttemptResult::Processing, 'result' => ScanAttemptResult::Processing,
]); ]);
@@ -200,6 +211,7 @@ class ScannerTicketService
$ticket = $this->baseQuery() $ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $scannedData) ->where('ticket', $scannedData)
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$ticketId = (int) $ticket->getKey(); $ticketId = (int) $ticket->getKey();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Core\Auth\Services\AdminAppAccessService;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use Closure; use Closure;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -10,10 +11,12 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureAdminAppTenant class EnsureAdminAppTenant
{ {
public function __construct(private readonly AdminAppAccessService $accessService) {}
/** /**
* Ensure the authenticated user is an AdminApp user bound to a tenant. * Ensure the authenticated user is an AdminApp user bound to a tenant.
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next, string $access = 'tenant'): Response
{ {
$user = $request->user(); $user = $request->user();
@@ -21,6 +24,9 @@ class EnsureAdminAppTenant
! $user ! $user
|| $user->rol_codigo !== RoleCode::AdminApp->value || $user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo || ! $user->tenant_codigo
|| ! $this->accessService->hasValidScope($user)
// Only routes implementing event authorization may accept event administrators.
|| (! in_array($access, ['context', 'event'], true) && ! $user->isTenantAdministrator())
) { ) {
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Authorization\Enums\PermissionCode; use App\Domains\Core\Authorization\Enums\PermissionCode;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use Closure; use Closure;
@@ -27,6 +28,8 @@ class EnsureScannerTenant
throw new AuthorizationException; throw new AuthorizationException;
} }
app(EventScopeService::class)->eventId($user);
return $next($request); return $next($request);
} }
} }

View File

@@ -3,12 +3,12 @@
namespace App\Shared\Attachable\Models; namespace App\Shared\Attachable\Models;
use App\Shared\Attachable\Enums\AttachmentType; use App\Shared\Attachable\Enums\AttachmentType;
use App\Shared\Storage\Services\TemporaryUrlService;
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;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable([ #[Fillable([
@@ -70,9 +70,9 @@ class Attachment extends Model
*/ */
public function getTemporaryUrl(int $expiresInMinutes = 10): string public function getTemporaryUrl(int $expiresInMinutes = 10): string
{ {
return Storage::disk('s3')->temporaryUrl( return app(TemporaryUrlService::class)->generate(
$this->path, $this->path,
now()->addMinutes($expiresInMinutes) $expiresInMinutes,
); )['temporary_url'];
} }
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Shared\Forms\Controllers\AdminApp;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\DesfileEntryReservationFormResource;
use App\Shared\Forms\Services\DesfileEntryReservationFormService;
use Illuminate\Http\Request;
class DesfileEntryReservationFormController extends Controller
{
public function __construct(
private readonly DesfileEntryReservationFormService $formService,
) {}
public function __invoke(Request $request): DesfileEntryReservationFormResource
{
return DesfileEntryReservationFormResource::make(
$this->formService->get($request->user()->tenant()->firstOrFail()),
);
}
}

View File

@@ -2,9 +2,10 @@
namespace App\Shared\Forms\Controllers\AdminApp; namespace App\Shared\Forms\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\EventFormResource; use App\Shared\Forms\Resources\EventFormResource;
use App\Shared\Forms\Services\EventFormService; use App\Shared\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class EventFormController extends Controller class EventFormController extends Controller
@@ -15,7 +16,8 @@ class EventFormController extends Controller
{ {
return EventFormResource::make( return EventFormResource::make(
$this->eventFormService->get( $this->eventFormService->get(
$request->user('sanctum')->tenant()->firstOrFail() $request->user('sanctum')->tenant()->firstOrFail(),
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }

View File

@@ -2,9 +2,10 @@
namespace App\Shared\Forms\Controllers\AdminApp; namespace App\Shared\Forms\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\StaffFormResource; use App\Shared\Forms\Resources\StaffFormResource;
use App\Shared\Forms\Services\StaffFormService; use App\Shared\Forms\Services\StaffFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class StaffFormController extends Controller class StaffFormController extends Controller
@@ -15,7 +16,8 @@ class StaffFormController extends Controller
{ {
return StaffFormResource::make( return StaffFormResource::make(
$this->staffFormService->get( $this->staffFormService->get(
$request->user('sanctum')->tenant()->firstOrFail() $request->user('sanctum')->tenant()->firstOrFail(),
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Shared\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DesfileEntryReservationFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'payment_types' => $this->resource['payment_types'],
'fields' => $this->resource['fields'],
'variants' => $this->resource['variants'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Shared\Forms\Services;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType;
class DesfileEntryReservationFormService
{
/** @return array<string, mixed> */
public function get(Tenant $tenant): array
{
$entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada')
->with([
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.eventDate',
])->first();
$variants = $entry?->visibleVariants()
->map(function (Variant $variant): array {
$values = $variant->selectionValues();
return [
'id' => $variant->id,
'tipo' => (string) $values->get('tipo'),
'sector' => (string) $values->get('sector'),
'fila' => (string) $values->get('fila'),
'asiento' => (string) $values->get('asiento'),
'price' => $variant->getPrice(),
];
})->values() ?? collect();
return [
'payment_types' => EntryReservationPaymentType::options(),
'fields' => collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento'])
->map(fn (string $label, string $key): array => [
'key' => $key,
'label' => $label,
'options' => $variants->pluck($key)->unique()->sort(SORT_NATURAL)->values()
->map(fn (string $value): array => ['value' => $value, 'label' => $value])->all(),
])->values()->all(),
'variants' => $variants->all(),
];
}
}

View File

@@ -4,14 +4,17 @@ namespace App\Shared\Forms\Services;
use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class EventFormService class EventFormService
{ {
/** @return array{social_media: Collection<int, SocialMedia>} */ /** @return array{social_media: Collection<int, SocialMedia>} */
public function get(Tenant $tenant): array public function get(Tenant $tenant, ?int $eventId = null): array
{ {
$event = $tenant->activeEvent; $event = $eventId === null ? $tenant->activeEvent
: Event::query()
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
$urls = $event?->socialMedia() $urls = $event?->socialMedia()
->pluck('event_social_media.url', 'social_media.code') ?? collect(); ->pluck('event_social_media.url', 'social_media.code') ?? collect();

View File

@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Collection;
class StaffFormService class StaffFormService
{ {
/** @return array{categories: Collection<int, Category>} */ /** @return array{categories: Collection<int, Category>} */
public function get(Tenant $tenant): array public function get(Tenant $tenant, ?int $eventId = null): array
{ {
return [ return [
'categories' => Category::query() 'categories' => Category::query()
@@ -20,6 +20,8 @@ class StaffFormService
->orWhereHas('catalogItems', fn (Builder $items) => $items ->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo)); ->where('tenant_code', $tenant->codigo));
}) })
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
->orderBy('nombre') ->orderBy('nombre')
->get(), ->get(),
]; ];

View File

@@ -4,15 +4,19 @@ namespace App\Shared\Forms\Services;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketAttributeService;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService;
class TicketFilterFormService class TicketFilterFormService
{ {
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const DESFILE_PURA_TENDENCIA = 'desfile_pura_tendencia';
public function __construct( public function __construct(
private readonly TicketFormService $ticketFormService, private readonly TicketFormService $ticketFormService,
private readonly AdminAppTicketColumnService $columnService, private readonly AdminAppTicketColumnService $columnService,
private readonly AdminAppTicketAttributeService $attributeService,
) {} ) {}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -27,6 +31,13 @@ class TicketFilterFormService
]; ];
} }
if ($tenant->codigo === self::DESFILE_PURA_TENDENCIA) {
$fields = [
...$this->desfileFields($tenant),
...$this->commonFields(),
];
}
return [ return [
'code' => 'tickets_filter', 'code' => 'tickets_filter',
'action' => '/api/v1/adminapp/tenant/tickets', 'action' => '/api/v1/adminapp/tenant/tickets',
@@ -36,6 +47,21 @@ class TicketFilterFormService
]; ];
} }
/** @return list<array<string, mixed>> */
private function desfileFields(Tenant $tenant): array
{
return array_map(fn (array $attribute): array => [
'name' => $attribute['code'],
'query_param' => $attribute['code'],
'label' => $attribute['label'],
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => $attribute['label'],
'options' => $attribute['options'],
], $this->attributeService->attributes($tenant));
}
/** @return list<array<string, mixed>> */ /** @return list<array<string, mixed>> */
private function fiestaFutbolInfantilFields(Tenant $tenant): array private function fiestaFutbolInfantilFields(Tenant $tenant): array
{ {

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Shared\Forms\Controllers\AdminApp\DesfileEntryReservationFormController;
use App\Shared\Forms\Controllers\AdminApp\EntryFormController; use App\Shared\Forms\Controllers\AdminApp\EntryFormController;
use App\Shared\Forms\Controllers\AdminApp\EventFormController; use App\Shared\Forms\Controllers\AdminApp\EventFormController;
use App\Shared\Forms\Controllers\AdminApp\FoodFormController; use App\Shared\Forms\Controllers\AdminApp\FoodFormController;
@@ -11,28 +12,33 @@ use App\Shared\Forms\Controllers\AdminApp\TicketFormController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms') Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant:event'])
->group(function (): void { ->group(function (): void {
Route::get('event', EventFormController::class); Route::get('event', EventFormController::class);
Route::get(
'desfile/entry-reservation',
DesfileEntryReservationFormController::class
)->middleware('adminapp.tenant')->middleware('tenant.menu:adminapp.desfile.reservas')
->name('adminapp.forms.desfile.entry-reservation');
Route::get('sale', SaleFormController::class); Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class); Route::get('staff', StaffFormController::class);
Route::get('tickets-filter', TicketFilterFormController::class) Route::get('tickets-filter', TicketFilterFormController::class)->middleware('adminapp.tenant')
->middleware('tenant.menu:adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.forms.tickets-filter'); ->name('adminapp.forms.tickets-filter');
Route::get( Route::get(
'fiesta-futbol-infantil/ticket', 'fiesta-futbol-infantil/ticket',
TicketFormController::class TicketFormController::class
); )->middleware('adminapp.tenant');
Route::get( Route::get(
'fiesta-futbol-infantil/entry', 'fiesta-futbol-infantil/entry',
EntryFormController::class EntryFormController::class
); )->middleware('adminapp.tenant');
Route::get( Route::get(
'fiesta-futbol-infantil/merchandise', 'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class MerchandiseFormController::class
); )->middleware('adminapp.tenant');
Route::get( Route::get(
'fiesta-futbol-infantil/food', 'fiesta-futbol-infantil/food',
FoodFormController::class FoodFormController::class
); )->middleware('adminapp.tenant');
}); });

View File

@@ -3,11 +3,12 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Integration\Models\AdminWebsiteTypeIntegration;
use App\Shared\Integration\Models\ClientIntegration; use App\Shared\Integration\Models\ClientIntegration;
use App\Shared\Integration\Models\Integration; use App\Shared\Integration\Models\Integration;
use App\Shared\Integration\Models\IntegrationInstance; use App\Shared\Integration\Models\IntegrationInstance;
use App\Shared\Integration\Models\AdminWebsiteTypeIntegration;
use App\Domains\Core\Tenant\Models\Tenant;
use Exception; use Exception;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -86,6 +87,17 @@ abstract class BaseIntegrationService
return $this; return $this;
} }
public function forEvent(Event $event): self
{
$event->loadMissing(['client', 'tenant.client']);
$this->tenant = $event->tenant;
$this->tenantCode = $event->tenant_code;
$this->clientContext = $event->effectiveClient();
$this->loadIntegration();
return $this;
}
/** /**
* Load the integration definition and its effective instance configuration. * Load the integration definition and its effective instance configuration.
* *

View File

@@ -3,8 +3,9 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Models\AdminWebsiteType; use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Exception; use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer; use Illuminate\Contracts\Mail\Mailer;
@@ -39,14 +40,7 @@ class MailService extends BaseIntegrationService
public function forTenant(string $tenantCode): self public function forTenant(string $tenantCode): self
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
$this->configureMailer();
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
}
return $this; return $this;
} }
@@ -55,16 +49,30 @@ class MailService extends BaseIntegrationService
{ {
parent::forClient($client); parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first(); $this->tenant = $this->clientContext?->tenants()->first();
$this->configureMailer();
return $this;
}
public function forEvent(Event $event): self
{
parent::forEvent($event);
$this->configureMailer();
return $this;
}
private function configureMailer(): void
{
if ($this->integrationInstance) { if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true; $this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer(); return;
$this->usesInstanceMailer = false;
} }
return $this; $this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
} }
public function getHeaders(): array public function getHeaders(): array
@@ -83,7 +91,7 @@ class MailService extends BaseIntegrationService
array $attachments = [], array $attachments = [],
): void { ): void {
if (! $this->mailer || ! $this->tenant) { if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.');
} }
$brand ??= $this->tenant; $brand ??= $this->tenant;
@@ -160,7 +168,7 @@ class MailService extends BaseIntegrationService
public function onSetup(): void public function onSetup(): void
{ {
if (! $this->mailer || ! $this->clientContext) { if (! $this->mailer || ! $this->clientContext) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.');
} }
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');

View File

@@ -46,7 +46,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
public function getToken(): string public function getToken(): string
{ {
if (! $this->integrationInstance) { if (! $this->integrationInstance) {
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); throw new Exception('Client integration is not loaded. Call forTenant(), forClient(), or forEvent() first.');
} }
$cacheKey = $this->integrationInstance->tokenCacheKey(); $cacheKey = $this->integrationInstance->tokenCacheKey();

View File

@@ -2,12 +2,12 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosPayment;
use App\Domains\Commerce\Purchase\Models\TelepagosQr; use App\Domains\Commerce\Purchase\Models\TelepagosQr;
use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\DniDistanceService; use App\Domains\Commerce\Purchase\Services\DniDistanceService;
use App\Domains\Core\Client\Models\Client;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -75,9 +75,30 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$eligiblePurchases = Purchase::query() $eligiblePurchases = Purchase::query()
->whereIn('tenant_codigo', $tenantCodes) ->where(function (Builder $purchases) use ($client): void {
$purchases
->whereHas('event', function (Builder $events) use ($client): void {
$events
->where('client_id', $client->id)
->orWhere(function (Builder $fallback) use ($client): void {
$fallback
->whereNull('client_id')
->whereHas(
'tenant',
fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id),
);
});
})
->orWhere(function (Builder $legacy) use ($client): void {
$legacy
->whereNull('event_id')
->whereHas(
'tenant',
fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id),
);
});
})
->whereIn('status', [ ->whereIn('status', [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
@@ -170,7 +191,7 @@ class TelepagosWebhookService
return; return;
} }
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) { if (! $this->purchaseBelongsToClient($compra, $client)) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [ Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
'client_code' => $client->code, 'client_code' => $client->code,
'cashin_id' => $cashinId, 'cashin_id' => $cashinId,
@@ -244,6 +265,16 @@ class TelepagosWebhookService
return number_format((float) $amount, 2, '.', ''); return number_format((float) $amount, 2, '.', '');
} }
private function purchaseBelongsToClient(Purchase $purchase, Client $client): bool
{
$purchase->loadMissing(['event.client', 'event.tenant.client', 'tenant.client']);
$effectiveClient = $purchase->event?->effectiveClient()
?? $purchase->tenant?->client;
return $effectiveClient?->is($client) ?? false;
}
/** /**
* @param Builder<Purchase> $eligiblePurchases * @param Builder<Purchase> $eligiblePurchases
* @return Collection<int, Purchase> * @return Collection<int, Purchase>

View File

@@ -2,16 +2,16 @@
namespace App\Shared\Notification\Services; namespace App\Shared\Notification\Services;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Shared\Integration\Services\MailService;
use App\Shared\Notification\Events\PasswordResetRequested;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPdfService;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use App\Shared\Integration\Services\MailService;
use App\Shared\Notification\Events\PasswordResetRequested;
use Closure; use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -158,7 +158,7 @@ class NotificationMailService
$context = ['purchase_id' => $purchaseId]; $context = ['purchase_id' => $purchaseId];
$purchase = Purchase::query() $purchase = Purchase::query()
->with(['tenant', 'user', 'items']) ->with(['event', 'tenant', 'user', 'items'])
->find($purchaseId); ->find($purchaseId);
if ($purchase === null) { if ($purchase === null) {
@@ -197,8 +197,7 @@ class NotificationMailService
'mime' => 'application/pdf', 'mime' => 'application/pdf',
]]; ]];
$this->mailService $this->mailForPurchase($purchase)
->forTenant($purchase->tenant_codigo)
->send( ->send(
$recipient, $recipient,
"Compra confirmada - Compra #{$purchase->getKey()}", "Compra confirmada - Compra #{$purchase->getKey()}",
@@ -286,7 +285,6 @@ class NotificationMailService
$context, $context,
$recipient, $recipient,
function () use ( function () use (
$tenantCode,
$purchase, $purchase,
$recipient, $recipient,
$previousDate, $previousDate,
@@ -295,8 +293,7 @@ class NotificationMailService
): array { ): array {
$brand = $purchase->tenant; $brand = $purchase->tenant;
$this->mailService $this->mailForPurchase($purchase)
->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
@@ -387,7 +384,6 @@ class NotificationMailService
$context, $context,
$recipient, $recipient,
function () use ( function () use (
$tenantCode,
$purchase, $purchase,
$recipient, $recipient,
$date, $date,
@@ -396,8 +392,7 @@ class NotificationMailService
): array { ): array {
$brand = $purchase->tenant; $brand = $purchase->tenant;
$this->mailService $this->mailForPurchase($purchase)
->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}",
@@ -421,10 +416,17 @@ class NotificationMailService
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenantCode) ->where('tenant_codigo', $tenantCode)
->with(['tenant', 'user']) ->with(['event', 'tenant', 'user'])
->find($purchaseId); ->find($purchaseId);
} }
private function mailForPurchase(Purchase $purchase): MailService
{
return $purchase->event
? $this->mailService->forEvent($purchase->event)
: $this->mailService->forTenant($purchase->tenant_codigo);
}
private function recipientFor(Purchase $purchase): string private function recipientFor(Purchase $purchase): string
{ {
return (string) ($purchase->email ?: $purchase->user?->email); return (string) ($purchase->email ?: $purchase->user?->email);

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Shared\Storage\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class TemporaryUrlService
{
protected const REFRESH_MARGIN_SECONDS = 60;
/**
* @return array{temporary_url: string, temporary_url_expires_at: string}
*/
public function generate(string $path, int $expiresInMinutes = 10): array
{
if ($expiresInMinutes < 1) {
throw new InvalidArgumentException('The temporary URL expiration must be at least one minute.');
}
$cacheTtlInSeconds = max(
1,
($expiresInMinutes * 60) - self::REFRESH_MARGIN_SECONDS,
);
return Cache::remember(
$this->cacheKey($path, $expiresInMinutes),
now()->addSeconds($cacheTtlInSeconds),
function () use ($path, $expiresInMinutes): array {
$expiresAt = now()->addMinutes($expiresInMinutes);
return [
'temporary_url' => Storage::disk('s3')->temporaryUrl(
$path,
$expiresAt,
[
'ResponseCacheControl' => 'private, max-age='.($expiresInMinutes * 60),
],
),
'temporary_url_expires_at' => $expiresAt->toIso8601String(),
];
},
);
}
public function forget(string $path, int $expiresInMinutes = 10): void
{
Cache::forget($this->cacheKey($path, $expiresInMinutes));
}
protected function cacheKey(string $path, int $expiresInMinutes): string
{
return sprintf(
's3:temporary-url:%s:%d',
hash('sha256', $path),
$expiresInMinutes,
);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Shared\StorageTest\Services; namespace App\Shared\StorageTest\Services;
use App\Shared\Storage\Services\TemporaryUrlService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -10,6 +11,10 @@ use RuntimeException;
class S3TestService class S3TestService
{ {
public function __construct(
protected TemporaryUrlService $temporaryUrlService,
) {}
/** /**
* @return array<string, int|string|null> * @return array<string, int|string|null>
*/ */
@@ -44,8 +49,7 @@ class S3TestService
'mime_type' => $file->getClientMimeType(), 'mime_type' => $file->getClientMimeType(),
'extension' => $file->extension(), 'extension' => $file->extension(),
'size' => $file->getSize(), 'size' => $file->getSize(),
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)), ...$this->temporaryUrlService->generate($path, $expiresInMinutes),
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
]; ];
} }
@@ -58,16 +62,10 @@ class S3TestService
'disk' => 's3', 'disk' => 's3',
'key' => $path, 'key' => $path,
'path' => $path, 'path' => $path,
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes), ...$this->temporaryUrlService->generate($path, $expiresInMinutes),
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
]; ];
} }
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
{
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
}
protected function normalizeDirectory(?string $directory): string protected function normalizeDirectory(?string $directory): string
{ {
$directory = trim((string) $directory, '/'); $directory = trim((string) $directory, '/');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const MENU_CODE = 'adminapp.desfile.reservas';
private const TENANT_CODE = 'desfile_pura_tendencia';
public function up(): void
{
if (! DB::table('menues')->where('code', 'main.adminapp')->exists()) {
// Reference data is added by seeders on fresh installations.
return;
}
$now = now();
DB::transaction(function () use ($now): void {
DB::table('menues')->updateOrInsert(
['code' => self::MENU_CODE],
[
'label' => 'Reserva de Tickets',
'parent_menu_code' => 'main.adminapp',
'content_type' => 'dynamic',
'static_content_schema' => null,
'route' => '/admin/desfile/reservas',
'created_at' => $now,
'updated_at' => $now,
],
);
DB::table('tenants_menues')
->where('menu_code', self::MENU_CODE)
->where('tenant_code', '!=', self::TENANT_CODE)
->delete();
if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
DB::table('tenants_menues')->updateOrInsert(
[
'tenant_code' => self::TENANT_CODE,
'menu_code' => self::MENU_CODE,
],
[
'static_content' => null,
'created_at' => $now,
'updated_at' => $now,
],
);
}
DB::table('roles')
->whereIn('codigo', ['admin', 'adminapp'])
->pluck('codigo')
->each(function (string $roleCode): void {
DB::table('roles_menues')->updateOrInsert([
'rol_codigo' => $roleCode,
'menu_codigo' => self::MENU_CODE,
]);
});
});
}
public function down(): void
{
DB::transaction(function (): void {
DB::table('tenants_menues')
->where('menu_code', self::MENU_CODE)
->delete();
DB::table('roles_menues')
->where('menu_codigo', self::MENU_CODE)
->delete();
DB::table('menues')
->where('code', self::MENU_CODE)
->delete();
});
}
};

View File

@@ -0,0 +1,29 @@
<?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('desfile_entry_reservations', function (Blueprint $table): void {
$table->id();
$table->foreignId('variant_id')
->constrained('variantes')
->restrictOnDelete();
$table->timestamp('fecha_reserva');
$table->decimal('importe', 10, 2)->nullable();
$table->string('tipo_pago', 24);
$table->timestamps();
$table->index(['fecha_reserva', 'tipo_pago']);
});
}
public function down(): void
{
Schema::dropIfExists('desfile_entry_reservations');
}
};

View File

@@ -0,0 +1,43 @@
<?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->unsignedInteger('entry_reserved_stock')->default(0);
});
Schema::create('desfile_reservation_batches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
$table->string('tenant_code');
$table->uuid('idempotency_key');
$table->string('request_hash', 64);
$table->timestamps();
$table->unique(['user_id', 'idempotency_key']);
});
Schema::table('desfile_entry_reservations', function (Blueprint $table): void {
$table->foreignId('batch_id')->nullable()->constrained('desfile_reservation_batches')->restrictOnDelete();
$table->foreignId('ticket_id')->nullable()->unique()->constrained('tickets')->restrictOnDelete();
$table->foreignId('inventory_id')->nullable()->constrained('inventories')->restrictOnDelete();
});
// Legacy reservations remain excluded by visibleVariants(). Their past stock
// movements cannot be inferred safely; only new reservations use this counter.
}
public function down(): void
{
Schema::table('desfile_entry_reservations', function (Blueprint $table): void {
$table->dropConstrainedForeignId('batch_id');
$table->dropUnique(['ticket_id']);
$table->dropConstrainedForeignId('ticket_id');
$table->dropConstrainedForeignId('inventory_id');
});
Schema::dropIfExists('desfile_reservation_batches');
Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('entry_reserved_stock'));
}
};

View File

@@ -0,0 +1,326 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
private const CLIENT_CODE = 'mutual_smep';
private const TENANT_CODE = 'mutual_smep';
private const EXCLUDED_MENU_CODES = [
'account.tickets',
'adminapp.tickets',
'adminapp.fiesta-futbol-infantil.entradas',
'adminapp.fiesta-futbol-infantil.alojamientos',
'adminapp.fiesta-futbol-infantil.merchandising',
'adminapp.fiesta-futbol-infantil.comida',
'adminapp.desfile.entradas',
'event.index',
'event.category',
'event.detail',
];
/** @var list<string> */
private array $storedPaths = [];
public function up(): void
{
if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
if (
! DB::table('admin_website_types')->where('codigo', 'shopit')->exists()
|| ! DB::table('storefront_website_types')->where('codigo', 'shopit')->exists()
) {
// Fresh installations provision website-type reference data separately.
return;
}
try {
DB::transaction(function (): void {
$clientId = $this->clientId();
$headerLogoId = $this->storeImage(
'images/tennants/mutual_smep/mutual_smep_header.png',
'mutual_smep_header.png',
);
$footerLogoId = $this->storeImage(
'images/tennants/mutual_smep/mutual_smep_footer.png',
'mutual_smep_footer.png',
);
$faviconId = $this->storeImage(
'images/tennants/mutual_smep/mutual_smep_favicon.png',
'mutual_smep_favicon.png',
);
$now = now();
DB::table('tenants')->insert([
'client_id' => $clientId,
'codigo' => self::TENANT_CODE,
'nombre' => 'Mutual SMEP',
'timezone' => 'America/Argentina/Buenos_Aires',
'dominio' => 'mutual-smep.localhost',
'base_path' => '/',
'site_title' => 'Tienda Mutual SMEP',
'address' => 'San Lorenzo 1543, Rosario, Santa Fe',
'phone' => '+54 9 341 247-4530',
'primary_color' => '#4A7FF5',
'secondary_color' => '#0051A4',
'danger_color' => '#FF8888',
'success_color' => '#198754',
'header_bg_color' => '#FFFFFF',
'footer_bg_color' => '#0051A4',
'header_logo_id' => $headerLogoId,
'footer_logo_id' => $footerLogoId,
'favicon_id' => $faviconId,
'admin_website_type_code' => 'shopit',
'storefront_website_type_code' => 'shopit',
'display_categories' => true,
'display_seach_bar' => true,
'display_cart' => true,
'cart_editing_policy' => 'quantity_and_remove',
'checkout_editing_policy' => 'disabled',
'created_at' => $now,
'updated_at' => $now,
]);
$this->assignSocialMedia($now);
$this->assignMenus($now);
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($this->storedPaths);
throw $throwable;
}
}
public function down(): void
{
$tenant = DB::table('tenants')
->where('codigo', self::TENANT_CODE)
->first(['id', 'client_id', 'header_logo_id', 'footer_logo_id', 'favicon_id']);
if ($tenant === null) {
return;
}
$attachmentIds = collect([
$tenant->header_logo_id,
$tenant->footer_logo_id,
$tenant->favicon_id,
])->filter()->unique()->values();
$storedPaths = DB::table('attachments')
->whereIn('id', $attachmentIds)
->pluck('path')
->all();
DB::transaction(function () use ($tenant, $attachmentIds): void {
DB::table('tenant_social_media')
->where('tenant_code', self::TENANT_CODE)
->delete();
DB::table('tenants_menues')
->where('tenant_code', self::TENANT_CODE)
->delete();
DB::table('tenants')
->where('id', $tenant->id)
->delete();
DB::table('attachments')
->whereIn('id', $attachmentIds)
->delete();
$clientHasTenants = DB::table('tenants')
->where('client_id', $tenant->client_id)
->exists();
if (! $clientHasTenants) {
DB::table('clients')
->where('id', $tenant->client_id)
->where('code', self::CLIENT_CODE)
->delete();
}
});
Storage::disk('s3')->delete($storedPaths);
}
private function clientId(): int
{
$clientId = DB::table('clients')->where('code', self::CLIENT_CODE)->value('id');
if ($clientId !== null) {
DB::table('clients')->where('id', $clientId)->update([
'name' => 'Mutual SMEP',
'updated_at' => now(),
]);
return (int) $clientId;
}
return (int) DB::table('clients')->insertGetId([
'code' => self::CLIENT_CODE,
'name' => 'Mutual SMEP',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function assignSocialMedia(DateTimeInterface $now): void
{
$socialMedia = [
[
'code' => 'instagram',
'icon' => 'fa-brands fa-instagram',
'name' => 'Instagram',
'url' => 'https://www.instagram.com/mutualsmep/',
'orden' => 0,
],
[
'code' => 'facebook',
'icon' => 'fa-brands fa-facebook',
'name' => 'Facebook',
'url' => 'https://www.facebook.com/smeprosario',
'orden' => 1,
],
[
'code' => 'whatsapp',
'icon' => 'fa-brands fa-whatsapp',
'name' => 'WhatsApp',
'url' => 'https://wa.me/5493412474530',
'orden' => 2,
],
];
foreach ($socialMedia as $network) {
DB::table('social_media')->insertOrIgnore([
'code' => $network['code'],
'icon' => $network['icon'],
'name' => $network['name'],
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('tenant_social_media')->insertOrIgnore([
'tenant_code' => self::TENANT_CODE,
'social_media_code' => $network['code'],
'url' => $network['url'],
'orden' => $network['orden'],
'created_at' => $now,
'updated_at' => $now,
]);
}
}
private function assignMenus(DateTimeInterface $now): void
{
DB::table('menues')
->whereNotIn('code', self::EXCLUDED_MENU_CODES)
->pluck('code')
->each(function (string $menuCode) use ($now): void {
$staticContent = match ($menuCode) {
'help.faq' => $this->frequentlyAskedQuestions(),
'help.contact' => $this->contactContent(),
default => null,
};
DB::table('tenants_menues')->updateOrInsert(
[
'tenant_code' => self::TENANT_CODE,
'menu_code' => $menuCode,
],
[
'static_content' => $staticContent === null
? null
: json_encode($staticContent, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'created_at' => $now,
'updated_at' => $now,
],
);
});
}
/** @return list<array{pregunta: string, respuesta: string, is_active: bool}> */
private function frequentlyAskedQuestions(): array
{
return [
[
'pregunta' => '¿Hay algún límite de compra?',
'respuesta' => 'La cantidad disponible depende del stock de cada producto.',
'is_active' => true,
],
[
'pregunta' => '¿Cuáles son los medios de pago disponibles?',
'respuesta' => 'Podés consultar y seleccionar los medios de pago habilitados al finalizar tu compra.',
'is_active' => false,
],
[
'pregunta' => '¿Cómo puedo recibir asesoramiento antes de comprar?',
'respuesta' => 'Podés comunicarte con Mutual SMEP por WhatsApp al +54 9 341 247-4530.',
'is_active' => false,
],
];
}
/** @return array<string, mixed> */
private function contactContent(): array
{
return [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/5493412474530',
'whatsapp_label' => 'Chateá con Mutual SMEP',
],
'phone' => '+54 9 341 247-4530',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
[
'label' => 'Mutual SMEP',
'address' => 'San Lorenzo 1543, Rosario, Santa Fe',
'coordinates' => [-32.9431184, -60.6437991],
],
],
],
],
];
}
private function storeImage(string $relativePath, string $filename): int
{
$sourcePath = public_path($relativePath);
if (! is_file($sourcePath)) {
throw new RuntimeException("Image not found at path: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if ($contents === false) {
throw new RuntimeException("Could not read image at path: {$sourcePath}");
}
$key = (string) Str::uuid();
$storedPath = 'tenants/'.self::TENANT_CODE."/{$key}.png";
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Could not store image at path: {$storedPath}");
}
$this->storedPaths[] = $storedPath;
return DB::table('attachments')->insertGetId([
'key' => $key,
'path' => $storedPath,
'filename' => $filename,
'type' => 'image',
'mime_type' => 'image/png',
'extension' => 'png',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
}
};

View File

@@ -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('desfile_entry_reservations', function (Blueprint $table): void {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('desfile_entry_reservations', function (Blueprint $table): void {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,141 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
/** @var array<string, list<string>> */
private const CATEGORIES = [
'Dormitorio y Blanco' => [
'Colchones',
'Bases y Sommiers',
'Acolchados y Edredones',
'Frazadas y Mantas',
'Infantil y Cuna',
],
'Electrodomésticos' => [
'Heladeras',
'Lavarropas y Secarropas',
'Cocinas',
'Microondas',
'Hornos Eléctricos',
'Calefones y Termotanques',
'Purificadores y Extractores de Cocina',
'Preparación de Alimentos',
'Café y Desayuno',
'Freidoras y Cocción',
'Cuidado Personal',
'Limpieza y Planchado',
],
'Climatización' => [
'Aires Acondicionados',
'Calefactores a Gas con Salida',
'Calefactores a Gas sin Salida',
'Calefacción Eléctrica',
],
'Tecnología' => [
'Celulares',
'Notebooks',
'Smart TV',
'Audio',
'Accesorios de Computación',
'Soportes para TV',
],
'Bicicletas y Aire Libre' => [
'Bicicletas Infantiles',
'Bicicletas para Adultos',
'Piletas',
],
'Bazar' => [
'Termos',
],
];
public function up(): void
{
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
DB::transaction(function (): void {
foreach (self::CATEGORIES as $parentName => $subcategoryNames) {
$parentId = $this->upsertCategory($parentName, null);
foreach ($subcategoryNames as $subcategoryName) {
$this->upsertCategory($subcategoryName, $parentId);
}
}
});
}
public function down(): void
{
DB::transaction(function (): void {
foreach (self::CATEGORIES as $parentName => $subcategoryNames) {
$parentId = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('nombre', $parentName)
->whereNull('categoria_id')
->value('id');
if ($parentId === null) {
continue;
}
$subcategoryIds = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('categoria_id', $parentId)
->whereIn('nombre', $subcategoryNames)
->pluck('id');
foreach ($subcategoryIds as $subcategoryId) {
$hasChildren = DB::table('categorias')
->where('categoria_id', $subcategoryId)
->exists();
if (! $hasChildren) {
DB::table('categorias')->where('id', $subcategoryId)->delete();
}
}
$hasChildren = DB::table('categorias')
->where('categoria_id', $parentId)
->exists();
if (! $hasChildren) {
DB::table('categorias')->where('id', $parentId)->delete();
}
}
});
}
private function upsertCategory(string $name, ?int $parentId): int
{
$categoryId = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('nombre', $name)
->value('id');
if ($categoryId !== null) {
DB::table('categorias')->where('id', $categoryId)->update([
'categoria_id' => $parentId,
'is_enabled' => true,
'updated_at' => now(),
]);
return (int) $categoryId;
}
return (int) DB::table('categorias')->insertGetId([
'tenant_code' => self::TENANT_CODE,
'categoria_id' => $parentId,
'nombre' => $name,
'is_enabled' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
};

View File

@@ -0,0 +1,139 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
/** @var list<array<string, mixed>> */
private const ATTRIBUTES = [
[
'codigo' => 'color',
'nombre' => 'Color',
'metadata_schema' => [
'hex' => ['type' => 'string'],
],
'options' => [
['value' => 'Negro', 'label' => 'Negro', 'metadata' => ['hex' => '#000000']],
['value' => 'Blanco', 'label' => 'Blanco', 'metadata' => ['hex' => '#FFFFFF']],
['value' => 'Gris', 'label' => 'Gris', 'metadata' => ['hex' => '#808080']],
['value' => 'Plata', 'label' => 'Plata', 'metadata' => ['hex' => '#C0C0C0']],
['value' => 'Azul', 'label' => 'Azul', 'metadata' => ['hex' => '#0000FF']],
['value' => 'Rojo', 'label' => 'Rojo', 'metadata' => ['hex' => '#DC3545']],
['value' => 'Rosa', 'label' => 'Rosa', 'metadata' => ['hex' => '#FFC0CB']],
['value' => 'Multicolor', 'label' => 'Multicolor'],
],
],
[
'codigo' => 'medida_cama',
'nombre' => 'Medida',
'options' => [
['value' => 'Cuna', 'label' => 'Cuna'],
['value' => '1 Plaza', 'label' => '1 Plaza'],
['value' => '1 1/2 Plazas', 'label' => '1 1/2 Plazas'],
['value' => '2 Plazas', 'label' => '2 Plazas'],
['value' => '2 1/2 Plazas', 'label' => '2 1/2 Plazas'],
['value' => 'Queen', 'label' => 'Queen'],
['value' => 'King', 'label' => 'King'],
],
],
[
'codigo' => 'almacenamiento',
'nombre' => 'Almacenamiento',
'options' => [
['value' => '128 GB', 'label' => '128 GB'],
['value' => '256 GB', 'label' => '256 GB'],
['value' => '512 GB', 'label' => '512 GB'],
['value' => '1 TB', 'label' => '1 TB'],
],
],
[
'codigo' => 'memoria_ram',
'nombre' => 'Memoria RAM',
'options' => [
['value' => '4 GB', 'label' => '4 GB'],
['value' => '8 GB', 'label' => '8 GB'],
['value' => '16 GB', 'label' => '16 GB'],
],
],
[
'codigo' => 'rodado',
'nombre' => 'Rodado',
'options' => [
['value' => '16', 'label' => '16'],
['value' => '26', 'label' => '26'],
['value' => '29', 'label' => '29'],
],
],
];
public function up(): void
{
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
DB::transaction(function (): void {
foreach (self::ATTRIBUTES as $definition) {
$attributeId = $this->upsertAttribute($definition);
DB::table('attribute_options')->where('attribute_id', $attributeId)->delete();
foreach ($definition['options'] as $index => $option) {
DB::table('attribute_options')->insert([
'attribute_id' => $attributeId,
'value' => $option['value'],
'label' => $option['label'],
'sort_order' => $index + 1,
'metadata' => isset($option['metadata'])
? json_encode($option['metadata'], JSON_THROW_ON_ERROR)
: null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
});
}
public function down(): void
{
DB::table('attribute')
->where('tenant_codigo', self::TENANT_CODE)
->whereIn('codigo', array_column(self::ATTRIBUTES, 'codigo'))
->delete();
}
/** @param array<string, mixed> $definition */
private function upsertAttribute(array $definition): int
{
$attributeId = DB::table('attribute')
->where('tenant_codigo', self::TENANT_CODE)
->where('codigo', $definition['codigo'])
->value('id');
$values = [
'nombre' => $definition['nombre'],
'is_required' => false,
'metadata_schema' => isset($definition['metadata_schema'])
? json_encode($definition['metadata_schema'], JSON_THROW_ON_ERROR)
: null,
'type' => 'select',
'updated_at' => now(),
];
if ($attributeId !== null) {
DB::table('attribute')->where('id', $attributeId)->update($values);
return (int) $attributeId;
}
return (int) DB::table('attribute')->insertGetId([
'tenant_codigo' => self::TENANT_CODE,
'codigo' => $definition['codigo'],
...$values,
'created_at' => now(),
]);
}
};

View File

@@ -0,0 +1,462 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
private const MANIFEST = 'data/mutual_smep_catalog_v1.json';
private const STORAGE_PREFIX = 'tenants/mutual_smep/catalog-v2/';
private const LEGACY_STORAGE_PREFIX = 'tenants/mutual_smep/catalog-v1/';
private const BRAND_MARKER = 'Provisioned by Mutual SMEP catalog migration v1.';
/** @var list<array{code: string, category: string}> */
private const FEATURED_GROUPS = [
['code' => 'smep-dormitorio-y-blanco', 'category' => 'Dormitorio y Blanco'],
['code' => 'smep-electrodomesticos', 'category' => 'Electrodomésticos'],
['code' => 'smep-climatizacion', 'category' => 'Climatización'],
['code' => 'smep-tecnologia', 'category' => 'Tecnología'],
['code' => 'smep-bicicletas-y-aire-libre', 'category' => 'Bicicletas y Aire Libre'],
['code' => 'smep-bazar', 'category' => 'Bazar'],
];
private const HIGHLIGHTED_GROUP_CODE = 'productos-destacados-smep';
private const CATEGORY_FEATURED_ITEM_LIMIT = 6;
/** @var list<string> */
private const HIGHLIGHTED_PRODUCT_SLUGS = [
'acolchado-kavanagh-simil-plumon-reversible',
'acolchado-sense-duo-bitono-corderito',
'colchon-inducol-constanza',
'edredon-lisboa-jean-cartier',
'frazada-kavanagh-premium-soft',
'kit-edredon-alaska-corderito',
'motorola-g15',
'smart-tv-noblex-50-dr50-x8500-google-tv',
'heladera-gafa-hgf-368afp-330-lt-color-plata',
'cafetera-express-atma-ceat-5418p-1-litro',
'split-3300-w-tcl-taca-3300fcsa-frio-calor',
'bicicleta-venzo-loki-r29-fd-21-v-shimano',
'termo-stanley-1-l-adventure-go-to-con-tapon',
];
/** @var list<string> */
private array $storedPaths = [];
public function up(): void
{
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
throw new RuntimeException("Tenant '".self::TENANT_CODE."' not found.");
}
$manifest = $this->manifest();
$slugs = array_column($manifest['items'], 'slug');
$existingSlug = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->whereIn('slug', $slugs)
->value('slug');
if ($existingSlug !== null) {
throw new RuntimeException("Catalog item '{$existingSlug}' already exists for Mutual SMEP.");
}
try {
DB::transaction(function () use ($manifest): void {
$brandIds = [];
$categoryIds = [];
$attributeIds = [];
foreach ($manifest['items'] as $order => $item) {
$brandId = $this->brandId($item['brand'], $brandIds);
$categoryId = $this->categoryId(
$item['parent_category'],
$item['category'],
$categoryIds,
);
$hasVariants = $item['variants'] !== [];
$inventoryId = $hasVariants
? null
: $this->createInventory((int) $item['stock']);
$catalogItemId = DB::table('catalog_items')->insertGetId([
'tenant_code' => self::TENANT_CODE,
'category_id' => $categoryId,
'brand_id' => $brandId,
'inventory_id' => $inventoryId,
'type' => 'standard',
'slug' => $item['slug'],
'nombre' => $item['name'],
'group_order' => $order + 1,
'descripcion' => $item['description'],
'precio' => $item['price'],
'inventory_policy' => 'tracked',
'inventory_subject' => 'product',
'has_tickets' => false,
]);
$this->attachImages($catalogItemId, null, $item['images']);
if (! $hasVariants) {
continue;
}
$itemAttributeIds = [];
foreach ($item['attribute_codes'] as $attributeOrder => $attributeCode) {
$attributeId = $this->attributeId($attributeCode, $attributeIds);
$itemAttributeIds[$attributeCode] = DB::table('item_attributes')->insertGetId([
'catalog_item_id' => $catalogItemId,
'attribute_id' => $attributeId,
'allow_multi_select' => false,
'sort_order' => $attributeOrder + 1,
'show_in_selector' => true,
'ticket_label' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
foreach ($item['variants'] as $variant) {
$variantId = DB::table('variantes')->insertGetId([
'catalog_item_id' => $catalogItemId,
'inventory_id' => $this->createInventory((int) $variant['stock']),
'descripcion' => $variant['description'],
'precio' => $variant['price'],
]);
foreach ($variant['values'] as $attributeCode => $value) {
$itemAttributeId = $itemAttributeIds[$attributeCode] ?? null;
if ($itemAttributeId === null) {
throw new RuntimeException(
"Attribute '{$attributeCode}' is not assigned to '{$item['slug']}'."
);
}
DB::table('variant_values')->insert([
'variant_id' => $variantId,
'item_attribute_id' => $itemAttributeId,
'value' => $value,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->attachImages($catalogItemId, $variantId, $variant['images']);
}
}
$this->createFeaturedGroups();
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($this->storedPaths);
throw $throwable;
}
}
public function down(): void
{
$manifest = $this->manifest();
$catalogItemIds = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->whereIn('slug', array_column($manifest['items'], 'slug'))
->pluck('id');
$variantInventoryIds = DB::table('variantes')
->whereIn('catalog_item_id', $catalogItemIds)
->pluck('inventory_id');
$itemInventoryIds = DB::table('catalog_items')
->whereIn('id', $catalogItemIds)
->whereNotNull('inventory_id')
->pluck('inventory_id');
$attachmentIds = DB::table('catalog_items_attachments')
->whereIn('catalog_item_id', $catalogItemIds)
->pluck('attachment_id')
->unique()
->values();
$storedPaths = DB::table('attachments')
->whereIn('id', $attachmentIds)
->where(fn ($query) => $query
->where('path', 'like', self::STORAGE_PREFIX.'%')
->orWhere('path', 'like', self::LEGACY_STORAGE_PREFIX.'%'))
->pluck('path')
->all();
DB::transaction(function () use (
$catalogItemIds,
$variantInventoryIds,
$itemInventoryIds,
$attachmentIds,
): void {
DB::table('featured_groups')
->where('tenant_code', self::TENANT_CODE)
->whereIn('code', [
self::HIGHLIGHTED_GROUP_CODE,
...array_column(self::FEATURED_GROUPS, 'code'),
])
->delete();
DB::table('catalog_items')->whereIn('id', $catalogItemIds)->delete();
DB::table('inventories')
->whereIn('id', $variantInventoryIds->merge($itemInventoryIds)->unique())
->delete();
DB::table('attachments')->whereIn('id', $attachmentIds)->delete();
DB::table('brands')
->where('tenant_codigo', self::TENANT_CODE)
->where('descripcion', self::BRAND_MARKER)
->whereNotExists(fn ($query) => $query
->selectRaw('1')
->from('catalog_items')
->whereColumn('catalog_items.brand_id', 'brands.id'))
->delete();
});
Storage::disk('s3')->delete($storedPaths);
}
/** @return array<string, mixed> */
private function manifest(): array
{
$path = database_path(self::MANIFEST);
if (! is_file($path)) {
throw new RuntimeException("Mutual SMEP catalog manifest not found: {$path}");
}
$manifest = json_decode(
(string) file_get_contents($path),
true,
flags: JSON_THROW_ON_ERROR,
);
if (
($manifest['version'] ?? null) !== 1
|| ($manifest['tenant_code'] ?? null) !== self::TENANT_CODE
|| count($manifest['items'] ?? []) !== 142
|| ($manifest['image_count'] ?? null) !== 511
) {
throw new RuntimeException('Mutual SMEP catalog manifest is invalid.');
}
return $manifest;
}
/** @param array<string, int> $cache */
private function brandId(?string $name, array &$cache): ?int
{
if ($name === null) {
return null;
}
if (isset($cache[$name])) {
return $cache[$name];
}
$brandId = DB::table('brands')
->where('tenant_codigo', self::TENANT_CODE)
->where('nombre', $name)
->value('id');
if ($brandId === null) {
$brandId = DB::table('brands')->insertGetId([
'tenant_codigo' => self::TENANT_CODE,
'nombre' => $name,
'descripcion' => self::BRAND_MARKER,
'created_at' => now(),
'updated_at' => now(),
]);
}
return $cache[$name] = (int) $brandId;
}
/** @param array<string, int> $cache */
private function categoryId(string $parentName, string $name, array &$cache): int
{
$key = "{$parentName}|{$name}";
if (isset($cache[$key])) {
return $cache[$key];
}
$parentId = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('nombre', $parentName)
->whereNull('categoria_id')
->value('id');
$categoryId = $parentId === null
? null
: DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('categoria_id', $parentId)
->where('nombre', $name)
->value('id');
if ($categoryId === null) {
throw new RuntimeException("Category '{$parentName} > {$name}' not found for Mutual SMEP.");
}
return $cache[$key] = (int) $categoryId;
}
/** @param array<string, int> $cache */
private function attributeId(string $code, array &$cache): int
{
if (isset($cache[$code])) {
return $cache[$code];
}
$attributeId = DB::table('attribute')
->where('tenant_codigo', self::TENANT_CODE)
->where('codigo', $code)
->value('id');
if ($attributeId === null) {
throw new RuntimeException("Attribute '{$code}' not found for Mutual SMEP.");
}
return $cache[$code] = (int) $attributeId;
}
private function createInventory(int $stock): int
{
return (int) DB::table('inventories')->insertGetId([
'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0,
'real_stock' => $stock,
]);
}
private function createFeaturedGroups(): void
{
$this->createHighlightedGroup();
foreach (self::FEATURED_GROUPS as $order => $group) {
$categoryId = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->whereNull('categoria_id')
->where('nombre', $group['category'])
->value('id');
if ($categoryId === null) {
throw new RuntimeException("Parent category '{$group['category']}' not found for Mutual SMEP.");
}
$featuredGroupId = DB::table('featured_groups')->insertGetId([
'tenant_code' => self::TENANT_CODE,
'code' => $group['code'],
'source_type' => 'manual',
'category_id' => $categoryId,
'product_layout' => 'column_with_image',
'group_layout' => 'carousel',
'group_name' => $group['category'],
'group_order' => $order + 2,
]);
$catalogItemIds = DB::table('catalog_items')
->join('categorias', 'categorias.id', '=', 'catalog_items.category_id')
->where('catalog_items.tenant_code', self::TENANT_CODE)
->where('categorias.tenant_code', self::TENANT_CODE)
->where('categorias.categoria_id', $categoryId)
->orderBy('catalog_items.group_order')
->orderBy('catalog_items.id')
->limit(self::CATEGORY_FEATURED_ITEM_LIMIT)
->pluck('catalog_items.id');
foreach ($catalogItemIds as $itemOrder => $catalogItemId) {
DB::table('featured_items')->insert([
'featured_group_id' => $featuredGroupId,
'catalog_item_id' => $catalogItemId,
'order' => $itemOrder + 1,
]);
}
}
}
private function createHighlightedGroup(): void
{
$featuredGroupId = DB::table('featured_groups')->insertGetId([
'tenant_code' => self::TENANT_CODE,
'code' => self::HIGHLIGHTED_GROUP_CODE,
'source_type' => 'manual',
'category_id' => null,
'product_layout' => 'column_with_image',
'group_layout' => 'carousel',
'group_name' => 'Productos destacados',
'group_order' => 1,
]);
$catalogItemIds = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->whereIn('slug', self::HIGHLIGHTED_PRODUCT_SLUGS)
->pluck('id', 'slug');
foreach (self::HIGHLIGHTED_PRODUCT_SLUGS as $order => $slug) {
$catalogItemId = $catalogItemIds->get($slug);
if ($catalogItemId === null) {
throw new RuntimeException("Highlighted product '{$slug}' not found for Mutual SMEP.");
}
DB::table('featured_items')->insert([
'featured_group_id' => $featuredGroupId,
'catalog_item_id' => $catalogItemId,
'order' => $order + 1,
]);
}
}
/** @param list<string> $images */
private function attachImages(int $catalogItemId, ?int $variantId, array $images): void
{
foreach ($images as $order => $relativePath) {
$sourcePath = public_path($relativePath);
if (! is_file($sourcePath)) {
throw new RuntimeException("Catalog image not found: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if ($contents === false) {
throw new RuntimeException("Catalog image could not be read: {$sourcePath}");
}
$catalogRelativePath = Str::after(
str_replace('\\', '/', $relativePath),
'images/tennants/mutual_smep/catalog/',
);
$storedPath = self::STORAGE_PREFIX.$catalogRelativePath;
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Catalog image could not be stored: {$storedPath}");
}
$this->storedPaths[] = $storedPath;
$attachmentId = DB::table('attachments')->insertGetId([
'key' => (string) Str::uuid(),
'path' => $storedPath,
'filename' => basename($relativePath),
'type' => 'image',
'mime_type' => 'image/webp',
'extension' => 'webp',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('catalog_items_attachments')->insert([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'attachment_id' => $attachmentId,
'orden' => $order,
'is_enabled' => true,
]);
}
}
};

View File

@@ -0,0 +1,140 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
private const EXTRA_CODE = 'carousel';
private const IMAGE_DIRECTORY = 'images/tennants/mutual_smep/carousel';
private const STORAGE_PREFIX = 'tenants/mutual_smep/extras/carousel-v1/';
/** @var list<string> */
private const IMAGES = [
'01-tecnologia.webp',
'02-hogar.webp',
'03-vida-activa.webp',
'04-descanso.webp',
];
/** @var list<string> */
private array $storedPaths = [];
public function up(): void
{
$tenant = DB::table('tenants')
->where('codigo', self::TENANT_CODE)
->first(['codigo', 'storefront_website_type_code']);
if ($tenant === null) {
return;
}
$extraId = DB::table('storefront_website_type_extras')
->where('storefront_website_type_code', $tenant->storefront_website_type_code)
->where('codigo', self::EXTRA_CODE)
->value('id');
if ($extraId === null || DB::table('websites_extras')->where([
'website_code' => self::TENANT_CODE,
'website_type_extra_id' => $extraId,
])->exists()) {
return;
}
try {
DB::transaction(function () use ($extraId): void {
$attachmentIds = [];
foreach (self::IMAGES as $filename) {
$attachmentIds[] = $this->storeImage($filename);
}
DB::table('websites_extras')->insert([
'website_code' => self::TENANT_CODE,
'website_type_extra_id' => $extraId,
'config' => json_encode($attachmentIds, JSON_THROW_ON_ERROR),
'is_enabled' => true,
'created_at' => now(),
'updated_at' => now(),
]);
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($this->storedPaths);
throw $throwable;
}
}
public function down(): void
{
$attachments = DB::table('attachments')
->where('path', 'like', self::STORAGE_PREFIX.'%')
->get(['id', 'path']);
if ($attachments->isEmpty()) {
return;
}
$attachmentIds = $attachments->pluck('id')->map(fn ($id): int => (int) $id)->all();
$extraId = DB::table('storefront_website_type_extras')
->where('storefront_website_type_code', 'shopit')
->where('codigo', self::EXTRA_CODE)
->value('id');
DB::transaction(function () use ($attachmentIds, $extraId): void {
if ($extraId !== null) {
DB::table('websites_extras')
->where('website_code', self::TENANT_CODE)
->where('website_type_extra_id', $extraId)
->where('config', json_encode($attachmentIds, JSON_THROW_ON_ERROR))
->delete();
}
DB::table('attachments')->whereIn('id', $attachmentIds)->delete();
});
Storage::disk('s3')->delete($attachments->pluck('path')->all());
}
private function storeImage(string $filename): int
{
$sourcePath = public_path(self::IMAGE_DIRECTORY."/{$filename}");
if (! is_file($sourcePath)) {
throw new RuntimeException("Carousel image not found: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if ($contents === false) {
throw new RuntimeException("Carousel image could not be read: {$sourcePath}");
}
$storedPath = self::STORAGE_PREFIX.$filename;
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Carousel image could not be stored: {$storedPath}");
}
$this->storedPaths[] = $storedPath;
return (int) DB::table('attachments')->insertGetId([
'key' => (string) Str::uuid(),
'path' => $storedPath,
'filename' => $filename,
'type' => 'image',
'mime_type' => 'image/webp',
'extension' => 'webp',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
}
};

View File

@@ -0,0 +1,88 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
private const ITEM_LIMIT = 6;
/** @var array<string, string> */
private const GROUPS = [
'smep-dormitorio-y-blanco' => 'Dormitorio y Blanco',
'smep-electrodomesticos' => 'Electrodomésticos',
'smep-climatizacion' => 'Climatización',
'smep-tecnologia' => 'Tecnología',
'smep-bicicletas-y-aire-libre' => 'Bicicletas y Aire Libre',
'smep-bazar' => 'Bazar',
];
public function up(): void
{
foreach (array_keys(self::GROUPS) as $code) {
$groupId = DB::table('featured_groups')
->where('tenant_code', self::TENANT_CODE)
->where('code', $code)
->value('id');
if ($groupId === null) {
continue;
}
$keptItemIds = DB::table('featured_items')
->where('featured_group_id', $groupId)
->orderBy('order')
->orderBy('id')
->limit(self::ITEM_LIMIT)
->pluck('id');
DB::table('featured_items')
->where('featured_group_id', $groupId)
->when(
$keptItemIds->isNotEmpty(),
fn ($query) => $query->whereNotIn('id', $keptItemIds),
)
->delete();
}
}
public function down(): void
{
foreach (self::GROUPS as $code => $parentCategoryName) {
$groupId = DB::table('featured_groups')
->where('tenant_code', self::TENANT_CODE)
->where('code', $code)
->value('id');
$parentCategoryId = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->whereNull('categoria_id')
->where('nombre', $parentCategoryName)
->value('id');
if ($groupId === null || $parentCategoryId === null) {
continue;
}
$catalogItemIds = DB::table('catalog_items')
->join('categorias', 'categorias.id', '=', 'catalog_items.category_id')
->where('catalog_items.tenant_code', self::TENANT_CODE)
->where('categorias.tenant_code', self::TENANT_CODE)
->where('categorias.categoria_id', $parentCategoryId)
->orderBy('catalog_items.group_order')
->orderBy('catalog_items.id')
->pluck('catalog_items.id');
DB::table('featured_items')->where('featured_group_id', $groupId)->delete();
foreach ($catalogItemIds as $order => $catalogItemId) {
DB::table('featured_items')->insert([
'featured_group_id' => $groupId,
'catalog_item_id' => $catalogItemId,
'order' => $order + 1,
]);
}
}
}
};

View File

@@ -0,0 +1,112 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
private const LEGACY_PREFIX = 'tenants/mutual_smep/catalog-v1/';
private const OPTIMIZED_PREFIX = 'tenants/mutual_smep/catalog-v2/';
private const LOCAL_PREFIX = 'images/tennants/mutual_smep/catalog/';
public function up(): void
{
$attachments = $this->attachments(self::LEGACY_PREFIX);
$uploadedPaths = [];
try {
foreach ($attachments as $attachment) {
$relativePath = Str::after($attachment->path, self::LEGACY_PREFIX);
$localPath = public_path(self::LOCAL_PREFIX.$relativePath);
if (! is_file($localPath)) {
throw new RuntimeException("Optimized catalog image not found: {$localPath}");
}
$contents = file_get_contents($localPath);
if ($contents === false) {
throw new RuntimeException("Optimized catalog image could not be read: {$localPath}");
}
$optimizedPath = self::OPTIMIZED_PREFIX.$relativePath;
if (! Storage::disk('s3')->put($optimizedPath, $contents)) {
throw new RuntimeException("Optimized catalog image could not be stored: {$optimizedPath}");
}
$uploadedPaths[] = $optimizedPath;
$attachment->optimized_path = $optimizedPath;
$attachment->optimized_size = strlen($contents);
}
DB::transaction(function () use ($attachments): void {
foreach ($attachments as $attachment) {
DB::table('attachments')->where('id', $attachment->id)->update([
'path' => $attachment->optimized_path,
'size' => $attachment->optimized_size,
'updated_at' => now(),
]);
}
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($uploadedPaths);
throw $throwable;
}
}
public function down(): void
{
$attachments = $this->attachments(self::OPTIMIZED_PREFIX)
->filter(function (object $attachment): bool {
$relativePath = Str::after($attachment->path, self::OPTIMIZED_PREFIX);
$legacyPath = self::LEGACY_PREFIX.$relativePath;
if (! Storage::disk('s3')->exists($legacyPath)) {
return false;
}
$attachment->legacy_path = $legacyPath;
$attachment->legacy_size = Storage::disk('s3')->size($legacyPath);
return true;
})
->values();
DB::transaction(function () use ($attachments): void {
foreach ($attachments as $attachment) {
DB::table('attachments')->where('id', $attachment->id)->update([
'path' => $attachment->legacy_path,
'size' => $attachment->legacy_size,
'updated_at' => now(),
]);
}
});
Storage::disk('s3')->delete($attachments->pluck('path')->all());
}
private function attachments(string $prefix): Collection
{
return DB::table('attachments')
->join(
'catalog_items_attachments',
'catalog_items_attachments.attachment_id',
'=',
'attachments.id',
)
->join('catalog_items', 'catalog_items.id', '=', 'catalog_items_attachments.catalog_item_id')
->where('catalog_items.tenant_code', self::TENANT_CODE)
->where('attachments.path', 'like', $prefix.'%')
->select('attachments.id', 'attachments.path', 'attachments.size')
->distinct()
->orderBy('attachments.id')
->get();
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const MENU_CODE = 'adminapp.tickets';
private const TENANT_CODE = 'desfile_pura_tendencia';
public function up(): void
{
if (
! DB::table('menues')->where('code', self::MENU_CODE)->exists()
|| ! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()
) {
return;
}
$now = now();
DB::table('tenants_menues')->updateOrInsert(
[
'tenant_code' => self::TENANT_CODE,
'menu_code' => self::MENU_CODE,
],
[
'static_content' => null,
'created_at' => $now,
'updated_at' => $now,
],
);
}
public function down(): void
{
DB::table('tenants_menues')
->where('tenant_code', self::TENANT_CODE)
->where('menu_code', self::MENU_CODE)
->delete();
}
};

View File

@@ -0,0 +1,26 @@
<?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('events', function (Blueprint $table): void {
$table->foreignId('client_id')
->nullable()
->after('id')
->constrained('clients')
->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table): void {
$table->dropConstrainedForeignId('client_id');
});
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_codigo')
->constrained('events')
->nullOnDelete();
});
DB::table('compras')
->orderBy('id')
->each(function (object $purchase): void {
$eventIds = DB::table('compra_items')
->join('catalog_items', 'catalog_items.id', '=', 'compra_items.source_catalog_item_id')
->where('compra_items.compra_id', $purchase->id)
->whereNotNull('catalog_items.event_id')
->distinct()
->pluck('catalog_items.event_id');
if ($eventIds->count() === 1) {
DB::table('compras')
->where('id', $purchase->id)
->update(['event_id' => $eventIds->first()]);
}
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
});
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const EVENT_TITLE = '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas';
public function up(): void
{
$clientId = DB::table('clients')
->where('code', 'pyme_rural')
->value('id');
if ($clientId === null) {
return;
}
DB::table('events')
->where('tenant_code', 'onticket')
->where('title', self::EVENT_TITLE)
->update(['client_id' => $clientId]);
}
public function down(): void
{
$clientId = DB::table('clients')
->where('code', 'pyme_rural')
->value('id');
if ($clientId === null) {
return;
}
DB::table('events')
->where('tenant_code', 'onticket')
->where('title', self::EVENT_TITLE)
->where('client_id', $clientId)
->update(['client_id' => null]);
}
};

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