Compare commits

...

22 Commits

Author SHA1 Message Date
41c0902b83 feat(migration): optimize product images size for improved loading speed 2026-09-24 14:12:57 -03:00
a2439bf43b optimized storefront page and images 2026-09-24 14:12:49 -03:00
8d5a12f212 feat(migration): add comprehensive migration report for Mutual SMEP WhatsApp catalog to online store 2026-09-24 13:44:21 -03:00
92170b72f2 feat(migration): enhance Mutual SMEP catalog seeding with featured groups and highlighted products 2026-09-24 12:56:23 -03:00
c3def0678f feat(carousel): add migration and test for Mutual SMEP carousel images 2026-09-24 12:41:38 -03:00
74b72a7d4e Add new product images and implement SeedMutualSmepCatalogTest for catalog migration
- Added multiple new product images for smart TVs, audio devices, and accessories in WEBP format.
- Created a new test class SeedMutualSmepCatalogTest to validate the catalog migration process.
- Implemented setup and teardown methods to manage database schema and data during tests.
- Verified the integrity of catalog items, inventories, and associated attributes after migration.
2026-09-24 12:41:24 -03:00
16e2ca679c feat(seeder): implement Mutual SMEP attribute seeder and associated tests 2026-09-24 12:26:05 -03:00
6b0efbc6ee feat(seeder): add Mutual SMEP category seeder and test cases 2026-09-24 12:26:05 -03:00
2db83271fa feat(tenant): provision Mutual SMEP tenant with branding and menu setup 2026-09-24 12:26:05 -03:00
e259bba1d1 test(desfile): cover reservation cancellation 2026-09-24 11:09:43 -03:00
b3cd9f5de5 feat(desfile): cancel entry reservations 2026-09-24 11:09:38 -03:00
64820fe12b feat(desfile): soft delete entry reservations 2026-09-24 11:09:30 -03:00
e72b6cfdde feat(desfile): download reserved ticket PDF 2026-09-24 10:27:15 -03:00
0e870776c4 feat(desfile): export entry reservations 2026-09-24 10:14:56 -03:00
d8d8354070 feat(desfile): list paginated entry reservations 2026-09-24 10:02:26 -03:00
5bb61fc74c fix(reset): clear administrative entry reservations 2026-09-24 09:34:58 -03:00
9e9af70ba8 feat(desfile): add administrative entry reservations 2026-09-24 09:34:50 -03:00
884da2c89a feat(inventory): track administrative entry reservations 2026-09-24 09:34:42 -03:00
3671b95a83 feat(desfile): enhance entry reservation form and service with tenant-specific data 2026-09-23 16:55:35 -03:00
5e78f2bb1e feat(forms): add desfile entry reservation form 2026-09-23 16:28:35 -03:00
5296d595f1 feat(desfile): add entry reservation model 2026-09-23 16:28:24 -03:00
9901d449e1 feat(migration): add ticket reservation menu for admin app 2026-09-23 16:26:20 -03:00
574 changed files with 8086 additions and 16 deletions

View File

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

View File

@@ -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.');
}

View File

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

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\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,
]);

View File

@@ -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,
]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
namespace App\Domains\Ticketing\Desfile\Services;
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.',

View File

