Compare commits

...

13 Commits

Author SHA1 Message Date
bf02d37a33 feat(sales): add Excel exports 2026-08-25 11:37:53 -03:00
78ce263849 feat(notification): update email branding to use website type for welcome and password reset emails 2026-08-25 11:03:06 -03:00
0c8419a5eb feat(notification): implement branding for emails using WebsiteType and update templates 2026-08-25 11:03:06 -03:00
843659583e Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-25 10:49:07 -03:00
c2921166bd feat(catalog): exclude out-of-stock items and update variant visibility logic 2026-08-25 10:49:05 -03:00
0f67e66b6b feat(category-visibility): add success messages for category visibility updates 2026-08-25 10:47:53 -03:00
725abed06a feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-25 10:47:53 -03:00
743939fc63 feat(category-visibility): add success messages for category visibility updates 2026-08-25 10:47:53 -03:00
eb50e65fef feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-25 10:47:53 -03:00
1cf13501d8 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-25 10:47:53 -03:00
2eeef8d392 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-25 10:47:53 -03:00
87a4fa6288 feat(catalog): add group_order field to catalog items and update related logic 2026-08-25 10:45:12 -03:00
07d2129410 feat(catalog): exclude out-of-stock items and update variant visibility logic 2026-08-25 09:32:56 -03:00
31 changed files with 1070 additions and 82 deletions

View File

@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
'type',
'slug',
'nombre',
'group_order',
'descripcion',
'precio',
'inventory_policy',
@@ -47,6 +48,7 @@ class CatalogItem extends Model
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false,
'group_order' => 0,
];
protected function casts(): array
@@ -56,6 +58,7 @@ class CatalogItem extends Model
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
@@ -172,17 +175,29 @@ class CatalogItem extends Model
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder
public function scopeWhereAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereDoesntHave('variants')
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
->whereDoesntHave('variants')
->where(function (Builder $inventoryQuery): void {
$inventoryQuery
->whereNull('catalog_items.inventory_id')
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
});
});
}

View File

@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
),
],
'nombre' => ['required', 'string', 'max:255'],
'group_order' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],

View File

