diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index fcc37ae..22bcfe1 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -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'), + ); + } } diff --git a/app/Domains/Sale/Services/AdminAppSaleExcelService.php b/app/Domains/Sale/Services/AdminAppSaleExcelService.php new file mode 100644 index 0000000..ca88bce --- /dev/null +++ b/app/Domains/Sale/Services/AdminAppSaleExcelService.php @@ -0,0 +1,211 @@ + $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 $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 $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', + }; + } +} diff --git a/app/Domains/Sale/documentacion/README.md b/app/Domains/Sale/documentacion/README.md index 3f6c49b..7446341 100644 --- a/app/Domains/Sale/documentacion/README.md +++ b/app/Domains/Sale/documentacion/README.md @@ -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. diff --git a/app/Domains/Sale/routes/adminapp.php b/app/Domains/Sale/routes/adminapp.php index b803f1e..4d0076b 100644 --- a/app/Domains/Sale/routes/adminapp.php +++ b/app/Domains/Sale/routes/adminapp.php @@ -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'); diff --git a/composer.json b/composer.json index 6b11461..78ca5d7 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index f9e8e33..8633ccc 100644 --- a/composer.lock +++ b/composer.lock @@ -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" diff --git a/tests/Unit/Sale/AdminAppSaleExcelServiceTest.php b/tests/Unit/Sale/AdminAppSaleExcelServiceTest.php new file mode 100644 index 0000000..34676b0 --- /dev/null +++ b/tests/Unit/Sale/AdminAppSaleExcelServiceTest.php @@ -0,0 +1,146 @@ +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, + ]); + } +}