Compare commits

...

55 Commits

Author SHA1 Message Date
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
6f191b27f9 feat(seeder): add ticket products to test events 2026-09-22 12:26:34 -03:00
3401c934b2 feat(tickets): group user tickets by event 2026-09-22 12:26:16 -03:00
6efe8439c7 feat: add slug field to events and implement tests for slug uniqueness per tenant 2026-09-22 10:46:34 -03:00
506f8a5176 feat(event): add slug field to events and update related resources and routes 2026-09-22 10:32:25 -03:00
03bb7709c4 feat(seeder): update FiestaTradicionArrufoSeeder to use AVIF image format and validate image existence 2026-09-22 10:12:45 -03:00
c90a393081 feat: Optimize image handling for OnTicket tenant
- Added migration to optimize OnTicket images using OptimizeOnticketImagesSeeder.
- Updated OnTicketTenantSeeder to reference optimized image formats (AVIF).
- Modified OnticketImmersiveHeroCarouselSeeder to use AVIF images for hero carousel.
- Created OptimizeOnticketImagesSeeder to replace existing attachments with optimized images.
- Adjusted TestEventsSeeder to utilize AVIF images for events.
- Updated tests to verify the correct image formats and ensure optimized images are stored.
- Added new image files in AVIF format for logos, backgrounds, and event images.
2026-09-22 10:01:05 -03:00
a4a8a3ef8f feat(seeder): add FiestaTradicionArrufoSeeder to tenant provisioning and update OnTicketTenantSeeder for image validation 2026-09-21 16:52:40 -03:00
f5f4cb4e87 feat(tenant): implement TenantProvisioningService and OnTicketTenantSeeder for tenant management 2026-09-21 16:44:57 -03:00
d33b435ba5 feat(ticket): add command to convert fixed_window validity times between local timezone and UTC 2026-09-21 16:22:48 -03:00
969e99b3ae feat(ticketing): add service and command for converting validity times to UTC 2026-09-21 16:05:18 -03:00
3e3b3bfac0 feat(ticketing): resolve validity in tenant timezone 2026-09-21 15:19:09 -03:00
f4de2ff5b2 refactor(tenants): remove general write endpoints 2026-09-21 15:18:48 -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
675 changed files with 10678 additions and 1864 deletions

View File

@@ -2470,45 +2470,6 @@
}, },
"response": [] "response": []
}, },
{
"name": "Create Tenant",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `POST /api/tenants`\n\nControlador: `App\\Domains\\Tenant\\Controllers\\TenantController@store`",
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"admin_website_type_code\": \"shopit\",\n \"storefront_website_type_code\": \"shopit\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{ {
"name": "Get Tenant", "name": "Get Tenant",
"request": { "request": {
@@ -2535,86 +2496,6 @@
}, },
"response": [] "response": []
}, },
{
"name": "Update Tenant (PUT)",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/tenants/{tenant}`\n\nControlador: `App\\Domains\\Tenant\\Controllers\\TenantController@update`",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_id}}"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Update Tenant (PATCH)",
"request": {
"method": "PATCH",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PATCH /api/tenants/{tenant}`\n\nControlador: `App\\Domains\\Tenant\\Controllers\\TenantController@update`",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_id}}"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{ {
"name": "Delete Tenant", "name": "Delete Tenant",
"request": { "request": {

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

@@ -7,8 +7,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem;
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\Commerce\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Shared\Enums\FieldType;
use App\Domains\Ticketing\Ticket\Resources\ValidityTimeResource; use App\Domains\Ticketing\Ticket\Resources\ValidityTimeResource;
use App\Shared\Enums\FieldType;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -140,7 +140,7 @@ class CatalogItemDetailResource extends JsonResource
'validity_time_id' => $option->validity_time_id, 'validity_time_id' => $option->validity_time_id,
'validity_time' => $option->validityTime === null 'validity_time' => $option->validityTime === null
? null ? null
: ValidityTimeResource::make($option->validityTime), : ValidityTimeResource::make($option->validityTime, $this->tenant->timezone),
'metadata' => $option->metadata, 'metadata' => $option->metadata,
]) ])
->values(); ->values();

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

@@ -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

@@ -3,11 +3,8 @@
namespace App\Domains\Core\Tenant\Controllers; namespace App\Domains\Core\Tenant\Controllers;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Requests\StoreTenantRequest;
use App\Domains\Core\Tenant\Requests\UpdateTenantRequest;
use App\Domains\Core\Tenant\Resources\TenantResource; use App\Domains\Core\Tenant\Resources\TenantResource;
use App\Domains\Core\Tenant\Services\TenantInformationService; use App\Domains\Core\Tenant\Services\TenantInformationService;
use App\Domains\Core\Tenant\Services\TenantService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@@ -15,7 +12,6 @@ use Illuminate\Http\Response;
class TenantController extends Controller class TenantController extends Controller
{ {
public function __construct( public function __construct(
protected TenantService $tenantService,
protected TenantInformationService $tenantInformationService, protected TenantInformationService $tenantInformationService,
) {} ) {}
@@ -30,15 +26,6 @@ class TenantController extends Controller
return TenantResource::collection($tenants)->response(); return TenantResource::collection($tenants)->response();
} }
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = $this->tenantService->create($request->validated());
return TenantResource::make(
$this->tenantInformationService->load($tenant)
)->response()->setStatusCode(201);
}
public function show(Tenant $tenant): TenantResource public function show(Tenant $tenant): TenantResource
{ {
return TenantResource::make( return TenantResource::make(
@@ -46,15 +33,6 @@ class TenantController extends Controller
); );
} }
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
{
$tenant = $this->tenantService->update($tenant, $request->validated());
return TenantResource::make(
$this->tenantInformationService->load($tenant)
);
}
public function destroy(Tenant $tenant): Response public function destroy(Tenant $tenant): Response
{ {
$tenant->delete(); $tenant->delete();

View File

@@ -2,20 +2,20 @@
namespace App\Domains\Core\Tenant\Models; namespace App\Domains\Core\Tenant\Models;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Enums\GroupLayout; use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout; use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventCategory;
use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\Menu\Models\TenantMenu; use App\Domains\Core\Menu\Models\TenantMenu;
use App\Domains\Core\Tenant\Enums\CartEditingPolicy; use App\Domains\Core\Tenant\Enums\CartEditingPolicy;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventCategory;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
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;
@@ -28,6 +28,7 @@ use Illuminate\Support\Facades\Schema;
'client_id', 'client_id',
'codigo', 'codigo',
'nombre', 'nombre',
'timezone',
'dominio', 'dominio',
'base_path', 'base_path',
'site_title', 'site_title',
@@ -66,7 +67,10 @@ class Tenant extends Model
{ {
use HasFactory; use HasFactory;
public const DEFAULT_TIMEZONE = 'America/Argentina/Buenos_Aires';
protected $attributes = [ protected $attributes = [
'timezone' => self::DEFAULT_TIMEZONE,
'base_path' => '/', 'base_path' => '/',
'search_product_layout' => ProductLayout::ColumnWithImage->value, 'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value, 'search_group_layout' => GroupLayout::Paginated->value,

View File

@@ -1,139 +0,0 @@
<?php
namespace App\Domains\Core\Tenant\Requests;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Core\Tenant\Enums\CartEditingPolicy;
use App\Domains\Core\Tenant\Services\WebsiteExtraService;
use App\Domains\Core\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$rawDomain = $this->input('dominio');
$hasExplicitBasePath = $this->has('base_path');
$rawBasePath = $hasExplicitBasePath
? $this->input('base_path')
: TenantDomainNormalizer::pathFromDomain($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|| $normalizedBasePath === null;
$this->merge([
'dominio' => $normalizedDomain,
'base_path' => $normalizedBasePath,
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$logoRule = ['required', new ImageOrBase64Rule];
return array_merge([
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'dominio' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidDomain) {
$fail("The {$attribute} field must contain a valid domain or URL.");
}
},
'required',
'string',
'max:255',
Rule::unique('tenants', 'dominio')
->where('base_path', $this->input('base_path')),
],
'base_path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidBasePath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'required',
'string',
'max:255',
Rule::unique('tenants', 'base_path')
->where('dominio', $this->input('dominio')),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
'storefront_website_type_code' => [
'required_with:extras',
'sometimes',
'string',
Rule::exists('storefront_website_types', 'codigo'),
],
'admin_website_type_code' => ['sometimes', 'nullable', 'string', Rule::exists('admin_website_types', 'codigo')],
], app(WebsiteExtraService::class)->requestRules($this->input('storefront_website_type_code')));
}
}

View File

@@ -1,165 +0,0 @@
<?php
namespace App\Domains\Core\Tenant\Requests;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Core\Tenant\Enums\CartEditingPolicy;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('dominio')) {
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& ($normalizedDomain === null || $embeddedBasePath === null);
$this->merge([
'dominio' => $normalizedDomain,
]);
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
$this->merge(['base_path' => $embeddedBasePath]);
}
}
if ($this->has('base_path')) {
$rawBasePath = $this->input('base_path');
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|| $normalizedBasePath === null;
$this->merge(['base_path' => $normalizedBasePath]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$domain = $this->input('dominio', $tenant?->dominio);
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
$logoRule = ['nullable', new ImageOrBase64Rule];
return [
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
'codigo' => [
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
],
'nombre' => ['nullable', 'string', 'max:255'],
'dominio' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidDomain) {
$fail("The {$attribute} field must contain a valid domain or URL.");
}
},
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'dominio')
->where('base_path', $basePath)
->ignore($tenant?->id),
],
'base_path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidBasePath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'base_path')
->where('dominio', $domain)
->ignore($tenant?->id),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
'admin_website_type_code' => ['sometimes', 'nullable', 'string', Rule::exists('admin_website_types', 'codigo')],
'storefront_website_type_code' => [
'sometimes',
'nullable',
'string',
Rule::exists('storefront_website_types', 'codigo'),
function (string $attribute, mixed $value, Closure $fail) use ($tenant): void {
if ($tenant && $value !== $tenant->storefront_website_type_code && $tenant->websiteExtras()->exists()) {
$fail('The storefront website type cannot be changed while the tenant has extras.');
}
},
],
];
}
}

View File

@@ -35,6 +35,7 @@ class TenantResource extends JsonResource
'client_id' => $this->client_id, 'client_id' => $this->client_id,
'codigo' => $this->codigo, 'codigo' => $this->codigo,
'nombre' => $this->nombre, 'nombre' => $this->nombre,
'timezone' => $this->timezone,
'dominio' => $this->dominio, 'dominio' => $this->dominio,
'base_path' => $this->base_path, 'base_path' => $this->base_path,
'site_title' => $this->site_title ?? 'ShopitFront', 'site_title' => $this->site_title ?? 'ShopitFront',

View File

@@ -2,9 +2,10 @@
namespace App\Domains\Core\Tenant\Services; namespace App\Domains\Core\Tenant\Services;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Domains\Core\Tenant\Services;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Internal provisioning used by migrations and seeders.
*
* General tenant writes are intentionally not exposed through the HTTP API.
*/
class TenantProvisioningService
{
public function __construct(
protected AttachmentService $attachmentService,
protected WebsiteExtraService $websiteExtraService,
) {}
/** @param array<string, mixed> $data */
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$images = [
'header_logo' => $data['header_logo'] ?? null,
'footer_logo' => $data['footer_logo'] ?? null,
'favicon' => $data['favicon'] ?? null,
'header_bg_image' => $data['header_bg_image'] ?? null,
'footer_bg_image' => $data['footer_bg_image'] ?? null,
];
$socialMedia = $data['social_media'] ?? [];
$extras = $data['extras'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media'],
$data['extras'],
);
foreach ($images as $key => $image) {
$data[$key.'_id'] = $this->storeTenantImage($image);
}
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
$this->syncSocialMedia($tenant, $socialMedia);
foreach ($extras as $code => $config) {
$this->websiteExtraService->updateForTenant($tenant, $code, $config);
}
return $tenant;
});
}
/** @param array<string, mixed> $data */
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$imageKeys = [
'header_logo',
'footer_logo',
'favicon',
'header_bg_image',
'footer_bg_image',
];
$hasSocialMedia = array_key_exists('social_media', $data);
$socialMedia = $data['social_media'] ?? [];
unset($data['social_media']);
foreach ($imageKeys as $key) {
if (! array_key_exists($key, $data)) {
continue;
}
$data[$key.'_id'] = $this->storeTenantImage($data[$key]);
unset($data[$key]);
}
$tenant->update($data);
if ($hasSocialMedia) {
$this->syncSocialMedia($tenant, $socialMedia);
}
return $tenant->refresh();
});
}
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
private function storeTenantImage(mixed $image): ?int
{
if (! $image) {
return null;
}
$attachment = is_string($image) && Str::isUuid($image)
? Attachment::query()->where('key', $image)->first()
: $this->attachmentService->store($image, 'tenants');
return $attachment?->id;
}
}