@@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource
$availableStock,
$remainingUserQuota,
),
'variants' => $catalogItem->variants
'variants' => $catalogItem->visibleVariants()
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
@@ -115,7 +115,7 @@ class CatalogFeaturedItemResource extends JsonResource
private function firstImageUrl(CatalogItem $catalogItem): ?string
{
$attachment = $catalogItem->attachments->first()
?? $catalogItem->variants
?? $catalogItem->visibleVariants()
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();

View File

@@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource
public function toArray(Request $request): array
{
$availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first()
?? $this->variants
?? $visibleVariants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
@@ -30,7 +31,7 @@ class CatalogSearchItemResource extends JsonResource
'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock),
'variants' => $this->variants
'variants' => $visibleVariants
->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null

View File

@@ -205,6 +205,13 @@ class CatalogService
]);
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
@@ -231,6 +238,7 @@ class CatalogService
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -280,6 +288,7 @@ class CatalogService
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereAvailable()
->with([
'attachments',
'inventory',

View File

@@ -49,6 +49,7 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereAvailable()
->where(function (Builder $query): void {
$query
->whereDoesntHave('category')
@@ -81,7 +82,9 @@ class FeaturedGroupService
FeaturedGroupSource::Category => $query
->where('catalog_items.category_id', $featuredGroup->category_id)
->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query
->orderBy('catalog_items.group_order')
->orderBy('catalog_items.id'),
};
}

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer;
@@ -70,24 +72,31 @@ class MailService extends BaseIntegrationService
return [];
}
public function send(string|array $recipient, string $subject, string $content): void
{
public function send(
string|array $recipient,
string $subject,
string $content,
Tenant|WebsiteType|null $brand = null,
): void {
if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$brand ??= $this->tenant;
$branding = $this->brandingFor($brand);
$html = Blade::render(
<<<'BLADE'
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
{!! $content !!}
</x-mail.branded-layout>
BLADE,
[
'tenant' => $this->tenant,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
'branding' => $branding,
'headerLogoUrl' => $brand instanceof WebsiteType
? $brand->siteLogo?->getTemporaryUrl(1440)
: $brand->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
'content' => $content,
],
);
@@ -106,6 +115,36 @@ class MailService extends BaseIntegrationService
: (string) config('mail.default');
}
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
private function brandingFor(Tenant|WebsiteType $brand): array
{
if ($brand instanceof WebsiteType) {
$brand->loadMissing(['siteLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#FF7006',
'body_color' => $brand->body_color ?? '#666666',
'background_color' => $brand->background_color ?? '#f8f8f8',
'surface_color' => $brand->surface_color ?? '#ffffff',
'header_bg_color' => $brand->surface_color ?? '#ffffff',
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
];
}
$brand->loadMissing(['headerLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
];
}
public function onSetup(): void
{
if (! $this->mailer || ! $this->clientContext) {

View File

@@ -28,10 +28,21 @@ class TestMail extends Mailable
{
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$branding = [
'name' => $this->tenant->nombre,
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
];
return new Content(
view: 'mail.test',
with: [
'tenant' => $this->tenant,
'branding' => $branding,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
],

View File

@@ -21,15 +21,17 @@ class NotificationMailService
public function sendWelcome(int $userId, string $tenantCode): void
{
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$tenant->nombre}",
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand,
);
}
@@ -75,13 +77,19 @@ class NotificationMailService
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$tenant->nombre}",
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
"Código para recuperar tu contraseña - {$brand->nombre}",
view('mail.notifications.password-reset', [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
}

View File

@@ -23,4 +23,6 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
- La recuperación no se envía si el intento dejó de estar pendiente.
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
- Los correos transaccionales (pago confirmado y tickets disponibles) usan la identidad visual del tenant/evento de la compra.
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.

View File

@@ -9,18 +9,21 @@ use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
protected AdminAppSaleExcelService $saleExcelService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
@@ -94,4 +97,27 @@ class SaleController extends Controller
$request->validated('timezone'),
);
}
public function downloadExcel(AdminAppSalePdfRequest $request): StreamedResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsExcel(
AdminAppSaleModificationPdfRequest $request,
): StreamedResponse {
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$request->validated('timezone'),
);
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
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 AdminAppSaleExcelService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): StreamedResponse
{
$generatedAt = now();
$spreadsheet = $this->spreadsheet($tenant, 'Historial de ventas');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Ventas');
$sheet->fromArray([
'ID',
'Fecha',
'Cliente',
'Cantidad',
'Estado',
'Importe',
'Tickets',
], null, 'A1');
foreach ($sales->values() as $index => $sale) {
$row = $index + 2;
$sheet->setCellValueExplicit("A{$row}", '#'.$sale->id, DataType::TYPE_STRING);
if ($sale->created_at) {
$sheet->setCellValue(
"B{$row}",
Date::dateTimeToExcel($sale->created_at->copy()->timezone($timeZone)),
);
}
$sheet->setCellValueExplicit(
"C{$row}",
$sale->nombre_apellido ?: 'Sin nombre',
DataType::TYPE_STRING,
);
$sheet->setCellValue("D{$row}", (int) ($sale->quantity ?? 0));
$sheet->setCellValue("E{$row}", $this->saleStatus($sale->status));
$sheet->setCellValue("F{$row}", (float) $sale->total);
$sheet->setCellValue("G{$row}", (int) ($sale->tickets_count ?? 0));
}
$lastRow = max(2, $sales->count() + 1);
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
$sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00');
$this->formatSheet($spreadsheet, 'A1:G1', "A1:G{$lastRow}", [
'A' => 13,
'B' => 20,
'C' => 32,
'D' => 12,
'E' => 22,
'F' => 16,
'G' => 12,
]);
return $this->download(
$spreadsheet,
'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(
Tenant $tenant,
Collection $modifications,
string $timeZone,
): StreamedResponse {
$generatedAt = now();
$spreadsheet = $this->spreadsheet($tenant, 'Historial de modificaciones de ventas');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Modificaciones');
$sheet->fromArray([
'Fecha',
'Hora',
'Venta',
'Cliente',
'Campo',
'Valor anterior',
'Valor nuevo',
'Modificado por',
], null, 'A1');
foreach ($modifications->values() as $index => $modification) {
$row = $index + 2;
$changedAt = $modification->changed_at->copy()->timezone($timeZone);
$sale = $modification->trackable;
$sheet->setCellValue("A{$row}", Date::dateTimeToExcel($changedAt));
$sheet->setCellValue("B{$row}", Date::dateTimeToExcel($changedAt));
$sheet->setCellValueExplicit(
"C{$row}",
'#'.$modification->trackable_id,
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"D{$row}",
$sale?->nombre_apellido ?: 'Sin nombre',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"E{$row}",
$modification->attribute,
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"F{$row}",
$modification->old_value ?? '-',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"G{$row}",
$modification->new_value ?? '-',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"H{$row}",
$modification->user?->nombre_apellido ?? 'Sistema',
DataType::TYPE_STRING,
);
}
$lastRow = max(2, $modifications->count() + 1);
$sheet->getStyle("A2:A{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('hh:mm:ss');
$this->formatSheet($spreadsheet, 'A1:H1', "A1:H{$lastRow}", [
'A' => 14,
'B' => 12,
'C' => 13,
'D' => 32,
'E' => 20,
'F' => 24,
'G' => 24,
'H' => 28,
]);
return $this->download(
$spreadsheet,
'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
);
}
private function spreadsheet(Tenant $tenant, string $title): Spreadsheet
{
$spreadsheet = new Spreadsheet;
$spreadsheet->getProperties()
->setCreator('Shopit')
->setTitle($title)
->setSubject($tenant->nombre);
return $spreadsheet;
}
/** @param array<string, int> $widths */
private function formatSheet(
Spreadsheet $spreadsheet,
string $headerRange,
string $filterRange,
array $widths,
): void {
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle($headerRange)->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($filterRange);
foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
}
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
{
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}
private function saleStatus(string $status): string
{
return match ($status) {
Purchase::STATUS_PAID => 'Confirmado',
Purchase::STATUS_CREATED => 'Por completar datos',
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
default => 'Anulado',
};
}
}

View File

@@ -8,6 +8,7 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
- `SaleController`: entrada HTTP del panel.
@@ -16,8 +17,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `GET /sales` y `GET /sales/pdf`.
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
## Dependencias
@@ -25,4 +26,4 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
## Consideraciones
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.

View File

@@ -8,8 +8,10 @@ Route::prefix('v1/adminapp/tenant')
->group(function (): void {
Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/excel', [SaleController::class, 'downloadExcel']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
Route::get('sales/modifications/excel', [SaleController::class, 'downloadModificationsExcel']);
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');

View File

@@ -6,15 +6,16 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"ext-gd": "*",
"php": "^8.3",
"ext-gd": "*",
"barryvdh/laravel-dompdf": "^3.1",
"endroid/qr-code": "^6.1",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.3",
"laravel/socialite": "^5.29",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0"
"league/flysystem-aws-s3-v3": "^3.0",
"phpoffice/phpspreadsheet": "^5.9"
},
"require-dev": {
"fakerphp/faker": "^1.23",

376
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ce185c60c617846be30ae694f0cf6e9c",
"content-hash": "a593ab47d99b233f75851dbb7ea50479",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -417,6 +417,82 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-06-07T11:47:49+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
@@ -2924,6 +3000,191 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.1",
@@ -3686,6 +3947,115 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.9.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.2",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
},
"time": "2026-07-12T19:17:39+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -9838,8 +10208,8 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"ext-gd": "*",
"php": "^8.3"
"php": "^8.3",
"ext-gd": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->unsignedInteger('group_order')->default(0)->after('nombre');
});
$footballOrder = [
1 => [
'slugs' => ['camiseta', 'camiseta-oficial-fnfi'],
'names' => ['Camiseta', 'CAMISETA OFICIAL FNFI'],
],
2 => [
'slugs' => ['alojamiento', 'camping'],
'names' => ['Alojamiento', 'CAMPING'],
],
3 => [
'slugs' => ['abono'],
'names' => ['Abono', 'ABONO'],
],
4 => [
'slugs' => ['comida'],
'names' => ['Comida', 'COMIDA'],
],
];
foreach ($footballOrder as $order => $identifiers) {
DB::table('catalog_items')
->where('tenant_code', 'fiesta_futbol_infantil')
->where(function ($query) use ($identifiers): void {
$query
->whereIn('slug', $identifiers['slugs'])
->orWhereIn('nombre', $identifiers['names']);
})
->update(['group_order' => $order]);
}
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('group_order');
});
}
};

