Skip to main content

Introducción

Las cotizaciones permiten generar presupuestos para clientes antes de confirmar la venta. Son documentos internos que NO se envían a SUNAT y NO afectan el inventario hasta su conversión a factura o boleta.
Las cotizaciones son ideales para:
  • Presentar propuestas comerciales
  • Negociar precios con clientes
  • Planificar ventas futuras sin comprometer stock
  • Gestionar aprobaciones internas

Estados de Cotización

EstadoDescripciónAcciones disponibles
pendienteRecién creada, esperando respuesta del clienteEditar, Convertir, Rechazar
aprobadaCliente aceptó la cotización y se convirtió a ventaSolo ver, no editable
rechazadaCliente rechazó o se eliminó manualmenteSolo ver
vencidaExpiró por tiempo (calculado dinámicamente)Solo ver
El estado aprobada se asigna automáticamente al convertir la cotización a venta.

Estructura de Datos

Tabla principal: cotizaciones

[
    'id',
    'numero',               // Correlativo interno: 1, 2, 3...
    'fecha',                // Fecha de emisión
    'id_cliente',           // FK opcional a tabla clientes
    'cliente_nombre',       // Nombre libre si no hay id_cliente
    'direccion',
    'subtotal',             // Base imponible (sin IGV)
    'igv',                  // 18% si aplicar_igv=true
    'total',                // subtotal + igv - descuento
    'descuento',            // Descuento general en monto
    'aplicar_igv',          // Boolean
    'moneda',               // PEN, USD
    'tipo_cambio',          // Tipo de cambio USD→PEN
    'dias_pago',            // Plazo de pago en texto libre
    'asunto',               // Título/concepto de la cotización
    'observaciones',        // Notas adicionales
    'estado',               // pendiente, aprobada, rechazada, vencida
    'id_empresa',
    'id_usuario',
]

Tabla de detalles: detalle_cotizaciones

[
    'id',
    'cotizacion_id',
    'producto_id',          // FK a productos
    'codigo',               // Snapshot del código
    'nombre',               // Snapshot del nombre
    'descripcion',          // Descripción adicional
    'cantidad',
    'precio_unitario',      // Precio base del producto
    'precio_especial',      // Precio promocional (opcional)
    'subtotal',             // cantidad * (precio_especial || precio_unitario)
]

Tabla de cuotas: cotizacion_cuotas

[
    'id',
    'cotizacion_id',
    'numero_cuota',         // 1, 2, 3...
    'monto',
    'fecha_vencimiento',
    'tipo',                 // 'inicial' o 'cuota'
]

Flujo Paso a Paso

1

Crear Nueva Cotización

Ruta: /cotizaciones/crearComponente: CotizacionForm.jsx

Datos del Cliente

Tres opciones:
  1. Cliente registrado: Buscar por RUC/DNI en autocomplete
  2. Cliente nuevo: Ingresar documento → Se crea automáticamente
  3. Sin cliente: Solo ingresar nombre libre (campo cliente_nombre)
// CotizacionController.php línea 113-137
if (!$idCliente && $request->cliente_documento) {
    // Buscar o crear cliente por documento
    $clienteModel = Cliente::where('documento', $request->cliente_documento)
        ->where('id_empresa', $idEmpresa)
        ->first();
    
    if (!$clienteModel) {
        $doc = $request->cliente_documento;
        $tipoDoc = strlen($doc) === 11 ? '6' : (strlen($doc) === 8 ? '1' : '4');
        $clienteModel = Cliente::create([
            'documento' => $doc,
            'tipo_doc' => $tipoDoc,
            'datos' => $request->cliente_datos,
            'direccion' => $request->cliente_direccion ?? '',
            'id_empresa' => $idEmpresa,
        ]);
    }
    $idCliente = $clienteModel->id_cliente;
} elseif (!$idCliente) {
    // Cliente libre (sin documento) — guardar nombre directamente
    $clienteNombre = $request->cliente_nombre ?: $request->cliente_datos;
}

Número de Cotización

Formato: COT-000001, COT-000002, etc.Generación automática:
// CotizacionController.php línea 140-143
$ultimaCotizacion = Cotizacion::where('id_empresa', $idEmpresa)
    ->orderBy('numero', 'desc')
    ->first();
$numero = $ultimaCotizacion ? $ultimaCotizacion->numero + 1 : 1;

Configuración Inicial

// CotizacionForm.jsx
const [formData, setFormData] = useState({
    fecha: new Date().toISOString().split('T')[0],
    moneda: 'PEN',
    tipo_cambio: 0,
    aplicar_igv: true,              // IGV activado por defecto
    descuento_general: 0,
    descuento_activado: false,
    precio_especial_activado: false,
    asunto: '',
    observaciones: '',
    dias_pago: '15 días',           // Plazo sugerido
});
2

