diff --git a/app/Domains/Forms/Controllers/AdminApp/SaleFormController.php b/app/Domains/Forms/Controllers/AdminApp/SaleFormController.php new file mode 100644 index 0000000..f17b0a0 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/SaleFormController.php @@ -0,0 +1,17 @@ +saleFormService->get()); + } +} diff --git a/app/Domains/Forms/Resources/SaleFormResource.php b/app/Domains/Forms/Resources/SaleFormResource.php new file mode 100644 index 0000000..8083a9f --- /dev/null +++ b/app/Domains/Forms/Resources/SaleFormResource.php @@ -0,0 +1,17 @@ + */ + public function toArray(Request $request): array + { + return [ + 'statuses' => $this->resource['statuses'], + ]; + } +} diff --git a/app/Domains/Forms/Services/SaleFormService.php b/app/Domains/Forms/Services/SaleFormService.php new file mode 100644 index 0000000..4066e34 --- /dev/null +++ b/app/Domains/Forms/Services/SaleFormService.php @@ -0,0 +1,31 @@ +} */ + public function get(): array + { + $names = [ + Purchase::STATUS_CREATED => 'Creada', + Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago', + Purchase::STATUS_PAID => 'Confirmada', + Purchase::STATUS_CANCELLED => 'Cancelada', + Purchase::STATUS_REJECTED => 'Rechazada', + Purchase::STATUS_EXPIRED => 'Vencida', + ]; + + return [ + 'statuses' => array_map( + fn (string $status): array => [ + 'code' => $status, + 'name' => $names[$status], + ], + Purchase::statuses(), + ), + ]; + } +} diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index da1e7e9..a25247d 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -1,10 +1,12 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { Route::get('event', EventFormController::class); + Route::get('sale', SaleFormController::class); }); diff --git a/app/Domains/Purchase/Controllers/AdminApp/PurchaseController.php b/app/Domains/Purchase/Controllers/AdminApp/PurchaseController.php deleted file mode 100644 index feb1c35..0000000 --- a/app/Domains/Purchase/Controllers/AdminApp/PurchaseController.php +++ /dev/null @@ -1,35 +0,0 @@ -user()->tenant()->firstOrFail(); - - return PurchaseResource::collection( - $this->purchaseService->purchases($tenant) - )->additional([ - 'confirmed_sales_total' => $this->purchaseService->confirmedSalesTotal($tenant), - ]); - } - - public function modifications(Request $request): AnonymousResourceCollection - { - return PurchaseModificationResource::collection( - $this->purchaseService->modifications( - $request->user()->tenant()->firstOrFail() - ) - ); - } -} diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 273b823..a5dcb11 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -46,6 +46,19 @@ class Purchase extends Model public const STATUS_EXPIRED = 'expired'; + /** @return list */ + public static function statuses(): array + { + return [ + self::STATUS_CREATED, + self::STATUS_PENDING_PAYMENT, + self::STATUS_PAID, + self::STATUS_CANCELLED, + self::STATUS_REJECTED, + self::STATUS_EXPIRED, + ]; + } + protected $table = 'compras'; /** @var array */ diff --git a/app/Domains/Purchase/Services/AdminAppPurchaseService.php b/app/Domains/Purchase/Services/AdminAppPurchaseService.php deleted file mode 100644 index 78bc441..0000000 --- a/app/Domains/Purchase/Services/AdminAppPurchaseService.php +++ /dev/null @@ -1,46 +0,0 @@ -where('tenant_codigo', $tenant->codigo) - ->where('status', Purchase::STATUS_PAID) - ->sum('total'); - - return number_format((float) $total, 2, '.', ''); - } - - /** @return LengthAwarePaginator */ - public function purchases(Tenant $tenant): LengthAwarePaginator - { - return Purchase::query() - ->where('tenant_codigo', $tenant->codigo) - ->with(['items.imageAttachment']) - ->withCount('tickets') - ->latest() - ->paginateFromRequest() - ->withQueryString(); - } - - /** @return LengthAwarePaginator */ - public function modifications(Tenant $tenant): LengthAwarePaginator - { - return ValueChange::query() - ->where('tenant_code', $tenant->codigo) - ->where('trackable_type', (new Purchase)->getMorphClass()) - ->with(['trackable', 'user']) - ->orderByDesc('changed_at') - ->orderByDesc('id') - ->paginateFromRequest() - ->withQueryString(); - } -} diff --git a/app/Domains/Purchase/routes/adminapp.php b/app/Domains/Purchase/routes/adminapp.php deleted file mode 100644 index e560ddc..0000000 --- a/app/Domains/Purchase/routes/adminapp.php +++ /dev/null @@ -1,11 +0,0 @@ -middleware(['auth:sanctum', 'adminapp.tenant']) - ->group(function (): void { - Route::get('purchases', [PurchaseController::class, 'index']); - Route::get('purchases/modifications', [PurchaseController::class, 'modifications']); - }); diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index 314a680..41cc919 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -15,5 +15,3 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']); Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']); }); - -require __DIR__.'/adminapp.php'; diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php new file mode 100644 index 0000000..8e5c302 --- /dev/null +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -0,0 +1,61 @@ +user()->tenant()->firstOrFail(); + + return SaleResource::collection( + $this->saleService->sales($tenant, $request->validated()) + )->additional([ + 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), + ]); + } + + public function modifications(Request $request): AnonymousResourceCollection + { + return SaleModificationResource::collection( + $this->saleService->modifications( + $request->user()->tenant()->firstOrFail() + ) + ); + } + + public function downloadPdf(AdminAppSaleIndexRequest $request): Response + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return $this->salePdfService->downloadSales( + $tenant, + $this->saleService->salesForExport($tenant, $request->validated()), + ); + } + + public function downloadModificationsPdf(Request $request): Response + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return $this->salePdfService->downloadModifications( + $tenant, + $this->saleService->modificationsForExport($tenant), + ); + } +} diff --git a/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php b/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php new file mode 100644 index 0000000..d4a2d32 --- /dev/null +++ b/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php @@ -0,0 +1,30 @@ +> */ + public function rules(): array + { + return [ + 'q' => ['sometimes', 'nullable', 'string', 'max:255'], + 'id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'], + 'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())], + 'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'], + 'sort_direction' => ['sometimes', 'string', 'in:asc,desc'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Domains/Purchase/Resources/AdminApp/PurchaseModificationResource.php b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php similarity index 60% rename from app/Domains/Purchase/Resources/AdminApp/PurchaseModificationResource.php rename to app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php index f42b219..c842c0a 100644 --- a/app/Domains/Purchase/Resources/AdminApp/PurchaseModificationResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php @@ -1,6 +1,6 @@ */ public function toArray(Request $request): array { - /** @var Purchase|null $purchase */ - $purchase = $this->whenLoaded('trackable'); + /** @var Purchase|null $sale */ + $sale = $this->whenLoaded('trackable'); $user = $this->whenLoaded('user'); return [ 'id' => $this->id, - 'purchase_id' => $this->trackable_id, + 'sale_id' => $this->trackable_id, 'attribute' => $this->attribute, 'old_value' => $this->old_value, 'new_value' => $this->new_value, - 'changed_at' => $this->changed_at, + 'date' => $this->changed_at->format('Y-m-d'), + 'time' => $this->changed_at->format('H:i:s'), 'actor_type' => $this->actor_type->value, - 'purchase' => $purchase instanceof Purchase ? [ - 'id' => $purchase->id, - 'customer_name' => $purchase->nombre_apellido, - 'status' => $purchase->status, + 'sale' => $sale instanceof Purchase ? [ + 'id' => $sale->id, + 'customer_name' => $sale->nombre_apellido, + 'status' => $sale->status, ] : null, 'modified_by' => $user ? [ 'id' => $user->id, diff --git a/app/Domains/Sale/Resources/AdminApp/SaleResource.php b/app/Domains/Sale/Resources/AdminApp/SaleResource.php new file mode 100644 index 0000000..3a11ced --- /dev/null +++ b/app/Domains/Sale/Resources/AdminApp/SaleResource.php @@ -0,0 +1,28 @@ + */ + public function toArray(Request $request): array + { + $ticketsCount = (int) ($this->tickets_count ?? 0); + + return [ + 'id' => $this->id, + 'created_at' => $this->created_at, + 'customer_name' => $this->nombre_apellido, + 'quantity' => (int) ($this->quantity ?? 0), + 'status' => $this->status, + 'total' => number_format((float) $this->total, 2, '.', ''), + 'tickets_count' => $ticketsCount, + 'has_generated_tickets' => $ticketsCount > 0, + ]; + } +} diff --git a/app/Domains/Sale/Services/AdminAppSalePdfService.php b/app/Domains/Sale/Services/AdminAppSalePdfService.php new file mode 100644 index 0000000..2712fb1 --- /dev/null +++ b/app/Domains/Sale/Services/AdminAppSalePdfService.php @@ -0,0 +1,68 @@ + $sales */ + public function downloadSales(Tenant $tenant, Collection $sales): Response + { + $pdf = Pdf::loadView('pdf.adminapp.sales', [ + 'tenant' => $tenant, + 'sales' => $sales, + 'generatedAt' => now(), + 'confirmedSalesTotal' => number_format( + (float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'), + 2, + '.', + '', + ), + ])->setPaper('a4', 'landscape'); + + $this->addPageNumbers($pdf); + + return $pdf->download( + 'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf' + ); + } + + /** @param Collection $modifications */ + public function downloadModifications(Tenant $tenant, Collection $modifications): Response + { + $pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [ + 'tenant' => $tenant, + 'modifications' => $modifications, + 'generatedAt' => now(), + ])->setPaper('a4', 'landscape'); + + $this->addPageNumbers($pdf); + + return $pdf->download( + 'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf' + ); + } + + private function addPageNumbers(DomPdf $pdf): void + { + $pdf->render(); + $domPdf = $pdf->getDomPDF(); + $font = $domPdf->getFontMetrics()->getFont('DejaVu Sans'); + + $domPdf->getCanvas()->page_text( + 385, + 575, + 'Página {PAGE_NUM} de {PAGE_COUNT}', + $font, + 7, + [0.48, 0.52, 0.49], + ); + } +} diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php new file mode 100644 index 0000000..fda53e6 --- /dev/null +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -0,0 +1,120 @@ +where('tenant_codigo', $tenant->codigo) + ->where('status', Purchase::STATUS_PAID) + ->sum('total'); + + return number_format((float) $total, 2, '.', ''); + } + + /** + * @param array{ + * q?: string|null, + * id?: int|null, + * sale_date?: string|null, + * status?: string|null, + * sort_by?: string, + * sort_direction?: string + * } $filters + * @return LengthAwarePaginator + */ + public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator + { + return $this->salesQuery($tenant, $filters) + ->paginateFromRequest() + ->withQueryString(); + } + + /** + * @param array $filters + * @return Collection + */ + public function salesForExport(Tenant $tenant, array $filters = []): Collection + { + return $this->salesQuery($tenant, $filters)->get(); + } + + /** @return LengthAwarePaginator */ + public function modifications(Tenant $tenant): LengthAwarePaginator + { + return $this->modificationsQuery($tenant) + ->paginateFromRequest() + ->withQueryString(); + } + + /** @return Collection */ + public function modificationsForExport(Tenant $tenant): Collection + { + return $this->modificationsQuery($tenant)->get(); + } + + /** @param array $filters */ + protected function salesQuery(Tenant $tenant, array $filters): Builder + { + $sortColumns = [ + 'id' => 'id', + 'date' => 'created_at', + 'customer_name' => 'nombre_apellido', + 'quantity' => 'quantity', + 'status' => 'status', + 'total' => 'total', + ]; + $requestedSort = $filters['sort_by'] ?? 'date'; + $sortBy = array_key_exists($requestedSort, $sortColumns) ? $requestedSort : 'date'; + $requestedDirection = $filters['sort_direction'] ?? 'desc'; + $sortDirection = in_array($requestedDirection, ['asc', 'desc'], true) + ? $requestedDirection + : 'desc'; + + return Purchase::query() + ->where('tenant_codigo', $tenant->codigo) + ->when($filters['q'] ?? null, function (Builder $query, string $search): void { + $term = trim($search); + + $query->where(function (Builder $query) use ($term): void { + $query + ->where('id', 'like', "%{$term}%") + ->orWhere('nombre_apellido', 'like', "%{$term}%") + ->orWhere('created_at', 'like', "%{$term}%"); + }); + }) + ->when($filters['id'] ?? null, fn (Builder $query, int $id): Builder => $query->whereKey($id)) + ->when( + $filters['sale_date'] ?? null, + fn (Builder $query, string $date): Builder => $query->whereDate('created_at', $date) + ) + ->when( + $filters['status'] ?? null, + fn (Builder $query, string $status): Builder => $query->where('status', $status) + ) + ->withSum('items as quantity', 'cantidad') + ->withCount('tickets') + ->orderBy($sortColumns[$sortBy], $sortDirection) + ->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id')); + } + + /** @return Builder */ + protected function modificationsQuery(Tenant $tenant): Builder + { + return ValueChange::query() + ->where('tenant_code', $tenant->codigo) + ->where('trackable_type', (new Purchase)->getMorphClass()) + ->with(['trackable', 'user']) + ->orderByDesc('changed_at') + ->orderByDesc('id'); + } +} diff --git a/app/Domains/Sale/routes/adminapp.php b/app/Domains/Sale/routes/adminapp.php new file mode 100644 index 0000000..3eea7bd --- /dev/null +++ b/app/Domains/Sale/routes/adminapp.php @@ -0,0 +1,13 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('sales', [SaleController::class, 'index']); + Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); + Route::get('sales/modifications', [SaleController::class, 'modifications']); + Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']); + }); diff --git a/app/Domains/Sale/routes/api.php b/app/Domains/Sale/routes/api.php new file mode 100644 index 0000000..062e0fe --- /dev/null +++ b/app/Domains/Sale/routes/api.php @@ -0,0 +1,3 @@ + + + + + + + +

Historial de modificaciones de ventas

+

{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}

+ + + + + + + + + + + + + + + + @forelse ($modifications as $modification) + @php($sale = $modification->trackable) + + + + + + + + + + + @empty + + @endforelse + +
FechaHoraVentaClienteCampoValor anteriorValor nuevoModificado por
{{ $modification->changed_at->format('d/m/Y') }}{{ $modification->changed_at->format('H:i:s') }}#{{ $modification->trackable_id }}{{ $sale?->nombre_apellido ?: 'Sin nombre' }}{{ $modification->attribute }}{{ $modification->old_value ?? '-' }}{{ $modification->new_value ?? '-' }}{{ $modification->user?->nombre_apellido ?? 'Sistema' }}
Todavía no hay modificaciones registradas.
+ + + diff --git a/resources/views/pdf/adminapp/sales.blade.php b/resources/views/pdf/adminapp/sales.blade.php new file mode 100644 index 0000000..a258ba6 --- /dev/null +++ b/resources/views/pdf/adminapp/sales.blade.php @@ -0,0 +1,65 @@ + + + + + + + +

Historial de ventas

+

{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}

+ +
+ Total de ventas confirmadas en este reporte: ${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }} +
+ + + + + + + + + + + + + + + @forelse ($sales as $sale) + + + + + + + + + + @empty + + @endforelse + +
IDFechaClienteCantidadEstadoImporteTickets
#{{ $sale->id }}{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}{{ $sale->nombre_apellido ?: 'Sin nombre' }}{{ (int) ($sale->quantity ?? 0) }}{{ match ($sale->status) { + 'paid' => 'Confirmado', + 'created', 'pending_payment' => 'Esperando pago', + default => 'Anulado', + } }}${{ number_format((float) $sale->total, 2, ',', '.') }}{{ (int) ($sale->tickets_count ?? 0) }}
No hay ventas para los criterios seleccionados.
+ + + diff --git a/routes/api.php b/routes/api.php index f724fab..eda54d3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ require __DIR__.'/../app/Domains/Cart/routes/api.php'; require __DIR__.'/../app/Domains/StorageTest/routes/api.php'; require __DIR__.'/../app/Domains/MailTest/routes/api.php'; require __DIR__.'/../app/Domains/Purchase/routes/api.php'; +require __DIR__.'/../app/Domains/Sale/routes/api.php'; require __DIR__.'/../app/Domains/Tenant/routes/api.php'; require __DIR__.'/../app/Domains/Integration/routes/api.php'; require __DIR__.'/../app/Domains/Menu/routes/api.php'; diff --git a/tests/Feature/Forms/AdminAppSaleFormControllerTest.php b/tests/Feature/Forms/AdminAppSaleFormControllerTest.php new file mode 100644 index 0000000..b6d3e96 --- /dev/null +++ b/tests/Feature/Forms/AdminAppSaleFormControllerTest.php @@ -0,0 +1,67 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/sale')->assertUnauthorized(); + } + + public function test_an_adminapp_user_can_get_the_sale_form(): void + { + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'website_type_code' => 'onticket', + ]); + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/sale') + ->assertOk() + ->assertJsonCount(count(Purchase::statuses()), 'data.statuses') + ->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED) + ->assertJsonPath('data.statuses.0.name', 'Creada') + ->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT) + ->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID) + ->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED); + } + + public function test_a_customer_cannot_get_the_sale_form(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => null, + ])); + + $this->getJson('/api/v1/adminapp/forms/sale')->assertForbidden(); + } +} diff --git a/tests/Unit/Sale/AdminAppSalePdfServiceTest.php b/tests/Unit/Sale/AdminAppSalePdfServiceTest.php new file mode 100644 index 0000000..04625ce --- /dev/null +++ b/tests/Unit/Sale/AdminAppSalePdfServiceTest.php @@ -0,0 +1,90 @@ +app->register(ServiceProvider::class); + } + + public function test_it_downloads_the_sales_report_as_a_pdf(): void + { + $response = app(AdminAppSalePdfService::class)->downloadSales( + $this->tenant(), + collect([$this->sale()]), + ); + + $this->assertSame('application/pdf', $response->headers->get('content-type')); + $this->assertStringContainsString( + 'attachment; filename=ventas_acme_', + (string) $response->headers->get('content-disposition'), + ); + $this->assertStringStartsWith('%PDF', $response->getContent()); + } + + public function test_it_downloads_the_modification_history_as_a_pdf(): 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(AdminAppSalePdfService::class)->downloadModifications( + $this->tenant(), + collect([$modification]), + ); + + $this->assertSame('application/pdf', $response->headers->get('content-type')); + $this->assertStringContainsString( + 'attachment; filename=historial_modificaciones_acme_', + (string) $response->headers->get('content-disposition'), + ); + $this->assertStringStartsWith('%PDF', $response->getContent()); + } + + 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, + ]); + } +}