View File

@@ -78,6 +78,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'camiseta',
'nombre' => 'Camiseta',
'group_order' => 1,
'category_id' => $categories['merchandising']->id,
'precio' => 18000,
'attribute_codes' => ['color', 'talle'],
@@ -92,6 +93,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'alojamiento',
'nombre' => 'Alojamiento',
'group_order' => 2,
'category_id' => $categories['alojamientos']->id,
'precio' => 35000,
'attribute_codes' => ['tipo_alojamiento'],
@@ -104,6 +106,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'comida',
'nombre' => 'Comida',
'group_order' => 4,
'category_id' => $categories['comidas']->id,
'precio' => 4000,
'attribute_codes' => ['event_date', 'horario', 'servicio'],
@@ -126,6 +129,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'abono',
'nombre' => 'Abono',
'group_order' => 3,
'category_id' => $categories['entradas']->id,
'precio' => 40000,
'has_tickets' => true,

View File

@@ -1,4 +1,4 @@
@props(['tenant', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
@props(['branding', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
<!DOCTYPE html>
<html lang="es">
@@ -6,7 +6,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>{{ $tenant->nombre }}</title>
<title>{{ $branding['name'] }}</title>
<style>
@media only screen and (max-width: 620px) {
.mail-container { width: 100% !important; }
@@ -14,17 +14,17 @@
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #f1f5f9; color: #334155; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f1f5f9;">
<body style="margin: 0; padding: 0; background-color: {{ $branding['background_color'] }}; color: {{ $branding['body_color'] }}; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: {{ $branding['background_color'] }};">
<tr>
<td align="center" style="padding: 32px 12px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" class="mail-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-top: 4px solid {{ $tenant->primary_color }}; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" class="mail-container" style="width: 600px; max-width: 600px; background-color: {{ $branding['surface_color'] }}; border-top: 4px solid {{ $branding['primary_color'] }}; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);">
<tr>
<td align="center" bgcolor="{{ $tenant->header_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->header_bg_color }};">
<td align="center" bgcolor="{{ $branding['header_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['header_bg_color'] }};">
@if ($headerLogoUrl)
<img src="{{ $headerLogoUrl }}" alt="{{ $tenant->nombre }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
<img src="{{ $headerLogoUrl }}" alt="{{ $branding['name'] }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
@else
<span style="color: {{ $tenant->primary_color }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $tenant->nombre }}</span>
<span style="color: {{ $branding['primary_color'] }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $branding['name'] }}</span>
@endif
</td>
</tr>
@@ -34,11 +34,11 @@
</td>
</tr>
<tr>
<td align="center" bgcolor="{{ $tenant->footer_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->footer_bg_color }}; color: #ffffff; font-size: 12px; line-height: 1.5;">
<td align="center" bgcolor="{{ $branding['footer_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['footer_bg_color'] }}; color: #ffffff; font-size: 12px; line-height: 1.5;">
@if ($footerLogoUrl)
<img src="{{ $footerLogoUrl }}" alt="{{ $tenant->nombre }}" width="140" style="display: block; width: auto; max-width: 140px; max-height: 48px; margin: 0 auto 16px; border: 0;">
<img src="{{ $footerLogoUrl }}" alt="{{ $branding['name'] }}" width="140" style="display: block; width: auto; max-width: 140px; max-height: 48px; margin: 0 auto 16px; border: 0;">
@endif
{{ $footer ?? 'Este correo fue enviado por '.$tenant->nombre.'.' }}
{{ $footer ?? 'Este correo fue enviado por '.$branding['name'].'.' }}
</td>
</tr>
</table>

View File

@@ -1,10 +1,10 @@
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
<h1 style="margin: 0 0 20px; color: {{ $brand->primary_color }};">
Recuperá tu contraseña
</h1>
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
<p>
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $tenant->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $brand->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
</p>
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
<p>
@@ -17,10 +17,10 @@
</p>
@endif
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
<p>Ingresá este código en {{ $brand->nombre }}:</p>
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $tenant->primary_color }}; border-radius: 8px; text-align: center;">
<span style="color: {{ $tenant->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $brand->primary_color }}; border-radius: 8px; text-align: center;">
<span style="color: {{ $brand->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
{{ $attempt->codigo }}
</span>
</div>
@@ -28,7 +28,7 @@
@if($recoveryUrl)
<div style="text-align: center; margin-bottom: 28px;">
<a href="{{ $recoveryUrl }}"
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
</a>
</div>

View File

@@ -1,3 +1,3 @@
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $tenant->nombre }}!</h1>
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
<p>Ya podés ingresar y comenzar a comprar.</p>

View File

@@ -1,4 +1,4 @@
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }}; font-size: 26px; line-height: 1.3;">
Prueba de correo de Shopit
</h1>