View File

@@ -1,198 +0,0 @@
<?php
namespace App\Domains\Core\Tenant\Services;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantService
{
public function __construct(
protected AttachmentService $attachmentService,
protected WebsiteExtraService $websiteExtraService,
) {}
/**
* Create a new tenant and store its logos.
*
* @param array<string, mixed> $data
*/
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
$extras = $data['extras'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media'],
$data['extras'],
);
$headerAttachmentId = null;
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
$headerAttachmentId = $attachment->id;
}
}
$footerAttachmentId = null;
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
$footerAttachmentId = $attachment->id;
}
}
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
$data['favicon_id'] = $this->storeTenantImage($favicon);
$data['header_bg_image_id'] = $this->storeTenantImage($headerBackgroundImage);
$data['footer_bg_image_id'] = $this->storeTenantImage($footerBackgroundImage);
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
$this->syncSocialMedia($tenant, $socialMedia);
$this->websiteExtraService->createForTenant($tenant, $extras);
return $tenant;
});
}
/**
* Update an existing tenant and store new logos if uploaded.
*
* @param array<string, mixed> $data
*/
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasFaviconKey = array_key_exists('favicon', $data);
$hasHeaderBackgroundImageKey = array_key_exists('header_bg_image', $data);
$hasFooterBackgroundImageKey = array_key_exists('footer_bg_image', $data);
$hasSocialMediaKey = array_key_exists('social_media', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media']
);
$tenant->fill($data);
if ($hasHeaderLogoKey) {
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
$tenant->header_logo_id = $attachment->id;
} else {
$tenant->header_logo_id = null;
}
} else {
$tenant->header_logo_id = null;
}
}
if ($hasFooterLogoKey) {
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
$tenant->footer_logo_id = $attachment->id;
} else {
$tenant->footer_logo_id = null;
}
} else {
$tenant->footer_logo_id = null;
}
}
if ($hasFaviconKey) {
$tenant->favicon_id = $this->storeTenantImage($favicon);
}
if ($hasHeaderBackgroundImageKey) {
$tenant->header_bg_image_id = $this->storeTenantImage($headerBackgroundImage);
}
if ($hasFooterBackgroundImageKey) {
$tenant->footer_bg_image_id = $this->storeTenantImage($footerBackgroundImage);
}
$tenant->save();
if ($hasSocialMediaKey) {
$this->syncSocialMedia($tenant, $socialMedia);
}
return $tenant;
});
}
/**
* @param array<int, array{code: string, url: string, orden?: int}> $socialMedia
*/
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$owner = $tenant->activeEvent ?? $tenant;
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$owner->socialMedia()->sync($associations);
$owner->unsetRelation('socialMedia');
}
private function storeTenantImage(mixed $image): ?int
{
if (! $image) {
return null;
}
$attachment = is_string($image) && Str::isUuid($image)
? Attachment::query()->where('key', $image)->first()
: $this->attachmentService->store($image, 'tenants');
return $attachment?->id;
}
}

View File