Agregar Productos

Búsqueda de Productos

Componente: ProductoFormSection.jsxCaracterísticas especiales para cotizaciones:
  1. Selector de almacén:
    • Almacén 1 (Facturación) → Genera Boleta/Factura
    • Almacén 2 (Kardex Real) → Genera Nota de Venta
  2. Selector de precio:
    // Opciones disponibles:
    - precio:    Precio de venta normal
    - precio2:   Precio mayorista
    - precio3:   Precio especial
    - costo:     Costo de compra (solo admin)
    
  3. Precio especial por producto:
    const handlePrecioSelect = ({ tipo, valor }) => {
        setProductoActual({
            ...productoActual,
            precio_mostrado: valor,
            precioVenta: valor,
            tipo_precio: tipo
        });
    };
    

Tabla de Productos

Columnas:
  • Código
  • Descripción
  • Cantidad (editable inline)
  • Precio unitario (editable inline)
  • Precio especial (si activado, editable inline)
  • Parcial (cantidad * precio aplicado)
Precio efectivo:
// ProductosTable.jsx
const precioEfectivo = producto.precioEspecial && producto.precioEspecial > 0
    ? producto.precioEspecial
    : producto.precioVenta;

const subtotal = producto.cantidad * precioEfectivo;
3

Configurar Precios y Descuentos

Precio Especial

Activación: Checkbox “Precio Especial”
// CotizacionForm.jsx línea 165-178
<div className="flex items-center gap-2 mb-2">
    <label>Precio Especial</label>
    <input
        type="checkbox"
        checked={formData.precio_especial_activado}
        onChange={(e) => setFormData({
            ...formData,
            precio_especial_activado: e.target.checked
        })}
    />
</div>
<Input
    type="number"
    value={productoActual.precioEspecial}
    disabled={!formData.precio_especial_activado}
/>
Efecto: Permite sobrescribir precio por producto en columna adicional.

Descuento General

Activación: Checkbox “Descuento %”Cálculo:
// useCotizacionForm.js
const calcularTotales = () => {
    const montoBruto = productos.reduce((sum, p) => {
        const precio = p.precioEspecial > 0 ? p.precioEspecial : p.precioVenta;
        return sum + (p.cantidad * precio);
    }, 0);
    
    const descuentoPorcentaje = formData.descuento_general || 0;
    const descuentoMonto = montoBruto * (descuentoPorcentaje / 100);
    
    const totalAntesIgv = montoBruto - descuentoMonto;
    
    if (formData.aplicar_igv) {
        const subtotal = totalAntesIgv / 1.18;
        const igv = totalAntesIgv - subtotal;
        return { subtotal, igv, total: totalAntesIgv };
    }
    
    return { subtotal: totalAntesIgv, igv: 0, total: totalAntesIgv };
};
El descuento se aplica al monto bruto antes de calcular IGV. Si aplica IGV, el subtotal = (monto - descuento) / 1.18.
4

Configurar Forma de Pago

Tipo de Pago

Select con opciones:
  • Contado
  • Crédito a 15 días
  • Crédito a 30 días
  • Crédito a 45 días
  • Crédito a 60 días

Cuotas de Pago

Modal: PaymentSchedule.jsxActivación: Click “Definir Cuotas”
// Configuración de cuotas
const handlePaymentScheduleConfirm = (cuotas) => {
    setFormData({
        ...formData,
        cuotas: cuotas.map((c, idx) => ({
            numero_cuota: idx + 1,
            monto: c.monto,
            fecha_vencimiento: c.fecha,
            tipo: c.tipo || 'cuota'
        }))
    });
};
Guardado en backend:
// CotizacionController.php línea 204-214
if ($request->has('cuotas') && is_array($request->cuotas)) {
    foreach ($request->cuotas as $index => $cuota) {
        CotizacionCuota::create([
            'cotizacion_id' => $cotizacion->id,
            'numero_cuota' => $index + 1,
            'monto' => $cuota['monto'],
            'fecha_vencimiento' => $cuota['fecha_vencimiento'],
            'tipo' => $cuota['tipo'] ?? 'cuota',
        ]);
    }
}
Las cuotas de cotización son referenciales. Al convertir a venta, se pueden modificar.
5

Guardar Cotización

