Compare commits
7 Commits
63012838a4
...
24983439b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 24983439b1 | |||
| d179a0f74e | |||
| 636e39e98e | |||
| bda94d02af | |||
| bfb16ef60e | |||
| 1b6303237a | |||
| 9da92c880a |
@@ -15,6 +15,7 @@ class TicketFilterFormResource extends JsonResource
|
|||||||
'action' => $this->resource['action'],
|
'action' => $this->resource['action'],
|
||||||
'method' => $this->resource['method'],
|
'method' => $this->resource['method'],
|
||||||
'fields' => $this->resource['fields'],
|
'fields' => $this->resource['fields'],
|
||||||
|
'columns' => $this->resource['columns'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ namespace App\Domains\Forms\Services;
|
|||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
|
|
||||||
class TicketFilterFormService
|
class TicketFilterFormService
|
||||||
{
|
{
|
||||||
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
public function __construct(private readonly TicketFormService $ticketFormService) {}
|
public function __construct(
|
||||||
|
private readonly TicketFormService $ticketFormService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function get(Tenant $tenant): array
|
public function get(Tenant $tenant): array
|
||||||
@@ -28,6 +32,7 @@ class TicketFilterFormService
|
|||||||
'action' => '/api/v1/adminapp/tenant/tickets',
|
'action' => '/api/v1/adminapp/tenant/tickets',
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'fields' => $fields,
|
'fields' => $fields,
|
||||||
|
'columns' => $this->columnService->publicColumns($tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Domains\Ticket\Controllers\AdminApp;
|
namespace App\Domains\Ticket\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Requests\AdminAppTicketExportRequest;
|
||||||
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
|
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
|
||||||
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
|
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketPdfService;
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketService;
|
use App\Domains\Ticket\Services\AdminAppTicketService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class TicketController extends Controller
|
class TicketController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly AdminAppTicketService $ticketService) {}
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketService $ticketService,
|
||||||
|
private readonly AdminAppTicketPdfService $ticketPdfService,
|
||||||
|
private readonly AdminAppTicketExcelService $ticketExcelService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection
|
public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection
|
||||||
{
|
{
|
||||||
@@ -19,4 +28,26 @@ class TicketController extends Controller
|
|||||||
$this->ticketService->search($tenant, $request->validated())
|
$this->ticketService->search($tenant, $request->validated())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function downloadPdf(AdminAppTicketExportRequest $request): Response
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->ticketPdfService->download(
|
||||||
|
$tenant,
|
||||||
|
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
||||||
|
$request->validated('timezone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadExcel(AdminAppTicketExportRequest $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->ticketExcelService->download(
|
||||||
|
$tenant,
|
||||||
|
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
||||||
|
$request->validated('timezone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php
Normal file
17
app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Shared\Rules\ValidTimezone;
|
||||||
|
|
||||||
|
class AdminAppTicketExportRequest extends AdminAppTicketIndexRequest
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::rules(),
|
||||||
|
'timezone' => ['required', 'string', new ValidTimezone],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Ticket\Requests;
|
namespace App\Domains\Ticket\Requests;
|
||||||
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ class AdminAppTicketIndexRequest extends FormRequest
|
|||||||
/** @return array<string, list<string>> */
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$tenant = $this->user()?->tenant()->first();
|
||||||
|
$sortableKeys = $tenant === null
|
||||||
|
? []
|
||||||
|
: app(AdminAppTicketColumnService::class)->sortableKeys($tenant);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
'category' => ['sometimes', 'nullable', 'string', 'max:255'],
|
'category' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
@@ -33,6 +39,8 @@ class AdminAppTicketIndexRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'page' => ['sometimes', 'integer', 'min:1'],
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)],
|
||||||
|
'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Domains\Ticket\Resources\AdminApp;
|
namespace App\Domains\Ticket\Resources\AdminApp;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use App\Domains\Ticket\Resources\TicketResource;
|
use App\Domains\Ticket\Resources\TicketResource;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketRowService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
/** @mixin Ticket */
|
/** @mixin Ticket */
|
||||||
@@ -14,74 +13,13 @@ class AdminAppTicketResource extends TicketResource
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$purchaseItem = $this->sourcePurchaseItem();
|
$rowService = app(AdminAppTicketRowService::class);
|
||||||
|
$details = $rowService->details($this->resource);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...parent::toArray($request),
|
...parent::toArray($request),
|
||||||
'source_purchase_id' => $this->source_purchase_id,
|
...$details,
|
||||||
'order_number' => $this->source_purchase_id,
|
'values' => $rowService->values($this->resource, $details),
|
||||||
'product' => $purchaseItem?->item_nombre
|
|
||||||
?? $this->sourceCatalogItem?->nombre
|
|
||||||
?? $this->name,
|
|
||||||
'amount' => $purchaseItem?->precio_unitario,
|
|
||||||
'client' => $this->sourcePurchase?->nombre_apellido ?? $this->user?->nombre_apellido,
|
|
||||||
'date' => $this->sourcePurchase?->created_at,
|
|
||||||
'status' => $this->status,
|
|
||||||
'scanned_by' => $this->scannerUser?->nombre_apellido,
|
|
||||||
'variant_properties' => $this->variantProperties(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sourcePurchaseItem(): ?PurchaseItem
|
|
||||||
{
|
|
||||||
return $this->sourcePurchase?->items->first(function (PurchaseItem $item): bool {
|
|
||||||
if ($this->source_variant_id !== null) {
|
|
||||||
return $item->source_variant_id === $this->source_variant_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $item->source_catalog_item_id === $this->source_catalog_item_id
|
|
||||||
&& $item->source_variant_id === null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* code: string,
|
|
||||||
* label: string,
|
|
||||||
* values: list<array{value: string, label: string}>
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private function variantProperties(): array
|
|
||||||
{
|
|
||||||
$variant = $this->sourceVariant;
|
|
||||||
|
|
||||||
if ($variant === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$itemAttributes = $variant->definitions
|
|
||||||
->map(fn ($definition) => $definition->itemAttribute)
|
|
||||||
->filter()
|
|
||||||
->merge($variant->catalogItem?->itemAttributes ?? collect())
|
|
||||||
->unique('id')
|
|
||||||
->values();
|
|
||||||
|
|
||||||
return $variant->selectionOptions($itemAttributes)
|
|
||||||
->map(function (array $selection, string $attributeCode) use ($itemAttributes): array {
|
|
||||||
$itemAttribute = $itemAttributes->first(
|
|
||||||
fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo
|
|
||||||
=== $attributeCode,
|
|
||||||
);
|
|
||||||
$values = array_is_list($selection) ? $selection : [$selection];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => $attributeCode,
|
|
||||||
'label' => $itemAttribute?->attribute?->nombre
|
|
||||||
?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode),
|
|
||||||
'values' => array_values($values),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
103
app/Domains/Ticket/Services/AdminAppTicketColumnService.php
Normal file
103
app/Domains/Ticket/Services/AdminAppTicketColumnService.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
|
||||||
|
class AdminAppTicketColumnService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
||||||
|
public function columns(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL
|
||||||
|
? ['order_number', 'category', 'product', 'type', 'amount', 'client', 'ticket', 'date', 'status', 'scanned_by']
|
||||||
|
: ['order_number', 'product', 'amount', 'client', 'ticket', 'date', 'status', 'scanned_by'];
|
||||||
|
|
||||||
|
$columns = array_map(fn (string $key): array => $this->definitions()[$key], $keys);
|
||||||
|
|
||||||
|
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
$columns = array_map(function (array $column): array {
|
||||||
|
if (in_array($column['key'], ['product', 'type'], true)) {
|
||||||
|
$column['sortable'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $columns);
|
||||||
|
} else {
|
||||||
|
$widths = [
|
||||||
|
'order_number' => '11%',
|
||||||
|
'product' => '15%',
|
||||||
|
'amount' => '10%',
|
||||||
|
'client' => '15%',
|
||||||
|
'ticket' => '19%',
|
||||||
|
'date' => '11%',
|
||||||
|
'status' => '8%',
|
||||||
|
'scanned_by' => '11%',
|
||||||
|
];
|
||||||
|
$columns = array_map(function (array $column) use ($widths): array {
|
||||||
|
$column['width'] = $widths[$column['key']];
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */
|
||||||
|
public function publicColumns(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return array_map(function (array $column): array {
|
||||||
|
unset($column['excel_width']);
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $this->columns($tenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function sortableKeys(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (array $column): string => $column['sort_param'],
|
||||||
|
array_filter($this->columns($tenant), fn (array $column): bool => $column['sortable']),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
||||||
|
private function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'order_number' => $this->column('order_number', 'N° de orden', 'order_number', '10.5%', 14),
|
||||||
|
'category' => $this->column('category', 'Categoría', 'text', '11%', 18),
|
||||||
|
'product' => $this->column('product', 'Producto', 'text', '11%', 22),
|
||||||
|
'type' => $this->column('type', 'Tipo', 'text', '8%', 18),
|
||||||
|
'amount' => $this->column('amount', 'Importe', 'currency', '9%', 15),
|
||||||
|
'client' => $this->column('client', 'Cliente', 'text', '13%', 30),
|
||||||
|
'ticket' => $this->column('ticket', 'ID', 'text', '14.5%', 39),
|
||||||
|
'date' => $this->column('date', 'Fecha', 'date', '9.5%', 20),
|
||||||
|
'status' => $this->column('status', 'Estado', 'status', '7%', 13),
|
||||||
|
'scanned_by' => $this->column('scanned_by', 'Escaneado por', 'text', '9%', 28),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int} */
|
||||||
|
private function column(
|
||||||
|
string $key,
|
||||||
|
string $label,
|
||||||
|
string $type,
|
||||||
|
string $width,
|
||||||
|
int $excelWidth,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'key' => $key,
|
||||||
|
'label' => $label,
|
||||||
|
'type' => $type,
|
||||||
|
'sortable' => true,
|
||||||
|
'sort_param' => $key,
|
||||||
|
'width' => $width,
|
||||||
|
'excel_width' => $excelWidth,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Domains/Ticket/Services/AdminAppTicketExcelService.php
Normal file
105
app/Domains/Ticket/Services/AdminAppTicketExcelService.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
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 AdminAppTicketExcelService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketReportService $reportService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param Collection<int, Ticket> $tickets */
|
||||||
|
public function download(Tenant $tenant, Collection $tickets, string $timeZone): StreamedResponse
|
||||||
|
{
|
||||||
|
$generatedAt = now();
|
||||||
|
$rows = $this->reportService->rows($tickets);
|
||||||
|
$columns = $this->columnService->columns($tenant);
|
||||||
|
$spreadsheet = new Spreadsheet;
|
||||||
|
$spreadsheet->getProperties()
|
||||||
|
->setCreator('Shopit')
|
||||||
|
->setTitle('Listado de tickets')
|
||||||
|
->setSubject($tenant->nombre);
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle('Tickets');
|
||||||
|
$sheet->fromArray([array_column($columns, 'label')], null, 'A1');
|
||||||
|
|
||||||
|
foreach ($rows as $index => $ticket) {
|
||||||
|
$row = $index + 2;
|
||||||
|
foreach ($columns as $columnIndex => $column) {
|
||||||
|
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row;
|
||||||
|
$value = $ticket[$column['key']] ?? null;
|
||||||
|
|
||||||
|
if ($column['type'] === 'currency' && $value !== null) {
|
||||||
|
$sheet->setCellValue($coordinate, (float) $value);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($column['type'] === 'date' && $value instanceof CarbonInterface) {
|
||||||
|
$sheet->setCellValue(
|
||||||
|
$coordinate,
|
||||||
|
Date::dateTimeToExcel($value->copy()->timezone($timeZone)),
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet->setCellValueExplicit(
|
||||||
|
$coordinate,
|
||||||
|
$this->reportService->displayValue($value, $column['type'], $timeZone),
|
||||||
|
DataType::TYPE_STRING,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastRow = max(2, $rows->count() + 1);
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex(count($columns));
|
||||||
|
foreach ($columns as $columnIndex => $column) {
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($columnIndex + 1);
|
||||||
|
if ($column['type'] === 'currency') {
|
||||||
|
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
|
||||||
|
->getNumberFormat()->setFormatCode('$ #,##0.00');
|
||||||
|
}
|
||||||
|
if ($column['type'] === 'date') {
|
||||||
|
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
|
||||||
|
->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
|
||||||
|
}
|
||||||
|
$sheet->getColumnDimension($letter)->setWidth($column['excel_width']);
|
||||||
|
}
|
||||||
|
$sheet->getStyle("A1:{$lastColumn}1")->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:{$lastColumn}{$lastRow}");
|
||||||
|
|
||||||
|
$filename = 'tickets_'.$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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Domains/Ticket/Services/AdminAppTicketPdfService.php
Normal file
56
app/Domains/Ticket/Services/AdminAppTicketPdfService.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketPdfService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketReportService $reportService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param Collection<int, Ticket> $tickets */
|
||||||
|
public function download(Tenant $tenant, Collection $tickets, string $timeZone): Response
|
||||||
|
{
|
||||||
|
$generatedAt = now();
|
||||||
|
$columns = $this->columnService->columns($tenant);
|
||||||
|
$rows = $this->reportService->rows($tickets);
|
||||||
|
$pdf = Pdf::loadView('pdf.adminapp.tickets', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'columns' => $columns,
|
||||||
|
'tickets' => $this->reportService->displayRows($rows, $columns, $timeZone),
|
||||||
|
'generatedAt' => $generatedAt,
|
||||||
|
'timeZone' => $timeZone,
|
||||||
|
])->setPaper('a3', 'landscape');
|
||||||
|
|
||||||
|
$this->addPageNumbers($pdf);
|
||||||
|
|
||||||
|
return $pdf->download(
|
||||||
|
'tickets_'.$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(
|
||||||
|
565,
|
||||||
|
805,
|
||||||
|
'Página {PAGE_NUM} de {PAGE_COUNT}',
|
||||||
|
$font,
|
||||||
|
7,
|
||||||
|
[0.48, 0.52, 0.49],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Domains/Ticket/Services/AdminAppTicketReportService.php
Normal file
35
app/Domains/Ticket/Services/AdminAppTicketReportService.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketReportService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdminAppTicketRowService $rowService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function rows(Collection $tickets): Collection
|
||||||
|
{
|
||||||
|
return $this->rowService->rows($tickets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @param list<array<string, mixed>> $columns
|
||||||
|
* @return Collection<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
|
||||||
|
{
|
||||||
|
return $this->rowService->displayRows($rows, $columns, $timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayValue(mixed $value, string $type, string $timeZone): string
|
||||||
|
{
|
||||||
|
return $this->rowService->displayValue($value, $type, $timeZone);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
app/Domains/Ticket/Services/AdminAppTicketRowService.php
Normal file
217
app/Domains/Ticket/Services/AdminAppTicketRowService.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketRowService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
private const CATEGORY_PRESENTATIONS = [
|
||||||
|
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null],
|
||||||
|
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null],
|
||||||
|
'entradas' => ['category' => null, 'product' => 'product', 'type' => null],
|
||||||
|
'comidas' => ['category' => 'Comida', 'product' => 'event_date', 'type' => 'horario'],
|
||||||
|
'comida' => ['category' => null, 'product' => 'event_date', 'type' => 'horario'],
|
||||||
|
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function details(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
$purchaseItem = $this->sourcePurchaseItem($ticket);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'source_purchase_id' => $ticket->source_purchase_id,
|
||||||
|
'order_number' => $ticket->source_purchase_id,
|
||||||
|
'product' => $purchaseItem?->item_nombre
|
||||||
|
?? $ticket->sourceCatalogItem?->nombre
|
||||||
|
?? $ticket->name,
|
||||||
|
'amount' => $purchaseItem?->precio_unitario,
|
||||||
|
'client' => $ticket->sourcePurchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,
|
||||||
|
'date' => $ticket->sourcePurchase?->created_at,
|
||||||
|
'status' => $ticket->status,
|
||||||
|
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
|
||||||
|
'variant_properties' => $this->variantProperties($ticket),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed>|null $details */
|
||||||
|
public function values(Ticket $ticket, ?array $details = null): array
|
||||||
|
{
|
||||||
|
$details ??= $this->details($ticket);
|
||||||
|
$presentation = $this->presentation($ticket, $details);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_number' => $details['order_number'],
|
||||||
|
'category' => $presentation['category'],
|
||||||
|
'product' => $presentation['product'],
|
||||||
|
'type' => $presentation['type'],
|
||||||
|
'amount' => $details['amount'] === null ? null : (float) $details['amount'],
|
||||||
|
'client' => $details['client'] ?? 'Sin nombre',
|
||||||
|
'ticket' => $ticket->ticket,
|
||||||
|
'date' => $details['date'],
|
||||||
|
'status' => $details['status'],
|
||||||
|
'scanned_by' => $details['scanned_by'] ?? '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function rows(Collection $tickets): Collection
|
||||||
|
{
|
||||||
|
return $tickets->values()->map(fn (Ticket $ticket): array => $this->values($ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @param list<array<string, mixed>> $columns
|
||||||
|
* @return Collection<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
|
||||||
|
{
|
||||||
|
return $rows->map(fn (array $row): array => collect($columns)
|
||||||
|
->mapWithKeys(fn (array $column): array => [
|
||||||
|
$column['key'] => $this->displayValue(
|
||||||
|
$row[$column['key']] ?? null,
|
||||||
|
$column['type'],
|
||||||
|
$timeZone,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayValue(mixed $value, string $type, string $timeZone): string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'order_number' => '#'.$value,
|
||||||
|
'currency' => '$'.number_format((float) $value, 2, ',', '.'),
|
||||||
|
'date' => ($value instanceof CarbonInterface ? $value : Carbon::parse((string) $value))
|
||||||
|
->copy()->timezone($timeZone)->format('d/m/Y H:i'),
|
||||||
|
'status' => match ((string) $value) {
|
||||||
|
Ticket::STATUS_USED => 'Usado',
|
||||||
|
Ticket::STATUS_EXPIRED => 'Vencido',
|
||||||
|
default => 'Activo',
|
||||||
|
},
|
||||||
|
default => (string) $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $details */
|
||||||
|
private function presentation(Ticket $ticket, array $details): array
|
||||||
|
{
|
||||||
|
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
|
||||||
|
|
||||||
|
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
return [
|
||||||
|
'category' => $sourceCategory,
|
||||||
|
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
|
||||||
|
'type' => $this->allPropertyLabels($details) ?: '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null;
|
||||||
|
if ($configuration === null) {
|
||||||
|
return [
|
||||||
|
'category' => $sourceCategory,
|
||||||
|
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
|
||||||
|
'type' => $this->allPropertyLabels($details) ?: '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'category' => $configuration['category'] ?? $sourceCategory,
|
||||||
|
'product' => $configuration['product'] === 'product'
|
||||||
|
? (string) ($details['product'] ?: $ticket->name ?: '-')
|
||||||
|
: ($this->propertyLabels($details, $configuration['product']) ?: '-'),
|
||||||
|
'type' => $configuration['type'] === null
|
||||||
|
? '-'
|
||||||
|
: ($this->propertyLabels($details, $configuration['type']) ?: '-'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $details */
|
||||||
|
private function propertyLabels(array $details, string $code): string
|
||||||
|
{
|
||||||
|
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
|
||||||
|
$labels = collect($property['values'] ?? [])->pluck('label')->filter();
|
||||||
|
|
||||||
|
if ($code === 'event_date') {
|
||||||
|
$labels = $labels->map(function (string $label): string {
|
||||||
|
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
||||||
|
|
||||||
|
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels->implode(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $details */
|
||||||
|
private function allPropertyLabels(array $details): string
|
||||||
|
{
|
||||||
|
return collect($details['variant_properties'] ?? [])
|
||||||
|
->flatMap(fn (array $property): array => $property['values'] ?? [])
|
||||||
|
->pluck('label')
|
||||||
|
->filter()
|
||||||
|
->implode(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourcePurchaseItem(Ticket $ticket): ?PurchaseItem
|
||||||
|
{
|
||||||
|
return $ticket->sourcePurchase?->items->first(function (PurchaseItem $item) use ($ticket): bool {
|
||||||
|
if ($ticket->source_variant_id !== null) {
|
||||||
|
return $item->source_variant_id === $ticket->source_variant_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item->source_catalog_item_id === $ticket->source_catalog_item_id
|
||||||
|
&& $item->source_variant_id === null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{code: string, label: string, values: list<array{value: string, label: string}>}> */
|
||||||
|
private function variantProperties(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
$variant = $ticket->sourceVariant;
|
||||||
|
if ($variant === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemAttributes = $variant->definitions
|
||||||
|
->map(fn ($definition) => $definition->itemAttribute)
|
||||||
|
->filter()
|
||||||
|
->merge($variant->catalogItem?->itemAttributes ?? collect())
|
||||||
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $variant->selectionOptions($itemAttributes)
|
||||||
|
->map(function (array $selection, string $attributeCode) use ($itemAttributes): array {
|
||||||
|
$itemAttribute = $itemAttributes->first(
|
||||||
|
fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo
|
||||||
|
=== $attributeCode,
|
||||||
|
);
|
||||||
|
$values = array_is_list($selection) ? $selection : [$selection];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $attributeCode,
|
||||||
|
'label' => $itemAttribute?->attribute?->nombre
|
||||||
|
?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode),
|
||||||
|
'values' => array_values($values),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,39 +2,80 @@
|
|||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class AdminAppTicketService
|
class AdminAppTicketService
|
||||||
{
|
{
|
||||||
|
private const RELATIONS = [
|
||||||
|
...TicketValidityResolver::RELATIONS,
|
||||||
|
...TicketPresentationResolver::RELATIONS,
|
||||||
|
'user',
|
||||||
|
'scannerUser',
|
||||||
|
'sourceCatalogItem.category',
|
||||||
|
'sourcePurchase.items',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
private readonly AdminAppTicketRowService $rowService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
*/
|
*/
|
||||||
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
|
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
|
||||||
{
|
{
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
$query = $this->baseQuery($tenant, $filters);
|
||||||
|
$countQuery = clone $query;
|
||||||
|
|
||||||
$tickets = (clone $query)
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
->with([
|
|
||||||
...TicketValidityResolver::RELATIONS,
|
if (($filters['sort_by'] ?? null) && ! $databaseSorted) {
|
||||||
...TicketPresentationResolver::RELATIONS,
|
$matchingTickets = (clone $query)
|
||||||
'user',
|
->with(self::RELATIONS)
|
||||||
'scannerUser',
|
->get();
|
||||||
'sourceCatalogItem.category',
|
$matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters);
|
||||||
'sourcePurchase.items',
|
$tickets = $this->paginate($matchingTickets, $filters);
|
||||||
])
|
$scannedTickets = $matchingTickets->whereNotNull('used_at')->count();
|
||||||
->orderByDesc('id')
|
} else {
|
||||||
->paginateFromRequest()
|
$tickets = (clone $query)
|
||||||
->withQueryString();
|
->with(self::RELATIONS)
|
||||||
|
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
$scannedTickets = $countQuery->whereNotNull('used_at')->count();
|
||||||
|
}
|
||||||
|
|
||||||
return new AdminAppTicketResult(
|
return new AdminAppTicketResult(
|
||||||
tickets: $tickets,
|
tickets: $tickets,
|
||||||
scannedTickets: (clone $query)->whereNotNull('used_at')->count(),
|
scannedTickets: $scannedTickets,
|
||||||
totalTickets: $tickets->total(),
|
totalTickets: $tickets->total(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
* @return Collection<int, Ticket>
|
||||||
|
*/
|
||||||
|
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
|
{
|
||||||
|
$query = $this->baseQuery($tenant, $filters);
|
||||||
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
|
$tickets = $query
|
||||||
|
->with(self::RELATIONS)
|
||||||
|
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
||||||
* @return Builder<Ticket>
|
* @return Builder<Ticket>
|
||||||
@@ -151,4 +192,147 @@ class AdminAppTicketService
|
|||||||
{
|
{
|
||||||
return mb_strtolower(trim($category));
|
return mb_strtolower(trim($category));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<Ticket> $query
|
||||||
|
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
*/
|
||||||
|
private function applyDatabaseSort(Builder $query, Tenant $tenant, array $filters): bool
|
||||||
|
{
|
||||||
|
$sortBy = (string) ($filters['sort_by'] ?? '');
|
||||||
|
if ($sortBy === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
$sortExpression = match ($sortBy) {
|
||||||
|
'order_number' => 'tickets.source_purchase_id',
|
||||||
|
'ticket' => 'tickets.ticket',
|
||||||
|
'date' => Purchase::query()
|
||||||
|
->select('created_at')
|
||||||
|
->whereColumn('compras.id', 'tickets.source_purchase_id'),
|
||||||
|
'amount' => $this->purchaseItemSortQuery('precio_unitario'),
|
||||||
|
'scanned_by' => User::query()
|
||||||
|
->select('nombre_apellido')
|
||||||
|
->whereColumn('users.id', 'tickets.scanner_user_id'),
|
||||||
|
'product' => $tenant->codigo === 'fiesta_futbol_infantil'
|
||||||
|
? null
|
||||||
|
: $this->purchaseItemSortQuery('item_nombre'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($sortExpression === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<PurchaseItem> */
|
||||||
|
private function purchaseItemSortQuery(string $column): Builder
|
||||||
|
{
|
||||||
|
return PurchaseItem::query()
|
||||||
|
->select($column)
|
||||||
|
->whereColumn('compra_items.compra_id', 'tickets.source_purchase_id')
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->where(function (Builder $variantQuery): void {
|
||||||
|
$variantQuery
|
||||||
|
->whereNotNull('tickets.source_variant_id')
|
||||||
|
->whereColumn('compra_items.source_variant_id', 'tickets.source_variant_id');
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $itemQuery): void {
|
||||||
|
$itemQuery
|
||||||
|
->whereNull('tickets.source_variant_id')
|
||||||
|
->whereNull('compra_items.source_variant_id')
|
||||||
|
->whereColumn('compra_items.source_catalog_item_id', 'tickets.source_catalog_item_id');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
* @return Collection<int, Ticket>
|
||||||
|
*/
|
||||||
|
private function sortTickets(Collection $tickets, Tenant $tenant, array $filters): Collection
|
||||||
|
{
|
||||||
|
$sortBy = (string) ($filters['sort_by'] ?? '');
|
||||||
|
if ($sortBy === '') {
|
||||||
|
return $tickets;
|
||||||
|
}
|
||||||
|
|
||||||
|
$column = collect($this->columnService->columns($tenant))
|
||||||
|
->firstWhere('sort_param', $sortBy);
|
||||||
|
if ($column === null) {
|
||||||
|
return $tickets;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
||||||
|
$values = $tickets->mapWithKeys(fn (Ticket $ticket): array => [
|
||||||
|
$ticket->getKey() => $this->rowService->values($ticket)[$column['key']] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $tickets->sort(function (Ticket $left, Ticket $right) use ($column, $direction, $values): int {
|
||||||
|
$leftValue = $values->get($left->getKey());
|
||||||
|
$rightValue = $values->get($right->getKey());
|
||||||
|
|
||||||
|
if ($leftValue === null || $leftValue === '') {
|
||||||
|
return $rightValue === null || $rightValue === '' ? $right->id <=> $left->id : 1;
|
||||||
|
}
|
||||||
|
if ($rightValue === null || $rightValue === '') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$comparison = $this->compareValues($leftValue, $rightValue, $column['type']);
|
||||||
|
|
||||||
|
return $comparison === 0
|
||||||
|
? $right->id <=> $left->id
|
||||||
|
: $comparison * $direction;
|
||||||
|
})->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareValues(mixed $left, mixed $right, string $type): int
|
||||||
|
{
|
||||||
|
if (in_array($type, ['currency', 'order_number'], true)) {
|
||||||
|
return (float) $left <=> (float) $right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'date') {
|
||||||
|
$leftTimestamp = $left instanceof \DateTimeInterface ? $left->getTimestamp() : strtotime((string) $left);
|
||||||
|
$rightTimestamp = $right instanceof \DateTimeInterface ? $right->getTimestamp() : strtotime((string) $right);
|
||||||
|
|
||||||
|
return $leftTimestamp <=> $rightTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'status') {
|
||||||
|
$left = $this->rowService->displayValue($left, $type, 'UTC');
|
||||||
|
$right = $this->rowService->displayValue($right, $type, 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strnatcasecmp((string) $left, (string) $right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @param array{page?: int, per_page?: int} $filters
|
||||||
|
* @return LengthAwarePaginator<Ticket>
|
||||||
|
*/
|
||||||
|
private function paginate(Collection $tickets, array $filters): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$page = (int) ($filters['page'] ?? 1);
|
||||||
|
$perPage = (int) ($filters['per_page'] ?? 15);
|
||||||
|
|
||||||
|
return (new LengthAwarePaginator(
|
||||||
|
$tickets->forPage($page, $perPage)->values(),
|
||||||
|
$tickets->count(),
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
['path' => request()->url()],
|
||||||
|
))->withQueryString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,10 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
Route::get('tickets', [TicketController::class, 'index'])
|
Route::get('tickets', [TicketController::class, 'index'])
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.index');
|
->name('adminapp.tickets.index');
|
||||||
|
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.tickets.pdf');
|
||||||
|
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.tickets.excel');
|
||||||
});
|
});
|
||||||
|
|||||||
60
resources/views/pdf/adminapp/tickets.blade.php
Normal file
60
resources/views/pdf/adminapp/tickets.blade.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 28px 34px 58px; }
|
||||||
|
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 8px; margin: 0; }
|
||||||
|
h1 { font-size: 21px; margin: 0 0 3px; }
|
||||||
|
.subtitle { color: #66736b; margin: 0 0 15px; }
|
||||||
|
.summary { background: #eef5f1; border-left: 4px solid #198754; margin-bottom: 14px; padding: 8px 11px; }
|
||||||
|
.summary strong { font-size: 12px; }
|
||||||
|
table { border-collapse: collapse; table-layout: fixed; width: 100%; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
th { background: #26382e; color: #fff; font-size: 7px; letter-spacing: .25px; padding: 6px 5px; text-align: left; text-transform: uppercase; }
|
||||||
|
td { border-bottom: 1px solid #dfe7e2; overflow-wrap: break-word; padding: 6px 5px; vertical-align: top; }
|
||||||
|
tbody tr:nth-child(even) { background: #f7f9f8; }
|
||||||
|
.number { text-align: right; }
|
||||||
|
.ticket-id { font-size: 6.8px; word-break: break-all; }
|
||||||
|
.empty { color: #66736b; padding: 24px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Listado de tickets</h1>
|
||||||
|
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
Tickets incluidos: <strong>{{ $tickets->count() }}</strong>
|
||||||
|
·
|
||||||
|
Escaneados: <strong>{{ $tickets->where('status', 'Usado')->count() }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@foreach ($columns as $column)
|
||||||
|
<th
|
||||||
|
style="width: {{ $column['width'] }}"
|
||||||
|
@class(['number' => $column['type'] === 'currency'])
|
||||||
|
>{{ $column['label'] }}</th>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($tickets as $ticket)
|
||||||
|
<tr>
|
||||||
|
@foreach ($columns as $column)
|
||||||
|
<td @class([
|
||||||
|
'number' => $column['type'] === 'currency',
|
||||||
|
'ticket-id' => $column['key'] === 'ticket',
|
||||||
|
])>{{ $ticket[$column['key']] }}</td>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td class="empty" colspan="{{ count($columns) }}">No hay tickets para los criterios seleccionados.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -56,6 +56,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
|
|||||||
'code' => 'tickets_filter',
|
'code' => 'tickets_filter',
|
||||||
'action' => '/api/v1/adminapp/tenant/tickets',
|
'action' => '/api/v1/adminapp/tenant/tickets',
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
|
'columns' => $this->commonColumns(),
|
||||||
'fields' => [
|
'fields' => [
|
||||||
[
|
[
|
||||||
'name' => 'date',
|
'name' => 'date',
|
||||||
@@ -104,7 +105,14 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.fields.3.name', 'date')
|
->assertJsonPath('data.fields.3.name', 'date')
|
||||||
->assertJsonPath('data.fields.3.query_param', 'date')
|
->assertJsonPath('data.fields.3.query_param', 'date')
|
||||||
->assertJsonPath('data.fields.4.name', 'status')
|
->assertJsonPath('data.fields.4.name', 'status')
|
||||||
->assertJsonPath('data.fields.4.query_param', 'status');
|
->assertJsonPath('data.fields.4.query_param', 'status')
|
||||||
|
->assertJsonPath('data.columns.0.key', 'order_number')
|
||||||
|
->assertJsonPath('data.columns.0.sort_param', 'order_number')
|
||||||
|
->assertJsonPath('data.columns.1.key', 'category')
|
||||||
|
->assertJsonPath('data.columns.2.key', 'product')
|
||||||
|
->assertJsonPath('data.columns.2.sortable', false)
|
||||||
|
->assertJsonPath('data.columns.3.key', 'type')
|
||||||
|
->assertJsonPath('data.columns.3.sortable', false);
|
||||||
|
|
||||||
$categories = collect($response->json('data.fields.0.options'));
|
$categories = collect($response->json('data.fields.0.options'));
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
@@ -221,7 +229,10 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
|
|||||||
]))
|
]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonCount(1, 'data')
|
->assertJsonCount(1, 'data')
|
||||||
->assertJsonPath('data.0.id', $ticket->id);
|
->assertJsonPath('data.0.id', $ticket->id)
|
||||||
|
->assertJsonPath('data.0.values.category', 'Comida')
|
||||||
|
->assertJsonPath('data.0.values.product', '12/10')
|
||||||
|
->assertJsonPath('data.0.values.type', 'Cena');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createFiestaFutbolInfantilTenant(): Tenant
|
private function createFiestaFutbolInfantilTenant(): Tenant
|
||||||
@@ -232,6 +243,21 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
|
|||||||
return $tenant->refresh();
|
return $tenant->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function commonColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['key' => 'order_number', 'label' => 'N° de orden', 'type' => 'order_number', 'sortable' => true, 'sort_param' => 'order_number', 'width' => '11%'],
|
||||||
|
['key' => 'product', 'label' => 'Producto', 'type' => 'text', 'sortable' => true, 'sort_param' => 'product', 'width' => '15%'],
|
||||||
|
['key' => 'amount', 'label' => 'Importe', 'type' => 'currency', 'sortable' => true, 'sort_param' => 'amount', 'width' => '10%'],
|
||||||
|
['key' => 'client', 'label' => 'Cliente', 'type' => 'text', 'sortable' => true, 'sort_param' => 'client', 'width' => '15%'],
|
||||||
|
['key' => 'ticket', 'label' => 'ID', 'type' => 'text', 'sortable' => true, 'sort_param' => 'ticket', 'width' => '19%'],
|
||||||
|
['key' => 'date', 'label' => 'Fecha', 'type' => 'date', 'sortable' => true, 'sort_param' => 'date', 'width' => '11%'],
|
||||||
|
['key' => 'status', 'label' => 'Estado', 'type' => 'status', 'sortable' => true, 'sort_param' => 'status', 'width' => '8%'],
|
||||||
|
['key' => 'scanned_by', 'label' => 'Escaneado por', 'type' => 'text', 'sortable' => true, 'sort_param' => 'scanned_by', 'width' => '11%'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function createTenant(string $code): Tenant
|
private function createTenant(string $code): Tenant
|
||||||
{
|
{
|
||||||
$headerLogo = $this->createAttachment("{$code}-header.png");
|
$headerLogo = $this->createAttachment("{$code}-header.png");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Domains\Shared\Enums\FieldType;
|
|||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Models\WebsiteType;
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider;
|
||||||
use Database\Seeders\AttributeSeeder;
|
use Database\Seeders\AttributeSeeder;
|
||||||
use Database\Seeders\AuthorizationSeeder;
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
|
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
|
||||||
@@ -33,6 +34,8 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->app->register(DomPdfServiceProvider::class);
|
||||||
|
|
||||||
$this->seed(AuthorizationSeeder::class);
|
$this->seed(AuthorizationSeeder::class);
|
||||||
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,8 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
->assertJsonCount(1, 'data')
|
->assertJsonCount(1, 'data')
|
||||||
->assertJsonPath('data.0.id', $ticket->id)
|
->assertJsonPath('data.0.id', $ticket->id)
|
||||||
->assertJsonPath('data.0.tenant_code', $tenant->codigo)
|
->assertJsonPath('data.0.tenant_code', $tenant->codigo)
|
||||||
|
->assertJsonPath('data.0.values.ticket', $ticket->ticket)
|
||||||
|
->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE)
|
||||||
->assertJsonPath('meta.total', 1);
|
->assertJsonPath('meta.total', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +96,60 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.0.ticket', $matching->ticket);
|
->assertJsonPath('data.0.ticket', $matching->ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_sorts_the_complete_filtered_result_before_paginating(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
||||||
|
$admin = $this->createAdminAppUser($tenant);
|
||||||
|
$this->grantTicketsMenu($tenant);
|
||||||
|
Sanctum::actingAs($admin);
|
||||||
|
|
||||||
|
foreach (['ticket-c', 'ticket-a', 'ticket-d', 'ticket-b'] as $value) {
|
||||||
|
$this->createTicket($tenant, $admin)->update(['ticket' => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'sort_by' => 'ticket',
|
||||||
|
'sort_direction' => 'asc',
|
||||||
|
'per_page' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?'.$query)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.values.ticket', 'ticket-a')
|
||||||
|
->assertJsonPath('data.1.values.ticket', 'ticket-b')
|
||||||
|
->assertJsonPath('meta.total', 4)
|
||||||
|
->assertJsonPath('meta.last_page', 2);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?'.$query.'&page=2')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.values.ticket', 'ticket-c')
|
||||||
|
->assertJsonPath('data.1.values.ticket', 'ticket-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_sort_columns_not_enabled_for_the_tenant(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('other');
|
||||||
|
$this->grantTicketsMenu($tenant);
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=category&sort_direction=sideways')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['sort_by', 'sort_direction']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_product_and_type_sorting_for_fiesta_futbol_infantil(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
||||||
|
$this->grantTicketsMenu($tenant);
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
foreach (['product', 'type'] as $column) {
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by='.$column)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('sort_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void
|
public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
||||||
@@ -284,6 +343,42 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.0.id', $active->id);
|
->assertJsonPath('data.0.id', $active->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_filtered_ticket_reports(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
||||||
|
$admin = $this->createAdminAppUser($tenant);
|
||||||
|
$this->grantTicketsMenu($tenant);
|
||||||
|
Sanctum::actingAs($admin);
|
||||||
|
|
||||||
|
$ticket = $this->createTicket($tenant, $admin);
|
||||||
|
$query = http_build_query([
|
||||||
|
'q' => (string) $ticket->id,
|
||||||
|
'timezone' => 'America/Argentina/Buenos_Aires',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/api/v1/adminapp/tenant/tickets/pdf?'.$query)
|
||||||
|
->assertOk()
|
||||||
|
->assertHeader('content-type', 'application/pdf');
|
||||||
|
|
||||||
|
$this->get('/api/v1/adminapp/tenant/tickets/excel?'.$query)
|
||||||
|
->assertOk()
|
||||||
|
->assertHeader(
|
||||||
|
'content-type',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ticket_reports_require_a_valid_timezone(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
||||||
|
$this->grantTicketsMenu($tenant);
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets/pdf')->assertUnprocessable();
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets/excel?timezone=Invalid')
|
||||||
|
->assertUnprocessable();
|
||||||
|
}
|
||||||
|
|
||||||
private function createTenant(string $code): Tenant
|
private function createTenant(string $code): Tenant
|
||||||
{
|
{
|
||||||
$headerLogo = $this->createAttachment("{$code}-header.png");
|
$headerLogo = $this->createAttachment("{$code}-header.png");
|
||||||
|
|||||||
151
tests/Unit/Ticket/AdminAppTicketExportServiceTest.php
Normal file
151
tests/Unit/Ticket/AdminAppTicketExportServiceTest.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Ticket;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketPdfService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketReportService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketRowService;
|
||||||
|
use Barryvdh\DomPDF\ServiceProvider;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Mockery;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppTicketExportServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->app->register(ServiceProvider::class);
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow();
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_the_ticket_report_as_an_excel_file(): void
|
||||||
|
{
|
||||||
|
$response = (new AdminAppTicketExcelService($this->reportService(), $this->columnService()))
|
||||||
|
->download($this->tenant(), collect(), 'America/La_Paz');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
$response->headers->get('content-type'),
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'attachment; filename=tickets_fiesta_futbol_infantil_20260824_135300.xlsx',
|
||||||
|
(string) $response->headers->get('content-disposition'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$path = $this->spreadsheetPath($response);
|
||||||
|
try {
|
||||||
|
$sheet = IOFactory::load($path)->getActiveSheet();
|
||||||
|
|
||||||
|
$this->assertSame('Tickets', $sheet->getTitle());
|
||||||
|
$this->assertSame('N° de orden', $sheet->getCell('A1')->getValue());
|
||||||
|
$this->assertSame('#15', $sheet->getCell('A2')->getValue());
|
||||||
|
$this->assertSame('Cena', $sheet->getCell('D2')->getValue());
|
||||||
|
$this->assertSame(8000.0, $sheet->getCell('E2')->getValue());
|
||||||
|
$this->assertSame('00000000-0000-0000-0000-000000000001', $sheet->getCell('G2')->getValue());
|
||||||
|
$this->assertSame('Usado', $sheet->getCell('I2')->getValue());
|
||||||
|
} finally {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_the_ticket_report_as_a_pdf(): void
|
||||||
|
{
|
||||||
|
$response = (new AdminAppTicketPdfService($this->reportService(), $this->columnService()))
|
||||||
|
->download($this->tenant(), collect(), 'America/La_Paz');
|
||||||
|
|
||||||
|
$this->assertSame('application/pdf', $response->headers->get('content-type'));
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'attachment; filename=tickets_fiesta_futbol_infantil_20260824_135300.pdf',
|
||||||
|
(string) $response->headers->get('content-disposition'),
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_pdf_view_contains_the_report_data_and_requested_timezone(): void
|
||||||
|
{
|
||||||
|
$columns = $this->columnService()->columns($this->tenant());
|
||||||
|
$html = view('pdf.adminapp.tickets', [
|
||||||
|
'tenant' => $this->tenant(),
|
||||||
|
'columns' => $columns,
|
||||||
|
'tickets' => (new AdminAppTicketRowService)->displayRows(
|
||||||
|
collect([$this->row()]),
|
||||||
|
$columns,
|
||||||
|
'America/La_Paz',
|
||||||
|
),
|
||||||
|
'generatedAt' => now(),
|
||||||
|
'timeZone' => 'America/La_Paz',
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $html);
|
||||||
|
$this->assertStringContainsString('00000000-0000-0000-0000-000000000001', $html);
|
||||||
|
$this->assertStringContainsString('Cena', $html);
|
||||||
|
$this->assertStringContainsString('24/08/2026 13:53', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportService(): AdminAppTicketReportService
|
||||||
|
{
|
||||||
|
$rowService = Mockery::mock(AdminAppTicketRowService::class)->makePartial();
|
||||||
|
$rowService->shouldReceive('rows')->once()->andReturn(collect([$this->row()]));
|
||||||
|
|
||||||
|
return new AdminAppTicketReportService($rowService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function row(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'order_number' => 15,
|
||||||
|
'category' => 'Comida',
|
||||||
|
'product' => '12/10',
|
||||||
|
'type' => 'Cena',
|
||||||
|
'amount' => 8000.0,
|
||||||
|
'client' => 'Cliente Test',
|
||||||
|
'ticket' => '00000000-0000-0000-0000-000000000001',
|
||||||
|
'date' => now(),
|
||||||
|
'status' => 'used',
|
||||||
|
'scanned_by' => 'Admin Test',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenant(): Tenant
|
||||||
|
{
|
||||||
|
return (new Tenant)->forceFill([
|
||||||
|
'codigo' => 'fiesta_futbol_infantil',
|
||||||
|
'nombre' => 'Fiesta del Fútbol Infantil',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnService(): AdminAppTicketColumnService
|
||||||
|
{
|
||||||
|
return new AdminAppTicketColumnService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function spreadsheetPath(StreamedResponse $response): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
($response->getCallback())();
|
||||||
|
$contents = ob_get_clean();
|
||||||
|
$this->assertIsString($contents);
|
||||||
|
$this->assertStringStartsWith('PK', $contents);
|
||||||
|
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'shopit_ticket_excel_');
|
||||||
|
$this->assertNotFalse($path);
|
||||||
|
file_put_contents($path, $contents);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user