@@ -2,16 +2,14 @@
namespace App\Domains\Core\Tenant\Services; namespace App\Domains\Core\Tenant\Services;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteTypeExtra;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Models\WebsiteExtra;
use App\Shared\Attachable\Enums\AttachmentType; use App\Shared\Attachable\Enums\AttachmentType;
use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
use App\Shared\Rules\CroppedImageOrBase64Rule; use App\Shared\Rules\CroppedImageOrBase64Rule;
use App\Shared\Rules\ImageOrBase64Rule; use App\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Models\WebsiteExtra;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteTypeExtra;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -21,109 +19,6 @@ class WebsiteExtraService
{ {
public function __construct(protected AttachmentService $attachmentService) {} public function __construct(protected AttachmentService $attachmentService) {}
/**
* Build the request rules declared by the selected website type.
*
* @return array<string, mixed>
*/
public function requestRules(?string $websiteTypeCode): array
{
if (! is_string($websiteTypeCode) || $websiteTypeCode === '') {
return [
'extras' => ['prohibited'],
];
}
$definitions = $this->definitionsFor($websiteTypeCode);
$allowedCodes = $definitions->pluck('codigo')->all();
$hasRequiredExtras = $definitions->contains(
fn (StorefrontWebsiteTypeExtra $definition): bool => $definition->is_required
);
$rules = [
'extras' => [
$hasRequiredExtras ? 'required' : 'sometimes',
'array',
function (string $attribute, mixed $value, \Closure $fail) use ($allowedCodes): void {
if (! is_array($value)) {
return;
}
$unknownCodes = array_diff(array_keys($value), $allowedCodes);
if ($unknownCodes !== []) {
$fail(
'The '.$attribute.' field contains extras not supported by the website type: '
.implode(', ', $unknownCodes).'.'
);
}
},
],
];
foreach ($definitions as $definition) {
$schemaRules = $definition->config_schema['request_rules'] ?? [];
$rootRules = $this->compileRules($schemaRules['$'] ?? []);
$rootRules = array_values(array_filter(
$rootRules,
fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true)
));
array_unshift($rootRules, $definition->is_required ? 'required' : 'sometimes');
$rules["extras.{$definition->codigo}"] = $rootRules;
foreach ($schemaRules as $path => $pathRules) {
if ($path === '$') {
continue;
}
$rules[$this->requestAttribute($definition->codigo, $path)] = $this->compileRules($pathRules);
}
}
return $rules;
}
/**
* Transform and persist each extra selected for a tenant.
*
* @param array<string, mixed> $extras
*/
public function createForTenant(Tenant $tenant, array $extras): void
{
if ($extras === []) {
return;
}
$definitions = $this->definitionsFor((string) $tenant->storefront_website_type_code)
->keyBy('codigo');
foreach ($extras as $name => $config) {
/** @var StorefrontWebsiteTypeExtra|null $definition */
$definition = $definitions->get($name);
if (! $definition) {
throw ValidationException::withMessages([
'extras' => ["The extra {$name} is not supported by the selected website type."],
]);
}
$requestRoot = "extras.{$definition->codigo}";
$transformedConfig = $this->applyTransforms(
$tenant,
$definition,
$config,
$requestRoot
);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => $transformedConfig,
]);
}
$tenant->unsetRelation('websiteExtras');
}
/** /**
* Build request rules for one extra addressed by its stable code. * Build request rules for one extra addressed by its stable code.
* *
@@ -201,19 +96,6 @@ class WebsiteExtraService
->firstOrFail(); ->firstOrFail();
} }
/**
* @return Collection<int, StorefrontWebsiteTypeExtra>
*/
private function definitionsFor(string $websiteTypeCode): Collection
{
$websiteType = StorefrontWebsiteType::query()
->where('codigo', $websiteTypeCode)
->with('extras')
->first();
return $websiteType?->extras ?? collect();
}
/** /**
* @param string|array<int, mixed> $rules * @param string|array<int, mixed> $rules
* @return array<int, mixed> * @return array<int, mixed>
@@ -232,11 +114,6 @@ class WebsiteExtraService
); );
} }
private function requestAttribute(string $extraCode, string $path): string
{
return $this->configAttribute("extras.{$extraCode}", $path);
}
private function configAttribute(string $root, string $path): string private function configAttribute(string $root, string $path): string
{ {
if ($path === '$') { if ($path === '$') {

View File

@@ -15,7 +15,6 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
## Servicios ## Servicios
- `TenantService`: crea y actualiza tenants, incluyendo sus recursos asociados.
- `TenantInformationService`: carga un tenant y las relaciones requeridas por cada contexto. - `TenantInformationService`: carga un tenant y las relaciones requeridas por cada contexto.
- `AdminWebsiteTypeService`: crea o actualiza los tipos de admin y su marca. - `AdminWebsiteTypeService`: crea o actualiza los tipos de admin y su marca.
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras. - `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
@@ -25,7 +24,8 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant
## Endpoints ## Endpoints
- Recurso REST público/administrativo `/tenants`. - `GET /tenants`, `GET /tenants/{codigo}` y `DELETE /tenants/{codigo}`.
- La creación y actualización general de tenants se administran mediante seeders o base de datos; no se exponen por API.
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación. - Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
## Dependencias y reglas ## Dependencias y reglas

View File

@@ -3,6 +3,6 @@
use App\Domains\Core\Tenant\Controllers\TenantController; use App\Domains\Core\Tenant\Controllers\TenantController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::apiResource('tenants', TenantController::class); Route::apiResource('tenants', TenantController::class)->except(['store', 'update']);
require __DIR__.'/adminapp.php'; require __DIR__.'/adminapp.php';

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,9 +2,10 @@
namespace App\Domains\Ticketing\Event\Models; namespace App\Domains\Ticketing\Event\Models;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
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\Commerce\Catalog\Models\CatalogItem; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment; 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;
@@ -13,7 +14,7 @@ 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', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] #[Fillable(['tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])]
class Event extends Model class Event extends Model
{ {
use HasFactory; use HasFactory;
@@ -53,6 +54,12 @@ class Event extends Model
return $this->hasMany(CatalogItem::class)->whereAvailable()->orderBy('group_order')->orderBy('id'); return $this->hasMany(CatalogItem::class)->whereAvailable()->orderBy('group_order')->orderBy('id');
} }
/** @return HasMany<Ticket, $this> */
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class);
}
/** @return BelongsToMany<SocialMedia, $this> */ /** @return BelongsToMany<SocialMedia, $this> */
public function socialMedia(): BelongsToMany public function socialMedia(): BelongsToMany
{ {

View File

@@ -3,10 +3,10 @@
namespace App\Domains\Ticketing\Event\Models; namespace App\Domains\Ticketing\Event\Models;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateStatus; use App\Domains\Ticketing\Event\Enums\EventDateStatus;
use App\Domains\Ticketing\Event\Services\EffectiveEventDateResolver; use App\Domains\Ticketing\Event\Services\EffectiveEventDateResolver;
use App\Domains\Ticketing\Event\Services\EventDateTextFormatter; use App\Domains\Ticketing\Event\Services\EventDateTextFormatter;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType; use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticketing\Ticket\Models\ValidityTime; use App\Domains\Ticketing\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
@@ -131,12 +131,12 @@ class EventDate extends Model
public function startsAt(): CarbonInterface public function startsAt(): CarbonInterface
{ {
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start); return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start, $this->tenant->timezone);
} }
public function endsAt(): CarbonInterface public function endsAt(): CarbonInterface
{ {
$endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); $endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end, $this->tenant->timezone);
return $endsAt->lessThanOrEqualTo($this->startsAt()) return $endsAt->lessThanOrEqualTo($this->startsAt())
? $endsAt->addDay() ? $endsAt->addDay()
@@ -184,8 +184,8 @@ class EventDate extends Model
private function syncValidityTime(): void private function syncValidityTime(): void
{ {
$startsAt = $this->startsAt(); $startsAt = $this->startsAt()->utc();
$expiresAt = $this->endsAt(); $expiresAt = $this->endsAt()->utc();
$attributes = [ $attributes = [
'type' => ValidityTimeType::FixedWindow, 'type' => ValidityTimeType::FixedWindow,

View File

@@ -2,8 +2,8 @@
namespace App\Domains\Ticketing\Event\Resources; namespace App\Domains\Ticketing\Event\Resources;
use App\Domains\Ticketing\Event\Services\EventDateGroupingService;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Services\EventDateGroupingService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,6 +17,7 @@ class EventResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'slug' => $this->slug,
'title' => $this->title, 'title' => $this->title,
'subtitle' => $this->subtitle, 'subtitle' => $this->subtitle,
'description' => $this->description, 'description' => $this->description,

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Ticketing\Event\Resources;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -15,6 +14,7 @@ class PublicEventResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'slug' => $this->slug,
'event_category_id' => $this->event_category_id, 'event_category_id' => $this->event_category_id,
'title' => $this->title, 'title' => $this->title,
'subtitle' => $this->subtitle, 'subtitle' => $this->subtitle,
@@ -29,10 +29,7 @@ class PublicEventResource extends JsonResource
return null; return null;
} }
return Carbon::parse( return $first->startsAt()->toISOString();
$first->date->format('Y-m-d').' '.$first->time_start,
config('app.event_timezone'),
)->toISOString();
}), }),
'dates' => $this->whenLoaded('dates', fn () => $this->dates->map(fn ($date): array => [ 'dates' => $this->whenLoaded('dates', fn () => $this->dates->map(fn ($date): array => [
'id' => $date->id, 'id' => $date->id,
@@ -52,8 +49,7 @@ class PublicEventResource extends JsonResource
'description' => $item->descripcion, 'description' => $item->descripcion,
'price' => $item->precio, 'price' => $item->precio,
'image' => $item->attachments->first()?->getTemporaryUrl(1440), 'image' => $item->attachments->first()?->getTemporaryUrl(1440),
'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool => 'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool => $attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'),
$attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'),
'maximum_quantity' => $item->inventory_policy === InventoryPolicy::Unlimited 'maximum_quantity' => $item->inventory_policy === InventoryPolicy::Unlimited
? null ? null
: $item->availableStock(), : $item->availableStock(),

View File

@@ -6,9 +6,9 @@ use Illuminate\Support\Facades\Route;
require __DIR__.'/adminapp.php'; require __DIR__.'/adminapp.php';
Route::prefix('tenants/{tenant:codigo}')->group(function (): void { Route::prefix('tenants/{tenant:codigo}')->scopeBindings()->group(function (): void {
Route::get('events', [PublicEventController::class, 'index']); Route::get('events', [PublicEventController::class, 'index']);
Route::get('events/{event}', [PublicEventController::class, 'show']); Route::get('events/{event:slug}', [PublicEventController::class, 'show']);
}); });
Route::middleware('auth:sanctum')->post( Route::middleware('auth:sanctum')->post(

View File

@@ -6,31 +6,47 @@ use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Exceptions\TicketNotAvailableException; use App\Domains\Ticketing\Ticket\Exceptions\TicketNotAvailableException;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Requests\DownloadTicketsPdfRequest; use App\Domains\Ticketing\Ticket\Requests\DownloadTicketsPdfRequest;
use App\Domains\Ticketing\Ticket\Requests\ListTicketsRequest;
use App\Domains\Ticketing\Ticket\Resources\TicketEventResource;
use App\Domains\Ticketing\Ticket\Resources\TicketResource; use App\Domains\Ticketing\Ticket\Resources\TicketResource;
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\Domains\Ticketing\Ticket\Services\UserTicketQueryService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class TicketController extends Controller class TicketController extends Controller
{ {
public function __construct(private readonly TicketPdfService $ticketPdfService) {} public function __construct(
private readonly TicketPdfService $ticketPdfService,
private readonly UserTicketQueryService $userTickets,
) {}
public function index(Request $request, Tenant $tenant): JsonResponse public function index(ListTicketsRequest $request, Tenant $tenant): JsonResponse
{ {
$tickets = Ticket::query() $eventId = $request->validated('event_id');
->where('tenant_code', $tenant->codigo) $tickets = $this->userTickets->tickets(
->where('user_id', $request->user()->getKey()) $tenant,
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) $request->user(),
->orderByDesc('id') $eventId === null ? null : (int) $eventId,
->get(); );
return TicketResource::collection($tickets)->response(); return TicketResource::collection($tickets)->response();
} }
public function events(Request $request, Tenant $tenant): AnonymousResourceCollection
{
abort_unless($tenant->storefront_website_type_code === 'onticket_multi_event', 404);
return TicketEventResource::collection(
$this->userTickets->events($tenant, $request->user())
);
}
public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response
{ {
$ticketIds = $request->validated('ticket_ids'); $ticketIds = $request->validated('ticket_ids');

View File

@@ -2,16 +2,18 @@
namespace App\Domains\Ticketing\Ticket\Models; namespace App\Domains\Ticketing\Ticket\Models;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
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\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\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity;
use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup; use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup;
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\Concerns\LogsValueChanges;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -24,6 +26,7 @@ use Illuminate\Validation\ValidationException;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
'event_id',
'ticket', 'ticket',
'source_purchase_item_id', 'source_purchase_item_id',
'source_catalog_item_id', 'source_catalog_item_id',
@@ -75,6 +78,7 @@ class Ticket extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'event_id' => 'integer',
'source_catalog_item_id' => 'integer', 'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer', 'source_variant_id' => 'integer',
'source_purchase_item_id' => 'integer', 'source_purchase_item_id' => 'integer',
@@ -136,6 +140,12 @@ class Ticket extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
} }
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function allow_refund(): bool public function allow_refund(): bool
{ {
return $this->tenant?->allow_refund() ?? false; return $this->tenant?->allow_refund() ?? false;
@@ -183,7 +193,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
@@ -226,6 +238,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
{ {
@@ -238,6 +256,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

@@ -47,30 +47,42 @@ class ValidityTime extends Model
public function startsAt( public function startsAt(
?CarbonInterface $at = null, ?CarbonInterface $at = null,
string $timezone = 'UTC',
): ?CarbonInterface { ): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) { if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_starts_at; return $this->fixed_starts_at;
} }
return $this->atCurrentDate($this->start_time, $at); return $this->atCurrentDate($this->start_time, $at, $timezone);
} }
public function expiresAt( public function expiresAt(
?CarbonInterface $at = null, ?CarbonInterface $at = null,
string $timezone = 'UTC',
): ?CarbonInterface { ): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) { if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_expires_at; return $this->fixed_expires_at;
} }
return $this->atCurrentDate($this->end_time, $at); return $this->atCurrentDate($this->end_time, $at, $timezone);
} }
public function isValid( public function isValid(
?CarbonInterface $at = null, ?CarbonInterface $at = null,
string $timezone = 'UTC',
): bool { ): bool {
$at ??= now(); $at ??= now();
$startsAt = $this->startsAt($at); $startsAt = $this->startsAt($at, $timezone);
$expiresAt = $this->expiresAt($at); $expiresAt = $this->expiresAt($at, $timezone);
if ($this->type === ValidityTimeType::TimeWindow
&& $startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) {
if ($at->lessThan($expiresAt)) {
$startsAt = $startsAt->subDay();
} else {
$expiresAt = $expiresAt->addDay();
}
}
return ($startsAt === null || $startsAt->lessThanOrEqualTo($at)) return ($startsAt === null || $startsAt->lessThanOrEqualTo($at))
&& ($expiresAt === null || $expiresAt->greaterThan($at)); && ($expiresAt === null || $expiresAt->greaterThan($at));
@@ -79,6 +91,7 @@ class ValidityTime extends Model
private function atCurrentDate( private function atCurrentDate(
?string $time, ?string $time,
?CarbonInterface $at, ?CarbonInterface $at,
string $timezone,
): ?CarbonInterface { ): ?CarbonInterface {
if ($time === null) { if ($time === null) {
return null; return null;
@@ -86,8 +99,9 @@ class ValidityTime extends Model
$at ??= now(); $at ??= now();
$localDate = CarbonImmutable::instance($at) $localDate = CarbonImmutable::instance($at)
->setTimezone($timezone)
->format('Y-m-d'); ->format('Y-m-d');
return CarbonImmutable::parse($localDate.' '.$time); return CarbonImmutable::parse($localDate.' '.$time, $timezone);
} }
} }

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

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Ticketing\Ticket\Requests;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ListTicketsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
/** @var Tenant $tenant */
$tenant = $this->route('tenant');
return [
'event_id' => [
'sometimes',
'integer',
Rule::exists('events', 'id')->where('tenant_code', $tenant->codigo),
],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Ticketing\Ticket\Resources;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Event */
class TicketEventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'image' => $this->attachment?->getTemporaryUrl(1440),
];
}
}

View File

@@ -15,6 +15,7 @@ class TicketResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'tenant_code' => $this->tenant_code, 'tenant_code' => $this->tenant_code,
'event_id' => $this->event_id,
'ticket' => $this->ticket, 'ticket' => $this->ticket,
'status' => $this->status, 'status' => $this->status,
'status_label' => $this->status_label, 'status_label' => $this->status_label,

View File

@@ -10,6 +10,11 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValidityTime */ /** @mixin ValidityTime */
class ValidityTimeResource extends JsonResource class ValidityTimeResource extends JsonResource
{ {
public function __construct($resource, private readonly string $timezone = 'UTC')
{
parent::__construct($resource);
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
@@ -27,7 +32,7 @@ class ValidityTimeResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'type' => $this->type->value, 'type' => $this->type->value,
'is_valid' => $this->isValid(), 'is_valid' => $this->isValid(timezone: $this->timezone),
...array_filter($fields, fn (mixed $value): bool => $value !== null), ...array_filter($fields, fn (mixed $value): bool => $value !== null),
]; ];
} }

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,12 +2,12 @@
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\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Models\TicketRefund; use App\Domains\Ticketing\Ticket\Models\TicketRefund;
@@ -27,11 +27,13 @@ class AdminAppTicketService
'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,
) {} ) {}
@@ -347,6 +349,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

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Ticketing\Ticket\Services;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class ConvertValidityTimesToUtcService
{
/**
* @param array<int, int> $ids
* @return array<int, array<string, mixed>>
*/
public function run(array $ids, string $timezone, bool $apply = false, bool $all = false, bool $reverse = false): array
{
if (! in_array($timezone, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC), true)) {
throw new InvalidArgumentException('Zona horaria inválida.');
}
if ($all === ($ids !== [])) {
throw new InvalidArgumentException('Indicar --all o --ids, exclusivamente una opción.');
}
return DB::transaction(function () use ($ids, $timezone, $apply, $all, $reverse): array {
$query = DB::table('validity_times')->orderBy('id');
$all ? $query->where('type', 'fixed_window') : $query->whereIn('id', $ids);
if ($apply) {
$query->lockForUpdate();
}
$rows = $query->get();
if (! $all && $rows->count() !== count(array_unique($ids))) {
throw new InvalidArgumentException('Uno o más IDs no existen.');
}
if ($rows->contains(fn ($row): bool => $row->type !== 'fixed_window')) {
throw new InvalidArgumentException('Sólo se admiten IDs de fixed_window.');
}
$source = $reverse ? 'UTC' : $timezone;
$target = $reverse ? $timezone : 'UTC';
$result = [];
foreach ($rows as $row) {
$convert = fn (?string $value): ?string => $value === null
? null
: CarbonImmutable::parse($value, $source)->setTimezone($target)->format('Y-m-d H:i:s');
$start = $convert($row->fixed_starts_at);
$end = $convert($row->fixed_expires_at);
$result[] = [
'id' => $row->id,
'original_start' => $row->fixed_starts_at,
'original_end' => $row->fixed_expires_at,
'result_start' => $start,
'result_end' => $end,
];
if ($apply) {
DB::table('validity_times')->where('id', $row->id)->update([
'fixed_starts_at' => $start,
'fixed_expires_at' => $end,
]);
}
}
return $result;
});
}
}

View File

@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
final readonly class ResolvedValidityGroup final readonly class ResolvedValidityGroup
{ {
/** @param Collection<int, ValidityTime> $validityTimes */ /** @param Collection<int, ValidityTime> $validityTimes */
public function __construct(public Collection $validityTimes) {} public function __construct(public Collection $validityTimes, public string $timezone = 'UTC') {}
/** Comprueba si el instante pertenece a la intersección efectiva del grupo. */ /** Comprueba si el instante pertenece a la intersección efectiva del grupo. */
public function isValid(?CarbonInterface $at = null): bool public function isValid(?CarbonInterface $at = null): bool
@@ -50,7 +50,17 @@ final readonly class ResolvedValidityGroup
$anchor = $this->dateAnchor() ?? $at; $anchor = $this->dateAnchor() ?? $at;
return $this->validityTimes return $this->validityTimes
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor)) ->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$start = $validityTime->startsAt($anchor, $this->timezone);
$end = $validityTime->expiresAt($anchor, $this->timezone);
if ($this->dateAnchor() === null && $start !== null && $end !== null
&& $end->lessThanOrEqualTo($start) && $anchor->lessThan($end)) {
return $start->copy()->subDay();
}
return $start;
})
->filter() ->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first(); ->first();
@@ -67,11 +77,13 @@ final readonly class ResolvedValidityGroup
return $this->validityTimes return $this->validityTimes
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface { ->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$startsAt = $validityTime->startsAt($anchor); $startsAt = $validityTime->startsAt($anchor, $this->timezone);
$expiresAt = $validityTime->expiresAt($anchor); $expiresAt = $validityTime->expiresAt($anchor, $this->timezone);
if ($startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) { if ($startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) {
return $expiresAt->addDay(); return $this->dateAnchor() === null && $anchor->lessThan($expiresAt)
? $expiresAt
: $expiresAt->copy()->addDay();
} }
return $expiresAt; return $expiresAt;

View File

@@ -2,9 +2,9 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -44,6 +44,7 @@ class TicketGeneratorService
$variant = $target['variant']; $variant = $target['variant'];
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'tenant_code' => $item->tenant_code, 'tenant_code' => $item->tenant_code,
'event_id' => $item->event_id,
'ticket' => (string) Str::uuid(), 'ticket' => (string) Str::uuid(),
'source_purchase_item_id' => $sourcePurchaseItemId, 'source_purchase_item_id' => $sourcePurchaseItemId,
'source_catalog_item_id' => $item->getKey(), 'source_catalog_item_id' => $item->getKey(),

View File

@@ -29,6 +29,7 @@ class TicketValidityResolver
/** Relaciones necesarias para resolver tickets sin consultas N+1. */ /** Relaciones necesarias para resolver tickets sin consultas N+1. */
public const RELATIONS = [ public const RELATIONS = [
'sourceVariant.catalogItem.tenant',
'sourceVariant.eventDates.validityTime', 'sourceVariant.eventDates.validityTime',
'sourceVariant.eventDate.validityTime', 'sourceVariant.eventDate.validityTime',
'sourceVariant.definitions.itemAttribute.attribute.options.validityTime', 'sourceVariant.definitions.itemAttribute.attribute.options.validityTime',
@@ -60,6 +61,7 @@ class TicketValidityResolver
public function resolveVariant(Variant $variant): ResolvedTicketValidity public function resolveVariant(Variant $variant): ResolvedTicketValidity
{ {
$variant->loadMissing([ $variant->loadMissing([
'catalogItem.tenant',
'eventDates.validityTime', 'eventDates.validityTime',
'eventDate.validityTime', 'eventDate.validityTime',
'definitions.itemAttribute.attribute.options.validityTime', 'definitions.itemAttribute.attribute.options.validityTime',
@@ -149,7 +151,7 @@ class TicketValidityResolver
} }
return new ResolvedTicketValidity( return new ResolvedTicketValidity(
$groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times)) $groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times, $variant->catalogItem?->tenant?->timezone ?? 'UTC'))
); );
} }
} }

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class UserTicketQueryService
{
/** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, User $user, ?int $eventId = null): Collection
{
return $this->ticketQuery($tenant, $user)
->when($eventId !== null, fn (Builder $query): Builder => $query
->where('event_id', $eventId))
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderByDesc('id')
->get();
}
/** @return Collection<int, Event> */
public function events(Tenant $tenant, User $user): Collection
{
return Event::query()
->where('tenant_code', $tenant->codigo)
->whereHas('tickets', fn (Builder $query): Builder => $query
->where('tenant_code', $tenant->codigo)
->where('user_id', $user->getKey()))
->with('attachment')
->orderByDesc('id')
->get();
}
/** @return Builder<Ticket> */
private function ticketQuery(Tenant $tenant, User $user): Builder
{
return Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $user->getKey());
}
}