Endpoint: POST /api/cotizacionesProceso en backend:
// CotizacionController.php línea 68-234
public function store(Request $request)
{
    DB::beginTransaction();
    
    try {
        // 1. Validar datos
        $validator = Validator::make($request->all(), [
            'fecha' => 'required|date',
            'moneda' => 'required|in:PEN,USD',
            'aplicar_igv' => 'required|boolean',
            'productos' => 'required|array|min:1',
            'productos.*.producto_id' => 'required|exists:productos,id_producto',
            'productos.*.cantidad' => 'required|numeric|min:0.01',
            'productos.*.precio_unitario' => 'required|numeric|min:0',
        ]);
        
        // 2. Generar número
        $numero = Cotizacion::where('id_empresa', $idEmpresa)
            ->max('numero') + 1;
        
        // 3. Calcular totales
        $montoBruto = 0;
        foreach ($request->productos as $prod) {
            $precio = $prod['precio_especial'] ?? $prod['precio_unitario'];
            $montoBruto += $precio * $prod['cantidad'];
        }
        
        $descuento = $request->descuento ?? 0;
        $total = $montoBruto - $descuento;
        
        $igv = 0;
        $subtotal = $total;
        
        if ($request->aplicar_igv) {
            $subtotal = $total / 1.18;  // Operaciones Gravadas
            $igv = $total - $subtotal;
        }
        
        // 4. Crear cotización
        $cotizacion = Cotizacion::create([
            'numero' => $numero,
            'fecha' => $request->fecha,
            'id_cliente' => $idCliente,
            'cliente_nombre' => $clienteNombre,
            'subtotal' => $subtotal,
            'igv' => $igv,
            'total' => $total,
            'descuento' => $descuento,
            'aplicar_igv' => $request->aplicar_igv,
            'moneda' => $request->moneda,
            'asunto' => $request->asunto,
            'observaciones' => $request->observaciones,
            'estado' => 'pendiente',
            'id_empresa' => $idEmpresa,
            'id_usuario' => $user->id,
        ]);
        
        // 5. Crear detalles
        foreach ($request->productos as $prod) {
            $precio = $prod['precio_especial'] ?? $prod['precio_unitario'];
            $subtotalDetalle = $precio * $prod['cantidad'];
            
            CotizacionDetalle::create([
                'cotizacion_id' => $cotizacion->id,
                'producto_id' => $prod['producto_id'],
                'codigo' => $prod['codigo'] ?? null,
                'nombre' => $prod['nombre'],
                'cantidad' => $prod['cantidad'],
                'precio_unitario' => $prod['precio_unitario'],
                'precio_especial' => $prod['precio_especial'] ?? null,
                'subtotal' => $subtotalDetalle,
            ]);
        }
        
        // 6. Crear cuotas
        if ($request->has('cuotas')) {
            foreach ($request->cuotas as $index => $cuota) {
                CotizacionCuota::create([
                    'cotizacion_id' => $cotizacion->id,
                    'numero_cuota' => $index + 1,
                    'monto' => $cuota['monto'],
                    'fecha_vencimiento' => $cuota['fecha_vencimiento'],
                    'tipo' => $cuota['tipo'] ?? 'cuota',
                ]);
            }
        }
        
        DB::commit();
        
        return response()->json([
            'success' => true,
            'message' => 'Cotización creada exitosamente',
            'data' => $cotizacion
        ], 201);
        
    } catch (Exception $e) {
        DB::rollBack();
        return response()->json([
            'success' => false,
            'message' => 'Error al crear cotización: ' . $e->getMessage()
        ], 500);
    }
}
6

Imprimir Cotización

Después de guardar, botón “Imprimir” abre modal PrintOptionsModal:Ruta PDF: /reporteCotizacion/a4.php?id={cotizacion_id}

Contenido del PDF:

  • Encabezado: Logo empresa, datos fiscales, título “COTIZACIÓN”
  • Número: COT-000001
  • Datos del cliente: Nombre/Razón social, documento, dirección
  • Tabla de productos:
    • Código
    • Descripción
    • Cantidad
    • Precio unitario
    • Precio especial (si aplica)
    • Subtotal
  • Totales:
    • Subtotal (operaciones gravadas si aplica IGV)
    • Descuento (si aplica)
    • IGV 18% (si aplica)
    • Total
  • Forma de pago:
    • Tipo de pago
    • Cuotas con montos y fechas
  • Observaciones: Notas adicionales
  • Validez: “Válido por 15 días” (configurable)
// Ejemplo implementación PDF con mPDF
$mpdf = new \Mpdf\Mpdf();

$html = view('reportes.cotizacion', [
    'cotizacion' => $cotizacion,
    'empresa' => $empresa,
    'cliente' => $cliente,
    'detalles' => $detalles,
    'cuotas' => $cuotas,
])->render();

