Compare commits
37 Commits
v1.0.0
...
tenant/sme
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c0902b83 | |||
| a2439bf43b | |||
| 8d5a12f212 | |||
| 92170b72f2 | |||
| c3def0678f | |||
| 74b72a7d4e | |||
| 16e2ca679c | |||
| 6b0efbc6ee | |||
| 2db83271fa | |||
| e259bba1d1 | |||
| b3cd9f5de5 | |||
| 64820fe12b | |||
| e72b6cfdde | |||
| 0e870776c4 | |||
| d8d8354070 | |||
| 5bb61fc74c | |||
| 9e9af70ba8 | |||
| 884da2c89a | |||
| 3671b95a83 | |||
| 5e78f2bb1e | |||
| 5296d595f1 | |||
| 9901d449e1 | |||
| 57605a56c4 | |||
| d9f19b42f1 | |||
| 90b2784859 | |||
| 6f191b27f9 | |||
| 3401c934b2 | |||
| 6efe8439c7 | |||
| 506f8a5176 | |||
| 03bb7709c4 | |||
| c90a393081 | |||
| a4a8a3ef8f | |||
| f5f4cb4e87 | |||
| d33b435ba5 | |||
| 969e99b3ae | |||
| 3e3b3bfac0 | |||
| f4de2ff5b2 |
@@ -2470,45 +2470,6 @@
|
||||
},
|
||||
"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",
|
||||
"request": {
|
||||
@@ -2535,86 +2496,6 @@
|
||||
},
|
||||
"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",
|
||||
"request": {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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\InventoryPolicy;
|
||||
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\Ticketing\Event\Models\Event;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -237,7 +237,7 @@ class CatalogItem extends Model
|
||||
->whereHas(
|
||||
'inventory',
|
||||
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 {
|
||||
@@ -249,7 +249,7 @@ class CatalogItem extends Model
|
||||
->orWhereHas(
|
||||
'inventory',
|
||||
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> */
|
||||
public function visibleVariants(?int $includedVariantId = null): Collection
|
||||
{
|
||||
$this->variants
|
||||
->filter(fn (Variant $variant): bool => $variant->exists)
|
||||
->loadMissing('desfileEntryReservations');
|
||||
|
||||
return $this->variants
|
||||
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this))
|
||||
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
|
||||
&& (! $variant->relationLoaded('desfileEntryReservations')
|
||||
|| $variant->desfileEntryReservations->isEmpty())
|
||||
&& (($includedVariantId !== null && $variant->id === $includedVariantId)
|
||||
|| ($variant->isSellable() && (
|
||||
$this->inventory_policy === InventoryPolicy::Unlimited
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
'sold_units',
|
||||
'refunded_units',
|
||||
'reserved_stock',
|
||||
'entry_reserved_stock',
|
||||
'real_stock',
|
||||
])]
|
||||
class Inventory extends Model
|
||||
@@ -35,6 +36,7 @@ class Inventory extends Model
|
||||
'sold_units' => 'integer',
|
||||
'refunded_units' => 'integer',
|
||||
'reserved_stock' => 'integer',
|
||||
'entry_reserved_stock' => 'integer',
|
||||
'real_stock' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -59,7 +61,26 @@ class Inventory extends Model
|
||||
|
||||
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
|
||||
@@ -92,7 +113,7 @@ class Inventory extends Model
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
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\Ticket\Models\Ticket;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -74,6 +75,12 @@ class Variant extends Model
|
||||
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> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -7,8 +7,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
|
||||
use App\Shared\Enums\FieldType;
|
||||
use App\Domains\Ticketing\Ticket\Resources\ValidityTimeResource;
|
||||
use App\Shared\Enums\FieldType;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -140,7 +140,7 @@ class CatalogItemDetailResource extends JsonResource
|
||||
'validity_time_id' => $option->validity_time_id,
|
||||
'validity_time' => $option->validityTime === null
|
||||
? null
|
||||
: ValidityTimeResource::make($option->validityTime),
|
||||
: ValidityTimeResource::make($option->validityTime, $this->tenant->timezone),
|
||||
'metadata' => $option->metadata,
|
||||
])
|
||||
->values();
|
||||
|
||||
@@ -153,7 +153,7 @@ class CatalogInventoryService
|
||||
|
||||
if ($operation === 'commit'
|
||||
&& $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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ class StockReservationService
|
||||
$inventory = $inventories->get($line->inventory_id)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Commerce\Catalog\Models\StockReservation;
|
||||
use App\Domains\Commerce\Catalog\Models\StockReservationLine;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
|
||||
use App\Domains\Ticketing\Event\Models\EventDate;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@@ -190,6 +191,7 @@ class VariantReplacementService
|
||||
'sold_units' => $sourceInventory->sold_units,
|
||||
'refunded_units' => $sourceInventory->refunded_units,
|
||||
'reserved_stock' => $reservedStock,
|
||||
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
|
||||
'real_stock' => $sourceInventory->real_stock,
|
||||
]);
|
||||
|
||||
@@ -198,7 +200,9 @@ class VariantReplacementService
|
||||
->whereKey($activeLines->modelKeys())
|
||||
->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;
|
||||
}
|
||||
@@ -235,6 +239,7 @@ class VariantReplacementService
|
||||
$destinationInventory->update([
|
||||
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_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,
|
||||
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
|
||||
]);
|
||||
@@ -243,9 +248,12 @@ class VariantReplacementService
|
||||
->whereKey($activeLines->modelKeys())
|
||||
->update(['inventory_id' => $destinationInventory->getKey()]);
|
||||
}
|
||||
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
|
||||
->update(['inventory_id' => $destinationInventory->id]);
|
||||
$sourceInventory->update([
|
||||
'real_stock' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'entry_reserved_stock' => 0,
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
]);
|
||||
|
||||
@@ -25,6 +25,9 @@ class TenantTransactionResetService
|
||||
'carts' => $scope['cart_ids']->count(),
|
||||
'cart_items' => $scope['cart_item_ids']->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(),
|
||||
'purchase_changes' => DB::table('value_changes')
|
||||
->where('tenant_code', $tenantCode)
|
||||
@@ -47,6 +50,10 @@ class TenantTransactionResetService
|
||||
$telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count();
|
||||
$summary = [
|
||||
'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(),
|
||||
'purchase_changes_deleted' => DB::table('value_changes')
|
||||
->where('tenant_code', $tenantCode)
|
||||
@@ -67,6 +74,7 @@ class TenantTransactionResetService
|
||||
->update([
|
||||
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
|
||||
'reserved_stock' => 0,
|
||||
'entry_reserved_stock' => 0,
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
]);
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
namespace App\Domains\Core\Tenant\Controllers;
|
||||
|
||||
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\Services\TenantInformationService;
|
||||
use App\Domains\Core\Tenant\Services\TenantService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -15,7 +12,6 @@ use Illuminate\Http\Response;
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TenantService $tenantService,
|
||||
protected TenantInformationService $tenantInformationService,
|
||||
) {}
|
||||
|
||||
@@ -30,15 +26,6 @@ class TenantController extends Controller
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
$tenant->delete();
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
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\ProductLayout;
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
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\TenantMenu;
|
||||
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\Shared\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -28,6 +28,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
'client_id',
|
||||
'codigo',
|
||||
'nombre',
|
||||
'timezone',
|
||||
'dominio',
|
||||
'base_path',
|
||||
'site_title',
|
||||
@@ -66,7 +67,10 @@ class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const DEFAULT_TIMEZONE = 'America/Argentina/Buenos_Aires';
|
||||
|
||||
protected $attributes = [
|
||||
'timezone' => self::DEFAULT_TIMEZONE,
|
||||
'base_path' => '/',
|
||||
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
||||
'search_group_layout' => GroupLayout::Paginated->value,
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class TenantResource extends JsonResource
|
||||
'client_id' => $this->client_id,
|
||||
'codigo' => $this->codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'timezone' => $this->timezone,
|
||||
'dominio' => $this->dominio,
|
||||
'base_path' => $this->base_path,
|
||||
'site_title' => $this->site_title ?? 'ShopitFront',
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
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\Services\AttachmentService;
|
||||
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -96,12 +97,12 @@ class AdminWebsiteTypeService
|
||||
{
|
||||
return Tenant::query()->where('favicon_id', $attachment->id)->exists()
|
||||
|| AdminWebsiteType::query()
|
||||
->where(function ($query) use ($attachment): void {
|
||||
$query
|
||||
->where('site_logo', $attachment->id)
|
||||
->orWhere('footer_logo', $attachment->id)
|
||||
->orWhere('favicon_id', $attachment->id);
|
||||
})
|
||||
->exists();
|
||||
->where(function ($query) use ($attachment): void {
|
||||
$query
|
||||
->where('site_logo', $attachment->id)
|
||||
->orWhere('footer_logo', $attachment->id)
|
||||
->orWhere('favicon_id', $attachment->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
125
app/Domains/Core/Tenant/Services/TenantProvisioningService.php
Normal file
125
app/Domains/Core/Tenant/Services/TenantProvisioningService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
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\Models\Attachment;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use App\Shared\Rules\CroppedImageOrBase64Rule;
|
||||
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\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -21,109 +19,6 @@ class WebsiteExtraService
|
||||
{
|
||||
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.
|
||||
*
|
||||
@@ -201,19 +96,6 @@ class WebsiteExtraService
|
||||
->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
|
||||
* @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
|
||||
{
|
||||
if ($path === '$') {
|
||||
|
||||
@@ -15,7 +15,6 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
|
||||
|
||||
## Servicios
|
||||
|
||||
- `TenantService`: crea y actualiza tenants, incluyendo sus recursos asociados.
|
||||
- `TenantInformationService`: carga un tenant y las relaciones requeridas por cada contexto.
|
||||
- `AdminWebsiteTypeService`: crea o actualiza los tipos de admin y su marca.
|
||||
- `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
|
||||
|
||||
- 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.
|
||||
|
||||
## Dependencias y reglas
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
use App\Domains\Core\Tenant\Controllers\TenantController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('tenants', TenantController::class);
|
||||
Route::apiResource('tenants', TenantController::class)->except(['store', 'update']);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Domains/Ticketing/Desfile/Models/EntryReservation.php
Normal file
54
app/Domains/Ticketing/Desfile/Models/EntryReservation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'] : '-';
|
||||
}
|
||||
}
|
||||
@@ -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(', ');
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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\Inventory;
|
||||
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
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\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -254,7 +254,7 @@ class EntryService
|
||||
{
|
||||
$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([
|
||||
$key => [
|
||||
'No se puede modificar ni eliminar un asiento con ventas o reservas.',
|
||||
|
||||
@@ -393,7 +393,7 @@ class InvitationPurchaseProvisioner
|
||||
|
||||
$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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Ticketing\Desfile\Controllers\EntryController;
|
||||
use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
|
||||
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')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
|
||||
->group(function (): void {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
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\Tenant;
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
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\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
|
||||
{
|
||||
use HasFactory;
|
||||
@@ -53,6 +54,12 @@ class Event extends Model
|
||||
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> */
|
||||
public function socialMedia(): BelongsToMany
|
||||
{
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace App\Domains\Ticketing\Event\Models;
|
||||
|
||||
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\Services\EffectiveEventDateResolver;
|
||||
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\Models\ValidityTime;
|
||||
use Carbon\CarbonInterface;
|
||||
@@ -131,12 +131,12 @@ class EventDate extends Model
|
||||
|
||||
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
|
||||
{
|
||||
$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())
|
||||
? $endsAt->addDay()
|
||||
@@ -184,8 +184,8 @@ class EventDate extends Model
|
||||
|
||||
private function syncValidityTime(): void
|
||||
{
|
||||
$startsAt = $this->startsAt();
|
||||
$expiresAt = $this->endsAt();
|
||||
$startsAt = $this->startsAt()->utc();
|
||||
$expiresAt = $this->endsAt()->utc();
|
||||
|
||||
$attributes = [
|
||||
'type' => ValidityTimeType::FixedWindow,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
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\Services\EventDateGroupingService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -17,6 +17,7 @@ class EventResource extends JsonResource
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
'description' => $this->description,
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Ticketing\Event\Resources;
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -15,6 +14,7 @@ class PublicEventResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'event_category_id' => $this->event_category_id,
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
@@ -29,10 +29,7 @@ class PublicEventResource extends JsonResource
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse(
|
||||
$first->date->format('Y-m-d').' '.$first->time_start,
|
||||
config('app.event_timezone'),
|
||||
)->toISOString();
|
||||
return $first->startsAt()->toISOString();
|
||||
}),
|
||||
'dates' => $this->whenLoaded('dates', fn () => $this->dates->map(fn ($date): array => [
|
||||
'id' => $date->id,
|
||||
@@ -52,8 +49,7 @@ class PublicEventResource extends JsonResource
|
||||
'description' => $item->descripcion,
|
||||
'price' => $item->precio,
|
||||
'image' => $item->attachments->first()?->getTemporaryUrl(1440),
|
||||
'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool =>
|
||||
$attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'),
|
||||
'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool => $attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'),
|
||||
'maximum_quantity' => $item->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $item->availableStock(),
|
||||
|
||||
@@ -6,9 +6,9 @@ use Illuminate\Support\Facades\Route;
|
||||
|
||||
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/{event}', [PublicEventController::class, 'show']);
|
||||
Route::get('events/{event:slug}', [PublicEventController::class, 'show']);
|
||||
});
|
||||
|
||||
Route::middleware('auth:sanctum')->post(
|
||||
|
||||
@@ -6,31 +6,47 @@ use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
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\Services\TicketPdfService;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
|
||||
use App\Domains\Ticketing\Ticket\Services\UserTicketQueryService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
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()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('user_id', $request->user()->getKey())
|
||||
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
$eventId = $request->validated('event_id');
|
||||
$tickets = $this->userTickets->tickets(
|
||||
$tenant,
|
||||
$request->user(),
|
||||
$eventId === null ? null : (int) $eventId,
|
||||
);
|
||||
|
||||
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
|
||||
{
|
||||
$ticketIds = $request->validated('ticket_ids');
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
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\Variant;
|
||||
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
|
||||
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
|
||||
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\Services\ResolvedTicketValidity;
|
||||
use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
|
||||
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -24,6 +25,7 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'event_id',
|
||||
'ticket',
|
||||
'source_purchase_item_id',
|
||||
'source_catalog_item_id',
|
||||
@@ -75,6 +77,7 @@ class Ticket extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => 'integer',
|
||||
'source_catalog_item_id' => 'integer',
|
||||
'source_variant_id' => 'integer',
|
||||
'source_purchase_item_id' => 'integer',
|
||||
@@ -136,6 +139,12 @@ class Ticket extends Model
|
||||
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
|
||||
{
|
||||
return $this->tenant?->allow_refund() ?? false;
|
||||
|
||||
@@ -47,30 +47,42 @@ class ValidityTime extends Model
|
||||
|
||||
public function startsAt(
|
||||
?CarbonInterface $at = null,
|
||||
string $timezone = 'UTC',
|
||||
): ?CarbonInterface {
|
||||
if ($this->type === ValidityTimeType::FixedWindow) {
|
||||
return $this->fixed_starts_at;
|
||||
}
|
||||
|
||||
return $this->atCurrentDate($this->start_time, $at);
|
||||
return $this->atCurrentDate($this->start_time, $at, $timezone);
|
||||
}
|
||||
|
||||
public function expiresAt(
|
||||
?CarbonInterface $at = null,
|
||||
string $timezone = 'UTC',
|
||||
): ?CarbonInterface {
|
||||
if ($this->type === ValidityTimeType::FixedWindow) {
|
||||
return $this->fixed_expires_at;
|
||||
}
|
||||
|
||||
return $this->atCurrentDate($this->end_time, $at);
|
||||
return $this->atCurrentDate($this->end_time, $at, $timezone);
|
||||
}
|
||||
|
||||
public function isValid(
|
||||
?CarbonInterface $at = null,
|
||||
string $timezone = 'UTC',
|
||||
): bool {
|
||||
$at ??= now();
|
||||
$startsAt = $this->startsAt($at);
|
||||
$expiresAt = $this->expiresAt($at);
|
||||
$startsAt = $this->startsAt($at, $timezone);
|
||||
$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))
|
||||
&& ($expiresAt === null || $expiresAt->greaterThan($at));
|
||||
@@ -79,6 +91,7 @@ class ValidityTime extends Model
|
||||
private function atCurrentDate(
|
||||
?string $time,
|
||||
?CarbonInterface $at,
|
||||
string $timezone,
|
||||
): ?CarbonInterface {
|
||||
if ($time === null) {
|
||||
return null;
|
||||
@@ -86,8 +99,9 @@ class ValidityTime extends Model
|
||||
|
||||
$at ??= now();
|
||||
$localDate = CarbonImmutable::instance($at)
|
||||
->setTimezone($timezone)
|
||||
->format('Y-m-d');
|
||||
|
||||
return CarbonImmutable::parse($localDate.' '.$time);
|
||||
return CarbonImmutable::parse($localDate.' '.$time, $timezone);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Domains/Ticketing/Ticket/Requests/ListTicketsRequest.php
Normal file
30
app/Domains/Ticketing/Ticket/Requests/ListTicketsRequest.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class TicketResource extends JsonResource
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_code' => $this->tenant_code,
|
||||
'event_id' => $this->event_id,
|
||||
'ticket' => $this->ticket,
|
||||
'status' => $this->status,
|
||||
'status_label' => $this->status_label,
|
||||
|
||||
@@ -10,6 +10,11 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
/** @mixin ValidityTime */
|
||||
class ValidityTimeResource extends JsonResource
|
||||
{
|
||||
public function __construct($resource, private readonly string $timezone = 'UTC')
|
||||
{
|
||||
parent::__construct($resource);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
@@ -27,7 +32,7 @@ class ValidityTimeResource extends JsonResource
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type->value,
|
||||
'is_valid' => $this->isValid(),
|
||||
'is_valid' => $this->isValid(timezone: $this->timezone),
|
||||
...array_filter($fields, fn (mixed $value): bool => $value !== null),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
|
||||
final readonly class ResolvedValidityGroup
|
||||
{
|
||||
/** @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. */
|
||||
public function isValid(?CarbonInterface $at = null): bool
|
||||
@@ -50,7 +50,17 @@ final readonly class ResolvedValidityGroup
|
||||
$anchor = $this->dateAnchor() ?? $at;
|
||||
|
||||
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()
|
||||
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
|
||||
->first();
|
||||
@@ -67,11 +77,13 @@ final readonly class ResolvedValidityGroup
|
||||
|
||||
return $this->validityTimes
|
||||
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
|
||||
$startsAt = $validityTime->startsAt($anchor);
|
||||
$expiresAt = $validityTime->expiresAt($anchor);
|
||||
$startsAt = $validityTime->startsAt($anchor, $this->timezone);
|
||||
$expiresAt = $validityTime->expiresAt($anchor, $this->timezone);
|
||||
|
||||
if ($startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) {
|
||||
return $expiresAt->addDay();
|
||||
return $this->dateAnchor() === null && $anchor->lessThan($expiresAt)
|
||||
? $expiresAt
|
||||
: $expiresAt->copy()->addDay();
|
||||
}
|
||||
|
||||
return $expiresAt;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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\Variant;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -44,6 +44,7 @@ class TicketGeneratorService
|
||||
$variant = $target['variant'];
|
||||
$ticket = Ticket::query()->create([
|
||||
'tenant_code' => $item->tenant_code,
|
||||
'event_id' => $item->event_id,
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'source_purchase_item_id' => $sourcePurchaseItemId,
|
||||
'source_catalog_item_id' => $item->getKey(),
|
||||
|
||||
@@ -29,6 +29,7 @@ class TicketValidityResolver
|
||||
|
||||
/** Relaciones necesarias para resolver tickets sin consultas N+1. */
|
||||
public const RELATIONS = [
|
||||
'sourceVariant.catalogItem.tenant',
|
||||
'sourceVariant.eventDates.validityTime',
|
||||
'sourceVariant.eventDate.validityTime',
|
||||
'sourceVariant.definitions.itemAttribute.attribute.options.validityTime',
|
||||
@@ -60,6 +61,7 @@ class TicketValidityResolver
|
||||
public function resolveVariant(Variant $variant): ResolvedTicketValidity
|
||||
{
|
||||
$variant->loadMissing([
|
||||
'catalogItem.tenant',
|
||||
'eventDates.validityTime',
|
||||
'eventDate.validityTime',
|
||||
'definitions.itemAttribute.attribute.options.validityTime',
|
||||
@@ -149,7 +151,7 @@ class TicketValidityResolver
|
||||
}
|
||||
|
||||
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'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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á
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('tenants/{tenant:codigo}')
|
||||
->middleware('auth:sanctum')
|
||||
->group(function (): void {
|
||||
Route::get('tickets/events', [TicketController::class, 'events']);
|
||||
Route::get('tickets', [TicketController::class, 'index']);
|
||||
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
namespace App\Shared\Attachable\Models;
|
||||
|
||||
use App\Shared\Attachable\Enums\AttachmentType;
|
||||
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
@@ -70,9 +70,9 @@ class Attachment extends Model
|
||||
*/
|
||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl(
|
||||
return app(TemporaryUrlService::class)->generate(
|
||||
$this->path,
|
||||
now()->addMinutes($expiresInMinutes)
|
||||
);
|
||||
$expiresInMinutes,
|
||||
)['temporary_url'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Shared\Forms\Controllers\AdminApp\DesfileEntryReservationFormController;
|
||||
use App\Shared\Forms\Controllers\AdminApp\EntryFormController;
|
||||
use App\Shared\Forms\Controllers\AdminApp\EventFormController;
|
||||
use App\Shared\Forms\Controllers\AdminApp\FoodFormController;
|
||||
@@ -14,6 +15,11 @@ Route::prefix('v1/adminapp/forms')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
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('staff', StaffFormController::class);
|
||||
Route::get('tickets-filter', TicketFilterFormController::class)
|
||||
|
||||
60
app/Shared/Storage/Services/TemporaryUrlService.php
Normal file
60
app/Shared/Storage/Services/TemporaryUrlService.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Shared\StorageTest\Services;
|
||||
|
||||
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -10,6 +11,10 @@ use RuntimeException;
|
||||
|
||||
class S3TestService
|
||||
{
|
||||
public function __construct(
|
||||
protected TemporaryUrlService $temporaryUrlService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
@@ -44,8 +49,7 @@ class S3TestService
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'extension' => $file->extension(),
|
||||
'size' => $file->getSize(),
|
||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
...$this->temporaryUrlService->generate($path, $expiresInMinutes),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -58,16 +62,10 @@ class S3TestService
|
||||
'disk' => 's3',
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
...$this->temporaryUrlService->generate($path, $expiresInMinutes),
|
||||
];
|
||||
}
|
||||
|
||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(?string $directory): string
|
||||
{
|
||||
$directory = trim((string) $directory, '/');
|
||||
|
||||
@@ -67,9 +67,6 @@ return [
|
||||
|
||||
'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
|
||||
|
||||
2691
database/data/mutual_smep_catalog_v1.json
Normal file
2691
database/data/mutual_smep_catalog_v1.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
311
database/scripts/build_mutual_smep_catalog_assets.php
Normal file
311
database/scripts/build_mutual_smep_catalog_assets.php
Normal 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;
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Attribute;
|
||||
use App\Shared\Enums\FieldType;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType;
|
||||
use App\Domains\Ticketing\Ticket\Models\ValidityTime;
|
||||
use App\Shared\Enums\FieldType;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class AttributeSeeder extends Seeder
|
||||
@@ -25,6 +25,10 @@ class AttributeSeeder extends Seeder
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tenant->codigo === 'mutual_smep') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->seedAttribute($tenant, [
|
||||
'codigo' => 'color',
|
||||
'nombre' => 'Color',
|
||||
|
||||
@@ -29,7 +29,9 @@ class DatabaseSeeder extends Seeder
|
||||
TenantSeeder::class,
|
||||
DesfilePuraTendenciaSeeder::class,
|
||||
AttributeSeeder::class,
|
||||
MutualSmepAttributeSeeder::class,
|
||||
CategorySeeder::class,
|
||||
MutualSmepCategorySeeder::class,
|
||||
BrandSeeder::class,
|
||||
ProductCatalogFromImagesSeeder::class,
|
||||
FiestaFutbolInfantilProductSeeder::class,
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Commerce\Catalog\Services\CatalogService;
|
||||
use App\Domains\Core\Client\Models\Client;
|
||||
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 Illuminate\Database\Seeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -27,7 +27,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
|
||||
private const TENANT_CODE = 'desfile_pura_tendencia';
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly TenantProvisioningService $tenantService,
|
||||
private readonly CatalogService $catalogService,
|
||||
private readonly InvitationPurchaseProvisioner $invitationPurchaseProvisioner,
|
||||
) {}
|
||||
@@ -115,6 +115,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
|
||||
]);
|
||||
|
||||
$event = $tenant->events()->create([
|
||||
'slug' => 'desfile-pura-tendencia',
|
||||
'title' => 'Desfile Pura Tendencia',
|
||||
'location' => 'Salón Centro Recreativo Luz y Fuerza',
|
||||
'date_text' => '16 de Octubre 2026',
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
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\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Services\CatalogService;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
@@ -13,13 +13,14 @@ use App\Shared\Enums\FieldType;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
class FiestaTradicionArrufoSeeder extends Seeder
|
||||
{
|
||||
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(
|
||||
private readonly CatalogService $catalog,
|
||||
@@ -51,7 +52,8 @@ class FiestaTradicionArrufoSeeder extends Seeder
|
||||
'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]);
|
||||
}
|
||||
|
||||
@@ -85,6 +87,7 @@ class FiestaTradicionArrufoSeeder extends Seeder
|
||||
$existing = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $data['slug'])->first();
|
||||
if ($existing !== null) {
|
||||
$existing->update(['sales_end_at' => $data['sales_end_at'] ?? null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,6 +107,12 @@ class FiestaTradicionArrufoSeeder extends Seeder
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class MenuSeeder extends Seeder
|
||||
[
|
||||
'code' => 'event.detail',
|
||||
'label' => 'Detalle de evento',
|
||||
'route' => '/evento/:id',
|
||||
'route' => '/evento/:slug',
|
||||
],
|
||||
[
|
||||
'code' => 'product.category',
|
||||
@@ -130,6 +130,12 @@ class MenuSeeder extends Seeder
|
||||
'parent_menu_code' => 'main.adminapp',
|
||||
'route' => '/admin/desfile/entradas',
|
||||
],
|
||||
[
|
||||
'code' => 'adminapp.desfile.reservas',
|
||||
'label' => 'Reserva de Tickets',
|
||||
'parent_menu_code' => 'main.adminapp',
|
||||
'route' => '/admin/desfile/reservas',
|
||||
],
|
||||
[
|
||||
'code' => 'account',
|
||||
'label' => 'Mi cuenta',
|
||||
@@ -290,6 +296,7 @@ class MenuSeeder extends Seeder
|
||||
$helpTenantCodes = [
|
||||
'sonder',
|
||||
'fiesta_futbol_infantil',
|
||||
'mutual_smep',
|
||||
];
|
||||
$fiestaCategoryMenuCodes = [
|
||||
'adminapp.tickets',
|
||||
@@ -306,6 +313,7 @@ class MenuSeeder extends Seeder
|
||||
];
|
||||
$desfileMenuCodes = [
|
||||
'adminapp.desfile.entradas',
|
||||
'adminapp.desfile.reservas',
|
||||
];
|
||||
$onTicketMenuCodes = [
|
||||
'event.index',
|
||||
@@ -356,6 +364,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) {
|
||||
$menuCodes = $allMenus;
|
||||
@@ -393,10 +437,14 @@ class MenuSeeder extends Seeder
|
||||
|
||||
if (in_array($tenant->codigo, $helpTenantCodes, true)) {
|
||||
$tenant->menues()->updateExistingPivot('help.faq', [
|
||||
'static_content' => $frequentlyAskedQuestions,
|
||||
'static_content' => $tenant->codigo === 'mutual_smep'
|
||||
? $mutualSmepFrequentlyAskedQuestions
|
||||
: $frequentlyAskedQuestions,
|
||||
]);
|
||||
$tenant->menues()->updateExistingPivot('help.contact', [
|
||||
'static_content' => $contactContent,
|
||||
'static_content' => $tenant->codigo === 'mutual_smep'
|
||||
? $mutualSmepContactContent
|
||||
: $contactContent,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
122
database/seeders/MutualSmepAttributeSeeder.php
Normal file
122
database/seeders/MutualSmepAttributeSeeder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
123
database/seeders/MutualSmepCategorySeeder.php
Normal file
123
database/seeders/MutualSmepCategorySeeder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
117
database/seeders/OnTicketTenantSeeder.php
Normal file
117
database/seeders/OnTicketTenantSeeder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,15 @@ use RuntimeException;
|
||||
|
||||
class OnticketImmersiveHeroCarouselSeeder extends Seeder
|
||||
{
|
||||
private const IMAGE_DIRECTORY = 'images/tennants/onticket_otpimizado/onticket_tennant_hero_carousel';
|
||||
|
||||
private const IMAGES = [
|
||||
'onticket_hero_carousel_1.png',
|
||||
'onticket_hero_carousel_2.png',
|
||||
'onticket_hero_carousel_1.avif',
|
||||
'onticket_hero_carousel_2.avif',
|
||||
'onticket_hero_carousel_3.avif',
|
||||
'onticket_hero_carousel_4.jfif',
|
||||
'onticket_hero_carousel_5.jfif',
|
||||
'onticket_hero_carousel_6.jfif',
|
||||
'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',
|
||||
];
|
||||
@@ -39,7 +41,7 @@ class OnticketImmersiveHeroCarouselSeeder extends Seeder
|
||||
}
|
||||
|
||||
$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)) {
|
||||
throw new RuntimeException("Image not found at path: {$path}");
|
||||
|
||||
155
database/seeders/OptimizeOnticketImagesSeeder.php
Normal file
155
database/seeders/OptimizeOnticketImagesSeeder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@
|
||||
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\TenantService;
|
||||
use App\Domains\Core\Tenant\Services\WebsiteExtraService;
|
||||
use App\Domains\Core\Tenant\Services\TenantProvisioningService;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -16,8 +14,6 @@ use Throwable;
|
||||
|
||||
class TenantSeeder extends Seeder
|
||||
{
|
||||
private const ONTICKET_CLIENT_CODE = 'onticket';
|
||||
|
||||
private const PYME_RURAL_CLIENT_CODE = 'pyme_rural';
|
||||
|
||||
private const SONDER_CLIENT_CODE = 'sonder';
|
||||
@@ -46,8 +42,7 @@ class TenantSeeder extends Seeder
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected TenantService $tenantService,
|
||||
protected WebsiteExtraService $websiteExtraService,
|
||||
protected TenantProvisioningService $tenantService,
|
||||
) {}
|
||||
|
||||
public function run(): void
|
||||
@@ -57,89 +52,12 @@ class TenantSeeder extends Seeder
|
||||
['name' => 'Sonder'],
|
||||
);
|
||||
|
||||
$onTicketClient = Client::query()->firstOrCreate(
|
||||
['code' => self::ONTICKET_CLIENT_CODE],
|
||||
['name' => 'OnTicket'],
|
||||
);
|
||||
|
||||
$pymeRuralClient = Client::query()->updateOrCreate(
|
||||
['code' => self::PYME_RURAL_CLIENT_CODE],
|
||||
['name' => 'Pyme Rural'],
|
||||
);
|
||||
|
||||
$onTicketType = AdminWebsiteType::query()->where('codigo', 'onticket')->firstOrFail();
|
||||
|
||||
$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->call(OnTicketTenantSeeder::class);
|
||||
|
||||
$this->deleteTenant('sonder');
|
||||
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
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\Ticketing\Event\Models\Event;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use App\Shared\Enums\FieldType;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -11,7 +18,12 @@ use RuntimeException;
|
||||
|
||||
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
|
||||
{
|
||||
@@ -20,45 +32,114 @@ class TestEventsSeeder extends Seeder
|
||||
$events = [
|
||||
[
|
||||
'title' => '[Prueba] Noche de Música en Vivo',
|
||||
'slug' => 'prueba-noche-de-musica-en-vivo',
|
||||
'category' => 'Música',
|
||||
'subtitle' => 'Una noche para cantar y bailar',
|
||||
'description' => 'Evento de prueba para explorar la cartelera de OnTicket.',
|
||||
'location' => 'Teatro Broadway, Rosario',
|
||||
'image' => 'live-music.png',
|
||||
'image' => 'live-music.avif',
|
||||
'weeks_from_now' => [2],
|
||||
'time_start' => '21:00: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',
|
||||
'slug' => 'prueba-festival-de-sabores',
|
||||
'category' => 'Otros',
|
||||
'subtitle' => 'Gastronomía y música para toda la familia',
|
||||
'description' => 'Evento de prueba con dos jornadas disponibles.',
|
||||
'location' => 'Parque de España, Rosario',
|
||||
'image' => 'food-festival.png',
|
||||
'image' => 'food-festival.avif',
|
||||
'weeks_from_now' => [3, 3],
|
||||
'day_offsets' => [0, 1],
|
||||
'time_start' => '12: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',
|
||||
'slug' => 'prueba-stand-up-en-el-centro',
|
||||
'category' => 'Teatro',
|
||||
'subtitle' => 'Humor para compartir',
|
||||
'description' => 'Evento de prueba para visualizar distintas propuestas.',
|
||||
'location' => 'Centro Cultural La Comedia, Rosario',
|
||||
'image' => 'stand-up.png',
|
||||
'image' => 'stand-up.avif',
|
||||
'weeks_from_now' => [4],
|
||||
'time_start' => '20:30: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 {
|
||||
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) {
|
||||
$event = $tenant->events()->firstOrCreate(
|
||||
['title' => $definition['title']],
|
||||
[
|
||||
'slug' => $definition['slug'],
|
||||
'subtitle' => $definition['subtitle'],
|
||||
'description' => $definition['description'],
|
||||
'location' => $definition['location'],
|
||||
@@ -68,41 +149,88 @@ class TestEventsSeeder extends Seeder
|
||||
],
|
||||
);
|
||||
|
||||
if ($event->slug !== $definition['slug']) {
|
||||
$event->update(['slug' => $definition['slug']]);
|
||||
}
|
||||
|
||||
if ($event->event_category_id === null) {
|
||||
$event->update(['event_category_id' => $tenant->eventCategories()
|
||||
->where('nombre', $definition['category'])->value('id')]);
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw new RuntimeException("Image not found at path: {$path}");
|
||||
}
|
||||
|
||||
$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',
|
||||
);
|
||||
$event->update(['attachment_id' => $attachment->id]);
|
||||
}
|
||||
|
||||
if ($event->dates()->exists()) {
|
||||
continue;
|
||||
if (! $event->dates()->exists()) {
|
||||
foreach ($definition['weeks_from_now'] as $index => $weeks) {
|
||||
$date = now()->startOfDay()->addWeeks($weeks)
|
||||
->addDays($definition['day_offsets'][$index] ?? 0);
|
||||
|
||||
$event->dates()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'date' => $date->toDateString(),
|
||||
'time_start' => $definition['time_start'],
|
||||
'time_end' => $definition['time_end'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($definition['weeks_from_now'] as $index => $weeks) {
|
||||
$date = now()->startOfDay()->addWeeks($weeks)
|
||||
->addDays($definition['day_offsets'][$index] ?? 0);
|
||||
|
||||
$event->dates()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'date' => $date->toDateString(),
|
||||
'time_start' => $definition['time_start'],
|
||||
'time_end' => $definition['time_end'],
|
||||
]);
|
||||
}
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 786 KiB |
368
docs/informe-carga-catalogo-mutual-smep.md
Normal file
368
docs/informe-carga-catalogo-mutual-smep.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Informe de carga del catálogo de Mutual SMEP
|
||||
|
||||
## Resumen ejecutivo
|
||||
|
||||
- Fuente analizada: `C:\Users\ncoronel\Documents\catalogo_smep_limpio`.
|
||||
- Registros de producto: **150**.
|
||||
- Imágenes asociadas: **511**.
|
||||
- Resultado propuesto: **142 ítems de catálogo**.
|
||||
- Registros absorbidos como variantes: **15**.
|
||||
- Stock inicial transitorio: **10 unidades por producto o variante**.
|
||||
- Productos sin descripción de origen: **104**.
|
||||
- Productos con marca no identificable de forma segura: **1**.
|
||||
|
||||
## Criterio de representación en Shopit
|
||||
|
||||
Cada registro se carga bajo el tenant `mutual_smep`. Los productos físicos utilizan `type=standard`, `inventory_policy=tracked`, `has_tickets=false` e inventario inicial de 10 unidades. Las imágenes se convierten en attachments y conservan el orden del JSON.
|
||||
|
||||
Un registro marcado como **Producto simple** genera un `catalog_item` con inventario directo. Un registro marcado como **Variante** se integra en el ítem indicado y genera una variante con precio, inventario e imágenes propios. Los datos técnicos detectados en productos simples permanecen en el nombre o la descripción: no se transforman en selectores cuando el comprador no tiene una alternativa real para elegir.
|
||||
|
||||
Los precios se toman literalmente del JSON. No se inventan SKU, códigos de barras, costos ni stock real. El stock 10 es deliberadamente provisional.
|
||||
|
||||
Para los ítems agrupados, el precio base del `catalog_item` será el menor precio de sus variantes y cada variante conservará el precio exacto de su registro original. De este modo, la tarjeta del catálogo podrá mostrar el precio inicial sin perder las diferencias entre medidas o configuraciones.
|
||||
|
||||
## Orden recomendado de carga
|
||||
|
||||
1. Confirmar que existan el tenant, las categorías, las subcategorías y los cinco atributos de SMEP.
|
||||
2. Crear o actualizar las marcas identificadas en este informe.
|
||||
3. Crear primero los productos simples, sus inventarios con `real_stock=10` y sus imágenes.
|
||||
4. Crear los productos agrupados, asociar sus atributos y generar una variante por cada registro indicado.
|
||||
5. Crear un inventario independiente con `real_stock=10` para cada variante.
|
||||
6. Subir cada imagen al almacenamiento del tenant y asociarla al producto o variante correspondiente, respetando el orden del JSON.
|
||||
7. Crear el grupo paginado general del catálogo y seleccionar los productos destacados en una operación posterior.
|
||||
|
||||
La importación debe ejecutarse dentro de una transacción para los datos de catálogo. Los archivos subidos deben registrarse para poder eliminarlos si la operación falla, dado que el almacenamiento de objetos no participa de la transacción de base de datos.
|
||||
|
||||
## Marcas que deberían existir para la importación
|
||||
|
||||
Acer, ASUS, Atma, BGH, BLU, Drean, Electrolux, Eskabe, Fire Bird, Florencia, Gafa, Gama, GBS, GIGO, Havit, Inducol, JBL, Jean Cartier, Kavanagh, King Koil, Kohinoor, Lenovo, Liliana, Longvie, Moonki, Motorola, Moulinex, MSI, Nakan, Noblex, Oster, Peabody, Pelopincho, Philco, Philips, Piero, Ross, Samsung, Spar, Stanley, Stark, Suavegom, TCL, Venzo, Whitenblack, Xiaomi, Xtrike Me.
|
||||
|
||||
## Detalle producto por producto
|
||||
|
||||
### Bazar → Termos
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 1 | Termo Stanley 1 L Adventure Go-To con tapón | Producto simple (`termo-stanley-1-l-adventure-go-to-con-tapon`) | Stanley | — | $170.000,00 | 10 | 5 | Color sujeto a disponibilidad |
|
||||
| 2 | Termo Stanley 800 ml Mate System classic | Producto simple (`termo-stanley-800-ml-mate-system-classic`) | Stanley | — | $160.000,00 | 10 | 4 | Color sujeto a disponibilidad |
|
||||
| 3 | Termo Stanley 950 ml clásico con manija | Producto simple (`termo-stanley-950-ml-clasico-con-manija`) | Stanley | — | $155.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
|
||||
### Bicicletas y Aire Libre → Bicicletas Infantiles
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 4 | BICI STARK R16 FLOWERS/PINK NENA C/ACC 6095 | Producto simple (`bici-stark-r16-flowers-pink-nena-c-acc-6095`) | Stark | rodado 16 (dato descriptivo, sin selector) | $320.000,00 | 10 | 1 | El color de los accesorios puede variar |
|
||||
| 5 | BICI STARK R16 TEAM JUNIOR NENE 6064 | Producto simple (`bici-stark-r16-team-junior-nene-6064`) | Stark | rodado 16 (dato descriptivo, sin selector) | $300.000,00 | 10 | 2 | — |
|
||||
|
||||
### Bicicletas y Aire Libre → Bicicletas para Adultos
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 6 | BICICLETA FIRE BIRD R29 FRENIO DISCO | Producto simple (`bicicleta-fire-bird-r29-frenio-disco`) | Fire Bird | rodado 29 (dato descriptivo, sin selector) | $399.000,00 | 10 | 1 | — |
|
||||
| 7 | BICICLETA PLAYERA ROSS FULL R26 | Producto simple (`bicicleta-playera-ross-full-r26`) | Ross | rodado 26 (dato descriptivo, sin selector) | $300.000,00 | 10 | 1 | — |
|
||||
| 8 | BICICLETA VENZO LOKI R29 FD 21 V SHIMANO | Producto simple (`bicicleta-venzo-loki-r29-fd-21-v-shimano`) | Venzo | rodado 29 (dato descriptivo, sin selector) | $660.000,00 | 10 | 1 | — |
|
||||
|
||||
### Bicicletas y Aire Libre → Piletas
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 9 | Pileta Pelopincho 1010 | Producto simple (`pileta-pelopincho-1010`) | Pelopincho | — | $87.000,00 | 10 | 2 | — |
|
||||
| 10 | Pileta Pelopincho 1020 | Producto simple (`pileta-pelopincho-1020`) | Pelopincho | — | $115.000,00 | 10 | 2 | — |
|
||||
| 11 | Pileta Pelopincho 1030 | Producto simple (`pileta-pelopincho-1030`) | Pelopincho | — | $140.000,00 | 10 | 2 | — |
|
||||
| 12 | Pileta Pelopincho 1043 | Producto simple (`pileta-pelopincho-1043`) | Pelopincho | — | $240.000,00 | 10 | 2 | — |
|
||||
| 13 | Pileta Pelopincho 1055 | Producto simple (`pileta-pelopincho-1055`) | Pelopincho | — | $315.000,00 | 10 | 2 | — |
|
||||
|
||||
### Climatización → Aires Acondicionados
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 14 | Split 3300 W TCL TACA-3300FCSA frío/calor | Producto simple (`split-3300-w-tcl-taca-3300fcsa-frio-calor`) | TCL | — | $840.000,00 | 10 | 2 | — |
|
||||
| 15 | Split BGH 5200 W BSH-52WCU frío/calor Silent Air 4300 frigorías | Producto simple (`split-bgh-5200-w-bsh-52wcu-frio-calor-silent-air-4300-frigorias`) | BGH | — | $1.200.000,00 | 10 | 4 | — |
|
||||
|
||||
### Climatización → Calefacción Eléctrica
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 16 | Calefactor de vidrio Whitenblack PVWB-01 Pie/Pared | Producto simple (`calefactor-de-vidrio-whitenblack-pvwb-01-pie-pared`) | Whitenblack | — | $100.000,00 | 10 | 2 | — |
|
||||
| 17 | Calefactor infrarrojo Liliana Calore CI-080 fijo 1400 W | Producto simple (`calefactor-infrarrojo-liliana-calore-ci-080-fijo-1400-w`) | Liliana | — | $70.000,00 | 10 | 3 | — |
|
||||
| 18 | Caloventor Liliana CFH417 Hotwind 2000 W | Producto simple (`caloventor-liliana-cfh417-hotwind-2000-w`) | Liliana | — | $58.000,00 | 10 | 6 | — |
|
||||
| 19 | Caloventor Whitenblack CAWB-02 2000 W doble posición | Producto simple (`caloventor-whitenblack-cawb-02-2000-w-doble-posicion`) | Whitenblack | — | $46.000,00 | 10 | 2 | — |
|
||||
| 20 | Caloventor split Liliana Whitenblack CPWB-01 2000 W | Producto simple (`caloventor-split-liliana-whitenblack-cpwb-01-2000-w`) | Liliana | — | $110.000,00 | 10 | 3 | — |
|
||||
| 21 | Torre Liliana Tropic FTP-530 1500 W | Producto simple (`torre-liliana-tropic-ftp-530-1500-w`) | Liliana | — | $164.000,00 | 10 | 3 | — |
|
||||
|
||||
### Climatización → Calefactores a Gas con Salida
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 22 | Eskabe 3000 cal. S21 Tiro Balanceado sin termostato | Producto simple (`eskabe-3000-cal-s21-tiro-balanceado-sin-termostato`) | Eskabe | — | $430.000,00 | 10 | 3 | — |
|
||||
| 23 | Eskabe 3000 cal. Tiro Balanceado con termostato | Producto simple (`eskabe-3000-cal-tiro-balanceado-con-termostato`) | Eskabe | — | $450.000,00 | 10 | 2 | — |
|
||||
| 24 | Eskabe TT 2000 cal. Tiro Balanceado con termostato | Producto simple (`eskabe-tt-2000-cal-tiro-balanceado-con-termostato`) | Eskabe | — | $450.000,00 | 10 | 5 | — |
|
||||
| 25 | Eskabe TT 3000 cal. Tiro Balanceado con termostato | Producto simple (`eskabe-tt-3000-cal-tiro-balanceado-con-termostato`) | Eskabe | — | $499.000,00 | 10 | 3 | — |
|
||||
| 26 | Longvie EBA2S 2000 cal. Tiro Balanceado | Producto simple (`longvie-eba2s-2000-cal-tiro-balanceado`) | Longvie | — | $280.000,00 | 10 | 3 | — |
|
||||
| 27 | Longvie EBA3S 3000 cal. Tiro Balanceado | Producto simple (`longvie-eba3s-3000-cal-tiro-balanceado`) | Longvie | — | $420.000,00 | 10 | 4 | — |
|
||||
|
||||
### Climatización → Calefactores a Gas sin Salida
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 28 | Eskabe 3000 cal. S21 MX3 sin salida | Producto simple (`eskabe-3000-cal-s21-mx3-sin-salida`) | Eskabe | — | $290.000,00 | 10 | 3 | Color marfil |
|
||||
| 29 | Longvie ECA-3KV 3200 cal. Infr visor grafito sin salida | Producto simple (`longvie-eca-3kv-3200-cal-infr-visor-grafito-sin-salida`) | Longvie | — | $300.000,00 | 10 | 4 | — |
|
||||
|
||||
### Dormitorio y Blanco → Acolchados y Edredones
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 30 | Acolchado 1 1/2 pl Dobby Kavanagh negro | Producto simple (`acolchado-1-1-2-pl-dobby-kavanagh-negro`) | Kavanagh | medida 1 1/2 Plazas (dato descriptivo, sin selector) | $82.000,00 | 10 | 1 | — |
|
||||
| 31 | Acolchado 1 1/2 pl Kavanagh Simil plumón reversible | Variante de **Acolchado Kavanagh Símil Plumón Reversible** (`acolchado-kavanagh-simil-plumon-reversible`) | Kavanagh | medida_cama=1 1/2 Plazas | $86.000,00 | 10 | 1 | — |
|
||||
| 32 | Acolchado 1 1/2 pl Sense Dúo Bitono con corderito JC | Variante de **Acolchado Sense Dúo Bitono con Corderito** (`acolchado-sense-duo-bitono-corderito`) | Jean Cartier | medida_cama=1 1/2 Plazas | $70.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 33 | Acolchado King Kavanagh Simil plumón con 2 fundas almohadón | Producto simple (`acolchado-king-kavanagh-simil-plumon-con-2-fundas-almohadon`) | Kavanagh | medida King (dato descriptivo, sin selector) | $130.000,00 | 10 | 1 | — |
|
||||
| 34 | Acolchado King Kavanagh Simil plumón reversible | Variante de **Acolchado Kavanagh Símil Plumón Reversible** (`acolchado-kavanagh-simil-plumon-reversible`) | Kavanagh | medida_cama=King | $125.000,00 | 10 | 1 | — |
|
||||
| 35 | Acolchado King Sense Dúo Bitono con corderito JC | Variante de **Acolchado Sense Dúo Bitono con Corderito** (`acolchado-sense-duo-bitono-corderito`) | Jean Cartier | medida_cama=King | $100.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 36 | Acolchado Queen Hotel Oxford Platinum 700 H JC | Producto simple (`acolchado-queen-hotel-oxford-platinum-700-h-jc`) | Jean Cartier | medida Queen (dato descriptivo, sin selector) | $100.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 37 | Acolchado Queen simil plumón reversible Kavanagh | Variante de **Acolchado Kavanagh Símil Plumón Reversible** (`acolchado-kavanagh-simil-plumon-reversible`) | Kavanagh | medida_cama=Queen | $100.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 38 | Edredón 1 1/2 pl Lisboa JC | Variante de **Edredón Lisboa Jean Cartier** (`edredon-lisboa-jean-cartier`) | Jean Cartier | medida_cama=1 1/2 Plazas | $90.000,00 | 10 | 1 | — |
|
||||
| 39 | Edredón 1 1/2 pl Londres JC | Producto simple (`edredon-1-1-2-pl-londres-jc`) | Jean Cartier | medida 1 1/2 Plazas (dato descriptivo, sin selector) | $50.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 40 | Edredón 2 1/2 pl Lisboa JC | Variante de **Edredón Lisboa Jean Cartier** (`edredon-lisboa-jean-cartier`) | Jean Cartier | medida_cama=2 1/2 Plazas | $120.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 41 | Kit Acolchado Queen Zúrich + 2 fundas almohadón JC | Producto simple (`kit-acolchado-queen-zurich-2-fundas-almohadon-jc`) | Jean Cartier | medida Queen (dato descriptivo, sin selector) | $80.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 42 | Kit Edredón 2 1/2 pl. Alaska c/corderito JC + 2 fundas almohadones | Variante de **Kit Edredón Alaska con Corderito** (`kit-edredon-alaska-corderito`) | Jean Cartier | medida_cama=2 1/2 Plazas | $130.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 43 | Kit Edredón King Alaska c/corderito + 2 fundas almohadones | Variante de **Kit Edredón Alaska con Corderito** (`kit-edredon-alaska-corderito`) | Pendiente de identificar | medida_cama=King | $160.000,00 | 10 | 1 | Para colchón 2 x 2 |
|
||||
|
||||
### Dormitorio y Blanco → Bases y Sommiers
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 44 | Sommier Inducol 0,80x1,90 | Producto simple (`sommier-inducol-0-80x1-90`) | Inducol | medida 1 Plaza (dato descriptivo, sin selector) | $180.000,00 | 10 | 2 | — |
|
||||
| 45 | Sommier King Koil Elite Contour 140x190 | Producto simple (`sommier-king-koil-elite-contour-140x190`) | King Koil | medida King (dato descriptivo, sin selector) | $260.000,00 | 10 | 3 | Ideal para armar el conjunto con el Colchón Inducol Vinson Espuma. Color sujeto a disponibilidad. |
|
||||
| 46 | Sommier Piero Legrand 140x190x020 | Producto simple (`sommier-piero-legrand-140x190x020`) | Piero | medida 2 Plazas (dato descriptivo, sin selector) | $280.000,00 | 10 | 2 | — |
|
||||
| 47 | Sommier Piero Paraíso 0,90x1,90x0,20 | Producto simple (`sommier-piero-paraiso-0-90x1-90x0-20`) | Piero | medida 1 Plaza (dato descriptivo, sin selector) | $200.000,00 | 10 | 2 | Base de sommier Piero para colchón de 0,90x1,90 |
|
||||
|
||||
### Dormitorio y Blanco → Colchones
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 48 | COLCHON PIERO RESORTE CORONA REAL 0,8X1,90X0,26 | Producto simple (`colchon-piero-resorte-corona-real-0-8x1-90x0-26`) | Piero | medida 1 Plaza (dato descriptivo, sin selector) | $325.000,00 | 10 | 3 | — |
|
||||
| 49 | Colchon Inducol Constanza Espuma Alta Densidad 080x24 | Variante de **Colchón Inducol Constanza** (`colchon-inducol-constanza`) | Inducol | medida_cama=1 Plaza | $280.000,00 | 10 | 2 | — |
|
||||
| 50 | Colchón Espuma Inducol Aurelia 0,80x1,90x0,20 | Producto simple (`colchon-espuma-inducol-aurelia-0-80x1-90x0-20`) | Inducol | medida 1 Plaza (dato descriptivo, sin selector) | $175.000,00 | 10 | 1 | — |
|
||||
| 51 | Colchón Inducol Constanza Espuma Alta Densidad 1,40x1,90x0,24 | Variante de **Colchón Inducol Constanza** (`colchon-inducol-constanza`) | Inducol | medida_cama=2 Plazas | $475.000,00 | 10 | 2 | — |
|
||||
| 52 | Colchón King Koil G22 Espuma Alta Densidad 1,40x1,90x0,24 | Producto simple (`colchon-king-koil-g22-espuma-alta-densidad-1-40x1-90x0-24`) | King Koil | medida King (dato descriptivo, sin selector) | $347.000,00 | 10 | 7 | Viene en bolsa, fácil traslado. El fabricante recomienda esperar 24hs luego de desenrollado para comenzar a usarse. |
|
||||
| 53 | Colchón King Koil resortes Bradley 080x190x026 | Producto simple (`colchon-king-koil-resortes-bradley-080x190x026`) | King Koil | medida King (dato descriptivo, sin selector) | $400.000,00 | 10 | 5 | — |
|
||||
| 54 | Colchón Piero Body Matelasse Espuma Media Densidad 0,90x1,90x0,20 | Producto simple (`colchon-piero-body-matelasse-espuma-media-densidad-0-90x1-90x0-20`) | Piero | medida 1 Plaza (dato descriptivo, sin selector) | $300.000,00 | 10 | 2 | — |
|
||||
| 55 | Colchón Suavegom Espuma Merit Doble Pillow 140x190x029 | Producto simple (`colchon-suavegom-espuma-merit-doble-pillow-140x190x029`) | Suavegom | medida 2 Plazas (dato descriptivo, sin selector) | $650.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
|
||||
### Dormitorio y Blanco → Frazadas y Mantas
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 56 | Frazada 2 1/2 pl Sherpa doble corderito y polar JC | Producto simple (`frazada-2-1-2-pl-sherpa-doble-corderito-y-polar-jc`) | Jean Cartier | medida 2 1/2 Plazas (dato descriptivo, sin selector) | $70.000,00 | 10 | 1 | Color sujeto a disponibilidad |
|
||||
| 57 | Frazada polar 1 1/2 pl Kavanagh Premium Soft | Variante de **Frazada Kavanagh Premium Soft** (`frazada-kavanagh-premium-soft`) | Kavanagh | medida_cama=1 1/2 Plazas | $47.000,00 | 10 | 1 | — |
|
||||
| 58 | Frazada polar King Kavanagh Premium Soft | Variante de **Frazada Kavanagh Premium Soft** (`frazada-kavanagh-premium-soft`) | Kavanagh | medida_cama=King | $72.000,00 | 10 | 1 | Color sujeto a disponibilidad |
|
||||
| 59 | Manta simil piel de conejo con reverso aterciopelado Jean Cartier | Producto simple (`manta-simil-piel-de-conejo-con-reverso-aterciopelado-jean-cartier`) | Jean Cartier | — | $50.000,00 | 10 | 1 | Color sujeto a disponibilidad |
|
||||
|
||||
### Dormitorio y Blanco → Infantil y Cuna
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 60 | Set Cuna de 6 piezas Acolchado + Sabanas Arcoiris multicolor JC | Producto simple (`set-cuna-de-6-piezas-acolchado-sabanas-arcoiris-multicolor-jc`) | Jean Cartier | medida Cuna (dato descriptivo, sin selector) | $50.000,00 | 10 | 1 | — |
|
||||
|
||||
### Electrodomésticos → Café y Desayuno
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 61 | Cafetera Atma CA-8182P digital 1000 W 1,8 L | Producto simple (`cafetera-atma-ca-8182p-digital-1000-w-1-8-l`) | Atma | — | $85.000,00 | 10 | 3 | — |
|
||||
| 62 | Cafetera Express Atma CEAT-5418P 1 Litro | Producto simple (`cafetera-express-atma-ceat-5418p-1-litro`) | Atma | — | $275.000,00 | 10 | 10 | — |
|
||||
| 63 | Cafetera Express Liliana AC-980 3 en 1 | Producto simple (`cafetera-express-liliana-ac-980-3-en-1`) | Liliana | — | $310.000,00 | 10 | 8 | — |
|
||||
| 64 | Cafetera Moulinex mini me Dolce cápsulas | Producto simple (`cafetera-moulinex-mini-me-dolce-capsulas`) | Moulinex | — | $230.000,00 | 10 | 3 | — |
|
||||
| 65 | Pava eléctrica Atma PE-0821AP/NAP 1,7 L | Producto simple (`pava-electrica-atma-pe-0821ap-nap-1-7-l`) | Atma | — | $40.000,00 | 10 | 3 | — |
|
||||
| 66 | Pava eléctrica Atma PED23MP Disney vintage | Producto simple (`pava-electrica-atma-ped23mp-disney-vintage`) | Atma | — | $90.000,00 | 10 | 5 | — |
|
||||
| 67 | Pava eléctrica GIGO G-17898 SD 1,7 L Digital cromada | Producto simple (`pava-electrica-gigo-g-17898-sd-1-7-l-digital-cromada`) | GIGO | — | $76.000,00 | 10 | 6 | — |
|
||||
| 68 | Pava eléctrica Liliana AP-165 matera color negra | Producto simple (`pava-electrica-liliana-ap-165-matera-color-negra`) | Liliana | — | $59.000,00 | 10 | 4 | — |
|
||||
| 69 | Pava eléctrica Liliana AP-200 Infustyle 1,7 L | Producto simple (`pava-electrica-liliana-ap-200-infustyle-1-7-l`) | Liliana | — | $85.000,00 | 10 | 5 | — |
|
||||
| 70 | Tostadora Atma TOAT-21VCP Vintage color crema | Producto simple (`tostadora-atma-toat-21vcp-vintage-color-crema`) | Atma | — | $60.000,00 | 10 | 4 | — |
|
||||
| 71 | Tostadora Oster TR500 Acero Inoxidable | Producto simple (`tostadora-oster-tr500-acero-inoxidable`) | Oster | — | $84.000,00 | 10 | 4 | — |
|
||||
|
||||
### Electrodomésticos → Calefones y Termotanques
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 72 | Calefón Longvie 14 L CN-514 SS-N Enc. Sensor | Producto simple (`calefon-longvie-14-l-cn-514-ss-n-enc-sensor`) | Longvie | — | $690.000,00 | 10 | 1 | — |
|
||||
|
||||
### Electrodomésticos → Cocinas
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 73 | Cocina Florencia 5536-F 56 cm color Blanca | Producto simple (`cocina-florencia-5536-f-56-cm-color-blanca`) | Florencia | — | $620.000,00 | 10 | 4 | Multigas |
|
||||
| 74 | Cocina Longvie 13331BF 56 cm color blanca | Producto simple (`cocina-longvie-13331bf-56-cm-color-blanca`) | Longvie | — | $940.000,00 | 10 | 7 | Multigas |
|
||||
| 75 | Cocina Longvie 13331XF 56cm Acero inoxidable | Producto simple (`cocina-longvie-13331xf-56cm-acero-inoxidable`) | Longvie | — | $999.000,00 | 10 | 3 | — |
|
||||
| 76 | Cocina Longvie 13501BF 56 cm color Blanca | Producto simple (`cocina-longvie-13501bf-56-cm-color-blanca`) | Longvie | — | $1.100.000,00 | 10 | 5 | Multigas |
|
||||
|
||||
### Electrodomésticos → Cuidado Personal
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 77 | Cortabarba Philips BT-7201 | Producto simple (`cortabarba-philips-bt-7201`) | Philips | — | $70.000,00 | 10 | 3 | — |
|
||||
| 78 | Cortacabello Philips QC 5560 8 Posiciones | Producto simple (`cortacabello-philips-qc-5560-8-posiciones`) | Philips | — | $45.000,00 | 10 | 3 | — |
|
||||
| 79 | Secador cabello Atma SP-8970 Classic 3 Velocidades | Producto simple (`secador-cabello-atma-sp-8970-classic-3-velocidades`) | Atma | — | $60.000,00 | 10 | 2 | — |
|
||||
| 80 | Secador cabello Gama 9465 Brillant Blue Titanium | Producto simple (`secador-cabello-gama-9465-brillant-blue-titanium`) | Gama | — | $120.000,00 | 10 | 3 | — |
|
||||
| 81 | Secador cabello Philips BHD-302/10 1600 W | Producto simple (`secador-cabello-philips-bhd-302-10-1600-w`) | Philips | — | $110.000,00 | 10 | 3 | — |
|
||||
|
||||
### Electrodomésticos → Freidoras y Cocción
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 82 | Freidora de aire Peabody PE-AFD420N 4,2 L digital | Producto simple (`freidora-de-aire-peabody-pe-afd420n-4-2-l-digital`) | Peabody | — | $115.000,00 | 10 | 8 | — |
|
||||
| 83 | Freidora de aire Peabody PE-AFG03N 7 L Grill | Producto simple (`freidora-de-aire-peabody-pe-afg03n-7-l-grill`) | Peabody | — | $260.000,00 | 10 | 5 | — |
|
||||
| 84 | Pochoclera Atma PO-AT9801DNP Disney Mickey | Producto simple (`pochoclera-atma-po-at9801dnp-disney-mickey`) | Atma | — | $64.000,00 | 10 | 5 | — |
|
||||
| 85 | Waflera Atma WS-027DRN disney 2 en 1 | Producto simple (`waflera-atma-ws-027drn-disney-2-en-1`) | Atma | — | $64.000,00 | 10 | 10 | — |
|
||||
|
||||
### Electrodomésticos → Heladeras
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 86 | Heladera Gafa HGF 358 AFB 282 LITROS BLANCA | Producto simple (`heladera-gafa-hgf-358-afb-282-litros-blanca`) | Gafa | — | $780.000,00 | 10 | 4 | — |
|
||||
| 87 | Heladera Gafa HGF 388 AFB 374 LITROS BLANCA | Producto simple (`heladera-gafa-hgf-388-afb-374-litros-blanca`) | Gafa | — | $950.000,00 | 10 | 5 | — |
|
||||
| 88 | Heladera Gafa HGF-368AFP 330 LT color plata | Producto simple (`heladera-gafa-hgf-368afp-330-lt-color-plata`) | Gafa | — | $910.000,00 | 10 | 5 | — |
|
||||
| 89 | Heladera No Frost Gafa HGNF333P Inverter 356 L plata | Producto simple (`heladera-no-frost-gafa-hgnf333p-inverter-356-l-plata`) | Gafa | — | $950.000,00 | 10 | 7 | — |
|
||||
|
||||
### Electrodomésticos → Hornos Eléctricos
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 90 | Horno Eléctrico 25L BGH BHE-25M25I Dúo negro | Producto simple (`horno-electrico-25l-bgh-bhe-25m25i-duo-negro`) | BGH | — | $145.000,00 | 10 | 3 | — |
|
||||
| 91 | Horno eléctrico Atma Grill 40 litros c/2 anafes HG-4022API | Producto simple (`horno-electrico-atma-grill-40-litros-c-2-anafes-hg-4022api`) | Atma | — | $250.000,00 | 10 | 6 | — |
|
||||
|
||||
### Electrodomésticos → Lavarropas y Secarropas
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 92 | Lavarropas automático Drean Concept 5.05 V1 5 Kg 500 rpm | Producto simple (`lavarropas-automatico-drean-concept-5-05-v1-5-kg-500-rpm`) | Drean | — | $650.000,00 | 10 | 5 | Carga superior |
|
||||
| 93 | Lavarropas automático Gafa Fuzzy Fit 7 kg 760 rpm color blanco | Producto simple (`lavarropas-automatico-gafa-fuzzy-fit-7-kg-760-rpm-color-blanco`) | Gafa | — | $650.000,00 | 10 | 6 | Carga superior |
|
||||
| 94 | Lavarropas automático Philco PHLF-6510B2 6,5 KG 1000 rpm blanco | Producto simple (`lavarropas-automatico-philco-phlf-6510b2-6-5-kg-1000-rpm-blanco`) | Philco | — | $600.000,00 | 10 | 6 | — |
|
||||
| 95 | Lavarropas automático Samsung WW65 6,5 Kg 1000 rpm blanco | Producto simple (`lavarropas-automatico-samsung-ww65-6-5-kg-1000-rpm-blanco`) | Samsung | — | $920.000,00 | 10 | 5 | Carga frontal |
|
||||
| 96 | Secarropas Kohinoor A-665 acero inoxidable 6,5 Kg | Producto simple (`secarropas-kohinoor-a-665-acero-inoxidable-6-5-kg`) | Kohinoor | — | $300.000,00 | 10 | 3 | — |
|
||||
|
||||
### Electrodomésticos → Limpieza y Planchado
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 97 | Lustraspiradora Liliana espejo LL350 850 W | Producto simple (`lustraspiradora-liliana-espejo-ll350-850-w`) | Liliana | — | $210.000,00 | 10 | 3 | — |
|
||||
| 98 | Plancha vapor Philips GC-1022/40 2000 W | Producto simple (`plancha-vapor-philips-gc-1022-40-2000-w`) | Philips | — | $91.000,00 | 10 | 3 | — |
|
||||
|
||||
### Electrodomésticos → Microondas
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 99 | Microondas BGH 28 L BGH B-228DS20I Plata Digital con grill | Producto simple (`microondas-bgh-28-l-bgh-b-228ds20i-plata-digital-con-grill`) | BGH | — | $350.000,00 | 10 | 3 | — |
|
||||
| 100 | Microondas Samsung MG23 F3K3TAK 23 litros Grill color negro | Producto simple (`microondas-samsung-mg23-f3k3tak-23-litros-grill-color-negro`) | Samsung | — | $335.000,00 | 10 | 7 | — |
|
||||
|
||||
### Electrodomésticos → Preparación de Alimentos
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 101 | Batidora de mano Oster HM 2600 negra | Producto simple (`batidora-de-mano-oster-hm-2600-negra`) | Oster | — | $97.000,00 | 10 | 3 | — |
|
||||
| 102 | Batidora planetaria Atma BPAT21WP | Producto simple (`batidora-planetaria-atma-bpat21wp`) | Atma | — | $225.000,00 | 10 | 6 | — |
|
||||
| 103 | Exprimidor eléctrico Liliana AE-920 Citrijug 40 W | Producto simple (`exprimidor-electrico-liliana-ae-920-citrijug-40-w`) | Liliana | — | $35.000,00 | 10 | 3 | — |
|
||||
| 104 | Juguera Liliana AJ-950 Nutrijug vaso 350 ml 2 velocidades | Producto simple (`juguera-liliana-aj-950-nutrijug-vaso-350-ml-2-velocidades`) | Liliana | — | $120.000,00 | 10 | 3 | — |
|
||||
| 105 | Licuadora Electrolux SBA10 personal 600 ml | Producto simple (`licuadora-electrolux-sba10-personal-600-ml`) | Electrolux | — | $75.000,00 | 10 | 3 | — |
|
||||
| 106 | Mixer Liliana AH-300 450 W + Vaso | Producto simple (`mixer-liliana-ah-300-450-w-vaso`) | Liliana | — | $56.000,00 | 10 | 2 | — |
|
||||
| 107 | Mixer Liliana Rainbow Mix AH-101/2/3 + Vaso medidor | Producto simple (`mixer-liliana-rainbow-mix-ah-101-2-3-vaso-medidor`) | Liliana | — | $67.000,00 | 10 | 2 | — |
|
||||
| 108 | Mixer Philips HR-2531/50 Promix 400 W + Vaso | Producto simple (`mixer-philips-hr-2531-50-promix-400-w-vaso`) | Philips | — | $83.000,00 | 10 | 7 | — |
|
||||
| 109 | Multiprocesadora Liliana AM-700 Simplix 700 W | Producto simple (`multiprocesadora-liliana-am-700-simplix-700-w`) | Liliana | — | $137.000,00 | 10 | 3 | — |
|
||||
| 110 | Picadora Moulinex AD-6011AR 750 W Blanca | Producto simple (`picadora-moulinex-ad-6011ar-750-w-blanca`) | Moulinex | — | $98.000,00 | 10 | 7 | — |
|
||||
| 111 | Yogurtera Atma YM3010P 7 porciones | Producto simple (`yogurtera-atma-ym3010p-7-porciones`) | Atma | — | $65.000,00 | 10 | 5 | — |
|
||||
|
||||
### Electrodomésticos → Purificadores y Extractores de Cocina
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 112 | Purificador Spar Bios 3766-BOO color blanco | Producto simple (`purificador-spar-bios-3766-boo-color-blanco`) | Spar | — | $200.000,00 | 10 | 3 | 1 motor |
|
||||
| 113 | Purificador Tst #360-60 Estratto acero inox. 60 cm | Producto simple (`purificador-tst-360-60-estratto-acero-inox-60-cm`) | Acer | — | $200.000,00 | 10 | 2 | — |
|
||||
|
||||
### Tecnología → Accesorios de Computación
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 114 | Mouse Xtrike Me Backlit | Producto simple (`mouse-xtrike-me-backlit`) | Xtrike Me | — | $12.000,00 | 10 | 1 | — |
|
||||
| 115 | Teclado Xtrike Me Rainbow mecánico | Producto simple (`teclado-xtrike-me-rainbow-mecanico`) | Xtrike Me | — | $40.000,00 | 10 | 1 | — |
|
||||
|
||||
### Tecnología → Audio
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 116 | Auricular BT negro on ear Moonki sound MH-0510BT | Producto simple (`auricular-bt-negro-on-ear-moonki-sound-mh-0510bt`) | Moonki | — | $30.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 117 | Auricular Moonki Earbuds sound MA-TWS66 black | Producto simple (`auricular-moonki-earbuds-sound-ma-tws66-black`) | Moonki | — | $16.000,00 | 10 | 5 | — |
|
||||
| 118 | Auricular on ear Moonki sound MH-0613 | Producto simple (`auricular-on-ear-moonki-sound-mh-0613`) | Moonki | — | $15.000,00 | 10 | 4 | Color sujeto a disponibilidad |
|
||||
| 119 | Bafle Havit Bluetooth SK-816BT | Producto simple (`bafle-havit-bluetooth-sk-816bt`) | Havit | — | $145.000,00 | 10 | 1 | — |
|
||||
| 120 | Parlante JBL GO 4 Bluetooth altavoz ultraportátil | Producto simple (`parlante-jbl-go-4-bluetooth-altavoz-ultraportatil`) | JBL | — | $97.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
|
||||
### Tecnología → Celulares
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 121 | BLU G73 128 GB | Producto simple (`blu-g73-128-gb`) | BLU | almacenamiento 128 GB (dato descriptivo, sin selector) | $225.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 122 | MOTOROLA G15 4/512 GB | Variante de **Motorola G15** (`motorola-g15`) | Motorola | memoria_ram=4 GB; almacenamiento=512 GB | $430.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 123 | Motorola G15 4/256 GB | Variante de **Motorola G15** (`motorola-g15`) | Motorola | memoria_ram=4 GB; almacenamiento=256 GB | $330.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 124 | Motorola G35 4/256 GB | Producto simple (`motorola-g35-4-256-gb`) | Motorola | RAM 4 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $410.000,00 | 10 | 4 | Color sujeto a disponibilidad |
|
||||
| 125 | SAMSUNG A17 4/128 GB | Producto simple (`samsung-a17-4-128-gb`) | Samsung | RAM 4 GB; almacenamiento 128 GB (dato descriptivo, sin selector) | $390.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 126 | SAMSUNG A17 5G 8/256 GB | Producto simple (`samsung-a17-5g-8-256-gb`) | Samsung | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $620.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 127 | SAMSUNG A26 5G 8/256 GB | Producto simple (`samsung-a26-5g-8-256-gb`) | Samsung | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $650.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 128 | Samsung A07 4/128 GB | Producto simple (`samsung-a07-4-128-gb`) | Samsung | RAM 4 GB; almacenamiento 128 GB (dato descriptivo, sin selector) | $310.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 129 | Samsung A36 5G 8/256 GB | Producto simple (`samsung-a36-5g-8-256-gb`) | Samsung | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $780.000,00 | 10 | 5 | Color sujeto a disponibilidad |
|
||||
| 130 | Samsung A56 5G 8/256 GB | Producto simple (`samsung-a56-5g-8-256-gb`) | Samsung | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $920.000,00 | 10 | 3 | — |
|
||||
| 131 | Samsung Galaxy A16 4/128 GB | Producto simple (`samsung-galaxy-a16-4-128-gb`) | Samsung | RAM 4 GB; almacenamiento 128 GB (dato descriptivo, sin selector) | $340.000,00 | 10 | 6 | Color sujeto a disponibilidad |
|
||||
| 132 | XIAOMI POCO C85 8/256 GB | Producto simple (`xiaomi-poco-c85-8-256-gb`) | Xiaomi | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $430.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 133 | XIAOMI POCO M7 8/256 GB | Producto simple (`xiaomi-poco-m7-8-256-gb`) | Xiaomi | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $420.000,00 | 10 | 3 | Color sujeto a disponibilidad |
|
||||
| 134 | Xiaomi Redmi 15C 8/256 GB | Producto simple (`xiaomi-redmi-15c-8-256-gb`) | Xiaomi | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $380.000,00 | 10 | 2 | Color sujeto a disponibilidad |
|
||||
| 135 | Xiaomi Redmi Note 14 Pro 5G 8/256 GB | Producto simple (`xiaomi-redmi-note-14-pro-5g-8-256-gb`) | Xiaomi | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $630.000,00 | 10 | 6 | Color sujeto a disponibilidad |
|
||||
| 136 | Xiaomi Redmi Note 15 8/256 GB | Producto simple (`xiaomi-redmi-note-15-8-256-gb`) | Xiaomi | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $496.000,00 | 10 | 5 | Color sujeto a disponibilidad |
|
||||
|
||||
### Tecnología → Notebooks
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 137 | NOTEBOOK ASUS VIVOBOOK F1504VAP Intel Core 7 512GB SSD 8GB 15.6 Touch WIN 11 | Producto simple (`notebook-asus-vivobook-f1504vap-intel-core-7-512gb-ssd-8gb-15-6-touch-win-11`) | ASUS | almacenamiento 512 GB; RAM 8 GB (dato descriptivo, sin selector) | $1.700.000,00 | 10 | 4 | — |
|
||||
| 138 | Notebook ASUS Vivobook Go 15 E1504GA-WS35 Intel Core i3 N305 8/256 GB Win11 | Producto simple (`notebook-asus-vivobook-go-15-e1504ga-ws35-intel-core-i3-n305-8-256-gb-win11`) | ASUS | RAM 8 GB; almacenamiento 256 GB (dato descriptivo, sin selector) | $1.300.000,00 | 10 | 4 | — |
|
||||
| 139 | Notebook Acer Aspire 7 I5-13420H/512SSD/16GB/15.6/RTX3050 | Producto simple (`notebook-acer-aspire-7-i5-13420h-512ssd-16gb-15-6-rtx3050`) | Acer | almacenamiento 512 GB; RAM 16 GB (dato descriptivo, sin selector) | $2.800.000,00 | 10 | 4 | — |
|
||||
| 140 | Notebook Lenovo IdeaPad Slim 3 AMD Ryzen 5 8/512GB SSD 15.6" Full HD Win11 | Producto simple (`notebook-lenovo-ideapad-slim-3-amd-ryzen-5-8-512gb-ssd-15-6-full-hd-win11`) | Lenovo | RAM 8 GB; almacenamiento 512 GB (dato descriptivo, sin selector) | $1.400.000,00 | 10 | 4 | — |
|
||||
| 141 | Notebook Lenovo Ryzen 7 8840HS 16/512GB 15,6" | Producto simple (`notebook-lenovo-ryzen-7-8840hs-16-512gb-15-6`) | Lenovo | RAM 16 GB; almacenamiento 512 GB (dato descriptivo, sin selector) | $1.700.000,00 | 10 | 5 | — |
|
||||
| 142 | Notebook Lenovo S3 15Q8X10 SNAPDRAGON X 512GB SSD 16GB DDR5 15.3" WIN11 | Producto simple (`notebook-lenovo-s3-15q8x10-snapdragon-x-512gb-ssd-16gb-ddr5-15-3-win11`) | Lenovo | almacenamiento 512 GB; RAM 16 GB (dato descriptivo, sin selector) | $1.500.000,00 | 10 | 7 | — |
|
||||
| 143 | Notebook MSI Katana 15 HX B14WGK-293US I7-14650HX UP TO 5.2GHZ 1TB SSD 16GB DDR5 Geforce RTX 5070 8GB 15.6" QHD 165HZ WIN 11 | Producto simple (`notebook-msi-katana-15-hx-b14wgk-293us-i7-14650hx-up-to-5-2ghz-1tb-ssd-16gb-ddr5-geforce-rtx-5070-8gb-15-6-qhd-165hz-win-11`) | MSI | almacenamiento 1 TB; RAM 16 GB (dato descriptivo, sin selector) | $3.800.000,00 | 10 | 5 | — |
|
||||
|
||||
### Tecnología → Smart TV
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 144 | SMART 32" BGH B-3225S5A ANDROID | Producto simple (`smart-32-bgh-b-3225s5a-android`) | BGH | — | $340.000,00 | 10 | 4 | — |
|
||||
| 145 | SMART TV NOBLEX 50" DR50-X8500 GOOGLE TV | Producto simple (`smart-tv-noblex-50-dr50-x8500-google-tv`) | Noblex | — | $730.000,00 | 10 | 4 | — |
|
||||
| 146 | Smart TV 43" Noblex DR43-X7180 Android | Producto simple (`smart-tv-43-noblex-dr43-x7180-android`) | Noblex | — | $510.000,00 | 10 | 4 | — |
|
||||
| 147 | Smart TV Noblex 32" DK32-X7000 Android | Producto simple (`smart-tv-noblex-32-dk32-x7000-android`) | Noblex | — | $340.000,00 | 10 | 3 | — |
|
||||
|
||||
### Tecnología → Soportes para TV
|
||||
|
||||
| # | Producto de origen | Representación en Shopit | Marca | Atributos/datos | Precio | Stock | Imágenes | Descripción/observación |
|
||||
|---:|---|---|---|---|---:|---:|---:|---|
|
||||
| 148 | Soporte led 26"-60" Nakan SPL-375E extensible y giratorio | Producto simple (`soporte-led-26-60-nakan-spl-375e-extensible-y-giratorio`) | Nakan | — | $55.000,00 | 10 | 3 | — Corrección: el archivo de origen lo ubicó dentro de colchones. |
|
||||
| 149 | Soporte led 32"-55" GBS con inclinacion | Producto simple (`soporte-led-32-55-gbs-con-inclinacion`) | GBS | — | $20.000,00 | 10 | 3 | — |
|
||||
| 150 | Soporte led GBS 32"-55" fijo | Producto simple (`soporte-led-gbs-32-55-fijo`) | GBS | — | $20.000,00 | 10 | 3 | — |
|
||||
|
||||
## Consolidaciones propuestas como variantes
|
||||
|
||||
Se consolidan solamente coincidencias suficientemente claras:
|
||||
|
||||
- Motorola G15: variantes por RAM y almacenamiento.
|
||||
- Acolchado Kavanagh Símil Plumón Reversible: variantes por medida.
|
||||
- Edredón Lisboa Jean Cartier: variantes por medida.
|
||||
- Acolchado Sense Dúo Bitono con Corderito: variantes por medida.
|
||||
- Frazada Kavanagh Premium Soft: variantes por medida.
|
||||
- Kit Edredón Alaska con Corderito: variantes por medida.
|
||||
- Colchón Inducol Constanza: variantes por medida.
|
||||
|
||||
No se consolidan modelos parecidos cuando el nombre no permite asegurar que sean el mismo producto. Esto evita mezclar, por ejemplo, versiones 4G/5G, tecnologías diferentes o modelos visualmente similares.
|
||||
|
||||
## Pendientes antes de una importación definitiva
|
||||
|
||||
- Confirmar las marcas marcadas como pendientes.
|
||||
- Reemplazar el stock provisional de 10 unidades por existencias reales.
|
||||
- Completar las descripciones ausentes o demasiado breves.
|
||||
- Definir SKU y códigos de barras si se integrará con otro sistema.
|
||||
- Confirmar las consolidaciones propuestas antes de convertir registros independientes en variantes.
|
||||
- Corregir definitivamente la clasificación del soporte Nakan que aparece en la carpeta de colchones.
|
||||
229
docs/informe-migracion-catalogo-whatsapp-mutual-smep.md
Normal file
229
docs/informe-migracion-catalogo-whatsapp-mutual-smep.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Informe de migración del catálogo de WhatsApp Business
|
||||
|
||||
## Tienda Mutual SMEP
|
||||
|
||||
**Fecha del informe:** 24 de septiembre de 2026
|
||||
**Estado:** catálogo preparado y revisado para su incorporación a la nueva tienda. La publicación definitiva todavía requiere la validación comercial de SMEP.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo del trabajo
|
||||
|
||||
El objetivo de esta migración fue trasladar el catálogo que Mutual SMEP utilizaba en WhatsApp Business a una tienda en línea propia, conservando la información disponible y reorganizándola para que los clientes puedan encontrar los productos con mayor facilidad.
|
||||
|
||||
Además del traslado de los productos, se preparó la identidad de la tienda, la navegación por categorías, las secciones destacadas y las imágenes principales de la página de inicio.
|
||||
|
||||
## 2. Resumen general
|
||||
|
||||
| Elemento | Resultado de la migración |
|
||||
|---|---:|
|
||||
| Publicaciones revisadas del catálogo anterior | 150 |
|
||||
| Productos que tendrá la nueva tienda | 142 |
|
||||
| Productos agrupados con opciones | 7 |
|
||||
| Categorías principales | 6 |
|
||||
| Subcategorías | 31 |
|
||||
| Marcas identificadas | 47 |
|
||||
| Imágenes de productos incorporadas | 511 |
|
||||
| Secciones de productos destacados | 7 |
|
||||
| Imágenes principales para la portada | 4 |
|
||||
|
||||
La diferencia entre las **150 publicaciones anteriores** y los **142 productos nuevos** no representa una pérdida de mercadería. Algunas publicaciones de WhatsApp correspondían al mismo producto en distintos colores, medidas o capacidades. En la nueva tienda esas publicaciones se reunieron en una sola ficha, donde el cliente podrá elegir la opción que prefiera.
|
||||
|
||||
## 3. Creación de una tienda propia para Mutual SMEP (tenant)
|
||||
|
||||
Se preparó una tienda exclusiva para Mutual SMEP, separada de las demás tiendas que pueda administrar la plataforma. Esto permite que SMEP tenga su propia imagen, productos, categorías, promociones y datos de contacto.
|
||||
|
||||
La tienda quedó identificada como **Tienda Mutual SMEP** y se configuró con:
|
||||
|
||||
- Los colores azul y celeste utilizados por la institución.
|
||||
- Logotipo, encabezado, pie de página e ícono propios.
|
||||
- Domicilio: San Lorenzo 1543, Rosario, Santa Fe.
|
||||
- Teléfono y contacto de WhatsApp: +54 9 341 247-4530.
|
||||
- Enlaces a Instagram y Facebook de Mutual SMEP.
|
||||
- Buscador de productos, acceso a categorías y carrito de compras.
|
||||
- Información de ayuda y preguntas frecuentes adaptadas a la tienda.
|
||||
|
||||
La dirección utilizada durante la preparación es interna y de prueba. Antes de la publicación deberá definirse o confirmarse la dirección pública que utilizarán los clientes.
|
||||
|
||||
## 4. Nueva organización por categorías
|
||||
|
||||
En WhatsApp Business el catálogo se recorría principalmente como una lista de publicaciones. En la nueva tienda, los productos se organizaron en categorías y subcategorías para facilitar la búsqueda.
|
||||
|
||||
### 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
|
||||
|
||||
Esta estructura se armó a partir de los productos reales del catálogo. También se corrigieron ubicaciones poco claras del catálogo anterior; por ejemplo, un soporte para TV que figuraba junto a colchones fue colocado en **Tecnología > Soportes para TV**.
|
||||
|
||||
## 5. Conversión de los productos
|
||||
|
||||
Cada publicación del catálogo anterior fue revisada para definir cómo debía mostrarse en la nueva tienda.
|
||||
|
||||
### Productos simples
|
||||
|
||||
Los artículos que tienen una sola presentación se convirtieron en una ficha individual. En ella se muestran el nombre, el precio, la marca, las imágenes disponibles y la categoría correspondiente.
|
||||
|
||||
### Productos con distintas opciones
|
||||
|
||||
Cuando varias publicaciones representaban el mismo artículo con una diferencia de medida, capacidad o presentación, se reunieron en una única ficha. De esta forma, el cliente no verá productos repetidos y podrá elegir la opción antes de agregar el artículo al carrito.
|
||||
|
||||
Los siete productos agrupados de esta manera son:
|
||||
|
||||
- Acolchado Kavanagh Símil Plumón Reversible: 3 opciones.
|
||||
- Acolchado Sense Dúo Bitono con Corderito: 2 opciones.
|
||||
- Colchón Inducol Constanza: 2 opciones.
|
||||
- Edredón Lisboa Jean Cartier: 2 opciones.
|
||||
- Frazada Kavanagh Premium Soft: 2 opciones.
|
||||
- Kit Edredón Alaska con Corderito: 2 opciones.
|
||||
- Motorola G15: 2 opciones.
|
||||
|
||||
Para facilitar futuras incorporaciones, también quedaron preparadas opciones habituales del rubro, como color, medida, almacenamiento, memoria y rodado. Estas opciones solamente se mostrarán cuando el cliente tenga una elección real para realizar.
|
||||
|
||||
### Información conservada
|
||||
|
||||
- Se mantuvieron los precios informados en el catálogo de origen.
|
||||
- Se utilizaron las marcas que pudieron identificarse con seguridad.
|
||||
- No se inventaron códigos, códigos de barras ni costos internos.
|
||||
- Las fotografías disponibles se asociaron con el producto correspondiente.
|
||||
|
||||
### Imágenes optimizadas
|
||||
|
||||
Se incorporaron **511 imágenes de productos**. Fueron optimizadas para reducir su peso aproximado de 30 MB a 9,2 MB, sin modificar el contenido de las fotografías. Esto ayuda a que la tienda cargue más rápido y consuma menos datos móviles.
|
||||
|
||||
### Existencias iniciales
|
||||
|
||||
Para poder visualizar y probar todos los artículos, se asignaron provisoriamente **10 unidades por producto u opción**, equivalentes a 1.500 unidades en total.
|
||||
|
||||
Esta cifra es solamente demostrativa y **no representa el inventario real de Mutual SMEP**. Antes de habilitar las ventas debe reemplazarse por las existencias verdaderas o definirse otra forma de administrar la disponibilidad.
|
||||
|
||||
## 6. Presentación de la página de inicio
|
||||
|
||||
La nueva portada se organizó en dos tipos de contenido: imágenes institucionales de gran tamaño y filas de productos destacados.
|
||||
|
||||
### Carrusel principal de imágenes (carousel)
|
||||
|
||||
Se prepararon cuatro imágenes panorámicas que se alternan en la parte superior de la tienda:
|
||||
|
||||
1. **Tecnología:** televisores, celulares y audio.
|
||||
2. **Hogar:** heladeras, cafeteras y electrodomésticos.
|
||||
3. **Vida activa:** bicicletas y actividades al aire libre.
|
||||
4. **Descanso:** colchones, camas y ropa de cama.
|
||||
|
||||
Todas respetan una línea visual azul y amarilla vinculada con la identidad de SMEP. También cuentan con espacio para colocar títulos, promociones o botones cuando se definan los mensajes comerciales.
|
||||
|
||||
### Secciones de productos destacados (featured groups)
|
||||
|
||||
La primera de estas secciones es una fila visual de **13 productos destacados**, pensada como el carrusel circular que verá el cliente en la portada.
|
||||
|
||||
Incluye primero los siete productos que permiten elegir variantes y, a continuación, una selección representativa del catálogo:
|
||||
|
||||
- Smart TV Noblex de 50 pulgadas.
|
||||
- Heladera Gafa de 330 litros.
|
||||
- Cafetera express Atma.
|
||||
- Aire acondicionado split TCL de 3300 W.
|
||||
- Bicicleta Venzo Loki rodado 29.
|
||||
- Termo Stanley de 1 litro.
|
||||
|
||||
La intención es mostrar variedad y permitir que el cliente descubra rápidamente productos importantes de distintas áreas de la tienda.
|
||||
|
||||
### Secciones por categoría principal
|
||||
|
||||
También se crearon seis filas adicionales, una por cada categoría principal:
|
||||
|
||||
| Sección | Productos incluidos |
|
||||
|---|---:|
|
||||
| Dormitorio y Blanco | 6 |
|
||||
| Electrodomésticos | 6 |
|
||||
| Climatización | 6 |
|
||||
| Tecnología | 6 |
|
||||
| Bicicletas y Aire Libre | 6 |
|
||||
| Bazar | 3 |
|
||||
|
||||
Estas filas funcionan como una vidriera breve: muestran una selección de cada rubro sin sobrecargar la portada. Los **142 productos** continúan disponibles mediante las categorías, subcategorías y el buscador. Algunos artículos también se repiten en “Productos destacados” de manera intencional.
|
||||
|
||||
## 7. Principales mejoras para los clientes
|
||||
|
||||
Con esta migración, el cliente pasará de recorrer una lista de WhatsApp a contar con una tienda donde podrá:
|
||||
|
||||
- Buscar un producto por su nombre.
|
||||
- Navegar por rubro y subcategoría.
|
||||
- Comparar opciones dentro de una misma ficha.
|
||||
- Consultar varias imágenes antes de decidir.
|
||||
- Identificar productos destacados desde la portada.
|
||||
- Agregar artículos al carrito y modificar cantidades.
|
||||
- Acceder fácilmente a los canales de contacto de SMEP.
|
||||
|
||||
Para los responsables del comercio, la nueva organización facilita la incorporación de productos, el cambio de precios, el control futuro de existencias y la selección de promociones para la portada.
|
||||
|
||||
## 8. Puntos que SMEP debería revisar antes de publicar
|
||||
|
||||
El catálogo está preparado, pero se recomienda que los responsables comerciales confirmen los siguientes puntos:
|
||||
|
||||
1. **Precios:** validar que los importes del catálogo anterior continúen vigentes.
|
||||
2. **Existencias:** reemplazar las 10 unidades demostrativas por cantidades reales o definir la política de disponibilidad.
|
||||
3. **Descripciones:** 104 productos no contaban con una descripción suficiente en el material de origen. Conviene completarlas gradualmente con medidas, funciones, garantía y condiciones de entrega.
|
||||
4. **Marca pendiente:** existe un artículo cuya marca no pudo determinarse con seguridad y requiere revisión.
|
||||
5. **Opciones de productos:** confirmar las medidas, capacidades y demás variantes agrupadas.
|
||||
6. **Orden de los destacados:** revisar si los 13 artículos elegidos son los que SMEP desea impulsar comercialmente.
|
||||
7. **Mensajes de portada:** definir los textos, promociones y llamados a la acción que acompañarán las cuatro imágenes principales.
|
||||
8. **Dirección pública de la tienda:** confirmar el nombre o dominio que utilizarán los clientes.
|
||||
9. **Venta y entrega:** acordar medios de pago, retiro, envío y condiciones comerciales antes del lanzamiento.
|
||||
|
||||
## 9. Estado y seguridad de la migración
|
||||
|
||||
La preparación fue realizada de forma ordenada y reversible. Se verificó que puedan incorporarse y retirarse tanto el catálogo como las secciones de portada sin alterar el funcionamiento general de la tienda.
|
||||
|
||||
También se realizaron pruebas sobre la carga de productos, imágenes, categorías, opciones, existencias y contenidos destacados. El resultado de esas verificaciones fue satisfactorio.
|
||||
|
||||
Hasta el momento, el trabajo quedó **preparado para ser aplicado**, pero no se publicó como tienda definitiva ni se reemplazó el inventario real del comercio.
|
||||
|
||||
## 10. Conclusión
|
||||
|
||||
La migración convierte el catálogo de WhatsApp Business en una tienda organizada, navegable y preparada para crecer. Se conservaron los productos, precios e imágenes disponibles, y se mejoró su presentación mediante categorías, opciones agrupadas, secciones destacadas y una portada visual propia de Mutual SMEP.
|
||||
|
||||
El paso siguiente recomendado es una revisión comercial breve por parte de los responsables de SMEP, especialmente sobre precios, existencias, descripciones y promociones. Una vez confirmados esos puntos, el catálogo estará en condiciones de avanzar hacia su publicación.
|
||||
@@ -120,36 +120,6 @@ function bodyFor(string $method, string $uri): ?array
|
||||
return jsonBody($exact[$key]);
|
||||
}
|
||||
|
||||
if ($key === 'POST api/tenants') {
|
||||
return jsonBody([
|
||||
'client_id' => '{{client_id}}',
|
||||
'codigo' => '{{tenant_code}}',
|
||||
'nombre' => 'Tenant Demo',
|
||||
'dominio' => '{{tenant_domain}}',
|
||||
'site_title' => 'ShopIt Demo',
|
||||
'primary_color' => '#111827',
|
||||
'secondary_color' => '#2563EB',
|
||||
'danger_color' => '#DC2626',
|
||||
'success_color' => '#16A34A',
|
||||
'header_bg_color' => '#FFFFFF',
|
||||
'footer_bg_color' => '#111827',
|
||||
'header_logo' => TINY_PNG,
|
||||
'footer_logo' => TINY_PNG,
|
||||
'search_product_layout' => 'column_with_image',
|
||||
'search_group_layout' => 'paginated',
|
||||
'search_items_per_page' => 12,
|
||||
'display_categories' => true,
|
||||
'display_seach_bar' => true,
|
||||
'display_cart' => true,
|
||||
'cart_editing_policy' => 'full',
|
||||
'website_type_code' => 'shopit',
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($key, ['PUT api/tenants/{tenant}', 'PATCH api/tenants/{tenant}'], true)) {
|
||||
return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_policy' => 'full']);
|
||||
}
|
||||
|
||||
if ($key === 'POST api/storage-test/s3/upload') {
|
||||
return formDataBody(['path' => 'postman/test-file.png', 'expires_in_minutes' => '60'], ['file']);
|
||||
}
|
||||
|
||||
BIN
public/images/tennants/mutual_smep/carousel/01-tecnologia.webp
Normal file
BIN
public/images/tennants/mutual_smep/carousel/01-tecnologia.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
public/images/tennants/mutual_smep/carousel/02-hogar.webp
Normal file
BIN
public/images/tennants/mutual_smep/carousel/02-hogar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
public/images/tennants/mutual_smep/carousel/03-vida-activa.webp
Normal file
BIN
public/images/tennants/mutual_smep/carousel/03-vida-activa.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
BIN
public/images/tennants/mutual_smep/carousel/04-descanso.webp
Normal file
BIN
public/images/tennants/mutual_smep/carousel/04-descanso.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user