View File

@@ -17,6 +17,29 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ
se combinan con OR y las dimensiones diferentes se combinan con AND. El modelo calcula si un ticket está se combinan con OR y las dimensiones diferentes se combinan con AND. El modelo calcula si un ticket está
vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin persistir vigencias en el ticket. vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin persistir vigencias en el ticket.
## Zona horaria y vigencias
La zona se configura por tenant mediante `timezone` (identificador IANA, por ejemplo
`America/Argentina/Buenos_Aires`), configurado mediante seeders o base de datos y expuesto en la respuesta de tenants.
Ese es el valor por defecto. La aplicación mantiene su zona global en UTC.
Las fechas y horas de `EventDate` y los horarios de `time_window` son locales al tenant.
Las ventanas `fixed_window` se guardan en UTC. El resolver pasa la zona del tenant del producto
al grupo de vigencia: antes de combinar fecha y hora, convierte el ancla a esa zona.
Sin fecha de evento, utiliza el día local del instante consultado; contempla ventanas nocturnas.
El inicio es inclusivo y el vencimiento exclusivo.
Para validar una ventana horaria directamente, pasar la zona explícitamente:
`$validityTime->isValid(now(), $tenant->timezone)`. Un `ValidityTime` aislado no tiene tenant;
los métodos mantienen UTC como valor por defecto para usos sin contexto.
La migración `2026_09_21_040000_add_timezone_to_tenants.php` asigna la zona inicial y reconstruye
una sola vez las ventanas asociadas a fechas existentes, usando las fechas y horas locales originales.
No modifica ventanas independientes. Los cambios posteriores de zona son responsabilidad del tenant:
reinterpretan los horarios locales, pero no reescriben los instantes UTC guardados. Editar la fecha
u horas de un evento sí vuelve a calcular su ventana. El rollback elimina la columna, sin deshacer
la corrección de los instantes. Ya no se utiliza `EVENT_TIMEZONE`.
## Flujo de generación ## Flujo de generación
1. `Purchase` emite `PurchasePaid` al confirmarse el pago. 1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
@@ -69,3 +92,48 @@ Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el
## Dependencias y reglas ## Dependencias y reglas
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación. Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.
## Conversión de datos históricos a UTC (homo / producción)
El comando no requiere migraciones adicionales ni crea una tabla de registro.
Para convertir todos los registros de tipo `fixed_window` desde hora argentina:
```bash
# Vista previa: no escribe en la base
php artisan tickets:convert-validity-times-to-utc --all
# Aplicar a todos los fixed_window
php artisan tickets:convert-validity-times-to-utc --all --apply
```
También permite seleccionar IDs específicos (los siguientes son ejemplos):
```bash
php artisan tickets:convert-validity-times-to-utc --ids=41,42
php artisan tickets:convert-validity-times-to-utc --ids=41,42 --apply
```
`--all` y `--ids` son excluyentes. El origen por defecto es
`America/Argentina/Buenos_Aires`; se puede indicar otra zona IANA con `--source-timezone`.
Cada extremo conserva su fecha original y se convierte por separado, incluso si la ventana abarca
varios días. Los límites nulos y los `time_window` no se modifican. La aplicación es transaccional.
No hay registro ni detección automática de conversiones anteriores: repetir `--apply` vuelve a
convertir los valores actuales. La migración original `2026_09_21_040000_add_timezone_to_tenants.php`
ya convierte las ventanas asociadas a eventos; no ejecutar este comando sobre esas ventanas si
ya fueron convertidas. `--all` incluye absolutamente todos los `fixed_window`, sin esa distinción.
### Revertir una conversión
`--reverse` convierte los valores actuales desde UTC hacia la zona local, con el mismo alcance
`--all` o `--ids`. Sin `--apply` sólo muestra la vista previa.
```bash
php artisan tickets:convert-validity-times-to-utc --all --reverse
php artisan tickets:convert-validity-times-to-utc --all --reverse --apply
php artisan tickets:convert-validity-times-to-utc --ids=41,42 --reverse --apply
```
Con `--reverse`, `--source-timezone` indica la zona local de destino (por defecto
`America/Argentina/Buenos_Aires`). Las fechas de cada extremo y los límites nulos se respetan;
los `time_window` no se modifican. Revierte una conversión si se seleccionan los mismos registros
sin ediciones intermedias. No recupera una copia histórica ni detecta conversiones previas.

View File

@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}') Route::prefix('tenants/{tenant:codigo}')
->middleware('auth:sanctum') ->middleware('auth:sanctum')
->group(function (): void { ->group(function (): void {
Route::get('tickets/events', [TicketController::class, 'events']);
Route::get('tickets', [TicketController::class, 'index']); Route::get('tickets', [TicketController::class, 'index']);
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']); Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
}); });

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

@@ -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,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;
@@ -14,6 +15,11 @@ Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('event', EventFormController::class); Route::get('event', EventFormController::class);
Route::get(
'desfile/entry-reservation',
DesfileEntryReservationFormController::class
)->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)

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, '/');

View File

@@ -67,9 +67,6 @@ return [
'timezone' => 'UTC', 'timezone' => 'UTC',
// Las fechas de los eventos se ingresan como horarios locales de Argentina.
'event_timezone' => env('EVENT_TIMEZONE', 'America/Argentina/Buenos_Aires'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Locale Configuration | Application Locale Configuration

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
<?php
use Carbon\CarbonImmutable;
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('tenants', function (Blueprint $table): void {
$table->string('timezone')->default('America/Argentina/Buenos_Aires');
});
// Correct legacy event windows once, using their original local date and hours.
// Subsequent tenant timezone changes do not rewrite stored UTC instants.
DB::table('event_dates')->orderBy('id')->chunkById(200, function ($dates): void {
foreach ($dates as $date) {
$timezone = DB::table('tenants')->where('codigo', $date->tenant_code)->value('timezone');
$start = CarbonImmutable::parse(substr($date->date, 0, 10).' '.$date->time_start, $timezone);
$end = CarbonImmutable::parse(substr($date->date, 0, 10).' '.$date->time_end, $timezone);
if ($end->lessThanOrEqualTo($start)) {
$end = $end->addDay();
}
DB::table('validity_times')->where('id', $date->validity_time_id)->update([
'fixed_starts_at' => $start->utc()->format('Y-m-d H:i:s'),
'fixed_expires_at' => $end->utc()->format('Y-m-d H:i:s'),
]);
}
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropColumn('timezone');
});
}
};

View File

@@ -0,0 +1,64 @@
<?php
use Database\Seeders\FiestaTradicionArrufoSeeder;
use Database\Seeders\OnTicketTenantSeeder;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
return new class extends Migration
{
private const TENANT_CODE = 'onticket';
private const EXCLUDED_MENU_CODES = [
'product.detail',
'help',
'help.faq',
'help.contact',
'help.payment-methods',
'help.shipping',
'help.terms-and-conditions',
'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',
];
public function up(): void
{
if (app()->environment('testing')) {
Storage::fake('s3');
}
app(WebsiteTypeSeeder::class)->run();
app(OnTicketTenantSeeder::class)->run();
app(FiestaTradicionArrufoSeeder::class)->run();
$now = now();
DB::table('menues')
->whereNotIn('code', self::EXCLUDED_MENU_CODES)
->pluck('code')
->each(function (string $menuCode) use ($now): void {
DB::table('tenants_menues')->updateOrInsert(
[
'tenant_code' => self::TENANT_CODE,
'menu_code' => $menuCode,
],
[
'static_content' => null,
'created_at' => $now,
'updated_at' => $now,
],
);
});
}
public function down(): void
{
// OnTicket is reference data. Never remove a possibly active tenant on rollback.
}
};

View File