$mpdf->WriteHTML($html);
$mpdf->Output('COT-' . str_pad($cotizacion->numero, 6, '0', STR_PAD_LEFT) . '.pdf', 'I');
7

Convertir a Venta

Desde la lista de cotizaciones, click en “Convertir a Venta” (icono de carrito).

Proceso automático:

  1. Redirección con parámetros:
    // cotizacionesColumns.jsx
    const handleConvertir = (cotizacion) => {
        const tipo = cotizacion.id_cliente 
            ? (cotizacion.cliente.documento?.length === 11 ? 'factura' : 'boleta')
            : 'boleta';
        
        window.location.href = baseUrl(
            `/ventas/crear?tipo=${tipo}&cotizacion_id=${cotizacion.id}`
        );
    };
    
  2. Formulario de venta precarga datos:
    // VentaForm.jsx línea 78-149
    useEffect(() => {
        const cotizacionId = new URLSearchParams(window.location.search)
            .get('cotizacion_id');
        
        if (cotizacionId) {
            fetch(`/api/cotizaciones/${cotizacionId}`)
                .then(res => res.json())
                .then(data => {
                    const cot = data.data;
                    
                    // Cargar cliente
                    if (cot.cliente) {
                        setCliente(cot.cliente);
                        setFormData(prev => ({
                            ...prev,
                            num_doc: cot.cliente.documento,
                            nom_cli: cot.cliente.datos,
                            dir_cli: cot.cliente.direccion,
                        }));
                    }
                    
                    // Cargar productos
                    setProductos(cot.detalles.map(d => ({
                        id_producto: d.producto_id,
                        codigo: d.codigo,
                        descripcion: d.nombre,
                        cantidad: d.cantidad,
                        precioVenta: d.precio_unitario,
                        precio_mostrado: d.precio_especial || d.precio_unitario,
                    })));
                    
                    // Guardar referencia
                    setFormData(prev => ({
                        ...prev,
                        cotizacion_id: cot.id,
                        moneda: cot.moneda,
                        aplicar_igv: cot.aplicar_igv,
                    }));
                });
        }
    }, []);
    
  3. Usuario revisa y guarda venta:
    • Puede modificar cantidades, precios, forma de pago
    • Al guardar, venta se crea normalmente
  4. Backend actualiza estado de cotización:
    // VentasController.php línea 337-341
    if (!empty($validated['cotizacion_id'])) {
        Cotizacion::where('id', $validated['cotizacion_id'])
            ->where('id_empresa', $user->id_empresa)
            ->update(['estado' => 'aprobada']);
    }
    
La conversión es de solo lectura en la cotización. Una vez aprobada, no se puede editar ni volver a convertir.

Edición de Cotizaciones

Ruta: /cotizaciones/editar/{id} Endpoint: PUT /api/cotizaciones/{id}

Proceso de actualización:

// CotizacionController.php línea 239-396
public function update(Request $request, $id)
{
    $cotizacion = Cotizacion::where('id_empresa', $user->id_empresa)
        ->findOrFail($id);
    
    // Solo editable si estado = pendiente
    if ($cotizacion->estado !== 'pendiente') {
        return response()->json([
            'success' => false,
            'message' => 'No se puede editar una cotización aprobada o rechazada'
        ], 400);
    }
    
    DB::beginTransaction();
    
    try {
        // Actualizar datos principales
        $cotizacion->update([...]);
        
        // Eliminar detalles y cuotas anteriores
        $cotizacion->detalles()->delete();
        $cotizacion->cuotas()->delete();
        
        // Crear nuevos detalles y cuotas
        foreach ($request->productos as $prod) { ... }
        foreach ($request->cuotas as $cuota) { ... }
        
        DB::commit();
        
        return response()->json([
            'success' => true,
            'message' => 'Cotización actualizada exitosamente'
        ]);
    } catch (Exception $e) {
        DB::rollBack();
        return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
    }
}
Solo se pueden editar cotizaciones con estado pendiente. Las aprobadas o rechazadas son de solo lectura.

Gestión de Estados

Cambiar estado manualmente

Endpoint: POST /api/cotizaciones/{id}/cambiar-estado
// CotizacionController.php línea 424-454
public function cambiarEstado(Request $request, $id)
{
    $request->validate([
        'estado' => 'required|in:pendiente,aprobada,rechazada,vencida',
    ]);
    
    $cotizacion = Cotizacion::where('id_empresa', $user->id_empresa)
        ->findOrFail($id);
    
    $cotizacion->update(['estado' => $request->estado]);
    
    return response()->json([
        'success' => true,
        'message' => 'Estado actualizado exitosamente'
    ]);
}

