Cobranza: ocultar "Marcar aceptada" y "Contrato firmado" en proyectos cancelados

vistaCobranza_ manda puedeAceptar y puedeFirmar (falsos si el proyecto está
Cancelado o, en el caso de aceptar, si ya está firmado); la interfaz oculta esos
botones y muestra un aviso con cómo reactivarlo. El servidor ya rechazaba ambas
acciones. Pruebas de cobranza e interfaz ampliadas. Publicar la API antes que la
interfaz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-20 18:26:03 -06:00
parent 847c5441c8
commit 09e9b7acfb
5 changed files with 43 additions and 8 deletions

View File

@ -167,7 +167,8 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
`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_<idSolicitud>`), `cancelarPago(folio, motivo)`. Las que cambian algo usan `conCandado_` y devuelven la vista
(`vistaCobranza_`).
(`vistaCobranza_`: además de los datos, `puedeAceptar`, `puedeFirmar` —falsos si el proyecto está `Cancelado`—, `puedePagar`,
`puedeDesbloquear`, `esquemaEditable`, `esquemaPideMotivo`; la interfaz muestra los botones según estos campos).
- Internas: `carpetaCobranza_` y `libroCobranza_` (se crean solas la primera vez; si el ID existe pero no se encuentra, **error**:
no se crea otra a escondidas), `libroCobranzaSiExiste_` (las cotizaciones no crean el libro), `hojaCobranza_(libro, nombre)`
(encabezados que se migran solos, `HOJAS_COBRANZA`), `datosCobranza_`, `escribirEsquema_`, `bitacora_`, `cotizacionesDeProyecto_`.
@ -530,8 +531,10 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio
solas (B4 = 18.4250); libro de cobranza con encabezados y sin datos.
- Catálogo real: solo tiene el renglón `HSE-EJ` "EJEMPLO" ($1,000, sin "Actualizado"). Parece de ejemplo: borrarlo o cargar los
productos reales (ver la idea de importar el "Catálogo Maestro de Precios - Helgen Energy").
- Idea (no hecha): ocultar "Marcar aceptada" (y firmar) en proyectos `Cancelado`. Hoy el botón aparece, pero el servidor lo
rechaza ("El proyecto … está cancelado.", en `marcarAceptada` y `firmarContrato`).
- Proyectos `Cancelado` en Cobranza (20/10/2026, commit de este cambio): la vista trae `puedeAceptar` y `puedeFirmar` en falso y
la interfaz oculta "Marcar aceptada" y "Contrato firmado" con un aviso (reactivar = Estado Activo en la hoja). El servidor ya lo
rechazaba. **Publicar primero la API y después la interfaz**: la interfaz nueva con la API vieja (sin esos campos) ocultaría
los botones en todos los proyectos.
- Rol de cobranza **sin** desbloqueo (Tania) solo se probó con las pruebas automáticas, no con su cuenta real.
- Precio USD del catálogo (20/10/2026): el tipo de cambio usado se guarda en `Cotizaciones!V` (no sale en el PDF). Leer
`GOOGLEFINANCE` desde Apps Script **sí funciona** (comprobado en PRUEBAS: 18.4248); si un día falla, usar el valor manual (B3).

View File