@@ -0,0 +1,44 @@
<?php
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use Database\Seeders\FiestaTradicionArrufoSeeder;
use Database\Seeders\OnTicketTenantSeeder;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Storage;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing')) {
Storage::fake('s3');
}
$type = AdminWebsiteType::query()
->with(['siteLogo', 'footerLogo', 'favicon'])
->where('codigo', 'onticket')
->first();
$typeAssets = $type === null
? []
: [$type->siteLogo, $type->footerLogo, $type->favicon];
if (
$type === null
|| collect($typeAssets)->contains(
fn ($attachment): bool => $attachment === null
|| ! Storage::disk('s3')->exists($attachment->path)
)
) {
app(WebsiteTypeSeeder::class)->run();
}
app(OnTicketTenantSeeder::class)->run();
app(FiestaTradicionArrufoSeeder::class)->run();
}
public function down(): void
{
// Reference and operational data are intentionally preserved.
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Database\Seeders\OptimizeOnticketImagesSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Storage;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing')) {
Storage::fake('s3');
}
app(OptimizeOnticketImagesSeeder::class)->run();
}
public function down(): void
{
// The optimized attachments may already be in active use. Keep this
// data migration irreversible instead of restoring heavier assets.
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table): void {
$table->string('slug')->nullable();
});
$usedSlugsByTenant = [];
foreach (DB::table('events')->orderBy('tenant_code')->orderBy('id')->get() as $event) {
$baseSlug = Str::slug((string) $event->title);
$baseSlug = $baseSlug !== '' ? substr($baseSlug, 0, 240) : 'evento-'.$event->id;
$slug = $baseSlug;
$suffix = 2;
while (isset($usedSlugsByTenant[$event->tenant_code][$slug])) {
$slug = $baseSlug.'-'.$suffix;
$suffix++;
}
DB::table('events')->where('id', $event->id)->update(['slug' => $slug]);
$usedSlugsByTenant[$event->tenant_code][$slug] = true;
}
Schema::table('events', function (Blueprint $table): void {
$table->unique(['tenant_code', 'slug']);
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table): void {
$table->dropUnique(['tenant_code', 'slug']);
$table->dropColumn('slug');
});
}
};

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tickets', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_code')
->constrained('events')
->cascadeOnUpdate()
->restrictOnDelete();
$table->index(['tenant_code', 'user_id', 'event_id']);
});
DB::table('tickets')
->join('catalog_items', 'catalog_items.id', '=', 'tickets.source_catalog_item_id')
->whereNotNull('catalog_items.event_id')
->select(['tickets.id', 'catalog_items.event_id as source_event_id'])
->orderBy('tickets.id')
->chunk(500, function ($tickets): void {
foreach ($tickets as $ticket) {
DB::table('tickets')
->where('id', $ticket->id)
->update(['event_id' => $ticket->source_event_id]);
}
});
}
public function down(): void
{
if (DB::getDriverName() === 'sqlite') {
Schema::table('tickets', function (Blueprint $table): void {
$table->dropIndex(['tenant_code', 'user_id', 'event_id']);
$table->dropColumn('event_id');
});
return;
}
Schema::table('tickets', function (Blueprint $table): void {
$table->dropIndex(['tenant_code', 'user_id', 'event_id']);
$table->dropConstrainedForeignId('event_id');
});
}
};

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,311 @@
<?php
declare(strict_types=1);
const CATALOG_IMAGE_MAX_DIMENSION = 960;
const CATALOG_IMAGE_WEBP_QUALITY = 76;
$sourceRoot = 'C:\\Users\\ncoronel\\Documents\\catalogo_smep_limpio';
$projectRoot = dirname(__DIR__, 2);
$assetRoot = $projectRoot.'/public/images/tennants/mutual_smep/catalog';
$manifestPath = $projectRoot.'/database/data/mutual_smep_catalog_v1.json';
$defaultStock = 10;
if (! is_dir($sourceRoot)) {
throw new RuntimeException("Source catalog not found: {$sourceRoot}");
}
/** @return array{0: string, 1: string} */
function catalogDestination(string $source, string $name): array
{
return match ($source) {
'acolchados-frazadas-y-edredon' => preg_match('/^(Frazada|Manta)/iu', $name)
? ['Dormitorio y Blanco', 'Frazadas y Mantas']
: ['Dormitorio y Blanco', 'Acolchados y Edredones'],
'acolchados-y-sabanas-infantil' => ['Dormitorio y Blanco', 'Infantil y Cuna'],
'base-sommier-1-pl-0-80x1-90', 'base-sommier-2-pl-1-40x1-90' => ['Dormitorio y Blanco', 'Bases y Sommiers'],
'colchon-1-plaza-0-80x1-90', 'colchon-2-plazas-1-40x1-90' => str_starts_with(mb_strtolower($name), 'soporte')
? ['Tecnología', 'Soportes para TV']
: ['Dormitorio y Blanco', 'Colchones'],
'aires-acondicionados' => ['Climatización', 'Aires Acondicionados'],
'calefaccion-con-salida-al-ext' => ['Climatización', 'Calefactores a Gas con Salida'],
'calefactor-sin-salida-al-ext' => ['Climatización', 'Calefactores a Gas sin Salida'],
'calefaccion-electrica' => ['Climatización', 'Calefacción Eléctrica'],
'calefones-y-termotanques' => ['Electrodomésticos', 'Calefones y Termotanques'],
'heladeras' => ['Electrodomésticos', 'Heladeras'],
'lavarropas-automaticos' => ['Electrodomésticos', 'Lavarropas y Secarropas'],
'cocinas' => ['Electrodomésticos', 'Cocinas'],
'purificador-de-aire' => ['Electrodomésticos', 'Purificadores y Extractores de Cocina'],
'microondas-y-hornos-electricos' => str_starts_with(mb_strtolower($name), 'horno')
? ['Electrodomésticos', 'Hornos Eléctricos']
: ['Electrodomésticos', 'Microondas'],
'electrodomesticos-pequenos' => smallApplianceCategory($name),
'celulares' => ['Tecnología', 'Celulares'],
'notebooks-y-tablets' => ['Tecnología', 'Notebooks'],
'smart-tv' => str_starts_with(mb_strtolower($name), 'soporte')
? ['Tecnología', 'Soportes para TV']
: ['Tecnología', 'Smart TV'],
'tecnologia' => preg_match('/^(Mouse|Teclado)/iu', $name)
? ['Tecnología', 'Accesorios de Computación']
: ['Tecnología', 'Audio'],
'bicicletas' => preg_match('/\bR16\b/iu', $name)
? ['Bicicletas y Aire Libre', 'Bicicletas Infantiles']
: ['Bicicletas y Aire Libre', 'Bicicletas para Adultos'],
'piletas' => ['Bicicletas y Aire Libre', 'Piletas'],
'bazar' => ['Bazar', 'Termos'],
default => throw new RuntimeException("Unmapped source category: {$source}"),
};
}
/** @return array{0: string, 1: string} */
function smallApplianceCategory(string $name): array
{
return match (true) {
preg_match('/^(Cafetera|Pava eléctrica|Tostadora)/iu', $name) === 1 => ['Electrodomésticos', 'Café y Desayuno'],
preg_match('/^(Freidora|Waflera|Pochoclera)/iu', $name) === 1 => ['Electrodomésticos', 'Freidoras y Cocción'],
preg_match('/^(Secador|Cortacabello|Cortabarba)/iu', $name) === 1 => ['Electrodomésticos', 'Cuidado Personal'],
preg_match('/^(Lustraspiradora|Plancha)/iu', $name) === 1 => ['Electrodomésticos', 'Limpieza y Planchado'],
default => ['Electrodomésticos', 'Preparación de Alimentos'],
};
}
function productBrand(string $name): ?string
{
if (preg_match('/\bJC\b/u', $name)) {
return 'Jean Cartier';
}
if (mb_stripos($name, 'Liliana') !== false) {
return 'Liliana';
}
foreach ([
'Rosario Central', 'Jean Cartier', 'King Koil', 'Fire Bird', 'Xtrike Me',
'Whitenblack', 'Electrolux', 'Kavanagh', 'Pelopincho', 'Suavegom',
'Motorola', 'Samsung', 'Xiaomi', 'Moulinex', 'Peabody', 'Philips',
'Florencia', 'Kohinoor', 'Inducol', 'Longvie', 'Stanley', 'Noblex',
'Moonki', 'Lenovo', 'Piero', 'Eskabe', 'Oster', 'Drean', 'Gafa', 'Atma',
'Spar', 'Stark', 'Venzo', 'Havit', 'Acer', 'ASUS', 'MSI', 'Philco',
'Gama', 'GIGO', 'Ross', 'Nakan', 'TCL', 'BGH', 'JBL', 'TST', 'GBS', 'BLU',
] as $brand) {
if (mb_stripos($name, $brand) !== false) {
return $brand;
}
}
return null;
}
/** @return array{key: string, name: string, values: array<string, string>}|null */
function variantGroup(string $name): ?array
{
$measure = bedMeasureValue($name);
$groups = [
'/(?:Kavanagh.*[Ss][ií]mil plumón reversible|[Ss][ií]mil plumón reversible.*Kavanagh)/iu' => ['acolchado-kavanagh-simil-plumon-reversible', 'Acolchado Kavanagh Símil Plumón Reversible'],
'/Edredón.*Lisboa/iu' => ['edredon-lisboa-jean-cartier', 'Edredón Lisboa Jean Cartier'],
'/Sense Dúo Bitono con corderito/iu' => ['acolchado-sense-duo-bitono-corderito', 'Acolchado Sense Dúo Bitono con Corderito'],
'/Frazada polar.*Kavanagh Premium Soft/iu' => ['frazada-kavanagh-premium-soft', 'Frazada Kavanagh Premium Soft'],
'/Kit Edredón.*Alaska.*corderito/iu' => ['kit-edredon-alaska-corderito', 'Kit Edredón Alaska con Corderito'],
'/Colch[oó]n Inducol Constanza/iu' => ['colchon-inducol-constanza', 'Colchón Inducol Constanza'],
];
foreach ($groups as $pattern => [$key, $itemName]) {
if ($measure !== null && preg_match($pattern, $name)) {
return [
'key' => $key,
'name' => $itemName,
'values' => ['medida_cama' => $measure],
];
}
}
if (preg_match('/Motorola G15\s+4\/(256|512)\s*GB/iu', $name, $match)) {
return [
'key' => 'motorola-g15',
'name' => 'Motorola G15',
'values' => [
'memoria_ram' => '4 GB',
'almacenamiento' => "{$match[1]} GB",
],
];
}
return null;
}
function bedMeasureValue(string $name): ?string
{
return match (true) {
preg_match('/\bcuna\b/iu', $name) === 1 => 'Cuna',
preg_match('/\bKing\b/iu', $name) === 1 => 'King',
preg_match('/\bQueen\b/iu', $name) === 1 => 'Queen',
preg_match('/2\s*1\/2\s*pl/iu', $name) === 1 => '2 1/2 Plazas',
preg_match('/1\s*1\/2\s*pl/iu', $name) === 1 => '1 1/2 Plazas',
preg_match('/1[,.]40\s*x?\s*1[,.]90|140x190|140x24/iu', $name) === 1 => '2 Plazas',
preg_match('/0[,.](80|90)\s*x?\s*1[,.]90|080x|090x|0,8x/iu', $name) === 1 => '1 Plaza',
default => null,
};
}
function compressCatalogImage(string $source, string $destination): void
{
$sourceImage = @imagecreatefromjpeg($source);
if ($sourceImage === false) {
throw new RuntimeException("Unable to read JPEG image: {$source}");
}
$width = imagesx($sourceImage);
$height = imagesy($sourceImage);
$scale = min(1, CATALOG_IMAGE_MAX_DIMENSION / max($width, $height));
$targetWidth = max(1, (int) round($width * $scale));
$targetHeight = max(1, (int) round($height * $scale));
$targetImage = imagecreatetruecolor($targetWidth, $targetHeight);
if ($targetImage === false) {
throw new RuntimeException("Unable to allocate image: {$source}");
}
imagefill($targetImage, 0, 0, imagecolorallocate($targetImage, 255, 255, 255));
imagecopyresampled(
$targetImage,
$sourceImage,
0,
0,
0,
0,
$targetWidth,
$targetHeight,
$width,
$height,
);
$destinationDirectory = dirname($destination);
if (! is_dir($destinationDirectory) && ! mkdir($destinationDirectory, 0777, true) && ! is_dir($destinationDirectory)) {
throw new RuntimeException("Unable to create directory: {$destinationDirectory}");
}
if (! imagewebp($targetImage, $destination, CATALOG_IMAGE_WEBP_QUALITY)) {
throw new RuntimeException("Unable to write WebP image: {$destination}");
}
}
$rawProducts = [];
$jsonFiles = glob($sourceRoot.'\\*\\*.json') ?: [];
sort($jsonFiles);
foreach ($jsonFiles as $jsonFile) {
$data = json_decode((string) file_get_contents($jsonFile), true, flags: JSON_THROW_ON_ERROR);
$sourceCategory = (string) $data['categoria']['slug'];
foreach ($data['productos'] as $product) {
[$parentCategory, $category] = catalogDestination($sourceCategory, $product['nombre']);
$images = [];
foreach ($product['imagenes'] as $relativeImage) {
$sourceImage = dirname($jsonFile).'/'.str_replace('/', DIRECTORY_SEPARATOR, $relativeImage);
$basename = pathinfo($relativeImage, PATHINFO_FILENAME).'.webp';
$publicRelativePath = "images/tennants/mutual_smep/catalog/{$sourceCategory}/{$basename}";
$destination = $projectRoot.'/public/'.$publicRelativePath;
compressCatalogImage($sourceImage, $destination);
$images[] = $publicRelativePath;
}
$rawProducts[] = [
'source_slug' => $product['slug'],
'name' => $product['nombre'],
'description' => $product['descripcion'] ?? null,
'price' => (float) $product['precio']['importe'],
'brand' => productBrand($product['nombre']),
'parent_category' => $parentCategory,
'category' => $category,
'images' => $images,
'variant_group' => variantGroup($product['nombre']),
];
}
}
$items = [];
foreach ($rawProducts as $product) {
$group = $product['variant_group'];
if ($group === null) {
$items[$product['source_slug']] = [
'slug' => $product['source_slug'],
'name' => $product['name'],
'description' => $product['description'],
'price' => $product['price'],
'stock' => $defaultStock,
'brand' => $product['brand'],
'parent_category' => $product['parent_category'],
'category' => $product['category'],
'images' => $product['images'],
'attribute_codes' => [],
'variants' => [],
];
continue;
}
$key = $group['key'];
if (! isset($items[$key])) {
$items[$key] = [
'slug' => $key,
'name' => $group['name'],
'description' => $product['description'],
'price' => $product['price'],
'stock' => null,
'brand' => $product['brand'],
'parent_category' => $product['parent_category'],
'category' => $product['category'],
'images' => [],
'attribute_codes' => [],
'variants' => [],
];
}
$items[$key]['price'] = min($items[$key]['price'], $product['price']);
$items[$key]['description'] ??= $product['description'];
$items[$key]['brand'] ??= $product['brand'];
$items[$key]['attribute_codes'] = array_values(array_unique([
...$items[$key]['attribute_codes'],
...array_keys($group['values']),
]));
$items[$key]['variants'][] = [
'source_slug' => $product['source_slug'],
'description' => $product['description'],
'price' => $product['price'],
'stock' => $defaultStock,
'values' => $group['values'],
'images' => $product['images'],
];
}
ksort($items);
$manifest = [
'version' => 1,
'tenant_code' => 'mutual_smep',
'default_stock' => $defaultStock,
'source_product_count' => count($rawProducts),
'catalog_item_count' => count($items),
'image_count' => array_sum(array_map(fn (array $product): int => count($product['images']), $rawProducts)),
'items' => array_values($items),
];
$manifestDirectory = dirname($manifestPath);
if (! is_dir($manifestDirectory) && ! mkdir($manifestDirectory, 0777, true) && ! is_dir($manifestDirectory)) {
throw new RuntimeException("Unable to create directory: {$manifestDirectory}");
}
file_put_contents(
$manifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL,
);
echo json_encode([
'manifest' => $manifestPath,
'source_products' => count($rawProducts),
'catalog_items' => count($items),
'images' => $manifest['image_count'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;

View File

@@ -3,10 +3,10 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Shared\Enums\FieldType;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType; use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticketing\Ticket\Models\ValidityTime; use App\Domains\Ticketing\Ticket\Models\ValidityTime;
use App\Shared\Enums\FieldType;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class AttributeSeeder extends Seeder class AttributeSeeder extends Seeder
@@ -25,6 +25,10 @@ class AttributeSeeder extends Seeder
continue; continue;
} }
if ($tenant->codigo === 'mutual_smep') {
continue;
}
$this->seedAttribute($tenant, [ $this->seedAttribute($tenant, [
'codigo' => 'color', 'codigo' => 'color',
'nombre' => 'Color', 'nombre' => 'Color',

View File

@@ -29,7 +29,9 @@ class DatabaseSeeder extends Seeder
TenantSeeder::class, TenantSeeder::class,
DesfilePuraTendenciaSeeder::class, DesfilePuraTendenciaSeeder::class,
AttributeSeeder::class, AttributeSeeder::class,
MutualSmepAttributeSeeder::class,
CategorySeeder::class, CategorySeeder::class,
MutualSmepCategorySeeder::class,
BrandSeeder::class, BrandSeeder::class,
ProductCatalogFromImagesSeeder::class, ProductCatalogFromImagesSeeder::class,
FiestaFutbolInfantilProductSeeder::class, FiestaFutbolInfantilProductSeeder::class,

View File

@@ -13,7 +13,7 @@ use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
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\Tenant;
use App\Domains\Core\Tenant\Services\TenantService; use App\Domains\Core\Tenant\Services\TenantProvisioningService;
use App\Domains\Ticketing\Desfile\Services\InvitationPurchaseProvisioner; use App\Domains\Ticketing\Desfile\Services\InvitationPurchaseProvisioner;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -27,7 +27,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
private const TENANT_CODE = 'desfile_pura_tendencia'; private const TENANT_CODE = 'desfile_pura_tendencia';
public function __construct( public function __construct(
private readonly TenantService $tenantService, private readonly TenantProvisioningService $tenantService,
private readonly CatalogService $catalogService, private readonly CatalogService $catalogService,
private readonly InvitationPurchaseProvisioner $invitationPurchaseProvisioner, private readonly InvitationPurchaseProvisioner $invitationPurchaseProvisioner,
) {} ) {}
@@ -115,6 +115,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
]); ]);
$event = $tenant->events()->create([ $event = $tenant->events()->create([
'slug' => 'desfile-pura-tendencia',
'title' => 'Desfile Pura Tendencia', 'title' => 'Desfile Pura Tendencia',
'location' => 'Salón Centro Recreativo Luz y Fuerza', 'location' => 'Salón Centro Recreativo Luz y Fuerza',
'date_text' => '16 de Octubre 2026', 'date_text' => '16 de Octubre 2026',

View File

@@ -3,8 +3,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
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\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
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;
@@ -13,13 +13,14 @@ use App\Shared\Enums\FieldType;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use RuntimeException; use RuntimeException;
class FiestaTradicionArrufoSeeder extends Seeder class FiestaTradicionArrufoSeeder extends Seeder
{ {
private const TITLE = '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas'; private const TITLE = '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas';
private const IMAGE = __DIR__.'/assets/fiesta-tradicion-arrufo-2026.png'; private const IMAGE = 'images/tennants/onticket/events/fiesta-tradicion-arrufo-2026.avif';
public function __construct( public function __construct(
private readonly CatalogService $catalog, private readonly CatalogService $catalog,
@@ -51,7 +52,8 @@ class FiestaTradicionArrufoSeeder extends Seeder
'location' => 'Predio de Doma del Club Unión Deportiva Arrufó', 'location' => 'Predio de Doma del Club Unión Deportiva Arrufó',
]); ]);
if ($event->attachment_id === null) { $eventImage = $event->attachment;
if ($eventImage === null || ! Storage::disk('s3')->exists($eventImage->path)) {
$event->update(['attachment_id' => $this->attachments->store($this->image(), 'events')->id]); $event->update(['attachment_id' => $this->attachments->store($this->image(), 'events')->id]);
} }
@@ -85,6 +87,7 @@ class FiestaTradicionArrufoSeeder extends Seeder
$existing = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $data['slug'])->first(); $existing = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $data['slug'])->first();
if ($existing !== null) { if ($existing !== null) {
$existing->update(['sales_end_at' => $data['sales_end_at'] ?? null]); $existing->update(['sales_end_at' => $data['sales_end_at'] ?? null]);
return; return;
} }
@@ -104,6 +107,12 @@ class FiestaTradicionArrufoSeeder extends Seeder
private function image(): UploadedFile private function image(): UploadedFile
{ {
return new UploadedFile(self::IMAGE, basename(self::IMAGE), 'image/png', null, true); $path = public_path(self::IMAGE);
if (! is_file($path)) {
throw new RuntimeException("Image not found at path: {$path}");
}
return new UploadedFile($path, basename($path), 'image/avif', null, true);
} }
} }

View File

@@ -26,7 +26,7 @@ class MenuSeeder extends Seeder
[ [
'code' => 'event.detail', 'code' => 'event.detail',
'label' => 'Detalle de evento', 'label' => 'Detalle de evento',
'route' => '/evento/:id', 'route' => '/evento/:slug',
], ],
[ [
'code' => 'product.category', 'code' => 'product.category',
@@ -130,6 +130,12 @@ class MenuSeeder extends Seeder
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/desfile/entradas', 'route' => '/admin/desfile/entradas',
], ],
[
'code' => 'adminapp.desfile.reservas',
'label' => 'Reserva de Tickets',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/desfile/reservas',
],
[ [
'code' => 'account', 'code' => 'account',
'label' => 'Mi cuenta', 'label' => 'Mi cuenta',
@@ -290,9 +296,12 @@ class MenuSeeder extends Seeder
$helpTenantCodes = [ $helpTenantCodes = [
'sonder', 'sonder',
'fiesta_futbol_infantil', 'fiesta_futbol_infantil',
'mutual_smep',
];
$ticketAdminMenuCodes = [
'adminapp.tickets',
]; ];
$fiestaCategoryMenuCodes = [ $fiestaCategoryMenuCodes = [
'adminapp.tickets',
'adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.entradas',
'adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos',
'adminapp.fiesta-futbol-infantil.merchandising', 'adminapp.fiesta-futbol-infantil.merchandising',
@@ -306,6 +315,7 @@ class MenuSeeder extends Seeder
]; ];
$desfileMenuCodes = [ $desfileMenuCodes = [
'adminapp.desfile.entradas', 'adminapp.desfile.entradas',
'adminapp.desfile.reservas',
]; ];
$onTicketMenuCodes = [ $onTicketMenuCodes = [
'event.index', 'event.index',
@@ -356,6 +366,42 @@ class MenuSeeder extends Seeder
], ],
], ],
]; ];
$mutualSmepFrequentlyAskedQuestions = [
[
'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,
],
];
$mutualSmepContactContent = [
'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],
],
],
],
],
];
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
$menuCodes = $allMenus; $menuCodes = $allMenus;
@@ -372,6 +418,10 @@ class MenuSeeder extends Seeder
$menuCodes = array_diff($menuCodes, $helpMenuCodes); $menuCodes = array_diff($menuCodes, $helpMenuCodes);
} }
if (! in_array($tenant->codigo, ['fiesta_futbol_infantil', 'desfile_pura_tendencia'], true)) {
$menuCodes = array_diff($menuCodes, $ticketAdminMenuCodes);
}
if ($tenant->codigo !== 'fiesta_futbol_infantil') { if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes); $menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes);
} else { } else {
@@ -393,10 +443,14 @@ class MenuSeeder extends Seeder
if (in_array($tenant->codigo, $helpTenantCodes, true)) { if (in_array($tenant->codigo, $helpTenantCodes, true)) {
$tenant->menues()->updateExistingPivot('help.faq', [ $tenant->menues()->updateExistingPivot('help.faq', [
'static_content' => $frequentlyAskedQuestions, 'static_content' => $tenant->codigo === 'mutual_smep'
? $mutualSmepFrequentlyAskedQuestions
: $frequentlyAskedQuestions,
]); ]);
$tenant->menues()->updateExistingPivot('help.contact', [ $tenant->menues()->updateExistingPivot('help.contact', [
'static_content' => $contactContent, 'static_content' => $tenant->codigo === 'mutual_smep'
? $mutualSmepContactContent
: $contactContent,
]); ]);
} }
} }