View File

@@ -73,19 +73,18 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('0.items.0.descripcion', 'Variants description')
->assertJsonPath('0.items.0.precio', '100.00')
->assertJsonPath('0.items.0.maximum_addable_quantity', 7)
->assertJsonCount(3, '0.items.0.variants')
->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3)
->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id)
->assertJsonPath('0.items.0.variants.2.maximum_addable_quantity', 0)
->assertJsonPath(
'0.items.0.variants.2.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
->assertJsonMissingPath('1.items.data.0.stock_tecnico')
->assertJsonCount(0, '1.items.data.0.variants');
$this->assertNotContains(
$unavailableVariant->id,
collect($response->json('0.items.0.variants'))->pluck('id')->all(),
);
}
public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void
@@ -133,7 +132,7 @@ class CatalogControllerTest extends TestCase
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
}
public function test_it_includes_out_of_stock_items_with_an_unavailable_message(): void
public function test_it_excludes_out_of_stock_items(): void
{
$tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup(
@@ -161,15 +160,10 @@ class CatalogControllerTest extends TestCase
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(2, '0.items')
->assertJsonPath('0.items.0.nombre', 'Unavailable')
->assertJsonPath('0.items.0.maximum_addable_quantity', 0)
->assertJsonPath(
'0.items.0.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('0.items.1.nombre', 'Available')
->assertJsonPath('0.items.1.unavailable_message', null);
->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonPath('0.items.0.unavailable_message', null)
->assertJsonMissing(['nombre' => 'Unavailable']);
}
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
@@ -354,8 +348,10 @@ class CatalogControllerTest extends TestCase
]);
$food = $this->createItem($tenant, 'Hamburger');
$food->update(['group_order' => 2]);
$food->category()->associate($category)->save();
$this->createItem($tenant, 'Parking');
$parking = $this->createItem($tenant, 'Parking');
$parking->update(['group_order' => 1]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
@@ -364,6 +360,9 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('0.items.0.nombre', 'Hamburger')
->assertJsonPath('1.title', 'All products')
->assertJsonCount(2, '1.items.data')
->assertJsonPath('1.items.data.0.nombre', 'Parking')
->assertJsonPath('1.items.data.1.nombre', 'Hamburger')
->assertJsonMissingPath('1.items.data.0.group_order')
->assertJsonPath('1.items.meta.total', 2);
}