@ -1276,6 +1276,9 @@ function vistaCobranza_(libro, proyecto) {
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' || esAdminDesbloqueo_()),
esquemaPideMotivo: !!c && c.estado !== ESTADO_FIRMADO && c.marca === 'energy' && esAdminDesbloqueo_(),
// Proyecto cancelado: no se acepta ni se firma (marcarAceptada y firmarContrato también lo rechazan)
puedeAceptar: proyecto.estado !== 'Cancelado' && !(c && c.estado === ESTADO_FIRMADO),
puedeFirmar: proyecto.estado !== 'Cancelado' && !!c && c.estado === ESTADO_ACEPTADA,
puedePagar: !!c && c.estado === ESTADO_FIRMADO,
puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(),
metodos: METODOS_PAGO,

View File

@ -191,7 +191,7 @@ 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,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);
assert.equal(v.puedePagar,false); assert.deepEqual([v.puedeAceptar,v.puedeFirmar],[true,false],'sin aceptada: se acepta, no se firma');
falla('tania','firmarContrato',[p1,HOY_ISO],/Primero marca la cotización aceptada/);
falla('tania','guardarEsquema',[p1,P.esquemaPorOmision()],/Primero marca/);
falla('tania','registrarPago',[{proyectoFolio:p1,comprobante:{datos:PDF.toString('base64')}}],/contrato firmado/);
@ -243,6 +243,7 @@ falla('tania','firmarContrato',[p1,''],/obligatoria/);
v=ok('tania','firmarContrato',p1,HOY_ISO);
assert.deepEqual([v.contrato.estado,v.contrato.fechaFirma,v.contrato.firmadoPor],['Firmado',HOY_ISO,'tania@helgen.tech']);
assert.equal(v.puedePagar,true); assert.equal(v.esquemaEditable,false); assert.equal(v.puedeDesbloquear,false,'Tania no desbloquea');
assert.deepEqual([v.puedeAceptar,v.puedeFirmar],[false,false],'firmado: ni aceptar ni firmar');
falla('tania','firmarContrato',[p1,HOY_ISO],/ya está firmado/);
falla('tania','marcarAceptada',[p1,cBarata],/Contrato firmado/);
falla('tania','guardarEsquema',[p1,P.esquemaPorOmision()],/Contrato firmado/);
@ -340,4 +341,14 @@ assert.deepEqual([carpCob.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'
// Los datos de cobranza nunca van en la Base de datos (todo el dominio la puede leer)
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos']);
assert(!JSON.stringify(db.pest).includes('PAG-'));
// --- Proyecto cancelado: la vista no ofrece aceptar ni firmar y el servidor lo rechaza ---
const p3=ok('ana','crearProyecto',{nombre:'Se canceló',cliente:{nombre:'Luis'},idSolicitud:'p3'}).folio;
const c3=cot(p3,100), c3b=cot(p3,200);
v=ok('tania','marcarAceptada',p3,c3); assert.deepEqual([v.puedeAceptar,v.puedeFirmar],[true,true],'aceptada: se puede firmar');
const filaP3=db.pest.Proyectos.m.findIndex(r=>r[0]===p3); db.pest.Proyectos.m[filaP3][6]='Cancelado';
v=ok('tania','getCobranzaProyecto',p3);
assert.deepEqual([v.proyecto.estado,v.puedeAceptar,v.puedeFirmar],['Cancelado',false,false]);
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');
console.log('OK cobranza');

View File

@ -137,8 +137,12 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
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'}];
const vista=(extra)=>({proyecto:PRY,cotizaciones:cots,contrato:null,esquema:[],resumen:null,pagos:[],esquemaEditable:false,
puedePagar:false,puedeDesbloquear:false,metodos:['Transferencia','Efectivo','Cheque'],hoy:'2026-10-20',...extra});
// Como vistaCobranza_: se acepta mientras no esté firmado; se firma con aceptada (proyecto no cancelado)
const vista=(extra={})=>{const v={proyecto:PRY,cotizaciones:cots,contrato:null,esquema:[],resumen:null,pagos:[],esquemaEditable:false,
puedePagar:false,puedeDesbloquear:false,metodos:['Transferencia','Efectivo','Cheque'],hoy:'2026-10-20',...extra};
if(!('puedeAceptar' in extra)) v.puedeAceptar=!(v.contrato&&v.contrato.estado==='Firmado');
if(!('puedeFirmar' in extra)) v.puedeFirmar=!!v.contrato&&v.contrato.estado==='Aceptada';
return v};
B.respuestas.getCobranzaProyecto={ok:true,datos:vista()};
B.$('cobProyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; B.$('cobProyecto').disparar('change'); await esperar();
assert.equal(B.llamadas.find(c=>c.funcion==='getCobranzaProyecto').args[0],'PRY-2026-0001');
@ -191,6 +195,15 @@ 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();
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));
// Proyecto cancelado: sin "Marcar aceptada" ni "Contrato firmado", con aviso
const cancel=vista({proyecto:{...PRY,estado:'Cancelado'},puedeAceptar:false,puedeFirmar:false,
contrato:{cotizacion:'HLG-2026-0010',marca:'helgen',moneda:'MXN',subtotal:1000,iva:160,total:1160,estado:'Aceptada'},
esquema:[{etapa:'Anticipo',porcentaje:100}]});
B.run('pintarCobranza('+JSON.stringify(cancel)+')');
assert(!/Marcar aceptada/.test(B.$('cobCots').innerHTML)&&!/btnFirmar|Contrato firmado/.test(B.$('cobContrato').innerHTML),'cancelado: sin aceptar ni firmar');
assert(/Proyecto cancelado: no se puede marcar/.test(B.$('cobContrato').innerHTML)&&/chip cancelado">Cancelado/.test(B.$('cobInfo').innerHTML));
B.run('pintarCobranza('+JSON.stringify(vista({proyecto:{...PRY,estado:'Cancelado'},puedeAceptar:false,puedeFirmar:false}))+')');
assert(!/Marcar aceptada/.test(B.$('cobCots').innerHTML)&&/Proyecto cancelado/.test(B.$('cobContrato').innerHTML),'cancelado sin aceptada');
// 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}],

View File

@ -907,13 +907,16 @@
'</td><td class="num">' + dinero(q.subtotal, q.moneda) + '</td><td class="num">' + dinero(q.total, q.moneda) + '</td><td>' +
(q.pdf ? '<a href="' + esc(q.pdf) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
(esAceptada ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">Aceptada</span>'
: firmado ? '' : '<button type="button" class="btn btn-sec" onclick="aceptarCot(\'' + esc(q.folio) + '\')">Marcar aceptada</button>') +
: !v.puedeAceptar ? '' : '<button type="button" class="btn btn-sec" onclick="aceptarCot(\'' + esc(q.folio) + '\')">Marcar aceptada</button>') +
'</td></tr>';
}).join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene cotizaciones.</td></tr>';
// Contrato
let h = '';
if (!c) h = '<span class="nota">Marca la cotización aceptada para definir el monto contratado.</span>';
const cancelado = v.proyecto.estado === 'Cancelado';
const avisoCancelado = '<div class="nota aviso-moneda" style="margin-top:6px">Proyecto cancelado: no se puede marcar la cotización ' +
'aceptada ni registrar el contrato firmado. Para reactivarlo, cambia su Estado a Activo en la hoja Proyectos.</div>';
if (!c) h = cancelado ? avisoCancelado : '<span class="nota">Marca la cotización aceptada para definir el monto contratado.</span>';
else {
h = '<div class="sat-titulo">Monto contratado: ' + dinero(c.total, mon) + ' (con IVA) · subtotal ' + dinero(c.subtotal, mon) +
' · ' + esc(MARCA_TXT[c.marca]) + '</div>';
@ -925,6 +928,8 @@
'<input id="cobMotivo" maxlength="500" placeholder="Ej. Se firmó con la cotización equivocada"></div>' +
'<div class="acciones"><button type="button" class="btn btn-sec" id="btnDesbloquear" onclick="desbloquearCob()">Desbloquear contrato</button></div>';
}
} else if (!v.puedeFirmar) {
h += cancelado ? avisoCancelado : '';
} else {
h += '<div class="proy-fila" style="margin-top:8px"><div style="max-width:220px"><label>Fecha de firma *</label>' +
'<input id="cobFechaFirma" type="date" max="' + esc(v.hoy) + '" value="' + esc(v.hoy) + '"></div>' +