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
Estado Descripción Acciones disponibles pendienteRecién creada, esperando respuesta del cliente Editar, Convertir, Rechazar aprobadaCliente aceptó la cotización y se convirtió a venta Solo ver, no editable rechazadaCliente rechazó o se eliminó manualmente Solo 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
Crear Nueva Cotización
Ruta: /cotizaciones/crearComponente: CotizacionForm.jsxDatos del Cliente Tres opciones:
Cliente registrado: Buscar por RUC/DNI en autocomplete
Cliente nuevo: Ingresar documento → Se crea automáticamente
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
});
Agregar Productos
Búsqueda de Productos Componente: ProductoFormSection.jsxCaracterísticas especiales para cotizaciones:
Selector de almacén:
Almacén 1 (Facturación) → Genera Boleta/Factura
Almacén 2 (Kardex Real) → Genera Nota de Venta
Selector de precio:
// Opciones disponibles:
- precio : Precio de venta normal
- precio2 : Precio mayorista
- precio3 : Precio especial
- costo : Costo de compra ( solo admin )
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 ;
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.
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.
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 );
}
}
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' );
Convertir a Venta
Desde la lista de cotizaciones, click en “Convertir a Venta” (icono de carrito). Proceso automático:
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 } `
);
};
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 ,
}));
});
}
}, []);
Usuario revisa y guarda venta:
Puede modificar cantidades, precios, forma de pago
Al guardar, venta se crea normalmente
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
Error: No se puede editar una cotización aprobada
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
Totales no coinciden al convertir a venta
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.
Cliente no aparece en venta convertida
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