From c99e89d91fccacd2697c8028fe067c245e7fdc0e Mon Sep 17 00:00:00 2001 From: "Efrain (Helgen)" Date: Wed, 21 Oct 2026 11:12:24 -0600 Subject: [PATCH] Cobranza: resumen de todos los proyectos (cobrado, por cobrar, margen) y CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resumenCobranzaGeneral() (solo ROL_COBRANZA; solo lee): una lectura de Contratos, Esquemas, Pagos, Proyectos y órdenes de compra. Usa resumenCobranza por proyecto: mismas cifras que la vista de cada proyecto. - Totales por moneda (nunca MXN + USD), solo de contratos firmados. Los "Por firmar" se muestran sin sumar; los proyectos cancelados no aparecen. - Por proyecto: contratado, cobrado, saldo, avance, siguiente etapa, último pago, costo de órdenes y margen (costosDeOrdenes, compartida con la vista). - Interfaz: el resumen es la pantalla inicial de Cobranza; filtros, clic para abrir el proyecto, "← Resumen" y Descargar CSV (UTF-8 con BOM, texto con = + - @ neutralizado). - Pruebas: cobranza, compras e interfaz ampliadas; las ocho pasan. Sin publicar (CLAUDE.md §9). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 24 +++++++- Code.js | 128 ++++++++++++++++++++++++++++++++++++--- tests/cobranza.test.js | 34 +++++++++++ tests/compras.test.js | 11 ++++ tests/interfaz.test.js | 40 ++++++++++++ web/cotizador/index.html | 117 ++++++++++++++++++++++++++++++++++- 6 files changed, 339 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 602882a..dd66057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,8 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e ### 4.5.2 Cobranza (sección 4.2 del archivo; ver §6.11) - Todas empiezan con `exigirCobranza_()` (token + correo en `ROL_COBRANZA`; si no, `errorPermiso_` → `codigo: "PERMISO"`). - *(UI)* `listarProyectosCobranza(busqueda)` (todos los estados + estado del contrato), `getCobranzaProyecto(folio)`, + `resumenCobranzaGeneral()` (resumen de todos los proyectos; solo lee: una lectura de Contratos, Esquemas, Pagos, Proyectos y + órdenes de compra; calcula con `resumenGeneralCobranza`), `marcarAceptada(proyecto, cotizacion, confirmado)` (al pasar de Helgen a Energy pone el 60/30/10; si eso reemplaza un esquema distinto devuelve `{confirmar, mensaje}` sin cambiar nada; entre cotizaciones de la misma línea conserva el esquema), `guardarEsquema(proyecto, etapas, motivo)` (Energy: solo `ADMIN_DESBLOQUEO` y motivo obligatorio en la Bitacora), `firmarContrato(proyecto, 'AAAA-MM-DD')`, @@ -225,16 +227,21 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e - SAT: `palabrasSat`, `SINONIMOS_SAT`, `PALABRAS_SERVICIO_SAT`, `buscarClavesSat(consulta, filas, n)`, `candidatosSat`, `solicitudIaSat`, `leerRespuestaIaSat` (solo acepta claves que estaban entre los candidatos). Modelo: `MODELO_IA_SAT`. - `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4). +- Resumen de cobranza: `resumenGeneralCobranza({contratos, esquemas, pagos, proyectos, ordenes})` → `{totales (por moneda), proyectos}` + (ver §6.11 "Resumen de cobranza"); usa `resumenCobranza` por proyecto, así las cifras son las mismas que en su vista. - Compras: `normalizarProveedor`, `proveedorId` (RFC válido no genérico o `SIN-RFC:razón social`), `fusionarProveedor` (un dato vacío no borra uno anterior), `proveedoresDesdeFilas` (con `etiqueta` y `fila`), `normalizarOrdenCompra(p, {moneda, ubicacion, hoy, ivaTasa})` (entrega obligatoria y no anterior a hoy; lugar = ubicación del proyecto si viene vacío; condiciones obligatorias; IVA 16/8/0 % (`IVA_OC`); renglones con costo capturado; tipo de cambio obligatorio si la moneda no es la del proyecto; - `subtotalProyecto` sin IVA en la moneda del proyecto), `margenProyecto(contratado, moneda, costos)` (lanza error si un costo + `subtotalProyecto` sin IVA en la moneda del proyecto), `costosDeOrdenes(ordenes, monedaContrato)` (suma `subtotal_proyecto` + por moneda; la usan el margen de la vista y el resumen), `margenProyecto(contratado, moneda, costos)` (lanza error si un costo está en otra moneda; `porcentaje` con 1 decimal), `nombreArchivoOc`, `terminosOcParaPdf(cfg)` (si la celda está vacía, los iniciales), `construirHtmlOrdenCompra(d, cfg, logoUrl)` (mismas reglas que el de cotización; sin tipo de cambio ni línea de negocio). ### 4.7 Interfaz (`web/cotizador/index.html`) -Pestañas: **Nueva cotización**, **Historial**, **Catálogo**, **Cobranza** (solo si `permisos.cobranza`; con tarjeta de margen +Pestañas: **Nueva cotización**, **Historial**, **Catálogo**, **Cobranza** (solo si `permisos.cobranza`; al entrar muestra el +**resumen** `#cobVistaResumen` — `verResumenCob`, `cargarResumenCob`, `filtrarResumenCob`, `pintarResumenCob`, `csvResumenCob`, +`descargarCsvCob` —; clic en un renglón → `abrirProyectoCob` muestra `#cobVistaProyecto` con "← Resumen"; tarjeta de margen `#cobMargen`) y **Compras** (solo si `permisos.compras`): `cargarProyectosCom`, `pintarCompras(vista)` (al cambiar de proyecto limpia el formulario), `recibirProveedores`/`autorrellenarProveedor`, `agregarRenglonOc` (el catálogo llena clave, descripción y unidad; el costo queda vacío; listas `#ocCatalogo`/`#ocClaves` sin precio), `recalcularOc`, `pintarTcOc` (tipo de cambio si la @@ -469,6 +476,13 @@ Compras: `ROL_COMPRAS` (correos separados por coma: efrain@, tania@, leo@; cread proyecto por más de **1.00** (moneda del proyecto) se rechaza. Si la moneda del pago es otra, tipo de cambio del día obligatorio (pesos por dólar) y se guarda el monto aplicado en la moneda del proyecto: **nunca se suman MXN y USD**. - **Cancelar pago:** rol de cobranza, con motivo; el pago queda como Cancelado (nunca se borra) y deja de contar. +- **Resumen de cobranza** (construido 21/10/2026; decisiones del usuario): pantalla inicial de la pestaña Cobranza. Totales por + moneda (nunca MXN + USD): contratado con IVA, cobrado, por cobrar, avance, proyectos con saldo y margen sin IVA. Tabla por + proyecto: contratado, cobrado, saldo, avance, siguiente etapa por cobrar, último pago, costo de órdenes y margen; firmados del + mayor saldo al menor y luego los **"Por firmar"** (aceptada sin firmar: se muestran pero **no suman** a los totales). Los + proyectos **Cancelados no aparecen** (no deberían tener contrato; si lo tienen, se excluyen). Filtros (búsqueda, moneda, + línea, solo con saldo) solo para la tabla; los totales son de todos. **Descargar CSV** de la tabla filtrada (UTF-8 con BOM + para Excel; texto que empiece con `= + - @` con apóstrofo). Solo lee: no escribe en ningún libro. - **USD en cotizaciones:** el catálogo guarda el precio en pesos y la columna I lo convierte a dólares con el tipo de cambio del día (Google Finance, o el manual de la pestaña "Tipo de cambio"; **no es el FIX de Banxico**). En USD la interfaz pone el precio en dólares al elegir un producto; al cambiar de moneda convierte los renglones del catálogo cuyo precio no se tocó (los capturados a @@ -535,7 +549,7 @@ node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.t node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora) node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado, USD, pestañas Cobranza (con margen) y Compras -node tests/cobranza.test.js . # cobranza por doPost: permisos, aceptada, esquema, firma, desbloqueo, pagos, monedas +node tests/cobranza.test.js . # cobranza por doPost: permisos, aceptada, esquema, firma, desbloqueo, pagos, monedas, resumen node tests/compras.test.js . # compras por doPost: ROL_COMPRAS, órdenes, PDF, proveedores, recibir/cancelar, margen, monedas node tests/probar-busqueda-sat.js Code.js "/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras @@ -613,6 +627,10 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio **Revisión en el cotizador real (21/10/2026, Claude in Chrome, sin crear nada):** sesión sin errores, pestañas Cobranza y Compras visibles con los dos roles, Compras sin proyectos ("No hay proyectos activos con contrato firmado": en la hoja real ningún contrato está firmado), vista de cobranza de `PRY-2026-0001` (Cancelado) carga con `margen: null`. Libro de compras vacío. +- **Resumen de cobranza: construido el 21/10/2026, SIN PUBLICAR** (código y pruebas en Node; revisado en local con datos de + ejemplo). Falta: prueba en PRUEBAS (`PRY-2026-0003`/`0004` tienen pagos y órdenes; tú sin cobranza = `pruebasComprasSinCobranza()`), + `clasp push` + comprobar, `clasp deploy` (@9), `wrangler deploy` y revisión en el cotizador real. Solo lee: no requiere respaldo + ni permisos nuevos de Google. - Tania y Leo (rol de compras) no se han probado con sus cuentas reales; la primera orden real será también la primera prueba de PDF en la carpeta real. - Subcarpetas "Órdenes de compra" de los proyectos viejos: quedan vacías y sin uso. Si el usuario lo pide, comprobar con el diff --git a/Code.js b/Code.js index 6486b0c..4fa1ae9 100644 --- a/Code.js +++ b/Code.js @@ -98,6 +98,7 @@ const API_FUNCIONES = { // Cobranza (solo ROL_COBRANZA; cada función lo revisa) listarProyectosCobranza: listarProyectosCobranza, getCobranzaProyecto: getCobranzaProyecto, + resumenCobranzaGeneral: resumenCobranzaGeneral, marcarAceptada: marcarAceptada, guardarEsquema: guardarEsquema, firmarContrato: firmarContrato, @@ -1337,16 +1338,7 @@ function vistaCobranza_(libro, proyecto) { function margenDeContrato_(contrato) { try { const ordenes = ordenesDeProyecto_(libroComprasSiExiste_(), contrato.proyectoFolio).filter(o => o.estado !== OC_CANCELADA); - const porMoneda = {}; - ordenes.forEach(o => { - const m = o.monedaProyecto || contrato.moneda; - const x = porMoneda[m] || (porMoneda[m] = { concepto: 'Órdenes de compra', moneda: m, monto: 0, cantidad: 0 }); - x.monto = (aCentavos(x.monto) + aCentavos(o.subtotalProyecto)) / 100; - x.cantidad++; - }); - const costos = Object.keys(porMoneda).map(m => porMoneda[m]); - if (!costos.length) costos.push({ concepto: 'Órdenes de compra', moneda: contrato.moneda, monto: 0, cantidad: 0 }); - return margenProyecto(contrato.subtotal, contrato.moneda, costos); + return margenProyecto(contrato.subtotal, contrato.moneda, costosDeOrdenes(ordenes, contrato.moneda)); } catch (e) { return { error: 'No se pudo calcular el margen: ' + e.message }; } @@ -1378,6 +1370,30 @@ function getCobranzaProyecto(folio) { return vistaCobranza_(libro, proyectoParaCobranza_(folio)); } +/** + * Resumen de cobranza de todos los proyectos con contrato (solo rol de cobranza; solo lee). Lee una vez Contratos, Esquemas, + * Pagos, Proyectos y las órdenes de compra, y calcula con resumenGeneralCobranza (las mismas cifras que la vista de cada proyecto). + */ +function resumenCobranzaGeneral() { + exigirCobranza_(); + const libro = libroCobranza_(); + const contratos = filasCobranza_(hojaCobranza_(libro, 'Contratos'), 'Contratos') + .map((r, i) => contratoDesdeFila_(r, i + 2)).filter(c => c.proyectoFolio); + const esquemas = filasCobranza_(hojaCobranza_(libro, 'Esquemas'), 'Esquemas') + .map(r => ({ proyectoFolio: String(r[0]), orden: Number(r[1]), etapa: String(r[2]), porcentaje: Number(r[3]) })); + const pagos = filasCobranza_(hojaCobranza_(libro, 'Pagos'), 'Pagos').map((r, i) => pagoDesdeFila_(r, i + 2)); + const hoja = hojaProyectos_(); + const proyectos = proyectosDesdeFilas(hoja.getLastRow() > 1 + ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []); + const libroCompras = libroComprasSiExiste_(); + const ordenes = libroCompras + ? filasDeArea_(hojaCompras_(libroCompras, 'OrdenesCompra'), HOJAS_COMPRAS, 'OrdenesCompra') + .filter(r => String(r[0] || '').trim()).map((r, i) => ordenDesdeFila_(r, i + 2)) + : []; + return Object.assign(resumenGeneralCobranza({ contratos: contratos, esquemas: esquemas, pagos: pagos, proyectos: proyectos, + ordenes: ordenes }), { hoy: hoyIso_() }); +} + /** * Marca la cotización aceptada del proyecto (una sola; se puede cambiar hasta firmar). Copia sus montos, marca y * moneda al contrato. Si la nueva es de Helgen Energy y el esquema actual no es el 60/30/10, pide confirmar @@ -2447,6 +2463,98 @@ function margenProyecto(contratado, moneda, costos) { }; } +/** + * Costos de órdenes de compra para margenProyecto: suma de subtotal_proyecto (sin IVA) de las órdenes recibidas (el que llama + * quita las canceladas), agrupada por moneda del proyecto. Sin órdenes: un costo en 0 en la moneda del contrato. + */ +function costosDeOrdenes(ordenes, monedaContrato) { + const porMoneda = {}; + (ordenes || []).forEach(o => { + const m = o.monedaProyecto || monedaContrato; + const x = porMoneda[m] || (porMoneda[m] = { concepto: 'Órdenes de compra', moneda: m, monto: 0, cantidad: 0 }); + x.monto = (aCentavos(x.monto) + aCentavos(o.subtotalProyecto)) / 100; + x.cantidad++; + }); + const costos = Object.keys(porMoneda).map(m => porMoneda[m]); + return costos.length ? costos : [{ concepto: 'Órdenes de compra', moneda: monedaContrato, monto: 0, cantidad: 0 }]; +} + +/** + * Resumen de cobranza de todos los proyectos. d = {contratos, esquemas: [{proyectoFolio, orden, etapa, porcentaje}], pagos, + * proyectos, ordenes}. Los proyectos Cancelados no aparecen. Un renglón por contrato: "Firmado" o "Por firmar" (aceptada sin + * firmar: se muestra pero no suma a los totales). Pagado y saldo salen de resumenCobranza (lo mismo que la vista del proyecto); + * margen = contratado sin IVA − órdenes de compra no canceladas. Totales por moneda, solo de contratos firmados: nunca se suman + * MXN y USD. Renglones: firmados primero, del mayor saldo al menor; luego los por firmar. + */ +function resumenGeneralCobranza(d) { + const may = s => String(s || '').trim().toUpperCase(); + const proyectos = {}; + (d.proyectos || []).forEach(p => { proyectos[may(p.folio)] = p; }); + const agrupar = (lista, campo) => { + const m = {}; + (lista || []).forEach(x => { const k = may(x[campo]); (m[k] = m[k] || []).push(x); }); + return m; + }; + const esquemas = agrupar(d.esquemas, 'proyectoFolio'), pagos = agrupar(d.pagos, 'proyectoFolio'); + const ordenes = agrupar(d.ordenes, 'proyectoFolio'); + + const renglones = []; + (d.contratos || []).forEach(c => { + const folio = may(c.proyectoFolio); + const p = proyectos[folio]; + if (!p || p.estado === 'Cancelado') return; + let etapas = (esquemas[folio] || []).slice().sort((a, b) => a.orden - b.orden) + .map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje })); + if (!etapas.length) etapas = [{ etapa: 'Total', porcentaje: 100 }]; + const pagosP = pagos[folio] || []; + const r = resumenCobranza(c, etapas, pagosP); + const siguiente = r.etapas.find(e => aCentavos(e.saldo) > 0); + const ultimoPago = pagosP.filter(x => x.estado === 'Vigente').map(x => String(x.fecha || '')).sort().pop() || ''; + const ocs = (ordenes[folio] || []).filter(o => o.estado !== OC_CANCELADA); + let margen = null, margenError = ''; + try { + margen = margenProyecto(c.subtotal, c.moneda, costosDeOrdenes(ocs, c.moneda)); + } catch (e) { + margenError = e.message; + } + renglones.push({ + folio: p.folio, nombre: p.nombre, cliente: p.cliente, estadoProyecto: p.estado, marca: c.marca, moneda: c.moneda, + estadoContrato: c.estado === ESTADO_FIRMADO ? 'Firmado' : 'Por firmar', cotizacion: c.cotizacion, fechaFirma: c.fechaFirma, + contratado: r.contratado, subtotal: r.subtotal, pagado: r.pagado, saldo: r.saldo, avance: r.avance, + siguienteEtapa: siguiente ? siguiente.etapa : '', siguienteSaldo: siguiente ? siguiente.saldo : 0, ultimoPago: ultimoPago, + ordenes: ocs.length, costoOc: margen ? margen.totalCostos : null, margen: margen ? margen.margen : null, + margenPorcentaje: margen ? margen.porcentaje : null, margenError: margenError, + }); + }); + renglones.sort((a, b) => (a.estadoContrato === b.estadoContrato ? 0 : a.estadoContrato === 'Firmado' ? -1 : 1) || + b.saldo - a.saldo || (a.folio < b.folio ? -1 : 1)); + + // Totales por moneda (en centavos para no acumular errores de redondeo) + const tot = {}; + renglones.forEach(x => { + const t = tot[x.moneda] || (tot[x.moneda] = { moneda: x.moneda, proyectos: 0, conSaldo: 0, contratado: 0, cobrado: 0, + porCobrar: 0, contratadoSinIva: 0, costoOc: 0, porFirmar: 0, montoPorFirmar: 0, margenError: false }); + if (x.estadoContrato !== 'Firmado') { t.porFirmar++; t.montoPorFirmar += aCentavos(x.contratado); return; } + t.proyectos++; + if (aCentavos(x.saldo) > 0) t.conSaldo++; + t.contratado += aCentavos(x.contratado); t.cobrado += aCentavos(x.pagado); t.porCobrar += aCentavos(x.saldo); + t.contratadoSinIva += aCentavos(x.subtotal); + if (x.margenError) t.margenError = true; else t.costoOc += aCentavos(x.costoOc); + }); + const totales = Object.keys(tot).sort().map(k => { + const t = tot[k]; + const margenCts = t.contratadoSinIva - t.costoOc; + return { + moneda: t.moneda, proyectos: t.proyectos, conSaldo: t.conSaldo, contratado: t.contratado / 100, cobrado: t.cobrado / 100, + porCobrar: t.porCobrar / 100, avance: t.contratado > 0 ? Math.min(100, Math.round(t.cobrado / t.contratado * 1000) / 10) : 0, + contratadoSinIva: t.contratadoSinIva / 100, costoOc: t.costoOc / 100, margen: t.margenError ? null : margenCts / 100, + margenPorcentaje: t.margenError || t.contratadoSinIva <= 0 ? null : Math.round(margenCts / t.contratadoSinIva * 1000) / 10, + porFirmar: t.porFirmar, montoPorFirmar: t.montoPorFirmar / 100, + }; + }); + return { totales: totales, proyectos: renglones }; +} + /** Nombre del PDF de una orden: "OC-2026-0001 — .pdf", sin caracteres raros. */ function nombreArchivoOc(folio, proveedor) { const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim(); diff --git a/tests/cobranza.test.js b/tests/cobranza.test.js index bd89812..fd3d81b 100644 --- a/tests/cobranza.test.js +++ b/tests/cobranza.test.js @@ -351,4 +351,38 @@ assert.deepEqual([v.proyecto.estado,v.puedeAceptar,v.puedeFirmar],['Cancelado',f falla('tania','marcarAceptada',[p3,c3b],/está cancelado/); falla('tania','firmarContrato',[p3,HOY_ISO],/está cancelado/); db.pest.Proyectos.m[filaP3][6]='Activo'; assert.deepEqual([ok('tania','getCobranzaProyecto',p3).puedeAceptar,ok('tania','getCobranzaProyecto',p3).puedeFirmar],[true,true],'reactivado'); + +// --- Resumen de cobranza (todos los proyectos): solo rol de cobranza; mismas cifras que la vista de cada proyecto --- +falla('ana','resumenCobranzaGeneral',[],/No tienes permiso de cobranza/,'PERMISO'); +falla('intruso','resumenCobranzaGeneral',[],/@helgen.tech/,'SESION'); +let rs=ok('tania','resumenCobranzaGeneral'); +assert.deepEqual(rs.proyectos.map(x=>x.folio+':'+x.estadoContrato),[p1+':Firmado',p2+':Firmado',p3+':Por firmar'],'firmados por saldo, luego por firmar'); +const r1=rs.proyectos[0], v1=ok('tania','getCobranzaProyecto',p1); +assert.deepEqual([r1.contratado,r1.pagado,r1.saldo,r1.avance],[v1.resumen.contratado,v1.resumen.pagado,v1.resumen.saldo,v1.resumen.avance],'igual que la vista'); +assert.deepEqual([r1.contratado,r1.pagado,r1.saldo,r1.avance,r1.siguienteEtapa,r1.siguienteSaldo,r1.ultimoPago,r1.moneda], + [1160,800,360,69,'Instalación',244,HOY_ISO,'MXN'],'el pago cancelado no cuenta'); +assert.deepEqual([r1.costoOc,r1.margen,r1.margenPorcentaje,r1.ordenes],[0,1000,100,0],'sin órdenes: margen = contratado sin IVA'); +const r2=rs.proyectos[1]; +assert.deepEqual([r2.moneda,r2.contratado,r2.pagado,r2.saldo,r2.siguienteEtapa,r2.margen],['USD',1000,1000,0,'',862.07],'pago en MXN convertido a USD'); +assert.deepEqual([rs.proyectos[2].contratado,rs.proyectos[2].pagado],[116,0]); +const tMXN=rs.totales.find(t=>t.moneda==='MXN'), tUSD=rs.totales.find(t=>t.moneda==='USD'); +assert.deepEqual([tMXN.proyectos,tMXN.conSaldo,tMXN.contratado,tMXN.cobrado,tMXN.porCobrar,tMXN.avance,tMXN.margen,tMXN.margenPorcentaje,tMXN.porFirmar,tMXN.montoPorFirmar], + [1,1,1160,800,360,69,1000,100,1,116],'MXN: el por firmar no suma'); +assert.deepEqual([tUSD.proyectos,tUSD.conSaldo,tUSD.contratado,tUSD.cobrado,tUSD.porCobrar,tUSD.avance],[1,0,1000,1000,0,100],'USD aparte: nunca se suma con MXN'); +assert.deepEqual(rs.totales.map(t=>t.moneda),['MXN','USD']); +// Proyecto cancelado: no aparece ni suma +db.pest.Proyectos.m[filaP3][6]='Cancelado'; +rs=ok('tania','resumenCobranzaGeneral'); +assert(!rs.proyectos.some(x=>x.folio===p3)); assert.equal(rs.totales.find(t=>t.moneda==='MXN').porFirmar,0); +db.pest.Proyectos.m[filaP3][6]='Activo'; +// Solo lee: no escribe en ningún libro +const antes=JSON.stringify(Object.values(libros).map(L=>Object.keys(L.pest).map(k=>L.pest[k].m.length))); +ok('tania','resumenCobranzaGeneral'); +assert.equal(JSON.stringify(Object.values(libros).map(L=>Object.keys(L.pest).map(k=>L.pest[k].m.length))),antes,'el resumen no escribe'); +// Lógica pura: sin esquema (etapa única), orden en otra moneda → margen a revisar (y totales sin margen) +const pu=P.resumenGeneralCobranza({contratos:[{proyectoFolio:'PRY-1',estado:'Firmado',moneda:'MXN',marca:'helgen',total:116,subtotal:100}], + esquemas:[],pagos:[],proyectos:[{folio:'PRY-1',estado:'Cerrado',nombre:'N',cliente:'C'}], + ordenes:[{proyectoFolio:'PRY-1',estado:'Emitida',monedaProyecto:'USD',subtotalProyecto:5}]}); +assert.deepEqual([pu.proyectos[0].siguienteEtapa,pu.proyectos[0].saldo,pu.proyectos[0].margen,!!pu.proyectos[0].margenError,pu.totales[0].margen], + ['Total',116,null,true,null],'cerrado sí aparece; margen con monedas mezcladas se marca'); console.log('OK cobranza'); diff --git a/tests/compras.test.js b/tests/compras.test.js index f0c0ff6..c2c6983 100644 --- a/tests/compras.test.js +++ b/tests/compras.test.js @@ -271,6 +271,17 @@ v=ok('tania','getCobranzaProyecto',p2); assert.deepEqual([v.margen.moneda,v.margen.contratado,v.margen.totalCostos,v.margen.margen,v.margen.porcentaje],['USD',5000,100,4900,98]); v=ok('tania','getCobranzaProyecto',p3); assert.deepEqual([v.margen.totalCostos,v.margen.margen],[0,100],'sin órdenes'); +// --- Resumen de cobranza: costo de órdenes y margen por proyecto (las mismas cifras que la vista) --- +const rs=ok('tania','resumenCobranzaGeneral'); +const rp1=rs.proyectos.find(x=>x.folio===p1), rp2=rs.proyectos.find(x=>x.folio===p2); +assert.deepEqual([rp1.ordenes,rp1.costoOc,rp1.margen,rp1.margenPorcentaje],[2,3850,6150,61.5],'la cancelada no cuenta'); +assert.deepEqual([rp2.moneda,rp2.costoOc,rp2.margen,rp2.margenPorcentaje],['USD',100,4900,98]); +assert(!rs.proyectos.some(x=>x.folio===p4),'proyecto cancelado fuera del resumen'); +assert.deepEqual(rs.proyectos.filter(x=>x.estadoContrato==='Por firmar').map(x=>x.folio),[p3]); +const tm=rs.totales.find(t=>t.moneda==='MXN'); +assert.deepEqual([tm.contratadoSinIva,tm.costoOc,tm.margen,tm.margenPorcentaje],[10000,3850,6150,61.5],'el por firmar no suma al margen'); +falla('pedro','resumenCobranzaGeneral',[],/No tienes permiso de cobranza/,'PERMISO'); + // --- Con órdenes vigentes en MXN, la aceptada no puede cambiar a USD (tras desbloquear) --- ok('efrain','desbloquearContrato',p1,'Prueba de moneda con órdenes'); falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/); diff --git a/tests/interfaz.test.js b/tests/interfaz.test.js index 878152e..b412759 100644 --- a/tests/interfaz.test.js +++ b/tests/interfaz.test.js @@ -137,7 +137,47 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia assert(!B.$('tabCob').classList.contains('oculto'),'con permiso ve la pestaña'); assert(B.$('tabCom').classList.contains('oculto'),'cobranza sin compras no ve Compras'); B.respuestas.listarProyectosCobranza={ok:true,datos:[{...PRY,contrato:''}]}; + // Resumen de todos los proyectos: aparece al entrar a la pestaña + const RES={hoy:'2026-10-21',totales:[{moneda:'MXN',proyectos:2,conSaldo:1,contratado:11600,cobrado:5000,porCobrar:6600,avance:43.1, + contratadoSinIva:10000,costoOc:4850,margen:5150,margenPorcentaje:51.5,porFirmar:1,montoPorFirmar:116}, + {moneda:'USD',proyectos:1,conSaldo:0,contratado:1000,cobrado:1000,porCobrar:0,avance:100,contratadoSinIva:862.07,costoOc:1200,margen:-337.93,margenPorcentaje:-39.2,porFirmar:0,montoPorFirmar:0}], + proyectos:[{folio:'PRY-2026-0003',nombre:'Techo',cliente:'=Luis, "SA"',estadoProyecto:'Activo',marca:'helgen',moneda:'MXN',estadoContrato:'Firmado', + contratado:11600,subtotal:10000,pagado:5000,saldo:6600,avance:43.1,siguienteEtapa:'Instalación',siguienteSaldo:3480,ultimoPago:'2026-10-20',ordenes:2,costoOc:4850,margen:5150,margenPorcentaje:51.5,margenError:''}, + {folio:'PRY-2026-0004',nombre:'Texas',cliente:'Solar Corp',estadoProyecto:'Cerrado',marca:'energy',moneda:'USD',estadoContrato:'Firmado', + contratado:1000,subtotal:862.07,pagado:1000,saldo:0,avance:100,siguienteEtapa:'',siguienteSaldo:0,ultimoPago:'2026-10-19',ordenes:1,costoOc:1200,margen:-337.93,margenPorcentaje:-39.2,margenError:''}, + {folio:'PRY-2026-0005',nombre:'Nuevo',cliente:'Ana',estadoProyecto:'Activo',marca:'helgen',moneda:'MXN',estadoContrato:'Por firmar', + contratado:116,subtotal:100,pagado:0,saldo:116,avance:0,siguienteEtapa:'Anticipo',siguienteSaldo:69.6,ultimoPago:'',ordenes:0,costoOc:null,margen:null,margenPorcentaje:null,margenError:'hay costos en USD'}]}; + B.respuestas.resumenCobranzaGeneral={ok:true,datos:RES}; B.run("verTab('cob')"); await esperar(); + assert(!B.$('cobVistaResumen').classList.contains('oculto')&&B.$('cobVistaProyecto').classList.contains('oculto'),'al entrar se ve el resumen'); + const tt=B.$('resTotales').innerHTML; + assert(/MXN · 2 contratos firmados · 1 por firmar \(\$116\.00 MXN, no suman\)/.test(tt)&&/Por cobrar<\/span>\$6,600\.00 MXN/.test(tt)&&/1 de 2/.test(tt),tt); + assert(/Margen \(sin IVA\)<\/span>\$5,150\.00 MXN · 51\.5%/.test(tt)&&/class="negativo">-\$337\.93 USD · -39\.2%/.test(tt),'margen por moneda'); + let rl=B.$('resLista').innerHTML; + assert.deepEqual((rl.match(/data-folio="([^"]+)"/g)||[]).length,3); + assert(/chip aceptada" style="white-space:nowrap">Por firmar/.test(rl)&&/Instalación · \$3,480\.00 MXN/.test(rl)&&/Último pago 20\/10\/2026/.test(rl)&&/>PagadoRevisar/.test(rl)&&/aviso-moneda">-\$337\.93 USD/.test(rl)); + // Filtros (solo la tabla) + B.$('resMoneda').value='USD'; B.run('pintarResumenCob()'); assert.deepEqual(B.$('resLista').innerHTML.match(/data-folio="([^"]+)"/g),['data-folio="PRY-2026-0004"']); + B.$('resMoneda').value=''; B.$('resSaldo').checked=true; B.run('pintarResumenCob()'); + assert.deepEqual(B.$('resLista').innerHTML.match(/data-folio="([^"]+)"/g),['data-folio="PRY-2026-0003"'],'solo con saldo: firmados con saldo'); + B.$('resSaldo').checked=false; B.$('resBuscar').value='ana'; B.run('pintarResumenCob()'); + assert.deepEqual(B.$('resLista').innerHTML.match(/data-folio="([^"]+)"/g),['data-folio="PRY-2026-0005"']); + B.$('resBuscar').value='zzz'; B.run('pintarResumenCob()'); assert(/Ningún proyecto coincide/.test(B.$('resLista').innerHTML)); + B.$('resBuscar').value=''; + // CSV: encabezados, números con punto, texto entrecomillado, fórmulas neutralizadas + const csv=B.run('csvResumenCob(RESUMEN.proyectos)').split('\r\n'); + assert.equal(csv[0].charCodeAt(0),0xFEFF,'BOM para Excel'); assert(csv[0].includes('"Contratado con IVA","Cobrado","Saldo"')); + assert.equal(csv[1],'"PRY-2026-0003","Techo","\'=Luis, ""SA""","Helgen","Firmado","MXN",11600.00,5000.00,6600.00,43.1,"Instalación",3480.00,"2026-10-20",2,4850.00,5150.00,51.5'); + assert(csv[3].endsWith(',0,,,'),'sin margen: celdas vacías'); assert.equal(csv.length,4); + // Clic en un renglón abre el proyecto; "← Resumen" regresa + B.respuestas.getCobranzaProyecto={ok:true,datos:{proyecto:PRY,cotizaciones:[],contrato:null,esquema:[],resumen:null,pagos:[],puedeAceptar:true,puedeFirmar:false,metodos:[],hoy:'2026-10-20'}}; + B.run("abrirProyectoCob('PRY-2026-0001')"); await esperar(); + assert(B.$('cobVistaResumen').classList.contains('oculto')&&!B.$('cobVistaProyecto').classList.contains('oculto'),'se abre el proyecto'); + const nRes=B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length; + B.run('verResumenCob()'); await esperar(); + assert(!B.$('cobVistaResumen').classList.contains('oculto')&&B.$('cobVistaProyecto').classList.contains('oculto')); + assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes+1,'al regresar se actualiza el resumen'); assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML)); const cots=[{folio:'HLG-2026-0010',fecha:'20/10/2026',marca:'helgen',moneda:'MXN',subtotal:1000,total:1160,pdf:'p'}, {folio:'HLG-2026-0011',fecha:'20/10/2026',marca:'energy',moneda:'MXN',subtotal:2000,total:2320,pdf:'p'}]; diff --git a/web/cotizador/index.html b/web/cotizador/index.html index 17653dc..a7a7c22 100644 --- a/web/cotizador/index.html +++ b/web/cotizador/index.html @@ -274,13 +274,49 @@
+ +
+
+