View File

@@ -31,6 +31,7 @@ class CatalogItemControllerTest extends TestCase
$response = $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'shirt',
'nombre' => 'Shirt',
'group_order' => 7,
'precio' => 100,
'max_units_per_user' => 4,
'attribute_codes' => [$attribute->codigo],
@@ -47,6 +48,7 @@ class CatalogItemControllerTest extends TestCase
$response
->assertCreated()
->assertJsonPath('data.nombre', 'Shirt')
->assertJsonMissingPath('data.group_order')
->assertJsonPath('data.max_units_per_user', 4)
->assertJsonCount(2, 'data.images')
->assertJsonCount(1, 'data.variants')
@@ -57,6 +59,7 @@ class CatalogItemControllerTest extends TestCase
$this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all());
$this->assertSame(4, $item->max_units_per_user);
$this->assertSame(7, $item->group_order);
$this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all());
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $item->id,

View File

@@ -48,7 +48,7 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
}
public function test_it_lists_unavailable_variants_and_selects_the_first_available_one(): void
public function test_it_omits_unavailable_variants_and_selects_the_first_available_one(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-default');
@@ -69,14 +69,8 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertOk()
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.id', $firstVariant->id)
->assertJsonPath('data.variants.0.maximum_addable_quantity', 0)
->assertJsonPath(
'data.variants.0.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('data.variants.1.id', $secondVariant->id)
->assertJsonCount(1, 'data.variants')
->assertJsonPath('data.variants.0.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6)
->assertJsonMissingPath('data.selected_variant.stock_tecnico')
@@ -84,6 +78,10 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images');
$this->assertNotContains(
$firstVariant->id,
collect($response->json('data.variants'))->pluck('id')->all(),
);
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
@@ -93,6 +91,19 @@ class CatalogItemDetailControllerTest extends TestCase
)->assertNotFound();
}
public function test_it_does_not_return_an_out_of_stock_item(): void
{
$tenant = $this->createTenant('detail-out-of-stock');
$inventory = Inventory::query()->create([
'real_stock' => 5,
'reserved_stock' => 5,
]);
$item = $this->createItem($tenant, 'Sold out item', $inventory);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertNotFound();
}
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
{
Storage::fake('s3');

View File

@@ -62,6 +62,24 @@ class CatalogSearchTest extends TestCase
$this->createCatalogItem($tenant, "Running {$number}");
}
$exactMatch = $this->createCatalogItem($tenant, 'Running');
$outOfStock = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'running-sold-out',
'nombre' => 'Running sold out',
'descripcion' => 'Running sold out description',
'precio' => 100,
]);
$outOfStock->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
]);
CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => Inventory::query()->create(['real_stock' => 2, 'reserved_stock' => 2])->id,
'slug' => 'running-direct-sold-out',
'nombre' => 'Running direct sold out',
'descripcion' => 'Running direct sold out description',
'precio' => 100,
]);
$this->createCatalogItem($tenant, 'Unrelated');
$this->createCatalogItem($otherTenant, 'Running foreign');
@@ -76,6 +94,8 @@ class CatalogSearchTest extends TestCase
->assertJsonPath('meta.total', 6)
->assertJsonCount(4, 'data')
->assertJsonPath('data.0.id', $exactMatch->id)
->assertJsonMissing(['nombre' => 'Running sold out'])
->assertJsonMissing(['nombre' => 'Running direct sold out'])
->assertJsonMissing(['nombre' => 'Running foreign'])
->assertJsonMissing(['nombre' => 'Unrelated']);
}

