From f2d2fd5aaf4a144a824af94e7e0a9d49c190ef83 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Tue, 3 Feb 2026 15:23:45 -0600 Subject: [PATCH] feat: implementar controlador y modelo para solicitudes de factura, incluyendo validaciones y rutas --- .../App/InventorySerialController.php | 15 ++ ...taController.php => InvoiceController.php} | 118 ++++++----- .../App/InvoiceRequestController.php | 198 ++++++++++++++++++ .../App/InvoiceRequestProcessRequest.php | 36 ++++ .../App/InvoiceRequestRejectRequest.php | 38 ++++ app/Http/Requests/App/InvoiceStoreRequest.php | 50 +++++ app/Models/Client.php | 8 + app/Models/InvoiceRequest.php | 77 +++++++ app/Models/Sale.php | 9 + app/Models/SaleDetail.php | 2 +- ...3_093420_create_invoice_requests_table.php | 38 ++++ routes/api.php | 16 +- 12 files changed, 543 insertions(+), 62 deletions(-) rename app/Http/Controllers/App/{FacturaDataController.php => InvoiceController.php} (53%) create mode 100644 app/Http/Controllers/App/InvoiceRequestController.php create mode 100644 app/Http/Requests/App/InvoiceRequestProcessRequest.php create mode 100644 app/Http/Requests/App/InvoiceRequestRejectRequest.php create mode 100644 app/Http/Requests/App/InvoiceStoreRequest.php create mode 100644 app/Models/InvoiceRequest.php create mode 100644 database/migrations/2026_02_03_093420_create_invoice_requests_table.php diff --git a/app/Http/Controllers/App/InventorySerialController.php b/app/Http/Controllers/App/InventorySerialController.php index 46bf35e..b892e28 100644 --- a/app/Http/Controllers/App/InventorySerialController.php +++ b/app/Http/Controllers/App/InventorySerialController.php @@ -41,6 +41,21 @@ public function index(Inventory $inventario, Request $request) ]); } + public function search(Request $request) + { + $serialNumber = $request->input('serial_number'); + + $serial = InventorySerial::with(['inventory.price', 'inventory.category']) + ->where('serial_number', $serialNumber) + ->where('status', 'disponible') + ->first(); + + return response()->json([ + 'status' => 'success', + 'data' => ['serial' => $serial] + ]); + } + /** * Mostrar un serial específico */ diff --git a/app/Http/Controllers/App/FacturaDataController.php b/app/Http/Controllers/App/InvoiceController.php similarity index 53% rename from app/Http/Controllers/App/FacturaDataController.php rename to app/Http/Controllers/App/InvoiceController.php index 41b6485..65c148d 100644 --- a/app/Http/Controllers/App/FacturaDataController.php +++ b/app/Http/Controllers/App/InvoiceController.php @@ -3,12 +3,13 @@ namespace App\Http\Controllers\App; use App\Http\Controllers\Controller; +use App\Http\Requests\App\InvoiceStoreRequest; use App\Models\Client; +use App\Models\InvoiceRequest; use App\Models\Sale; -use Illuminate\Http\Request; use Notsoweb\ApiResponse\Enums\ApiResponse; -class FacturaDataController extends Controller +class InvoiceController extends Controller { /** * Muestra los datos de la venta para el formulario de facturación. @@ -20,26 +21,42 @@ public function show(string $invoiceNumber) 'client', 'details.inventory.category', 'details.serials', - 'user:id,name,email' - ]) - ->first(); + 'user:id,name,email', + 'invoiceRequests' => function ($query) { + $query->orderBy('requested_at', 'desc'); + }, + ])->first(); if (!$sale) { - return ApiResponse::INTERNAL_ERROR->response([ + return ApiResponse::NOT_FOUND->response([ 'message' => 'Venta no encontrada' ]); } - return ApiResponse::OK->response([ + // Verificar si ya tiene solicitud pendiente o procesada + $existingRequest = $sale->invoiceRequests + ->whereIn('status', [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED]) + ->first(); + + if ($existingRequest) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Esta venta ya tiene una solicitud de facturación ' . + ($existingRequest->status === InvoiceRequest::STATUS_PENDING ? 'pendiente' : 'procesada'), + 'invoice_request' => $existingRequest, + ]); + } + + return ApiResponse::OK->response([ 'sale' => $this->formatSaleData($sale), 'client' => $sale->client, + 'invoice_requests' => $sale->invoiceRequests, ]); } /** * Guarda los datos fiscales del cliente para la venta. */ - public function store(Request $request, string $invoiceNumber) + public function store(InvoiceStoreRequest $request, string $invoiceNumber) { $sale = Sale::where('invoice_number', $invoiceNumber) ->with('details.serials') @@ -51,9 +68,14 @@ public function store(Request $request, string $invoiceNumber) ]); } - if ($sale->client_id) { - return ApiResponse::NO_CONTENT->response([ - 'message' => 'Esta venta ya tiene datos de facturación registrados' + $existingRequest = InvoiceRequest::where('sale_id', $sale->id) + ->whereIn('status', [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED]) + ->first(); + + if ($existingRequest) { + return ApiResponse::NO_CONTENT->response([ + 'message' => 'Esta venta ya tiene una solicitud de facturación ' . + ($existingRequest->status === InvoiceRequest::STATUS_PENDING ? 'pendiente' : 'procesada') ]); } @@ -64,56 +86,35 @@ public function store(Request $request, string $invoiceNumber) ]); } - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|max:255', - 'phone' => 'nullable|string|max:20', - 'address' => 'nullable|string|max:500', - 'rfc' => 'required|string|size:13|regex:/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i', - 'razon_social' => 'required|string|max:255', - 'regimen_fiscal' => 'required|string|max:100', - 'cp_fiscal' => 'required|string|size:5|regex:/^\d{5}$/', - 'uso_cfdi' => 'required|string|max:100', - ], [ - 'rfc.regex' => 'El RFC no tiene un formato válido', - 'rfc.size' => 'El RFC debe tener 13 caracteres', - 'cp_fiscal.regex' => 'El código postal debe ser de 5 dígitos', - 'cp_fiscal.size' => 'El código postal debe ser de 5 dígitos', - 'name.required' => 'El nombre es obligatorio', - 'email.required' => 'El correo electrónico es obligatorio', - 'email.email' => 'El correo electrónico debe ser válido', - 'razon_social.required' => 'La razón social es obligatoria', - 'regimen_fiscal.required' => 'El régimen fiscal es obligatorio', - 'uso_cfdi.required' => 'El uso de CFDI es obligatorio', - ]); // Buscar si ya existe un cliente con ese RFC - $client = Client::where('rfc', strtoupper($validated['rfc']))->first(); + $client = Client::where('rfc', strtoupper($request->rfc))->first(); if ($client) { // Actualizar datos del cliente existente $client->update([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'phone' => $validated['phone'] ?? $client->phone, - 'address' => $validated['address'] ?? $client->address, - 'razon_social' => $validated['razon_social'], - 'regimen_fiscal' => $validated['regimen_fiscal'], - 'cp_fiscal' => $validated['cp_fiscal'], - 'uso_cfdi' => $validated['uso_cfdi'], + 'name' => $request->name, + 'email' => $request->email, + 'phone' => $request->phone ?? $client->phone, + 'address' => $request->address ?? $client->address, + 'razon_social' => $request->razon_social, + 'regimen_fiscal' => $request->regimen_fiscal, + 'cp_fiscal' => $request->cp_fiscal, + 'uso_cfdi' => $request->uso_cfdi, ]); } else { // Crear nuevo cliente $client = Client::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'phone' => $validated['phone'], - 'address' => $validated['address'], - 'rfc' => strtoupper($validated['rfc']), - 'razon_social' => $validated['razon_social'], - 'regimen_fiscal' => $validated['regimen_fiscal'], - 'cp_fiscal' => $validated['cp_fiscal'], - 'uso_cfdi' => $validated['uso_cfdi'], + 'name' => $request->name, + 'client_number' => strtoupper($request->rfc), + 'email' => $request->email, + 'phone' => $request->phone, + 'address' => $request->address, + 'rfc' => strtoupper($request->rfc), + 'razon_social' => $request->razon_social, + 'regimen_fiscal' => $request->regimen_fiscal, + 'cp_fiscal' => $request->cp_fiscal, + 'uso_cfdi' => $request->uso_cfdi, ]); } @@ -121,17 +122,18 @@ public function store(Request $request, string $invoiceNumber) $sale->update(['client_id' => $client->id]); // Recargar relaciones - $sale->load([ - 'client', - 'details.inventory.category', - 'details.serials', - 'user:id,name,email' + $invoiceRequest = InvoiceRequest::create([ + 'sale_id' => $sale->id, + 'client_id' => $client->id, + 'status' => InvoiceRequest::STATUS_PENDING, + 'requested_at' => now(), ]); return ApiResponse::OK->response([ - 'message' => 'Datos de facturación guardados correctamente', + 'message' => 'Solicitud de facturación guardada correctamente', 'client' => $client, - 'sale' => $this->formatSaleData($sale) + 'sale' => $this->formatSaleData($sale), + 'invoice_request' => $invoiceRequest, ]); } diff --git a/app/Http/Controllers/App/InvoiceRequestController.php b/app/Http/Controllers/App/InvoiceRequestController.php new file mode 100644 index 0000000..24a1685 --- /dev/null +++ b/app/Http/Controllers/App/InvoiceRequestController.php @@ -0,0 +1,198 @@ +has('status') && $request->status !== '') { + $query->where('status', $request->status); + } + + // Filtro por rango de fechas + if ($request->has('date_from')) { + $query->whereDate('requested_at', '>=', $request->date_from); + } + + if ($request->has('date_to')) { + $query->whereDate('requested_at', '<=', $request->date_to); + } + + // Búsqueda por folio de venta + if ($request->has('invoice_number') && $request->invoice_number !== '') { + $query->whereHas('sale', function ($q) use ($request) { + $q->where('invoice_number', 'like', '%' . $request->invoice_number . '%'); + }); + } + + // Búsqueda por cliente + if ($request->has('client_search') && $request->client_search !== '') { + $query->whereHas('client', function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->client_search . '%') + ->orWhere('rfc', 'like', '%' . $request->client_search . '%') + ->orWhere('email', 'like', '%' . $request->client_search . '%'); + }); + } + + // Ordenamiento + $sortBy = $request->get('sort_by', 'requested_at'); + $sortOrder = $request->get('sort_order', 'desc'); + $query->orderBy($sortBy, $sortOrder); + + // Paginación + $perPage = $request->get('per_page', 15); + $invoiceRequests = $query->paginate($perPage); + + return ApiResponse::OK->response([ + 'invoice_requests' => $invoiceRequests + ]); + } + + /** + * Mostrar detalles de una solicitud específica + */ + public function show($id) + { + $invoiceRequest = InvoiceRequest::with([ + 'sale.user:id,name,email', + 'sale.details:id,sale_id,inventory_id,product_name,quantity,unit_price,subtotal,discount_percentage,discount_amount', + 'sale.details:inventory_id.category_id,name', + 'sale.details.inventory:id,category_id,name,sku', + 'sale.details.inventory.category:id,name', + 'sale.details.serials:id,inventory_id,sale_detail_id,serial_number,status', + 'client', + 'processedBy:id,name,email' + ])->find($id); + + if (!$invoiceRequest) { + return ApiResponse::NOT_FOUND->response([ + 'message' => 'Solicitud de factura no encontrada' + ]); + } + + return ApiResponse::OK->response([ + 'invoice_request' => $invoiceRequest + ]); + } + + /** + * Marcar una solicitud como procesada + * + */ + public function process(InvoiceRequestProcessRequest $request, $id) + { + $invoiceRequest = InvoiceRequest::find($id); + + if (!$invoiceRequest) { + return ApiResponse::NOT_FOUND->response([ + 'message' => 'Solicitud de factura no encontrada' + ]); + } + + if ($invoiceRequest->status !== InvoiceRequest::STATUS_PENDING) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Solo se pueden procesar solicitudes pendientes', + 'current_status' => $invoiceRequest->status + ]); + } + + $invoiceRequest->markAsProcessed( + $request->user()->id, + $request->notes + ); + + $invoiceRequest->load([ + 'sale:id,invoice_number,total', + 'client:id,name,rfc,email', + 'processedBy:id,name' + ]); + + return ApiResponse::OK->response([ + 'message' => 'Solicitud marcada como procesada correctamente', + 'invoice_request' => $invoiceRequest + ]); + } + + /** + * Rechazar una solicitud + * + */ + public function reject(InvoiceRequestRejectRequest $request, $id) + { + $invoiceRequest = InvoiceRequest::find($id); + + if (!$invoiceRequest) { + return ApiResponse::NOT_FOUND->response([ + 'message' => 'Solicitud de factura no encontrada' + ]); + } + + if ($invoiceRequest->status !== InvoiceRequest::STATUS_PENDING) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Solo se pueden rechazar solicitudes pendientes', + 'current_status' => $invoiceRequest->status + ]); + } + + $invoiceRequest->markAsRejected( + $request->user()->id, + $request->notes + ); + + $invoiceRequest->load([ + 'sale:id,invoice_number,total', + 'client:id,name,rfc,email', + 'processedBy:id,name' + ]); + + return ApiResponse::OK->response([ + 'message' => 'Solicitud rechazada correctamente', + 'invoice_request' => $invoiceRequest + ]); + } + + /** + * Obtener estadísticas de solicitudes de factura + */ + public function stats() + { + $stats = [ + 'pending' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PENDING)->count(), + 'processed' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PROCESSED)->count(), + 'rejected' => InvoiceRequest::where('status', InvoiceRequest::STATUS_REJECTED)->count(), + 'total' => InvoiceRequest::count(), + 'today_pending' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PENDING) + ->whereDate('requested_at', today()) + ->count(), + 'this_month' => InvoiceRequest::whereMonth('requested_at', now()->month) + ->whereYear('requested_at', now()->year) + ->count(), + ]; + + return ApiResponse::OK->response([ + 'stats' => $stats + ]); + } +} diff --git a/app/Http/Requests/App/InvoiceRequestProcessRequest.php b/app/Http/Requests/App/InvoiceRequestProcessRequest.php new file mode 100644 index 0000000..9a7f4a3 --- /dev/null +++ b/app/Http/Requests/App/InvoiceRequestProcessRequest.php @@ -0,0 +1,36 @@ + ['nullable', 'string', 'max:500'], + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'notes.max' => 'Las notas no pueden exceder 500 caracteres', + ]; + } +} diff --git a/app/Http/Requests/App/InvoiceRequestRejectRequest.php b/app/Http/Requests/App/InvoiceRequestRejectRequest.php new file mode 100644 index 0000000..bf1b11f --- /dev/null +++ b/app/Http/Requests/App/InvoiceRequestRejectRequest.php @@ -0,0 +1,38 @@ + ['required', 'string', 'min:10', 'max:500'], + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'notes.required' => 'Debe proporcionar una razón para rechazar la solicitud', + 'notes.min' => 'La razón debe tener al menos 10 caracteres', + 'notes.max' => 'La razón no puede exceder 500 caracteres', + ]; + } +} diff --git a/app/Http/Requests/App/InvoiceStoreRequest.php b/app/Http/Requests/App/InvoiceStoreRequest.php new file mode 100644 index 0000000..0661d10 --- /dev/null +++ b/app/Http/Requests/App/InvoiceStoreRequest.php @@ -0,0 +1,50 @@ + ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:20'], + 'address' => ['nullable', 'string', 'max:500'], + 'rfc' => ['required', 'string', 'min:12', 'max:13', 'regex:/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i'], + 'razon_social' => ['required', 'string', 'max:255'], + 'regimen_fiscal' => ['required', 'string', 'max:100'], + 'cp_fiscal' => ['required', 'string', 'size:5', 'regex:/^\d{5}$/'], + 'uso_cfdi' => ['required', 'string', 'max:100'], + ]; + } + + public function messages(): array + { + return [ + 'rfc.regex' => 'El RFC no tiene un formato válido', + 'rfc.min' => 'El RFC debe tener al menos 12 caracteres', + 'rfc.max' => 'El RFC debe tener máximo 13 caracteres', + 'cp_fiscal.regex' => 'El código postal debe ser de 5 dígitos', + 'cp_fiscal.size' => 'El código postal debe ser de 5 dígitos', + 'name.required' => 'El nombre es obligatorio', + 'email.required' => 'El correo electrónico es obligatorio', + 'email.email' => 'El correo electrónico debe ser válido', + 'razon_social.required' => 'La razón social es obligatoria', + 'regimen_fiscal.required' => 'El régimen fiscal es obligatorio', + 'uso_cfdi.required' => 'El uso de CFDI es obligatorio', + ]; + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php index 003e64a..ef58001 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -67,4 +67,12 @@ public function getNetPurchasesAttribute(): float { return $this->total_purchases - $this->lifetime_returns; } + + /** + * Solicitudes de factura del cliente + */ + public function invoiceRequests(): HasMany + { + return $this->hasMany(InvoiceRequest::class); + } } diff --git a/app/Models/InvoiceRequest.php b/app/Models/InvoiceRequest.php new file mode 100644 index 0000000..a24a563 --- /dev/null +++ b/app/Models/InvoiceRequest.php @@ -0,0 +1,77 @@ + + * + * @version 1.0.0 + */ +class InvoiceRequest extends Model +{ + protected $fillable = [ + 'sale_id', + 'client_id', + 'status', + 'requested_at', + 'processed_at', + 'processed_by', + 'notes', + ]; + + protected $casts = [ + 'requested_at' => 'datetime', + 'processed_at' => 'datetime', + ]; + + const STATUS_PENDING = 'pending'; + const STATUS_PROCESSED = 'processed'; + const STATUS_REJECTED = 'rejected'; + + public function sale() + { + return $this->belongsTo(Sale::class); + } + + public function client() + { + return $this->belongsTo(Client::class); + } + + public function processedBy() + { + return $this->belongsTo(User::class, 'processed_by'); + } + + /** + * Marcar como procesada + */ + public function markAsProcessed(int $userId, ?string $notes = null): bool + { + return $this->update([ + 'status' => self::STATUS_PROCESSED, + 'processed_at' => now(), + 'processed_by' => $userId, + 'notes' => $notes ?? $this->notes, + ]); + } + + /** + * Marcar como rechazada + */ + public function markAsRejected(int $userId, ?string $notes = null): bool + { + return $this->update([ + 'status' => self::STATUS_REJECTED, + 'processed_at' => now(), + 'processed_by' => $userId, + 'notes' => $notes ?? $this->notes, + ]); + } +} diff --git a/app/Models/Sale.php b/app/Models/Sale.php index bbe1e54..ae94bf8 100644 --- a/app/Models/Sale.php +++ b/app/Models/Sale.php @@ -93,4 +93,13 @@ public function getNetTotalAttribute(): float { return (float) ($this->total - $this->getTotalReturnedAttribute()); } + + + /** + * Solicitudes de factura asociadas a esta venta + */ + public function invoiceRequests() + { + return $this->hasMany(InvoiceRequest::class); + } } diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 41d06d6..79ec22c 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -45,7 +45,7 @@ public function inventory() public function serials() { - return $this->hasMany(InventorySerial::class); + return $this->hasMany(InventorySerial::class, 'sale_detail_id'); } /** diff --git a/database/migrations/2026_02_03_093420_create_invoice_requests_table.php b/database/migrations/2026_02_03_093420_create_invoice_requests_table.php new file mode 100644 index 0000000..276c9a3 --- /dev/null +++ b/database/migrations/2026_02_03_093420_create_invoice_requests_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('sale_id')->constrained()->onDelete('cascade'); + $table->foreignId('client_id')->constrained()->onDelete('cascade'); + $table->enum('status', ['pending', 'processed', 'rejected'])->default('pending'); + $table->timestamp('requested_at')->useCurrent(); + $table->timestamp('processed_at')->nullable(); + $table->foreignId('processed_by')->nullable()->constrained('users')->onDelete('set null'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['sale_id', 'status']); + $table->index(['client_id']); + $table->index(['status', 'requested_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoice_requests'); + } +}; diff --git a/routes/api.php b/routes/api.php index 1a7af85..2eaf67b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,8 +10,9 @@ use App\Http\Controllers\App\ReportController; use App\Http\Controllers\App\SaleController; use App\Http\Controllers\App\ReturnController; -use App\Http\Controllers\App\FacturaDataController; +use App\Http\Controllers\App\InvoiceController; use App\Http\Controllers\App\InventorySerialController; +use App\Http\Controllers\App\InvoiceRequestController; use Illuminate\Support\Facades\Route; /** @@ -39,6 +40,7 @@ // Números de serie Route::resource('inventario.serials', InventorySerialController::class); + Route::get('serials/search', [InventorySerialController::class, 'search']); //CATEGORIAS Route::resource('categorias', CategoryController::class); @@ -98,11 +100,19 @@ Route::delete('/{tier}', [ClientTierController::class, 'destroy']); Route::patch('/{tier}/toggle-active', [ClientTierController::class, 'toggleActive']); }); + + Route::prefix('invoice-requests')->group(function () { + Route::get('/', [InvoiceRequestController::class, 'index']); + Route::get('/stats', [InvoiceRequestController::class, 'stats']); + Route::get('/{id}', [InvoiceRequestController::class, 'show']); + Route::put('/{id}/process', [InvoiceRequestController::class, 'process']); + Route::put('/{id}/reject', [InvoiceRequestController::class, 'reject']); + }); }); /** Rutas públicas */ // Formulario de datos fiscales para facturación Route::prefix('facturacion')->group(function () { - Route::get('/{invoiceNumber}', [FacturaDataController::class, 'show']); - Route::post('/{invoiceNumber}', [FacturaDataController::class, 'store']); + Route::get('/{invoiceNumber}', [InvoiceController::class, 'show']); + Route::post('/{invoiceNumber}', [InvoiceController::class, 'store']); });