From d08f4025f5d1d9996b6aee946aae639551a185c6 Mon Sep 17 00:00:00 2001 From: "Efrain (Helgen)" Date: Tue, 20 Oct 2026 16:49:18 -0600 Subject: [PATCH] Cobranza: esquema de Energy editable solo por el administrador; columnas USD en el PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esquema de Helgen Energy: solo ADMIN_DESBLOQUEO lo edita (antes de firmar), con motivo obligatorio que queda en la Bitacora junto con el esquema anterior. Al cambiar entre cotizaciones de Energy se conserva el esquema; al pasar de Helgen a Energy se pone el 60/30/10 (con confirmación si reemplaza otro). - PDF en USD: columnas "P. Unitario (USD)" e "Importe (USD)"; en MXN sin cambios. - Prueba de que la cotización USD imprime el mismo bloque de términos que MXN. - Pruebas de cobranza e interfaz ampliadas; CLAUDE.md actualizado. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 +++++++++------ Code.js | 38 +++++++++++++++++++++++++++----------- tests/cobranza.test.js | 29 +++++++++++++++++++++++------ tests/cotizacion.test.js | 7 +++++++ tests/interfaz.test.js | 15 ++++++++++++++- web/cotizador/index.html | 15 ++++++++++----- 6 files changed, 90 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 850f05b..f3bd680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,8 +155,9 @@ 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)`, - `marcarAceptada(proyecto, cotizacion, confirmado)` (si es Energy y reemplaza un esquema distinto al 60/30/10 devuelve - `{confirmar, mensaje}` sin cambiar nada), `guardarEsquema(proyecto, etapas)`, `firmarContrato(proyecto, 'AAAA-MM-DD')`, + `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')`, `desbloquearContrato(proyecto, motivo)` (además `esAdminDesbloqueo_`), `registrarPago(p)` (con `comprobante.datos` en base64 e idempotencia `PAG_`), `cancelarPago(folio, motivo)`. Las que cambian algo usan `conCandado_` y devuelven la vista (`vistaCobranza_`). @@ -371,8 +372,9 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A - **Aceptada:** una por proyecto; se puede cambiar hasta firmar. Monto contratado = total **con IVA**; subtotal de referencia. **Línea de negocio y moneda del proyecto salen de la aceptada.** Si ya hay pagos (tras un desbloqueo), la nueva aceptada debe tener la misma moneda. -- **Esquema:** Helgen Energy **fijo** 60% Anticipo / 30% Instalación / 10% Encendido del sistema (el servidor rechaza editarlo); - Helgen: mismas etapas como sugerencia, editables (nombres, % y número de etapas, 1–10) hasta firmar. Suma exacta 100%. +- **Esquema:** Helgen Energy: 60% Anticipo / 30% Instalación / 10% Encendido del sistema; **solo `ADMIN_DESBLOQUEO` lo edita**, + con motivo obligatorio que queda en la Bitacora (con el esquema anterior). Helgen: mismas etapas como sugerencia, editables por + el rol de cobranza (nombres, % y número de etapas, 1–10). En ambos casos solo antes de firmar y con suma exacta de 100%. - **Contrato firmado** (fecha, no futura): bloquea aceptada, monto y esquema. No se puede firmar si lo pagado supera el nuevo monto. **Desbloqueo:** solo `ADMIN_DESBLOQUEO`, motivo obligatorio; vuelve a "Aceptada", conserva los pagos y queda en Bitacora. - **Pagos:** solo con contrato firmado; comprobante **obligatorio** (PDF/JPG/PNG/HEIC, máx. 10 MB, tipo revisado por contenido) en @@ -382,7 +384,9 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `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. - **USD en cotizaciones:** el catálogo está en pesos: en USD la interfaz llena clave y descripción, y el precio se captura a mano - (no se convierte con tipo de cambio). El PDF dice "Moneda: USD (dólares estadounidenses)", "TOTAL USD" e importe con letra en dólares. + (no se convierte con tipo de cambio). El PDF dice "Moneda: USD (dólares estadounidenses)", columnas "P. Unitario (USD)" e + "Importe (USD)", "TOTAL USD" e importe con letra en dólares. **Términos y condiciones: el mismo bloque que en MXN, sin cambios** + (incluido "$300 MXN por metro" en Energy; decisión del usuario, 20/10/2026). ## 7. Reglas del proyecto (no romper) 1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa. @@ -466,7 +470,6 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio - **Acceso del dominio a la Base de datos y a la carpeta maestra** (lector para todo helgen.tech): se queda por ahora; revisarlo junto con la idea "cada vendedor ve solo sus cotizaciones" (quitarlo impide abrir los PDF desde el Historial). - Tania y Leo eran **editores** de la Base de datos (20/10/2026); el usuario los baja a lector. -- Los T&C de Helgen Energy dicen "$300 MXN por metro" también en cotizaciones USD: confirmar con el usuario si se ajusta. - Mover el proyecto de prueba (ya sin deployment, §10.5) a la carpeta `1w-T05ql…` junto a los "ARCHIVO PRUEBAS" (lo hace el usuario). ## 10. Interfaz web y sesión con Google diff --git a/Code.js b/Code.js index 7ab980d..7b5c6ac 100644 --- a/Code.js +++ b/Code.js @@ -1167,7 +1167,8 @@ function vistaCobranza_(libro, proyecto) { esquema: d.etapas, resumen: resumen, pagos: d.pagos.map(p => { const o = Object.assign({}, p); delete o.fila; return o; }).reverse(), - esquemaEditable: !!c && c.estado !== ESTADO_FIRMADO && c.marca !== 'energy', + esquemaEditable: !!c && c.estado !== ESTADO_FIRMADO && (c.marca !== 'energy' || esAdminDesbloqueo_()), + esquemaPideMotivo: !!c && c.estado !== ESTADO_FIRMADO && c.marca === 'energy' && esAdminDesbloqueo_(), puedePagar: !!c && c.estado === ESTADO_FIRMADO, puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(), metodos: METODOS_PAGO, @@ -1226,15 +1227,17 @@ function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) { vigentes[0].monedaProyecto + '.'); } - // Esquema: Helgen Energy siempre 60/30/10; Helgen conserva el que ya tenga (o el sugerido) + // Esquema: al pasar a Helgen Energy se pone el 60/30/10 (después solo el administrador lo cambia); entre cotizaciones + // de la misma línea, o de Energy a Helgen, se conserva el que ya tenga const porOmision = esquemaPorOmision(); const igualAlDeOmision = d.etapas.length === porOmision.length && d.etapas.every((e, i) => e.etapa === porOmision[i].etapa && e.porcentaje === porOmision[i].porcentaje); - if (cot.marca === 'energy' && d.etapas.length && !igualAlDeOmision && !confirmado) { - return { confirmar: true, mensaje: 'La cotización ' + cot.folio + ' es de Helgen Energy: su esquema de pagos es fijo ' + - '(60% Anticipo, 30% Instalación, 10% Encendido del sistema) y reemplazará el esquema actual. ¿Continuar?' }; + const pasaAEnergy = cot.marca === 'energy' && (!c || c.marca !== 'energy'); + if (pasaAEnergy && d.etapas.length && !igualAlDeOmision && !confirmado) { + return { confirmar: true, mensaje: 'La cotización ' + cot.folio + ' es de Helgen Energy: su esquema de pagos es ' + + '60% Anticipo, 30% Instalación, 10% Encendido del sistema, y reemplazará el esquema actual. ¿Continuar?' }; } - const etapas = cot.marca === 'energy' || !d.etapas.length ? porOmision : d.etapas; + const etapas = pasaAEnergy || !d.etapas.length ? porOmision : d.etapas; const t = textoCelda; const fila = [proyecto.folio, cot.folio, MARCAS[cot.marca], cot.moneda, cot.subtotal, cot.iva, cot.total, ESTADO_ACEPTADA, @@ -1251,10 +1254,14 @@ function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) { }); } -/** Guarda el esquema de pagos (solo Helgen y solo antes de firmar). */ -function guardarEsquema(proyectoFolio, etapas) { +/** + * Guarda el esquema de pagos (solo antes de firmar). Helgen: rol de cobranza. Helgen Energy (60/30/10 por omisión): solo + * ADMIN_DESBLOQUEO, con motivo que queda en la Bitacora. + */ +function guardarEsquema(proyectoFolio, etapas, motivo) { exigirCobranza_(); const limpio = validarEsquema(etapas); + const m = String(motivo || '').trim(); const libro = libroCobranza_(); return conCandado_(() => { const proyecto = proyectoParaCobranza_(proyectoFolio); @@ -1262,9 +1269,16 @@ function guardarEsquema(proyectoFolio, etapas) { const c = d.contrato; if (!c) throw new Error('Primero marca la cotización aceptada del proyecto.'); if (c.estado === ESTADO_FIRMADO) throw new Error('Contrato firmado: el esquema de pagos ya no se puede cambiar.'); - if (c.marca === 'energy') throw new Error('El esquema de Helgen Energy es fijo (60% / 30% / 10%).'); + if (c.marca === 'energy') { + if (!esAdminDesbloqueo_()) throw errorPermiso_('El esquema de Helgen Energy solo lo puede cambiar el administrador.'); + if (m.length < 5) throw new Error('Escribe el motivo del cambio al esquema de Helgen Energy.'); + if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.'); + } + const antes = d.etapas.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · '); escribirEsquema_(d.hojas.esquemas, proyecto.folio, limpio, c.total); - bitacora_(libro, 'Editar esquema de pagos', proyecto.folio, limpio.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · ')); + bitacora_(libro, c.marca === 'energy' ? 'Editar esquema de pagos (Helgen Energy)' : 'Editar esquema de pagos', proyecto.folio, + limpio.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · ') + (antes ? ' (antes ' + antes + ')' : ''), + c.marca === 'energy' ? m : ''); SpreadsheetApp.flush(); return vistaCobranza_(libro, proyecto); }); @@ -2201,7 +2215,9 @@ function construirHtmlPdf(d, cfg, logoUrl) { '' + '' + '' + - '' + + (d.moneda === 'USD' + ? '' + : '') + filas + '
#ClaveDescripciónUnidadCant.P. UnitarioImporte
P. Unitario (USD)Importe (USD)
P. UnitarioImporte
' + // Módulo 3: Totales diff --git a/tests/cobranza.test.js b/tests/cobranza.test.js index d723455..3131290 100644 --- a/tests/cobranza.test.js +++ b/tests/cobranza.test.js @@ -138,6 +138,7 @@ const cH=cot(p1,1000); // Helgen MXN: total 1160 const cE=cot(p1,2000,{marca:'energy'}); // Energy MXN: total 2320 const cU=cot(p1,500,{moneda:'USD'}); // USD en el mismo proyecto const cBarata=cot(p1,100); // 116 +const cE2=cot(p1,1000,{marca:'energy'}); // otra de Energy: total 1160 const cOtro=cot(p2,862.07,{moneda:'USD'}); // Proyecto USD: total 1000.00 const sinProy=cot('',50); @@ -166,8 +167,8 @@ assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','Contratos','Esquemas', const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1); let v=ok('tania','getCobranzaProyecto',p1.toLowerCase()); -assert.equal(v.contrato,null); assert.deepEqual(v.cotizaciones.map(c=>c.folio),[cH,cE,cU,cBarata]); -assert.deepEqual(v.cotizaciones.map(c=>c.moneda+'/'+c.marca),['MXN/helgen','MXN/energy','USD/helgen','MXN/helgen']); +assert.equal(v.contrato,null); assert.deepEqual(v.cotizaciones.map(c=>c.folio),[cH,cE,cU,cBarata,cE2]); +assert.deepEqual(v.cotizaciones.map(c=>c.moneda+'/'+c.marca),['MXN/helgen','MXN/energy','USD/helgen','MXN/helgen','MXN/energy']); assert.equal(v.puedePagar,false); falla('tania','firmarContrato',[p1,HOY_ISO],/Primero marca la cotización aceptada/); falla('tania','guardarEsquema',[p1,P.esquemaPorOmision()],/Primero marca/); @@ -191,11 +192,27 @@ assert.equal(H('Contratos')[1][1],cH,'sin confirmar no cambia'); v=ok('tania','marcarAceptada',p1,cE,true); assert.deepEqual([v.contrato.cotizacion,v.contrato.marca,v.contrato.total],[cE,'energy',2320]); assert.deepEqual(v.resumen.etapas.map(e=>e.etapa+':'+e.monto),['Anticipo:1392','Instalación:696','Encendido del sistema:232']); -assert.equal(v.esquemaEditable,false); -falla('tania','guardarEsquema',[p1,[{etapa:'A',porcentaje:100}]],/Helgen Energy es fijo/); -// De regreso a Helgen: conserva el 60/30/10 (ahora editable), sin pedir confirmación +assert.deepEqual([v.esquemaEditable,v.esquemaPideMotivo],[false,false],'Tania no edita el de Energy'); +// Esquema de Energy: solo el administrador y con motivo (queda en la Bitacora) +falla('tania','guardarEsquema',[p1,[{etapa:'A',porcentaje:100}],'el cliente lo pidió'],/solo lo puede cambiar el administrador/,'PERMISO'); +v=ok('efrain','getCobranzaProyecto',p1); assert.deepEqual([v.esquemaEditable,v.esquemaPideMotivo],[true,true]); +falla('efrain','guardarEsquema',[p1,[{etapa:'Anticipo',porcentaje:50},{etapa:'Cierre',porcentaje:50}]],/motivo del cambio/); +falla('efrain','guardarEsquema',[p1,[{etapa:'Anticipo',porcentaje:50},{etapa:'Cierre',porcentaje:40}],'negociado'],/suman 90%/); +v=ok('efrain','guardarEsquema',p1,[{etapa:'Anticipo',porcentaje:50},{etapa:'Cierre',porcentaje:50}],'El cliente negoció 50% de anticipo'); +assert.deepEqual(v.resumen.etapas.map(e=>e.etapa+':'+e.monto),['Anticipo:1160','Cierre:1160']); +const be=bit().filter(b=>b[2]==='Editar esquema de pagos (Helgen Energy)').pop(); +assert.deepEqual([be[1],be[5]],['efrain@helgen.tech','El cliente negoció 50% de anticipo']); +assert(/Anticipo 50% · Cierre 50% \(antes Anticipo 60% · Instalación 30% · Encendido del sistema 10%\)/.test(be[4])); +// Otra cotización de Energy: conserva el esquema que puso el administrador (sin pedir confirmación) +v=ok('tania','marcarAceptada',p1,cE2); +assert.equal(v.confirmar,undefined); assert.deepEqual(v.resumen.etapas.map(e=>e.monto),[580,580]); +// De regreso a Helgen: conserva el esquema (ahora editable por cobranza), sin pedir confirmación v=ok('tania','marcarAceptada',p1,cH); -assert.equal(v.confirmar,undefined); assert.deepEqual(v.resumen.etapas.map(e=>e.monto),[696,348,116]); +assert.equal(v.confirmar,undefined); assert.deepEqual(v.resumen.etapas.map(e=>e.monto),[580,580]); +assert.deepEqual([v.esquemaEditable,v.esquemaPideMotivo],[true,false]); +v=ok('tania','guardarEsquema',p1,P.esquemaPorOmision()); +assert.deepEqual(v.resumen.etapas.map(e=>e.monto),[696,348,116]); +assert.equal(bit().pop()[5],'','en Helgen el cambio no pide motivo'); assert.equal(H('Contratos').length,2,'una sola aceptada por proyecto'); // Firma: fecha obligatoria y no futura diff --git a/tests/cotizacion.test.js b/tests/cotizacion.test.js index d2b4cd3..b695e43 100644 --- a/tests/cotizacion.test.js +++ b/tests/cotizacion.test.js @@ -90,6 +90,13 @@ if (ctx.monedaDeCotizacion) { assert(u.includes('TOTAL USD')&&!u.includes('TOTAL MXN')&&u.includes('USD (dólares estadounidenses)')); assert(u.includes('DÓLARES')&&u.includes('/100 USD)')&&!u.includes('PESOS')); assert(h.includes('TOTAL MXN')&&h.includes('M.N.')&&!h.includes('dólares'),'MXN sin cambios'); + // Encabezados de columna con la moneda solo en USD + assert(u.includes('P. Unitario (USD)')&&u.includes('Importe (USD)')); + assert(h.includes('>P. Unitario')&&h.includes('>Importe')&&!h.includes('(USD)'),'MXN sin moneda en columnas'); + // Términos: el mismo bloque completo que en MXN (Energy: 4 renglones, texto idéntico, incluido "$300 MXN") + const bloque=x=>x.slice(x.indexOf('TÉRMINOS Y CONDICIONES'),x.indexOf('')); + assert.equal(li(u),4); assert.equal(bloque(u),bloque(e),'USD muestra los mismos términos que MXN'); + assert(bloque(u).includes('$300 MXN por metro')); fs.writeFileSync(process.argv[3]+'/usd.html',u); } console.log('OK'); diff --git a/tests/interfaz.test.js b/tests/interfaz.test.js index 30e9b53..dba444e 100644 --- a/tests/interfaz.test.js +++ b/tests/interfaz.test.js @@ -136,7 +136,7 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia B.dialogos.confirmar=true; B.run("aceptarCot('HLG-2026-0011')"); await esperar(); const ma=B.llamadas.filter(c=>c.funcion==='marcarAceptada'); assert.equal(ma.length,3); assert.deepEqual(ma[2].args,['PRY-2026-0001','HLG-2026-0011',true]); - assert(/Energy: esquema fijo/.test(B.$('cobEsqNota').textContent)&&!/input/.test(B.$('cobEsquema').innerHTML),'Energy no editable'); + assert(/Solo el administrador lo puede cambiar/.test(B.$('cobEsqNota').textContent)&&!/input/.test(B.$('cobEsquema').innerHTML),'Energy no editable para Tania'); assert(/\$1,392\.00 MXN/.test(B.$('cobEsquema').innerHTML),'monto 60% de 2320'); assert(/Contrato firmado/.test(B.$('cobContrato').innerHTML)&&B.$('cobFormPago').classList.contains('oculto')); @@ -173,6 +173,19 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia B.dialogos.prompt='Se capturó dos veces'; B.run("cancelarPagoCob('PAG-2026-0001')"); await esperar(); assert.deepEqual(B.llamadas.find(c=>c.funcion==='cancelarPago').args,['PAG-2026-0001','Se capturó dos veces']); assert(/No tienes permiso de cobranza/.test(B.$('aviso').innerHTML)); + // Administrador en un contrato de Energy sin firmar: edita el esquema con motivo obligatorio + const energyAdmin=vista({contrato:{cotizacion:'HLG-2026-0011',marca:'energy',moneda:'MXN',subtotal:2000,iva:320,total:2320,estado:'Aceptada'}, + esquema:[{etapa:'Anticipo',porcentaje:60},{etapa:'Instalación',porcentaje:30},{etapa:'Encendido del sistema',porcentaje:10}], + esquemaEditable:true,esquemaPideMotivo:true}); + B.run('pintarCobranza('+JSON.stringify(energyAdmin)+')'); + assert(/c.funcion==='guardarEsquema')); + B.respuestas.guardarEsquema={ok:true,datos:energyAdmin}; + B.run("editarEtapa(0,'porcentaje','50'); editarEtapa(1,'porcentaje','40')"); + B.$('cobMotivoEsq').value='Negociado con el cliente'; B.run('guardarEsquemaCob()'); await esperar(); + const ge=B.llamadas.find(c=>c.funcion==='guardarEsquema').args; + assert.deepEqual([ge[0],ge[1].map(e=>e.porcentaje),ge[2]],['PRY-2026-0001',['50','40',10],'Negociado con el cliente']); // Administrador: ve el desbloqueo B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')'); assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML)); diff --git a/web/cotizador/index.html b/web/cotizador/index.html index 802c6d0..c2775d7 100644 --- a/web/cotizador/index.html +++ b/web/cotizador/index.html @@ -907,9 +907,10 @@ // Esquema $('cobModEsquema').classList.toggle('oculto', !c); ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje })); - $('cobEsqNota').textContent = !c ? '' : v.esquemaEditable - ? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.' - : c.marca === 'energy' && !firmado ? 'Helgen Energy: esquema fijo (60% / 30% / 10%).' : 'Esquema bloqueado por el contrato firmado.'; + $('cobEsqNota').textContent = !c ? '' : firmado ? 'Esquema bloqueado por el contrato firmado.' + : v.esquemaPideMotivo ? 'Helgen Energy: solo el administrador cambia este esquema; el motivo queda registrado. Deben sumar 100%.' + : v.esquemaEditable ? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.' + : 'Helgen Energy: esquema 60% / 30% / 10%. Solo el administrador lo puede cambiar.'; pintarEsquema(); // Pagos @@ -958,7 +959,9 @@ '' + (edit && ESQ.length > 1 ? '' : '') + ''; }).join('') + (ESQ.length ? 'Suma 0.001 ? ' class="aviso-moneda"' : '') + '>' + suma + '%' + dinero(total, mon) + '' : ''); - $('cobEsqAcciones').innerHTML = edit ? '' + + $('cobEsqAcciones').innerHTML = edit ? (v.esquemaPideMotivo ? '
' + + '
' : '') + + '' + '' : ''; } // Al editar un nombre no se vuelve a pintar (no se pierde el cursor); el % sí actualiza montos y suma @@ -984,7 +987,9 @@ }); } function guardarEsquemaCob() { - accionCob('btnEsquema', 'guardarEsquema', [COB.proyecto.folio, ESQ.map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }))], + const motivo = COB.esquemaPideMotivo ? $('cobMotivoEsq').value.trim() : ''; + if (COB.esquemaPideMotivo && !motivo) { mostrarError('Escribe el motivo del cambio al esquema de Helgen Energy.'); return; } + accionCob('btnEsquema', 'guardarEsquema', [COB.proyecto.folio, ESQ.map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje })), motivo], v => { pintarCobranza(v); aviso('ok', 'Esquema de pagos guardado.'); }); } function firmarCob() {