View File

@@ -29,6 +29,17 @@ class CategoryDetailTest extends TestCase
$this->createCatalogItem($tenant, $category, 'Remera C');
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
$outOfStock = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'category_id' => $category->id,
'slug' => 'remera-agotada',
'nombre' => 'Remera agotada',
'descripcion' => 'Sin stock',
'precio' => 100,
]);
$outOfStock->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
]);
$this->createCatalogItem($tenant, $otherCategory, 'Pantalón');
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
@@ -44,6 +55,7 @@ class CategoryDetailTest extends TestCase
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $firstItem->id)
->assertJsonPath('data.1.id', $secondItem->id)
->assertJsonMissing(['nombre' => 'Remera agotada'])
->assertJsonMissing(['nombre' => 'Pantalón']);
}

View File

@@ -71,19 +71,24 @@ class NotificationMailServiceTest extends TestCase
public function test_it_sends_a_branded_welcome_email(): void
{
$this->useWebsiteTypeBranding();
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('ada@example.com');
$mail->assertHasSubject('Bienvenido a Mail Tenant');
$mail->assertHasSubject('Bienvenido a OnTicket');
return str_contains($mail->render(), 'Ada Lovelace')
&& str_contains($mail->render(), 'Mail Tenant');
&& str_contains($mail->render(), 'OnTicket')
&& ! str_contains($mail->render(), 'Mail Tenant')
&& str_contains($mail->render(), 'border-top: 4px solid #ff7006');
});
}
public function test_it_sends_a_branded_password_reset_email(): void
{
$this->useWebsiteTypeBranding();
$attempt = $this->user->resetPasswordAttempts()->create([
'codigo' => '0123',
]);
@@ -95,13 +100,15 @@ class NotificationMailServiceTest extends TestCase
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('ada@example.com');
$mail->assertHasSubject('Código para recuperar tu contraseña - Mail Tenant');
$mail->assertHasSubject('Código para recuperar tu contraseña - OnTicket');
$rendered = $mail->render();
return str_contains($rendered, '0123')
&& str_contains($rendered, 'Ada Lovelace')
&& str_contains($rendered, 'Mail Tenant')
&& str_contains($rendered, '#112233');
&& str_contains($rendered, 'OnTicket')
&& ! str_contains($rendered, 'Mail Tenant')
&& str_contains($rendered, '#ff7006')
&& ! str_contains($rendered, 'border: 2px solid #112233');
});
}
@@ -137,6 +144,7 @@ class NotificationMailServiceTest extends TestCase
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
{
$this->useWebsiteTypeBranding();
$purchase = Purchase::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'user_id' => $this->user->id,
@@ -180,13 +188,34 @@ class NotificationMailServiceTest extends TestCase
$mail->assertTo('checkout@example.com');
return $mail->subject === "Pago confirmado - Compra #{$purchase->id}"
&& str_contains($mail->render(), 'Total pagado');
&& str_contains($mail->render(), 'Total pagado')
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
});
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('checkout@example.com');
return $mail->subject === 'Tus tickets ya están disponibles'
&& str_contains($mail->render(), 'Entrada general');
&& str_contains($mail->render(), 'Entrada general')
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
});
}
private function useWebsiteTypeBranding(): void
{
$websiteType = WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
'dominio' => 'onticket.local',
'primary_color' => '#ff7006',
'body_color' => '#666666',
'background_color' => '#f8f8f8',
'surface_color' => '#ffffff',
'login_header_footer_color' => '#838383',
]);
$this->tenant->update(['website_type_code' => $websiteType->codigo]);
$this->tenant->unsetRelation('websiteType');
}
}