1 · Resumen de cobranza

+
Cargando…
+
+ + + + + + +
+
+
+

2 · Proyectos

+
+ + + + + +
ProyectoContratoContratadoCobradoSaldoAvanceSiguiente etapa · último pagoCosto OCMargen
+
+
Haz clic en un proyecto para ver su contrato, esquema y pagos. Contratado, cobrado y saldo + con IVA; costo de órdenes de compra y margen sin IVA. Los contratos "Por firmar" no suman a los totales; los proyectos + cancelados no aparecen.
+
+
+ +

1 · Proyecto y contrato

- +
Elige un proyecto para ver su contrato, esquema y pagos.
@@ -331,6 +367,7 @@
+
@@ -975,10 +1012,86 @@ $('cobProyecto').addEventListener('change', () => { const p = proyectoCobElegido(); if (p) abrirProyectoCob(p.folio); }); function abrirProyectoCob(folio) { + $('cobVistaResumen').classList.add('oculto'); $('cobVistaProyecto').classList.remove('oculto'); $('cobInfo').textContent = 'Cargando…'; google.script.run.withSuccessHandler(pintarCobranza).withFailureHandler(mostrarError).getCobranzaProyecto(folio); } + // ---- Resumen de cobranza (todos los proyectos; lo calcula el servidor con las mismas cifras que cada proyecto) ---- + let RESUMEN = null; + + function verResumenCob() { + $('cobVistaProyecto').classList.add('oculto'); $('cobVistaResumen').classList.remove('oculto'); + cargarResumenCob(); + } + + function cargarResumenCob() { + if (!RESUMEN) $('resTotales').textContent = 'Cargando…'; + google.script.run.withSuccessHandler(r => { RESUMEN = r; pintarResumenCob(); }) + .withFailureHandler(mostrarError).resumenCobranzaGeneral(); + } + + /** Renglones del resumen que pasan los filtros (búsqueda, moneda, línea, solo con saldo). */ + function filtrarResumenCob() { + const q = $('resBuscar').value.trim().toLowerCase(), m = $('resMoneda').value, l = $('resMarca').value, conSaldo = $('resSaldo').checked; + return ((RESUMEN && RESUMEN.proyectos) || []).filter(p => (!q || (p.folio + ' ' + p.cliente + ' ' + p.nombre).toLowerCase().includes(q)) && + (!m || p.moneda === m) && (!l || p.marca === l) && (!conSaldo || (p.estadoContrato === 'Firmado' && p.saldo > 0.004))); + } + + function pintarResumenCob() { + if (!RESUMEN) return; + const tile = (k, x, clase) => '
' + esc(k) + '' + x + '
'; + // Totales: uno por moneda, solo contratos firmados (los filtros aplican a la tabla, no a los totales) + $('resTotales').innerHTML = RESUMEN.totales.length ? RESUMEN.totales.map(t => + '
' + esc(t.moneda) + ' · ' + t.proyectos + (t.proyectos === 1 ? ' contrato firmado' : ' contratos firmados') + + (t.porFirmar ? ' · ' + t.porFirmar + ' por firmar (' + dinero(t.montoPorFirmar, t.moneda) + ', no suman)' : '') + '
' + + '
' + tile('Contratado (con IVA)', dinero(t.contratado, t.moneda)) + tile('Cobrado', dinero(t.cobrado, t.moneda)) + + tile('Por cobrar', dinero(t.porCobrar, t.moneda)) + tile('Avance', t.avance + '%') + + tile('Proyectos con saldo', t.conSaldo + ' de ' + t.proyectos) + + tile('Margen (sin IVA)', t.margen == null ? '—' : dinero(t.margen, t.moneda) + (t.margenPorcentaje == null ? '' : ' · ' + t.margenPorcentaje + '%'), + t.margen != null && t.margen < 0 ? 'negativo' : '') + '
').join('') + : 'Todavía no hay proyectos con cotización aceptada.'; + const lista = filtrarResumenCob(); + $('resLista').innerHTML = lista.length ? lista.map(p => { + const firmado = p.estadoContrato === 'Firmado'; + return '' + esc(p.folio) + '' + + ' · ' + esc(MARCA_TXT[p.marca] || '') + '
' + esc(p.cliente) + ' · ' + esc(p.nombre) + + (p.estadoProyecto !== 'Activo' ? ' · ' + esc(p.estadoProyecto) : '') + '
' + + '' + esc(p.estadoContrato) + '' + + '' + dinero(p.contratado, p.moneda) + '' + dinero(p.pagado, p.moneda) + '' + + '' + dinero(p.saldo, p.moneda) + '' + + '
' + p.avance + '%' + + '' + (p.siguienteEtapa ? esc(p.siguienteEtapa) + ' · ' + dinero(p.siguienteSaldo, p.moneda) : 'Pagado') + + '
' + (p.ultimoPago ? 'Último pago ' + esc(fechaMx(p.ultimoPago)) : 'Sin pagos') + '
' + + '' + (p.costoOc == null ? '—' : dinero(p.costoOc, p.moneda) + '
' + p.ordenes + (p.ordenes === 1 ? ' orden' : ' órdenes') + '
') + '' + + '' + (p.margen == null ? 'Revisar' + : '' + dinero(p.margen, p.moneda) + '
' + p.margenPorcentaje + '%
') + ''; + }).join('') : '' + ((RESUMEN.proyectos || []).length ? 'Ningún proyecto coincide con los filtros.' : 'Sin proyectos con contrato.') + ''; + $('resLista').querySelectorAll('tr[data-folio]').forEach(tr => tr.onclick = () => abrirProyectoCob(tr.dataset.folio)); + } + + /** CSV de los renglones filtrados (UTF-8 con BOM para que Excel lea los acentos). Texto que empiece con = + - @ va con apóstrofo. */ + function csvResumenCob(lista) { + const texto = s => { const v = String(s ?? ''); return '"' + (/^[=+\-@]/.test(v) ? "'" + v : v).replace(/"/g, '""') + '"'; }; + const num = n => n == null ? '' : r2(n).toFixed(2); + const enc = ['Folio', 'Proyecto', 'Cliente', 'Línea de negocio', 'Contrato', 'Moneda', 'Contratado con IVA', 'Cobrado', 'Saldo', 'Avance %', + 'Siguiente etapa', 'Saldo siguiente etapa', 'Último pago', 'Órdenes de compra', 'Costo OC sin IVA', 'Margen sin IVA', 'Margen %']; + const filas = lista.map(p => [texto(p.folio), texto(p.nombre), texto(p.cliente), texto(MARCA_TXT[p.marca] || ''), texto(p.estadoContrato), + texto(p.moneda), num(p.contratado), num(p.pagado), num(p.saldo), String(p.avance), texto(p.siguienteEtapa), + p.siguienteEtapa ? num(p.siguienteSaldo) : '', texto(p.ultimoPago), String(p.ordenes), num(p.costoOc), num(p.margen), + p.margenPorcentaje == null ? '' : String(p.margenPorcentaje)].join(',')); + return '' + [enc.map(texto).join(',')].concat(filas).join('\r\n'); + } + + function descargarCsvCob() { + if (!RESUMEN) { mostrarError('Espera a que cargue el resumen.'); return; } + const url = URL.createObjectURL(new Blob([csvResumenCob(filtrarResumenCob())], { type: 'text/csv;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; a.download = 'cobranza-' + (RESUMEN.hoy || 'resumen') + '.csv'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + /** Pinta todo a partir de la respuesta del servidor (vistaCobranza_). */ function pintarCobranza(v) { COB = v; @@ -1368,7 +1481,7 @@ $('tabHist').classList.toggle('activo', t === 'hist'); if (t === 'hist') cargarHistorial(); if (t === 'cat') cargarCatalogo(); - if (t === 'cob') cargarProyectosCob(); + if (t === 'cob') { cargarProyectosCob(); verResumenCob(); } if (t === 'com') cargarProyectosCom(); $('aviso').className = 'aviso'; }