@@ -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.");
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('desfile_entry_reservations', function (Blueprint $table): void {
$table->id();
$table->foreignId('variant_id')
->constrained('variantes')
->restrictOnDelete();
$table->timestamp('fecha_reserva');
$table->decimal('importe', 10, 2)->nullable();
$table->string('tipo_pago', 24);
$table->timestamps();
$table->index(['fecha_reserva', 'tipo_pago']);
});
}
public function down(): void
{
Schema::dropIfExists('desfile_entry_reservations');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@
namespace Database\Seeders;
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',

View File

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

View File

@@ -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,
]);
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Enums\FieldType;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class MutualSmepAttributeSeeder extends Seeder
{
private const TENANT_CODE = 'mutual_smep';
/** @var list<array<string, mixed>> */
private const ATTRIBUTES = [
[
'codigo' => 'color',
'nombre' => 'Color',
'metadata_schema' => [
'hex' => ['type' => 'string'],
],
'options' => [
['value' => 'Negro', 'label' => 'Negro', 'metadata' => ['hex' => '#000000']],
['value' => 'Blanco', 'label' => 'Blanco', 'metadata' => ['hex' => '#FFFFFF']],
['value' => 'Gris', 'label' => 'Gris', 'metadata' => ['hex' => '#808080']],
['value' => 'Plata', 'label' => 'Plata', 'metadata' => ['hex' => '#C0C0C0']],
['value' => 'Azul', 'label' => 'Azul', 'metadata' => ['hex' => '#0000FF']],
['value' => 'Rojo', 'label' => 'Rojo', 'metadata' => ['hex' => '#DC3545']],
['value' => 'Rosa', 'label' => 'Rosa', 'metadata' => ['hex' => '#FFC0CB']],
['value' => 'Multicolor', 'label' => 'Multicolor'],
],
],
[
'codigo' => 'medida_cama',
'nombre' => 'Medida',
'options' => [
['value' => 'Cuna', 'label' => 'Cuna'],
['value' => '1 Plaza', 'label' => '1 Plaza'],
['value' => '1 1/2 Plazas', 'label' => '1 1/2 Plazas'],
['value' => '2 Plazas', 'label' => '2 Plazas'],
['value' => '2 1/2 Plazas', 'label' => '2 1/2 Plazas'],
['value' => 'Queen', 'label' => 'Queen'],
['value' => 'King', 'label' => 'King'],
],
],
[
'codigo' => 'almacenamiento',
'nombre' => 'Almacenamiento',
'options' => [
['value' => '128 GB', 'label' => '128 GB'],
['value' => '256 GB', 'label' => '256 GB'],
['value' => '512 GB', 'label' => '512 GB'],
['value' => '1 TB', 'label' => '1 TB'],
],
],
[
'codigo' => 'memoria_ram',
'nombre' => 'Memoria RAM',
'options' => [
['value' => '4 GB', 'label' => '4 GB'],
['value' => '8 GB', 'label' => '8 GB'],
['value' => '16 GB', 'label' => '16 GB'],
],
],
[
'codigo' => 'rodado',
'nombre' => 'Rodado',
'options' => [
['value' => '16', 'label' => '16'],
['value' => '26', 'label' => '26'],
['value' => '29', 'label' => '29'],
],
],
];
public function run(): void
{
if (! Tenant::query()->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
DB::transaction(function (): void {
foreach (self::ATTRIBUTES as $definition) {
$options = $definition['options'];
unset($definition['options']);
$attribute = Attribute::query()->updateOrCreate(
[
'tenant_codigo' => self::TENANT_CODE,
'codigo' => $definition['codigo'],
],
[
...$definition,
'type' => FieldType::Select->value,
'is_required' => false,
'metadata_schema' => $definition['metadata_schema'] ?? null,
],
);
$attribute->options()->delete();
$attribute->options()->createMany(
collect($options)
->values()
->map(fn (array $option, int $index): array => [
...$option,
'sort_order' => $index + 1,
])
->all()
);
}
});
}
public function down(): void
{
Attribute::query()
->where('tenant_codigo', self::TENANT_CODE)
->whereIn('codigo', array_column(self::ATTRIBUTES, 'codigo'))
->delete();
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class MutualSmepCategorySeeder extends Seeder
{
private const TENANT_CODE = 'mutual_smep';
/** @var array<string, list<string>> */
private const CATEGORIES = [
'Dormitorio y Blanco' => [
'Colchones',
'Bases y Sommiers',
'Acolchados y Edredones',
'Frazadas y Mantas',
'Infantil y Cuna',
],
'Electrodomésticos' => [
'Heladeras',
'Lavarropas y Secarropas',
'Cocinas',
'Microondas',
'Hornos Eléctricos',
'Calefones y Termotanques',
'Purificadores y Extractores de Cocina',
'Preparación de Alimentos',
'Café y Desayuno',
'Freidoras y Cocción',
'Cuidado Personal',
'Limpieza y Planchado',
],
'Climatización' => [
'Aires Acondicionados',
'Calefactores a Gas con Salida',
'Calefactores a Gas sin Salida',
'Calefacción Eléctrica',
],
'Tecnología' => [
'Celulares',
'Notebooks',
'Smart TV',
'Audio',
'Accesorios de Computación',
'Soportes para TV',
],
'Bicicletas y Aire Libre' => [
'Bicicletas Infantiles',
'Bicicletas para Adultos',
'Piletas',
],
'Bazar' => [
'Termos',
],
];
public function run(): void
{
if (! Tenant::query()->where('codigo', self::TENANT_CODE)->exists()) {
return;
}
DB::transaction(function (): void {
foreach (self::CATEGORIES as $parentName => $subcategoryNames) {
$parent = Category::query()->updateOrCreate(
[
'tenant_code' => self::TENANT_CODE,
'nombre' => $parentName,
],
[
'categoria_id' => null,
'is_enabled' => true,
],
);
foreach ($subcategoryNames as $subcategoryName) {
Category::query()->updateOrCreate(
[
'tenant_code' => self::TENANT_CODE,
'nombre' => $subcategoryName,
],
[
'categoria_id' => $parent->id,
'is_enabled' => true,
],
);
}
}
});
}
public function down(): void
{
DB::transaction(function (): void {
foreach (self::CATEGORIES as $parentName => $subcategoryNames) {
$parent = Category::query()
->where('tenant_code', self::TENANT_CODE)
->where('nombre', $parentName)
->whereNull('categoria_id')
->first();
if (! $parent instanceof Category) {
continue;
}
Category::query()
->where('tenant_code', self::TENANT_CODE)
->where('categoria_id', $parent->id)
->whereIn('nombre', $subcategoryNames)
->whereDoesntHave('subCategories')
->delete();
if (! $parent->subCategories()->exists()) {
$parent->delete();
}
}
});
}
}

View File

@@ -0,0 +1,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.

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

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