Eliminar cotización

Endpoint: DELETE /api/cotizaciones/{id}
// CotizacionController.php línea 401-418
public function destroy(Request $request, $id)
{
    $cotizacion = Cotizacion::where('id_empresa', $user->id_empresa)
        ->findOrFail($id);
    
    // Soft delete: cambiar a rechazada
    $cotizacion->update(['estado' => 'rechazada']);
    
    return response()->json([
        'success' => true,
        'message' => 'Cotización eliminada exitosamente'
    ]);
}
El sistema usa “soft delete” cambiando el estado a rechazada en lugar de eliminar físicamente.

Reportes y Listados

Vista de lista

Componente: CotizacionesList.jsx Endpoint: GET /api/cotizaciones Query:
// CotizacionController.php línea 19-40
$cotizaciones = DB::table('view_cotizaciones')
    ->where('id_empresa', $idEmpresa)
    ->orderBy('id', 'desc')
    ->get();
Vista: view_cotizaciones (JOIN optimizado)
CREATE VIEW view_cotizaciones AS
SELECT 
    c.id,
    c.numero,
    CONCAT('COT-', LPAD(c.numero, 6, '0')) as numero_completo,
    c.fecha,
    COALESCE(cl.datos, c.cliente_nombre) as cliente_nombre,
    COALESCE(cl.documento, '') as cliente_documento,
    c.moneda,
    c.total,
    c.estado,
    c.asunto,
    u.name as usuario_nombre,
    c.created_at
FROM cotizaciones c
LEFT JOIN clientes cl ON c.id_cliente = cl.id_cliente
LEFT JOIN users u ON c.id_usuario = u.id;

Filtros disponibles:

// CotizacionesList.jsx
const filtros = [
    { label: 'Todas', value: '' },
    { label: 'Pendientes', value: 'pendiente' },
    { label: 'Aprobadas', value: 'aprobada' },
    { label: 'Rechazadas', value: 'rechazada' },
];

const cotizacionesFiltradas = cotizaciones.filter(c => 
    !estadoActivo || c.estado === estadoActivo
);

Badges de estado:

// cotizacionesColumns.jsx
const getBadgeVariant = (estado) => {
    switch (estado) {
        case 'pendiente': return 'warning';  // Amarillo
        case 'aprobada': return 'success';   // Verde
        case 'rechazada': return 'danger';   // Rojo
        case 'vencida': return 'secondary';  // Gris
        default: return 'default';
    }
};

Integraciones

Con módulo de Ventas

  • Conversión directa a Factura/Boleta
  • Preserva precios especiales y cuotas
  • Actualiza estado automáticamente

Con módulo de Clientes

  • Historial de cotizaciones por cliente
  • Tasa de conversión (cotizaciones → ventas)
  • Análisis de propuestas aceptadas/rechazadas

Con módulo de Productos

  • No afecta stock (cotización es referencial)
  • Permite cotizar productos sin stock
  • Validación de disponibilidad al momento de conversión

Errores Comunes

Causa: Intentando editar cotización ya convertida a venta.Solución:
  • Las cotizaciones aprobadas son de solo lectura
  • Crear nueva cotización basada en la anterior
  • O editar directamente la venta generada
Causa: IGV o descuentos calculados de forma diferente.Explicación:
  • Cotización: Totales son referenciales
  • Venta: Recalcula con datos actuales (precio, IGV)
Solución: Revisar precios y configuración de IGV antes de guardar venta.
Causa: Cotización sin id_cliente, solo con cliente_nombre libre.Solución:
  • Al convertir, ingresar RUC/DNI del cliente
  • Sistema lo buscará o creará automáticamente

Referencias Técnicas

Controlador: app/Http/Controllers/CotizacionController.php Modelos:
  • app/Models/Cotizacion.php
  • app/Models/CotizacionDetalle.php
  • app/Models/CotizacionCuota.php
Frontend:
  • resources/js/components/Cotizaciones/CotizacionForm.jsx
  • resources/js/components/Cotizaciones/CotizacionesList.jsx
  • resources/js/components/Cotizaciones/hooks/useCotizacionForm.js
Rutas API:
GET    /api/cotizaciones                          // Listar
POST   /api/cotizaciones                          // Crear
GET    /api/cotizaciones/{id}                     // Ver detalle
PUT    /api/cotizaciones/{id}                     // Actualizar
DELETE /api/cotizaciones/{id}                     // Eliminar (soft)
POST   /api/cotizaciones/{id}/cambiar-estado      // Cambiar estado
GET    /api/cotizaciones/proximo-numero           // Siguiente número

Build docs developers (and LLMs) love