feat: agregar funcionalidad para subir archivos de factura y almacenar UUID del CFDI

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-04 14:26:29 -06:00
parent f2d2fd5aaf
commit c68a16763c
7 changed files with 209 additions and 2 deletions

View File

@ -45,7 +45,7 @@ server {
# Handle storage files (Laravel storage link) # Handle storage files (Laravel storage link)
location /storage { location /storage {
alias /var/www/pdv.backend/storage/app; alias /var/www/pdv.backend/storage/app/public;
try_files $uri =404; try_files $uri =404;
} }

View File

@ -5,8 +5,10 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\App\InvoiceRequestProcessRequest; use App\Http\Requests\App\InvoiceRequestProcessRequest;
use App\Http\Requests\App\InvoiceRequestRejectRequest; use App\Http\Requests\App\InvoiceRequestRejectRequest;
use App\Http\Requests\App\InvoiceRequestUploadRequest;
use App\Models\InvoiceRequest; use App\Models\InvoiceRequest;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Notsoweb\ApiResponse\Enums\ApiResponse; use Notsoweb\ApiResponse\Enums\ApiResponse;
/** /**
@ -195,4 +197,88 @@ public function stats()
'stats' => $stats 'stats' => $stats
]); ]);
} }
/**
* Subir archivos de factura (XML y PDF) y guardar UUID del CFDI
*/
public function uploadInvoiceFile(InvoiceRequestUploadRequest $request, $id)
{
$invoiceRequest = InvoiceRequest::find($id);
if (!$invoiceRequest) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Solicitud de factura no encontrada'
]);
}
// Validar que la solicitud esté pendiente o procesada (permitir actualizar facturas)
if (!in_array($invoiceRequest->status, [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se pueden subir archivos a solicitudes rechazadas',
'current_status' => $invoiceRequest->status
]);
}
// Generar timestamp para nombres de archivo
$timestamp = now()->format('YmdHis');
$storagePath = 'invoices/' . now()->format('Y/m');
$updateData = [
'cfdi_uuid' => $request->cfdi_uuid,
];
// Procesar XML si se envió
if ($request->hasFile('invoice_xml')) {
// Eliminar XML anterior si existe
if ($invoiceRequest->invoice_xml_path) {
Storage::disk('public')->delete($invoiceRequest->invoice_xml_path);
}
$xmlFileName = "invoice-{$id}-{$timestamp}.xml";
$xmlPath = $request->file('invoice_xml')->storeAs(
$storagePath,
$xmlFileName,
'public'
);
$updateData['invoice_xml_path'] = $xmlPath;
}
// Procesar PDF (siempre requerido)
if ($request->hasFile('invoice_pdf')) {
// Eliminar PDF anterior si existe
if ($invoiceRequest->invoice_pdf_path) {
Storage::disk('public')->delete($invoiceRequest->invoice_pdf_path);
}
$pdfFileName = "invoice-{$id}-{$timestamp}.pdf";
$pdfPath = $request->file('invoice_pdf')->storeAs(
$storagePath,
$pdfFileName,
'public'
);
$updateData['invoice_pdf_path'] = $pdfPath;
}
// Actualizar registro
$invoiceRequest->update($updateData);
$invoiceRequest->load([
'sale:id,invoice_number',
'client:id,name,rfc',
]);
$files = [];
if (isset($xmlPath)) {
$files['xml_url'] = Storage::url($xmlPath);
}
if (isset($pdfPath)) {
$files['pdf_url'] = Storage::url($pdfPath);
}
return ApiResponse::OK->response([
'message' => 'Archivos de factura subidos correctamente',
'invoice_request' => $invoiceRequest,
'files' => $files
]);
}
} }

View File

@ -0,0 +1,53 @@
<?php namespace App\Http\Requests\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Foundation\Http\FormRequest;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class InvoiceRequestUploadRequest extends FormRequest
{
/**
* Determinar si el usuario está autorizado para realizar esta solicitud
*/
public function authorize(): bool
{
return true; // Autorización manejada por middleware
}
/**
* Obtener las reglas de validación que se aplican a la solicitud
*/
public function rules(): array
{
return [
'invoice_xml' => ['nullable', 'file', 'mimes:xml', 'max:2048'], // Max 2MB
'invoice_pdf' => ['required', 'file', 'mimes:pdf', 'max:5120'], // Max 5MB
'cfdi_uuid' => ['required', 'string', 'size:36', 'regex:/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'],
];
}
/**
* Mensajes personalizados de validación
*/
public function messages(): array
{
return [
'invoice_xml.mimes' => 'El archivo debe ser un XML válido',
'invoice_xml.max' => 'El archivo XML no puede ser mayor a 2MB',
'invoice_pdf.required' => 'El archivo PDF de la factura es obligatorio',
'invoice_pdf.mimes' => 'El archivo debe ser un PDF válido',
'invoice_pdf.max' => 'El archivo PDF no puede ser mayor a 5MB',
'cfdi_uuid.required' => 'El UUID del CFDI es obligatorio',
'cfdi_uuid.size' => 'El UUID del CFDI debe tener 36 caracteres',
'cfdi_uuid.regex' => 'El formato del UUID del CFDI no es válido',
];
}
}

