cotizador-helgen/Code.js
Efrain (Helgen) c75e754137 Cobranza: prepararCobranza() restringe primero la carpeta y luego el libro
El libro hereda de la carpeta el acceso del dominio y Drive no deja quitar un permiso
heredado en el hijo (403 cannotDeletePermission). El simulacro de la API de Drive
ahora reproduce la herencia; la raíz de Mi unidad simulada ya no está compartida.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:57:22 -06:00

2379 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* COTIZADOR HELGEN — Backend (Google Apps Script)
* ------------------------------------------------
* - API de la interfaz web (Cloudflare): doPost con sesión de Google verificada
* - Asigna folios consecutivos sin duplicados (LockService)
* - Recalcula subtotal / IVA / total en el servidor (no confía en el navegador)
* - Genera el PDF, lo guarda en Drive y registra la cotización en una hoja
*
* La base de datos, el catálogo y la carpeta de PDF se toman de Script Properties (IDs).
*/
// ====== 1. ENTRADA DE LA APP WEB ======
/**
* La interfaz vive en Cloudflare y todo pasa por doPost con sesión verificada.
* doGet ya no sirve la app: una página de HtmlService permitiría llamar funciones con google.script.run
* sin token (el despliegue es ANYONE_ANONYMOUS).
*/
function doGet() {
return ContentService.createTextOutput('Cotizador Helgen: esta dirección es solo la API. ' +
'Abre el cotizador en https://cotizador-helgen.cotizador-helgen.workers.dev/cotizador/');
}
// ====== 1.1 SESIÓN: Google Identity Services ======
/**
* Client ID de OAuth (tipo "Aplicación web") del proyecto de Google Cloud. No es secreto: también va en la interfaz.
* Debe ser el mismo valor que CLIENT_ID_GOOGLE en web/cotizador/index.html.
*/
const CLIENT_ID_GOOGLE = '884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com';
const DOMINIO_HELGEN = 'helgen.tech';
/** Vendedor de la petición actual, tomado del token verificado en doPost (cada ejecución empieza en null). */
let VENDEDOR_ = null;
/** Correo de quien usa la app: el del token verificado; si no hay (editor), el de la sesión de Google. */
function usuarioActual_() {
return VENDEDOR_ ? VENDEDOR_.email : Session.getActiveUser().getEmail();
}
/** Error de sesión: la interfaz lo distingue (codigo "SESION") para pedir que se vuelva a iniciar sesión. */
function errorSesion_(mensaje) {
const err = new Error(mensaje);
err.sesion = true;
return err;
}
/**
* Verifica el ID token con Google (tokeninfo revisa firma y expiración) y luego aud, emisor y dominio.
* Devuelve {email, nombre, apellido}. El resultado se guarda en caché hasta que el token expire (máx. 10 min)
* para no consultar a Google en cada llamada.
*/
function verificarTokenGoogle_(idToken) {
const token = String(idToken || '');
if (!token) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.');
if (token.length > 4096) throw errorSesion_('Sesión inválida. Vuelve a iniciar sesión.');
if (CLIENT_ID_GOOGLE.indexOf('PEGAR_AQUI') === 0) {
throw new Error('Falta configurar CLIENT_ID_GOOGLE en el servidor.');
}
const cache = CacheService.getScriptCache();
const huella = Utilities.base64Encode(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, token));
const claveCache = 'TOK_' + huella;
const previo = cache.get(claveCache);
if (previo) return JSON.parse(previo);
const r = UrlFetchApp.fetch('https://oauth2.googleapis.com/tokeninfo?id_token=' + encodeURIComponent(token),
{ muteHttpExceptions: true });
if (r.getResponseCode() !== 200) throw errorSesion_('Tu sesión expiró o no es válida. Vuelve a iniciar sesión.');
let claims;
try {
claims = JSON.parse(r.getContentText());
} catch (err) {
throw errorSesion_('No se pudo verificar tu sesión. Intenta de nuevo.');
}
const ahora = Math.floor(Date.now() / 1000);
const vendedor = validarClaimsToken(claims, CLIENT_ID_GOOGLE, DOMINIO_HELGEN, ahora);
const segundos = Math.min(Number(claims.exp) - ahora - 30, 600);
if (segundos > 0) cache.put(claveCache, JSON.stringify(vendedor), segundos);
return vendedor;
}
/**
* Funciones que la interfaz externa (Cloudflare) puede llamar por doPost.
* Son las mismas que usa google.script.run; nada más (ni setup ni funciones internas con _).
*/
const API_FUNCIONES = {
getDatosIniciales: getDatosIniciales,
getClientes: getClientes,
listarCotizaciones: listarCotizaciones,
getCatalogo: getCatalogo,
guardarProducto: guardarProducto,
guardarCotizacion: guardarCotizacion,
sugerirClavesSat: sugerirClavesSat,
listarProyectos: listarProyectos,
crearProyecto: crearProyecto,
// Cobranza (solo ROL_COBRANZA; cada función lo revisa)
listarProyectosCobranza: listarProyectosCobranza,
getCobranzaProyecto: getCobranzaProyecto,
marcarAceptada: marcarAceptada,
guardarEsquema: guardarEsquema,
firmarContrato: firmarContrato,
desbloquearContrato: desbloquearContrato,
registrarPago: registrarPago,
cancelarPago: cancelarPago,
};
/**
* API para la interfaz fuera de Apps Script. Cuerpo (text/plain para evitar la consulta CORS previa):
* {"funcion": "listarCotizaciones", "args": ["HLG"], "token": "<ID token de Google>"}
* Respuesta: {"ok": true, "datos": ...} o {"ok": false, "error": "mensaje en español", "codigo": "SESION"?}.
* Sin un token válido de @helgen.tech no se ejecuta ninguna función.
*/
function doPost(e) {
let respuesta;
VENDEDOR_ = null;
try {
let cuerpo;
try {
cuerpo = JSON.parse((e && e.postData && e.postData.contents) || '');
} catch (err) {
throw new Error('Solicitud inválida: el cuerpo debe ser JSON.');
}
if (!cuerpo || typeof cuerpo !== 'object') throw new Error('Solicitud inválida: el cuerpo debe ser JSON.');
VENDEDOR_ = verificarTokenGoogle_(cuerpo.token);
const nombre = String(cuerpo.funcion || '');
if (!Object.prototype.hasOwnProperty.call(API_FUNCIONES, nombre)) {
throw new Error('Función no permitida: ' + (nombre || '(vacía)') + '.');
}
const args = Array.isArray(cuerpo.args) ? cuerpo.args : [];
respuesta = { ok: true, datos: API_FUNCIONES[nombre].apply(null, args) };
} catch (err) {
respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
if (err && err.sesion) respuesta.codigo = 'SESION';
else if (err && err.codigo) respuesta.codigo = err.codigo; // 'PROYECTO' (errorProyecto_), 'PERMISO' (errorPermiso_)
} finally {
VENDEDOR_ = null;
}
return ContentService.createTextOutput(JSON.stringify(respuesta)).setMimeType(ContentService.MimeType.JSON);
}
// ====== 2. INSTALACIÓN (ejecutar 1 sola vez) ======
const TERMINO_VIGENCIA =
'Precios vigentes por 15 días o hasta agotar existencias, sujetos a variaciones en costos de suministro de componentes.';
/** Líneas de negocio: Helgen (general) y Helgen Energy (submarca de paneles solares). */
const MARCAS = { helgen: 'Helgen', energy: 'Helgen Energy' };
const CONFIG_INICIAL = [
['EMPRESA_NOMBRE', 'Helgen Servicios Especializados'],
['EMPRESA_RFC', 'HSE240304PC1'],
['EMPRESA_DIRECCION', 'Sendero del Regocijo 32 int 1, Milenio III, Querétaro, Querétaro, 76060'],
['EMPRESA_TELEFONO', '+52 442 644 5391'],
['EMPRESA_EMAIL', 'contacto@helgen.tech'],
['EMPRESA_WEB', 'helgen.tech'],
['PREFIJO_FOLIO', 'HLG'],
['IVA_TASA', 0.16],
['VIGENCIA_DIAS', 15],
['MONEDA', 'MXN'],
['LOGO_FILE_ID', ''], // Opcional: ID de un logo en Drive (para fondo blanco) que reemplaza el de Logo.gs
// Términos por línea de negocio (un renglón por término)
['TERMINOS_HELGEN', TERMINO_VIGENCIA],
['TERMINOS_ENERGY',
'Metraje adicional: $300 MXN por metro después de 25 m.\n' +
'Centro de carga: cambios o actualizaciones no incluidos (se cotizan por separado).\n' +
'Obra civil: se cotiza por separado según las condiciones arquitectónicas del inmueble.\n' +
TERMINO_VIGENCIA],
];
/** Valores de ejemplo de versiones anteriores: si siguen en Config, se cambian por los datos reales. */
const CONFIG_EJEMPLO_ANTERIOR = {
EMPRESA_NOMBRE: 'Helgen Technologies',
EMPRESA_RFC: 'XAXX010101000',
EMPRESA_DIRECCION: 'Calle, número, colonia, municipio, estado, C.P.',
EMPRESA_TELEFONO: '+52 000 000 0000',
};
const ENCABEZADOS_COTIZACIONES = [
'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono',
'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección',
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio', 'moneda', 'tipo_cambio'
];
function setup() {
const props = PropertiesService.getScriptProperties();
// Hoja de cálculo = base de datos
let ss;
if (props.getProperty('SPREADSHEET_ID')) {
ss = SpreadsheetApp.openById(props.getProperty('SPREADSHEET_ID'));
} else {
ss = SpreadsheetApp.create('Cotizador Helgen — Base de datos');
props.setProperty('SPREADSHEET_ID', ss.getId());
}
const hCot = ss.getSheetByName('Cotizaciones') || ss.insertSheet('Cotizaciones');
if (hCot.getLastRow() === 0) {
hCot.appendRow(ENCABEZADOS_COTIZACIONES);
hCot.setFrozenRows(1);
hCot.getRange(1, 1, 1, ENCABEZADOS_COTIZACIONES.length)
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
}
// El catálogo vive en su propio archivo ("Catálogo Helgen"): ver hojaCatalogo_()
const hCfg = ss.getSheetByName('Config') || ss.insertSheet('Config');
if (hCfg.getLastRow() === 0) {
hCfg.getRange('B:B').setNumberFormat('@'); // texto: evita que "+52 ..." se lea como fórmula
hCfg.getRange(1, 1, CONFIG_INICIAL.length + 1, 2).setValues([['Clave', 'Valor']].concat(CONFIG_INICIAL));
hCfg.setColumnWidth(2, 600);
}
const hoja1 = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
if (hoja1 && ss.getSheets().length > 1) ss.deleteSheet(hoja1);
// Carpeta de Drive para los PDF
if (!props.getProperty('FOLDER_ID')) {
const carpeta = DriveApp.createFolder('Cotizaciones Helgen (PDF)');
props.setProperty('FOLDER_ID', carpeta.getId());
}
Logger.log('Base de datos: ' + ss.getUrl());
Logger.log('Carpeta PDF: ' + DriveApp.getFolderById(props.getProperty('FOLDER_ID')).getUrl());
}
// ====== 3. LECTURA DE DATOS ======
function db_() {
const props = PropertiesService.getScriptProperties();
if (!props.getProperty('SPREADSHEET_ID') || !props.getProperty('FOLDER_ID')) {
// Primera vez: se instala sola (hoja + carpeta) en lugar de exigir correr setup() a mano.
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('Se está preparando el cotizador. Recarga la página en unos segundos.');
try {
if (!props.getProperty('SPREADSHEET_ID') || !props.getProperty('FOLDER_ID')) setup();
} finally {
lock.releaseLock();
}
}
return SpreadsheetApp.openById(props.getProperty('SPREADSHEET_ID'));
}
function getConfig_() {
const hoja = db_().getSheetByName('Config');
migrarConfig_(hoja);
const filas = hoja.getDataRange().getValues().slice(1);
const cfg = {};
filas.forEach(([k, v]) => { if (k) cfg[String(k).trim()] = v; });
cfg.IVA_TASA = Number(cfg.IVA_TASA) || 0.16;
cfg.VIGENCIA_DIAS = Number(cfg.VIGENCIA_DIAS) || 15;
return cfg;
}
/**
* Actualiza una hoja Config creada con versiones anteriores:
* - TERMINOS (único) -> TERMINOS_HELGEN + TERMINOS_ENERGY; agrega cualquier clave nueva que falte
* - Valores de ejemplo (CONFIG_EJEMPLO_ANTERIOR) -> datos reales de Helgen
* - Valores que Sheets leyó como fórmula con error (p. ej. "+52 ..." -> #ERROR!) se reponen como texto.
*/
function migrarConfig_(hoja) {
const necesita = () => {
const v = hoja.getDataRange().getValues();
const claves = v.map(r => r[0]);
return CONFIG_INICIAL.some(([k]) => claves.indexOf(k) === -1) ||
v.some(r => /^#[A-Z/0!?]+/.test(String(r[1])) || CONFIG_EJEMPLO_ANTERIOR[r[0]] === String(r[1]));
};
if (!necesita()) return;
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) return;
try {
if (!necesita()) return;
const valores = hoja.getDataRange().getValues();
const iniciales = {};
CONFIG_INICIAL.forEach(([k, v]) => { iniciales[k] = v; });
hoja.getRange('B:B').setNumberFormat('@');
valores.forEach((r, i) => {
const fila = i + 1;
if (r[0] === 'TERMINOS') {
hoja.getRange(fila, 1, 1, 2).setValues([['TERMINOS_ENERGY', iniciales.TERMINOS_ENERGY]]);
} else if (i > 0 && r[0] in iniciales &&
(/^#[A-Z/0!?]+/.test(String(r[1])) || CONFIG_EJEMPLO_ANTERIOR[r[0]] === String(r[1]))) {
hoja.getRange(fila, 2).setValue(String(iniciales[r[0]]));
}
});
const claves = hoja.getDataRange().getValues().map(r => r[0]);
CONFIG_INICIAL.forEach(([k, v]) => {
if (claves.indexOf(k) === -1) hoja.appendRow([k, v]);
});
SpreadsheetApp.flush();
} finally {
lock.releaseLock();
}
}
// ====== CATÁLOGO: Google Sheets aparte ("Catálogo Helgen"), ligado por CATALOGO_ID ======
// Clave SAT y Unidad SAT son datos internos para contabilidad (facturación); no salen en el PDF
const ENCABEZADOS_CATALOGO = ['Clave', 'Descripción', 'Unidad', 'Precio unitario (sin IVA)', 'Actualizado', 'Actualizó',
'Clave SAT', 'Unidad SAT'];
/** Hoja del catálogo. La primera vez crea el archivo y pasa ahí los productos de la pestaña anterior. */
function hojaCatalogo_() {
const props = PropertiesService.getScriptProperties();
if (!props.getProperty('CATALOGO_ID')) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('Se está preparando el catálogo. Recarga la página en unos segundos.');
try {
if (!props.getProperty('CATALOGO_ID')) crearCatalogo_();
} finally {
lock.releaseLock();
}
}
const ss = SpreadsheetApp.openById(props.getProperty('CATALOGO_ID'));
const hoja = ss.getSheetByName('Catalogo') || ss.getSheets()[0];
asegurarColumnasSat_(hoja);
// Precio en USD: dato extra; si falla, el catálogo funciona igual (en USD el precio se captura a mano)
try { asegurarPrecioUsd_(hoja); } catch (e) { console.warn('Sin columna de precio USD: ' + e.message); }
return hoja;
}
// ---- Precio en USD (columna I) y tipo de cambio del día (pestaña "Tipo de cambio" del catálogo) ----
// Fórmula por renglón (no ARRAYFORMULA: una fórmula que llena toda la columna haría que appendRow escriba al final
// de la hoja). Los renglones sin fórmula (p. ej. capturados a mano en Sheets) se reparan solos al abrir el catálogo.
const ENC_PRECIO_USD = 'Precio USD (sin IVA, automático)';
const COL_PRECIO_USD = 9; // I
const HOJA_TIPO_CAMBIO = 'Tipo de cambio';
const RANGO_TIPO_CAMBIO = 'TIPO_CAMBIO';
const formulaPrecioUsd_ = fila => '=IF(ISNUMBER(D' + fila + '),IFERROR(ROUND(D' + fila + '/' + RANGO_TIPO_CAMBIO + ',2),""),"")';
/**
* Pestaña "Tipo de cambio": B2 = Google Finance (USD→MXN, automático), B3 = manual opcional (si tiene valor, manda),
* B4 = en uso (rango con nombre TIPO_CAMBIO). Columna I del catálogo = precio en pesos ÷ TIPO_CAMBIO.
*/
function asegurarPrecioUsd_(hoja) {
const ss = hoja.getParent();
if (!ss.getSheetByName(HOJA_TIPO_CAMBIO)) {
const tc = ss.insertSheet(HOJA_TIPO_CAMBIO);
tc.getRange(1, 1, 5, 2).setValues([
['Concepto', 'Valor'],
['Automático: Google Finance (pesos por dólar)', ''],
['Manual (opcional): si escribes un valor aquí, se usa en lugar del automático', ''],
['Tipo de cambio en uso', ''],
['Nota', 'Los precios en USD del catálogo (columna I) se calculan solos con el tipo de cambio en uso. Google Finance ' +
'se actualiza durante el día y puede tener unos minutos de retraso; no es el FIX de Banxico.'],
]);
tc.getRange(2, 2).setFormula('=GOOGLEFINANCE("CURRENCY:USDMXN")');
tc.getRange(4, 2).setFormula('=IF(ISNUMBER(B3),B3,IFERROR(ROUND(B2,4),""))');
tc.getRange(2, 2, 3, 1).setNumberFormat('0.0000');
tc.getRange(1, 1, 1, 2).setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
tc.setColumnWidth(1, 470); tc.setColumnWidth(2, 160);
ss.setNamedRange(RANGO_TIPO_CAMBIO, tc.getRange(4, 2));
// GOOGLEFINANCE se recalcula cada hora aunque nadie abra el archivo
ss.setRecalculationInterval(SpreadsheetApp.RecalculationInterval.HOUR);
}
if (hoja.getRange(1, COL_PRECIO_USD).getValue() !== ENC_PRECIO_USD) {
hoja.getRange(1, COL_PRECIO_USD).setValue(ENC_PRECIO_USD)
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
hoja.getRange('I:I').setNumberFormat('$#,##0.00');
hoja.setColumnWidth(COL_PRECIO_USD, 210);
hoja.getRange('I:I').protect().setDescription('Precio USD automático: no escribir aquí (cambia el precio en pesos ' +
'o el tipo de cambio)').setWarningOnly(true);
}
// Renglones con producto y sin fórmula en I: se les pone (incluye los dados de alta a mano en Sheets)
const n = hoja.getLastRow() - 1;
if (n < 1) return;
const claves = hoja.getRange(2, 1, n, 1).getValues();
const formulas = hoja.getRange(2, COL_PRECIO_USD, n, 1).getFormulas();
claves.forEach((r, i) => {
if (String(r[0] || '').trim() && !formulas[i][0]) hoja.getRange(i + 2, COL_PRECIO_USD).setFormula(formulaPrecioUsd_(i + 2));
});
}
/** Tipo de cambio en uso según la pestaña "Tipo de cambio" ({valor, fuente}); valor null si no hay uno válido. */
function tipoCambioCatalogo_(hoja) {
try {
const tc = hoja.getParent().getSheetByName(HOJA_TIPO_CAMBIO);
if (!tc) return { valor: null, fuente: '' };
const v = tc.getRange(2, 2, 2, 1).getValues();
return tipoCambioDesdeValores(v[0][0], v[1][0]);
} catch (e) {
console.warn('Sin tipo de cambio: ' + e.message);
return { valor: null, fuente: '' };
}
}
/** Catálogos creados antes de las columnas SAT: agrega los encabezados G:H con formato texto. */
function asegurarColumnasSat_(hoja) {
const n = ENCABEZADOS_CATALOGO.length;
const enc = hoja.getRange(1, 7, 1, 2).getValues()[0];
if (enc[0] === ENCABEZADOS_CATALOGO[6] && enc[1] === ENCABEZADOS_CATALOGO[7]) return;
hoja.getRange('G:H').setNumberFormat('@'); // "01010101" no pierde el cero inicial
hoja.getRange(1, 7, 1, 2).setValues([ENCABEZADOS_CATALOGO.slice(6, n)])
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
hoja.setColumnWidth(7, 110); hoja.setColumnWidth(8, 100);
}
function crearCatalogo_() {
const props = PropertiesService.getScriptProperties();
const ss = SpreadsheetApp.create('Catálogo Helgen');
const hoja = ss.getSheets()[0].setName('Catalogo');
const n = ENCABEZADOS_CATALOGO.length;
hoja.getRange(1, 1, 1, n).setValues([ENCABEZADOS_CATALOGO])
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
hoja.setFrozenRows(1);
hoja.getRange('A:C').setNumberFormat('@'); // texto: claves como "001" no pierden ceros
hoja.getRange('G:H').setNumberFormat('@');
hoja.getRange('D:D').setNumberFormat('$#,##0.00');
hoja.getRange('E:E').setNumberFormat('dd/MM/yyyy HH:mm');
hoja.setColumnWidth(1, 110); hoja.setColumnWidth(2, 420); hoja.setColumnWidth(4, 170); hoja.setColumnWidth(6, 200);
// Validaciones al capturar directo en Sheets: clave única y precio >= 0
hoja.getRange('A2:A').setDataValidation(SpreadsheetApp.newDataValidation()
.requireFormulaSatisfied('=COUNTIF($A:$A,$A2)=1').setAllowInvalid(false)
.setHelpText('La clave ya existe en el catálogo. Usa una clave distinta.').build());
hoja.getRange('D2:D').setDataValidation(SpreadsheetApp.newDataValidation()
.requireNumberGreaterThanOrEqualTo(0).setAllowInvalid(false)
.setHelpText('Precio unitario sin IVA (número mayor o igual a 0).').build());
// Migra la pestaña "Catalogo" de la base de datos (versiones anteriores), sin el producto de ejemplo
const db = db_();
const vieja = db.getSheetByName('Catalogo');
if (vieja) {
const filas = vieja.getDataRange().getValues().slice(1)
.filter(r => r[1] && String(r[0]) !== 'EJ-001')
.map(r => [String(r[0]).trim().toUpperCase(), String(r[1]), String(r[2] || 'PZA'), Number(r[3]) || 0, '', 'Migrado', '', '']);
if (filas.length) hoja.getRange(2, 1, filas.length, n).setValues(filas);
if (db.getSheets().length > 1) db.deleteSheet(vieja); // los datos ya quedaron en el archivo nuevo
}
// Lo deja en la misma carpeta de Drive que la base de datos
try {
const padres = DriveApp.getFileById(db.getId()).getParents();
if (padres.hasNext()) DriveApp.getFileById(ss.getId()).moveTo(padres.next());
} catch (e) { /* si no se puede mover, queda en Mi unidad */ }
props.setProperty('CATALOGO_ID', ss.getId());
}
function getCatalogo_(hoja) {
const h = hoja || hojaCatalogo_();
const filas = h.getDataRange().getValues().slice(1);
// Precio en USD con el mismo cálculo que la columna I (no depende de que Sheets ya haya recalculado la fórmula)
const tc = tipoCambioCatalogo_(h).valor;
return filas
.filter(r => r[0] && r[1])
.map(r => ({ clave: String(r[0]), descripcion: String(r[1]), unidad: String(r[2] || 'PZA'), precio: Number(r[3]) || 0,
claveSat: String(r[6] || ''), unidadSat: String(r[7] || ''), precioUsd: precioEnDolares(Number(r[3]) || 0, tc) }));
}
/** Datos para la pestaña Catálogo de la app (incluye las unidades del SAT para elegir). */
function getCatalogo() {
const hoja = hojaCatalogo_();
let unidadesSat = [];
try { unidadesSat = getUnidadesSat_(); } catch (e) { /* sin catálogo SAT: la app sigue funcionando */ }
return { productos: getCatalogo_(hoja), url: hoja.getParent().getUrl(), unidadesSat: unidadesSat,
tipoCambio: tipoCambioCatalogo_(hoja) };
}
// ====== CATÁLOGOS DEL SAT (CFDI 4.0): Google Sheets "Catálogos SAT (CFDI 4.0)", ligado por SAT_ID ======
// Fuente: catCFDI_V_4_20261015.xls (Anexo 20, sat.gob.mx), solo claves vigentes. Se importa una vez desde
// los CSV SAT_c_ClaveProdServ.csv y SAT_c_ClaveUnidad.csv de la carpeta "Catálogos SAT".
const FUENTE_SAT = 'catCFDI_V_4_20261015.xls — Anexo 20 del SAT (CFDI 4.0), descargado el 15/10/2026. Solo claves vigentes.';
function libroSat_() {
const props = PropertiesService.getScriptProperties();
if (!props.getProperty('SAT_ID')) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(60000)) throw new Error('Se está cargando el catálogo del SAT. Intenta en unos segundos.');
try {
if (!props.getProperty('SAT_ID')) importarCatalogosSat_();
} finally {
lock.releaseLock();
}
}
return SpreadsheetApp.openById(props.getProperty('SAT_ID'));
}
function importarCatalogosSat_() {
const archivo = nombre => {
const it = DriveApp.getFilesByName(nombre);
while (it.hasNext()) { const f = it.next(); if (!f.isTrashed()) return f; }
throw new Error('No se encontró ' + nombre + ' en Drive (carpeta Cotizador Helgen / Catálogos SAT).');
};
const csvProd = archivo('SAT_c_ClaveProdServ.csv'), csvUni = archivo('SAT_c_ClaveUnidad.csv');
const ss = SpreadsheetApp.create('Catálogos SAT (CFDI 4.0)');
const cargar = (hoja, nombre, csv, anchos) => {
const filas = Utilities.parseCsv(csv.getBlob().getDataAsString('UTF-8'));
hoja.setName(nombre);
if (hoja.getMaxRows() < filas.length) hoja.insertRowsAfter(hoja.getMaxRows(), filas.length - hoja.getMaxRows());
hoja.getRange('A:A').setNumberFormat('@');
hoja.getRange(1, 1, filas.length, filas[0].length).setValues(filas);
hoja.getRange(1, 1, 1, filas[0].length).setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
hoja.setFrozenRows(1);
anchos.forEach((w, i) => hoja.setColumnWidth(i + 1, w));
};
cargar(ss.getSheets()[0], 'ClaveProdServ', csvProd, [100, 600]);
cargar(ss.insertSheet(), 'ClaveUnidad', csvUni, [100, 320, 90]);
ss.insertSheet('Fuente').getRange(1, 1, 2, 1).setValues([[FUENTE_SAT],
['Uso interno de contabilidad. Para actualizar: reemplazar los CSV y borrar la propiedad SAT_ID del proyecto.']]);
try { DriveApp.getFileById(ss.getId()).moveTo(csvProd.getParents().next()); } catch (e) { /* queda en Mi unidad */ }
PropertiesService.getScriptProperties().setProperty('SAT_ID', ss.getId());
}
/** Unidades del SAT [[clave, nombre], ...] para la lista de selección. */
function getUnidadesSat_() {
const hoja = libroSat_().getSheetByName('ClaveUnidad');
return hoja.getRange(2, 1, hoja.getLastRow() - 1, 2).getValues().map(r => [String(r[0]), String(r[1])]);
}
/**
* Sugerencias de clave SAT para una descripción (o la clave exacta si se escribe una de 8 dígitos).
* Con la propiedad ANTHROPIC_API_KEY, la IA elige las 5 mejores de ~40 candidatos (con su razón);
* sin ella, o si la llamada falla, quedan los resultados de la búsqueda por palabras.
*/
function sugerirClavesSat(texto) {
const hoja = libroSat_().getSheetByName('ClaveProdServ');
const filas = hoja.getRange(2, 1, hoja.getLastRow() - 1, 2).getValues();
const exacta = String(texto || '').replace(/\D/g, '');
if (/^\d{7,8}$/.test(exacta)) {
const f = filas.find(r => String(r[0]) === exacta.padStart(8, '0'));
return f ? [{ clave: String(f[0]), descripcion: String(f[1]), puntaje: 1 }] : [];
}
const candidatos = candidatosSat(texto, filas, 40);
return ordenarConIaSat_(texto, candidatos) || candidatos.slice(0, 8);
}
/**
* Ejecutar una vez desde el editor tras agregar la IA: autoriza la conexión externa (UrlFetchApp) y muestra
* en el registro qué sugiere para unos servicios de ejemplo. Sin ANTHROPIC_API_KEY, muestra la búsqueda por palabras.
*/
function probarIaSat() {
['Mantenimiento preventivo', 'Servicio de rastreo GPS vehicular', 'Limpieza de patios',
'Instalación de paneles solares'].forEach(q => {
console.log(q + '\n' + sugerirClavesSat(q).map(o => o.clave + ' ' + o.descripcion +
(o.razon ? ' — ' + o.razon : '')).join('\n'));
});
}
/** Pide a Claude que elija y ordene entre los candidatos. null si no hay API key o algo falla. */
function ordenarConIaSat_(texto, candidatos) {
const apiKey = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY');
if (!apiKey || !candidatos.length) return null;
const cache = CacheService.getScriptCache();
const llave = 'satIA:' + Utilities.base64Encode(Utilities.computeDigest(Utilities.DigestAlgorithm.MD5,
palabrasSat(texto).join(' ') + '|' + candidatos.map(c => c.clave).join(','), Utilities.Charset.UTF_8));
const guardada = cache.get(llave);
if (guardada) return JSON.parse(guardada);
try {
const r = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
method: 'post', contentType: 'application/json', muteHttpExceptions: true,
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
payload: JSON.stringify(solicitudIaSat(texto, candidatos)),
});
if (r.getResponseCode() !== 200) {
console.warn('IA claves SAT: HTTP ' + r.getResponseCode() + ' ' + r.getContentText().slice(0, 300));
return null;
}
const lista = leerRespuestaIaSat(r.getContentText(), candidatos);
cache.put(llave, JSON.stringify(lista), 21600); // 6 h: la misma búsqueda no se vuelve a cobrar
return lista;
} catch (e) {
console.warn('IA claves SAT: ' + (e && e.message ? e.message : e));
return null;
}
}
/** true si la clave existe en esa pestaña del catálogo SAT (columna A, celda completa). */
function existeEnSat_(pestana, clave) {
const hoja = libroSat_().getSheetByName(pestana);
return !!hoja.getRange(1, 1, hoja.getLastRow(), 1).createTextFinder(clave).matchEntireCell(true).findNext();
}
/**
* Alta o edición de un producto. p = {clave, descripcion, unidad, precio, claveOriginal?}
* claveOriginal indica que se edita ese producto (permite cambiarle la clave).
*/
function guardarProducto(p) {
const prod = normalizarProducto(p);
const original = String((p && p.claveOriginal) || '').trim();
if (prod.claveSat && !existeEnSat_('ClaveProdServ', prod.claveSat)) {
throw new Error('La clave SAT ' + prod.claveSat + ' no existe en el catálogo vigente del SAT.');
}
if (prod.unidadSat && !existeEnSat_('ClaveUnidad', prod.unidadSat)) {
throw new Error('La unidad SAT ' + prod.unidadSat + ' no existe en el catálogo vigente del SAT.');
}
const hoja = hojaCatalogo_(); // fuera del candado: la primera vez crea el archivo con su propio candado
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('El catálogo está ocupado. Intenta de nuevo en unos segundos.');
try {
const datos = hoja.getDataRange().getValues().slice(1);
const claves = datos.map(r => r[0]);
const iExiste = indiceDeClave(claves, prod.clave);
const iOriginal = original ? indiceDeClave(claves, original) : -1;
if (original && iOriginal === -1) throw new Error('El producto ' + original + ' ya no existe en el catálogo. Recarga la lista.');
if (iExiste !== -1 && iExiste !== iOriginal) {
throw new Error('La clave ' + prod.clave + ' ya existe: "' + datos[iExiste][1] + '". Usa otra clave o edita ese producto.');
}
// Columnas A:C tienen formato texto: lo capturado no se interpreta como fórmula
const fila = [prod.clave, prod.descripcion, prod.unidad, prod.precio, new Date(), usuarioActual_(),
prod.claveSat, prod.unidadSat];
if (iOriginal !== -1) {
hoja.getRange(iOriginal + 2, 1, 1, fila.length).setValues([fila]);
} else {
hoja.appendRow(fila);
}
try { asegurarPrecioUsd_(hoja); } catch (e) { console.warn('Sin columna de precio USD: ' + e.message); }
SpreadsheetApp.flush();
return { productos: getCatalogo_(hoja), url: hoja.getParent().getUrl(), clave: prod.clave,
tipoCambio: tipoCambioCatalogo_(hoja) };
} finally {
lock.releaseLock();
}
}
/** Llamada por la app al cargar. */
function getDatosIniciales() {
const cfg = getConfig_();
const hojaCat = hojaCatalogo_();
return {
ivaTasa: cfg.IVA_TASA,
vigenciaDias: cfg.VIGENCIA_DIAS,
marcas: MARCAS,
catalogo: getCatalogo_(hojaCat),
tipoCambio: tipoCambioCatalogo_(hojaCat),
clientes: getClientes(),
proyectos: proyectosParaInicio_(),
usuario: usuarioActual_(),
vendedor: VENDEDOR_,
// Solo para mostrar u ocultar la pestaña Cobranza; cada función de cobranza lo vuelve a revisar
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_() },
};
}
/** Proyectos activos para el buscador. Si fallan, la app carga igual (se puede cotizar "Sin proyecto"). */
function proyectosParaInicio_() {
try {
return listarProyectos('', false);
} catch (e) {
console.warn('Sin lista de proyectos: ' + e.message);
return [];
}
}
/**
* Clientes que ya cotizaron, tomados de la hoja Cotizaciones.
* Si se borran sus filas de la base de datos, dejan de sugerirse.
*/
function getClientes() {
const filas = db_().getSheetByName('Cotizaciones').getDataRange().getValues().slice(1)
.map(r => {
const f = r.slice();
f[1] = f[1] instanceof Date ? Utilities.formatDate(f[1], 'America/Mexico_City', 'dd/MM/yyyy') : String(f[1] || '');
return f;
});
return clientesDesdeFilas(filas);
}
/** Historial (últimas 100, con búsqueda opcional). */
function listarCotizaciones(busqueda) {
const hoja = db_().getSheetByName('Cotizaciones');
const filas = hoja.getDataRange().getDisplayValues().slice(1);
// Correo → "Nombre Apellido" con todas las filas (antes de filtrar), para ponerle nombre a las filas viejas
const nombres = nombresDeVendedores(filas);
const datos = filas.reverse();
const q = String(busqueda || '').toLowerCase().trim();
return datos
.filter(r => !q || (r[0] + ' ' + r[2] + ' ' + r[3] + ' ' + (r[19] || '')).toLowerCase().includes(q))
.slice(0, 100)
.map(r => ({
folio: r[0], fecha: r[1], cliente: r[2], empresa: r[3], total: r[9], elaboro: r[10], pdf: r[11],
vendedor: vendedorDeFila(r, nombres),
// Filas anteriores a Proyectos (o cotizadas sin proyecto)
proyecto: r[19] || 'Sin proyecto',
moneda: r[20] || 'MXN', // filas anteriores a USD: pesos
}));
}
// ====== 4. GUARDAR COTIZACIÓN + PDF ======
function guardarCotizacion(payload) {
// El vendedor sale del token verificado en doPost, nunca de lo que mande el navegador
const vendedor = VENDEDOR_;
if (!vendedor) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + ' para guardar.');
const cfg = getConfig_();
const datos = normalizarCotizacion(payload, cfg.IVA_TASA);
// Para facturación: cada concepto que viene del catálogo guarda su clave y unidad SAT (no salen en el PDF)
// Es un dato extra: si el catálogo falla, la cotización se guarda igual
try {
const catalogo = getCatalogo_();
const claves = catalogo.map(c => c.clave);
datos.items.forEach(it => {
const p = it.clave && catalogo[indiceDeClave(claves, it.clave)];
if (p && p.claveSat) { it.claveSat = p.claveSat; it.unidadSat = p.unidadSat; }
});
} catch (e) { console.warn('Sin datos SAT del catálogo: ' + e.message); }
// Idempotencia: si el mismo formulario llega dos veces (doble clic, reintento), se devuelve el mismo folio.
const idSolicitud = String((payload && payload.idSolicitud) || '').slice(0, 64);
const cache = CacheService.getScriptCache();
const claveCache = idSolicitud ? 'REQ_' + idSolicitud : '';
// Proyecto opcional: vacío = "Sin proyecto" (el PDF va a la carpeta del año, como antes)
const proyectoFolio = String((payload && payload.proyectoFolio) || '').trim().toUpperCase();
const lock = LockService.getScriptLock();
// Evita folios duplicados si dos personas guardan a la vez
if (!lock.tryLock(30000)) {
throw new Error('El sistema está ocupado guardando otra cotización. Intenta de nuevo en unos segundos.');
}
try {
if (claveCache) {
const previo = cache.get(claveCache);
if (previo) return JSON.parse(previo);
}
// El proyecto se valida dentro del candado: que exista y siga Activo
let carpetaPdf = null;
if (proyectoFolio) {
const proyecto = buscarProyecto_(hojaProyectos_(), proyectoFolio);
if (!proyecto) throw errorProyecto_('No existe el proyecto ' + proyectoFolio + '. Elige otro o cotiza sin proyecto.');
if (proyecto.estado !== 'Activo') {
throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() +
': ya no se le pueden agregar cotizaciones.');
}
// Fase 2: con contrato firmado ya no se cotiza dentro del proyecto (se revisará con los adicionales)
if (foliosConContratoFirmado_()[proyecto.folio.toUpperCase()]) {
throw errorProyecto_('Proyecto con contrato firmado: no admite cotizaciones nuevas.');
}
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
}
const hojaCot = db_().getSheetByName('Cotizaciones');
const folio = siguienteFolio_(cfg.PREFIJO_FOLIO || 'HLG', hojaCot);
const ahora = new Date();
datos.folio = folio;
datos.fecha = Utilities.formatDate(ahora, 'America/Mexico_City', 'dd/MM/yyyy');
datos.vigencia = Utilities.formatDate(
new Date(ahora.getTime() + cfg.VIGENCIA_DIAS * 86400000), 'America/Mexico_City', 'dd/MM/yyyy');
datos.elaboro = vendedor.email;
// PDF
const html = construirHtmlPdf(datos, cfg, logoDataUrl_(cfg.LOGO_FILE_ID));
const nombre = folio + ' - ' + (datos.cliente.empresa || datos.cliente.nombre) + '.pdf';
const pdf = Utilities.newBlob(html, 'text/html', folio + '.html').getAs('application/pdf').setName(nombre);
const archivo = (carpetaPdf || carpetaDelAnio_(ahora.getFullYear())).createFile(pdf);
// Registro (Marca, Dirección, vendedor_* y proyecto_folio se agregaron después: se reescribe solo la fila 1
// de encabezados; las filas de datos no se mueven y las viejas quedan vacías en las columnas nuevas)
const nCols = ENCABEZADOS_COTIZACIONES.length;
const actuales = hojaCot.getRange(1, 1, 1, nCols).getValues()[0];
if (ENCABEZADOS_COTIZACIONES.some((h, i) => actuales[i] !== h)) {
hojaCot.getRange(1, 1, 1, nCols).setValues([ENCABEZADOS_COTIZACIONES])
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
}
const t = textoCelda;
hojaCot.appendRow([
folio, ahora, t(datos.cliente.nombre), t(datos.cliente.empresa), t(datos.cliente.rfc),
t(datos.cliente.email), t(datos.cliente.telefono),
datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(),
JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion),
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio, datos.moneda, datos.tipoCambio,
]);
SpreadsheetApp.flush();
const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total, moneda: datos.moneda,
proyectoFolio: proyectoFolio };
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
return resultado;
} finally {
lock.releaseLock();
}
}
/**
* Folio: HLG-2026-0001 (reinicia cada año).
* Toma el mayor entre el contador guardado y el último folio registrado en la hoja,
* así no se repite aunque alguien borre o edite el contador en Script Properties.
*/
function siguienteFolio_(prefijo, hoja, baseContador) {
const props = PropertiesService.getScriptProperties();
const anio = Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy');
// Cotizaciones: CONSECUTIVO_AAAA; proyectos: CONSECUTIVO_PROY_AAAA (cada tipo de folio lleva su contador)
const clave = (baseContador || 'CONSECUTIVO_') + anio;
const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 1).getValues() : [];
const enHoja = maxConsecutivo(filas.map(r => r[0]), prefijo, anio);
const n = Math.max(Number(props.getProperty(clave) || 0), enHoja) + 1;
props.setProperty(clave, String(n));
return formatearFolio(prefijo, anio, n);
}
function carpetaDelAnio_(anio) {
const raiz = DriveApp.getFolderById(PropertiesService.getScriptProperties().getProperty('FOLDER_ID'));
const it = raiz.getFoldersByName(String(anio));
return it.hasNext() ? it.next() : raiz.createFolder(String(anio));
}
/** Logo de Drive si se configuró LOGO_FILE_ID; si no, el logo azul incluido en Logo.gs. */
function logoDataUrl_(fileId) {
if (!fileId) return LOGO_HELGEN_AZUL;
try {
const blob = DriveApp.getFileById(String(fileId).trim()).getBlob();
return 'data:' + blob.getContentType() + ';base64,' + Utilities.base64Encode(blob.getBytes());
} catch (e) {
return LOGO_HELGEN_AZUL;
}
}
// ====== 4.1 PROYECTOS ======
// Pestaña "Proyectos" en la base de datos + carpeta "Cotizador Helgen/Proyectos/AAAA/<folio> — <cliente> — <nombre>/".
// Lo que se agregue después (OrdenesCompra, Pagos, Gastos) se liga por proyecto_folio (ver CLAUDE.md).
const ENCABEZADOS_PROYECTOS = [
'Folio', 'Fecha', 'Nombre', 'Cliente', 'cliente_id', 'Ubicación', 'Estado', 'carpeta_id',
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido'
];
const COL_ESTADO_PROYECTO = 7; // G
const COL_CARPETA_PROYECTO = 8; // H
const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado'];
// Sin "Pagos": los comprobantes van en la carpeta de cobranza (no visible para todo el dominio). Los proyectos
// creados antes la conservan; no se borra.
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra'];
const PREFIJO_PROYECTO = 'PRY';
/**
* Pestaña "Proyectos". La primera vez la crea (encabezados + lista desplegable de Estado);
* si le faltan encabezados nuevos, reescribe solo la fila 1.
*/
function hojaProyectos_() {
const ss = db_();
let hoja = ss.getSheetByName('Proyectos');
if (!hoja) {
try {
hoja = ss.insertSheet('Proyectos');
} catch (e) {
// Otra petición la acaba de crear
hoja = ss.getSheetByName('Proyectos');
if (!hoja) throw e;
}
hoja.setFrozenRows(1);
hoja.getRange('A:H').setNumberFormat('@');
hoja.getRange('B:B').setNumberFormat('dd/MM/yyyy HH:mm');
hoja.getRange(2, COL_ESTADO_PROYECTO, hoja.getMaxRows() - 1, 1).setDataValidation(
SpreadsheetApp.newDataValidation().requireValueInList(ESTADOS_PROYECTO, true).setAllowInvalid(false)
.setHelpText('Estado del proyecto: Activo, Cerrado o Cancelado.').build());
hoja.setColumnWidth(3, 260); hoja.setColumnWidth(4, 260); hoja.setColumnWidth(6, 220);
}
const n = ENCABEZADOS_PROYECTOS.length;
const actuales = hoja.getRange(1, 1, 1, n).getValues()[0];
if (ENCABEZADOS_PROYECTOS.some((h, i) => actuales[i] !== h)) {
hoja.getRange(1, 1, 1, n).setValues([ENCABEZADOS_PROYECTOS])
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
}
return hoja;
}
/** Proyectos con búsqueda por folio, cliente o nombre. Por omisión solo los Activos; los más recientes primero. */
function listarProyectos(busqueda, incluirInactivos) {
const hoja = hojaProyectos_();
const filas = hoja.getLastRow() > 1
? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
// El buscador de cotizaciones (solo activos) no muestra proyectos con contrato firmado
const firmados = incluirInactivos ? {} : foliosConContratoFirmado_();
return filtrarProyectos(proyectosDesdeFilas(filas).filter(p => !firmados[p.folio.toUpperCase()]),
busqueda, incluirInactivos, 50)
.map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId,
ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }));
}
/** Error de proyecto inexistente o no Activo: doPost lo manda con codigo 'PROYECTO' y la interfaz limpia el campo. */
function errorProyecto_(mensaje) {
const e = new Error(mensaje);
e.codigo = 'PROYECTO';
return e;
}
/** Proyecto por folio (con su número de fila), o null. */
function buscarProyecto_(hoja, folio) {
const buscado = String(folio || '').trim().toUpperCase();
if (!buscado || hoja.getLastRow() < 2) return null;
const filas = hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues();
const lista = proyectosDesdeFilas(filas);
for (let i = 0; i < lista.length; i++) {
if (lista[i].folio.toUpperCase() === buscado) return Object.assign(lista[i], { hoja: hoja, fila: i + 2 });
}
return null;
}
/**
* Alta de proyecto (desde "Nuevo proyecto" en la interfaz). p = {nombre, ubicacion, cliente:{nombre, empresa, rfc},
* idSolicitud}. Folio PRY-AAAA-NNNN bajo el mismo candado que las cotizaciones; primero crea la carpeta y luego la
* fila, para que no quede un proyecto registrado sin carpeta.
*/
function crearProyecto(p) {
const vendedor = VENDEDOR_;
if (!vendedor) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + ' para crear proyectos.');
const datos = normalizarProyecto(p);
const idSolicitud = String((p && p.idSolicitud) || '').slice(0, 64);
const cache = CacheService.getScriptCache();
const claveCache = idSolicitud ? 'PRY_' + idSolicitud : '';
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) {
throw new Error('El sistema está ocupado guardando otro registro. Intenta de nuevo en unos segundos.');
}
try {
if (claveCache) {
const previo = cache.get(claveCache);
if (previo) return JSON.parse(previo);
}
const hoja = hojaProyectos_();
const folio = siguienteFolio_(PREFIJO_PROYECTO, hoja, 'CONSECUTIVO_PROY_');
const ahora = new Date();
const carpeta = crearCarpetaProyecto_(folio, datos, ahora.getFullYear());
const t = textoCelda;
hoja.appendRow([
folio, ahora, t(datos.nombre), t(datos.cliente), t(datos.clienteId), t(datos.ubicacion), 'Activo', carpeta.getId(),
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido),
]);
SpreadsheetApp.flush();
const resultado = { folio: folio, nombre: datos.nombre, cliente: datos.cliente, clienteId: datos.clienteId,
ubicacion: datos.ubicacion, estado: 'Activo', url: carpeta.getUrl() };
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
return resultado;
} finally {
lock.releaseLock();
}
}
/** Carpeta "Proyectos" junto a la base de datos (en la carpeta maestra). Se crea sola y se guarda en PROYECTOS_FOLDER_ID. */
function carpetaProyectos_() {
const props = PropertiesService.getScriptProperties();
const id = props.getProperty('PROYECTOS_FOLDER_ID');
if (id) {
try {
const f = DriveApp.getFolderById(id);
if (!f.isTrashed()) return f;
} catch (e) { /* se borró: se vuelve a crear abajo */ }
}
const padres = DriveApp.getFileById(props.getProperty('SPREADSHEET_ID')).getParents();
const maestra = padres.hasNext() ? padres.next() : DriveApp.getRootFolder();
const carpeta = subcarpeta_(maestra, 'Proyectos');
props.setProperty('PROYECTOS_FOLDER_ID', carpeta.getId());
return carpeta;
}
/** Subcarpeta por nombre (ignora las que están en la Papelera); si no existe, la crea. */
function subcarpeta_(padre, nombre) {
const it = padre.getFoldersByName(nombre);
while (it.hasNext()) {
const f = it.next();
if (!f.isTrashed()) return f;
}
return padre.createFolder(nombre);
}
/** Proyectos/AAAA/<folio> — <cliente> — <nombre>/ con sus subcarpetas. */
function crearCarpetaProyecto_(folio, datos, anio) {
const carpetaAnio = subcarpeta_(carpetaProyectos_(), String(anio));
const carpeta = carpetaAnio.createFolder(nombreCarpetaProyecto(folio, datos.cliente, datos.nombre));
SUBCARPETAS_PROYECTO.forEach(s => carpeta.createFolder(s));
return carpeta;
}
/**
* Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra"…). Se busca por nombre: si alguien la
* borró o la renombró, se crea una nueva. Si falta la carpeta del proyecto, se vuelve a crear y se actualiza
* carpeta_id en la hoja.
*/
function subcarpetaProyecto_(proyecto, nombreSub) {
let carpeta = null;
try {
carpeta = proyecto.carpetaId ? DriveApp.getFolderById(proyecto.carpetaId) : null;
if (carpeta && carpeta.isTrashed()) carpeta = null;
} catch (e) { carpeta = null; }
if (!carpeta) {
const anio = Number(String(proyecto.folio).split('-')[1]) || new Date().getFullYear();
carpeta = crearCarpetaProyecto_(proyecto.folio, proyecto, anio);
proyecto.carpetaId = carpeta.getId();
if (proyecto.hoja && proyecto.fila) proyecto.hoja.getRange(proyecto.fila, COL_CARPETA_PROYECTO).setValue(carpeta.getId());
}
return subcarpeta_(carpeta, nombreSub);
}
// ====== 4.2 COBRANZA (Fase 2) ======
// Libro aparte "Cotizador Helgen — Cobranza (datos)" dentro de la carpeta "Cotizador Helgen — Cobranza" (fuera de
// la carpeta maestra, que todo el dominio puede leer). Solo el rol de cobranza (ROL_COBRANZA) ve o cambia estos datos;
// el servidor lo revisa en cada llamada. Comprobantes en Cobranza/AAAA/<folio del proyecto>/.
const NOMBRE_CARPETA_COBRANZA = 'Cotizador Helgen — Cobranza';
const NOMBRE_LIBRO_COBRANZA = 'Cotizador Helgen — Cobranza (datos)';
const PREFIJO_PAGO = 'PAG';
/** Estructura del libro. numeros = columnas (1 = A) con formato de importe; las demás van como texto. */
const HOJAS_COBRANZA = {
Contratos: {
enc: ['proyecto_folio', 'cotizacion_aceptada', 'marca', 'moneda', 'subtotal', 'iva', 'total', 'estado',
'aceptada_por', 'fecha_aceptacion', 'fecha_firma', 'firmado_por', 'actualizado'],
numeros: [5, 6, 7],
},
Esquemas: { enc: ['proyecto_folio', 'orden', 'etapa', 'porcentaje', 'monto'], numeros: [2, 4, 5] },
Pagos: {
enc: ['folio', 'fecha_pago', 'fecha_registro', 'proyecto_folio', 'monto', 'moneda_pago', 'tipo_cambio',
'monto_aplicado', 'moneda_proyecto', 'metodo', 'referencia', 'etapa_orden', 'etapa', 'aplicacion (JSON)',
'comprobante_id', 'comprobante_url', 'registro_email', 'registro_nombre', 'estado', 'cancelado_por',
'fecha_cancelacion', 'motivo_cancelacion'],
numeros: [5, 7, 8, 12],
},
Bitacora: { enc: ['fecha', 'correo', 'accion', 'proyecto_folio', 'detalle', 'motivo'], numeros: [] },
};
const ESTADO_ACEPTADA = 'Aceptada', ESTADO_FIRMADO = 'Firmado';
/** Error de permisos: doPost lo manda con codigo 'PERMISO'. */
function errorPermiso_(mensaje) {
const e = new Error(mensaje);
e.codigo = 'PERMISO';
return e;
}
/** ¿El usuario del token está en ROL_COBRANZA? Sin token (editor) o sin la propiedad: no. */
function esCobranza_() {
if (!VENDEDOR_) return false;
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA'), DOMINIO_HELGEN);
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
}
/** ¿Puede desbloquear contratos firmados? (ADMIN_DESBLOQUEO; sin la propiedad, nadie). */
function esAdminDesbloqueo_() {
if (!VENDEDOR_) return false;
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ADMIN_DESBLOQUEO'), DOMINIO_HELGEN);
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
}
function exigirCobranza_() {
if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.');
if (!esCobranza_()) throw errorPermiso_('No tienes permiso de cobranza.');
}
/**
* Deja un archivo o carpeta de cobranza en "Restringido": quita el acceso de todo el dominio o por enlace y conserva a
* las personas agregadas una por una (tania@, leo@). Workspace comparte con todo helgen.tech lo que se crea (§6.11).
* Usa la API de Drive con el token del script: DriveApp.setSharing da "Access denied: DriveApp." en helgen.tech
* (20/10/2026). No pide permisos nuevos (el de Drive ya lo da DriveApp). Si no puede, lanza error: nada queda abierto.
* Restringir siempre de arriba abajo: un permiso heredado de la carpeta no se puede quitar en el hijo (403).
*/
function privado_(item) {
const url = 'https://www.googleapis.com/drive/v3/files/' + encodeURIComponent(item.getId()) + '/permissions';
const opciones = { headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }, muteHttpExceptions: true };
const r = UrlFetchApp.fetch(url + '?supportsAllDrives=true&fields=permissions(id,type)', opciones);
if (r.getResponseCode() !== 200) throw new Error('No se pudo revisar el acceso en Drive (' + r.getResponseCode() + '): ' + r.getContentText());
(JSON.parse(r.getContentText()).permissions || [])
.filter(p => p.type === 'domain' || p.type === 'anyone')
.forEach(p => {
const d = UrlFetchApp.fetch(url + '/' + encodeURIComponent(p.id) + '?supportsAllDrives=true',
Object.assign({ method: 'delete' }, opciones));
if (d.getResponseCode() >= 300) throw new Error('No se pudo restringir el acceso en Drive (' + d.getResponseCode() + '): ' + d.getContentText());
});
return item;
}
/** Carpeta de cobranza. Se crea sola en Mi unidad (restringida). Si se borró, error: no se crea otra a escondidas. */
function carpetaCobranza_() {
const props = PropertiesService.getScriptProperties();
const id = props.getProperty('COBRANZA_FOLDER_ID');
if (!id) {
const carpeta = privado_(DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA));
props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId());
return carpeta;
}
let carpeta = null;
try { carpeta = DriveApp.getFolderById(id); } catch (e) { carpeta = null; }
if (!carpeta || carpeta.isTrashed()) {
throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.');
}
return privado_(carpeta);
}
/** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */
function libroCobranza_() {
const props = PropertiesService.getScriptProperties();
if (!props.getProperty('COBRANZA_DB_ID')) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('Se está preparando cobranza. Intenta de nuevo en unos segundos.');
try {
if (!props.getProperty('COBRANZA_DB_ID')) crearLibroCobranza_();
} finally {
lock.releaseLock();
}
}
return abrirLibroCobranza_(props.getProperty('COBRANZA_DB_ID'));
}
/** Libro de cobranza si ya existe; null si todavía no se crea (las cotizaciones no lo crean). */
function libroCobranzaSiExiste_() {
const id = PropertiesService.getScriptProperties().getProperty('COBRANZA_DB_ID');
return id ? abrirLibroCobranza_(id) : null;
}
function abrirLibroCobranza_(id) {
try {
return SpreadsheetApp.openById(id);
} catch (e) {
throw new Error('No se pudo abrir el libro de cobranza (COBRANZA_DB_ID). Revisa que no esté en la Papelera.');
}
}
function crearLibroCobranza_() {
const carpeta = carpetaCobranza_();
const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA);
privado_(DriveApp.getFileById(ss.getId())).moveTo(carpeta);
Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre));
const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante);
PropertiesService.getScriptProperties().setProperty('COBRANZA_DB_ID', ss.getId());
}
/** Pestaña del libro de cobranza; la crea o le repone los encabezados (patrón de hojaProyectos_). */
function hojaCobranza_(libro, nombre) {
const def = HOJAS_COBRANZA[nombre];
let hoja = libro.getSheetByName(nombre);
if (!hoja) {
hoja = libro.insertSheet(nombre);
hoja.setFrozenRows(1);
hoja.getRange(1, 1, hoja.getMaxRows(), def.enc.length).setNumberFormat('@'); // texto: nada se lee como fórmula
def.numeros.forEach(c => hoja.getRange(2, c, hoja.getMaxRows() - 1, 1).setNumberFormat('#,##0.00##'));
}
const actuales = hoja.getRange(1, 1, 1, def.enc.length).getValues()[0];
if (def.enc.some((h, i) => actuales[i] !== h)) {
hoja.getRange(1, 1, 1, def.enc.length).setValues([def.enc])
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
}
return hoja;
}
/** Filas de datos (sin encabezado) de una pestaña, con el ancho de sus encabezados. */
function filasCobranza_(hoja, nombre) {
const n = HOJAS_COBRANZA[nombre].enc.length;
return hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, n).getValues() : [];
}
const marcaDeTexto_ = t => (String(t || '').trim() === MARCAS.energy || t === 'energy') ? 'energy' : 'helgen';
function contratoDesdeFila_(r, fila) {
return {
proyectoFolio: String(r[0]), cotizacion: String(r[1]), marca: marcaDeTexto_(r[2]), moneda: String(r[3] || 'MXN'),
subtotal: Number(r[4]) || 0, iva: Number(r[5]) || 0, total: Number(r[6]) || 0, estado: String(r[7] || ''),
aceptadaPor: String(r[8] || ''), fechaAceptacion: fechaTexto_(r[9]), fechaFirma: fechaIso_(r[10]),
firmadoPor: String(r[11] || ''), fila: fila,
};
}
function pagoDesdeFila_(r, fila) {
return {
folio: String(r[0]), fecha: fechaIso_(r[1]), fechaRegistro: fechaTexto_(r[2]), proyectoFolio: String(r[3]),
monto: Number(r[4]) || 0, monedaPago: String(r[5] || ''), tipoCambio: r[6] === '' ? '' : Number(r[6]),
montoAplicado: Number(r[7]) || 0, monedaProyecto: String(r[8] || ''), metodo: String(r[9] || ''),
referencia: String(r[10] || ''), etapaOrden: Number(r[11]) || 1, etapa: String(r[12] || ''),
comprobanteUrl: String(r[15] || ''), registro: String(r[17] || r[16] || ''), estado: String(r[18] || 'Vigente'),
canceladoPor: String(r[19] || ''), motivoCancelacion: String(r[21] || ''), fila: fila,
};
}
function fechaTexto_(v) {
return v instanceof Date ? Utilities.formatDate(v, 'America/Mexico_City', 'dd/MM/yyyy HH:mm') : String(v || '');
}
/**
* Fecha sin hora (pago, firma) como "AAAA-MM-DD". Se escriben con apóstrofo (textoFecha_) para que Sheets no las
* convierta en fecha (appendRow lo hace aunque la columna sea texto); las filas guardadas antes vienen como Date.
*/
function fechaIso_(v) {
return v instanceof Date ? Utilities.formatDate(v, 'America/Mexico_City', 'yyyy-MM-dd') : String(v || '');
}
const textoFecha_ = iso => iso ? "'" + iso : '';
const hoyIso_ = () => Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd');
/** Todo lo de cobranza de un proyecto, leído del libro. */
function datosCobranza_(libro, folio) {
const f = String(folio).toUpperCase();
const hC = hojaCobranza_(libro, 'Contratos'), hE = hojaCobranza_(libro, 'Esquemas'), hP = hojaCobranza_(libro, 'Pagos');
let contrato = null;
filasCobranza_(hC, 'Contratos').forEach((r, i) => {
if (String(r[0]).toUpperCase() === f) contrato = contratoDesdeFila_(r, i + 2);
});
const etapas = filasCobranza_(hE, 'Esquemas')
.filter(r => String(r[0]).toUpperCase() === f)
.sort((a, b) => Number(a[1]) - Number(b[1]))
.map(r => ({ etapa: String(r[2]), porcentaje: Number(r[3]) }));
const pagos = [];
filasCobranza_(hP, 'Pagos').forEach((r, i) => {
if (String(r[3]).toUpperCase() === f) pagos.push(pagoDesdeFila_(r, i + 2));
});
return { hojas: { contratos: hC, esquemas: hE, pagos: hP }, contrato: contrato, etapas: etapas, pagos: pagos };
}
/** {"PRY-2026-0001": true, …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */
function foliosConContratoFirmado_() {
const libro = libroCobranzaSiExiste_();
const mapa = {};
if (!libro) return mapa;
const hoja = libro.getSheetByName('Contratos');
if (!hoja) return mapa;
filasCobranza_(hoja, 'Contratos').forEach(r => {
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = true;
});
return mapa;
}
/** Cotizaciones ligadas a un proyecto (columna T), de la hoja Cotizaciones. */
function cotizacionesDeProyecto_(folio) {
const f = String(folio).toUpperCase();
const hoja = db_().getSheetByName('Cotizaciones');
return hoja.getDataRange().getValues().slice(1)
.filter(r => String(r[19] || '').toUpperCase() === f && String(r[0] || '').trim())
.map(r => ({
folio: String(r[0]), fecha: r[1] instanceof Date ? Utilities.formatDate(r[1], 'America/Mexico_City', 'dd/MM/yyyy') : String(r[1] || ''),
cliente: String(r[2] || ''), subtotal: Number(r[7]) || 0, iva: Number(r[8]) || 0, total: Number(r[9]) || 0,
pdf: String(r[11] || ''), marca: marcaDeTexto_(r[14]), moneda: String(r[20] || '') || 'MXN',
vendedor: vendedorDeFila(r, {}),
}));
}
/** Proyecto existente (o error). */
function proyectoParaCobranza_(folio) {
const proyecto = buscarProyecto_(hojaProyectos_(), folio);
if (!proyecto) throw new Error('No existe el proyecto ' + String(folio || '').toUpperCase() + '.');
return proyecto;
}
function bitacora_(libro, accion, folio, detalle, motivo) {
const t = textoCelda;
hojaCobranza_(libro, 'Bitacora').appendRow([
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), t(VENDEDOR_ ? VENDEDOR_.email : ''),
t(accion), t(folio), t(detalle || ''), t(motivo || ''),
]);
}
/** Reescribe las etapas del proyecto en "Esquemas" (borra las anteriores) con sus montos. */
function escribirEsquema_(hoja, folio, etapas, total) {
const f = String(folio).toUpperCase();
const filas = filasCobranza_(hoja, 'Esquemas');
for (let i = filas.length - 1; i >= 0; i--) {
if (String(filas[i][0]).toUpperCase() === f) hoja.deleteRow(i + 2);
}
montosDeEsquema(etapas, total).forEach((e, i) => hoja.appendRow([f, i + 1, textoCelda(e.etapa), e.porcentaje, e.monto]));
}
/** Respuesta para la pestaña Cobranza: proyecto, cotizaciones, contrato, esquema con avance y pagos. */
function vistaCobranza_(libro, proyecto) {
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
const resumen = c && d.etapas.length ? resumenCobranza(c, d.etapas, d.pagos) : null;
return {
proyecto: { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado,
ubicacion: proyecto.ubicacion },
cotizaciones: cotizacionesDeProyecto_(proyecto.folio),
contrato: c ? { cotizacion: c.cotizacion, marca: c.marca, moneda: c.moneda, subtotal: c.subtotal, iva: c.iva,
total: c.total, estado: c.estado, aceptadaPor: c.aceptadaPor, fechaAceptacion: c.fechaAceptacion,
fechaFirma: c.fechaFirma, firmadoPor: c.firmadoPor } : null,
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' || 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,
hoy: hoyIso_(),
};
}
/** Candado de script (el mismo de folios) alrededor de una función. */
function conCandado_(f) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('El sistema está ocupado guardando otro registro. Intenta de nuevo en unos segundos.');
try {
return f();
} finally {
lock.releaseLock();
}
}
/** Proyectos para el buscador de Cobranza (todos los estados), con el estado de su contrato. */
function listarProyectosCobranza(busqueda) {
exigirCobranza_();
const libro = libroCobranza_();
const estados = {};
filasCobranza_(hojaCobranza_(libro, 'Contratos'), 'Contratos').forEach(r => { estados[String(r[0]).toUpperCase()] = String(r[7]); });
return listarProyectos(busqueda, true).map(p => Object.assign(p, { contrato: estados[p.folio.toUpperCase()] || '' }));
}
function getCobranzaProyecto(folio) {
exigirCobranza_();
const libro = libroCobranza_();
return vistaCobranza_(libro, proyectoParaCobranza_(folio));
}
/**
* 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
* (devuelve {confirmar, mensaje}) antes de reemplazarlo.
*/
function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) {
exigirCobranza_();
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado.');
const folioCot = String(cotizacionFolio || '').trim().toUpperCase();
const cot = cotizacionesDeProyecto_(proyecto.folio).find(c => c.folio.toUpperCase() === folioCot);
if (!cot) throw new Error('La cotización ' + folioCot + ' no pertenece al proyecto ' + proyecto.folio + '.');
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (c && c.estado === ESTADO_FIRMADO) throw new Error('Contrato firmado: la cotización aceptada ya no se puede cambiar.');
if (c && c.cotizacion === cot.folio) return vistaCobranza_(libro, proyecto);
const vigentes = d.pagos.filter(p => p.estado === 'Vigente');
if (vigentes.length && vigentes[0].monedaProyecto && vigentes[0].monedaProyecto !== cot.moneda) {
throw new Error('El proyecto ya tiene pagos en ' + vigentes[0].monedaProyecto + ': la cotización aceptada debe ser en ' +
vigentes[0].monedaProyecto + '.');
}
// 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);
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 = 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,
t(VENDEDOR_.email), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm'), '', '',
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')];
if (c) d.hojas.contratos.getRange(c.fila, 1, 1, fila.length).setValues([fila]);
else d.hojas.contratos.appendRow(fila);
escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, cot.total);
bitacora_(libro, c ? 'Cambiar cotización aceptada' : 'Marcar cotización aceptada', proyecto.folio,
cot.folio + ' · ' + MARCAS[cot.marca] + ' · total ' + formatoMoneda(cot.total) + ' ' + cot.moneda +
(c ? ' (antes ' + c.cotizacion + ')' : ''));
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/**
* 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);
const d = datosCobranza_(libro, proyecto.folio);
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') {
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, 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);
});
}
/** Contrato firmado (fecha AAAA-MM-DD): fija la aceptada, el monto contratado y el esquema. */
function firmarContrato(proyectoFolio, fecha) {
exigirCobranza_();
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
const fechaFirma = validarFechaIso(fecha, hoyIso_(), 'La fecha de firma');
const d = datosCobranza_(libro, proyecto.folio);
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('El contrato de ' + proyecto.folio + ' ya está firmado.');
if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado.');
const etapas = validarEsquema(d.etapas);
// Tras un desbloqueo puede haber pagos: el nuevo monto no puede quedar por debajo de lo pagado
const r = resumenCobranza(c, etapas, d.pagos);
if (r.saldo < -TOLERANCIA_PAGO) {
throw new Error('Lo pagado (' + formatoMoneda(r.pagado) + ' ' + c.moneda + ') supera el monto contratado (' +
formatoMoneda(r.contratado) + ').');
}
escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, c.total);
d.hojas.contratos.getRange(c.fila, 8).setValue(ESTADO_FIRMADO);
d.hojas.contratos.getRange(c.fila, 11, 1, 3).setValues([[textoFecha_(fechaFirma), textoCelda(VENDEDOR_.email),
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]);
bitacora_(libro, 'Contrato firmado', proyecto.folio, 'Fecha de firma ' + fechaFirma + ' · ' + c.cotizacion + ' · ' +
formatoMoneda(c.total) + ' ' + c.moneda + ' · ' + etapas.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · '));
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/** Desbloqueo por error: solo ADMIN_DESBLOQUEO, con motivo. El contrato vuelve a "Aceptada"; los pagos se conservan. */
function desbloquearContrato(proyectoFolio, motivo) {
exigirCobranza_();
if (!esAdminDesbloqueo_()) throw errorPermiso_('Solo el administrador puede desbloquear un contrato firmado.');
const m = String(motivo || '').trim();
if (m.length < 5) throw new Error('Escribe el motivo del desbloqueo.');
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('El contrato de ' + proyecto.folio + ' no está firmado.');
d.hojas.contratos.getRange(c.fila, 8).setValue(ESTADO_ACEPTADA);
d.hojas.contratos.getRange(c.fila, 11, 1, 3).setValues([['', '',
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]);
bitacora_(libro, 'Desbloquear contrato', proyecto.folio,
'Estaba firmado el ' + c.fechaFirma + ' por ' + c.firmadoPor + ' · ' + c.cotizacion, m);
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/**
* Registra un pago con su comprobante (obligatorio). p = {proyectoFolio, fecha, monto, monedaPago, tipoCambio, metodo,
* referencia, etapaOrden, comprobante: {datos (base64)}, idSolicitud}. Solo con contrato firmado; si el pago excede su
* etapa, el excedente pasa a la siguiente; si excede el saldo del proyecto (más la tolerancia), se rechaza.
*/
function registrarPago(p) {
exigirCobranza_();
const d0 = p || {};
let bytes;
try {
bytes = d0.comprobante && d0.comprobante.datos ? Utilities.base64Decode(String(d0.comprobante.datos)) : null;
} catch (e) {
throw new Error('No se pudo leer el comprobante. Vuelve a adjuntarlo.');
}
const tipo = validarComprobante(bytes);
const libro = libroCobranza_();
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
const cache = CacheService.getScriptCache();
const claveCache = idSolicitud ? 'PAG_' + idSolicitud : '';
return conCandado_(() => {
if (claveCache) {
const previo = cache.get(claveCache);
if (previo) return JSON.parse(previo);
}
const proyecto = proyectoParaCobranza_(d0.proyectoFolio);
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('Solo se registran pagos en proyectos con contrato firmado.');
const pago = normalizarPago(d0, { moneda: c.moneda, etapas: d.etapas.length, hoy: hoyIso_() });
const resumen = resumenCobranza(c, d.etapas, d.pagos);
if (aCentavos(pago.montoAplicado) > aCentavos(resumen.saldo) + aCentavos(TOLERANCIA_PAGO)) {
throw new Error('El pago (' + formatoMoneda(pago.montoAplicado) + ' ' + c.moneda + ') excede el saldo del proyecto (' +
formatoMoneda(Math.max(resumen.saldo, 0)) + ' ' + c.moneda + ').');
}
const reparto = repartirPago(resumen.etapas, pago.etapaOrden - 1, pago.montoAplicado);
if (reparto.sobranteCts) reparto.aplicacion.push({ orden: pago.etapaOrden, centavos: reparto.sobranteCts });
const aplicacion = reparto.aplicacion.map(a => ({ etapa: d.etapas[a.orden - 1].etapa, orden: a.orden, monto: a.centavos / 100 }));
const hojaPagos = d.hojas.pagos;
const folio = siguienteFolio_(PREFIJO_PAGO, hojaPagos, 'CONSECUTIVO_PAG_');
const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4);
const carpeta = privado_(subcarpeta_(privado_(subcarpeta_(carpetaCobranza_(), anio)), proyecto.folio));
const archivo = privado_(carpeta.createFile(Utilities.newBlob(bytes, tipo.mime,
nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext))));
const t = textoCelda;
hojaPagos.appendRow([
folio, textoFecha_(pago.fecha), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), proyecto.folio,
pago.monto, pago.monedaPago, pago.tipoCambio, pago.montoAplicado, c.moneda, pago.metodo, t(pago.referencia),
pago.etapaOrden, t(d.etapas[pago.etapaOrden - 1].etapa), JSON.stringify(aplicacion), archivo.getId(), archivo.getUrl(),
t(VENDEDOR_.email), t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ')), 'Vigente', '', '', '',
]);
bitacora_(libro, 'Registrar pago', proyecto.folio, folio + ' · ' + formatoMoneda(pago.monto) + ' ' + pago.monedaPago +
(pago.tipoCambio ? ' · TC ' + pago.tipoCambio + ' = ' + formatoMoneda(pago.montoAplicado) + ' ' + c.moneda : '') +
' · ' + pago.metodo + ' · ' + aplicacion.map(a => a.etapa + ' ' + formatoMoneda(a.monto)).join(' + '));
SpreadsheetApp.flush();
const resultado = { folio: folio, url: archivo.getUrl(), vista: vistaCobranza_(libro, proyecto) };
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
return resultado;
});
}
/** Cancela un pago (no se borra): rol de cobranza, con motivo; queda en la Bitácora. */
function cancelarPago(folioPago, motivo) {
exigirCobranza_();
const m = String(motivo || '').trim();
if (m.length < 5) throw new Error('Escribe el motivo de la cancelación.');
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
const libro = libroCobranza_();
return conCandado_(() => {
const folio = String(folioPago || '').trim().toUpperCase();
const hoja = hojaCobranza_(libro, 'Pagos');
let pago = null;
filasCobranza_(hoja, 'Pagos').forEach((r, i) => { if (String(r[0]).toUpperCase() === folio) pago = pagoDesdeFila_(r, i + 2); });
if (!pago) throw new Error('No existe el pago ' + folio + '.');
if (pago.estado !== 'Vigente') throw new Error('El pago ' + folio + ' ya está cancelado.');
hoja.getRange(pago.fila, 19, 1, 4).setValues([['Cancelado', textoCelda(VENDEDOR_.email),
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
bitacora_(libro, 'Cancelar pago', pago.proyectoFolio, folio + ' · ' + formatoMoneda(pago.monto) + ' ' + pago.monedaPago, m);
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyectoParaCobranza_(pago.proyectoFolio));
});
}
/**
* Correr desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad) y su libro, y deja los dos en
* "Restringido" (también si ya existían). Sirve para autorizar permisos antes de publicar (§6.8). No comparte nada.
*/
function prepararCobranza() {
const libro = libroCobranza_();
const carpeta = carpetaCobranza_(); // primero la carpeta: el libro hereda su acceso y Drive no deja quitarlo en el hijo
const archivoLibro = privado_(DriveApp.getFileById(libro.getId()));
Logger.log('Carpeta de cobranza: ' + carpeta.getUrl() + ' (restringida)');
Logger.log('Libro de cobranza: ' + archivoLibro.getUrl() + ' (restringido)');
Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)'));
}
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
function redondear(n) {
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
}
function normalizarCotizacion(p, ivaTasa) {
const c = (p && p.cliente) || {};
const cliente = {
nombre: String(c.nombre || '').trim(),
empresa: String(c.empresa || '').trim(),
rfc: String(c.rfc || '').trim().toUpperCase(),
email: String(c.email || '').trim(),
telefono: String(c.telefono || '').trim(),
direccion: String(c.direccion || '').trim(),
};
if (!cliente.nombre) throw new Error('El nombre del cliente es obligatorio.');
const items = ((p && p.items) || [])
.map(it => ({
clave: String(it.clave || '').trim(),
descripcion: String(it.descripcion || '').trim(),
unidad: String(it.unidad || 'PZA').trim(),
cantidad: Number(it.cantidad) || 0,
precio: redondear(it.precio),
}))
.filter(it => it.descripcion && it.cantidad > 0);
if (!items.length) throw new Error('Agrega al menos un concepto con cantidad mayor a 0.');
if (items.some(it => it.precio < 0)) throw new Error('Hay precios negativos.');
items.forEach(it => { it.importe = redondear(it.cantidad * it.precio); });
const subtotal = redondear(items.reduce((s, it) => s + it.importe, 0));
const iva = redondear(subtotal * ivaTasa);
const total = redondear(subtotal + iva);
const datos = {
cliente: cliente, items: items, subtotal: subtotal, iva: iva, total: total, ivaTasa: ivaTasa,
notas: String((p && p.notas) || '').trim(),
marca: p && p.marca === 'energy' ? 'energy' : 'helgen',
moneda: monedaDeCotizacion(p && p.moneda),
};
// Tipo de cambio (pesos por dólar) con que la interfaz convirtió los precios del catálogo; solo en USD y solo si es válido
datos.tipoCambio = datos.moneda === 'USD' ? (tipoCambioDesdeValores(Number(p && p.tipoCambio), '').valor || '') : '';
return datos;
}
function formatearFolio(prefijo, anio, n) {
return prefijo + '-' + anio + '-' + String(n).padStart(4, '0');
}
/** Mayor consecutivo ya usado para ese prefijo y año, p. ej. ['HLG-2026-0007', ...] -> 7. */
function maxConsecutivo(folios, prefijo, anio) {
const base = String(prefijo) + '-' + String(anio) + '-';
return (folios || []).reduce((max, f) => {
const s = String(f || '').trim();
if (s.indexOf(base) !== 0) return max;
const n = Number(s.slice(base.length));
return Number.isInteger(n) && n > max ? n : max;
}, 0);
}
/**
* Agrupa las filas de Cotizaciones en clientes únicos (mismo nombre + empresa, sin importar mayúsculas,
* acentos ni espacios). La cotización más reciente manda; los datos vacíos no borran los anteriores.
* Columnas: 0 Folio, 1 Fecha, 2 Cliente, 3 Empresa, 4 RFC, 5 Correo, 6 Teléfono, 15 Dirección.
*/
function clientesDesdeFilas(filas) {
const normal = textoNormal;
const mapa = {};
(filas || []).forEach((r, i) => {
const nombre = String(r[2] || '').trim();
if (!nombre) return;
const llave = normal(nombre) + '|' + normal(r[3]);
const c = mapa[llave] || (mapa[llave] = { nombre: '', empresa: '', rfc: '', email: '', telefono: '', direccion: '',
cotizaciones: 0, ultimaFecha: '', orden: 0 });
const campos = { nombre: nombre, empresa: r[3], rfc: r[4], email: r[5], telefono: r[6], direccion: r[15] };
Object.keys(campos).forEach(k => { const v = String(campos[k] || '').trim(); if (v) c[k] = v; });
c.cotizaciones++;
c.ultimaFecha = String(r[1] || '') || c.ultimaFecha;
c.orden = i;
});
const lista = Object.keys(mapa).map(k => mapa[k]).sort((a, b) => b.orden - a.orden);
// Etiqueta única para elegir en la lista: si hay dos clientes con el mismo nombre, se distingue por empresa
const porNombre = {};
lista.forEach(c => { const n = normal(c.nombre); porNombre[n] = (porNombre[n] || 0) + 1; });
lista.forEach(c => {
c.etiqueta = porNombre[normal(c.nombre)] > 1 && c.empresa ? c.nombre + ' — ' + c.empresa : c.nombre;
c.id = idCliente(c);
delete c.orden;
});
return lista;
}
/**
* Correo (minúsculas) → "Nombre Apellido", tomado de las filas de Cotizaciones que sí tienen vendedor_nombre /
* vendedor_apellido (columnas Q:S). Filas en orden de la hoja: si un correo aparece con nombres distintos, gana la más reciente.
*/
function nombresDeVendedores(filas) {
const mapa = {};
(filas || []).forEach(r => {
const correo = String(r[16] || '').trim().toLowerCase();
const nombre = [r[17], r[18]].map(x => String(x || '').trim()).filter(Boolean).join(' ');
if (correo && nombre) mapa[correo] = nombre;
});
return mapa;
}
/**
* Vendedor para el Historial: nombre y apellido de la fila; si no tiene (filas anteriores a la sesión con Google),
* el nombre que ese correo tiene en otras filas; si no se conoce, el correo (vendedor_email o "Elaboró").
*/
function vendedorDeFila(r, nombres) {
const propio = [r[17], r[18]].map(x => String(x || '').trim()).filter(Boolean).join(' ');
if (propio) return propio;
const correo = String(r[16] || r[10] || '').trim();
return (nombres || {})[correo.toLowerCase()] || correo;
}
/** Texto sin acentos, en minúsculas y con espacios simples (para comparar nombres). */
function textoNormal(s) {
return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/\s+/g, ' ').trim();
}
// RFC genéricos del SAT (público en general / extranjeros): los comparten muchos clientes, no identifican a nadie
const RFC_GENERICOS = ['XAXX010101000', 'XEXX010101000'];
/**
* Identificador de un cliente (no hay hoja de clientes: salen de Cotizaciones). El RFC si es válido y no genérico;
* si no, "SIN-RFC:" + nombre|empresa normalizados (la misma llave con que clientesDesdeFilas agrupa).
*/
function idCliente(c) {
const rfc = String((c && c.rfc) || '').replace(/[\s-]/g, '').toUpperCase();
if (/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/.test(rfc) && RFC_GENERICOS.indexOf(rfc) === -1) return rfc;
return 'SIN-RFC:' + textoNormal(c && c.nombre) + '|' + textoNormal(c && c.empresa);
}
/** Valida y limpia un proyecto nuevo. El cliente se guarda como "nombre — empresa" más su cliente_id. */
function normalizarProyecto(p) {
const nombre = String((p && p.nombre) || '').trim().replace(/\s+/g, ' ');
const ubicacion = String((p && p.ubicacion) || '').trim();
const c = (p && p.cliente) || {};
const cli = { nombre: String(c.nombre || '').trim(), empresa: String(c.empresa || '').trim(),
rfc: String(c.rfc || '').trim().toUpperCase() };
if (!nombre) throw new Error('El nombre del proyecto es obligatorio.');
if (nombre.length > 80) throw new Error('El nombre del proyecto debe tener máximo 80 caracteres.');
if (ubicacion.length > 200) throw new Error('La ubicación debe tener máximo 200 caracteres.');
if (!cli.nombre) throw new Error('Elige o escribe el cliente del proyecto (en "Datos del cliente").');
return {
nombre: nombre, ubicacion: ubicacion,
cliente: cli.empresa && textoNormal(cli.empresa) !== textoNormal(cli.nombre) ? cli.nombre + ' — ' + cli.empresa : cli.nombre,
clienteId: idCliente(cli),
};
}
/** "PRY-2026-0001 — Cliente — Nombre", sin caracteres que Drive o Windows no acepten, máximo 150 caracteres. */
function nombreCarpetaProyecto(folio, cliente, nombre) {
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
const cli = limpio(cliente).slice(0, 60);
return [limpio(folio), cli, limpio(nombre)].filter(Boolean).join(' — ').slice(0, 150).trim();
}
/** Filas de la pestaña Proyectos (sin encabezado) → objetos. Columnas en ENCABEZADOS_PROYECTOS. */
function proyectosDesdeFilas(filas) {
return (filas || []).filter(r => String(r[0] || '').trim()).map(r => ({
folio: String(r[0]).trim(),
fecha: r[1] instanceof Date
? [r[1].getDate(), r[1].getMonth() + 1].map(x => String(x).padStart(2, '0')).join('/') + '/' + r[1].getFullYear()
: String(r[1] || ''),
nombre: String(r[2] || ''), cliente: String(r[3] || ''), clienteId: String(r[4] || ''),
ubicacion: String(r[5] || ''),
estado: String(r[6] || '').trim() || 'Activo',
carpetaId: String(r[7] || ''),
}));
}
/** Busca por folio, cliente o nombre (sin acentos ni mayúsculas); más recientes primero; máximo n. */
function filtrarProyectos(lista, busqueda, incluirInactivos, n) {
const q = textoNormal(busqueda);
return (lista || []).slice().reverse()
.filter(p => incluirInactivos || p.estado === 'Activo')
.filter(p => !q || textoNormal(p.folio + ' ' + p.cliente + ' ' + p.nombre).includes(q))
.slice(0, n || 50);
}
// ---- Monedas (MXN / USD) ----
// Cada cotización lleva su moneda (columna U); nunca se suman importes de monedas distintas.
const MONEDAS = ['MXN', 'USD'];
/** Moneda de una cotización nueva: vacía = MXN (como antes); otra que no sea MXN/USD es error. */
function monedaDeCotizacion(m) {
const s = String(m == null ? '' : m).trim().toUpperCase();
if (!s) return 'MXN';
if (MONEDAS.indexOf(s) === -1) throw new Error('Moneda no válida: ' + s + '. Usa MXN o USD.');
return s;
}
/**
* Tipo de cambio en uso (pesos por dólar): el manual si es un número válido; si no, el automático (Google Finance).
* Fuera de 1–1000 se descarta (p. ej. "#N/A" o "Cargando…" de GOOGLEFINANCE). Devuelve {valor, fuente}.
*/
function tipoCambioDesdeValores(automatico, manual) {
const valido = v => typeof v === 'number' && isFinite(v) && v >= 1 && v <= 1000;
if (valido(manual)) return { valor: Math.round(manual * 10000) / 10000, fuente: 'manual' };
if (valido(automatico)) return { valor: Math.round(automatico * 10000) / 10000, fuente: 'Google Finance' };
return { valor: null, fuente: '' };
}
/** Precio en pesos -> dólares con el tipo de cambio (2 decimales); null si no hay tipo de cambio. */
function precioEnDolares(precioMxn, tipoCambio) {
return tipoCambio > 0 ? redondear(Number(precioMxn) / tipoCambio) : null;
}
// ---- Cobranza: esquema de pagos, reparto, comprobantes y roles ----
const ESQUEMA_POR_OMISION = [['Anticipo', 60], ['Instalación', 30], ['Encendido del sistema', 10]];
const MAX_ETAPAS = 10;
const METODOS_PAGO = ['Transferencia', 'Efectivo', 'Cheque'];
const TOLERANCIA_PAGO = 1.00; // en la moneda del proyecto (diferencias de redondeo)
const MAX_COMPROBANTE = 10 * 1024 * 1024; // 10 MB
/** Etapas por omisión: Helgen Energy 60/30/10 (fijo); Helgen, las mismas como sugerencia editable. */
function esquemaPorOmision() {
return ESQUEMA_POR_OMISION.map(([etapa, porcentaje]) => ({ etapa: etapa, porcentaje: porcentaje }));
}
/** Pesos/dólares -> centavos enteros (evita errores de punto flotante al sumar). */
function aCentavos(n) {
return Math.round(redondear(n) * 100);
}
/**
* Valida un esquema [{etapa, porcentaje}]: de 1 a MAX_ETAPAS etapas, nombres únicos (máx. 40 caracteres),
* porcentajes mayores a 0 con 2 decimales y suma exacta de 100%. Devuelve el esquema limpio.
*/
function validarEsquema(etapas) {
if (!Array.isArray(etapas) || !etapas.length) throw new Error('El esquema de pagos necesita al menos una etapa.');
if (etapas.length > MAX_ETAPAS) throw new Error('El esquema de pagos admite máximo ' + MAX_ETAPAS + ' etapas.');
const vistos = {};
const limpio = etapas.map((e, i) => {
const etapa = String((e && e.etapa) || '').trim().replace(/\s+/g, ' ');
const txt = e && e.porcentaje;
const porcentaje = Number(txt);
if (!etapa) throw new Error('La etapa ' + (i + 1) + ' no tiene nombre.');
if (etapa.length > 40) throw new Error('El nombre de la etapa "' + etapa.slice(0, 20) + '…" debe tener máximo 40 caracteres.');
const llave = textoNormal(etapa);
if (vistos[llave]) throw new Error('La etapa "' + etapa + '" está repetida.');
vistos[llave] = true;
if (txt === '' || txt == null || !isFinite(porcentaje) || porcentaje <= 0) {
throw new Error('El porcentaje de "' + etapa + '" debe ser mayor a 0.');
}
if (Math.abs(redondear(porcentaje) - porcentaje) > 1e-9) {
throw new Error('El porcentaje de "' + etapa + '" admite máximo 2 decimales.');
}
return { etapa: etapa, porcentaje: redondear(porcentaje) };
});
const suma = limpio.reduce((s, e) => s + Math.round(e.porcentaje * 100), 0);
if (suma !== 10000) {
throw new Error('Los porcentajes deben sumar 100% (suman ' + redondear(suma / 100) + '%).');
}
return limpio;
}
/** Monto de cada etapa = total × %, redondeado; la última etapa se queda con la diferencia de centavos. */
function montosDeEsquema(etapas, total) {
const totalCts = aCentavos(total);
let acumulado = 0;
return etapas.map((e, i) => {
const cts = i === etapas.length - 1 ? totalCts - acumulado : Math.round(totalCts * e.porcentaje / 100);
acumulado += cts;
return { etapa: e.etapa, porcentaje: e.porcentaje, monto: cts / 100 };
});
}
/**
* Convierte el monto de un pago a la moneda del proyecto. Si las monedas son distintas, el tipo de cambio
* (pesos por dólar) es obligatorio: MXN -> USD divide, USD -> MXN multiplica.
*/
function convertirMoneda(monto, monedaPago, monedaProyecto, tipoCambio) {
if (monedaPago === monedaProyecto) return redondear(monto);
const tc = Number(tipoCambio);
if (!isFinite(tc) || tc <= 0) throw new Error('Captura el tipo de cambio del día (pesos por dólar).');
if (monedaPago === 'MXN' && monedaProyecto === 'USD') return redondear(monto / tc);
if (monedaPago === 'USD' && monedaProyecto === 'MXN') return redondear(monto * tc);
throw new Error('Moneda no válida.');
}
/**
* Reparte un monto entre las etapas: primero la elegida; el excedente pasa a la siguiente con saldo y, al llegar
* a la última, a las anteriores en orden. etapas = [{monto, pagado}]. Devuelve {aplicacion: [{orden, centavos}],
* sobranteCts} sin modificar las etapas.
*/
function repartirPago(etapas, indice, monto) {
const n = etapas.length;
let restante = aCentavos(monto);
const aplicacion = [];
for (let k = 0; k < n && restante > 0; k++) {
const i = (indice + k) % n;
const saldo = aCentavos(etapas[i].monto) - aCentavos(etapas[i].pagado || 0);
if (saldo <= 0) continue;
const toma = Math.min(saldo, restante);
aplicacion.push({ orden: i + 1, centavos: toma });
restante -= toma;
}
return { aplicacion: aplicacion, sobranteCts: restante };
}
/**
* Resumen de cobranza de un proyecto. contrato = {total, subtotal, moneda}; etapas = [{etapa, porcentaje}];
* pagos = [{estado, etapaOrden, montoAplicado}] en orden de registro (solo cuentan los Vigentes, se vuelven
* a repartir en ese orden). Lo que no cabe en ninguna etapa (dentro de la tolerancia) se suma a la etapa elegida.
*/
function resumenCobranza(contrato, etapas, pagos) {
const lista = montosDeEsquema(etapas, contrato.total).map(e => Object.assign(e, { pagado: 0 }));
let pagadoCts = 0;
(pagos || []).filter(p => p.estado === 'Vigente').forEach(p => {
const indice = Math.min(Math.max((Number(p.etapaOrden) || 1) - 1, 0), lista.length - 1);
const r = repartirPago(lista, indice, p.montoAplicado);
r.aplicacion.forEach(a => { lista[a.orden - 1].pagado = (aCentavos(lista[a.orden - 1].pagado) + a.centavos) / 100; });
if (r.sobranteCts) lista[indice].pagado = (aCentavos(lista[indice].pagado) + r.sobranteCts) / 100;
pagadoCts += aCentavos(p.montoAplicado);
});
lista.forEach(e => {
e.saldo = (aCentavos(e.monto) - aCentavos(e.pagado)) / 100;
e.avance = e.monto > 0 ? Math.min(100, Math.round(e.pagado / e.monto * 1000) / 10) : 0;
});
const contratadoCts = aCentavos(contrato.total);
return {
moneda: contrato.moneda, contratado: contratadoCts / 100, subtotal: redondear(contrato.subtotal),
pagado: pagadoCts / 100, saldo: (contratadoCts - pagadoCts) / 100,
avance: contratadoCts > 0 ? Math.min(100, Math.round(pagadoCts / contratadoCts * 1000) / 10) : 0,
etapas: lista,
};
}
/** Fecha "AAAA-MM-DD" válida, no futura (hoy = "AAAA-MM-DD") y no anterior a 2020. */
function validarFechaIso(s, hoy, campo) {
const v = String(s || '').trim();
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const nombre = campo || 'La fecha';
if (!m) throw new Error(nombre + ' es obligatoria (AAAA-MM-DD).');
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
if (d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) {
throw new Error(nombre + ' no es válida.');
}
if (v < '2020-01-01') throw new Error(nombre + ' no es válida.');
if (hoy && v > hoy) throw new Error(nombre + ' no puede ser futura.');
return v;
}
/**
* Valida y limpia un pago. proyecto = {moneda, etapas (número de etapas), hoy "AAAA-MM-DD"}.
* Devuelve {fecha, monto, monedaPago, tipoCambio, montoAplicado, metodo, referencia, etapaOrden}.
*/
function normalizarPago(p, proyecto) {
const d = p || {};
const fecha = validarFechaIso(d.fecha, proyecto.hoy, 'La fecha del pago');
const montoTxt = d.monto, monto = Number(montoTxt);
if (montoTxt === '' || montoTxt == null || !isFinite(monto) || monto <= 0) throw new Error('El monto del pago debe ser mayor a 0.');
if (Math.abs(redondear(monto) - monto) > 1e-9) throw new Error('El monto admite máximo 2 decimales.');
if (monto > 1e10) throw new Error('El monto del pago no es válido.');
const monedaPago = String(d.monedaPago || proyecto.moneda).trim().toUpperCase();
if (MONEDAS.indexOf(monedaPago) === -1) throw new Error('Moneda del pago no válida. Usa MXN o USD.');
const metodo = METODOS_PAGO.find(m => textoNormal(m) === textoNormal(d.metodo));
if (!metodo) throw new Error('Elige el método de pago: ' + METODOS_PAGO.join(', ') + '.');
const referencia = String(d.referencia || '').trim().replace(/\s+/g, ' ');
if (!referencia && metodo !== 'Efectivo') {
throw new Error('Captura la referencia (' + (metodo === 'Cheque' ? 'número de cheque' : 'clave de rastreo o folio') + ').');
}
if (referencia.length > 100) throw new Error('La referencia debe tener máximo 100 caracteres.');
const etapaOrden = Number(d.etapaOrden);
if (!Number.isInteger(etapaOrden) || etapaOrden < 1 || etapaOrden > proyecto.etapas) throw new Error('Elige la etapa del pago.');
let tipoCambio = '';
if (monedaPago !== proyecto.moneda) {
tipoCambio = Number(d.tipoCambio);
if (d.tipoCambio === '' || d.tipoCambio == null || !isFinite(tipoCambio) || tipoCambio <= 0 || tipoCambio > 1000) {
throw new Error('El proyecto es en ' + proyecto.moneda + ' y el pago en ' + monedaPago +
': captura el tipo de cambio del día (pesos por dólar).');
}
tipoCambio = Math.round(tipoCambio * 10000) / 10000;
}
return { fecha: fecha, monto: redondear(monto), monedaPago: monedaPago, tipoCambio: tipoCambio,
montoAplicado: convertirMoneda(monto, monedaPago, proyecto.moneda, tipoCambio),
metodo: metodo, referencia: referencia, etapaOrden: etapaOrden };
}
/**
* Tipo real de un comprobante por sus primeros bytes (no por la extensión). bytes = arreglo de enteros; en Apps
* Script vienen con signo (-128…127). Acepta PDF, JPG, PNG y HEIC; máximo MAX_COMPROBANTE. Devuelve {mime, ext}.
*/
function validarComprobante(bytes) {
if (!bytes || !bytes.length) throw new Error('Adjunta el comprobante de pago (PDF o imagen).');
if (bytes.length > MAX_COMPROBANTE) throw new Error('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.');
const b = i => (bytes[i] || 0) & 0xFF;
const txt = (desde, n) => String.fromCharCode.apply(null, Array.from({ length: n }, (_, i) => b(desde + i)));
if (txt(0, 5) === '%PDF-') return { mime: 'application/pdf', ext: 'pdf' };
if (b(0) === 0xFF && b(1) === 0xD8 && b(2) === 0xFF) return { mime: 'image/jpeg', ext: 'jpg' };
if (b(0) === 0x89 && txt(1, 3) === 'PNG') return { mime: 'image/png', ext: 'png' };
if (txt(4, 4) === 'ftyp' && ['heic', 'heix', 'hevc', 'heim', 'heis', 'mif1', 'msf1'].indexOf(txt(8, 4)) !== -1) {
return { mime: 'image/heic', ext: 'heic' };
}
throw new Error('El comprobante debe ser PDF o imagen (JPG, PNG o HEIC).');
}
/** Correos de una propiedad ("a@helgen.tech, b@helgen.tech"), en minúsculas; solo del dominio. */
function correosDeLista(texto, dominio) {
return String(texto || '').split(/[\s,;]+/).map(s => s.trim().toLowerCase())
.filter(s => s && s.endsWith('@' + dominio) && s.indexOf('@') > 0)
.filter((s, i, a) => a.indexOf(s) === i);
}
/** Nombre del archivo del comprobante: "PAG-2026-0001 — <método> <referencia>.pdf", sin caracteres raros. */
function nombreComprobante(folio, metodo, referencia, ext) {
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
return (limpio(folio) + ' — ' + [limpio(metodo), limpio(referencia).slice(0, 60)].filter(Boolean).join(' ')).slice(0, 140) +
'.' + ext;
}
// ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ----
const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' +
'tipo marca modelo incluye incluido kw w v a mm cm m mts pza pzas').split(' ');
/** Texto a palabras base: sin acentos, minúsculas, sin plurales simples, sin palabras vacías ni números. */
function palabrasSat(texto) {
return String(texto || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
.replace(/[^a-z0-9ñ]+/g, ' ').split(' ')
.filter(w => w.length > 1 && !/^\d+$/.test(w) && PALABRAS_VACIAS_SAT.indexOf(w) === -1)
.map(w => w.length > 4 && /[^aeiou]es$/.test(w) ? w.slice(0, -2) : (w.length > 3 && /s$/.test(w) ? w.slice(0, -1) : w))
.map(w => w.length > 5 && /[aeo]$/.test(w) ? w.slice(0, -1) : w); // eléctrica/eléctrico -> electric
}
/**
* Términos de Helgen -> palabras que usa el catálogo del SAT (ya en forma base de palabrasSat).
* Ampliar aquí cuando una búsqueda no encuentre lo esperado.
*/
const SINONIMOS_SAT = {
panel: ['fotovoltaic', 'generador', 'kit'], solar: ['fotovoltaic', 'energia'], fotovoltaic: ['solar'],
auto: ['vehicul', 'automovil'], carro: ['vehicul'], coche: ['vehicul'], automovil: ['vehicul'],
cargador: ['carga', 'cargado'], gps: ['localizacion', 'navegacion', 'posicionamient', 'satelital'],
rastreo: ['localizacion', 'navegacion', 'seguimient'], monitoreo: ['vigilancia', 'seguimient'],
instalacion: ['montaje'], reparacion: ['mantenimient'], mantenimient: ['reparacion'],
patio: ['exterior', 'terreno'],
};
/** Palabras que indican que se busca un servicio (claves del SAT de los segmentos 70 a 95). */
const PALABRAS_SERVICIO_SAT = ['servicio', 'instalacion', 'mantenimient', 'reparacion', 'limpieza', 'mano', 'obra',
'renta', 'arrendamient', 'consultoria', 'capacitacion', 'monitoreo', 'rastreo', 'asesoria', 'diseño', 'proyecto'];
/** Sinónimos con la llave en forma base de palabrasSat ("rastreo" -> "rastre"), como llegan las consultas. */
function sinonimosSat_() {
const r = {};
Object.keys(SINONIMOS_SAT).forEach(k => {
const b = palabrasSat(k)[0] || k;
r[b] = (r[b] || []).concat(SINONIMOS_SAT[k]);
});
return r;
}
/** true si alguna palabra (ya en forma base) indica un servicio. */
function esServicioSat_(q) {
const servicio = PALABRAS_SERVICIO_SAT.map(w => palabrasSat(w)[0] || w);
return q.some(w => servicio.indexOf(w) !== -1);
}
/**
* Claves del SAT que más se parecen a la descripción. filas = [[clave, descripción], ...].
* Puntaje: palabras de la consulta presentes en la descripción, pesadas por lo poco comunes que son
* (una palabra rara como "fotovoltaica" vale más que "servicio"), y prefiere descripciones cortas y que
* cubren toda la consulta. Devuelve hasta n resultados [{clave, descripcion, puntaje}].
*/
function buscarClavesSat(consulta, filas, n) {
const q = palabrasSat(consulta).filter((w, i, a) => a.indexOf(w) === i);
if (!q.length) return [];
// Coincide si la palabra del SAT empieza con la buscada ("cargador" ~ "cargadores"); al revés solo con
// palabras largas, para no confundir "foto" con "fotovoltaica"
const igual = (w, p) => p === w || (w.length >= 4 && p.indexOf(w) === 0) || (p.length >= 6 && w.indexOf(p) === 0);
const sinonimos = sinonimosSat_();
const opciones = q.map(w => [w].concat(sinonimos[w] || []));
const esServicio = esServicioSat_(q);
const candidatos = [];
const df = q.map(() => 0);
(filas || []).forEach(r => {
const clave = String(r[0] || '').trim();
if (!clave || clave === '01010101') return; // "No existe en el catálogo"
const palabras = palabrasSat(r[1]);
const hits = opciones.map(ops => palabras.some(p => ops.some(w => igual(w, p))));
hits.forEach((h, i) => { if (h) df[i]++; });
if (hits.some(Boolean)) candidatos.push({ clave: clave, descripcion: String(r[1]), hits: hits, largo: palabras.length });
});
const total = (filas || []).length || 1;
const peso = df.map(d => Math.log(1 + total / (1 + d)));
const pesoTotal = peso.reduce((a, b) => a + b, 0);
return candidatos.map(c => {
const cubre = c.hits.reduce((s, h, i) => s + (h ? peso[i] : 0), 0) / pesoTotal; // 0..1
const todas = c.hits.every(Boolean) ? 0.15 : 0;
const extra = Math.max(0, c.largo - c.hits.filter(Boolean).length); // palabras que sobran
const segmento = Number(c.clave.slice(0, 2));
const tipo = esServicio ? (segmento >= 70 ? 1.25 : 0.85) : (segmento >= 70 ? 0.9 : 1); // servicio vs. producto
return { clave: c.clave, descripcion: c.descripcion, puntaje: redondear(tipo * (cubre + todas) / (1 + 0.08 * extra)) };
})
.sort((a, b) => b.puntaje - a.puntaje || a.descripcion.length - b.descripcion.length)
.slice(0, n || 5);
}
/**
* Candidatos para que la IA elija (hasta n, sin repetir). Para servicios, además de la búsqueda completa toma
* los mejores servicios (segmentos 70 a 95) por cada palabra, porque el SAT casi nunca repite la frase entera
* ("Limpieza de patios" -> "Mantenimiento de terrenos exteriores").
*/
function candidatosSat(consulta, filas, n) {
n = n || 40;
const lista = [], vistos = {};
const agregar = arr => arr.forEach(o => {
if (!vistos[o.clave] && lista.length < n) { vistos[o.clave] = true; lista.push(o); }
});
const q = palabrasSat(consulta).filter((w, i, a) => a.indexOf(w) === i);
if (!esServicioSat_(q)) return buscarClavesSat(consulta, filas, n);
agregar(buscarClavesSat(consulta, filas, Math.ceil(n / 2)));
const servicios = (filas || []).filter(r => Number(String(r[0]).slice(0, 2)) >= 70);
const palabras = q.filter(w => ['servici', 'mano', 'obra'].indexOf(w) === -1);
const porPalabra = Math.max(3, Math.ceil(n / 2 / Math.max(1, palabras.length)));
palabras.forEach(w => agregar(buscarClavesSat(w, servicios, porPalabra)));
agregar(buscarClavesSat(consulta, filas, n)); // rellena si faltan
return lista;
}
const MODELO_IA_SAT = 'claude-haiku-5-5';
const INSTRUCCIONES_IA_SAT =
'Ayudas a Helgen, empresa mexicana (energía solar, cargadores para autos eléctricos, rastreo GPS, instalación ' +
'y mantenimiento, entre otros), a elegir la clave de producto o servicio del SAT (c_ClaveProdServ, CFDI 4.0) ' +
'para un concepto de cotización. Elige solo de la lista de candidatos que se te da, hasta 5, de la más a la ' +
'menos adecuada. Si el concepto es un servicio (instalación, mantenimiento, limpieza, renta, monitoreo, etc.) ' +
'prefiere claves de servicios (empiezan con 70 a 95); si es un bien, claves de productos. Prefiere la clave ' +
'más específica que describa bien el concepto. Para cada una da una razón breve (máximo 15 palabras, español ' +
'de México). Si ninguna encaja bien, devuelve las más cercanas y dilo en la razón.';
/** Cuerpo de la petición a la API de Claude (Messages) para ordenar candidatos SAT. */
function solicitudIaSat(consulta, candidatos) {
return {
model: MODELO_IA_SAT,
max_tokens: 4000,
system: INSTRUCCIONES_IA_SAT,
output_config: {
effort: 'low',
format: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
opciones: {
type: 'array',
items: {
type: 'object',
properties: { clave: { type: 'string' }, razon: { type: 'string' } },
required: ['clave', 'razon'], additionalProperties: false,
},
},
},
required: ['opciones'], additionalProperties: false,
},
},
},
messages: [{
role: 'user',
content: 'Concepto: ' + String(consulta || '').trim() + '\n\nCandidatos (clave | descripción):\n' +
candidatos.map(c => c.clave + ' | ' + c.descripcion).join('\n'),
}],
};
}
/**
* Lee la respuesta JSON de la API y devuelve [{clave, descripcion, razon, puntaje}] (máximo 5).
* Solo acepta claves que estaban entre los candidatos; lanza error si la respuesta no sirve.
*/
function leerRespuestaIaSat(cuerpo, candidatos) {
const r = typeof cuerpo === 'string' ? JSON.parse(cuerpo) : cuerpo;
if (!r || r.stop_reason !== 'end_turn') throw new Error('Respuesta incompleta de la IA (' + (r && r.stop_reason) + ').');
const texto = (r.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
const opciones = (JSON.parse(texto).opciones || []);
const porClave = {};
candidatos.forEach(c => { porClave[c.clave] = c; });
const lista = [];
opciones.forEach(o => {
const clave = String((o && o.clave) || '').replace(/\D/g, '').padStart(8, '0');
const c = porClave[clave];
if (c && lista.length < 5 && !lista.some(x => x.clave === clave)) {
lista.push({ clave: clave, descripcion: c.descripcion, razon: String(o.razon || '').trim().slice(0, 200),
puntaje: c.puntaje });
}
});
if (!lista.length) throw new Error('La IA no eligió ninguna clave de la lista.');
return lista;
}
/** Valida y limpia un producto del catálogo. */
function normalizarProducto(p) {
const clave = String((p && p.clave) || '').trim().toUpperCase();
const descripcion = String((p && p.descripcion) || '').trim();
const unidad = String((p && p.unidad) || '').trim().toUpperCase() || 'PZA';
const precioTxt = p && p.precio;
const precio = Number(precioTxt);
if (!clave) throw new Error('La clave es obligatoria.');
if (clave.length > 30) throw new Error('La clave debe tener máximo 30 caracteres.');
if (!descripcion) throw new Error('La descripción es obligatoria.');
if (precioTxt === '' || precioTxt == null || !isFinite(precio)) throw new Error('El precio unitario es obligatorio.');
if (precio < 0) throw new Error('El precio unitario no puede ser negativo.');
// SAT (opcionales): clave de 8 dígitos (se repone el cero inicial si Sheets lo quitó) y unidad
const satTxt = String((p && p.claveSat) || '').replace(/\s/g, '');
if (satTxt && !/^\d{7,8}$/.test(satTxt)) throw new Error('La clave SAT debe tener 8 dígitos.');
const unidadSat = String((p && p.unidadSat) || '').trim().toUpperCase();
if (unidadSat && !/^[A-Z0-9]{1,3}$/.test(unidadSat)) throw new Error('La unidad SAT debe ser una clave como H87, E48 o MTR.');
return { clave: clave, descripcion: descripcion, unidad: unidad, precio: redondear(precio),
claveSat: satTxt ? satTxt.padStart(8, '0') : '', unidadSat: unidadSat };
}
/** Posición de una clave en la lista (sin distinguir mayúsculas ni espacios); -1 si no está. */
function indiceDeClave(claves, clave) {
const buscada = String(clave || '').trim().toUpperCase();
if (!buscada) return -1;
return (claves || []).findIndex(c => String(c || '').trim().toUpperCase() === buscada);
}
/** Renglones de Términos y Condiciones de la marca ('helgen' | 'energy'); siempre incluye la vigencia. */
function terminosParaPdf(cfg, marca) {
const dias = Number(cfg.VIGENCIA_DIAS) || 15;
const clave = marca === 'energy' ? 'TERMINOS_ENERGY' : 'TERMINOS_HELGEN';
const lineas = String(cfg[clave] || '').split('\n').map(t => t.trim()).filter(Boolean);
if (!lineas.some(t => /vigen/i.test(t))) {
lineas.push(TERMINO_VIGENCIA.replace('15 días', dias + ' días'));
}
return lineas;
}
/** Evita que Sheets interprete texto del usuario como fórmula ("+52 ...", "=...", "-..."). */
function textoCelda(s) {
const v = String(s == null ? '' : s);
return /^[=+\-@]/.test(v) ? "'" + v : v;
}
/**
* Revisa los datos (claims) de un ID token de Google ya validado por firma: audiencia = nuestro Client ID,
* emisor de Google, sin expirar, correo verificado y cuenta del dominio (hd). Devuelve {email, nombre, apellido}.
*/
function validarClaimsToken(claims, clientId, dominio, ahoraSeg) {
const fallar = msg => { const err = new Error(msg); err.sesion = true; throw err; };
const c = claims || {};
if (c.aud !== clientId) fallar('La sesión no corresponde a esta aplicación. Vuelve a iniciar sesión.');
if (c.iss !== 'accounts.google.com' && c.iss !== 'https://accounts.google.com') fallar('Sesión inválida. Vuelve a iniciar sesión.');
if (!(Number(c.exp) > ahoraSeg)) fallar('Tu sesión expiró. Vuelve a iniciar sesión.');
const email = String(c.email || '').toLowerCase();
const verificado = c.email_verified === true || c.email_verified === 'true';
// hd solo viene en cuentas de Google Workspace: una cuenta personal (@gmail.com) no lo trae
if (c.hd !== dominio || !verificado || !email.endsWith('@' + dominio)) {
fallar('Solo se permiten cuentas @' + dominio + '.');
}
let nombre = String(c.given_name || '').trim();
let apellido = String(c.family_name || '').trim();
if (!nombre && !apellido && c.name) {
const partes = String(c.name).trim().split(/\s+/);
nombre = partes.shift() || '';
apellido = partes.join(' ');
}
return { email: email, nombre: nombre, apellido: apellido };
}
function formatoMoneda(n) {
const [ent, dec] = redondear(n).toFixed(2).split('.');
return '$' + ent.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + dec;
}
function escaparHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* 1234.5 -> "MIL DOSCIENTOS TREINTA Y CUATRO PESOS 50/100 M.N."
* En USD: "MIL DOSCIENTOS TREINTA Y CUATRO DÓLARES 50/100 USD".
*/
function importeConLetra(n, moneda) {
const UNI = ['', 'UN', 'DOS', 'TRES', 'CUATRO', 'CINCO', 'SEIS', 'SIETE', 'OCHO', 'NUEVE', 'DIEZ',
'ONCE', 'DOCE', 'TRECE', 'CATORCE', 'QUINCE', 'DIECISÉIS', 'DIECISIETE', 'DIECIOCHO', 'DIECINUEVE', 'VEINTE',
'VEINTIÚN', 'VEINTIDÓS', 'VEINTITRÉS', 'VEINTICUATRO', 'VEINTICINCO', 'VEINTISÉIS', 'VEINTISIETE', 'VEINTIOCHO', 'VEINTINUEVE'];
const DEC = ['', '', '', 'TREINTA', 'CUARENTA', 'CINCUENTA', 'SESENTA', 'SETENTA', 'OCHENTA', 'NOVENTA'];
const CEN = ['', 'CIENTO', 'DOSCIENTOS', 'TRESCIENTOS', 'CUATROCIENTOS', 'QUINIENTOS', 'SEISCIENTOS',
'SETECIENTOS', 'OCHOCIENTOS', 'NOVECIENTOS'];
function hasta999(x) {
if (x === 0) return '';
if (x === 100) return 'CIEN';
const c = Math.floor(x / 100), r = x % 100;
let s = CEN[c];
if (r) {
const t = r < 30 ? UNI[r] : DEC[Math.floor(r / 10)] + (r % 10 ? ' Y ' + UNI[r % 10] : '');
s = (s ? s + ' ' : '') + t;
}
return s;
}
function entero(x) {
if (x === 0) return 'CERO';
const millones = Math.floor(x / 1e6), miles = Math.floor((x % 1e6) / 1000), resto = x % 1000;
const partes = [];
if (millones) partes.push(millones === 1 ? 'UN MILLÓN' : entero(millones) + ' MILLONES');
if (miles) partes.push(miles === 1 ? 'MIL' : hasta999(miles) + ' MIL');
if (resto) partes.push(hasta999(resto));
return partes.join(' ');
}
const v = redondear(n);
const ent = Math.floor(v);
const cent = Math.round((v - ent) * 100);
const base = entero(ent);
const de = (ent >= 1e6 && ent % 1e6 === 0) ? ' DE' : '';
const cts = String(cent).padStart(2, '0') + '/100';
if (moneda === 'USD') return base + de + (ent === 1 ? ' DÓLAR ' : ' DÓLARES ') + cts + ' USD';
return base + de + (ent === 1 ? ' PESO ' : ' PESOS ') + cts + ' M.N.';
}
/**
* Arma el HTML que se convierte en PDF.
* El conversor de Google NO soporta flex/grid y NO imprime colores de fondo (background):
* todo se resuelve con texto azul, líneas y bordes; los fondos (bgcolor) son solo un extra opcional.
*/
function construirHtmlPdf(d, cfg, logoUrl) {
const e = escaparHtml;
// Brand Guidelines Helgen (Canva): primarios #2C3C5B / #51F3DA, secundario #0AC5A8
const AZUL = '#2C3C5B', AQUA = '#51F3DA', TEAL = '#0AC5A8', CIELO = '#F2F4F7', GRIS = '#6B7280';
const logo = logoUrl
? '<img src="' + logoUrl + '" style="height:44px;">'
: '<div style="font-size:26px;font-weight:bold;color:' + AZUL + ';">Helgen</div>';
const filas = d.items.map((it, i) =>
'<tr' + (i % 2 ? ' bgcolor="' + CIELO + '"' : '') + '>' +
'<td class="c">' + (i + 1) + '</td>' +
'<td>' + e(it.clave) + '</td>' +
'<td>' + e(it.descripcion).replace(/\n/g, '<br>') + '</td>' +
'<td class="c">' + e(it.unidad) + '</td>' +
'<td class="r">' + e(it.cantidad) + '</td>' +
'<td class="r">' + formatoMoneda(it.precio) + '</td>' +
'<td class="r">' + formatoMoneda(it.importe) + '</td></tr>').join('');
const terminos = terminosParaPdf(cfg, d.marca).map(t => '<li>' + e(t) + '</li>').join('');
const dato = (k, v) => v ? '<tr><td class="k">' + k + '</td><td>' + e(v) + '</td></tr>' : '';
const docFila = (k, v, fuerte) =>
'<tr><td style="padding:1px 10px 1px 0;color:' + GRIS + ';text-align:right;">' + k + '</td>' +
'<td style="padding:1px 0;text-align:right;color:' + AZUL + ';' + (fuerte ? 'font-weight:bold;font-size:12px;' : '') + '">' + e(v) + '</td></tr>';
return '<!DOCTYPE html><html><head><meta charset="utf-8"><style>' +
'body{font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#1F2937;margin:0;}' +
'table{border-collapse:collapse;width:100%;}' +
'td,th{padding:5px 6px;vertical-align:top;}' +
'.c{text-align:center;}.r{text-align:right;white-space:nowrap;}' +
'.k{color:' + GRIS + ';width:90px;}' +
'.mod{font-size:11px;font-weight:bold;color:' + AZUL + ';border-bottom:2px solid ' + TEAL + ';padding:0 0 3px 0;margin:16px 0 6px 0;}' +
'.conceptos th{color:' + AZUL + ';font-size:9px;text-transform:uppercase;text-align:left;' +
'border-top:2px solid ' + AZUL + ';border-bottom:2px solid ' + AZUL + ';}' +
'.conceptos td{border-bottom:1px solid #E5E7EB;}' +
'.conceptos th.r{text-align:right;}.conceptos th.c{text-align:center;}' +
'</style></head><body>' +
// Encabezado: logo azul + datos del documento
'<table><tr>' +
'<td style="width:55%;padding:4px 0;vertical-align:middle;">' + logo + '</td>' +
'<td style="width:45%;padding:0;text-align:right;vertical-align:middle;">' +
'<table style="width:auto;margin-left:auto;">' +
'<tr><td colspan="2" style="font-size:20px;font-weight:bold;letter-spacing:2px;color:' + AZUL + ';padding:0 0 3px;text-align:right;">COTIZACIÓN</td></tr>' +
(d.marca === 'energy'
? '<tr><td colspan="2" style="padding:0 0 5px;text-align:right;">' +
'<span style="color:' + AZUL + ';font-weight:bold;letter-spacing:1px;border:1.5px solid ' + TEAL + ';padding:2px 8px;">HELGEN ENERGY</span></td></tr>'
: '') +
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Vigencia', d.vigencia) +
(d.moneda === 'USD' ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
'</table></td></tr></table>' +
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
'<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' +
'<b style="color:' + AZUL + ';">' + e(cfg.EMPRESA_NOMBRE) + '</b> · RFC ' + e(cfg.EMPRESA_RFC) + '<br>' + e(cfg.EMPRESA_DIRECCION) + '<br>' +
e(cfg.EMPRESA_TELEFONO) + ' · ' + e(cfg.EMPRESA_EMAIL) + ' · ' + e(cfg.EMPRESA_WEB) + '</div>' +
// Módulo 1: Cliente
'<div class="mod">1 · DATOS DEL CLIENTE</div>' +
'<table><tr><td style="width:50%;padding:0;"><table>' +
dato('Cliente', d.cliente.nombre) + dato('Empresa', d.cliente.empresa) + dato('RFC', d.cliente.rfc) +
'</table></td><td style="width:50%;padding:0;"><table>' +
dato('Correo', d.cliente.email) + dato('Teléfono', d.cliente.telefono) + dato('Dirección', d.cliente.direccion) +
'</table></td></tr></table>' +
// Módulo 2: Conceptos
'<div class="mod">2 · CONCEPTOS</div>' +
'<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:45px;">Unidad</th><th class="r" style="width:45px;">Cant.</th>' +
(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>' +
// Módulo 3: Totales
'<div class="mod">3 · RESUMEN</div>' +
'<table><tr>' +
'<td style="width:58%;padding-left:0;">' +
'<div style="color:' + GRIS + ';">Importe con letra</div>' +
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total, d.moneda) + ')</div>' +
(d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') +
'</td><td style="width:42%;padding-right:0;"><table>' +
'<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' +
'<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' +
'<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' +
'<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(d.moneda || 'MXN') + '</td>' +
'<td class="r" style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">' + formatoMoneda(d.total) + '</td></tr>' +
'</table></td></tr></table>' +
// Términos
'<table style="margin-top:18px;"><tr><td bgcolor="' + CIELO + '" style="border-left:3px solid ' + TEAL + ';padding:8px 10px;">' +
'<div style="font-weight:bold;color:' + AZUL + ';margin-bottom:4px;">TÉRMINOS Y CONDICIONES</div>' +
'<ul style="margin:0;padding-left:16px;line-height:1.5;">' + terminos + '</ul></td></tr></table>' +
// Pie
'<div style="margin-top:18px;border-top:1px solid #D1D5DB;padding:6px 0 0;color:' + GRIS + ';font-size:9px;">' +
'Elaboró: ' + e(d.elaboro) + ' &nbsp;·&nbsp; ' + e(cfg.EMPRESA_EMAIL) + ' &nbsp;·&nbsp; ' + e(cfg.EMPRESA_TELEFONO) +
'</div>' +
'</body></html>';
}