View File

@@ -0,0 +1,122 @@
<?php
namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Enums\FieldType;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class MutualSmepAttributeSeeder extends Seeder
{
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 run(): void
{
if (! Tenant::query()->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
DB::transaction(function (): void {
foreach (self::ATTRIBUTES as $definition) {
$options = $definition['options'];
unset($definition['options']);
$attribute = Attribute::query()->updateOrCreate(
[
'tenant_codigo' => self::TENANT_CODE,
'codigo' => $definition['codigo'],
],
[
...$definition,
'type' => FieldType::Select->value,
'is_required' => false,
'metadata_schema' => $definition['metadata_schema'] ?? null,
],
);
$attribute->options()->delete();
$attribute->options()->createMany(
collect($options)
->values()
->map(fn (array $option, int $index): array => [
...$option,
'sort_order' => $index + 1,
])
->all()
);
}
});
}
public function down(): void
{
Attribute::query()
->where('tenant_codigo', self::TENANT_CODE)
->whereIn('codigo', array_column(self::ATTRIBUTES, 'codigo'))
->delete();
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class MutualSmepCategorySeeder extends Seeder
{
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 run(): void
{
if (! Tenant::query()->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
DB::transaction(function (): void {
foreach (self::CATEGORIES as $parentName => $subcategoryNames) {
$parent = Category::query()->updateOrCreate(
[
'tenant_code' => self::TENANT_CODE,
'nombre' => $parentName,
],
[
'categoria_id' => null,
'is_enabled' => true,
],
);
foreach ($subcategoryNames as $subcategoryName) {
Category::query()->updateOrCreate(
[
'tenant_code' => self::TENANT_CODE,
'nombre' => $subcategoryName,
],
[
'categoria_id' => $parent->id,
'is_enabled' => true,
],
);
}
}
});
}
public function down(): void
{
DB::transaction(function (): void {
foreach (self::CATEGORIES as $parentName => $subcategoryNames) {
$parent = Category::query()
->where('tenant_code', self::TENANT_CODE)
->where('nombre', $parentName)
->whereNull('categoria_id')
->first();
if (! $parent instanceof Category) {
continue;
}
Category::query()
->where('tenant_code', self::TENANT_CODE)
->where('categoria_id', $parent->id)
->whereIn('nombre', $subcategoryNames)
->whereDoesntHave('subCategories')
->delete();
if (! $parent->subCategories()->exists()) {
$parent->delete();
}
}
});
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Database\Seeders;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Services\TenantProvisioningService;
use App\Domains\Core\Tenant\Services\WebsiteExtraService;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class OnTicketTenantSeeder extends Seeder
{
private const IMAGE_DIRECTORY = 'images/tennants/onticket_otpimizado';
public function __construct(
private readonly TenantProvisioningService $tenantProvisioningService,
private readonly WebsiteExtraService $websiteExtraService,
private readonly OnticketImmersiveHeroCarouselSeeder $carouselSeeder,
) {}
public function run(): void
{
$client = Client::query()->firstOrCreate(
['code' => 'onticket'],
['name' => 'OnTicket'],
);
$type = AdminWebsiteType::query()->where('codigo', 'onticket')->firstOrFail();
$tenant = Tenant::query()->where('codigo', 'onticket')->first();
if ($tenant === null) {
$tenant = $this->tenantProvisioningService->create([
'client_id' => $client->id,
'codigo' => 'onticket',
'nombre' => $type->nombre,
'dominio' => $type->dominio,
'site_title' => $type->site_title,
'primary_color' => $type->primary_color,
'secondary_color' => $type->secondary_color,
'danger_color' => $type->danger_color,
'success_color' => $type->success_color,
'header_bg_color' => $type->surface_color,
'footer_bg_color' => $type->primary_color,
'display_categories' => true,
'display_seach_bar' => true,
'admin_website_type_code' => $type->codigo,
'storefront_website_type_code' => 'onticket_multi_event',
'header_logo' => $this->uploadedImage('onticket_logo.png'),
'footer_logo' => $this->uploadedImage('onticket_footer_logo.png'),
'favicon' => $this->uploadedImage('onticket_favicon.svg'),
'header_bg_image' => $this->uploadedImage('onticket_header_background.avif'),
]);
} else {
$tenant->update([
'client_id' => $client->id,
'storefront_website_type_code' => 'onticket_multi_event',
'display_categories' => true,
'display_seach_bar' => true,
]);
$missingImages = [];
foreach ([
'header_logo' => ['relation' => 'headerLogo', 'filename' => 'onticket_logo.png'],
'footer_logo' => ['relation' => 'footerLogo', 'filename' => 'onticket_footer_logo.png'],
'favicon' => ['relation' => 'favicon', 'filename' => 'onticket_favicon.svg'],
'header_bg_image' => ['relation' => 'headerBackgroundImage', 'filename' => 'onticket_header_background.avif'],
] as $field => $image) {
$attachment = $tenant->{$image['relation']};
if ($attachment === null || ! Storage::disk('s3')->exists($attachment->path)) {
$missingImages[$field] = $this->uploadedImage($image['filename']);
}
}
if ($missingImages !== []) {
$tenant = $this->tenantProvisioningService->update($tenant, $missingImages);
}
}
foreach (['Música', 'Teatro', 'Deportes', 'Infantiles', 'Otros'] as $name) {
$tenant->eventCategories()->firstOrCreate(['nombre' => $name]);
}
if (! $tenant->websiteExtras()->whereHas(
'websiteTypeExtra',
fn ($query) => $query->where('codigo', 'immersiveHero')
)->exists()) {
$this->websiteExtraService->updateForTenant($tenant, 'immersiveHero', [
'eyebrow' => 'Encendé tu',
'title' => 'experiencia',
'description' => 'Reservá tu entrada y formá parte',
]);
}
$this->carouselSeeder->run();
}
private function uploadedImage(string $filename): UploadedFile
{
$path = public_path(self::IMAGE_DIRECTORY."/{$filename}");
if (! is_file($path)) {
throw new RuntimeException("Image not found at path: {$path}");
}
$mimeType = match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) {
'svg' => 'image/svg+xml',
'avif' => 'image/avif',
default => 'image/png',
};
return new UploadedFile($path, $filename, $mimeType, null, true);
}
}

View File

@@ -10,13 +10,15 @@ use RuntimeException;
class OnticketImmersiveHeroCarouselSeeder extends Seeder class OnticketImmersiveHeroCarouselSeeder extends Seeder
{ {
private const IMAGE_DIRECTORY = 'images/tennants/onticket_otpimizado/onticket_tennant_hero_carousel';
private const IMAGES = [ private const IMAGES = [
'onticket_hero_carousel_1.png', 'onticket_hero_carousel_1.avif',
'onticket_hero_carousel_2.png', 'onticket_hero_carousel_2.avif',
'onticket_hero_carousel_3.avif', 'onticket_hero_carousel_3.avif',
'onticket_hero_carousel_4.jfif', 'onticket_hero_carousel_4.avif',
'onticket_hero_carousel_5.jfif', 'onticket_hero_carousel_5.avif',
'onticket_hero_carousel_6.jfif', 'onticket_hero_carousel_6.avif',
'onticket_hero_carousel_7.avif', 'onticket_hero_carousel_7.avif',
'onticket_hero_carousel_8.avif', 'onticket_hero_carousel_8.avif',
]; ];
@@ -39,7 +41,7 @@ class OnticketImmersiveHeroCarouselSeeder extends Seeder
} }
$images = array_map(function (string $filename): UploadedFile { $images = array_map(function (string $filename): UploadedFile {
$path = public_path("images/tennants/onticket/onticket_tennant_hero_carousel/{$filename}"); $path = public_path(self::IMAGE_DIRECTORY."/{$filename}");
if (! is_file($path)) { if (! is_file($path)) {
throw new RuntimeException("Image not found at path: {$path}"); throw new RuntimeException("Image not found at path: {$path}");

View File

@@ -0,0 +1,155 @@
<?php
namespace Database\Seeders;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
class OptimizeOnticketImagesSeeder extends Seeder
{
private const IMAGE_DIRECTORY = 'images/tennants/onticket_otpimizado';
private const CAROUSEL_IMAGES = [
'onticket_hero_carousel_1.avif',
'onticket_hero_carousel_2.avif',
'onticket_hero_carousel_3.avif',
'onticket_hero_carousel_4.avif',
'onticket_hero_carousel_5.avif',
'onticket_hero_carousel_6.avif',
'onticket_hero_carousel_7.avif',
'onticket_hero_carousel_8.avif',
];
private const EVENT_IMAGES = [
'[Prueba] Noche de Música en Vivo' => 'live-music.avif',
'[Prueba] Festival de Sabores' => 'food-festival.avif',
'[Prueba] Stand Up en el Centro' => 'stand-up.avif',
];
public function run(): void
{
$tenant = Tenant::query()->where('codigo', 'onticket')->first();
if ($tenant === null) {
return;
}
$this->replaceAttachment(
$tenant->headerBackgroundImage,
'onticket_header_background.avif',
);
$carousel = $tenant->websiteExtras()
->whereHas(
'websiteTypeExtra',
fn ($query) => $query->where('codigo', 'immersiveHeroCarousel')
)
->first();
$carouselIds = is_array($carousel?->config) ? array_values($carousel->config) : [];
foreach (self::CAROUSEL_IMAGES as $index => $filename) {
$attachmentId = $carouselIds[$index] ?? null;
if (is_int($attachmentId)) {
$this->replaceAttachment(
Attachment::query()->find($attachmentId),
'onticket_tennant_hero_carousel/'.$filename,
);
}
}
foreach (self::EVENT_IMAGES as $title => $filename) {
$event = $tenant->events()->where('title', $title)->first();
$this->replaceAttachment(
$event?->attachment,
'test-events/'.$filename,
);
}
}
private function replaceAttachment(?Attachment $attachment, string $relativePath): void
{
if ($attachment === null) {
return;
}
$sourcePath = public_path(self::IMAGE_DIRECTORY.'/'.$relativePath);
if (! is_file($sourcePath)) {
throw new RuntimeException("Image not found at path: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if (! is_string($contents) || $contents === '') {
throw new RuntimeException("Could not read image at path: {$sourcePath}");
}
$filename = basename($relativePath);
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$mimeType = match ($extension) {
'avif' => 'image/avif',
'svg' => 'image/svg+xml',
default => 'image/png',
};
$directory = trim(str_replace('\\', '/', dirname($attachment->path)), './');
$storedFilename = $attachment->key.($extension === '' ? '' : '.'.$extension);
$newPath = $directory === '' ? $storedFilename : $directory.'/'.$storedFilename;
$disk = Storage::disk('s3');
if (
$attachment->path === $newPath
&& $attachment->filename === $filename
&& $attachment->mime_type === $mimeType
&& $attachment->size === strlen($contents)
&& $disk->exists($newPath)
) {
return;
}
$oldPath = $attachment->path;
$oldContents = $disk->exists($oldPath) ? $disk->get($oldPath) : null;
if (! $disk->put($newPath, $contents)) {
throw new RuntimeException("Could not store optimized image at path: {$newPath}");
}
try {
DB::transaction(function () use (
$attachment,
$extension,
$filename,
$mimeType,
$newPath,
$contents,
): void {
$attachment->update([
'path' => $newPath,
'filename' => $filename,
'type' => 'image',
'mime_type' => $mimeType,
'extension' => $extension,
'size' => strlen($contents),
]);
});
} catch (Throwable $throwable) {
if ($newPath === $oldPath && is_string($oldContents)) {
$disk->put($oldPath, $oldContents);
} else {
$disk->delete($newPath);
}
throw $throwable;
}
if ($oldPath !== $newPath && $disk->exists($oldPath)) {
$disk->delete($oldPath);
}
}
}

View File

@@ -3,10 +3,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Services\TenantService; use App\Domains\Core\Tenant\Services\TenantProvisioningService;
use App\Domains\Core\Tenant\Services\WebsiteExtraService;
use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -16,8 +14,6 @@ use Throwable;
class TenantSeeder extends Seeder class TenantSeeder extends Seeder
{ {
private const ONTICKET_CLIENT_CODE = 'onticket';
private const PYME_RURAL_CLIENT_CODE = 'pyme_rural'; private const PYME_RURAL_CLIENT_CODE = 'pyme_rural';
private const SONDER_CLIENT_CODE = 'sonder'; private const SONDER_CLIENT_CODE = 'sonder';
@@ -46,8 +42,7 @@ class TenantSeeder extends Seeder
]; ];
public function __construct( public function __construct(
protected TenantService $tenantService, protected TenantProvisioningService $tenantService,
protected WebsiteExtraService $websiteExtraService,
) {} ) {}
public function run(): void public function run(): void
@@ -57,89 +52,12 @@ class TenantSeeder extends Seeder
['name' => 'Sonder'], ['name' => 'Sonder'],
); );
$onTicketClient = Client::query()->firstOrCreate(
['code' => self::ONTICKET_CLIENT_CODE],
['name' => 'OnTicket'],
);
$pymeRuralClient = Client::query()->updateOrCreate( $pymeRuralClient = Client::query()->updateOrCreate(
['code' => self::PYME_RURAL_CLIENT_CODE], ['code' => self::PYME_RURAL_CLIENT_CODE],
['name' => 'Pyme Rural'], ['name' => 'Pyme Rural'],
); );
$onTicketType = AdminWebsiteType::query()->where('codigo', 'onticket')->firstOrFail(); $this->call(OnTicketTenantSeeder::class);
$onTicketTenant = Tenant::query()->where('codigo', 'onticket')->first();
if ($onTicketTenant === null) {
$this->tenantService->create([
'client_id' => $onTicketClient->id,
'codigo' => 'onticket',
'nombre' => $onTicketType->nombre,
'dominio' => $onTicketType->dominio,
'site_title' => $onTicketType->site_title,
'primary_color' => $onTicketType->primary_color,
'secondary_color' => $onTicketType->secondary_color,
'danger_color' => $onTicketType->danger_color,
'success_color' => $onTicketType->success_color,
'header_bg_color' => $onTicketType->surface_color,
'footer_bg_color' => $onTicketType->primary_color,
'display_categories' => true,
'display_seach_bar' => true,
'admin_website_type_code' => $onTicketType->codigo,
'storefront_website_type_code' => 'onticket_multi_event',
'header_logo' => $this->uploadedImage(
'images/tennants/onticket/onticket_logo.png',
'onticket_logo.png',
),
'footer_logo' => $this->uploadedImage(
'images/tennants/onticket/onticket_footer_logo.png',
'onticket_footer_logo.png',
),
'favicon' => $this->uploadedImage(
'images/tennants/onticket/onticket_favicon.svg',
'onticket_favicon.svg',
),
'header_bg_image' => $this->uploadedImage(
'images/tennants/onticket/onticket_header_background.png',
'onticket_header_background.png',
),
]);
} elseif ($onTicketTenant->header_bg_image_id === null) {
$this->tenantService->update($onTicketTenant, [
'header_bg_image' => $this->uploadedImage(
'images/tennants/onticket/onticket_header_background.png',
'onticket_header_background.png',
),
]);
}
if ($onTicketTenant !== null && $onTicketTenant->storefront_website_type_code === 'onticket') {
$onTicketTenant->update(['storefront_website_type_code' => 'onticket_multi_event']);
}
$onTicketTenant = Tenant::query()->where('codigo', 'onticket')->firstOrFail();
foreach (['Música', 'Teatro', 'Deportes', 'Infantiles', 'Otros'] as $nombre) {
$onTicketTenant->eventCategories()->firstOrCreate(['nombre' => $nombre]);
}
if ($onTicketTenant->storefront_website_type_code === 'onticket_multi_event' && ! $onTicketTenant->display_seach_bar) {
$onTicketTenant->update(['display_seach_bar' => true]);
}
if (
$onTicketTenant->storefront_website_type_code === 'onticket_multi_event'
&& ! $onTicketTenant->websiteExtras()->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'immersiveHero'))->exists()
) {
$this->websiteExtraService->updateForTenant($onTicketTenant, 'immersiveHero', [
'eyebrow' => 'Encendé tu',
'title' => 'experiencia',
'description' => 'Reservá tu entrada y formá parte',
]);
}
$this->call(OnticketImmersiveHeroCarouselSeeder::class);
$this->deleteTenant('sonder'); $this->deleteTenant('sonder');

View File

@@ -2,8 +2,15 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
use App\Shared\Enums\FieldType;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -11,7 +18,12 @@ use RuntimeException;
class TestEventsSeeder extends Seeder class TestEventsSeeder extends Seeder
{ {
public function __construct(private readonly AttachmentService $attachmentService) {} private const IMAGE_DIRECTORY = 'images/tennants/onticket_otpimizado/test-events';
public function __construct(
private readonly AttachmentService $attachmentService,
private readonly CatalogService $catalogService,
) {}
public function run(): void public function run(): void
{ {
@@ -20,45 +32,114 @@ class TestEventsSeeder extends Seeder
$events = [ $events = [
[ [
'title' => '[Prueba] Noche de Música en Vivo', 'title' => '[Prueba] Noche de Música en Vivo',
'slug' => 'prueba-noche-de-musica-en-vivo',
'category' => 'Música', 'category' => 'Música',
'subtitle' => 'Una noche para cantar y bailar', 'subtitle' => 'Una noche para cantar y bailar',
'description' => 'Evento de prueba para explorar la cartelera de OnTicket.', 'description' => 'Evento de prueba para explorar la cartelera de OnTicket.',
'location' => 'Teatro Broadway, Rosario', 'location' => 'Teatro Broadway, Rosario',
'image' => 'live-music.png', 'image' => 'live-music.avif',
'weeks_from_now' => [2], 'weeks_from_now' => [2],
'time_start' => '21:00:00', 'time_start' => '21:00:00',
'time_end' => '23:30:00', 'time_end' => '23:30:00',
'catalog_items' => [
[
'slug' => 'prueba-noche-musica-entrada-general',
'name' => 'Entrada general',
'description' => 'Acceso general a la noche de música en vivo.',
'price' => 18000,
'stock' => 100,
],
[
'slug' => 'prueba-noche-musica-entrada-vip',
'name' => 'Entrada VIP',
'description' => 'Acceso preferencial y ubicación VIP.',
'price' => 32000,
'stock' => 40,
],
],
], ],
[ [
'title' => '[Prueba] Festival de Sabores', 'title' => '[Prueba] Festival de Sabores',
'slug' => 'prueba-festival-de-sabores',
'category' => 'Otros', 'category' => 'Otros',
'subtitle' => 'Gastronomía y música para toda la familia', 'subtitle' => 'Gastronomía y música para toda la familia',
'description' => 'Evento de prueba con dos jornadas disponibles.', 'description' => 'Evento de prueba con dos jornadas disponibles.',
'location' => 'Parque de España, Rosario', 'location' => 'Parque de España, Rosario',
'image' => 'food-festival.png', 'image' => 'food-festival.avif',
'weeks_from_now' => [3, 3], 'weeks_from_now' => [3, 3],
'day_offsets' => [0, 1], 'day_offsets' => [0, 1],
'time_start' => '12:00:00', 'time_start' => '12:00:00',
'time_end' => '20:00:00', 'time_end' => '20:00:00',
'catalog_items' => [
[
'slug' => 'prueba-festival-sabores-entrada-general',
'name' => 'Entrada general',
'description' => 'Acceso general para la jornada seleccionada.',
'price' => 12000,
'stock' => 150,
],
[
'slug' => 'prueba-festival-sabores-pase-premium',
'name' => 'Pase premium',
'description' => 'Acceso premium para la jornada seleccionada.',
'price' => 22000,
'stock' => 50,
],
],
], ],
[ [
'title' => '[Prueba] Stand Up en el Centro', 'title' => '[Prueba] Stand Up en el Centro',
'slug' => 'prueba-stand-up-en-el-centro',
'category' => 'Teatro', 'category' => 'Teatro',
'subtitle' => 'Humor para compartir', 'subtitle' => 'Humor para compartir',
'description' => 'Evento de prueba para visualizar distintas propuestas.', 'description' => 'Evento de prueba para visualizar distintas propuestas.',
'location' => 'Centro Cultural La Comedia, Rosario', 'location' => 'Centro Cultural La Comedia, Rosario',
'image' => 'stand-up.png', 'image' => 'stand-up.avif',
'weeks_from_now' => [4], 'weeks_from_now' => [4],
'time_start' => '20:30:00', 'time_start' => '20:30:00',
'time_end' => '22:00:00', 'time_end' => '22:00:00',
'catalog_items' => [
[
'slug' => 'prueba-stand-up-entrada-general',
'name' => 'Entrada general',
'description' => 'Acceso general al show de stand up.',
'price' => 14000,
'stock' => 120,
],
[
'slug' => 'prueba-stand-up-primera-fila',
'name' => 'Primera fila',
'description' => 'Ubicación preferencial en primera fila.',
'price' => 26000,
'stock' => 30,
],
],
], ],
]; ];
DB::transaction(function () use ($tenant, $events): void { DB::transaction(function () use ($tenant, $events): void {
Attribute::query()->firstOrCreate(
[
'tenant_codigo' => $tenant->codigo,
'codigo' => 'event_date',
],
[
'nombre' => 'Fecha',
'type' => FieldType::EventDate->value,
'is_required' => true,
],
);
$entryCategory = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Entradas',
]);
foreach ($events as $definition) { foreach ($events as $definition) {
$event = $tenant->events()->firstOrCreate( $event = $tenant->events()->firstOrCreate(
['title' => $definition['title']], ['title' => $definition['title']],
[ [
'slug' => $definition['slug'],
'subtitle' => $definition['subtitle'], 'subtitle' => $definition['subtitle'],
'description' => $definition['description'], 'description' => $definition['description'],
'location' => $definition['location'], 'location' => $definition['location'],
@@ -68,29 +149,30 @@ class TestEventsSeeder extends Seeder
], ],
); );
if ($event->slug !== $definition['slug']) {
$event->update(['slug' => $definition['slug']]);
}
if ($event->event_category_id === null) { if ($event->event_category_id === null) {
$event->update(['event_category_id' => $tenant->eventCategories() $event->update(['event_category_id' => $tenant->eventCategories()
->where('nombre', $definition['category'])->value('id')]); ->where('nombre', $definition['category'])->value('id')]);
} }
if ($event->attachment_id === null) { if ($event->attachment_id === null) {
$path = public_path('images/tennants/onticket/test-events/'.$definition['image']); $path = public_path(self::IMAGE_DIRECTORY.'/'.$definition['image']);
if (! is_file($path)) { if (! is_file($path)) {
throw new RuntimeException("Image not found at path: {$path}"); throw new RuntimeException("Image not found at path: {$path}");
} }
$attachment = $this->attachmentService->store( $attachment = $this->attachmentService->store(
new UploadedFile($path, $definition['image'], 'image/png', null, true), new UploadedFile($path, $definition['image'], 'image/avif', null, true),
'tenants/onticket/events', 'tenants/onticket/events',
); );
$event->update(['attachment_id' => $attachment->id]); $event->update(['attachment_id' => $attachment->id]);
} }
if ($event->dates()->exists()) { if (! $event->dates()->exists()) {
continue;
}
foreach ($definition['weeks_from_now'] as $index => $weeks) { foreach ($definition['weeks_from_now'] as $index => $weeks) {
$date = now()->startOfDay()->addWeeks($weeks) $date = now()->startOfDay()->addWeeks($weeks)
->addDays($definition['day_offsets'][$index] ?? 0); ->addDays($definition['day_offsets'][$index] ?? 0);
@@ -103,6 +185,52 @@ class TestEventsSeeder extends Seeder
]); ]);
} }
} }
$this->seedCatalogItems($tenant, $event, $entryCategory, $definition['catalog_items']);
}
}); });
} }
/** @param array<int, array<string, mixed>> $definitions */
private function seedCatalogItems(
Tenant $tenant,
Event $event,
Category $category,
array $definitions,
): void {
$eventDateIds = $event->dates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->values();
foreach ($definitions as $order => $definition) {
$catalogItem = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', $definition['slug'])
->first();
if ($catalogItem === null) {
$catalogItem = $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'category_id' => $category->id,
'slug' => $definition['slug'],
'nombre' => $definition['name'],
'descripcion' => $definition['description'],
'precio' => $definition['price'],
'group_order' => $order,
'has_tickets' => true,
'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_codes' => ['event_date'],
'hidden_attribute_codes' => ['event_date'],
'images' => [$event->attachment->key],
'variants' => $eventDateIds->map(fn (int $eventDateId): array => [
'real_stock' => $definition['stock'],
'event_date_ids' => [$eventDateId],
])->all(),
]);
}
$catalogItem->forceFill(['event_id' => $event->id])->save();
}
}
} }

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