View File

@ -5,6 +5,8 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/** /**
* Descripción * Descripción
@ -23,6 +25,9 @@ class InvoiceRequest extends Model
'processed_at', 'processed_at',
'processed_by', 'processed_by',
'notes', 'notes',
'invoice_xml_path',
'invoice_pdf_path',
'cfdi_uuid',
]; ];
protected $casts = [ protected $casts = [
@ -30,6 +35,12 @@ class InvoiceRequest extends Model
'processed_at' => 'datetime', 'processed_at' => 'datetime',
]; ];
/**
* Atributos adicionales para serializar
*/
protected $appends = ['invoice_xml_url', 'invoice_pdf_url'];
const STATUS_PENDING = 'pending'; const STATUS_PENDING = 'pending';
const STATUS_PROCESSED = 'processed'; const STATUS_PROCESSED = 'processed';
const STATUS_REJECTED = 'rejected'; const STATUS_REJECTED = 'rejected';
@ -74,4 +85,28 @@ public function markAsRejected(int $userId, ?string $notes = null): bool
'notes' => $notes ?? $this->notes, 'notes' => $notes ?? $this->notes,
]); ]);
} }
/**
* Obtener la URL completa del archivo XML
*/
public function getInvoiceXmlUrlAttribute()
{
if (!$this->invoice_xml_path) {
return null;
}
return Storage::disk('public')->url($this->invoice_xml_path);
}
/**
* Obtener la URL completa del archivo PDF
*/
public function getInvoicePdfUrlAttribute()
{
if (!$this->invoice_pdf_path) {
return null;
}
return Storage::disk('public')->url($this->invoice_pdf_path);
}
} }

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('invoice_requests', function (Blueprint $table) {
$table->string('invoice_xml_path')->nullable()->after('notes');
$table->string('invoice_pdf_path')->nullable()->after('invoice_xml_path');
$table->string('cfdi_uuid')->nullable()->after('invoice_pdf_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('invoice_requests', function (Blueprint $table) {
$table->dropColumn(['invoice_xml_path', 'invoice_pdf_path', 'cfdi_uuid']);
});
}
};

View File

@ -29,6 +29,7 @@ services:
- "${NGINX_PORT}:80" - "${NGINX_PORT}:80"
volumes: volumes:
- ./public:/var/www/pdv.backend/public - ./public:/var/www/pdv.backend/public
- ./storage:/var/www/pdv.backend/storage
- ./Docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf - ./Docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
networks: networks:
- pdv-network - pdv-network

View File

@ -38,7 +38,7 @@
Route::post('inventario/import', [InventoryController::class, 'import']); Route::post('inventario/import', [InventoryController::class, 'import']);
Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']); Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']);
// Números de serie // NÚMEROS DE SERIE DE INVENTARIO
Route::resource('inventario.serials', InventorySerialController::class); Route::resource('inventario.serials', InventorySerialController::class);
Route::get('serials/search', [InventorySerialController::class, 'search']); Route::get('serials/search', [InventorySerialController::class, 'search']);
@ -101,12 +101,14 @@
Route::patch('/{tier}/toggle-active', [ClientTierController::class, 'toggleActive']); Route::patch('/{tier}/toggle-active', [ClientTierController::class, 'toggleActive']);
}); });
// SOLICITUDES DE FACTURACIÓN
Route::prefix('invoice-requests')->group(function () { Route::prefix('invoice-requests')->group(function () {
Route::get('/', [InvoiceRequestController::class, 'index']); Route::get('/', [InvoiceRequestController::class, 'index']);
Route::get('/stats', [InvoiceRequestController::class, 'stats']); Route::get('/stats', [InvoiceRequestController::class, 'stats']);
Route::get('/{id}', [InvoiceRequestController::class, 'show']); Route::get('/{id}', [InvoiceRequestController::class, 'show']);
Route::put('/{id}/process', [InvoiceRequestController::class, 'process']); Route::put('/{id}/process', [InvoiceRequestController::class, 'process']);
Route::put('/{id}/reject', [InvoiceRequestController::class, 'reject']); Route::put('/{id}/reject', [InvoiceRequestController::class, 'reject']);
Route::post('/{id}/upload', [InvoiceRequestController::class, 'uploadInvoiceFile']);
}); });
}); });