View File

@@ -70,6 +70,14 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
['abono', 'alojamiento', 'camiseta', 'comida'],
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(),
);
$this->assertSame(
['camiseta', 'alojamiento', 'abono', 'comida'],
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->orderBy('group_order')
->pluck('slug')
->all(),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)

View File

@@ -83,11 +83,13 @@ class CatalogModelsTest extends TestCase
public function test_catalog_item_is_the_catalog_root(): void
{
$item = new CatalogItem;
$this->assertSame(0, $item->group_order);
$item->setRawAttributes([
'category_id' => '10',
'brand_id' => '20',
'inventory_id' => '30',
'type' => CatalogItemType::Standard->value,
'group_order' => '4',
'precio' => '12.50',
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Seat->value,
@@ -100,6 +102,7 @@ class CatalogModelsTest extends TestCase
$this->assertSame(20, $item->brand_id);
$this->assertSame(30, $item->inventory_id);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame(4, $item->group_order);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
$this->assertSame(InventorySubject::Seat, $item->inventory_subject);

View File

@@ -0,0 +1,146 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
class AdminAppSaleExcelServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_it_downloads_filtered_sales_as_an_excel_file(): void
{
$response = app(AdminAppSaleExcelService::class)->downloadSales(
$this->tenant(),
collect([$this->sale()]),
'America/La_Paz',
);
$this->assertExcelResponse(
$response,
'ventas_acme_20260824_135300.xlsx',
function (string $path): void {
$sheet = IOFactory::load($path)->getActiveSheet();
$this->assertSame('Ventas', $sheet->getTitle());
$this->assertSame('Cliente Test', $sheet->getCell('C2')->getValue());
$this->assertSame('Confirmado', $sheet->getCell('E2')->getValue());
$this->assertSame(25000.0, $sheet->getCell('F2')->getValue());
},
);
}
public function test_it_downloads_the_modification_history_as_an_excel_file(): void
{
$sale = $this->sale();
$admin = (new User)->forceFill([
'id' => 10,
'nombre_apellido' => 'Admin Test',
'email' => 'admin@example.test',
]);
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => $sale->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'user',
]);
$modification->setRelation('trackable', $sale);
$modification->setRelation('user', $admin);
$response = app(AdminAppSaleExcelService::class)->downloadModifications(
$this->tenant(),
collect([$modification]),
'America/La_Paz',
);
$this->assertExcelResponse(
$response,
'historial_modificaciones_acme_20260824_135300.xlsx',
function (string $path): void {
$sheet = IOFactory::load($path)->getActiveSheet();
$this->assertSame('Modificaciones', $sheet->getTitle());
$this->assertSame('#15', $sheet->getCell('C2')->getValue());
$this->assertSame('pending_payment', $sheet->getCell('F2')->getValue());
$this->assertSame('paid', $sheet->getCell('G2')->getValue());
$this->assertSame('Admin Test', $sheet->getCell('H2')->getValue());
},
);
}
/** @param callable(string): void $assertSpreadsheet */
private function assertExcelResponse(
StreamedResponse $response,
string $filename,
callable $assertSpreadsheet,
): void {
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
$response->headers->get('content-type'),
);
$this->assertStringContainsString(
"attachment; filename={$filename}",
(string) $response->headers->get('content-disposition'),
);
ob_start();
($response->getCallback())();
$contents = ob_get_clean();
$this->assertIsString($contents);
$this->assertStringStartsWith('PK', $contents);
$path = tempnam(sys_get_temp_dir(), 'shopit_excel_');
$this->assertNotFalse($path);
try {
file_put_contents($path, $contents);
$assertSpreadsheet($path);
} finally {
@unlink($path);
}
}
private function tenant(): Tenant
{
return (new Tenant)->forceFill([
'codigo' => 'acme',
'nombre' => 'Acme Eventos',
]);
}
private function sale(): Purchase
{
return (new Purchase)->forceFill([
'id' => 15,
'created_at' => now(),
'nombre_apellido' => 'Cliente Test',
'quantity' => 2,
'status' => Purchase::STATUS_PAID,
'total' => '25000.00',
'tickets_count' => 2,
]);
}
}