Cobranza: resumen de todos los proyectos (cobrado, por cobrar, margen) y CSV

- 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 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-21 11:12:24 -06:00
parent 294cc13bd3
commit c99e89d91f
6 changed files with 339 additions and 15 deletions

View File

@ -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 "<ruta>/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

128
Code.js
View File

@ -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 — <proveedor>.pdf", sin caracteres raros. */
function nombreArchivoOc(folio, proveedor) {
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();

View File

@ -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');

View File

@ -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/);

View File

@ -137,7 +137,47 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
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><b>\$6,600\.00 MXN/.test(tt)&&/1 de 2/.test(tt),tt);
assert(/Margen \(sin IVA\)<\/span><b>\$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)&&/>Pagado</.test(rl)&&/Sin pagos/.test(rl)&&/· Cerrado/.test(rl)&&/· Helgen Energy/.test(rl),rl);
assert(/aviso-moneda" title="hay costos en USD">Revisar/.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'}];

View File

@ -274,13 +274,49 @@
<!-- ===== COBRANZA (solo rol de cobranza; el servidor lo vuelve a revisar en cada llamada) ===== -->
<section id="vistaCob" class="oculto">
<!-- Resumen de todos los proyectos (al entrar a la pestaña); clic en un renglón abre el proyecto -->
<div id="cobVistaResumen">
<div class="modulo">
<h2>1 · Resumen de cobranza</h2>
<div id="resTotales" class="nota">Cargando…</div>
<div class="acciones">
<input id="resBuscar" placeholder="Buscar por folio, cliente o nombre…" oninput="pintarResumenCob()" style="flex:1; min-width:220px">
<select id="resMoneda" onchange="pintarResumenCob()" style="width:auto">
<option value="">Todas las monedas</option><option value="MXN">MXN</option><option value="USD">USD</option>
</select>
<select id="resMarca" onchange="pintarResumenCob()" style="width:auto">
<option value="">Todas las líneas</option><option value="helgen">Helgen</option><option value="energy">Helgen Energy</option>
</select>
<label style="display:flex; align-items:center; gap:6px; margin:0; white-space:nowrap">
<input type="checkbox" id="resSaldo" onchange="pintarResumenCob()" style="width:auto"> Solo con saldo</label>
<button type="button" class="btn btn-sec" onclick="cargarResumenCob()">Actualizar</button>
<button type="button" class="btn btn-sec" onclick="descargarCsvCob()">Descargar CSV</button>
</div>
</div>
<div class="modulo">
<h2>2 · Proyectos</h2>
<div style="overflow-x:auto">
<table style="font-size:12px">
<thead><tr><th>Proyecto</th><th>Contrato</th><th class="num">Contratado</th><th class="num">Cobrado</th>
<th class="num">Saldo</th><th style="width:90px">Avance</th><th>Siguiente etapa · último pago</th>
<th class="num">Costo OC</th><th class="num">Margen</th></tr></thead>
<tbody id="resLista"></tbody>
</table>
</div>
<div class="nota" style="margin-top:8px">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.</div>
</div>
</div>
<div id="cobVistaProyecto" class="oculto">
<div class="modulo">
<h2>1 · Proyecto y contrato</h2>
<div class="proy-fila">
<div><label>Proyecto</label>
<input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
<datalist id="cobProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosCob()">Actualizar lista</button>
<button type="button" class="btn btn-sec" onclick="verResumenCob()">← Resumen</button>
</div>
<div id="cobInfo" class="nota" style="margin-top:6px">Elige un proyecto para ver su contrato, esquema y pagos.</div>
<div id="cobDetalle" class="oculto">
@ -331,6 +367,7 @@
<tbody id="cobPagos"></tbody>
</table>
</div>
</div>
</section>
<!-- ===== COMPRAS (solo rol de compras; el servidor lo vuelve a revisar en cada llamada) ===== -->
@ -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) => '<div class="tile"><span>' + esc(k) + '</span><b' + (clase ? ' class="' + clase + '"' : '') + '>' + x + '</b></div>';
// 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 =>
'<div class="sat-titulo" style="margin-top:4px">' + 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)' : '') + '</div>' +
'<div class="tiles">' + 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' : '') + '</div>').join('')
: '<span class="nota">Todavía no hay proyectos con cotización aceptada.</span>';
const lista = filtrarResumenCob();
$('resLista').innerHTML = lista.length ? lista.map(p => {
const firmado = p.estadoContrato === 'Firmado';
return '<tr class="fila-cat" data-folio="' + esc(p.folio) + '"><td style="min-width:190px"><b style="white-space:nowrap">' + esc(p.folio) + '</b>' +
' <span class="nota">· ' + esc(MARCA_TXT[p.marca] || '') + '</span><div class="nota">' + esc(p.cliente) + ' · ' + esc(p.nombre) +
(p.estadoProyecto !== 'Activo' ? ' · ' + esc(p.estadoProyecto) : '') + '</div></td><td>' +
'<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '" style="white-space:nowrap">' + esc(p.estadoContrato) + '</span></td>' +
'<td class="num">' + dinero(p.contratado, p.moneda) + '</td><td class="num">' + dinero(p.pagado, p.moneda) + '</td>' +
'<td class="num"><b>' + dinero(p.saldo, p.moneda) + '</b></td>' +
'<td><div class="barra" title="' + p.avance + '%"><i style="width:' + Math.max(0, Math.min(100, p.avance)) + '%"></i></div><span class="nota">' + p.avance + '%</span></td>' +
'<td>' + (p.siguienteEtapa ? esc(p.siguienteEtapa) + ' · ' + dinero(p.siguienteSaldo, p.moneda) : 'Pagado') +
'<div class="nota">' + (p.ultimoPago ? 'Último pago ' + esc(fechaMx(p.ultimoPago)) : 'Sin pagos') + '</div></td>' +
'<td class="num">' + (p.costoOc == null ? '—' : dinero(p.costoOc, p.moneda) + '<div class="nota">' + p.ordenes + (p.ordenes === 1 ? ' orden' : ' órdenes') + '</div>') + '</td>' +
'<td class="num">' + (p.margen == null ? '<span class="nota aviso-moneda" title="' + esc(p.margenError) + '">Revisar</span>'
: '<b' + (p.margen < 0 ? ' class="aviso-moneda"' : '') + '>' + dinero(p.margen, p.moneda) + '</b><div class="nota">' + p.margenPorcentaje + '%</div>') + '</td></tr>';
}).join('') : '<tr><td colspan="9" class="nota">' + ((RESUMEN.proyectos || []).length ? 'Ningún proyecto coincide con los filtros.' : 'Sin proyectos con contrato.') + '</td></tr>';
$('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';
}