Cobranza: esquema de Energy editable solo por el administrador; columnas USD en el PDF

- 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 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-20 16:49:18 -06:00
parent 745c9e3a66
commit d08f4025f5
6 changed files with 90 additions and 29 deletions

View File

@ -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) ### 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"`). - 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)`, - *(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 `marcarAceptada(proyecto, cotizacion, confirmado)` (al pasar de Helgen a Energy pone el 60/30/10; si eso reemplaza un esquema
`{confirmar, mensaje}` sin cambiar nada), `guardarEsquema(proyecto, etapas)`, `firmarContrato(proyecto, 'AAAA-MM-DD')`, 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 `desbloquearContrato(proyecto, motivo)` (además `esAdminDesbloqueo_`), `registrarPago(p)` (con `comprobante.datos` en base64
e idempotencia `PAG_<idSolicitud>`), `cancelarPago(folio, motivo)`. Las que cambian algo usan `conCandado_` y devuelven la vista e idempotencia `PAG_<idSolicitud>`), `cancelarPago(folio, motivo)`. Las que cambian algo usan `conCandado_` y devuelven la vista
(`vistaCobranza_`). (`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. - **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 **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. tener la misma moneda.
- **Esquema:** Helgen Energy **fijo** 60% Anticipo / 30% Instalación / 10% Encendido del sistema (el servidor rechaza editarlo); - **Esquema:** Helgen Energy: 60% Anticipo / 30% Instalación / 10% Encendido del sistema; **solo `ADMIN_DESBLOQUEO` lo edita**,
Helgen: mismas etapas como sugerencia, editables (nombres, % y número de etapas, 1–10) hasta firmar. Suma exacta 100%. 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 - **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. 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 - **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**. 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. - **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 - **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) ## 7. Reglas del proyecto (no romper)
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa. 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 - **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). 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. - 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). - 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 ## 10. Interfaz web y sesión con Google

38
Code.js
View File

@ -1167,7 +1167,8 @@ function vistaCobranza_(libro, proyecto) {
esquema: d.etapas, esquema: d.etapas,
resumen: resumen, resumen: resumen,
pagos: d.pagos.map(p => { const o = Object.assign({}, p); delete o.fila; return o; }).reverse(), 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, puedePagar: !!c && c.estado === ESTADO_FIRMADO,
puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(), puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(),
metodos: METODOS_PAGO, metodos: METODOS_PAGO,
@ -1226,15 +1227,17 @@ function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) {
vigentes[0].monedaProyecto + '.'); 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 porOmision = esquemaPorOmision();
const igualAlDeOmision = d.etapas.length === porOmision.length && const igualAlDeOmision = d.etapas.length === porOmision.length &&
d.etapas.every((e, i) => e.etapa === porOmision[i].etapa && e.porcentaje === porOmision[i].porcentaje); d.etapas.every((e, i) => e.etapa === porOmision[i].etapa && e.porcentaje === porOmision[i].porcentaje);
if (cot.marca === 'energy' && d.etapas.length && !igualAlDeOmision && !confirmado) { const pasaAEnergy = cot.marca === 'energy' && (!c || c.marca !== 'energy');
return { confirmar: true, mensaje: 'La cotización ' + cot.folio + ' es de Helgen Energy: su esquema de pagos es fijo ' + if (pasaAEnergy && d.etapas.length && !igualAlDeOmision && !confirmado) {
'(60% Anticipo, 30% Instalación, 10% Encendido del sistema) y reemplazará el esquema actual. ¿Continuar?' }; 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 t = textoCelda;
const fila = [proyecto.folio, cot.folio, MARCAS[cot.marca], cot.moneda, cot.subtotal, cot.iva, cot.total, ESTADO_ACEPTADA, 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_(); exigirCobranza_();
const limpio = validarEsquema(etapas); const limpio = validarEsquema(etapas);
const m = String(motivo || '').trim();
const libro = libroCobranza_(); const libro = libroCobranza_();
return conCandado_(() => { return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio); const proyecto = proyectoParaCobranza_(proyectoFolio);
@ -1262,9 +1269,16 @@ function guardarEsquema(proyectoFolio, etapas) {
const c = d.contrato; const c = d.contrato;
if (!c) throw new Error('Primero marca la cotización aceptada del proyecto.'); 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.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); 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(); SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto); return vistaCobranza_(libro, proyecto);
}); });
@ -2201,7 +2215,9 @@ function construirHtmlPdf(d, cfg, logoUrl) {
'<table class="conceptos"><tr>' + '<table class="conceptos"><tr>' +
'<th class="c" style="width:22px;">#</th><th style="width:60px;">Clave</th><th>Descripción</th>' + '<th class="c" style="width:22px;">#</th><th style="width:60px;">Clave</th><th>Descripción</th>' +
'<th class="c" style="width:45px;">Unidad</th><th class="r" style="width:45px;">Cant.</th>' + '<th class="c" style="width:45px;">Unidad</th><th class="r" style="width:45px;">Cant.</th>' +
'<th class="r" style="width:80px;">P. Unitario</th><th class="r" style="width:85px;">Importe</th></tr>' + (d.moneda === 'USD'
? '<th class="r" style="width:95px;">P. Unitario (USD)</th><th class="r" style="width:95px;">Importe (USD)</th></tr>'
: '<th class="r" style="width:80px;">P. Unitario</th><th class="r" style="width:85px;">Importe</th></tr>') +
filas + '</table>' + filas + '</table>' +
// Módulo 3: Totales // Módulo 3: Totales

View File

@ -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 cE=cot(p1,2000,{marca:'energy'}); // Energy MXN: total 2320
const cU=cot(p1,500,{moneda:'USD'}); // USD en el mismo proyecto const cU=cot(p1,500,{moneda:'USD'}); // USD en el mismo proyecto
const cBarata=cot(p1,100); // 116 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 cOtro=cot(p2,862.07,{moneda:'USD'}); // Proyecto USD: total 1000.00
const sinProy=cot('',50); 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); const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1);
let v=ok('tania','getCobranzaProyecto',p1.toLowerCase()); 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.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']); 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); assert.equal(v.puedePagar,false);
falla('tania','firmarContrato',[p1,HOY_ISO],/Primero marca la cotización aceptada/); falla('tania','firmarContrato',[p1,HOY_ISO],/Primero marca la cotización aceptada/);
falla('tania','guardarEsquema',[p1,P.esquemaPorOmision()],/Primero marca/); 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); v=ok('tania','marcarAceptada',p1,cE,true);
assert.deepEqual([v.contrato.cotizacion,v.contrato.marca,v.contrato.total],[cE,'energy',2320]); 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.deepEqual(v.resumen.etapas.map(e=>e.etapa+':'+e.monto),['Anticipo:1392','Instalación:696','Encendido del sistema:232']);
assert.equal(v.esquemaEditable,false); assert.deepEqual([v.esquemaEditable,v.esquemaPideMotivo],[false,false],'Tania no edita el de Energy');
falla('tania','guardarEsquema',[p1,[{etapa:'A',porcentaje:100}]],/Helgen Energy es fijo/); // Esquema de Energy: solo el administrador y con motivo (queda en la Bitacora)
// De regreso a Helgen: conserva el 60/30/10 (ahora editable), sin pedir confirmación 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); 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'); assert.equal(H('Contratos').length,2,'una sola aceptada por proyecto');
// Firma: fecha obligatoria y no futura // Firma: fecha obligatoria y no futura

View File

@ -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('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(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'); 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)</th>')&&u.includes('Importe (USD)</th>'));
assert(h.includes('>P. Unitario</th>')&&h.includes('>Importe</th>')&&!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('</ul>'));
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); fs.writeFileSync(process.argv[3]+'/usd.html',u);
} }
console.log('OK'); console.log('OK');

View File

@ -136,7 +136,7 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
B.dialogos.confirmar=true; B.run("aceptarCot('HLG-2026-0011')"); await esperar(); B.dialogos.confirmar=true; B.run("aceptarCot('HLG-2026-0011')"); await esperar();
const ma=B.llamadas.filter(c=>c.funcion==='marcarAceptada'); 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.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(/\$1,392\.00 MXN/.test(B.$('cobEsquema').innerHTML),'monto 60% de 2320');
assert(/Contrato firmado/.test(B.$('cobContrato').innerHTML)&&B.$('cobFormPago').classList.contains('oculto')); 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;i<n;i++) await new Promise(r=>setImmedia
B.dialogos.prompt='Se capturó dos veces'; B.run("cancelarPagoCob('PAG-2026-0001')"); await esperar(); 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.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)); 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(/<input/.test(B.$('cobEsquema').innerHTML)&&/cobMotivoEsq/.test(B.$('cobEsqAcciones').innerHTML)&&/motivo queda registrado/.test(B.$('cobEsqNota').textContent));
B.$('cobMotivoEsq').value=''; B.run('guardarEsquemaCob()'); await esperar();
assert(/motivo del cambio/.test(B.$('aviso').innerHTML)); assert(!B.llamadas.some(c=>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 // Administrador: ve el desbloqueo
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')'); B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML)); assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));

View File

@ -907,9 +907,10 @@
// Esquema // Esquema
$('cobModEsquema').classList.toggle('oculto', !c); $('cobModEsquema').classList.toggle('oculto', !c);
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje })); ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
$('cobEsqNota').textContent = !c ? '' : v.esquemaEditable $('cobEsqNota').textContent = !c ? '' : firmado ? 'Esquema bloqueado por el contrato firmado.'
? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.' : v.esquemaPideMotivo ? 'Helgen Energy: solo el administrador cambia este esquema; el motivo queda registrado. Deben sumar 100%.'
: c.marca === 'energy' && !firmado ? 'Helgen Energy: esquema fijo (60% / 30% / 10%).' : 'Esquema bloqueado por el contrato firmado.'; : 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(); pintarEsquema();
// Pagos // Pagos
@ -958,7 +959,9 @@
'</td><td>' + (edit && ESQ.length > 1 ? '<button type="button" class="quitar" title="Quitar etapa" onclick="quitarEtapa(' + i + ')">×</button>' : '') + '</td></tr>'; '</td><td>' + (edit && ESQ.length > 1 ? '<button type="button" class="quitar" title="Quitar etapa" onclick="quitarEtapa(' + i + ')">×</button>' : '') + '</td></tr>';
}).join('') + (ESQ.length ? '<tr><td></td><td><b>Suma</b></td><td class="num"><b' + (Math.abs(suma - 100) > 0.001 ? ' class="aviso-moneda"' : '') + '>' + }).join('') + (ESQ.length ? '<tr><td></td><td><b>Suma</b></td><td class="num"><b' + (Math.abs(suma - 100) > 0.001 ? ' class="aviso-moneda"' : '') + '>' +
suma + '%</b></td><td class="num"><b>' + dinero(total, mon) + '</b></td><td colspan="4"></td></tr>' : ''); suma + '%</b></td><td class="num"><b>' + dinero(total, mon) + '</b></td><td colspan="4"></td></tr>' : '');
$('cobEsqAcciones').innerHTML = edit ? '<button type="button" class="btn btn-sec" onclick="agregarEtapa()">+ Agregar etapa</button>' + $('cobEsqAcciones').innerHTML = edit ? (v.esquemaPideMotivo ? '<div style="flex:1;min-width:260px"><label>Motivo del cambio (queda registrado) *</label>' +
'<input id="cobMotivoEsq" maxlength="500" placeholder="Ej. El cliente negoció 50% de anticipo"></div>' : '') +
'<button type="button" class="btn btn-sec" onclick="agregarEtapa()">+ Agregar etapa</button>' +
'<button type="button" class="btn" id="btnEsquema" onclick="guardarEsquemaCob()">Guardar esquema</button>' : ''; '<button type="button" class="btn" id="btnEsquema" onclick="guardarEsquemaCob()">Guardar esquema</button>' : '';
} }
// Al editar un nombre no se vuelve a pintar (no se pierde el cursor); el % sí actualiza montos y suma // 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() { 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.'); }); v => { pintarCobranza(v); aviso('ok', 'Esquema de pagos guardado.'); });
} }
function firmarCob() { function firmarCob() {