mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
prepararCobranza() falló en la API real con "Access denied: DriveApp." en setSharing. Con Access.PRIVATE el permiso se ignora. El simulacro ahora rechaza NONE como Google. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2367 lines
114 KiB
JavaScript
2367 lines
114 KiB
JavaScript
/**
|
||
* 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 por enlace o de todo el dominio y conserva a
|
||
* las personas agregadas una por una (tania@, leo@). Workspace comparte con todo helgen.tech lo que se crea (§6.11).
|
||
*/
|
||
function privado_(item) {
|
||
// Permission.NONE no está soportado (da "Access denied: DriveApp"); con PRIVATE el permiso se ignora.
|
||
if (item.getSharingAccess() !== DriveApp.Access.PRIVATE) item.setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.VIEW);
|
||
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 archivoLibro = privado_(DriveApp.getFileById(libro.getId()));
|
||
const carpeta = carpetaCobranza_();
|
||
Logger.log('Carpeta de cobranza: ' + carpeta.getUrl() + ' (acceso: ' + carpeta.getSharingAccess() + ')');
|
||
Logger.log('Libro de cobranza: ' + libro.getUrl() + ' (acceso: ' + archivoLibro.getSharingAccess() + ')');
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* 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) + ' · ' + e(cfg.EMPRESA_EMAIL) + ' · ' + e(cfg.EMPRESA_TELEFONO) +
|
||
'</div>' +
|
||
'</body></html>';
|
||
}
|