diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index eb7f267..55aac5a 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2", "name": "ShopIt API — Complete", - "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 122 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", + "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ @@ -385,6 +385,42 @@ } }, "response": [] + }, + { + "name": "redirect Google Auth", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /auth/google/redirect`\n\nControlador: `App\\Domains\\Auth\\Controllers\\GoogleAuthController@redirect`", + "url": { + "raw": "{{base_url}}/auth/google/redirect?tenant={{tenant_code}}&return_url={{storefront_url}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "auth", + "google", + "redirect" + ], + "query": [ + { + "key": "tenant", + "value": "{{tenant_code}}" + }, + { + "key": "return_url", + "value": "{{storefront_url}}" + } + ] + } + }, + "response": [] } ] }, @@ -572,60 +608,6 @@ } }, "response": [] - }, - { - "name": "Update Checkout Cart Item Cart", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}`\n\nControlador: `App\\Domains\\Cart\\Controllers\\CartController@updateCheckoutItem`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/checkout-carts/{{cart}}/items/{{cart_item_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "checkout-carts", - "{{cart}}", - "items", - "{{cart_item_id}}" - ] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cantidad\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] } ] }, @@ -1169,99 +1151,6 @@ }, "response": [] }, - { - "name": "Prepare Item Editing Purchase", - "request": { - "method": "POST", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/compras/{compra}/edit-items`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@prepareItemEditing`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/edit-items", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "compras", - "{{purchase_id}}", - "edit-items" - ] - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, - { - "name": "Update Item Purchase", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItem`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/items/{{purchase_item_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "compras", - "{{purchase_id}}", - "items", - "{{purchase_item_id}}" - ] - }, - "body": { - "mode": "raw", - "raw": "{\n \"quantity\": 2,\n \"variant_id\": {{variant_id}}\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, { "name": "Create Payment Intent Purchase", "request": { @@ -2214,7 +2103,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}", + "raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}", "options": { "raw": { "language": "json" @@ -2280,7 +2169,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\"\n}", + "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}", "options": { "raw": { "language": "json" @@ -2320,7 +2209,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\"\n}", + "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}", "options": { "raw": { "language": "json" @@ -2663,6 +2552,45 @@ }, "response": [] }, + { + "name": "Ticket Form", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/forms/fiesta-futbol-infantil/ticket`\n\nControlador: `App\\Domains\\Forms\\Controllers\\AdminApp\\TicketFormController`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "forms", + "fiesta-futbol-infantil", + "ticket" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Sale Form", "request": { @@ -2738,6 +2666,44 @@ } }, "response": [] + }, + { + "name": "Ticket Filter Form", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/forms/tickets-filter`\n\nControlador: `App\\Domains\\Forms\\Controllers\\AdminApp\\TicketFilterFormController`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/forms/tickets-filter", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "forms", + "tickets-filter" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] } ] }, @@ -3058,6 +3024,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/accommodations/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/accommodations/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "accommodations", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/accommodations/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/accommodations/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "accommodations", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Accommodation", "request": { @@ -3187,6 +3231,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/entries/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/entries/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "entries", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/entries/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/entries/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "entries", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Entry", "request": { @@ -3316,6 +3438,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/foods/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/foods/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "foods", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/foods/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/foods/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "foods", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Food", "request": { @@ -3445,6 +3645,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/merchandise/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/merchandise/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "merchandise", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/merchandise/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/merchandise/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "merchandise", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Merchandise", "request": { @@ -4049,6 +4327,45 @@ }, "response": [] }, + { + "name": "download Excel Sale", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/sales/excel`\n\nControlador: `App\\Domains\\Sale\\Controllers\\AdminApp\\SaleController@downloadExcel`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/sales/excel", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "sales", + "excel" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "List Modifications Sale", "request": { @@ -4088,6 +4405,46 @@ }, "response": [] }, + { + "name": "download Modifications Excel Sale", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/sales/modifications/excel`\n\nControlador: `App\\Domains\\Sale\\Controllers\\AdminApp\\SaleController@downloadModificationsExcel`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/sales/modifications/excel", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "sales", + "modifications", + "excel" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Download Modifications PDF Sale", "request": { @@ -4601,6 +4958,127 @@ } ] }, + { + "name": "Ticket", + "item": [ + { + "name": "List Ticket", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/tickets`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\AdminApp\\TicketController@index`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/tickets", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "tickets" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "download Excel Ticket", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/tickets/excel`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\AdminApp\\TicketController@downloadExcel`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/tickets/excel", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "tickets", + "excel" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Download PDF Ticket", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/tickets/pdf`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\AdminApp\\TicketController@downloadPdf`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/tickets/pdf", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "tickets", + "pdf" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, { "name": "Tenant", "item": [ @@ -5254,6 +5732,11 @@ "value": "http://localhost:8000", "type": "string" }, + { + "key": "storefront_url", + "value": "http://localhost:4200", + "type": "string" + }, { "key": "token", "value": "", diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index 329af25..8a3979b 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -13,16 +13,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; #[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] -#[Hidden(['password', 'remember_token'])] +#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])] class User extends Authenticatable { /** @use HasFactory */ - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, SoftDeletes; protected $attributes = [ 'rol_codigo' => RoleCode::User->value, diff --git a/app/Domains/Auth/Requests/RegisterUserRequest.php b/app/Domains/Auth/Requests/RegisterUserRequest.php index 8590fa1..5acfb09 100644 --- a/app/Domains/Auth/Requests/RegisterUserRequest.php +++ b/app/Domains/Auth/Requests/RegisterUserRequest.php @@ -21,7 +21,13 @@ class RegisterUserRequest extends FormRequest return [ 'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')], 'nombre_apellido' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users', 'email')->whereNull('deleted_at'), + ], 'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()], 'dni' => ['nullable', 'string', 'max:255'], 'telefono' => ['nullable', 'string', 'max:255'], diff --git a/app/Domains/Auth/Requests/UpdateProfileRequest.php b/app/Domains/Auth/Requests/UpdateProfileRequest.php index dae0ed4..a4984d7 100644 --- a/app/Domains/Auth/Requests/UpdateProfileRequest.php +++ b/app/Domains/Auth/Requests/UpdateProfileRequest.php @@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Password; class UpdateProfileRequest extends FormRequest { @@ -19,11 +20,13 @@ class UpdateProfileRequest extends FormRequest 'email' => [ 'required', 'email', - Rule::unique('users', 'email')->ignore($this->user()->id), + Rule::unique('users', 'email') + ->whereNull('deleted_at') + ->ignore($this->user()->id), ], 'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'], 'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'], - 'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()], + 'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()], ]; } } diff --git a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php index e72f7a1..c30015c 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -5,6 +5,7 @@ namespace App\Domains\Desfile\Services; use DateTimeInterface; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use RuntimeException; @@ -58,7 +59,7 @@ class InvitationPurchaseProvisioner $allocation['type'], ); - $this->createPurchaseItem( + $purchaseItemId = $this->createPurchaseItem( $purchaseId, $catalogItem, $variant, @@ -70,6 +71,7 @@ class InvitationPurchaseProvisioner ); $this->createTicketAndCommitStock( $purchaseId, + $purchaseItemId, $userId, (int) $catalogItem->id, $variant, @@ -102,7 +104,10 @@ class InvitationPurchaseProvisioner private function userId(DateTimeInterface $now): int { - $user = DB::table('users')->where('email', self::USER_EMAIL)->first(); + $user = DB::table('users') + ->where('email', self::USER_EMAIL) + ->whereNull('deleted_at') + ->first(); if ($user !== null) { if ($user->tenant_codigo !== self::TENANT_CODE) { @@ -326,12 +331,13 @@ class InvitationPurchaseProvisioner int $seat, string $type, DateTimeInterface $now, - ): void { - if (DB::table('compra_items') + ): int { + $existingId = DB::table('compra_items') ->where('compra_id', $purchaseId) ->where('source_variant_id', $variant->id) - ->exists()) { - return; + ->value('id'); + if ($existingId !== null) { + return (int) $existingId; } $attributes = [ @@ -341,7 +347,7 @@ class InvitationPurchaseProvisioner ['name' => 'Asiento', 'value' => (string) $seat], ]; - DB::table('compra_items')->insert([ + return DB::table('compra_items')->insertGetId([ 'compra_id' => $purchaseId, 'source_catalog_item_id' => $catalogItem->id, 'source_variant_id' => $variant->id, @@ -367,13 +373,18 @@ class InvitationPurchaseProvisioner private function createTicketAndCommitStock( int $purchaseId, + int $purchaseItemId, int $userId, int $catalogItemId, object $variant, DateTimeInterface $now, ): void { + $purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id') + ? ['source_purchase_item_id' => $purchaseItemId] + : ['source_purchase_id' => $purchaseId]; + if (DB::table('tickets') - ->where('source_purchase_id', $purchaseId) + ->where($purchaseReference) ->where('source_variant_id', $variant->id) ->exists()) { return; @@ -420,7 +431,7 @@ class InvitationPurchaseProvisioner 'ticket' => (string) Str::uuid(), 'name' => null, 'description' => null, - 'source_purchase_id' => $purchaseId, + ...$purchaseReference, 'source_catalog_item_id' => $catalogItemId, 'source_variant_id' => $variant->id, 'used_at' => null, diff --git a/app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php b/app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php new file mode 100644 index 0000000..d6c1201 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php @@ -0,0 +1,20 @@ +user('sanctum')->tenant()->firstOrFail(); + + return TicketFilterFormResource::make($this->formService->get($tenant)); + } +} diff --git a/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php b/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php new file mode 100644 index 0000000..5dc3936 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php @@ -0,0 +1,22 @@ +ticketFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/TicketFilterFormResource.php b/app/Domains/Forms/Resources/TicketFilterFormResource.php new file mode 100644 index 0000000..f82513c --- /dev/null +++ b/app/Domains/Forms/Resources/TicketFilterFormResource.php @@ -0,0 +1,21 @@ + */ + public function toArray(Request $request): array + { + return [ + 'code' => $this->resource['code'], + 'action' => $this->resource['action'], + 'method' => $this->resource['method'], + 'fields' => $this->resource['fields'], + 'columns' => $this->resource['columns'], + ]; + } +} diff --git a/app/Domains/Forms/Resources/TicketFormResource.php b/app/Domains/Forms/Resources/TicketFormResource.php new file mode 100644 index 0000000..dcaa7a9 --- /dev/null +++ b/app/Domains/Forms/Resources/TicketFormResource.php @@ -0,0 +1,18 @@ + */ + public function toArray(Request $request): array + { + return [ + 'statuses' => $this->resource['statuses'], + 'categories' => $this->resource['categories'], + ]; + } +} diff --git a/app/Domains/Forms/Services/SaleFormService.php b/app/Domains/Forms/Services/SaleFormService.php index 8064968..8ddcead 100644 --- a/app/Domains/Forms/Services/SaleFormService.php +++ b/app/Domains/Forms/Services/SaleFormService.php @@ -6,27 +6,18 @@ use App\Domains\Purchase\Models\Purchase; class SaleFormService { - /** @return array{statuses: list} */ + /** @return array{statuses: list}>} */ public function get(): array { - $names = [ - Purchase::STATUS_CREATED => 'Creada', - Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago', - Purchase::STATUS_IN_REVIEW => 'En revisión', - Purchase::STATUS_PAID => 'Confirmada', - Purchase::STATUS_CANCELLED => 'Cancelada', - Purchase::STATUS_REJECTED => 'Rechazada', - Purchase::STATUS_EXPIRED => 'Vencida', - Purchase::STATUS_SUPERSEDED => 'Reemplazada', - ]; - return [ 'statuses' => array_map( - fn (string $status): array => [ - 'code' => $status, - 'name' => $names[$status], + fn (string $code, array $definition): array => [ + 'value' => $code, + 'label' => $definition['name'], + 'real_statuses' => $definition['statuses'], ], - Purchase::statuses(), + array_keys(Purchase::adminStatuses()), + array_values(Purchase::adminStatuses()), ), ]; } diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php new file mode 100644 index 0000000..72ccc65 --- /dev/null +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -0,0 +1,152 @@ + */ + public function get(Tenant $tenant): array + { + $fields = $this->commonFields(); + + if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) { + $fields = [ + ...$this->fiestaFutbolInfantilFields($tenant), + ...$this->commonFields(includeDate: false), + ]; + } + + return [ + 'code' => 'tickets_filter', + 'action' => '/api/v1/adminapp/tenant/tickets', + 'method' => 'GET', + 'fields' => $fields, + 'columns' => $this->columnService->publicColumns($tenant), + ]; + } + + /** @return list> */ + private function fiestaFutbolInfantilFields(Tenant $tenant): array + { + $form = $this->ticketFormService->getForFilters($tenant); + + return [ + [ + 'name' => 'category', + 'query_param' => 'category', + 'label' => 'Categoría', + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => 'Categoría', + 'options' => array_map( + fn (array $category): array => [ + 'value' => $category['value'], + 'label' => $category['label'], + 'children' => [ + [ + 'field' => 'product', + 'disabled' => $category['products'] === [], + 'options' => array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'children' => [ + [ + 'field' => 'type', + 'disabled' => $product['types'] === [], + 'options' => array_map( + fn (array $type): array => [ + 'value' => $type['value'], + 'label' => $type['label'], + 'children' => [[ + 'field' => 'size', + 'disabled' => ($type['sizes'] ?? []) === [], + 'options' => $type['sizes'] ?? [], + ]], + ], + $product['types'], + ), + ], + ], + ], + $category['products'], + ), + ], + [ + 'field' => 'date', + 'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true), + 'options' => [], + ], + ], + ], + $form['categories'], + ), + ], + $this->dependentSelect('product', 'Producto', 'category'), + $this->dependentSelect('type', 'Tipo', 'product'), + $this->dependentSelect('size', 'Talle', 'type'), + [ + ...$this->dependentSelect('date', 'Fecha', 'category'), + 'type' => 'date', + ], + ]; + } + + /** @return list> */ + private function commonFields(bool $includeDate = true): array + { + return [ + ...($includeDate ? [[ + 'name' => 'date', + 'query_param' => 'date', + 'label' => 'Fecha', + 'type' => 'date', + 'required' => false, + 'default' => null, + ]] : []), + [ + 'name' => 'status', + 'query_param' => 'status', + 'label' => 'Estado', + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => 'Estado', + 'options' => [ + ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], + ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], + ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], + ], + ], + ]; + } + + /** @return array */ + private function dependentSelect(string $name, string $label, string $dependency): array + { + return [ + 'name' => $name, + 'query_param' => $name, + 'label' => $label, + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => $label, + 'depends_on' => $dependency, + 'disabled' => true, + 'options' => [], + ]; + } +} diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php new file mode 100644 index 0000000..b9436d2 --- /dev/null +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -0,0 +1,400 @@ + + */ + private const CATEGORY_PRESENTATIONS = [ + 'entradas' => [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => null, + 'order' => 1, + ], + 'alojamientos' => [ + 'label' => 'Camping', + 'product' => 'tipo_alojamiento', + 'type' => null, + 'order' => 2, + ], + 'camping' => [ + 'label' => null, + 'product' => 'tipo_alojamiento', + 'type' => null, + 'order' => 2, + ], + 'comidas' => [ + 'label' => 'Comida', + 'product' => 'event_date', + 'type' => 'horario', + 'order' => 3, + ], + 'comida' => [ + 'label' => null, + 'product' => 'event_date', + 'type' => 'horario', + 'order' => 3, + ], + 'merchandising' => [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => 'color', + 'order' => 4, + ], + ]; + + /** + * @var array + */ + private const FILTER_CATEGORY_PRESENTATIONS = [ + 'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1], + 'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2], + 'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2], + 'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3], + 'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3], + 'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4], + ]; + + /** + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + public function get(Tenant $tenant): array + { + $items = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('has_tickets', true) + ->whereHas('category') + ->with($this->relations()) + ->orderBy('group_order') + ->orderBy('nombre') + ->get(); + + return $this->build($items, self::CATEGORY_PRESENTATIONS); + } + + /** + * Return active catalog options plus soft-deleted sources still referenced by + * tickets, so historical tickets never become impossible to filter. + * + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + public function getForFilters(Tenant $tenant): array + { + $historicalVariantIds = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->whereNotNull('source_variant_id') + ->distinct() + ->pluck('source_variant_id') + ->map(fn ($id): int => (int) $id) + ->all(); + $historicalCatalogItemIds = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->whereNotNull('source_catalog_item_id') + ->distinct() + ->pluck('source_catalog_item_id') + ->map(fn ($id): int => (int) $id) + ->merge( + Variant::withTrashed() + ->whereKey($historicalVariantIds) + ->pluck('catalog_item_id') + ->map(fn ($id): int => (int) $id), + ) + ->unique() + ->values() + ->all(); + + $items = CatalogItem::withTrashed() + ->where('tenant_code', $tenant->codigo) + ->whereHas('category') + ->where(function ($query) use ($historicalCatalogItemIds): void { + $query + ->where(function ($activeQuery): void { + $activeQuery + ->whereNull('catalog_items.deleted_at') + ->where('has_tickets', true); + }) + ->orWhereIn('catalog_items.id', $historicalCatalogItemIds); + }) + ->with([ + 'category', + 'itemAttributes.attribute.options', + 'variants' => fn ($query) => $query + ->withTrashed() + ->where(function ($variantQuery) use ($historicalVariantIds): void { + $variantQuery + ->whereNull('variantes.deleted_at') + ->orWhereIn('variantes.id', $historicalVariantIds); + }), + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->orderBy('group_order') + ->orderBy('nombre') + ->get(); + + return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true); + } + + /** + * @param Collection $items + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + private function build(Collection $items, array $presentations, bool $includeSizes = false): array + { + $categories = []; + + foreach ($items as $item) { + $sourceCategory = trim((string) $item->category?->nombre); + $categoryValue = mb_strtolower($sourceCategory); + $presentation = $presentations[$categoryValue] ?? [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => null, + 'size' => null, + 'order' => PHP_INT_MAX, + ]; + + $categories[$categoryValue] ??= [ + 'value' => $categoryValue, + 'label' => $presentation['label'] ?? $sourceCategory, + 'order' => $presentation['order'], + 'products' => [], + ]; + + foreach ($this->products( + $item, + $presentation['product'], + $presentation['type'], + $presentation['size'] ?? null, + ) as $product) { + $productValue = $product['value']; + $existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [ + 'value' => $productValue, + 'label' => $product['label'], + 'types' => [], + 'sizes' => [], + ]; + + foreach ($product['types'] as $type) { + $existingProduct['types'][$type['value']] = $type; + } + + foreach ($product['sizes'] as $size) { + $existingProduct['sizes'][$size['value']] = $size; + } + + $categories[$categoryValue]['products'][$productValue] = $existingProduct; + } + } + + uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order'] + ?: $left['label'] <=> $right['label']); + + return [ + 'statuses' => [ + ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], + ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], + ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], + ], + 'categories' => array_values(array_map( + fn (array $category): array => [ + 'value' => $category['value'], + 'label' => $category['label'], + 'products' => array_values(array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'types' => array_values($product['types']), + ...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []), + ], + $category['products'], + )), + ], + $categories, + )), + ]; + } + + /** @return list */ + private function relations(): array + { + return [ + 'category', + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]; + } + + /** + * @return list, + * sizes: list + * }> + */ + private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array + { + if ($productCode === self::PRODUCT) { + return [[ + 'value' => $item->slug, + 'label' => $item->nombre, + 'types' => $this->types($item, $typeCode, $sizeCode), + 'sizes' => $this->types($item, $sizeCode), + ]]; + } + + $products = []; + + foreach ($item->variants as $variant) { + foreach ($this->variantOptions($variant, $productCode) as $productOption) { + $productValue = $productOption['value']; + $products[$productValue] ??= [ + 'value' => $productValue, + 'label' => $this->optionLabel($productOption['label'], $productCode), + 'types' => [], + 'sizes' => [], + ]; + + foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { + $this->mergeTypeOption( + $products[$productValue]['types'], + $typeOption, + $variant, + $sizeCode, + ); + } + + foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) { + $products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption; + } + } + } + + return array_values(array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'types' => array_values($product['types']), + 'sizes' => array_values($product['sizes']), + ], + $products, + )); + } + + /** @return list> */ + private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array + { + $types = []; + + foreach ($item->variants as $variant) { + foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { + $this->mergeTypeOption($types, $typeOption, $variant, $sizeCode); + } + } + + return array_values(array_map(function (array $type) use ($sizeCode): array { + if ($sizeCode !== null) { + $type['sizes'] = array_values($type['sizes']); + } + + return $type; + }, $types)); + } + + /** + * @param array> $types + * @param array{value: string, label: string} $typeOption + */ + private function mergeTypeOption( + array &$types, + array $typeOption, + Variant $variant, + ?string $sizeCode, + ): void { + $typeValue = $typeOption['value']; + $types[$typeValue] ??= [ + ...$typeOption, + ...($sizeCode !== null ? ['sizes' => []] : []), + ]; + + foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) { + $types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption; + } + } + + /** @return list */ + private function variantOptions(Variant $variant, ?string $attributeCode): array + { + if ($attributeCode === null) { + return []; + } + + $selection = $variant->selectionOptions($variant->catalogItem->itemAttributes) + ->get($attributeCode); + + if ($selection === null) { + return []; + } + + return array_is_list($selection) ? $selection : [$selection]; + } + + private function optionLabel(string $label, string $attributeCode): string + { + if ($attributeCode !== 'event_date') { + return $label; + } + + [$day, $month] = array_pad(explode('/', $label), 2, null); + + return $day !== null && $month !== null ? "{$day}/{$month}" : $label; + } +} diff --git a/app/Domains/Forms/documentacion/README.md b/app/Domains/Forms/documentacion/README.md index 8f3a84b..9a3782d 100644 --- a/app/Domains/Forms/documentacion/README.md +++ b/app/Domains/Forms/documentacion/README.md @@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm - `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant. - `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación. - `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant. +- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil. Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales. @@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`: - `GET /event`. - `GET /sale`. - `GET /staff`. +- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets. - `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising. ## Dependencias diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index cb8683f..70f9ee3 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -5,6 +5,8 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController; use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController; use App\Domains\Forms\Controllers\AdminApp\SaleFormController; use App\Domains\Forms\Controllers\AdminApp\StaffFormController; +use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController; +use App\Domains\Forms\Controllers\AdminApp\TicketFormController; use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/forms') @@ -13,6 +15,13 @@ Route::prefix('v1/adminapp/forms') Route::get('event', EventFormController::class); Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); + Route::get('tickets-filter', TicketFilterFormController::class) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.forms.tickets-filter'); + Route::get( + 'fiesta-futbol-infantil/ticket', + TicketFormController::class + ); Route::get( 'fiesta-futbol-infantil/merchandise', MerchandiseFormController::class diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 6b5382a..9cb54ad 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -138,8 +138,7 @@ class NotificationMailService } /** @var Collection $tickets */ - $tickets = Ticket::query() - ->where('source_purchase_id', $purchase->getKey()) + $tickets = $purchase->tickets() ->where('tenant_code', $purchase->tenant_codigo) ->with(TicketPresentationResolver::RELATIONS) ->get(); diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index b177040..9e64608 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\DB; @@ -51,6 +52,14 @@ class Purchase extends Model public const STATUS_SUPERSEDED = 'superseded'; + public const ADMIN_STATUS_INCOMPLETE = 'incomplete'; + + public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment'; + + public const ADMIN_STATUS_CONFIRMED = 'confirmed'; + + public const ADMIN_STATUS_CANCELLED = 'cancelled'; + /** @return list */ public static function statuses(): array { @@ -66,6 +75,68 @@ class Purchase extends Model ]; } + /** + * @return array}> + */ + public static function adminStatuses(): array + { + return [ + self::ADMIN_STATUS_INCOMPLETE => [ + 'name' => 'Por completar datos', + 'statuses' => [self::STATUS_CREATED], + ], + self::ADMIN_STATUS_AWAITING_PAYMENT => [ + 'name' => 'Esperando pago', + 'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW], + ], + self::ADMIN_STATUS_CONFIRMED => [ + 'name' => 'Confirmado', + 'statuses' => [self::STATUS_PAID], + ], + self::ADMIN_STATUS_CANCELLED => [ + 'name' => 'Anulado', + 'statuses' => [ + self::STATUS_CANCELLED, + self::STATUS_REJECTED, + self::STATUS_EXPIRED, + self::STATUS_SUPERSEDED, + ], + ], + ]; + } + + /** @return list */ + public static function adminStatusCodes(): array + { + return array_keys(self::adminStatuses()); + } + + /** @return list */ + public static function realStatusesForAdminStatus(string $adminStatus): array + { + return self::adminStatuses()[$adminStatus]['statuses'] ?? []; + } + + public static function adminStatusFor(string $realStatus): ?string + { + foreach (self::adminStatuses() as $adminStatus => $definition) { + if (in_array($realStatus, $definition['statuses'], true)) { + return $adminStatus; + } + } + + return null; + } + + public static function adminStatusNameFor(string $realStatus): ?string + { + $adminStatus = self::adminStatusFor($realStatus); + + return $adminStatus === null + ? null + : self::adminStatuses()[$adminStatus]['name']; + } + protected $table = 'compras'; /** @var array */ @@ -115,12 +186,15 @@ class Purchase extends Model return $this->hasMany(PurchaseItem::class, 'compra_id'); } - /** - * @return HasMany - */ - public function tickets(): HasMany + /** @return HasManyThrough */ + public function tickets(): HasManyThrough { - return $this->hasMany(Ticket::class, 'source_purchase_id'); + return $this->hasManyThrough( + Ticket::class, + PurchaseItem::class, + 'compra_id', + 'source_purchase_item_id', + ); } /** @return BelongsTo */ diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 484ef07..f15bf6b 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -5,10 +5,12 @@ namespace App\Domains\Purchase\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'compra_id', @@ -56,6 +58,12 @@ class PurchaseItem extends Model return $this->belongsTo(Purchase::class, 'compra_id'); } + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'source_purchase_item_id'); + } + /** @return BelongsTo */ public function imageAttachment(): BelongsTo { diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index a63ef14..70ce711 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -43,6 +43,11 @@ class ReleaseCheckoutService ): Purchase { $purchase = $this->lockPurchase($purchase); + if ($purchase->status === Purchase::STATUS_EXPIRED + && $targetStatus === Purchase::STATUS_CANCELLED) { + return $this->loadPurchase($purchase); + } + if ($purchase->status === Purchase::STATUS_EXPIRED && $targetStatus !== Purchase::STATUS_EXPIRED) { throw new PurchaseExpiredException; @@ -72,6 +77,14 @@ class ReleaseCheckoutService $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + // Leaving checkout is allowed at the exact instant the reservation + // expires. Finish the expiration while holding the purchase lock so + // the request is idempotent with the scheduled expiration job. + if ($targetStatus === Purchase::STATUS_CANCELLED + && $this->hasOverdueActiveReservation($purchase)) { + $targetStatus = Purchase::STATUS_EXPIRED; + } + if ($targetStatus === Purchase::STATUS_CANCELLED && $cart?->status === 'active' && in_array($purchase->status, [ diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index 22bcfe1..383f700 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -3,6 +3,7 @@ namespace App\Domains\Sale\Controllers\AdminApp; use App\Domains\Sale\Requests\AdminAppSaleIndexRequest; +use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest; use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest; use App\Domains\Sale\Requests\AdminAppSalePdfRequest; use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; @@ -67,11 +68,13 @@ class SaleController extends Controller return new SaleResource($this->saleService->cancel($tenant, $sale)); } - public function modifications(Request $request): AnonymousResourceCollection - { + public function modifications( + AdminAppSaleModificationIndexRequest $request, + ): AnonymousResourceCollection { return SaleModificationResource::collection( $this->saleService->modifications( - $request->user()->tenant()->firstOrFail() + $request->user()->tenant()->firstOrFail(), + $request->validated(), ) ); } @@ -93,7 +96,7 @@ class SaleController extends Controller return $this->salePdfService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant), + $this->saleService->modificationsForExport($tenant, $request->validated()), $request->validated('timezone'), ); } @@ -116,7 +119,7 @@ class SaleController extends Controller return $this->saleExcelService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant), + $this->saleService->modificationsForExport($tenant, $request->validated()), $request->validated('timezone'), ); } diff --git a/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php b/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php index d4a2d32..0462296 100644 --- a/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php +++ b/app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php @@ -20,7 +20,7 @@ class AdminAppSaleIndexRequest extends FormRequest '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())], + 'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())], 'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'], 'sort_direction' => ['sometimes', 'string', 'in:asc,desc'], 'page' => ['sometimes', 'integer', 'min:1'], diff --git a/app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php b/app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php new file mode 100644 index 0000000..a2d21d5 --- /dev/null +++ b/app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php @@ -0,0 +1,16 @@ +> */ + public function rules(): array + { + $rules = parent::rules(); + + unset($rules['sort_by'], $rules['sort_direction']); + + return $rules; + } +} diff --git a/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php b/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php index db42725..8ad2fd5 100644 --- a/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php +++ b/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php @@ -3,19 +3,14 @@ namespace App\Domains\Sale\Requests; use App\Domains\Shared\Rules\ValidTimezone; -use Illuminate\Foundation\Http\FormRequest; -class AdminAppSaleModificationPdfRequest extends FormRequest +class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest { - public function authorize(): bool - { - return true; - } - /** @return array> */ public function rules(): array { return [ + ...parent::rules(), 'timezone' => ['required', 'string', new ValidTimezone], ]; } diff --git a/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php index 8a9a651..5554dd1 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php @@ -23,6 +23,12 @@ class SaleModificationResource extends JsonResource 'attribute' => $this->attribute, 'old_value' => $this->old_value, 'new_value' => $this->new_value, + 'admin_status' => is_string($this->new_value) + ? Purchase::adminStatusFor($this->new_value) + : null, + 'status_label' => is_string($this->new_value) + ? Purchase::adminStatusNameFor($this->new_value) + : null, 'changed_at' => $this->changed_at->utc()->toIso8601String(), 'date' => $this->changed_at->format('Y-m-d'), 'time' => $this->changed_at->format('H:i:s'), @@ -31,6 +37,8 @@ class SaleModificationResource extends JsonResource 'id' => $sale->id, 'customer_name' => $sale->nombre_apellido, 'status' => $sale->status, + 'admin_status' => Purchase::adminStatusFor($sale->status), + 'status_label' => Purchase::adminStatusNameFor($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 index 3a11ced..3e23620 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleResource.php @@ -20,6 +20,8 @@ class SaleResource extends JsonResource 'customer_name' => $this->nombre_apellido, 'quantity' => (int) ($this->quantity ?? 0), 'status' => $this->status, + 'admin_status' => Purchase::adminStatusFor($this->status), + 'status_label' => Purchase::adminStatusNameFor($this->status), 'total' => number_format((float) $this->total, 2, '.', ''), 'tickets_count' => $ticketsCount, 'has_generated_tickets' => $ticketsCount > 0, diff --git a/app/Domains/Sale/Services/AdminAppSaleExcelService.php b/app/Domains/Sale/Services/AdminAppSaleExcelService.php index ca88bce..ff77163 100644 --- a/app/Domains/Sale/Services/AdminAppSaleExcelService.php +++ b/app/Domains/Sale/Services/AdminAppSaleExcelService.php @@ -201,11 +201,6 @@ class AdminAppSaleExcelService 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', - }; + return Purchase::adminStatusNameFor($status) ?? $status; } } diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index e4e073d..eecd5fc 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -92,18 +92,24 @@ class AdminAppSaleService return $this->salesQuery($tenant, $filters)->get(); } - /** @return LengthAwarePaginator */ - public function modifications(Tenant $tenant): LengthAwarePaginator + /** + * @param array $filters + * @return LengthAwarePaginator + */ + public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator { - return $this->modificationsQuery($tenant) + return $this->modificationsQuery($tenant, $filters) ->paginateFromRequest() ->withQueryString(); } - /** @return Collection */ - public function modificationsForExport(Tenant $tenant): Collection + /** + * @param array $filters + * @return Collection + */ + public function modificationsForExport(Tenant $tenant, array $filters = []): Collection { - return $this->modificationsQuery($tenant)->get(); + return $this->modificationsQuery($tenant, $filters)->get(); } /** @param array $filters */ @@ -126,7 +132,6 @@ class AdminAppSaleService return Purchase::query() ->where('tenant_codigo', $tenant->codigo) - ->where('status', '!=', Purchase::STATUS_SUPERSEDED) ->when($filters['q'] ?? null, function (Builder $query, string $search): void { $term = trim($search); @@ -144,12 +149,10 @@ class AdminAppSaleService ) ->when( $filters['status'] ?? null, - fn (Builder $query, string $status): Builder => $status === Purchase::STATUS_PENDING_PAYMENT - ? $query->whereIn('status', [ - Purchase::STATUS_PENDING_PAYMENT, - Purchase::STATUS_IN_REVIEW, - ]) - : $query->where('status', $status) + fn (Builder $query, string $status): Builder => $query->whereIn( + 'status', + Purchase::realStatusesForAdminStatus($status), + ) ) ->select('compras.*') ->selectRaw( @@ -162,12 +165,50 @@ class AdminAppSaleService ->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id')); } - /** @return Builder */ - protected function modificationsQuery(Tenant $tenant): Builder + /** + * @param array $filters + * @return Builder + */ + protected function modificationsQuery(Tenant $tenant, array $filters): Builder { return ValueChange::query() ->where('tenant_code', $tenant->codigo) ->where('trackable_type', (new Purchase)->getMorphClass()) + ->when($filters['q'] ?? null, function (Builder $query, string $search): void { + $term = trim($search); + + $query->where(function (Builder $query) use ($term): void { + $query + ->where('trackable_id', 'like', "%{$term}%") + ->orWhereHasMorph( + 'trackable', + [Purchase::class], + function (Builder $sales) use ($term): void { + $sales + ->where('nombre_apellido', 'like', "%{$term}%") + ->orWhere('created_at', 'like', "%{$term}%"); + }, + ); + }); + }) + ->when( + $filters['id'] ?? null, + fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id) + ) + ->when( + $filters['sale_date'] ?? null, + fn (Builder $query, string $date): Builder => $query->whereHasMorph( + 'trackable', + [Purchase::class], + fn (Builder $sales): Builder => $sales->whereDate('created_at', $date), + ) + ) + ->when( + $filters['status'] ?? null, + fn (Builder $query, string $status): Builder => $query + ->where('attribute', 'status') + ->whereIn('new_value', Purchase::realStatusesForAdminStatus($status)) + ) ->with(['trackable', 'user']) ->orderByDesc('changed_at') ->orderByDesc('id'); diff --git a/app/Domains/Sale/documentacion/README.md b/app/Domains/Sale/documentacion/README.md index 7446341..2c1b5fe 100644 --- a/app/Domains/Sale/documentacion/README.md +++ b/app/Domains/Sale/documentacion/README.md @@ -9,7 +9,8 @@ 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. +- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación de ventas. +- `AdminAppSaleModificationIndexRequest`: valida los filtros compartidos por el historial y sus exportaciones. - `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp. - `SaleController`: entrada HTTP del panel. @@ -27,3 +28,5 @@ 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, PDF y Excel. + +El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. diff --git a/app/Domains/Staff/Requests/StoreStaffRequest.php b/app/Domains/Staff/Requests/StoreStaffRequest.php index 152f923..b592e4a 100644 --- a/app/Domains/Staff/Requests/StoreStaffRequest.php +++ b/app/Domains/Staff/Requests/StoreStaffRequest.php @@ -23,7 +23,12 @@ class StoreStaffRequest extends FormRequest return [ 'nombre_apellido' => ['required', 'string', 'max:255'], 'dni' => ['required', 'string', 'max:50'], - 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('users', 'email')->whereNull('deleted_at'), + ], 'category_ids' => $categoryRules, 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], ]; diff --git a/app/Domains/Staff/Requests/UpdateStaffRequest.php b/app/Domains/Staff/Requests/UpdateStaffRequest.php index cc1b6c4..19e1a94 100644 --- a/app/Domains/Staff/Requests/UpdateStaffRequest.php +++ b/app/Domains/Staff/Requests/UpdateStaffRequest.php @@ -28,7 +28,9 @@ class UpdateStaffRequest extends FormRequest 'required', 'email', 'max:255', - Rule::unique('users', 'email')->ignore($staffId), + Rule::unique('users', 'email') + ->whereNull('deleted_at') + ->ignore($staffId), ], 'category_ids' => $categoryRules, 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], diff --git a/app/Domains/Staff/Services/StaffService.php b/app/Domains/Staff/Services/StaffService.php index fc7c445..4039cb5 100644 --- a/app/Domains/Staff/Services/StaffService.php +++ b/app/Domains/Staff/Services/StaffService.php @@ -94,7 +94,12 @@ class StaffService public function delete(Tenant $tenant, int $staffId): void { - $this->find($tenant, $staffId)->delete(); + $staff = $this->find($tenant, $staffId); + + DB::transaction(function () use ($staff): void { + $staff->tokens()->delete(); + $staff->delete(); + }); } public function find(Tenant $tenant, int $staffId): User diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php new file mode 100644 index 0000000..00194f4 --- /dev/null +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -0,0 +1,53 @@ +user()->tenant()->firstOrFail(); + + return new AdminAppTicketCollection( + $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'), + ); + } +} diff --git a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php index 3c7d823..dfeb404 100644 --- a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php +++ b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php @@ -42,7 +42,7 @@ class GenerateTicketsForPaidPurchase $user, $purchaseItem->cantidad, $purchaseItem->source_variant_id, - $purchase->getKey(), + $purchaseItem->getKey(), ); } } diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 9fe6027..ab71c90 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -5,7 +5,7 @@ namespace App\Domains\Ticket\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticket\Services\ResolvedValidityGroup; @@ -21,7 +21,7 @@ use Illuminate\Support\Collection; #[Fillable([ 'tenant_code', 'ticket', - 'source_purchase_id', + 'source_purchase_item_id', 'source_catalog_item_id', 'source_variant_id', 'used_at', @@ -56,7 +56,7 @@ class Ticket extends Model return [ 'source_catalog_item_id' => 'integer', 'source_variant_id' => 'integer', - 'source_purchase_id' => 'integer', + 'source_purchase_item_id' => 'integer', 'used_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', @@ -78,13 +78,13 @@ class Ticket extends Model /** @return BelongsTo */ public function scannerUser(): BelongsTo { - return $this->belongsTo(User::class, 'scanner_user_id'); + return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed(); } - /** @return BelongsTo */ - public function sourcePurchase(): BelongsTo + /** @return BelongsTo */ + public function sourcePurchaseItem(): BelongsTo { - return $this->belongsTo(Purchase::class, 'source_purchase_id'); + return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id'); } /** @return BelongsTo */ diff --git a/app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php new file mode 100644 index 0000000..a55a60c --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php @@ -0,0 +1,17 @@ + */ + public function rules(): array + { + return [ + ...parent::rules(), + 'timezone' => ['required', 'string', new ValidTimezone], + ]; + } +} diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php new file mode 100644 index 0000000..5a80395 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -0,0 +1,47 @@ +> */ + public function rules(): array + { + $tenant = $this->user()?->tenant()->first(); + $sortableKeys = $tenant === null + ? [] + : app(AdminAppTicketColumnService::class)->sortableKeys($tenant); + + return [ + 'q' => ['sometimes', 'nullable', 'string', 'max:255'], + 'category' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product' => ['sometimes', 'nullable', 'string', 'max:255'], + 'type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'date' => ['sometimes', 'nullable', 'date_format:Y-m-d'], + 'size' => ['sometimes', 'nullable', 'string', 'max:255'], + 'status' => [ + 'sometimes', + 'nullable', + Rule::in([ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_USED, + Ticket::STATUS_EXPIRED, + ]), + ], + 'page' => ['sometimes', 'integer', 'min:1'], + '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'])], + ]; + } +} diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php new file mode 100644 index 0000000..3af312c --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -0,0 +1,34 @@ + */ + public $collects = AdminAppTicketResource::class; + + private readonly int $scannedTickets; + + private readonly int $totalTickets; + + public function __construct(AdminAppTicketResult $result) + { + parent::__construct($result->tickets); + + $this->scannedTickets = $result->scannedTickets; + $this->totalTickets = $result->totalTickets; + } + + /** @return array{scanned_tickets: int, total_tickets: int} */ + public function with(Request $request): array + { + return [ + 'scanned_tickets' => $this->scannedTickets, + 'total_tickets' => $this->totalTickets, + ]; + } +} diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php new file mode 100644 index 0000000..feda27c --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -0,0 +1,25 @@ + */ + public function toArray(Request $request): array + { + $rowService = app(AdminAppTicketRowService::class); + $details = $rowService->details($this->resource); + + return [ + ...parent::toArray($request), + ...$details, + 'values' => $rowService->values($this->resource, $details), + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php new file mode 100644 index 0000000..173db1b --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php @@ -0,0 +1,103 @@ + */ + public function columns(Tenant $tenant): array + { + $keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL + ? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by'] + : ['order_number', 'product', 'amount', 'client', 'id', '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', 'date', 'size'], true)) { + $column['sortable'] = false; + } + + return $column; + }, $columns); + } else { + $widths = [ + 'order_number' => '11%', + 'product' => '15%', + 'amount' => '10%', + 'client' => '15%', + 'id' => '8%', + '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 */ + public function publicColumns(Tenant $tenant): array + { + return array_map(function (array $column): array { + unset($column['excel_width']); + + return $column; + }, $this->columns($tenant)); + } + + /** @return list */ + 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 */ + 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), + 'date' => $this->column('date', 'Fecha', 'text', '7%', 14), + 'size' => $this->column('size', 'Talle', 'text', '6%', 12), + 'amount' => $this->column('amount', 'Importe', 'currency', '8%', 15), + 'client' => $this->column('client', 'Cliente', 'text', '11%', 30), + 'id' => $this->column('id', 'ID', 'text', '6%', 12), + 'status' => $this->column('status', 'Estado', 'status', '7%', 13), + 'scanned_by' => $this->column('scanned_by', 'Escaneado por', 'text', '8%', 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, + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php new file mode 100644 index 0000000..c34496e --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php @@ -0,0 +1,105 @@ + $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', + ]); + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketPdfService.php b/app/Domains/Ticket/Services/AdminAppTicketPdfService.php new file mode 100644 index 0000000..83e85ac --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketPdfService.php @@ -0,0 +1,56 @@ + $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], + ); + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketReportService.php b/app/Domains/Ticket/Services/AdminAppTicketReportService.php new file mode 100644 index 0000000..4c2accc --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketReportService.php @@ -0,0 +1,35 @@ + $tickets + * @return Collection> + */ + public function rows(Collection $tickets): Collection + { + return $this->rowService->rows($tickets); + } + + /** + * @param Collection> $rows + * @param list> $columns + * @return Collection> + */ + 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); + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketResult.php b/app/Domains/Ticket/Services/AdminAppTicketResult.php new file mode 100644 index 0000000..e9f65f4 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketResult.php @@ -0,0 +1,16 @@ + $tickets */ + public function __construct( + public LengthAwarePaginator $tickets, + public int $scannedTickets, + public int $totalTickets, + ) {} +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php new file mode 100644 index 0000000..e29b879 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -0,0 +1,210 @@ + ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], + 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], + 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null], + 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], + 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], + 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'], + ]; + + /** @return array */ + public function details(Ticket $ticket): array + { + $purchaseItem = $ticket->sourcePurchaseItem; + + return [ + 'source_purchase_item_id' => $ticket->source_purchase_item_id, + 'order_number' => $purchaseItem?->compra_id, + 'product' => $purchaseItem?->item_nombre + ?? $ticket->sourceCatalogItem?->nombre + ?? $ticket->name, + 'amount' => $purchaseItem?->precio_unitario, + 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, + 'status' => $ticket->status, + 'scanned_by' => $ticket->scannerUser?->nombre_apellido, + 'variant_properties' => $this->variantProperties($ticket), + ]; + } + + /** @param array|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'], + 'date' => $presentation['date'], + 'size' => $presentation['size'], + 'amount' => $details['amount'] === null ? null : (float) $details['amount'], + 'client' => $details['client'] ?? 'Sin nombre', + 'id' => $ticket->id, + 'status' => $details['status'], + 'scanned_by' => $details['scanned_by'] ?? '-', + ]; + } + + /** + * @param Collection $tickets + * @return Collection> + */ + public function rows(Collection $tickets): Collection + { + return $tickets->values()->map(fn (Ticket $ticket): array => $this->values($ticket)); + } + + /** + * @param Collection> $rows + * @param list> $columns + * @return Collection> + */ + 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, ',', '.'), + 'status' => match ((string) $value) { + Ticket::STATUS_USED => 'Usado', + Ticket::STATUS_EXPIRED => 'Vencido', + default => 'Activo', + }, + default => (string) $value, + }; + } + + /** @param array $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) ?: '-', + 'date' => '-', + 'size' => '-', + ]; + } + + $configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null; + if ($configuration === null) { + return [ + 'category' => $sourceCategory, + 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), + 'type' => $this->allPropertyLabels($details) ?: '-', + 'date' => '-', + 'size' => '-', + ]; + } + + 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']) ?: '-'), + 'date' => $configuration['date'] === null + ? '-' + : ($this->propertyLabels($details, $configuration['date']) ?: '-'), + 'size' => $configuration['size'] === null + ? '-' + : ($this->propertyLabels($details, $configuration['size']) ?: '-'), + ]; + } + + /** @param array $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 $details */ + private function allPropertyLabels(array $details): string + { + return collect($details['variant_properties'] ?? []) + ->flatMap(fn (array $property): array => $property['values'] ?? []) + ->pluck('label') + ->filter() + ->implode(', '); + } + + /** @return list}> */ + 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(); + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php new file mode 100644 index 0000000..cc41319 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -0,0 +1,404 @@ +baseQuery($tenant, $filters); + $countQuery = clone $query; + + $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); + + if (($filters['sort_by'] ?? null) && ! $databaseSorted) { + $matchingTickets = (clone $query) + ->with(self::RELATIONS) + ->get(); + $matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters); + $tickets = $this->paginate($matchingTickets, $filters); + $scannedTickets = $matchingTickets->whereNotNull('used_at')->count(); + } else { + $tickets = (clone $query) + ->with(self::RELATIONS) + ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) + ->paginateFromRequest() + ->withQueryString(); + $scannedTickets = $countQuery->whereNotNull('used_at')->count(); + } + + return new AdminAppTicketResult( + tickets: $tickets, + scannedTickets: $scannedTickets, + totalTickets: $tickets->total(), + ); + } + + /** + * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters + * @return Collection + */ + 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, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters + * @return Builder + */ + private function baseQuery(Tenant $tenant, array $filters): Builder + { + $search = trim((string) ($filters['q'] ?? '')); + + $query = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->when($search !== '', function (Builder $query) use ($search): void { + $this->applySearchFilter($query, $search); + }) + ->when($filters['category'] ?? null, function (Builder $query, string $category): void { + $query->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery + ->whereRaw('LOWER(nombre) = ?', [mb_strtolower(trim($category))])); + }) + ->when($filters['product'] ?? null, function (Builder $query, string $product) use ($filters): void { + $this->applyProductFilter($query, (string) ($filters['category'] ?? ''), $product); + }) + ->when($filters['type'] ?? null, function (Builder $query, string $type) use ($filters): void { + $this->applyTypeFilter($query, (string) ($filters['category'] ?? ''), $type); + }) + ->when($filters['date'] ?? null, function (Builder $query, string $date) use ($tenant): void { + if ($tenant->codigo === 'fiesta_futbol_infantil') { + $this->applyEventDateFilter($query, $date); + + return; + } + + $query->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + ->whereDate('created_at', $date)); + }) + ->when($filters['size'] ?? null, function (Builder $query, string $size) use ($filters): void { + $this->applySizeFilter($query, (string) ($filters['category'] ?? ''), $size); + }); + + $this->applyStatusFilter($query, $filters['status'] ?? null); + + return $query; + } + + /** @param Builder $query */ + private function applySearchFilter(Builder $query, string $search): void + { + $containsPattern = '%'.mb_strtolower($search).'%'; + $amount = $this->searchAmount($search); + + $query->where(function (Builder $searchQuery) use ($search, $containsPattern, $amount): void { + $searchQuery + ->where(function (Builder $clientQuery) use ($containsPattern): void { + $clientQuery + ->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])) + ->orWhere(function (Builder $fallbackClientQuery) use ($containsPattern): void { + $fallbackClientQuery + ->where(function (Builder $missingPurchaseClientQuery): void { + $missingPurchaseClientQuery + ->whereDoesntHave('sourcePurchaseItem.purchase') + ->orWhereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + ->whereNull('nombre_apellido')); + }) + ->whereHas('user', fn (Builder $userQuery): Builder => $userQuery + ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])); + }); + }) + ->orWhereHas('scannerUser', fn (Builder $scannerQuery): Builder => $scannerQuery + ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])); + + if (ctype_digit($search)) { + $searchQuery + ->orWhere('tickets.id', (int) $search) + ->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery + ->where('compra_id', (int) $search)); + } + + if ($amount !== null) { + $searchQuery->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery + ->where('precio_unitario', $amount)); + } + }); + } + + private function searchAmount(string $search): ?string + { + $value = preg_replace('/[\s$]/u', '', trim($search)); + if ($value === null || $value === '') { + return null; + } + + if (preg_match('/^\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/', $value) === 1) { + $value = str_replace(['.', ','], ['', '.'], $value); + } elseif (preg_match('/^\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/', $value) === 1) { + $value = str_replace(',', '', $value); + } elseif (preg_match('/^\d+(?:[.,]\d{1,2})?$/', $value) === 1) { + $value = str_replace(',', '.', $value); + } else { + return null; + } + + return number_format((float) $value, 2, '.', ''); + } + + /** @param Builder $query */ + private function applyProductFilter(Builder $query, string $category, string $product): void + { + $category = $this->normalizedCategory($category); + + if (in_array($category, ['alojamientos', 'camping'], true)) { + $this->whereVariantDefinition($query, 'tipo_alojamiento', $product); + + return; + } + + if (in_array($category, ['comidas', 'comida'], true)) { + $this->whereVariantDefinition($query, 'horario', $product); + + return; + } + + $query->whereHas('sourceCatalogItem', fn (Builder $itemQuery): Builder => $itemQuery + ->where('slug', $product)); + } + + /** @param Builder $query */ + private function applyTypeFilter(Builder $query, string $category, string $type): void + { + $attribute = match ($this->normalizedCategory($category)) { + 'comidas', 'comida' => 'servicio', + 'merchandising' => 'color', + default => null, + }; + + if ($attribute !== null) { + $this->whereVariantDefinition($query, $attribute, $type); + } + } + + /** @param Builder $query */ + private function applyEventDateFilter(Builder $query, string $date): void + { + $query + ->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery + ->whereRaw('LOWER(nombre) IN (?, ?)', ['comidas', 'comida'])) + ->whereHas('sourceVariant', function (Builder $variantQuery) use ($date): void { + $variantQuery->where(function (Builder $dateQuery) use ($date): void { + $dateQuery + ->whereHas('eventDate', fn (Builder $eventDateQuery): Builder => $eventDateQuery + ->whereDate('date', $date)) + ->orWhereHas('eventDates', fn (Builder $eventDateQuery): Builder => $eventDateQuery + ->whereDate('date', $date)); + }); + }); + } + + /** @param Builder $query */ + private function applySizeFilter(Builder $query, string $category, string $size): void + { + if ($this->normalizedCategory($category) === 'merchandising') { + $this->whereVariantDefinition($query, 'talle', $size); + } + } + + /** @param Builder $query */ + private function whereVariantDefinition(Builder $query, string $attribute, string $value): void + { + $query->whereHas('sourceVariant.definitions', fn (Builder $definitionQuery): Builder => $definitionQuery + ->where('value', $value) + ->whereHas('itemAttribute.attribute', fn (Builder $attributeQuery): Builder => $attributeQuery + ->where('codigo', $attribute))); + } + + /** @param Builder $query */ + private function applyStatusFilter(Builder $query, ?string $status): void + { + if ($status === null || $status === '') { + return; + } + + if ($status === Ticket::STATUS_USED) { + $query->whereNotNull('used_at'); + + return; + } + + $matchingIds = (clone $query) + ->whereNull('used_at') + ->with(TicketValidityResolver::RELATIONS) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->status === $status) + ->pluck('id'); + + $query->whereIn('tickets.id', $matchingIds); + } + + private function normalizedCategory(string $category): string + { + return mb_strtolower(trim($category)); + } + + /** + * @param Builder $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' => $this->purchaseItemColumnQuery('compra_id'), + 'id' => 'tickets.id', + 'amount' => $this->purchaseItemColumnQuery('precio_unitario'), + 'scanned_by' => User::query() + ->withTrashed() + ->select('nombre_apellido') + ->whereColumn('users.id', 'tickets.scanner_user_id'), + 'product' => $tenant->codigo === 'fiesta_futbol_infantil' + ? null + : $this->purchaseItemColumnQuery('item_nombre'), + default => null, + }; + + if ($sortExpression === null) { + return false; + } + + $query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id'); + + return true; + } + + /** @return Builder */ + private function purchaseItemColumnQuery(string $column): Builder + { + return PurchaseItem::query() + ->select($column) + ->whereColumn('compra_items.id', 'tickets.source_purchase_item_id') + ->limit(1); + } + + /** + * @param Collection $tickets + * @param array{sort_by?: string|null, sort_direction?: string|null} $filters + * @return Collection + */ + 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 $tickets + * @param array{page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + 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(); + } +} diff --git a/app/Domains/Ticket/Services/LoadTestTicketDatasetService.php b/app/Domains/Ticket/Services/LoadTestTicketDatasetService.php new file mode 100644 index 0000000..89bf279 --- /dev/null +++ b/app/Domains/Ticket/Services/LoadTestTicketDatasetService.php @@ -0,0 +1,229 @@ + + * } + */ + public function prepare( + string $tenantCode, + int $ticketCount, + int $scannerCount, + int $ownerCount, + ?int $catalogItemId = null, + ?int $variantId = null, + ?string $runId = null, + ): array { + $this->validateInput($tenantCode, $ticketCount, $scannerCount, $ownerCount); + + $tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail(); + $catalogItem = $this->resolveCatalogItem($tenant, $catalogItemId); + $variant = $this->resolveVariant($catalogItem, $variantId); + $runId ??= now()->format('Ymd-His').'-'.Str::lower(Str::random(6)); + + if (preg_match('/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/', $runId) !== 1) { + throw new InvalidArgumentException('run_id contiene caracteres inválidos o es demasiado largo.'); + } + + if ($variant !== null && ! $this->validityResolver->resolveVariant($variant)->isValid()) { + throw new InvalidArgumentException( + 'La variante seleccionada no tiene una vigencia activa y resoluble.' + ); + } + + $scanners = $this->scanners($tenant, $catalogItem, $scannerCount); + $owners = $this->owners($tenant, $ownerCount); + $tokens = $scanners->map(function (User $scanner): string { + $scanner->tokens()->where('name', 'load-test-scanner')->delete(); + + return $scanner->createToken( + 'load-test-scanner', + ['scanner'], + now()->addMinutes((int) config('sanctum.expiration', 720)), + )->plainTextToken; + })->values(); + + $rows = []; + $remaining = $ticketCount; + $ownerIndex = 0; + $scannerIndex = 0; + $batchSize = min(500, max(1, (int) ceil($ticketCount / $ownerCount))); + + while ($remaining > 0) { + $quantity = min($batchSize, $remaining); + $owner = $owners[$ownerIndex % $owners->count()]; + $tickets = $this->ticketGenerator->generate( + $catalogItem, + $owner, + $quantity, + $variant?->getKey(), + ); + + foreach ($tickets as $ticket) { + if (! $ticket->is_valid) { + throw new InvalidArgumentException( + 'La configuración seleccionada genera tickets que no están vigentes.' + ); + } + + $rows[] = [ + 'scanner_token' => $tokens[$scannerIndex % $tokens->count()], + 'ticket_uuid' => $ticket->ticket, + 'expected_status' => 200, + ]; + $scannerIndex++; + } + + $remaining -= $quantity; + $ownerIndex++; + } + + return [ + 'run_id' => $runId, + 'tenant_code' => $tenant->codigo, + 'catalog_item_id' => $catalogItem->getKey(), + 'variant_id' => $variant?->getKey(), + 'tickets' => count($rows), + 'scanners' => $scanners->count(), + 'owners' => $owners->count(), + 'rows' => $rows, + ]; + } + + private function validateInput( + string $tenantCode, + int $ticketCount, + int $scannerCount, + int $ownerCount, + ): void { + foreach ([ + 'tickets' => [$ticketCount, 100_000], + 'scanners' => [$scannerCount, 10_000], + 'owners' => [$ownerCount, 100_000], + ] as $name => [$value, $maximum]) { + if ($value < 1 || $value > $maximum) { + throw new InvalidArgumentException("{$name} debe estar entre 1 y {$maximum}."); + } + } + + if ($scannerCount > $ticketCount || $ownerCount > $ticketCount) { + throw new InvalidArgumentException( + 'La cantidad de scanners y propietarios no puede superar la cantidad de tickets.' + ); + } + } + + private function resolveCatalogItem(Tenant $tenant, ?int $catalogItemId): CatalogItem + { + $query = $tenant->catalogItems() + ->where('has_tickets', true) + ->where('type', CatalogItemType::Standard->value); + + if ($catalogItemId !== null) { + $query->whereKey($catalogItemId); + } + + $catalogItem = $query->first(); + + if ($catalogItem === null) { + throw new InvalidArgumentException( + 'No se encontró un producto estándar con tickets habilitados para el tenant.' + ); + } + + if ($tenant->requiresScannerCategoryValidation() && $catalogItem->category_id === null) { + throw new InvalidArgumentException( + 'El producto debe tener una categoría para autorizar a los scanners.' + ); + } + + return $catalogItem; + } + + private function resolveVariant(CatalogItem $catalogItem, ?int $variantId): ?Variant + { + if ($variantId === null) { + return null; + } + + $variant = $catalogItem->variants()->whereKey($variantId)->first(); + + if ($variant === null) { + throw new InvalidArgumentException('La variante no pertenece al producto seleccionado.'); + } + + return $variant; + } + + /** @return Collection */ + private function scanners(Tenant $tenant, CatalogItem $catalogItem, int $count): Collection + { + return Collection::times($count, function (int $number) use ($tenant, $catalogItem): User { + $scanner = User::query()->updateOrCreate( + ['email' => $this->email($tenant, 'scanner', $number)], + [ + 'nombre_apellido' => "Load test scanner {$number}", + 'password' => Str::password(32), + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $tenant->codigo, + ], + ); + if ($tenant->requiresScannerCategoryValidation()) { + $scanner->scanCategories()->syncWithoutDetaching([$catalogItem->category_id]); + } + + return $scanner; + }); + } + + /** @return Collection */ + private function owners(Tenant $tenant, int $count): Collection + { + return Collection::times($count, function (int $number) use ($tenant): User { + $owner = User::query()->updateOrCreate( + ['email' => $this->email($tenant, 'owner', $number)], + [ + 'nombre_apellido' => "Load test owner {$number}", + 'password' => Str::password(32), + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $tenant->codigo, + ], + ); + + return $owner; + }); + } + + private function email(Tenant $tenant, string $kind, int $number): string + { + $tenantSlug = Str::lower(preg_replace('/[^a-z0-9]+/i', '-', $tenant->codigo)); + + return "loadtest+{$tenantSlug}.{$kind}.{$number}@shopit.test"; + } +} diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index bda45ac..a3d6c7e 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -23,13 +23,13 @@ class TicketGeneratorService User $user, int $quantity = 1, ?int $sourceVariantId = null, - ?int $sourcePurchaseId = null, + ?int $sourcePurchaseItemId = null, ): Collection { if ($quantity < 1) { throw TicketGenerationException::invalidQuantity(); } - return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection { + return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseItemId): Collection { $targets = $this->resolveTargets( $catalogItem, $quantity, @@ -37,7 +37,7 @@ class TicketGeneratorService ); return $targets->map(function (array $target) use ( - $sourcePurchaseId, + $sourcePurchaseItemId, $user, ): Ticket { $item = $target['catalog_item']; @@ -45,7 +45,7 @@ class TicketGeneratorService $ticket = Ticket::query()->create([ 'tenant_code' => $item->tenant_code, 'ticket' => (string) Str::uuid(), - 'source_purchase_id' => $sourcePurchaseId, + 'source_purchase_item_id' => $sourcePurchaseItemId, 'source_catalog_item_id' => $item->getKey(), 'source_variant_id' => $variant?->getKey(), 'used_at' => null, diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index a266d30..d4ced7f 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -6,7 +6,8 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ ## Modelo -- `Ticket`: pertenece a tenant y usuario, y conserva referencias a compra, producto, variante y usuario escáner. +- `Ticket`: pertenece a tenant y usuario, y conserva referencias al ítem de compra que lo generó, producto, + variante y usuario escáner. La compra se obtiene a través de su ítem. - El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no persisten una copia de esos textos. - `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos. @@ -23,6 +24,33 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe 3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia. 4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen. +## Datos descartables para pruebas de carga + +En ambientes `local`, `testing`, `staging`, `homo` u `homologation`, el comando siguiente crea tickets +válidos, identidades scanner con tokens Sanctum y un dataset JSON importable por Postman: + +```bash +php artisan load-test:tickets:prepare loadtest-evento \ + --tickets=40000 \ + --scanners=100 \ + --owners=1000 \ + --catalog-item=123 \ + --run=evento-001 +``` + +El tenant debe existir y se recomienda que sea exclusivo para carga. Si no se indica `--catalog-item`, +se usa el primer producto estándar del tenant con tickets habilitados. `--variant` +es opcional; al indicarlo, su configuración de vigencia debe estar activa y ser resoluble. Sin variante, +los tickets tienen vigencia irrestricta. + +El archivo se escribe por defecto en `storage/app/private/load-tests/` y contiene tokens secretos, por +lo que no debe versionarse. Para limpiar el tenant después de la ejecución: + +```bash +php artisan tenants:reset-transactions loadtest-evento --dry-run +php artisan tenants:reset-transactions loadtest-evento +``` + ## Endpoints Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: @@ -30,6 +58,12 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: - `GET /tickets`. - `POST /tickets/pdf`. +Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú +`adminapp.tickets`: + +- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye + `scanned_tickets` y `total_tickets` para el tenant autenticado. + `TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas. ## Dependencias y reglas diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php new file mode 100644 index 0000000..f2602cc --- /dev/null +++ b/app/Domains/Ticket/routes/adminapp.php @@ -0,0 +1,18 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('tickets', [TicketController::class, 'index']) + ->middleware('tenant.menu:adminapp.tickets') + ->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'); + }); diff --git a/app/Domains/Ticket/routes/api.php b/app/Domains/Ticket/routes/api.php index da5305b..8bcdb8e 100644 --- a/app/Domains/Ticket/routes/api.php +++ b/app/Domains/Ticket/routes/api.php @@ -11,3 +11,4 @@ Route::prefix('tenants/{tenant:codigo}') }); require __DIR__.'/scanner.php'; +require __DIR__.'/adminapp.php'; diff --git a/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php new file mode 100644 index 0000000..87c41fb --- /dev/null +++ b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php @@ -0,0 +1,82 @@ +where('code', 'main.adminapp')->exists()) { + // Reference data is added by seeders on fresh installations. + return; + } + + $now = now(); + + DB::transaction(function () use ($now): void { + DB::table('menues')->updateOrInsert( + ['code' => self::MENU_CODE], + [ + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'content_type' => 'dynamic', + 'static_content_schema' => null, + 'route' => '/admin/tickets', + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->where('tenant_code', '!=', self::TENANT_CODE) + ->delete(); + + if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => self::TENANT_CODE, + 'menu_code' => self::MENU_CODE, + ], + [ + 'static_content' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + DB::table('roles') + ->whereIn('codigo', ['admin', 'adminapp']) + ->pluck('codigo') + ->each(function (string $roleCode): void { + DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => self::MENU_CODE, + ]); + }); + }); + } + + public function down(): void + { + DB::transaction(function (): void { + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->delete(); + + DB::table('roles_menues') + ->where('menu_codigo', self::MENU_CODE) + ->delete(); + + DB::table('menues') + ->where('code', self::MENU_CODE) + ->delete(); + }); + } +}; diff --git a/database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php b/database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php new file mode 100644 index 0000000..444cec2 --- /dev/null +++ b/database/migrations/2026_09_02_000000_add_source_purchase_item_id_to_tickets.php @@ -0,0 +1,94 @@ +foreignId('source_purchase_item_id') + ->nullable() + ->after('source_purchase_id') + ->constrained('compra_items') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + + DB::table('tickets') + ->whereNotNull('source_purchase_id') + ->whereNull('source_purchase_item_id') + ->orderBy('id') + ->chunkById(500, function (Collection $tickets): void { + $purchaseIds = $tickets->pluck('source_purchase_id')->unique()->values(); + $itemsByPurchase = DB::table('compra_items') + ->whereIn('compra_id', $purchaseIds) + ->get(['id', 'compra_id', 'source_catalog_item_id', 'source_variant_id']) + ->groupBy('compra_id'); + $bundleIds = $itemsByPurchase->flatten(1) + ->pluck('source_catalog_item_id') + ->filter() + ->unique() + ->values(); + $componentsByBundle = DB::table('bundle_components') + ->whereIn('bundle_catalog_item_id', $bundleIds) + ->get([ + 'bundle_catalog_item_id', + 'component_catalog_item_id', + 'component_variant_id', + ]) + ->groupBy('bundle_catalog_item_id'); + + foreach ($tickets as $ticket) { + $candidates = collect($itemsByPurchase->get($ticket->source_purchase_id, [])) + ->filter(function (object $item) use ($ticket, $componentsByBundle): bool { + if ($this->sameCatalogTarget($item, $ticket)) { + return true; + } + + return collect($componentsByBundle->get($item->source_catalog_item_id, [])) + ->contains(fn (object $component): bool => $this->sameBundleTarget($component, $ticket)); + }) + ->pluck('id') + ->unique() + ->values(); + + if ($candidates->count() === 1) { + DB::table('tickets')->where('id', $ticket->id)->update([ + 'source_purchase_item_id' => $candidates->sole(), + ]); + } + } + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('source_purchase_item_id'); + }); + } + + private function sameCatalogTarget(object $item, object $ticket): bool + { + return (int) $item->source_catalog_item_id === (int) $ticket->source_catalog_item_id + && $this->sameNullableId($item->source_variant_id, $ticket->source_variant_id); + } + + private function sameBundleTarget(object $component, object $ticket): bool + { + return (int) $component->component_catalog_item_id === (int) $ticket->source_catalog_item_id + && $this->sameNullableId($component->component_variant_id, $ticket->source_variant_id); + } + + private function sameNullableId(mixed $left, mixed $right): bool + { + return $left === null || $right === null + ? $left === null && $right === null + : (int) $left === (int) $right; + } +}; diff --git a/database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php b/database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php new file mode 100644 index 0000000..0c6ec71 --- /dev/null +++ b/database/migrations/2026_09_02_010000_remove_source_purchase_id_from_tickets.php @@ -0,0 +1,73 @@ +whereNotNull('source_purchase_id') + ->whereNull('source_purchase_item_id') + ->orderBy('id') + ->limit(20) + ->pluck('id'); + + if ($unresolved->isNotEmpty()) { + throw new RuntimeException( + 'No se puede eliminar tickets.source_purchase_id: hay tickets sin un compra_item inequívoco. ' + .'IDs: '.$unresolved->implode(', ') + ); + } + + $inconsistent = DB::table('tickets') + ->join('compra_items', 'compra_items.id', '=', 'tickets.source_purchase_item_id') + ->whereNotNull('tickets.source_purchase_id') + ->whereColumn('tickets.source_purchase_id', '!=', 'compra_items.compra_id') + ->orderBy('tickets.id') + ->limit(20) + ->pluck('tickets.id'); + + if ($inconsistent->isNotEmpty()) { + throw new RuntimeException( + 'No se puede eliminar tickets.source_purchase_id: hay referencias de compra inconsistentes. ' + .'IDs: '.$inconsistent->implode(', ') + ); + } + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('source_purchase_id'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->foreignId('source_purchase_id') + ->nullable() + ->after('description') + ->constrained('compras') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + + DB::table('tickets') + ->whereNotNull('source_purchase_item_id') + ->orderBy('id') + ->chunkById(500, function (Collection $tickets): void { + $purchaseIdsByItem = DB::table('compra_items') + ->whereIn('id', $tickets->pluck('source_purchase_item_id')) + ->pluck('compra_id', 'id'); + + foreach ($tickets as $ticket) { + DB::table('tickets')->where('id', $ticket->id)->update([ + 'source_purchase_id' => $purchaseIdsByItem->get($ticket->source_purchase_item_id), + ]); + } + }); + } +}; diff --git a/database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php b/database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php new file mode 100644 index 0000000..cf636d3 --- /dev/null +++ b/database/migrations/2026_09_02_020000_restrict_deletion_of_purchase_items_with_tickets.php @@ -0,0 +1,38 @@ +dropForeign(['source_purchase_item_id']); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->foreign('source_purchase_item_id') + ->references('id') + ->on('compra_items') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropForeign(['source_purchase_item_id']); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->foreign('source_purchase_item_id') + ->references('id') + ->on('compra_items') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } +}; diff --git a/database/migrations/2026_09_03_000000_add_soft_deletes_to_users_table.php b/database/migrations/2026_09_03_000000_add_soft_deletes_to_users_table.php new file mode 100644 index 0000000..0ab6b93 --- /dev/null +++ b/database/migrations/2026_09_03_000000_add_soft_deletes_to_users_table.php @@ -0,0 +1,23 @@ +softDeletes(); + }); + + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2026_09_03_000100_scope_user_email_unique_to_active_accounts.php b/database/migrations/2026_09_03_000100_scope_user_email_unique_to_active_accounts.php new file mode 100644 index 0000000..91e0acc --- /dev/null +++ b/database/migrations/2026_09_03_000100_scope_user_email_unique_to_active_accounts.php @@ -0,0 +1,28 @@ +dropUnique(['email']); + $table->string('active_email') + ->nullable() + ->storedAs('CASE WHEN `deleted_at` IS NULL THEN LOWER(`email`) ELSE NULL END'); + $table->unique('active_email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropUnique(['active_email']); + $table->dropColumn('active_email'); + $table->unique('email'); + }); + } +}; diff --git a/database/migrations/2026_09_03_000200_scope_google_id_unique_to_active_accounts.php b/database/migrations/2026_09_03_000200_scope_google_id_unique_to_active_accounts.php new file mode 100644 index 0000000..1ee6159 --- /dev/null +++ b/database/migrations/2026_09_03_000200_scope_google_id_unique_to_active_accounts.php @@ -0,0 +1,28 @@ +dropUnique(['google_id']); + $table->string('active_google_id') + ->nullable() + ->storedAs('CASE WHEN `deleted_at` IS NULL THEN `google_id` ELSE NULL END'); + $table->unique('active_google_id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropUnique(['active_google_id']); + $table->dropColumn('active_google_id'); + $table->unique('google_id'); + }); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index dbab420..11885b5 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -78,6 +78,12 @@ class MenuSeeder extends Seeder 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/staff', ], + [ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/tickets', + ], [ 'code' => 'adminapp.fiesta-futbol-infantil.entradas', 'label' => 'Entradas', @@ -270,6 +276,7 @@ class MenuSeeder extends Seeder 'fiesta_futbol_infantil', ]; $fiestaCategoryMenuCodes = [ + 'adminapp.tickets', 'adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.merchandising', diff --git a/phpunit.xml b/phpunit.xml index a3b9b10..0bce1ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,7 @@ - + diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index a63f36e..2aaf14a 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -11,7 +11,7 @@ declare(strict_types=1); $root = dirname(__DIR__); chdir($root); -$command = escapeshellarg(PHP_BINARY).' artisan route:list --path=api --json'; +$command = escapeshellarg(PHP_BINARY).' artisan route:list --json'; $routeJson = shell_exec($command); if (! is_string($routeJson) || trim($routeJson) === '') { @@ -20,6 +20,11 @@ if (! is_string($routeJson) || trim($routeJson) === '') { } $routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR); +$routes = array_values(array_filter( + $routes, + fn (array $route): bool => str_starts_with($route['uri'], 'api/') + || $route['uri'] === 'auth/google/redirect', +)); const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; @@ -177,6 +182,10 @@ function bodyFor(string $method, string $uri): ?array function queryFor(string $uri): array { return match ($uri) { + 'auth/google/redirect' => [ + ['key' => 'tenant', 'value' => '{{tenant_code}}'], + ['key' => 'return_url', 'value' => '{{storefront_url}}'], + ], 'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']], 'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']], 'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']], @@ -415,6 +424,7 @@ foreach ($tree as $section => $folders) { $variables = [ 'base_url' => 'http://localhost:8000', + 'storefront_url' => 'http://localhost:4200', 'token' => '', 'admin_token' => '', 'scanner_token' => '', 'user_email' => 'usuario@example.com', 'user_password' => 'Password!123', 'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123', diff --git a/resources/views/pdf/adminapp/sales.blade.php b/resources/views/pdf/adminapp/sales.blade.php index a413c20..5f35264 100644 --- a/resources/views/pdf/adminapp/sales.blade.php +++ b/resources/views/pdf/adminapp/sales.blade.php @@ -47,11 +47,7 @@ {{ $sale->created_at?->copy()->timezone($timeZone)->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', - } }} + {{ \App\Domains\Purchase\Models\Purchase::adminStatusNameFor($sale->status) ?? $sale->status }} ${{ number_format((float) $sale->total, 2, ',', '.') }} {{ (int) ($sale->tickets_count ?? 0) }} diff --git a/resources/views/pdf/adminapp/tickets.blade.php b/resources/views/pdf/adminapp/tickets.blade.php new file mode 100644 index 0000000..86105b2 --- /dev/null +++ b/resources/views/pdf/adminapp/tickets.blade.php @@ -0,0 +1,57 @@ + + + + + + + +

Listado de tickets

+

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

+ +
+ Tickets incluidos: {{ $tickets->count() }} +   ·   + Escaneados: {{ $tickets->where('status', 'Usado')->count() }} +
+ + + + + @foreach ($columns as $column) + + @endforeach + + + + @forelse ($tickets as $ticket) + + @foreach ($columns as $column) + + @endforeach + + @empty + + @endforelse + +
$column['type'] === 'currency'])>{{ $column['label'] }}
$column['type'] === 'currency', + 'ticket-id' => $column['key'] === 'ticket', + ])>{{ $ticket[$column['key']] }}
No hay tickets para los criterios seleccionados.
+ + diff --git a/routes/console.php b/routes/console.php index e9597ab..1d98696 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,9 +3,11 @@ use App\Domains\Auth\Services\AdminCredentialVerifier; use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Purchase\Services\TenantTransactionResetService; +use App\Domains\Ticket\Services\LoadTestTicketDatasetService; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; +use Illuminate\Support\Facades\Storage; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); @@ -24,6 +26,79 @@ Schedule::command('reservations:expire') ->everyMinute() ->withoutOverlapping(); +Artisan::command( + 'load-test:tickets:prepare + {tenant : Código del tenant que recibirá los datos de carga} + {--tickets=1000 : Cantidad de tickets válidos} + {--scanners=10 : Cantidad de identidades scanner} + {--owners=100 : Cantidad de propietarios de tickets} + {--catalog-item= : Producto estándar con tickets habilitados} + {--variant= : Variante opcional que define la vigencia} + {--run= : Identificador opcional de la ejecución} + {--output= : Ruta relativa dentro del disco local}', + function (LoadTestTicketDatasetService $datasetService): int { + if (! app()->environment(['local', 'testing', 'staging', 'homo', 'homologation'])) { + $this->error('Este comando sólo puede ejecutarse en local u homologación.'); + + return self::FAILURE; + } + + $requestedOutput = filled($this->option('output')) + ? ltrim((string) $this->option('output'), '/\\') + : null; + + if ($requestedOutput !== null + && ($requestedOutput === '' || str_contains(str_replace('\\', '/', $requestedOutput), '../'))) { + $this->error('La ruta de salida debe permanecer dentro del disco local.'); + + return self::FAILURE; + } + + try { + $dataset = $datasetService->prepare( + (string) $this->argument('tenant'), + (int) $this->option('tickets'), + (int) $this->option('scanners'), + (int) $this->option('owners'), + filled($this->option('catalog-item')) ? (int) $this->option('catalog-item') : null, + filled($this->option('variant')) ? (int) $this->option('variant') : null, + filled($this->option('run')) ? (string) $this->option('run') : null, + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $relativePath = $requestedOutput !== null + ? $requestedOutput + : "load-tests/{$dataset['run_id']}.postman.json"; + $payload = json_encode($dataset['rows'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if (! is_string($payload) || ! Storage::disk('local')->put($relativePath, $payload.PHP_EOL)) { + $this->error('No se pudo escribir el dataset.'); + + return self::FAILURE; + } + + $this->info('Dataset de carga generado.'); + $this->table(['Dato', 'Valor'], [ + ['run_id', $dataset['run_id']], + ['tenant', $dataset['tenant_code']], + ['catalog_item_id', $dataset['catalog_item_id']], + ['variant_id', $dataset['variant_id'] ?? 'sin variante (vigencia irrestricta)'], + ['tickets', $dataset['tickets']], + ['scanners', $dataset['scanners']], + ['owners', $dataset['owners']], + ['archivo', Storage::disk('local')->path($relativePath)], + ]); + $this->warn('El archivo contiene tokens secretos. No lo subas al repositorio.'); + $this->comment("Limpieza: php artisan tenants:reset-transactions {$dataset['tenant_code']}"); + + return self::SUCCESS; + }, +)->purpose('Prepare disposable scanner tickets and a Postman performance dataset'); + Artisan::command( 'tenants:reset-transactions {tenant : Código del tenant que se limpiará} diff --git a/tests/Feature/Auth/RegisterControllerTest.php b/tests/Feature/Auth/RegisterControllerTest.php index 2580c61..8dafa91 100644 --- a/tests/Feature/Auth/RegisterControllerTest.php +++ b/tests/Feature/Auth/RegisterControllerTest.php @@ -135,4 +135,36 @@ class RegisterControllerTest extends TestCase 'password', ]); } + + public function test_it_can_reuse_the_email_of_a_soft_deleted_user(): void + { + $deletedUser = User::factory()->create([ + 'email' => 'reused@example.com', + ]); + $deletedUser->delete(); + + $response = $this->postJson('/api/register', [ + 'nombre_apellido' => 'New Account', + 'email' => 'reused@example.com', + 'password' => 'Secret!123', + 'password_confirmation' => 'Secret!123', + ])->assertCreated(); + + $newUserId = $response->json('data.id'); + + $this->assertNotSame($deletedUser->id, $newUserId); + $this->assertSame( + 2, + User::withTrashed()->where('email', 'reused@example.com')->count(), + ); + $this->assertDatabaseHas('users', [ + 'id' => $deletedUser->id, + 'active_email' => null, + ]); + $this->assertDatabaseHas('users', [ + 'id' => $newUserId, + 'active_email' => 'reused@example.com', + 'deleted_at' => null, + ]); + } } diff --git a/tests/Feature/Forms/AdminAppSaleFormControllerTest.php b/tests/Feature/Forms/AdminAppSaleFormControllerTest.php index da33dd9..4b90bcb 100644 --- a/tests/Feature/Forms/AdminAppSaleFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppSaleFormControllerTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Forms; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Purchase\Models\Purchase; @@ -34,12 +36,7 @@ class AdminAppSaleFormControllerTest extends TestCase 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', - ]); + $tenant = $this->createTenant('acme'); Sanctum::actingAs(User::factory()->create([ 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $tenant->codigo, @@ -47,13 +44,15 @@ class AdminAppSaleFormControllerTest extends TestCase $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_IN_REVIEW) - ->assertJsonPath('data.statuses.3.code', Purchase::STATUS_PAID) - ->assertJsonPath('data.statuses.6.code', Purchase::STATUS_EXPIRED); + ->assertJsonCount(count(Purchase::adminStatusCodes()), 'data.statuses') + ->assertJsonPath('data.statuses.0.value', Purchase::ADMIN_STATUS_INCOMPLETE) + ->assertJsonPath('data.statuses.0.label', 'Por completar datos') + ->assertJsonPath('data.statuses.0.real_statuses.0', Purchase::STATUS_CREATED) + ->assertJsonPath('data.statuses.1.value', Purchase::ADMIN_STATUS_AWAITING_PAYMENT) + ->assertJsonPath('data.statuses.1.real_statuses.0', Purchase::STATUS_PENDING_PAYMENT) + ->assertJsonPath('data.statuses.1.real_statuses.1', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.statuses.2.value', Purchase::ADMIN_STATUS_CONFIRMED) + ->assertJsonPath('data.statuses.3.value', Purchase::ADMIN_STATUS_CANCELLED); } public function test_a_customer_cannot_get_the_sale_form(): void @@ -65,4 +64,34 @@ class AdminAppSaleFormControllerTest extends TestCase $this->getJson('/api/v1/adminapp/forms/sale')->assertForbidden(); } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#ffffff', + 'danger_color' => '#dc3545', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + 'website_type_code' => 'onticket', + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } } diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php new file mode 100644 index 0000000..43d19da --- /dev/null +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -0,0 +1,357 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_tickets_menu(): void + { + $tenant = $this->createTenant('tenant_without_tickets'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertNotFound(); + } + + public function test_it_returns_the_common_ticket_filter_fields_for_other_tenants(): void + { + $tenant = $this->createTenant('another_tenant'); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/forms/tickets-filter') + ->assertOk() + ->assertExactJson([ + 'data' => [ + 'code' => 'tickets_filter', + 'action' => '/api/v1/adminapp/tenant/tickets', + 'method' => 'GET', + 'columns' => $this->commonColumns(), + 'fields' => [ + [ + 'name' => 'date', + 'query_param' => 'date', + 'label' => 'Fecha', + 'type' => 'date', + 'required' => false, + 'default' => null, + ], + [ + 'name' => 'status', + 'query_param' => 'status', + 'label' => 'Estado', + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => 'Estado', + 'options' => [ + ['value' => 'active', 'label' => 'Activo'], + ['value' => 'used', 'label' => 'Usado'], + ['value' => 'expired', 'label' => 'Vencido'], + ], + ], + ], + ], + ]); + } + + public function test_it_returns_the_complete_nested_form_for_fiesta_futbol_infantil(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter') + ->assertOk() + ->assertJsonPath('data.fields.0.name', 'category') + ->assertJsonPath('data.fields.0.query_param', 'category') + ->assertJsonPath('data.fields.1.name', 'product') + ->assertJsonPath('data.fields.1.query_param', 'product') + ->assertJsonPath('data.fields.1.depends_on', 'category') + ->assertJsonPath('data.fields.2.name', 'type') + ->assertJsonPath('data.fields.2.query_param', 'type') + ->assertJsonPath('data.fields.2.depends_on', 'product') + ->assertJsonPath('data.fields.3.name', 'size') + ->assertJsonPath('data.fields.3.query_param', 'size') + ->assertJsonPath('data.fields.3.depends_on', 'type') + ->assertJsonPath('data.fields.4.name', 'date') + ->assertJsonPath('data.fields.4.query_param', 'date') + ->assertJsonPath('data.fields.4.depends_on', 'category') + ->assertJsonPath('data.fields.5.name', 'status') + ->assertJsonPath('data.fields.5.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) + ->assertJsonPath('data.columns.4.key', 'date') + ->assertJsonPath('data.columns.4.sortable', false) + ->assertJsonPath('data.columns.5.key', 'size') + ->assertJsonPath('data.columns.5.sortable', false); + + $categories = collect($response->json('data.fields.0.options')); + $this->assertSame( + ['Entradas', 'Camping', 'Comida', 'Merchandising'], + $categories->pluck('label')->all(), + ); + + $entries = $categories->firstWhere('value', 'entradas'); + $this->assertSame('Abono', $entries['children'][0]['options'][0]['label']); + $this->assertTrue($entries['children'][0]['options'][0]['children'][0]['disabled']); + $this->assertTrue($entries['children'][1]['disabled']); + + $camping = $categories->firstWhere('value', 'alojamientos'); + $this->assertSame( + ['Carpa', 'Motorhome'], + collect($camping['children'][0]['options'])->pluck('label')->all(), + ); + $this->assertTrue($camping['children'][0]['options'][0]['children'][0]['disabled']); + + $merchandise = $categories->firstWhere('value', 'merchandising'); + $merchandiseProduct = $merchandise['children'][0]['options'][0]; + $this->assertSame('Camiseta', $merchandiseProduct['label']); + $this->assertSame( + ['Verde', 'Blanco'], + collect($merchandiseProduct['children'][0]['options'])->pluck('label')->all(), + ); + $this->assertSame( + ['14', 'S', 'M', 'L', 'XL', 'XXL'], + collect($merchandiseProduct['children'][0]['options'][0]['children'][0]['options']) + ->pluck('label') + ->all(), + ); + } + + public function test_merchandise_sizes_are_scoped_to_the_selected_product_and_color(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $merchandise = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'camiseta') + ->with([ + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + ]) + ->firstOrFail(); + $whiteXxl = $merchandise->variants->first(fn ($variant): bool => $variant->selectionValues()->get('color') === 'Blanco' + && $variant->selectionValues()->get('talle') === 'XXL'); + $this->assertNotNull($whiteXxl); + app(CatalogService::class)->deleteVariant($whiteXxl); + + $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertOk(); + $merchandiseCategory = collect($response->json('data.fields.0.options')) + ->firstWhere('value', 'merchandising'); + $product = $merchandiseCategory['children'][0]['options'][0]; + $colors = collect($product['children'][0]['options']); + $greenSizes = collect($colors->firstWhere('value', 'Verde')['children'][0]['options']) + ->pluck('value'); + $whiteSizes = collect($colors->firstWhere('value', 'Blanco')['children'][0]['options']) + ->pluck('value'); + + $this->assertContains('XXL', $greenSizes); + $this->assertNotContains('XXL', $whiteSizes); + } + + public function test_a_food_schedule_is_only_returned_when_a_variant_exists_for_the_date(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with([ + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + + $food->variants + ->filter(fn ($variant): bool => $variant->selectionValues()->get('horario') === 'Almuerzo') + ->each->delete(); + + $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertOk(); + $foodCategory = collect($response->json('data.fields.0.options'))->firstWhere('value', 'comidas'); + $schedules = collect($foodCategory['children'][0]['options'])->pluck('value'); + + $this->assertNotContains('Almuerzo', $schedules); + $this->assertContains('Desayuno', $schedules); + $this->assertContains('Cena', $schedules); + } + + public function test_a_deleted_food_variant_remains_in_the_filter_when_a_ticket_references_it(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with([ + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + $historicalVariant = $food->variants->first(function ($variant): bool { + return $variant->selectedEventDates()->first()?->date->format('d/m') === '12/10' + && $variant->selectionValues()->get('horario') === 'Cena'; + }); + $this->assertNotNull($historicalVariant); + $historicalService = $historicalVariant->selectionValues()->get('servicio'); + + $ticket = Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $admin->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $historicalVariant->id, + ]); + + $catalogService = app(CatalogService::class); + foreach ($food->variants as $variant) { + $catalogService->deleteVariant($variant); + } + + $this->assertTrue(CatalogItem::withTrashed()->findOrFail($food->id)->trashed()); + + $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertOk(); + $foodCategory = collect($response->json('data.fields.0.options'))->firstWhere('value', 'comidas'); + $products = collect($foodCategory['children'][0]['options']); + + $this->assertCount(1, $products); + $this->assertSame('Cena', $products->first()['label']); + $this->assertSame( + [$historicalService], + collect($products->first()['children'][0]['options'])->pluck('label')->all(), + ); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ + 'category' => 'comidas', + 'product' => $products->first()['value'], + 'type' => $historicalService, + 'date' => '2026-10-12', + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.values.category', 'Comida') + ->assertJsonPath('data.0.values.product', 'Cena') + ->assertJsonPath('data.0.values.type', $historicalService) + ->assertJsonPath('data.0.values.date', '12/10') + ->assertJsonPath('data.0.values.size', '-'); + } + + private function createFiestaFutbolInfantilTenant(): Tenant + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + + return $tenant->refresh(); + } + + /** @return list> */ + 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' => 'id', 'label' => 'ID', 'type' => 'text', 'sortable' => true, 'sort_param' => 'id', 'width' => '8%'], + ['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 + { + $headerLogo = $this->createAttachment("{$code}-header.png"); + $footerLogo = $this->createAttachment("{$code}-footer.png"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function grantTicketsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'route' => '/admin/tickets', + ]); + + $tenant->menues()->attach($menu->code); + } +} diff --git a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php new file mode 100644 index 0000000..5bb4ef3 --- /dev/null +++ b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php @@ -0,0 +1,155 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket') + ->assertUnauthorized(); + } + + public function test_it_returns_nested_ticket_options_using_the_frontend_presentation_mapping(): void + { + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta-futbol-infantil.test', + 'primary_color' => '#00973F', + 'secondary_color' => '#A0A0A0', + 'danger_color' => '#FF8888', + 'success_color' => '#198754', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#015327', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $response = $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket') + ->assertOk() + ->assertExactJson([ + 'data' => [ + 'statuses' => [ + ['value' => 'active', 'label' => 'Activo'], + ['value' => 'used', 'label' => 'Usado'], + ['value' => 'expired', 'label' => 'Vencido'], + ], + 'categories' => [ + [ + 'value' => 'entradas', + 'label' => 'Entradas', + 'products' => [[ + 'value' => 'abono', + 'label' => 'Abono', + 'types' => [], + ]], + ], + [ + 'value' => 'alojamientos', + 'label' => 'Camping', + 'products' => [ + ['value' => 'Carpa', 'label' => 'Carpa', 'types' => []], + ['value' => 'Motorhome', 'label' => 'Motorhome', 'types' => []], + ], + ], + [ + 'value' => 'comidas', + 'label' => 'Comida', + 'products' => [ + [ + 'value' => (string) $tenant->eventDates[0]->id, + 'label' => '09/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[1]->id, + 'label' => '10/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[2]->id, + 'label' => '11/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[3]->id, + 'label' => '12/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + ], + ], + [ + 'value' => 'merchandising', + 'label' => 'Merchandising', + 'products' => [[ + 'value' => 'camiseta', + 'label' => 'Camiseta', + 'types' => [ + ['value' => 'Verde', 'label' => 'Verde'], + ['value' => 'Blanco', 'label' => 'Blanco'], + ], + ]], + ], + ], + ], + ]); + + $response->assertJsonMissingPath('data.categories.0.products.0.category'); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index bba638e..26dbc29 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -161,7 +161,7 @@ class NotificationMailServiceTest extends TestCase 'precio' => 25, 'has_tickets' => true, ]); - $purchase->items()->create([ + $purchaseItem = $purchase->items()->create([ 'source_catalog_item_id' => $catalogItem->id, 'nombre' => 'Entrada', 'descripcion' => 'Entrada general', @@ -175,7 +175,7 @@ class NotificationMailServiceTest extends TestCase $ticket = Ticket::query()->create([ 'tenant_code' => $this->tenant->codigo, 'ticket' => fake()->uuid(), - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $this->user->id, ]); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 8a667e5..e7fd951 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -10,6 +10,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; @@ -714,11 +715,11 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") - ->assertUnprocessable() - ->assertJsonPath('code', 'purchase.expired'); + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_EXPIRED); } - public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): void + public function test_leaving_an_overdue_purchase_expires_it_before_the_expiration_job_runs(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -728,20 +729,17 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") - ->assertUnprocessable() - ->assertExactJson([ - 'code' => 'stock_reservation.expired', - 'message' => __('api.cart.reservation_expired'), - ]); + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_EXPIRED); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, - 'status' => Purchase::STATUS_CREATED, + 'status' => Purchase::STATUS_EXPIRED, 'stock_reservation_id' => $purchase->stock_reservation_id, ]); $this->assertDatabaseHas('stock_reservations', [ 'id' => $purchase->stock_reservation_id, - 'status' => 'active', + 'status' => StockReservation::STATUS_EXPIRED, ]); } diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 52d4ac1..1cc0d7f 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Sale; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Cart\Models\Cart; @@ -9,6 +11,8 @@ use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; +use App\Domains\Logging\Enums\ValueChangeActorType; +use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; @@ -34,6 +38,96 @@ class AdminAppSaleControllerTest extends TestCase WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); } + public function test_sale_modifications_use_sale_filters_and_resulting_status(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $sale = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'Ana Pérez', + 'status' => Purchase::STATUS_CANCELLED, + 'total' => '10000.00', + ]); + Purchase::query()->whereKey($sale->id)->update([ + 'created_at' => '2026-08-04 10:00:00', + ]); + + $otherSale = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'Otro cliente', + 'status' => Purchase::STATUS_PAID, + 'total' => '20000.00', + ]); + Purchase::query()->whereKey($otherSale->id)->update([ + 'created_at' => '2026-08-05 10:00:00', + ]); + + $confirmedChange = ValueChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'trackable_type' => $sale->getMorphClass(), + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => '2026-08-06 10:00:00', + 'actor_type' => ValueChangeActorType::User, + 'user_id' => $admin->id, + ]); + ValueChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'trackable_type' => $sale->getMorphClass(), + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PAID, + 'new_value' => Purchase::STATUS_CANCELLED, + 'changed_at' => '2026-08-07 10:00:00', + 'actor_type' => ValueChangeActorType::User, + 'user_id' => $admin->id, + ]); + ValueChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'trackable_type' => $otherSale->getMorphClass(), + 'trackable_id' => $otherSale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => '2026-08-08 10:00:00', + 'actor_type' => ValueChangeActorType::System, + ]); + ValueChange::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'trackable_type' => $sale->getMorphClass(), + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => '2026-08-09 10:00:00', + 'actor_type' => ValueChangeActorType::System, + ]); + + $this->getJson('/api/v1/adminapp/tenant/sales/modifications?'.http_build_query([ + 'q' => 'Ana', + 'id' => $sale->id, + 'sale_date' => '2026-08-04', + 'status' => Purchase::ADMIN_STATUS_CONFIRMED, + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $confirmedChange->id) + ->assertJsonPath('data.0.new_value', Purchase::STATUS_PAID) + ->assertJsonPath('data.0.admin_status', Purchase::ADMIN_STATUS_CONFIRMED); + + $this->getJson('/api/v1/adminapp/tenant/sales/modifications?status='. + Purchase::ADMIN_STATUS_CANCELLED) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.sale_id', $sale->id) + ->assertJsonPath('data.0.new_value', Purchase::STATUS_CANCELLED); + } + public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void { $tenant = $this->createTenant('acme'); @@ -64,6 +158,7 @@ class AdminAppSaleControllerTest extends TestCase PurchaseItem::query()->create([ 'compra_id' => $createdPurchase->id, 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, 'item_nombre' => $catalogItem->nombre, 'cantidad' => 3, 'precio_unitario' => '10000.00', @@ -88,6 +183,7 @@ class AdminAppSaleControllerTest extends TestCase PurchaseItem::query()->create([ 'compra_id' => $pendingPurchase->id, 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, 'item_nombre' => $catalogItem->nombre, 'cantidad' => 4, 'precio_unitario' => '10000.00', @@ -102,6 +198,7 @@ class AdminAppSaleControllerTest extends TestCase PurchaseItem::query()->create([ 'compra_id' => $paidPurchase->id, 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, 'item_nombre' => $catalogItem->nombre, 'cantidad' => 2, 'precio_unitario' => '10000.00', @@ -116,6 +213,7 @@ class AdminAppSaleControllerTest extends TestCase PurchaseItem::query()->create([ 'compra_id' => $supersededPurchase->id, 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, 'item_nombre' => $catalogItem->nombre, 'cantidad' => 5, 'precio_unitario' => '10000.00', @@ -124,17 +222,25 @@ class AdminAppSaleControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/sales?sort_by=id&sort_direction=asc') ->assertOk() - ->assertJsonCount(3, 'data') + ->assertJsonCount(4, 'data') ->assertJsonPath('data.0.id', $createdPurchase->id) ->assertJsonPath('data.0.quantity', 3) + ->assertJsonPath('data.0.admin_status', Purchase::ADMIN_STATUS_INCOMPLETE) + ->assertJsonPath('data.0.status_label', 'Por completar datos') ->assertJsonPath('data.1.id', $pendingPurchase->id) ->assertJsonPath('data.1.quantity', 4) + ->assertJsonPath('data.1.admin_status', Purchase::ADMIN_STATUS_AWAITING_PAYMENT) + ->assertJsonPath('data.1.status_label', 'Esperando pago') ->assertJsonPath('data.2.id', $paidPurchase->id) - ->assertJsonPath('data.2.quantity', 2); + ->assertJsonPath('data.2.quantity', 2) + ->assertJsonPath('data.2.admin_status', Purchase::ADMIN_STATUS_CONFIRMED) + ->assertJsonPath('data.2.status_label', 'Confirmado') + ->assertJsonPath('data.3.id', $supersededPurchase->id) + ->assertJsonPath('data.3.admin_status', Purchase::ADMIN_STATUS_CANCELLED) + ->assertJsonPath('data.3.status_label', 'Anulado'); $this->getJson('/api/v1/adminapp/tenant/sales?status='.Purchase::STATUS_SUPERSEDED) - ->assertOk() - ->assertJsonCount(0, 'data'); + ->assertUnprocessable(); } public function test_authentication_is_required_to_read_a_sale_detail(): void @@ -142,7 +248,7 @@ class AdminAppSaleControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized(); } - public function test_pending_payment_filter_also_returns_purchases_in_review(): void + public function test_awaiting_payment_filter_returns_every_mapped_real_status(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -160,13 +266,41 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PAID, ]); - $this->getJson('/api/v1/adminapp/tenant/sales?status=pending_payment&sort_by=id&sort_direction=asc') + $this->getJson('/api/v1/adminapp/tenant/sales?status=awaiting_payment&sort_by=id&sort_direction=asc') ->assertOk() ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $pendingPurchase->id) ->assertJsonPath('data.1.id', $reviewPurchase->id); } + public function test_cancelled_filter_returns_every_visible_mapped_real_status(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + foreach ([ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + Purchase::STATUS_SUPERSEDED, + ] as $status) { + Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => $status, + ]); + } + Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + ]); + + $this->getJson('/api/v1/adminapp/tenant/sales?status=cancelled') + ->assertOk() + ->assertJsonCount(4, 'data') + ->assertJsonPath('data.0.admin_status', Purchase::ADMIN_STATUS_CANCELLED) + ->assertJsonPath('data.0.status_label', 'Anulado'); + } + public function test_an_adminapp_user_can_read_a_sale_detail_from_its_tenant(): void { $tenant = $this->createTenant('acme'); @@ -233,6 +367,7 @@ class AdminAppSaleControllerTest extends TestCase $purchaseItem = PurchaseItem::query()->create([ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, 'item_nombre' => $catalogItem->nombre, 'cantidad' => 2, 'precio_unitario' => '12500.00', @@ -336,17 +471,28 @@ class AdminAppSaleControllerTest extends TestCase 'precio' => 20000, 'has_tickets' => true, ]); + $purchaseItem = $purchase->items()->create([ + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, + 'descripcion' => $catalogItem->descripcion, + 'slug' => $catalogItem->slug, + 'item_nombre' => $catalogItem->nombre, + 'variant_attributes' => [], + 'cantidad' => 2, + 'precio_unitario' => 10000, + 'total' => 20000, + ]); $firstTicket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => '11111111-1111-4111-8111-111111111111', - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $admin->id, ]); $usedTicket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => '22222222-2222-4222-8222-222222222222', - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $catalogItem->id, 'used_at' => now()->subMinute(), 'user_id' => $admin->id, @@ -468,6 +614,7 @@ class AdminAppSaleControllerTest extends TestCase PurchaseItem::query()->create([ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, 'item_nombre' => $catalogItem->nombre, 'cantidad' => 2, 'precio_unitario' => '10000.00', @@ -563,14 +710,34 @@ class AdminAppSaleControllerTest extends TestCase private function createTenant(string $code): Tenant { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + return Tenant::query()->create([ 'codigo' => $code, 'nombre' => ucfirst($code), 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#ffffff', + 'danger_color' => '#dc3545', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, 'website_type_code' => 'onticket', ]); } + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + private function createAdminAppUser(Tenant $tenant): User { return User::factory()->create([ diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index cc49e3b..df0e9b4 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -297,7 +297,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase ->where('total', 0) ->count()); $this->assertSame(49, DB::table('tickets') - ->where('source_purchase_id', $purchase->id) + ->whereIn('source_purchase_item_id', (clone $items)->pluck('id')) ->where('user_id', $user->id) ->where('source_catalog_item_id', $catalogItemId) ->count()); diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index 6f579d0..d253c8d 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -34,6 +34,7 @@ class MenuSeederTest extends TestCase 'adminapp.ventas' => ['Ventas', '/admin/ventas'], ]; $fiestaCategoryMenus = [ + 'adminapp.tickets' => ['Tickets', '/admin/tickets'], 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], @@ -49,7 +50,11 @@ class MenuSeederTest extends TestCase $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type); $this->assertSame('/', $adminApp->route); $this->assertSame( - array_keys([...$expectedMenus, ...$fiestaCategoryMenus]), + collect(array_keys([ + ...$expectedMenus, + ...$fiestaCategoryMenus, + 'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], + ]))->sort()->values()->all(), $adminApp->children->pluck('code')->sort()->values()->all() ); $this->assertTrue( diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php index e7c9434..194cd35 100644 --- a/tests/Feature/Staff/StaffControllerTest.php +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Staff; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; @@ -9,9 +11,11 @@ use App\Domains\Catalog\Models\Category; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; +use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -30,11 +34,21 @@ class StaffControllerTest extends TestCase Event::fake([PasswordResetRequested::class]); $this->seed(AuthorizationSeeder::class); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); $this->tenant = Tenant::query()->create([ 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.test', 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#cc0000', + 'success_color' => '#008800', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, ]); $this->admin = User::factory()->create([ 'rol_codigo' => RoleCode::AdminApp->value, @@ -92,8 +106,56 @@ class StaffControllerTest extends TestCase 'categoria_id' => $firstCategory->id, ]); + $ticket = Ticket::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $this->admin->id, + 'used_at' => now(), + 'scanner_user_id' => $staffId, + ]); + $accessTokenId = User::query() + ->findOrFail($staffId) + ->createToken('scanner', ['scanner']) + ->accessToken + ->getKey(); + $this->deleteJson("/api/v1/adminapp/tenant/staff/{$staffId}")->assertNoContent(); - $this->assertDatabaseMissing('users', ['id' => $staffId]); + $this->assertSoftDeleted('users', ['id' => $staffId]); + $this->assertDatabaseHas('users', [ + 'id' => $staffId, + 'email' => 'ada@example.test', + 'active_email' => null, + ]); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $accessTokenId]); + $this->assertDatabaseHas('category_scanners', [ + 'user_id' => $staffId, + 'categoria_id' => $secondCategory->id, + ]); + $this->assertSame($staffId, $ticket->fresh()->scanner_user_id); + $this->assertSame('Ada Byron', $ticket->fresh()->scannerUser?->nombre_apellido); + + $this->getJson('/api/v1/adminapp/tenant/staff') + ->assertOk() + ->assertJsonCount(0, 'data'); + + $replacementResponse = $this->postJson('/api/v1/adminapp/tenant/staff', [ + 'nombre_apellido' => 'Nueva Ada', + 'dni' => '11223344', + 'email' => 'ADA@example.test', + 'category_ids' => [$firstCategory->id], + ])->assertSuccessful() + ->assertJsonPath('data.email', 'ada@example.test'); + + $replacementStaffId = $replacementResponse->json('data.id'); + $this->assertNotSame($staffId, $replacementStaffId); + $this->assertSame('Ada Byron', $ticket->fresh()->scannerUser?->nombre_apellido); + $this->assertDatabaseHas('users', [ + 'id' => $replacementStaffId, + 'email' => 'ada@example.test', + 'active_email' => 'ada@example.test', + 'deleted_at' => null, + ]); + } public function test_admin_cannot_assign_another_tenants_category(): void @@ -104,6 +166,14 @@ class StaffControllerTest extends TestCase 'nombre' => 'Other', 'dominio' => 'other.test', 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#cc0000', + 'success_color' => '#008800', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $this->tenant->header_logo_id, + 'footer_logo_id' => $this->tenant->footer_logo_id, ]); $foreignCategory = Category::query()->create([ 'tenant_code' => $otherTenant->codigo, @@ -183,6 +253,16 @@ class StaffControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden(); } + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + private function createCategory(string $name): Category { return Category::query()->create([ diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php new file mode 100644 index 0000000..0ac1a67 --- /dev/null +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -0,0 +1,626 @@ +app->register(DomPdfServiceProvider::class); + + $this->seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_tickets_menu(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertNotFound(); + } + + public function test_it_lists_only_tickets_from_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $ticket = $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.tenant_code', $tenant->codigo) + ->assertJsonPath('data.0.values.id', $ticket->id) + ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) + ->assertJsonMissingPath('data.0.values.ticket') + ->assertJsonPath('data.0.values.date', '-') + ->assertJsonPath('data.0.values.size', '-') + ->assertJsonMissingPath('data.0.date') + ->assertJsonPath('meta.total', 1); + } + + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $matching = $this->createTicket($tenant, $admin); + $this->createTicket($tenant, $admin); + + $this->getJson("/api/v1/adminapp/tenant/tickets?q={$matching->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8)) + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + public function test_it_searches_by_visible_client_and_scanner_names(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $client = User::factory()->create([ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'María Cliente', + ]); + $scanner = $this->createAdminAppUser($tenant); + $scanner->update(['nombre_apellido' => 'Carlos Inspector']); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $clientTicket = $this->createTicket($tenant, $client); + $scannerTicket = $this->createTicket($tenant, $admin, [ + 'scanner_user_id' => $scanner->id, + 'used_at' => now(), + ]); + $this->createTicket($tenant, $admin); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'maría'])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $clientTicket->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'INSPECTOR'])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $scannerTicket->id); + } + + public function test_it_searches_by_order_number_and_formatted_unit_amount(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + foreach (range(1, 10) as $number) { + $this->createPurchase($tenant, $admin, now()->subMinutes($number)->toDateTimeString()); + } + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'entrada-busqueda', + 'nombre' => 'Entrada búsqueda', + 'precio' => '1234.50', + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => 'Cliente de la compra', + 'total' => '1234.50', + ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $item->id, + 'nombre' => $item->nombre, + 'descripcion' => '', + 'slug' => $item->slug, + 'item_nombre' => $item->nombre, + 'cantidad' => 1, + 'precio_unitario' => '1234.50', + 'total' => '1234.50', + ]); + $matching = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $item->id, + ]); + $this->createTicket($tenant, $admin); + + $this->getJson("/api/v1/adminapp/tenant/tickets?q={$purchase->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'cliente de la compra'])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + foreach (['1234,50', '$1.234,50', '1,234.50'] as $amount) { + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => $amount])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + } + } + + public function test_it_sorts_by_ticket_id_before_paginating(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $ids = collect(range(1, 4)) + ->map(fn (): int => $this->createTicket($tenant, $admin)->id) + ->all(); + + $query = http_build_query([ + 'sort_by' => 'id', + 'sort_direction' => 'asc', + 'per_page' => 2, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.$query) + ->assertOk() + ->assertJsonPath('data.0.values.id', $ids[0]) + ->assertJsonPath('data.1.values.id', $ids[1]) + ->assertJsonPath('meta.total', 4) + ->assertJsonPath('meta.last_page', 2); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.$query.'&page=2') + ->assertOk() + ->assertJsonPath('data.0.values.id', $ids[2]) + ->assertJsonPath('data.1.values.id', $ids[3]); + } + + 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 + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $this->createTicket($tenant, $admin)->update(['used_at' => now()]); + $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 0) + ->assertJsonPath('total_tickets', 0); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('scanned_tickets', 1) + ->assertJsonPath('total_tickets', 2); + } + + public function test_it_returns_structured_variant_properties(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'remera', + 'nombre' => 'Remera', + 'precio' => '8000.00', + 'has_tickets' => true, + ]); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Talle', + 'type' => FieldType::Select, + ]); + $attribute->options()->create(['value' => 'xl', 'label' => 'XL']); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'sort_order' => 1, + ]); + $variant = Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => 'xl', + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => 'Nombre Apellido', + 'total' => '8000.00', + ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'nombre' => 'Remera', + 'descripcion' => '', + 'slug' => 'remera', + 'item_nombre' => 'Remera', + 'cantidad' => 1, + 'precio_unitario' => '8000.00', + 'total' => '8000.00', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'scanner_user_id' => $admin->id, + 'used_at' => now(), + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.source_purchase_item_id', $purchaseItem->id) + ->assertJsonPath('data.0.order_number', $purchase->id) + ->assertJsonPath('data.0.product', 'Remera') + ->assertJsonPath('data.0.amount', '8000.00') + ->assertJsonPath('data.0.status', Ticket::STATUS_USED) + ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) + ->assertJsonPath('data.0.variant_properties.0.code', 'size') + ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') + ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') + ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); + } + + public function test_it_applies_the_ticket_filter_form_values(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $date = $tenant->eventDates()->orderByDesc('date')->firstOrFail(); + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with([ + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + $dinner = $food->variants->first(fn (Variant $variant): bool => $variant + ->selectedEventDates()->contains('id', $date->id) + && $variant->selectionValues()->get('horario') === 'Cena'); + $lunch = $food->variants->first(fn (Variant $variant): bool => $variant + ->selectedEventDates()->contains('id', $date->id) + && $variant->selectionValues()->get('horario') === 'Almuerzo'); + $this->assertNotNull($dinner); + $this->assertNotNull($lunch); + $dinnerService = $dinner->selectionValues()->get('servicio'); + $otherDate = $tenant->eventDates()->where('id', '!=', $date->id)->firstOrFail(); + $otherDinner = $food->variants->first(fn (Variant $variant): bool => $variant + ->selectedEventDates()->contains('id', $otherDate->id) + && $variant->selectionValues()->get('horario') === 'Cena' + && $variant->selectionValues()->get('servicio') === $dinnerService); + $this->assertNotNull($otherDinner); + + $matchingPurchase = $this->createPurchase($tenant, $admin, '2026-08-20 10:00:00'); + $otherPurchase = $this->createPurchase($tenant, $admin, '2026-08-21 10:00:00'); + $dinnerItem = $this->createPurchaseItem($matchingPurchase, $food, $dinner); + $lunchItem = $this->createPurchaseItem($matchingPurchase, $food, $lunch); + $otherDinnerItem = $this->createPurchaseItem($otherPurchase, $food, $otherDinner); + $matching = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $dinnerItem->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $dinner->id, + 'used_at' => now(), + ]); + $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $lunchItem->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $lunch->id, + 'used_at' => now(), + ]); + $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $otherDinnerItem->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $otherDinner->id, + 'used_at' => now(), + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ + 'category' => 'comidas', + 'product' => 'Cena', + 'type' => $dinnerService, + 'date' => $date->date->format('Y-m-d'), + 'status' => Ticket::STATUS_USED, + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.values.product', 'Cena') + ->assertJsonPath('data.0.values.type', $dinnerService) + ->assertJsonPath('data.0.values.date', $date->date->format('d/m')) + ->assertJsonPath('scanned_tickets', 1) + ->assertJsonPath('total_tickets', 1); + } + + public function test_it_exposes_and_filters_merchandise_color_and_size(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $merchandise = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'camiseta') + ->with([ + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + $greenMedium = $merchandise->variants->first(fn (Variant $variant): bool => $variant->selectionValues()->get('color') === 'Verde' + && $variant->selectionValues()->get('talle') === 'M'); + $whiteMedium = $merchandise->variants->first(fn (Variant $variant): bool => $variant->selectionValues()->get('color') === 'Blanco' + && $variant->selectionValues()->get('talle') === 'M'); + $this->assertNotNull($greenMedium); + $this->assertNotNull($whiteMedium); + + $matching = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $merchandise->id, + 'source_variant_id' => $greenMedium->id, + ]); + $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $merchandise->id, + 'source_variant_id' => $whiteMedium->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ + 'category' => 'merchandising', + 'product' => 'camiseta', + 'type' => 'Verde', + 'size' => 'M', + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.values.product', 'Camiseta') + ->assertJsonPath('data.0.values.type', 'Verde') + ->assertJsonPath('data.0.values.date', '-') + ->assertJsonPath('data.0.values.size', 'M'); + } + + public function test_it_filters_computed_active_and_expired_statuses(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with('variants') + ->firstOrFail(); + $expired = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $food->variants->firstOrFail()->id, + ]); + $active = $this->createTicket($tenant, $admin); + + $this->travelTo('2026-10-20 12:00:00'); + + $this->getJson('/api/v1/adminapp/tenant/tickets?status=expired') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $expired->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?status=active') + ->assertOk() + ->assertJsonCount(1, 'data') + ->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 + { + $headerLogo = $this->createAttachment("{$code}-header.png"); + $footerLogo = $this->createAttachment("{$code}-footer.png"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function createPurchase(Tenant $tenant, User $user, string $createdAt): Purchase + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => $user->nombre_apellido, + 'total' => '0.00', + ]); + $purchase->forceFill(['created_at' => $createdAt, 'updated_at' => $createdAt])->saveQuietly(); + + return $purchase; + } + + private function createPurchaseItem(Purchase $purchase, CatalogItem $item, Variant $variant): PurchaseItem + { + return $purchase->items()->create([ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'nombre' => $item->nombre, + 'descripcion' => $item->descripcion, + 'slug' => $item->slug, + 'item_nombre' => $item->nombre, + 'variant_attributes' => [], + 'cantidad' => 1, + 'precio_unitario' => 0, + 'total' => 0, + ]); + } + + private function grantTicketsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'route' => '/admin/tickets', + ]); + + $tenant->menues()->attach($menu->code); + } + + /** @param array $attributes */ + private function createTicket(Tenant $tenant, User $user, array $attributes = []): Ticket + { + return Ticket::query()->create(array_merge([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $user->id, + ], $attributes)); + } +} diff --git a/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php b/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php new file mode 100644 index 0000000..b7e2867 --- /dev/null +++ b/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php @@ -0,0 +1,107 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + $tenant = $this->createTenant('acme'); + $category = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Entradas de carga', + ]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'slug' => 'entrada-load-test', + 'nombre' => 'Entrada load test', + 'descripcion' => 'Ticket descartable', + 'precio' => 100, + 'has_tickets' => true, + ]); + + $exitCode = Artisan::call('load-test:tickets:prepare', [ + 'tenant' => $tenant->codigo, + '--tickets' => 4, + '--scanners' => 2, + '--owners' => 2, + '--catalog-item' => $catalogItem->id, + '--run' => 'test-run', + '--output' => 'load-tests/test-run.postman.json', + ]); + + $this->assertSame(0, $exitCode, Artisan::output()); + + Storage::disk('local')->assertExists('load-tests/test-run.postman.json'); + $rows = json_decode( + Storage::disk('local')->get('load-tests/test-run.postman.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $this->assertCount(4, $rows); + $this->assertCount(4, collect($rows)->pluck('ticket_uuid')->unique()); + $this->assertCount(2, collect($rows)->pluck('scanner_token')->unique()); + $this->assertSame([200], collect($rows)->pluck('expected_status')->unique()->values()->all()); + $this->assertDatabaseCount('tickets', 4); + $this->assertCount(2, Ticket::query()->distinct()->pluck('user_id')); + $this->assertDatabaseCount('category_scanners', 2); + + $first = $rows[0]; + $this->withToken($first['scanner_token']) + ->postJson("/api/v1/scanner/tickets/{$first['ticket_uuid']}/scan") + ->assertOk() + ->assertJsonPath('data.ticket', $first['ticket_uuid']); + + $this->assertNotNull( + Ticket::query()->where('ticket', $first['ticket_uuid'])->firstOrFail()->used_at + ); + } + + private function createTenant(string $code): Tenant + { + $logo = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => "tests/{$code}-logo.png", + 'filename' => "{$code}-logo.png", + 'type' => 'image', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 1, + ]); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#ff7006', + 'secondary_color' => '#777777', + 'danger_color' => '#e04a4a', + 'success_color' => '#81bc73', + 'header_bg_color' => '#313131', + 'footer_bg_color' => '#313131', + 'header_logo_id' => $logo->id, + 'footer_logo_id' => $logo->id, + 'website_type_code' => 'onticket', + ]); + } +} diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index e9eda8a..b1415cc 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -19,6 +19,7 @@ use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Services\TicketGeneratorService; use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; @@ -246,14 +247,59 @@ class TicketGeneratorServiceTest extends TestCase $purchase = $this->createPurchase($item, 1, $variant->id); $purchase->markAsPaid(); + $purchaseItem = $purchase->items()->sole(); $this->assertDatabaseHas('tickets', [ - 'source_purchase_id' => $purchase->id, + 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, ]); } + public function test_bundle_tickets_reference_the_purchase_item_that_generated_them(): void + { + $component = $this->createTicketableItem('bundle-component'); + $bundle = $this->createBundle('purchased-bundle'); + $bundle->bundleComponents()->create([ + 'component_catalog_item_id' => $component->id, + 'quantity' => 2, + ]); + $purchase = $this->createPurchase($bundle, 2); + $purchaseItem = $purchase->items()->sole(); + + $purchase->markAsPaid(); + + $this->assertCount(4, $purchaseItem->tickets); + $this->assertTrue($purchaseItem->tickets->every( + fn ($ticket): bool => $ticket->source_purchase_item_id === $purchaseItem->id + && $ticket->source_catalog_item_id === $component->id + )); + $this->assertCount(4, $purchase->tickets); + } + + public function test_a_purchase_item_that_generated_tickets_cannot_be_deleted(): void + { + $item = $this->createTicketableItem('protected-purchase-item'); + $purchase = $this->createPurchase($item, 1); + $purchase->markAsPaid(); + $purchaseItem = $purchase->items()->sole(); + + $this->expectException(QueryException::class); + + $purchaseItem->delete(); + } + + public function test_a_purchase_with_generated_tickets_cannot_be_deleted(): void + { + $item = $this->createTicketableItem('protected-purchase'); + $purchase = $this->createPurchase($item, 1); + $purchase->markAsPaid(); + + $this->expectException(QueryException::class); + + $purchase->delete(); + } + public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void { $item = $this->createTicketableItem('scheduled-meal'); diff --git a/tests/TestCase.php b/tests/TestCase.php index 9b31cc8..19a12de 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,17 +8,22 @@ use RuntimeException; abstract class TestCase extends BaseTestCase { + /** + * Boot the application only when the test database is explicitly isolated. + */ public function createApplication(): Application { $app = parent::createApplication(); - $connection = (string) $app['config']->get('database.default'); - $database = (string) $app['config']->get("database.connections.{$connection}.database"); - $usesInMemorySqlite = $connection === 'sqlite' && $database === ':memory:'; - if (! $usesInMemorySqlite && ! str_ends_with(strtolower($database), '_test')) { - throw new RuntimeException( - "Unsafe test database [{$database}]. Tests may only use an in-memory SQLite database or a database ending in _test.", - ); + $database = (string) $app['config']->get( + 'database.connections.'.$app['config']->get('database.default').'.database' + ); + + if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) { + throw new RuntimeException(sprintf( + 'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].', + $database !== '' ? $database : '(empty)' + )); } return $app; diff --git a/tests/Unit/Forms/SaleFormServiceTest.php b/tests/Unit/Forms/SaleFormServiceTest.php index e2f5518..741e2d4 100644 --- a/tests/Unit/Forms/SaleFormServiceTest.php +++ b/tests/Unit/Forms/SaleFormServiceTest.php @@ -8,20 +8,27 @@ use PHPUnit\Framework\TestCase; class SaleFormServiceTest extends TestCase { - public function test_it_returns_every_purchase_status_as_a_form_option(): void + public function test_it_returns_admin_statuses_with_their_real_statuses(): void { $form = (new SaleFormService)->get(); - $this->assertSame(Purchase::statuses(), array_column($form['statuses'], 'code')); + $this->assertSame(Purchase::adminStatusCodes(), array_column($form['statuses'], 'value')); $this->assertSame([ - 'Creada', + 'Por completar datos', 'Esperando pago', - 'En revisión', - 'Confirmada', - 'Cancelada', - 'Rechazada', - 'Vencida', - 'Reemplazada', - ], array_column($form['statuses'], 'name')); + 'Confirmado', + 'Anulado', + ], array_column($form['statuses'], 'label')); + $this->assertSame([ + [Purchase::STATUS_CREATED], + [Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW], + [Purchase::STATUS_PAID], + [ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + Purchase::STATUS_SUPERSEDED, + ], + ], array_column($form['statuses'], 'real_statuses')); } } diff --git a/tests/Unit/Purchase/PurchaseAdminStatusTest.php b/tests/Unit/Purchase/PurchaseAdminStatusTest.php new file mode 100644 index 0000000..f1fa7f5 --- /dev/null +++ b/tests/Unit/Purchase/PurchaseAdminStatusTest.php @@ -0,0 +1,69 @@ +assertSame($adminStatus, Purchase::adminStatusFor($realStatus)); + $this->assertSame($adminStatusName, Purchase::adminStatusNameFor($realStatus)); + $this->assertContains( + $realStatus, + Purchase::realStatusesForAdminStatus($adminStatus), + ); + } + + /** @return iterable */ + public static function statusMappings(): iterable + { + yield 'created' => [ + Purchase::STATUS_CREATED, + Purchase::ADMIN_STATUS_INCOMPLETE, + 'Por completar datos', + ]; + yield 'pending payment' => [ + Purchase::STATUS_PENDING_PAYMENT, + Purchase::ADMIN_STATUS_AWAITING_PAYMENT, + 'Esperando pago', + ]; + yield 'in review' => [ + Purchase::STATUS_IN_REVIEW, + Purchase::ADMIN_STATUS_AWAITING_PAYMENT, + 'Esperando pago', + ]; + yield 'paid' => [ + Purchase::STATUS_PAID, + Purchase::ADMIN_STATUS_CONFIRMED, + 'Confirmado', + ]; + yield 'cancelled' => [ + Purchase::STATUS_CANCELLED, + Purchase::ADMIN_STATUS_CANCELLED, + 'Anulado', + ]; + yield 'rejected' => [ + Purchase::STATUS_REJECTED, + Purchase::ADMIN_STATUS_CANCELLED, + 'Anulado', + ]; + yield 'expired' => [ + Purchase::STATUS_EXPIRED, + Purchase::ADMIN_STATUS_CANCELLED, + 'Anulado', + ]; + yield 'superseded' => [ + Purchase::STATUS_SUPERSEDED, + Purchase::ADMIN_STATUS_CANCELLED, + 'Anulado', + ]; + } +} diff --git a/tests/Unit/Sale/SaleModificationResourceTest.php b/tests/Unit/Sale/SaleModificationResourceTest.php index 1fe1386..3f2e3a4 100644 --- a/tests/Unit/Sale/SaleModificationResourceTest.php +++ b/tests/Unit/Sale/SaleModificationResourceTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Sale; use App\Domains\Logging\Models\ValueChange; +use App\Domains\Purchase\Models\Purchase; use App\Domains\Sale\Resources\AdminApp\SaleModificationResource; use Carbon\CarbonImmutable; use Illuminate\Http\Request; @@ -29,5 +30,7 @@ class SaleModificationResourceTest extends TestCase $this->assertSame('2026-08-24T17:53:00+00:00', $data['changed_at']); $this->assertSame('2026-08-24', $data['date']); $this->assertSame('17:53:00', $data['time']); + $this->assertSame(Purchase::ADMIN_STATUS_CANCELLED, $data['admin_status']); + $this->assertSame('Anulado', $data['status_label']); } } diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php new file mode 100644 index 0000000..0bf122e --- /dev/null +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -0,0 +1,154 @@ +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('Vianda', $sheet->getCell('D2')->getValue()); + $this->assertSame('12/10', $sheet->getCell('E2')->getValue()); + $this->assertSame(8000.0, $sheet->getCell('G2')->getValue()); + $this->assertSame('25', $sheet->getCell('I2')->getValue()); + $this->assertSame('Usado', $sheet->getCell('J2')->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('25', $html); + $this->assertStringNotContainsString('00000000-0000-0000-0000-000000000001', $html); + $this->assertStringContainsString('Almuerzo', $html); + $this->assertStringContainsString('Vianda', $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 */ + private function row(): array + { + return [ + 'order_number' => 15, + 'category' => 'Comida', + 'product' => 'Almuerzo', + 'type' => 'Vianda', + 'date' => '12/10', + 'size' => '-', + 'amount' => 8000.0, + 'client' => 'Cliente Test', + 'id' => 25, + '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; + } +}