Respaldo inicial del Cotizador Helgen (19/10/2026)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-19 16:14:08 -06:00
commit bbb34768b4
30 changed files with 9214 additions and 0 deletions

16
.clasp.json Normal file
View File

@ -0,0 +1,16 @@
{
"scriptId": "1GsH8DjaFEZ9dltqBUTUEFE_frblSYimRRCvPkqKdRB5UhmBwwYHLSwyS",
"rootDir": "",
"scriptExtensions": [
".js",
".gs"
],
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

5
.claspignore Normal file
View File

@ -0,0 +1,5 @@
**/**
!Code.js
!Logo.js
!Index.html
!appsscript.json

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Dependencias de Node (se reinstalan con `npm install`)
node_modules/
# Archivos temporales de Wrangler (Cloudflare)
.wrangler/
# Basura de macOS (los "Icon" son archivos vacíos de carpetas con ícono)
Icon?
.DS_Store
# Credenciales y secretos: nunca subirlos
.env
.env.*
.dev.vars
.clasprc.json
*.pem
*.key
*.p12
credentials*.json
client_secret*.json
service-account*.json
# Registros
*.log
npm-debug.log*

363
CLAUDE.md Normal file
View File

@ -0,0 +1,363 @@
# Cotizador Helgen — contexto para Claude Code
Léelo completo antes de cambiar algo. Resume el sistema, dónde viven los datos y las decisiones ya tomadas
(con su porqué) para no repetir errores. Textos de la interfaz y comentarios del código: **español de México**.
## 1. Qué es
App web interna de **Helgen Servicios Especializados** para hacer cotizaciones, solo para cuentas `@helgen.tech`.
Desde el **19/10/2026** (§11) la interfaz se sirve desde **Cloudflare** y habla por `doPost` con una API en
**Google Apps Script** (sin servidor propio), con sesión de Google verificada en cada llamada. Flujo:
1. El usuario inicia sesión con Google y llena el formulario (cliente, conceptos, notas, línea de negocio Helgen / Helgen Energy).
2. El servidor **recalcula** subtotal, IVA y total, asigna un **folio único** `HLG-AAAA-0001` y genera el **PDF**.
3. El PDF se guarda en Drive (carpeta por año) y la cotización queda como una fila en Google Sheets, con el vendedor del token.
Además: historial con búsqueda, catálogo de productos con precios (Google Sheets aparte), clientes sugeridos con
autorrelleno, y claves SAT internas por producto para contabilidad (con sugerencias por palabras o con IA).
**Helgen Energy** es la submarca de paneles solares: mismo folio y diseño, pero sus propios Términos y Condiciones.
## 2. Datos clave (IDs y URLs)
| Qué | Valor |
|---|---|
| **Cotizador (lo usa el equipo)** | `https://cotizador-helgen.cotizador-helgen.workers.dev/cotizador/` (portada en `/`) |
| Proyecto Apps Script de la API (`scriptId` en `.clasp.json`) | `1GsH8DjaFEZ9dltqBUTUEFE_frblSYimRRCvPkqKdRB5UhmBwwYHLSwyS` (nombre anterior "Cotizador Helgen — PRUEBAS interfaz web"; ver §9) |
| Deployment de la API (siempre actualizar este; `API_URL` en `web/cotizador/index.html`) | `AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2` (@3 al 19/10/2026, `access: ANYONE_ANONYMOUS` + token) |
| Client ID de OAuth (`CLIENT_ID_GOOGLE` en `web/cotizador/index.html` y `Code.js`) | `884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com` (no es secreto) |
| Carpeta maestra en Drive "Cotizador Helgen" | `1lf3Z4d3e_oqFbYJse4QbyYkWowYT9HCo` |
| Base de datos "Cotizador Helgen — Base de datos" (`SPREADSHEET_ID`) | `1ozFsyt1OjSnSv-PVpRWZjgqeWt6voQREbjvOJAXPEaI` |
| Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` |
| Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) |
| "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) |
| Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) |
| Worker de Cloudflare | `cotizador-helgen`, cuenta de efrain@helgen.tech `badd8bca60b008822a73ca24e983ff43` |
| Dueño de todo / cuenta de clasp | `efrain@helgen.tech` (la API se ejecuta como esta cuenta) |
| **Proyecto anterior (EN DESUSO)** | `1X-qSKgvqKwuDj4iPZBhXlakR5udau9ibVMevhwvlLXgtSkjV4IoQxqwU`; deployment `AKfycbxahS3…` en @10 = aviso "El cotizador se mudó". Código en `anterior/` (§11) |
Los IDs que usa el código están en **Script Properties** del proyecto `1GsH8Dja…` (ver §5). Esta tabla es solo referencia.
## 3. Archivos del proyecto (carpeta local `~/Downloads/cotizador-helgen`)
| Archivo | Para qué |
|---|---|
| `Code.js` | Todo el backend: API `doPost` con sesión + lógica (ver §4). Se sube a Apps Script. |
| `Logo.js` | Logos en base64: `LOGO_HELGEN_AZUL` (PDF, fondo blanco, 700×200) y `LOGO_HELGEN_BLANCO`. Se sube. |
| `Index.html` | Interfaz vieja de HtmlService. Se sube pero **no se usa** (`doGet` ya no la sirve; su `API_URL` es un marcador). La interfaz real es `web/cotizador/index.html`. |
| `appsscript.json` | Manifiesto: zona `America/Mexico_City`, V8, web app `executeAs: USER_DEPLOYING`, `access: ANYONE_ANONYMOUS`. Se sube. |
| `.clasp.json` | Liga la carpeta al proyecto `1GsH8Dja…`. **No cambiar el scriptId.** |
| `.claspignore` | Lista blanca: solo se suben `Code.js`, `Logo.js`, `Index.html`, `appsscript.json` (excluye `anterior/`). |
| `web/` | Portada (`index.html`, `logo-helgen-blanco.png`) y la interfaz real `web/cotizador/index.html` (§10). No se sube a Apps Script. `.assetsignore` excluye `Icon`/`.DS_Store`. |
| `wrangler.jsonc` | Publica `web/` en Cloudflare como Worker solo de archivos estáticos. |
| `tests/` | Pruebas en Node con servicios de Google simulados (ver §8). No se suben. Aceptan `Code.js` (actual) o `Code.gs` (anterior). |
| `anterior/` | Código del proyecto viejo (@9), su `.clasp.json` (`1X-qSKgv…`), `LEEME.md` con cómo regresar y el CLAUDE.md previo a la migración. No tocar salvo para regresar. |
| `herramientas/extraer-catalogos-sat.js` | Convierte el Excel oficial del SAT en los CSV que importa la app (ver §6.5). |
| `GUIA.md` | Guía para personas (instalación inicial). Desactualizada: describe el proyecto anterior. |
| `package.json` | Solo `@google/clasp` como dependencia de desarrollo. |
| `Icon` | Archivo vacío de macOS; ignorarlo. |
No hay git inicializado en la carpeta.
## 4. Funciones de `Code.js`
Convención: las que terminan en `_` son internas; las demás las llama la interfaz (por `doPost`, ver `API_FUNCIONES`)
o son lógica pura (sección 5 del archivo, sin servicios de Google, probables en Node).
### 4.1 Entrada, sesión e instalación
- `doGet()` — solo responde un texto con la dirección del cotizador (no sirve la app; ver §10.3).
- `doPost(e)` — verifica el token (`verificarTokenGoogle_`) **antes** de buscar la función; solo ejecuta las de `API_FUNCIONES`.
Pone el vendedor en `VENDEDOR_` durante la petición y lo limpia en `finally`. Detalle en §10.3.
- `verificarTokenGoogle_(token)`, `validarClaimsToken(...)` (pura), `usuarioActual_()` (reemplaza `Session.getActiveUser()`).
- `setup()` — crea la base de datos (pestañas `Cotizaciones` y `Config`) y la carpeta de PDF, y guarda sus IDs.
`db_()` la ejecuta sola si faltan `SPREADSHEET_ID`/`FOLDER_ID` (bajo candado).
- `db_()` — abre la base de datos; si faltan `SPREADSHEET_ID`/`FOLDER_ID`, corre `setup()`.
- `getConfig_()` — lee `Config` como objeto; antes llama `migrarConfig_()`.
- `migrarConfig_(hoja)` — actualiza hojas `Config` viejas: `TERMINOS` → `TERMINOS_HELGEN` + `TERMINOS_ENERGY`, agrega claves
nuevas de `CONFIG_INICIAL`, cambia valores de ejemplo (`CONFIG_EJEMPLO_ANTERIOR`) por los reales y repara celdas `#ERROR!`.
### 4.2 Catálogo de productos
- `hojaCatalogo_()` — abre "Catálogo Helgen"; si no existe, `crearCatalogo_()` (bajo candado). Llama `asegurarColumnasSat_()`.
- `crearCatalogo_()` — crea el archivo con formato, validaciones (clave única con `COUNTIF`, precio ≥ 0), migra la pestaña
`Catalogo` vieja de la base de datos (sin el ejemplo `EJ-001`, luego la borra) y lo mueve junto a la base de datos.
- `asegurarColumnasSat_(hoja)` — agrega encabezados `Clave SAT`/`Unidad SAT` (G:H, formato texto) a catálogos viejos.
- `getCatalogo_(hoja?)` — lista `{clave, descripcion, unidad, precio, claveSat, unidadSat}`.
- `getCatalogo()` *(UI)* — productos + URL del archivo + unidades SAT para la lista de selección.
- `guardarProducto(p)` *(UI)* — alta o edición (`p.claveOriginal` = edición, permite cambiar la clave). Valida con
`normalizarProducto`, rechaza claves duplicadas (sin distinguir mayúsculas) y claves/unidades SAT que no existan.
"Actualizó" = `usuarioActual_()`.
### 4.3 Catálogos del SAT
- `libroSat_()` — abre "Catálogos SAT (CFDI 4.0)"; si no existe `SAT_ID`, `importarCatalogosSat_()`.
- `importarCatalogosSat_()` — busca en Drive `SAT_c_ClaveProdServ.csv` y `SAT_c_ClaveUnidad.csv`, crea el libro con pestañas
`ClaveProdServ`, `ClaveUnidad` y `Fuente`, y lo deja en la carpeta de los CSV.
- `getUnidadesSat_()` — `[[clave, nombre], …]` de unidades.
- `sugerirClavesSat(texto)` *(UI)* — si el texto es una clave de 7–8 dígitos la busca exacta; si no, toma ~40 candidatos
(`candidatosSat`) y, si existe `ANTHROPIC_API_KEY`, la IA elige las 5 mejores con razón (`ordenarConIaSat_`);
si no hay key o falla, devuelve los 8 mejores por palabras.
- `ordenarConIaSat_(texto, candidatos)` — llama a la API de Claude (`UrlFetchApp`), guarda el resultado 6 h en caché; `null` si falla o no hay key.
- `probarIaSat()` — correr desde el editor: muestra en el registro sugerencias de ejemplo.
- `existeEnSat_(pestana, clave)` — valida una clave con `TextFinder` (celda completa).
### 4.4 Datos para la interfaz
- `getDatosIniciales()` *(UI, al cargar)* — IVA, vigencia, marcas, catálogo, clientes, `usuario` (correo) y `vendedor` (`{email, nombre, apellido}`).
- `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`).
- `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa; `vendedor` = nombre + apellido
(filas viejas: `vendedor_email` o "Elaboró").
### 4.5 Guardar cotización
- `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT
a cada concepto que venga del catálogo (si falla, guarda igual), toma el candado, revisa idempotencia (`payload.idSolicitud`
en `CacheService`, 6 h), asigna folio, genera PDF, lo guarda en la carpeta del año, reescribe encabezados si no coinciden con
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total}`.
- `siguienteFolio_(prefijo, hojaCot)` — `max(CONSECUTIVO_AAAA, mayor folio del año en la hoja) + 1`.
- `carpetaDelAnio_(anio)` — subcarpeta `2026`, `2027`… dentro de la carpeta de PDF (la busca **por nombre**).
- `logoDataUrl_(fileId)` — logo de Drive si `LOGO_FILE_ID` tiene valor; si no, `LOGO_HELGEN_AZUL`.
### 4.6 Lógica pura (sin Google; se prueba en Node)
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.").
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`.
- `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`.
- `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (sin acentos/mayúsculas/espacios), la fila más
reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite.
- `normalizarProducto(p)`, `indiceDeClave(claves, clave)`.
- `terminosParaPdf(cfg, marca)` — renglones de T&C de la marca; agrega la línea de vigencia si falta.
- `textoCelda(s)` — antepone `'` a texto que empiece con `= + - @` para que Sheets no lo lea como fórmula.
- `validarClaimsToken(claims, clientId, dominio, ahora)` — ver §10.3.
- SAT: `palabrasSat`, `SINONIMOS_SAT`, `PALABRAS_SERVICIO_SAT`, `buscarClavesSat(consulta, filas, n)`, `candidatosSat`,
`solicitudIaSat`, `leerRespuestaIaSat` (solo acepta claves que estaban entre los candidatos). Modelo: `MODELO_IA_SAT`.
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
### 4.7 Interfaz (`web/cotizador/index.html`)
Tres pestañas: **Nueva cotización**, **Historial**, **Catálogo**. Funciones principales: `agregarFila`, `leerItems`,
`recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`,
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
Autocompletado con `<datalist>`: clientes (en "Nombre"), productos por descripción y por clave, unidades SAT.
Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
## 5. Dónde se guardan los datos
**Todo en la carpeta maestra "Cotizador Helgen"** de Mi unidad:
```
📁 Cotizador Helgen
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
├─ 📊 Catálogo Helgen (Catalogo)
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf
├─ 📁 Catálogos SAT / SAT_c_ClaveProdServ.csv, SAT_c_ClaveUnidad.csv, 📊 Catálogos SAT (CFDI 4.0)
├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
└─ 📜 Cotizador Helgen (código de la app) ← proyecto ANTERIOR, en desuso
```
El proyecto de la API (`1GsH8Dja…`) está en la carpeta `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p` (antes "PRUEBAS — Cotizador web"),
junto con los archivos de prueba que ya no se usan (§9).
**Pestaña `Cotizaciones`** (19 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono,
Subtotal, IVA, Total, Elaboró, PDF (link), Conceptos (JSON: clave, descripcion, unidad, cantidad, precio, importe y, si vienen
del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Dirección, `vendedor_email`, `vendedor_nombre`,
`vendedor_apellido`. Las columnas P:S se agregaron el 19/10/2026 (§11); las filas 0001–0004 no las tienen llenas.
`guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`.
**Pestaña `Config`** (Clave | Valor, columna B en formato texto): `EMPRESA_NOMBRE` (Helgen Servicios Especializados),
`EMPRESA_RFC` (HSE240304PC1), `EMPRESA_DIRECCION`, `EMPRESA_TELEFONO` (+52 442 644 5391), `EMPRESA_EMAIL`, `EMPRESA_WEB`,
`PREFIJO_FOLIO` (HLG), `IVA_TASA` (0.16), `VIGENCIA_DIAS` (15), `MONEDA`, `LOGO_FILE_ID` (vacío = logo de `Logo.js`),
`TERMINOS_HELGEN`, `TERMINOS_ENERGY` (un término por renglón dentro de la celda).
**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA),
Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
**Script Properties** del proyecto `1GsH8Dja…` (Configuración del proyecto en el editor):
`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
`ANTHROPIC_API_KEY` (opcional; activa la IA para claves SAT; el proyecto anterior no la tenía). Nunca poner la API key en el código.
## 6. Decisiones tomadas (y por qué)
### 6.1 Folios sin duplicados
- Todo el guardado va dentro de `LockService.getScriptLock()` con `tryLock(30000)` y mensaje en español si se agota.
- El folio sale del **mayor** entre el contador y el último folio de la hoja: si alguien borra o edita `CONSECUTIVO_AAAA`, no se repite.
- **Idempotencia:** el formulario manda `idSolicitud` (UUID por cotización, se renueva al limpiar); un doble clic o reenvío devuelve el mismo folio.
- Borrar una fila no hace retroceder el contador (intencional). El consecutivo reinicia cada año.
- **El candado es por proyecto:** nunca dejar dos proyectos de Apps Script guardando en la misma hoja (por eso el anterior
lanza error al guardar; §11).
### 6.2 Instalación y migraciones automáticas
- La app se instala sola (`db_()` → `setup()`, `hojaCatalogo_()` → `crearCatalogo_()`, `libroSat_()` → `importarCatalogosSat_()`),
porque correr `setup()` a mano falló la primera vez.
- Toda estructura nueva se migra sola (Config, columnas del catálogo, encabezados de Cotizaciones).
Al agregar columnas o claves, seguir este patrón: **nunca pedir al usuario que edite la hoja a mano**.
### 6.3 PDF (lecciones del conversor HTML→PDF de Google)
- **No imprime colores de fondo** (`background`): con la banda azul, el logo blanco y el texto blanco desaparecieron.
Por eso el PDF usa texto azul, líneas y bordes; `bgcolor` solo como extra opcional.
- No soporta flex/grid: solo tablas e inline CSS. Fuente del PDF: Arial (las web fonts no son confiables ahí).
- Logo azul en el PDF; el blanco solo sobre fondo azul.
- No hay forma local de ver el PDF real: para revisar, renderizar el HTML **quitando los fondos** (simula al conversor).
### 6.4 Interfaz
- Fuentes de Google (League Spartan, Roboto Mono) sí funcionan.
- Máximo 3 módulos por vista. Mensajes de error en español.
- (Solo aplicaba a `Index.html` con HtmlService: imágenes `data:` con `<?!= ?>`, porque `<?= ?>` las bloquea.)
### 6.5 Catálogo y SAT
- El catálogo es un **archivo aparte** para poder compartirlo con quien mantiene precios sin exponer el historial.
- La **clave SAT no reemplaza la clave interna**: muchas claves internas comparten una clave SAT. Es dato interno de
contabilidad: **no sale en el PDF**; se valida contra el catálogo vigente y viaja en el JSON de conceptos.
- Catálogo SAT: `catCFDI_V_4_20261015.xls` del Anexo 20 (`http://omawww.sat.gob.mx/tramitesyservicios/Paginas/anexo_20.htm`;
el sitio rechaza HTTPS desde aquí, usar HTTP con `curl`). 52,513 claves de producto/servicio y 2,418 unidades vigentes.
- **Actualizar el catálogo SAT:** descargar el `.xls` nuevo, correr
`node herramientas/extraer-catalogos-sat.js <archivo.xls> <carpeta-salida>` (requiere `npm i xlsx@0.18.5` en una carpeta
temporal), reemplazar los CSV en la subcarpeta "Catálogos SAT", **borrar la propiedad `SAT_ID`** y abrir la pestaña Catálogo.
- La búsqueda por palabras funciona bien para productos (panel solar → 26111607 / 32111701 / 60104702; inversor → 32121705;
cargador VE → 25175002) y mal para servicios genéricos; para eso existe la IA opcional. Ampliar `SINONIMOS_SAT` si una búsqueda falla.
- **No inventar claves SAT de memoria:** una vez se afirmó que 26111604 era "Paneles solares" y es "Generadores a gas".
Verificar siempre contra el CSV/hoja. Contabilidad debe validar toda clave sugerida.
### 6.6 Clientes sugeridos
- No hay lista de clientes aparte: se calcula cada vez desde `Cotizaciones`. Si se borran sus filas, el cliente deja de sugerirse (lo pidió el usuario).
### 6.7 Drive
- La app usa **IDs, no rutas**: se pueden mover o renombrar archivos. Rompe: copiar-y-borrar (cambia el ID), borrar, renombrar
las pestañas `Cotizaciones`/`Config`, sacar o renombrar las subcarpetas de año, perder acceso de edición de efrain@helgen.tech.
- **Peligro:** la carpeta local `~/Downloads` (incluida esta carpeta del proyecto) se sincroniza a Drive en una carpeta
"Downloads" (`1BBB4vyr9WW1TowC6Uemtd3K6V7_NitAh`). No mover ni borrar esas copias desde Drive: afectaría la carpeta local.
- Google Drive para escritorio está montado en `~/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/`:
sirve para dejar archivos en Drive (así se subieron los CSV del SAT).
### 6.8 clasp
- clasp está instalado **local** en el proyecto (instalar global pedía permisos de administrador): usar siempre `npx clasp`.
- **No volver a correr `clasp create`**: crea otro proyecto y sobrescribe `appsscript.json` (pone zona de Nueva York y
quita la configuración de web app). Ya pasó y hubo que borrar un proyecto duplicado.
- Publicar siempre sobre el mismo deployment (misma `API_URL`). Nunca `clasp deploy` sin `--deploymentId`.
- Si se agrega un servicio nuevo de Google, el dueño debe autorizar el permiso una vez desde el editor (`UrlFetchApp` ya está autorizado).
## 7. Reglas del proyecto (no romper)
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa.
2. Redondeo a 2 decimales por renglón (`redondear`). IVA = subtotal × tasa.
3. PDF con **tablas e inline CSS**, sin flex/grid y **sin depender de colores de fondo** (§6.3).
4. Todo texto del usuario que va al PDF pasa por `escaparHtml`.
5. Colores de marca (Brand Guidelines de Helgen en Canva): azul `#2C3C5B` y aqua `#51F3DA` (primarios), teal `#0AC5A8`
(secundario), negro/blanco; fondo neutro `#F2F4F7`. Tipografías: League Spartan Bold (títulos), Roboto Mono (texto).
No usar los colores viejos `#0B1C3F` / `#4CAF50`.
6. Toda cotización imprime Términos y Condiciones según la línea de negocio:
- **Helgen** (`TERMINOS_HELGEN`): solo "Precios vigentes por 15 días o hasta agotar existencias, sujetos a variaciones en costos de suministro de componentes."
- **Helgen Energy** (`TERMINOS_ENERGY`): metraje adicional, centro de carga, obra civil + vigencia.
No agregar términos por cuenta propia (se quitó uno de "fuera de alcance" que el usuario no quería).
7. Texto del usuario que va a Sheets pasa por `textoCelda` (un "+52…" se leería como fórmula → `#ERROR!`).
8. Textos de la interfaz en español de México.
9. Las claves SAT son internas: nunca en el PDF.
10. Toda llamada a la API exige token válido de @helgen.tech (`hd`, `email_verified`, `aud`); el vendedor sale **siempre**
del token, nunca de lo que mande el navegador. No quitar esas revisiones.
## 8. Cómo trabajar
```bash
# Pruebas (Node, servicios de Google simulados). Correr las cuatro antes de subir.
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes; deja helgen.html / energy.html
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
node tests/probar-busqueda-sat.js Code.js "<ruta>/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras
# Subir y publicar la API (misma API_URL)
npx clasp push --force
npx clasp deploy --deploymentId AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2 -d "descripción del cambio"
npx clasp list-deployments
# Interfaz en Cloudflare (desde esta carpeta; usa wrangler.jsonc)
npx --yes wrangler@4 deploy
```
- Antes de `push`, confirmar que `.clasp.json` apunta a `1GsH8Dja…` y que nadie cambió el proyecto en el editor
(clonar en una carpeta temporal y comparar).
- Las pruebas de cotización y SAT simulan un vendedor (`VENDEDOR_`), porque `guardarCotizacion` exige sesión.
- Revisar sintaxis del JS de la interfaz: extraer los `<script>` de `web/cotizador/index.html` y `node --check`.
- Comprobar la API con `curl` usando `-d` sin `-X POST` (con `-X POST -L` curl repite el POST en la redirección de Google y
sale "No se encontró la página"). Sin token → `{"ok":false,"error":"Inicia sesión con tu cuenta @helgen.tech.","codigo":"SESION"}`.
- El flujo completo exige sesión @helgen.tech: pedir al usuario que pruebe y mande captura; los errores del servidor se ven
con `npx clasp logs` o en el editor (Ejecuciones). Los datos se pueden revisar en solo lectura con el conector de Drive.
- Al agregar funciones que llamen servicios de Google, extender los simulacros de `tests/`. Si la interfaz necesita una
función nueva, agregarla a `API_FUNCIONES`.
## 9. Pendientes e ideas
- **Renombrar en Drive / Apps Script (lo hace el usuario):** proyecto `1GsH8Dja…` → "Cotizador Helgen (código de la app)";
carpeta `1w-T05ql…` → "Cotizador Helgen — API web"; proyecto anterior → "Cotizador Helgen (EN DESUSO — no usar)";
los archivos de prueba sin uso → "ARCHIVO PRUEBAS — …" (§10.4).
- **OAuth en External/Testing:** solo entran las cuentas de la lista de usuarios de prueba (4 agregadas el 19/10/2026).
Para un vendedor nuevo hay que agregarlo (§10.1). Ideal: cambiar a "Interna".
- La IA para claves SAT no se ha probado contra la API real; requiere `ANTHROPIC_API_KEY` y correr `probarIaSat()` en el editor.
- Existen archivos previos de Helgen Energy en Mi unidad (no movidos): "Catálogo Maestro de Precios - Helgen Energy" (Sheets),
"Catálogo Helgen Energy - Soluciones 2026" (Slides), "COTIZADOR HELGEN ENERGY.html". Se ofreció importar esos precios al catálogo.
- Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones.
- `GUIA.md` describe el proyecto anterior; actualizarla si se va a usar.
- Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
## 10. Interfaz web y sesión con Google
### 10.1 Google Cloud (cliente OAuth)
- Client ID (tipo "Aplicación web"): `884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com`. No es secreto;
debe coincidir en `CLIENT_ID_GOOGLE` de `web/cotizador/index.html` y de `Code.js`. El secreto del cliente no se usa ni se guarda.
- Pantalla de consentimiento: **External en modo Testing, con usuarios de prueba agregados a mano** (decisión del usuario).
- Solo entran las cuentas de la lista. **Para dar acceso a otro vendedor:** Google Cloud Console → Pantalla de consentimiento
de OAuth (Google Auth Platform) → Público → Usuarios de prueba.
- Con External, Google **no** limita por dominio: una cuenta @gmail en la lista podría obtener un token. Por eso el
servidor exige `hd` y `email_verified` (y la prueba lo cubre).
- Lo ideal: cambiar a "Interna" (requiere que el proyecto de Cloud esté en la organización helgen.tech).
- Orígenes autorizados de JavaScript: `https://cotizador-helgen.cotizador-helgen.workers.dev` y `http://127.0.0.1:8788`.
Sin URIs de redireccionamiento. Un origen nuevo tarda de minutos a horas; mientras, el botón da `origin_mismatch`.
### 10.2 Interfaz (`web/cotizador/index.html`, `<script id="api-web">`)
- Es la interfaz completa con el logo incrustado y una capa que imita `google.script.run` con `fetch(API_URL)`
(POST `text/plain` para evitar la consulta CORS previa; cuerpo `{"funcion", "args", "token"}`, respuesta
`{"ok", "datos" | "error", "codigo"?}`; `codigo: "SESION"` = falta sesión o no es válida).
- Carga Google Identity Services (`https://accounts.google.com/gsi/client`). La pantalla `#login` tapa la app hasta tener token;
botón "Acceder con Google" + One Tap, con `hd: 'helgen.tech'` (solo filtra la lista de cuentas; quien decide es el servidor).
- El ID token va en **cada** llamada. Se guarda en `sessionStorage` y se reutiliza hasta 1 min antes de expirar
(~1 h); las llamadas sin sesión vigente esperan en cola hasta que se inicia sesión.
- Si el servidor responde `codigo: "SESION"`, se borra el token, se muestra el login y la llamada se repite **una vez** al entrar
(guardar es idempotente por `idSolicitud`: no duplica folio).
- Encabezado: nombre del vendedor (del token, solo para mostrar) + botón "Cerrar sesión" (`disableAutoSelect`).
- Tras publicar, el navegador puede mostrar la versión anterior en caché: recargar la página.
- Wrangler 4 delega Cloudflare Pages "clásico" a Workers: se eligió **Worker con assets** (`wrangler.jsonc`), no `wrangler pages`.
Si pide sesión: `npx --yes wrangler@4 login`. Probar local: `python3 -m http.server 8788 --bind 127.0.0.1` dentro de `web/`.
### 10.3 Verificación del token en `doPost` (`Code.js`)
1. `doPost` lee el cuerpo y, **antes de buscar la función**, llama `verificarTokenGoogle_(cuerpo.token)`.
Sin token o con token inválido no se ejecuta nada: responde `{"ok": false, "error": "…", "codigo": "SESION"}`.
2. `verificarTokenGoogle_()`: busca en `CacheService` por la huella SHA-256 del token (`TOK_…`). Si no está, consulta
`https://oauth2.googleapis.com/tokeninfo?id_token=…` con `UrlFetchApp`: Google revisa **firma y expiración**; si no
responde 200, es sesión inválida.
3. `validarClaimsToken` exige `aud === CLIENT_ID_GOOGLE`, `iss` = `accounts.google.com` o `https://accounts.google.com`,
`exp` en el futuro, `hd === 'helgen.tech'`, `email_verified` verdadero y correo terminado en `@helgen.tech`. Devuelve
`{email, nombre, apellido}` (de `given_name`/`family_name`; si faltan, se parte `name`). Todo error lleva `sesion = true`.
4. El resultado se guarda en caché hasta que el token expire (máx. 10 min) para no consultar a Google en cada clic.
5. `VENDEDOR_` vale el vendedor **solo durante esa petición** (se limpia en `finally`). `Session.getActiveUser()` viene vacío con
`ANYONE_ANONYMOUS`; por eso existe `usuarioActual_()`.
- **`doGet` no sirve la app:** con `ANYONE_ANONYMOUS`, una página de HtmlService permitiría llamar funciones con
`google.script.run` sin token.
- Columnas `vendedor_*` y "Elaboró" (columna K y el PDF) = datos del token; pasan por `textoCelda`.
### 10.4 Archivos de prueba que quedaron sin uso
Se usaron para probar la interfaz web con datos inventados (Fases 1 y 2, 19/10/2026). Ya **no** los usa el código; no apuntar
ninguna propiedad a ellos. Están en la carpeta `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p`:
- "PRUEBAS — Cotizador Helgen — Base de datos" `10WLYHF2vB2TDmoTXwfAxnG8bp7gDOPPzoj9p6B0tUGY`
- "PRUEBAS — Catálogo Helgen" `1GQBK0_WXSQRuCOeE-Pv8AA6PFA4tjZwCqae4L7WZsH0`
- "PRUEBAS — Cotizaciones Helgen (PDF)" `1iDk4faAM557SZh67EA122dj6eU4RJpsH`
## 11. Migración al proyecto nuevo (19/10/2026)
El proyecto que se usaba para pruebas (`1GsH8Dja…`) pasó a ser el oficial, en este orden (cada paso confirmado por el usuario):
1. **Respaldos:** copias de la base de datos y del catálogo en "Respaldos 2026-10-19".
2. **Retiro del proyecto anterior** (`1X-qSKgv…`): deployment `AKfycbxahS3…` en **@10** = página "El cotizador se mudó" con
botón a Cloudflare; `guardarCotizacion` y `guardarProducto` lanzan error (para que solo un proyecto escriba en la hoja).
No se borró nada. Se hizo justo antes del paso 3 para no tener dos proyectos guardando a la vez.
3. **Propiedades del proyecto nuevo** → IDs reales (`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`) y
`CONSECUTIVO_2026 = 4` (último folio real: HLG-2026-0004; el contador del anterior también era 4).
4. **Columnas P:S** en la hoja real: una función temporal escribió solo P1:S1 (comprobando antes que las filas no cambiaran)
y se quitó después. Las filas 0001–0004 quedaron intactas.
5. **Prueba real:** HLG-2026-0005 ("PRUEBA - CANCELADA") con vendedor, PDF en `2026` y catálogo real. Comprobado en Drive.
6. **Textos sin "PRUEBAS"** en `Code.js` (API @3), portada y título de la interfaz (Cloudflare). La carpeta local pasó a
ser el código del proyecto nuevo; el anterior quedó en `anterior/`.
**Regresar (solo si hace falta):** primero apuntar `SPREADSHEET_ID`, `FOLDER_ID` y `CATALOGO_ID` del proyecto nuevo a los IDs
de prueba (§10.4) para que deje de escribir en la hoja real (**no** borrarlas: `db_()` correría `setup()` y crearía otra base
de datos), y luego, desde `anterior/`:
`npx clasp deploy --deploymentId AKfycbxahS3PbizPrRrQIC1m2uz-2G8PoT64BqPdtGLu0Ms5t9RKs2ucxH_c3tpJXkj8uLE -V 9 -d "regreso a @9"`.
Nunca tener los dos proyectos guardando a la vez (§6.1).

1183
Code.js Normal file

File diff suppressed because it is too large Load Diff

122
GUIA.md Normal file
View File

@ -0,0 +1,122 @@
# Guía: poner en marcha el Cotizador Helgen con Claude Code
Tiempo estimado: 45–60 min la primera vez.
## Cómo funciona (en una línea)
Formulario web → Apps Script asigna folio `HLG-2026-0001` → recalcula subtotal/IVA/total → genera PDF → lo guarda en Drive → registra la fila en una hoja de Google.
```
[Navegador del equipo] ──► [Apps Script: Code.gs] ──► Google Sheets (registro, catálogo, config)
└──────────────► Google Drive /Cotizaciones Helgen (PDF)/2026/
```
---
## Paso 1 · Instalar herramientas (una sola vez, en tu Mac)
Abre **Terminal** y ejecuta:
```bash
# Node.js (si no lo tienes): descárgalo de https://nodejs.org (versión LTS)
node -v
# Claude Code
npm install -g @anthropic-ai/claude-code
# clasp: la herramienta oficial de Google para subir código a Apps Script
npm install -g @google/clasp
```
Activa la API de Apps Script en tu cuenta: entra a https://script.google.com/home/usersettings y enciende **"Google Apps Script API"**.
## Paso 2 · Crear la carpeta del proyecto
1. Descarga los archivos de esta carpeta (`Code.gs`, `Index.html`, `appsscript.json`, `CLAUDE.md`) a una carpeta, por ejemplo `~/Helgen/cotizador-helgen`.
2. En Terminal:
```bash
cd ~/Helgen/cotizador-helgen
clasp login # se abre el navegador: entra con tu cuenta @helgen.tech
clasp create --type standalone --title "Cotizador Helgen"
```
> `clasp create` puede sobrescribir `appsscript.json`. Si pasa, vuelve a copiar el que te di.
## Paso 3 · Abrir Claude Code y subir el código
```bash
claude
```
Claude Code lee `CLAUDE.md` automáticamente, así ya entiende el proyecto. Escríbele:
> Sube el proyecto con clasp push y ábreme el editor.
## Paso 4 · Instalación inicial (en el editor de Apps Script)
1. En el editor, elige la función **`setup`** y pulsa **Ejecutar**.
2. Acepta los permisos (Drive y Sheets).
3. En **Registro de ejecución** verás dos links: la **hoja de base de datos** y la **carpeta de PDF**.
## Paso 5 · Configurar tus datos
Abre la hoja de base de datos:
- **Config** → llena nombre, RFC, dirección, teléfono, correo, prefijo del folio y términos.
- **Logo** → sube tu logo (PNG con fondo transparente, ~600 px de ancho) a Drive. Copia su ID (la parte larga del link entre `/d/` y `/view`) y pégalo en `LOGO_FILE_ID`.
- **Catalogo** → captura tus productos/servicios frecuentes con su precio. Aparecerán como autocompletado.
## Paso 6 · Publicar para el equipo
Pídele a Claude Code:
> Publica la app web con clasp deploy y dame la URL.
O manualmente en el editor: **Implementar → Nueva implementación → App web**
- Ejecutar como: **Yo** (así todos los PDF quedan en una sola carpeta)
- Quién tiene acceso: **Cualquier usuario de helgen.tech**
Comparte la URL con el equipo y agrégala a favoritos.
## Paso 7 · Prueba de aceptación
- [ ] Crear una cotización de prueba → aparece el folio `HLG-2026-0001`.
- [ ] El PDF se abre, muestra el logo y los totales coinciden con la calculadora.
- [ ] Aparece la fila en la hoja **Cotizaciones** y el PDF en Drive/2026.
- [ ] Dos personas guardan casi al mismo tiempo → folios distintos.
- [ ] Borrar la fila de prueba (el contador NO retrocede; es intencional para no repetir folios).
- [ ] Doble clic rápido en "Guardar" → se crea un solo folio y un solo PDF.
> Protección de folios: el folio sale del mayor entre el contador (`CONSECUTIVO_AAAA`) y el último folio en la hoja **Cotizaciones**, dentro de un candado (`LockService`). Aunque alguien borre el contador, no se repite.
---
## Cómo pedirle cambios a Claude Code (ejemplos de prompts)
Usa frases concretas; Claude Code edita, prueba y sube por ti.
| Quiero… | Prompt |
|---|---|
| Ajustar el diseño al PDF de referencia | "Adjunto `referencia.pdf`. Ajusta `construirHtmlPdf` para que se parezca: mismo orden de secciones y tipografía. Genera un PDF de prueba local con Playwright antes de subir." |
| Descuento por renglón | "Agrega una columna de descuento % por concepto, en el formulario y en el PDF. El cálculo debe hacerse en el servidor." |
| Enviar por correo | "Agrega un botón 'Enviar al cliente' que mande el PDF por Gmail al correo del cliente con copia a mí." |
| Duplicar una cotización | "En el historial agrega un botón 'Duplicar' que cargue los datos en el formulario con un folio nuevo." |
| Estatus | "Agrega una columna Estatus (Enviada, Aceptada, Rechazada) editable desde el historial." |
| Corregir un error | "Al guardar sale este error: [pega el mensaje]. Revisa `clasp logs` y corrígelo." |
**Flujo de cada cambio:** pides → Claude Code edita → prueba local → `clasp push` → `clasp deploy --deploymentId <ID>` (misma URL para el equipo).
**Recomendación:** inicializa git en la carpeta (`git init`, o pídeselo a Claude Code) para poder deshacer cualquier cambio.
---
## Por qué esta arquitectura
| Criterio | Resultado |
|---|---|
| Costo | $0 — usa Google Workspace que ya pagan |
| Servidores | Ninguno que mantener |
| Acceso | Solo cuentas @helgen.tech |
| Respaldo | Drive + historial de versiones de Sheets |
| Folios | Consecutivos y sin duplicados aunque varios usuarios guarden a la vez |
| Límite | ~cientos de cotizaciones/día sin problema. Si algún día necesitan CRM, facturación o app móvil, se migra a Next.js + base de datos y se importa el historial desde la hoja. |

466
Index.html Normal file
View File

@ -0,0 +1,466 @@
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* Brand Guidelines Helgen (Canva): primarios #2C3C5B / #51F3DA, secundario #0AC5A8 */
:root { --azul:#2C3C5B; --aqua:#51F3DA; --teal:#0AC5A8; --cielo:#F2F4F7; --gris:#6B7280; --borde:#E5E7EB; }
* { box-sizing:border-box; }
body { margin:0; font-family:'Roboto Mono',monospace; background:var(--cielo); color:#1F2937; font-size:13px; }
h1, h2, .btn, nav button { font-family:'League Spartan',Arial,sans-serif; }
header { background:var(--azul); color:#fff; padding:14px 24px; display:flex; align-items:center; gap:24px; flex-wrap:wrap;
border-bottom:3px solid var(--aqua); }
header img { height:30px; display:block; }
header h1 { font-size:16px; margin:0; letter-spacing:2px; color:var(--aqua); }
nav button { background:none; border:none; color:#A9B4C8; font-size:14px; cursor:pointer; padding:6px 10px; border-radius:6px; }
nav button.activo { background:rgba(255,255,255,.12); color:#fff; }
main { max-width:1100px; margin:24px auto; padding:0 16px; }
.modulo { background:#fff; border-radius:10px; padding:20px; margin-bottom:16px; border:1px solid var(--borde); }
.modulo h2 { margin:0 0 14px; font-size:15px; color:var(--azul); border-bottom:2px solid var(--teal); padding-bottom:6px; display:inline-block; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
label { display:block; font-size:12px; color:var(--gris); margin-bottom:4px; }
input, textarea, select { width:100%; padding:8px 10px; border:1px solid var(--borde); border-radius:6px; font:inherit; }
select { background:#fff; }
input:focus, textarea:focus, select:focus { outline:2px solid var(--teal); border-color:transparent; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--gris); padding:6px; border-bottom:1px solid var(--borde); }
td { padding:4px; }
td.num, th.num { text-align:right; white-space:nowrap; }
.btn { background:var(--teal); color:var(--azul); border:none; padding:10px 18px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:14px; }
.btn:hover { background:var(--aqua); }
.btn:disabled { opacity:.5; cursor:wait; }
.btn-sec { background:#fff; color:var(--azul); border:1px solid var(--azul); }
.btn-sec:hover { background:var(--cielo); }
.quitar { background:none; border:none; color:#DC2626; cursor:pointer; font-size:18px; }
.totales { margin-left:auto; width:320px; }
.totales div { display:flex; justify-content:space-between; padding:6px 0; }
.totales .total { background:var(--azul); color:var(--aqua); padding:10px 12px; border-radius:6px; font-size:18px; font-weight:bold; }
.aviso { padding:12px 14px; border-radius:6px; margin-bottom:16px; display:none; }
.aviso.ok { background:#E6FBF7; border:1px solid var(--teal); display:block; }
.aviso.error { background:#FEE2E2; border:1px solid #DC2626; display:block; }
.oculto { display:none; }
.fila-cat { cursor:pointer; }
.fila-cat:hover td { background:var(--cielo); }
.fila-cat.editando td { background:#E6FBF7; }
.acciones { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:12px; }
.nota { font-size:12px; color:var(--gris); }
.sat { margin-top:16px; padding:14px; border:1px dashed var(--borde); border-radius:8px; background:#FAFBFC; }
.sat-titulo { font-size:12px; font-weight:bold; color:var(--azul); margin-bottom:10px; }
.sat-op { display:flex; gap:12px; align-items:center; padding:7px 10px; border:1px solid var(--borde); border-radius:6px;
margin-top:6px; cursor:pointer; background:#fff; }
.sat-op:hover { border-color:var(--teal); background:#E6FBF7; }
.sat-op b { min-width:80px; }
.sat-razon { display:block; color:#5B6B85; font-size:12px; margin-top:2px; }
a { color:var(--azul); font-weight:bold; }
</style>
</head>
<body>
<header>
<img src="data:image/png;base64,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" alt="Helgen">
<h1>COTIZADOR</h1>
<nav>
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
<button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
</nav>
</header>
<main>
<div id="aviso" class="aviso"></div>
<!-- ===== NUEVA COTIZACIÓN ===== -->
<section id="vistaNueva">
<div class="modulo">
<h2>1 · Datos del cliente</h2>
<div class="grid">
<div><label>Línea de negocio *</label>
<select id="marca">
<option value="helgen">Helgen</option>
<option value="energy">Helgen Energy (paneles solares)</option>
</select></div>
<div><label>Nombre / Contacto *</label>
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
<datalist id="clientesList"></datalist>
<div id="cInfo" class="nota" style="margin-top:4px"></div></div>
<div><label>Empresa</label><input id="cEmpresa"></div>
<div><label>RFC</label><input id="cRfc" maxlength="13"></div>
<div><label>Correo</label><input id="cEmail" type="email"></div>
<div><label>Teléfono</label><input id="cTel"></div>
<div><label>Dirección / Obra</label><input id="cDir"></div>
</div>
</div>
<div class="modulo">
<h2>2 · Conceptos</h2>
<datalist id="catalogo"></datalist>
<datalist id="claves"></datalist>
<table>
<thead><tr>
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">P. Unitario</th>
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
</tr></thead>
<tbody id="filas"></tbody>
</table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
</div>
<div class="modulo">
<h2>3 · Resumen</h2>
<div style="display:flex; gap:24px; flex-wrap:wrap;">
<div style="flex:1; min-width:260px;">
<label>Notas para el cliente (opcional)</label>
<textarea id="notas" rows="4"></textarea>
</div>
<div class="totales">
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
<div class="total"><span>TOTAL</span><span id="tTot">$0.00</span></div>
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
</div>
</div>
</div>
</section>
<!-- ===== HISTORIAL ===== -->
<section id="vistaHist" class="oculto">
<div class="modulo">
<h2>Historial de cotizaciones</h2>
<input id="buscar" placeholder="Buscar por folio, cliente o empresa…" oninput="buscarDebounce()" style="margin-bottom:12px">
<table>
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Elaboró</th><th>PDF</th></tr></thead>
<tbody id="hist"></tbody>
</table>
</div>
</section>
<!-- ===== CATÁLOGO ===== -->
<section id="vistaCat" class="oculto">
<div class="modulo">
<h2 id="tituloProd">Agregar producto</h2>
<div class="grid">
<div><label>Clave *</label><input id="pClave" maxlength="30" placeholder="Ej. CAR-7KW"></div>
<div style="grid-column:span 2"><label>Descripción *</label><input id="pDesc"></div>
<div><label>Unidad</label><input id="pUni" value="PZA"></div>
<div><label>Precio unitario sin IVA *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
</div>
<div class="sat">
<div class="sat-titulo">Datos para facturación (SAT) · uso interno de contabilidad, no aparecen en el PDF</div>
<div class="grid">
<div><label>Clave SAT (producto/servicio)</label><input id="pSat" maxlength="8" inputmode="numeric" placeholder="8 dígitos"></div>
<div><label>Unidad SAT</label><input id="pUniSat" list="unidadesSat" maxlength="3" placeholder="Ej. H87, E48"><datalist id="unidadesSat"></datalist></div>
<div style="grid-column:span 2"><label>Buscar en el catálogo del SAT</label>
<div style="display:flex; gap:8px"><input id="satBusca" placeholder="Se toma de la descripción; puedes cambiar las palabras">
<button class="btn btn-sec" onclick="sugerirSat()" style="white-space:nowrap">Sugerir clave SAT</button></div></div>
</div>
<div id="satRes"></div>
</div>
<div class="acciones">
<button id="btnProd" class="btn" onclick="guardarProd()">Guardar en catálogo</button>
<button class="btn btn-sec" onclick="limpiarProd()">Nuevo / Cancelar</button>
<span class="nota">Haz clic en un producto de la lista para editarlo.</span>
</div>
</div>
<div class="modulo">
<h2 id="tituloLista">Productos</h2>
<div class="acciones" style="margin:0 0 12px">
<input id="buscarCat" placeholder="Buscar por clave o descripción…" oninput="pintarCatalogo()" style="flex:1; min-width:220px">
<a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
</div>
<table>
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th style="width:110px">Clave SAT</th></tr></thead>
<tbody id="listaCat"></tbody>
</table>
</div>
</section>
</main>
<script id="api-web">
// Fuera de Apps Script (Cloudflare Pages) no existe google.script.run: esta capa lo imita con fetch al doPost.
// Dentro de Apps Script no hace nada y se usa el original.
(function () {
if (window.google && google.script && google.script.run) return;
const API_URL = 'PEGAR_AQUI_LA_URL_/exec_DE_LA_IMPLEMENTACION_DE_PRUEBA';
function llamar(funcion, args, alExito, alFallar) {
const fallar = msg => { if (alFallar) alFallar(new Error(msg)); else console.error(msg); };
if (!/^https:\/\//.test(API_URL)) { fallar('Falta configurar la URL del servidor (API_URL).'); return; }
// text/plain: es una "solicitud simple" y el navegador no hace la consulta CORS previa (Apps Script no la responde)
fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' },
body: JSON.stringify({ funcion: funcion, args: args }) })
.then(r => {
if (!r.ok) return { ok: false, error: 'El servidor respondió ' + r.status + '.' };
return r.json().catch(() => ({ ok: false, error: 'Respuesta inválida del servidor.' }));
}, err => ({ ok: false, error: 'No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo. (' + err.message + ')' }))
.then(res => {
if (res && res.ok) { if (alExito) alExito(res.datos); }
else fallar((res && res.error) || 'Respuesta inválida del servidor.');
});
}
// Igual que google.script.run: withSuccessHandler / withFailureHandler encadenables y luego la función
function corredor(alExito, alFallar) {
return new Proxy({}, {
get(_, nombre) {
if (nombre === 'withSuccessHandler') return f => corredor(f, alFallar);
if (nombre === 'withFailureHandler') return f => corredor(alExito, f);
if (typeof nombre !== 'string' || nombre === 'then') return undefined;
return (...args) => llamar(nombre, args, alExito, alFallar);
},
});
}
window.google = window.google || {};
google.script = google.script || {};
google.script.run = corredor(null, null);
})();
</script>
<script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [];
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId();
const $ = id => document.getElementById(id);
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---- Arranque ----
google.script.run.withSuccessHandler(d => {
IVA = d.ivaTasa; CATALOGO = d.catalogo;
recibirClientes(d.clientes);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists();
agregarFila();
}).withFailureHandler(mostrarError).getDatosIniciales();
// ---- Conceptos ----
function agregarFila() {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><input class="clave" list="claves"></td>' +
'<td><input class="desc" list="catalogo" placeholder="Escribe o elige del catálogo"></td>' +
'<td><input class="uni" value="PZA"></td>' +
'<td><input class="cant" type="number" min="0" step="any" value="1" style="text-align:right"></td>' +
'<td><input class="pu" type="number" min="0" step="0.01" value="0" style="text-align:right"></td>' +
'<td class="num imp">$0.00</td>' +
'<td><button class="quitar" title="Quitar">×</button></td>';
// Al elegir por descripción o por clave se llenan clave, unidad y precio desde el catálogo
const aplicar = p => {
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.pu').value = p.precio;
};
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalcular));
tr.querySelector('.quitar').onclick = () => { tr.remove(); recalcular(); };
$('filas').appendChild(tr);
tr.querySelector('.desc').focus();
}
function leerItems() {
return [...$('filas').querySelectorAll('tr')].map(tr => ({
clave: tr.querySelector('.clave').value,
descripcion: tr.querySelector('.desc').value,
unidad: tr.querySelector('.uni').value,
cantidad: Number(tr.querySelector('.cant').value) || 0,
precio: Number(tr.querySelector('.pu').value) || 0,
_tr: tr,
}));
}
function recalcular() {
let sub = 0;
leerItems().forEach(it => {
const imp = r2(it.cantidad * r2(it.precio));
it._tr.querySelector('.imp').textContent = mxn(imp);
sub += imp;
});
sub = r2(sub);
const iva = r2(sub * IVA);
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva);
}
// ---- Guardar ----
function guardar() {
const payload = {
cliente: { nombre:$('cNombre').value, empresa:$('cEmpresa').value, rfc:$('cRfc').value,
email:$('cEmail').value, telefono:$('cTel').value, direccion:$('cDir').value },
items: leerItems().map(({_tr, ...it}) => it),
notas: $('notas').value,
marca: $('marca').value,
idSolicitud: ID_SOLICITUD,
};
if ($('btnGuardar').disabled) return;
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run
.withSuccessHandler(r => {
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar();
})
.withFailureHandler(mostrarError)
.guardarCotizacion(payload);
}
function limpiar() {
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = '';
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
ID_SOLICITUD = nuevoId();
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
// ---- Historial ----
let tBusq;
function buscarDebounce() { clearTimeout(tBusq); tBusq = setTimeout(cargarHistorial, 300); }
function cargarHistorial() {
$('hist').innerHTML = '<tr><td colspan="7">Cargando…</td></tr>';
google.script.run.withSuccessHandler(lista => {
$('hist').innerHTML = lista.length ? lista.map(c =>
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.elaboro) + '</td><td>' +
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="7">Sin resultados.</td></tr>';
}).withFailureHandler(mostrarError).listarCotizaciones($('buscar').value);
}
// ---- Clientes que ya cotizaron (salen de la hoja Cotizaciones) ----
const CAMPOS_CLIENTE = { cNombre:'nombre', cEmpresa:'empresa', cRfc:'rfc', cEmail:'email', cTel:'telefono', cDir:'direccion' };
function recibirClientes(lista) {
CLIENTES = lista || [];
$('clientesList').innerHTML = CLIENTES.map(c =>
'<option value="' + esc(c.etiqueta) + '" label="' + esc([c.empresa, c.rfc].filter(Boolean).join(' · ') || 'Cliente registrado') + '">').join('');
}
function autorrellenarCliente() {
const v = $('cNombre').value.trim().toLowerCase();
const c = v && CLIENTES.find(x => x.etiqueta.toLowerCase() === v);
if (!c) { $('cInfo').textContent = ''; return; }
Object.keys(CAMPOS_CLIENTE).forEach(id => $(id).value = c[CAMPOS_CLIENTE[id]] || '');
$('cInfo').textContent = 'Cliente registrado · ' + c.cotizaciones + (c.cotizaciones === 1 ? ' cotización' : ' cotizaciones') +
(c.ultimaFecha ? ' · última ' + c.ultimaFecha : '');
}
$('cNombre').addEventListener('change', autorrellenarCliente);
$('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); });
// ---- Catálogo ----
let CLAVE_EDITANDO = '';
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
function pintarDatalists() {
$('catalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + mxn(p.precio)) + '">').join('');
$('claves').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + mxn(p.precio)) + '">').join('');
}
function cargarCatalogo() {
$('listaCat').innerHTML = '<tr><td colspan="5">Cargando…</td></tr>';
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
}
function recibirCatalogo(r) {
CATALOGO = r.productos;
$('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
if (r.unidadesSat && r.unidadesSat.length) {
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join('');
}
pintarDatalists(); pintarCatalogo();
}
function pintarCatalogo() {
const q = $('buscarCat').value.toLowerCase().trim();
const lista = CATALOGO.filter(p => !q || (p.clave + ' ' + p.descripcion).toLowerCase().includes(q));
$('tituloLista').textContent = 'Productos (' + CATALOGO.length + ')';
$('listaCat').innerHTML = lista.length ? lista.map(p =>
'<tr class="fila-cat' + (p.clave === CLAVE_EDITANDO ? ' editando' : '') + '" data-clave="' + esc(p.clave) + '">' +
'<td><b>' + esc(p.clave) + '</b></td><td>' + esc(p.descripcion) + '</td><td>' + esc(p.unidad) + '</td>' +
'<td class="num">' + mxn(p.precio) + '</td>' +
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
: '<tr><td colspan="5">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
$('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
}
function editarProd(clave) {
const p = buscarClave(clave); if (!p) return;
CLAVE_EDITANDO = p.clave;
$('pClave').value = p.clave; $('pDesc').value = p.descripcion; $('pUni').value = p.unidad; $('pPrecio').value = p.precio;
$('pSat').value = p.claveSat || ''; $('pUniSat').value = p.unidadSat || ''; $('satBusca').value = ''; $('satRes').innerHTML = '';
$('tituloProd').textContent = 'Editar producto ' + p.clave;
$('btnProd').textContent = 'Guardar cambios';
pintarCatalogo(); window.scrollTo(0, 0); $('pDesc').focus();
}
function limpiarProd() {
CLAVE_EDITANDO = '';
['pClave','pDesc','pPrecio','pSat','pUniSat','satBusca'].forEach(id => $(id).value = ''); $('pUni').value = 'PZA';
$('satRes').innerHTML = '';
$('tituloProd').textContent = 'Agregar producto';
$('btnProd').textContent = 'Guardar en catálogo'; $('btnProd').disabled = false;
pintarCatalogo();
}
function guardarProd() {
if ($('btnProd').disabled) return;
const prod = { clave:$('pClave').value, descripcion:$('pDesc').value, unidad:$('pUni').value,
precio:$('pPrecio').value, claveSat:$('pSat').value, unidadSat:$('pUniSat').value,
claveOriginal:CLAVE_EDITANDO };
const editando = !!CLAVE_EDITANDO;
$('btnProd').disabled = true;
google.script.run.withSuccessHandler(r => {
recibirCatalogo(r);
aviso('ok', 'Producto <b>' + esc(r.clave) + '</b> ' + (editando ? 'actualizado' : 'agregado') + ' en el catálogo.');
limpiarProd();
}).withFailureHandler(err => { $('btnProd').disabled = false; mostrarError(err); }).guardarProducto(prod);
}
// ---- Sugerencias de clave SAT ----
function sugerirSat() {
const q = $('satBusca').value.trim() || $('pDesc').value.trim();
if (!q) { aviso('error', 'Escribe la descripción del producto o palabras para buscar en el catálogo del SAT.'); return; }
$('satBusca').value = q;
$('satRes').innerHTML = '<div class="nota" style="margin-top:8px">Buscando en el catálogo del SAT…</div>';
google.script.run.withSuccessHandler(lista => {
$('satRes').innerHTML = lista.length
? '<div class="nota" style="margin-top:10px">' + (lista[0].razon ? 'Sugeridas con IA. ' : '') +
'Elige la que mejor describa el producto (contabilidad debe validarla):</div>' +
lista.map(o => '<div class="sat-op" data-clave="' + esc(o.clave) + '"><b>' + esc(o.clave) + '</b><span>' + esc(o.descripcion) +
(o.razon ? '<small class="sat-razon">' + esc(o.razon) + '</small>' : '') + '</span></div>').join('')
: '<div class="nota" style="margin-top:8px">Sin coincidencias. Prueba con otras palabras (p. ej. el tipo de producto o "servicio de …").</div>';
$('satRes').querySelectorAll('.sat-op').forEach(d => d.onclick = () => {
$('pSat').value = d.dataset.clave; $('satRes').innerHTML = '';
});
}).withFailureHandler(err => { $('satRes').innerHTML = ''; mostrarError(err); }).sugerirClavesSat(q);
}
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
// ---- UI ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva');
$('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo();
$('aviso').className = 'aviso';
}
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
function mostrarError(err) {
aviso('error', esc(err && err.message ? err.message : err));
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
</script>
</body>
</html>

5
Logo.js Normal file

File diff suppressed because one or more lines are too long

16
anterior/.clasp.json Normal file
View File

@ -0,0 +1,16 @@
{
"scriptId": "1X-qSKgvqKwuDj4iPZBhXlakR5udau9ibVMevhwvlLXgtSkjV4IoQxqwU",
"rootDir": "",
"scriptExtensions": [
".js",
".gs"
],
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

5
anterior/.claspignore Normal file
View File

@ -0,0 +1,5 @@
**/**
!Code.gs
!Logo.gs
!Index.html
!appsscript.json

View File

@ -0,0 +1,374 @@
# Cotizador Helgen — contexto para Claude Code
Léelo completo antes de cambiar algo. Resume el sistema, dónde viven los datos y las decisiones ya tomadas
(con su porqué) para no repetir errores. Textos de la interfaz y comentarios del código: **español de México**.
## 1. Qué es
App web interna de **Helgen Servicios Especializados** para hacer cotizaciones. Corre en **Google Apps Script**
(sin servidor propio), solo para cuentas `@helgen.tech`. Flujo:
1. El usuario llena el formulario (cliente, conceptos, notas, línea de negocio Helgen / Helgen Energy).
2. El servidor **recalcula** subtotal, IVA y total, asigna un **folio único** `HLG-AAAA-0001` y genera el **PDF**.
3. El PDF se guarda en Drive (carpeta por año) y la cotización queda como una fila en Google Sheets.
Además: historial con búsqueda, catálogo de productos con precios (Google Sheets aparte), clientes sugeridos con
autorrelleno, y claves SAT internas por producto para contabilidad (con sugerencias por palabras o con IA).
**Helgen Energy** es la submarca de paneles solares: mismo folio y diseño, pero sus propios Términos y Condiciones.
## 2. Datos clave (IDs y URLs)
| Qué | Valor |
|---|---|
| URL de la app (la usa el equipo) | `https://script.google.com/a/macros/helgen.tech/s/AKfycbxahS3PbizPrRrQIC1m2uz-2G8PoT64BqPdtGLu0Ms5t9RKs2ucxH_c3tpJXkj8uLE/exec` |
| Deployment publicado (siempre actualizar este) | `AKfycbxahS3PbizPrRrQIC1m2uz-2G8PoT64BqPdtGLu0Ms5t9RKs2ucxH_c3tpJXkj8uLE` (versión @9 al 19/10/2026) |
| Proyecto Apps Script (`scriptId` en `.clasp.json`) | `1X-qSKgvqKwuDj4iPZBhXlakR5udau9ibVMevhwvlLXgtSkjV4IoQxqwU` — "Cotizador Helgen (código de la app)" |
| Carpeta maestra en Drive "Cotizador Helgen" | `1lf3Z4d3e_oqFbYJse4QbyYkWowYT9HCo` |
| Base de datos "Cotizador Helgen — Base de datos" (`SPREADSHEET_ID`) | `1ozFsyt1OjSnSv-PVpRWZjgqeWt6voQREbjvOJAXPEaI` |
| Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` |
| Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` |
| "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) |
| Interfaz web en Cloudflare (PRUEBAS) | `https://cotizador-helgen.cotizador-helgen.workers.dev/cotizador/` (portada en `/`). IDs de PRUEBAS en §10 |
| Dueño de todo / cuenta de clasp | `efrain@helgen.tech` (la app se ejecuta como esta cuenta) |
Los IDs reales que usa el código están en **Script Properties** (ver §5). Esta tabla es solo referencia.
## 3. Archivos del proyecto (carpeta local `~/Downloads/cotizador-helgen`)
| Archivo | Para qué |
|---|---|
| `Code.gs` | Todo el backend (ver §4). Se sube a Apps Script. |
| `Index.html` | Interfaz completa (HTML + CSS + JS en un archivo). Plantilla de HtmlService. Se sube. |
| `Logo.gs` | Logos en base64: `LOGO_HELGEN_AZUL` (PDF, fondo blanco, 700×200) y `LOGO_HELGEN_BLANCO` (encabezado azul de la app). Se sube. |
| `appsscript.json` | Manifiesto: zona `America/Mexico_City`, V8, web app `executeAs: USER_DEPLOYING`, `access: DOMAIN`. Se sube. |
| `.clasp.json` | Liga la carpeta al proyecto `1X-qSKgv…`. **No cambiar el scriptId.** |
| `.claspignore` | Lista blanca: solo se suben `Code.gs`, `Logo.gs`, `Index.html`, `appsscript.json`. |
| `tests/` | Pruebas en Node con servicios de Google simulados (ver §8). No se suben. `sesion.test.js` es de PRUEBAS (Fase 2, §10). |
| `herramientas/extraer-catalogos-sat.js` | Convierte el Excel oficial del SAT en los CSV que importa la app (ver §6.5). |
| `GUIA.md` | Guía para personas (instalación inicial). Partes desactualizadas: hoy se usa `npx clasp` y `setup()` corre solo. |
| `web/` | Portada con la marca (`index.html`, `logo-helgen-blanco.png` extraído de `Logo.gs`); su botón lleva a `web/cotizador/index.html`: la interfaz completa con una capa que imita `google.script.run` por `fetch` al `doPost` de PRUEBAS (`API_URL`). El código de pruebas (`Code.js` con `doPost`/`API_FUNCIONES`) vive solo en el proyecto de PRUEBAS, no en esta carpeta. `.assetsignore` excluye `Icon`/`.DS_Store`. No se sube a Apps Script. Ver §10. |
| `wrangler.jsonc` | Publica `web/` en Cloudflare como Worker solo de archivos estáticos (ver §10). |
| `package.json` | Solo `@google/clasp` como dependencia de desarrollo. |
| `Icon` | Archivo vacío de macOS; ignorarlo. |
No hay git inicializado en la carpeta.
## 4. Funciones de `Code.gs`
Convención: las que terminan en `_` son internas; las demás las llama la interfaz con `google.script.run`
o son lógica pura (sección 5 del archivo, sin servicios de Google, probables en Node).
### 4.1 Entrada e instalación
- `doGet()` — sirve `Index.html` como plantilla (`createTemplateFromFile(...).evaluate()`).
- `setup()` — crea la base de datos (pestañas `Cotizaciones` y `Config`) y la carpeta de PDF, y guarda sus IDs.
**No hace falta correrla a mano:** `db_()` la ejecuta sola la primera vez (bajo candado).
- `db_()` — abre la base de datos; si faltan `SPREADSHEET_ID`/`FOLDER_ID`, corre `setup()`.
- `getConfig_()` — lee `Config` como objeto; antes llama `migrarConfig_()`.
- `migrarConfig_(hoja)` — actualiza hojas `Config` viejas: `TERMINOS` → `TERMINOS_HELGEN` + `TERMINOS_ENERGY`, agrega claves
nuevas de `CONFIG_INICIAL`, cambia valores de ejemplo (`CONFIG_EJEMPLO_ANTERIOR`) por los reales y repara celdas `#ERROR!`.
### 4.2 Catálogo de productos
- `hojaCatalogo_()` — abre "Catálogo Helgen"; si no existe, `crearCatalogo_()` (bajo candado). Llama `asegurarColumnasSat_()`.
- `crearCatalogo_()` — crea el archivo con formato, validaciones (clave única con `COUNTIF`, precio ≥ 0), migra la pestaña
`Catalogo` vieja de la base de datos (sin el ejemplo `EJ-001`, luego la borra) y lo mueve junto a la base de datos.
- `asegurarColumnasSat_(hoja)` — agrega encabezados `Clave SAT`/`Unidad SAT` (G:H, formato texto) a catálogos viejos.
- `getCatalogo_(hoja?)` — lista `{clave, descripcion, unidad, precio, claveSat, unidadSat}`.
- `getCatalogo()` *(UI)* — productos + URL del archivo + unidades SAT para la lista de selección.
- `guardarProducto(p)` *(UI)* — alta o edición (`p.claveOriginal` = edición, permite cambiar la clave). Valida con
`normalizarProducto`, rechaza claves duplicadas (sin distinguir mayúsculas) y claves/unidades SAT que no existan.
### 4.3 Catálogos del SAT
- `libroSat_()` — abre "Catálogos SAT (CFDI 4.0)"; si no existe, `importarCatalogosSat_()`.
- `importarCatalogosSat_()` — busca en Drive `SAT_c_ClaveProdServ.csv` y `SAT_c_ClaveUnidad.csv`, crea el libro con pestañas
`ClaveProdServ`, `ClaveUnidad` y `Fuente`, y lo deja en la carpeta de los CSV.
- `getUnidadesSat_()` — `[[clave, nombre], …]` de unidades.
- `sugerirClavesSat(texto)` *(UI)* — si el texto es una clave de 7–8 dígitos la busca exacta; si no, toma ~40 candidatos
(`candidatosSat`) y, si existe `ANTHROPIC_API_KEY`, la IA elige las 5 mejores con razón (`ordenarConIaSat_`);
si no hay key o falla, devuelve los 8 mejores por palabras.
- `ordenarConIaSat_(texto, candidatos)` — llama a la API de Claude (`UrlFetchApp`), guarda el resultado 6 h en caché; `null` si falla.
- `probarIaSat()` — correr desde el editor: autoriza `UrlFetchApp` y muestra en el registro sugerencias de ejemplo.
- `existeEnSat_(pestana, clave)` — valida una clave con `TextFinder` (celda completa).
### 4.4 Datos para la interfaz
- `getDatosIniciales()` *(UI, al cargar)* — IVA, vigencia, marcas, catálogo, clientes, usuario.
- `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`).
- `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa.
### 4.5 Guardar cotización
- `guardarCotizacion(payload)` *(UI)* — normaliza, agrega clave/unidad SAT a cada concepto que venga del catálogo (si falla,
guarda igual), toma el candado, revisa idempotencia (`payload.idSolicitud` en `CacheService`, 6 h), asigna folio,
genera PDF, lo guarda en la carpeta del año y agrega la fila. Devuelve `{folio, url, total}`.
- `siguienteFolio_(prefijo, hojaCot)` — `max(CONSECUTIVO_AAAA, mayor folio del año en la hoja) + 1`.
- `carpetaDelAnio_(anio)` — subcarpeta `2026`, `2027`… dentro de la carpeta de PDF (la busca **por nombre**).
- `logoDataUrl_(fileId)` — logo de Drive si `LOGO_FILE_ID` tiene valor; si no, `LOGO_HELGEN_AZUL`.
### 4.6 Lógica pura (sin Google; se prueba en Node)
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.").
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`.
- `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`.
- `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (sin acentos/mayúsculas/espacios), la fila más
reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite.
- `normalizarProducto(p)`, `indiceDeClave(claves, clave)`.
- `terminosParaPdf(cfg, marca)` — renglones de T&C de la marca; agrega la línea de vigencia si falta.
- `textoCelda(s)` — antepone `'` a texto que empiece con `= + - @` para que Sheets no lo lea como fórmula.
- SAT: `palabrasSat`, `SINONIMOS_SAT`, `PALABRAS_SERVICIO_SAT`, `buscarClavesSat(consulta, filas, n)`, `candidatosSat`,
`solicitudIaSat`, `leerRespuestaIaSat` (solo acepta claves que estaban entre los candidatos). Modelo: `MODELO_IA_SAT`.
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
### 4.7 `Index.html` (interfaz)
Tres pestañas: **Nueva cotización**, **Historial**, **Catálogo**. Funciones principales: `agregarFila`, `leerItems`,
`recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`,
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
Autocompletado con `<datalist>`: clientes (en "Nombre"), productos por descripción y por clave, unidades SAT.
## 5. Dónde se guardan los datos
**Todo en la carpeta maestra "Cotizador Helgen"** de Mi unidad:
```
📁 Cotizador Helgen
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
├─ 📊 Catálogo Helgen (Catalogo)
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf
├─ 📁 Catálogos SAT / SAT_c_ClaveProdServ.csv, SAT_c_ClaveUnidad.csv, 📊 Catálogos SAT (CFDI 4.0)
└─ 📜 Cotizador Helgen (código de la app)
```
**Pestaña `Cotizaciones`** (16 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono,
Subtotal, IVA, Total, Elaboró, PDF (link), Conceptos (JSON: clave, descripcion, unidad, cantidad, precio, importe y, si vienen
del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Dirección. Las columnas Marca y Dirección se
agregaron después: `guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`.
En PRUEBAS la hoja tiene 19 columnas: además `vendedor_email`, `vendedor_nombre`, `vendedor_apellido` (Fase 2, §10.5).
**Pestaña `Config`** (Clave | Valor, columna B en formato texto): `EMPRESA_NOMBRE` (Helgen Servicios Especializados),
`EMPRESA_RFC` (HSE240304PC1), `EMPRESA_DIRECCION`, `EMPRESA_TELEFONO` (+52 442 644 5391), `EMPRESA_EMAIL`, `EMPRESA_WEB`,
`PREFIJO_FOLIO` (HLG), `IVA_TASA` (0.16), `VIGENCIA_DIAS` (15), `MONEDA`, `LOGO_FILE_ID` (vacío = logo de `Logo.gs`),
`TERMINOS_HELGEN`, `TERMINOS_ENERGY` (un término por renglón dentro de la celda).
**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA),
Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
**Script Properties** (Configuración del proyecto en el editor):
`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
`ANTHROPIC_API_KEY` (opcional; activa la IA para claves SAT). Nunca poner la API key en el código.
## 6. Decisiones tomadas (y por qué)
### 6.1 Folios sin duplicados
- Todo el guardado va dentro de `LockService.getScriptLock()` con `tryLock(30000)` y mensaje en español si se agota.
- El folio sale del **mayor** entre el contador y el último folio de la hoja: si alguien borra o edita `CONSECUTIVO_AAAA`, no se repite.
- **Idempotencia:** el formulario manda `idSolicitud` (UUID por cotización, se renueva al limpiar); un doble clic o reenvío devuelve el mismo folio.
- Borrar una fila no hace retroceder el contador (intencional). El consecutivo reinicia cada año.
### 6.2 Instalación y migraciones automáticas
- La app se instala sola (`db_()` → `setup()`, `hojaCatalogo_()` → `crearCatalogo_()`, `libroSat_()` → `importarCatalogosSat_()`),
porque correr `setup()` a mano falló la primera vez.
- Toda estructura nueva se migra sola al abrir la app (Config, columnas del catálogo, encabezados de Cotizaciones).
Al agregar columnas o claves, seguir este patrón: **nunca pedir al usuario que edite la hoja a mano**.
### 6.3 PDF (lecciones del conversor HTML→PDF de Google)
- **No imprime colores de fondo** (`background`): con la banda azul, el logo blanco y el texto blanco desaparecieron.
Por eso el PDF usa texto azul, líneas y bordes; `bgcolor` solo como extra opcional.
- No soporta flex/grid: solo tablas e inline CSS. Fuente del PDF: Arial (las web fonts no son confiables ahí).
- Logo azul en el PDF; el blanco solo sobre fondo azul.
- No hay forma local de ver el PDF real: para revisar, renderizar el HTML **quitando los fondos** (simula al conversor).
### 6.4 Interfaz
- En la plantilla, imágenes `data:` se insertan con `<?!= ?>`: `<?= ?>` las bloquea por seguridad.
- Fuentes de Google (League Spartan, Roboto Mono) sí funcionan en la app.
- Máximo 3 módulos por vista. Mensajes de error en español.
### 6.5 Catálogo y SAT
- El catálogo es un **archivo aparte** para poder compartirlo con quien mantiene precios sin exponer el historial.
- La **clave SAT no reemplaza la clave interna**: muchas claves internas comparten una clave SAT. Es dato interno de
contabilidad: **no sale en el PDF**; se valida contra el catálogo vigente y viaja en el JSON de conceptos.
- Catálogo SAT: `catCFDI_V_4_20261015.xls` del Anexo 20 (`http://omawww.sat.gob.mx/tramitesyservicios/Paginas/anexo_20.htm`;
el sitio rechaza HTTPS desde aquí, usar HTTP con `curl`). 52,513 claves de producto/servicio y 2,418 unidades vigentes.
- **Actualizar el catálogo SAT:** descargar el `.xls` nuevo, correr
`node herramientas/extraer-catalogos-sat.js <archivo.xls> <carpeta-salida>` (requiere `npm i xlsx@0.18.5` en una carpeta
temporal), reemplazar los CSV en la subcarpeta "Catálogos SAT", **borrar la propiedad `SAT_ID`** y abrir la pestaña Catálogo.
- La búsqueda por palabras funciona bien para productos (panel solar → 26111607 / 32111701 / 60104702; inversor → 32121705;
cargador VE → 25175002) y mal para servicios genéricos; para eso existe la IA opcional. Ampliar `SINONIMOS_SAT` si una búsqueda falla.
- **No inventar claves SAT de memoria:** una vez se afirmó que 26111604 era "Paneles solares" y es "Generadores a gas".
Verificar siempre contra el CSV/hoja. Contabilidad debe validar toda clave sugerida.
### 6.6 Clientes sugeridos
- No hay lista de clientes aparte: se calcula cada vez desde `Cotizaciones`. Si se borran sus filas, el cliente deja de sugerirse (lo pidió el usuario).
### 6.7 Drive
- La app usa **IDs, no rutas**: se pueden mover o renombrar archivos. Rompe: copiar-y-borrar (cambia el ID), borrar, renombrar
las pestañas `Cotizaciones`/`Config`, sacar o renombrar las subcarpetas de año, perder acceso de edición de efrain@helgen.tech.
- **Peligro:** la carpeta local `~/Downloads` (incluida esta carpeta del proyecto) se sincroniza a Drive en una carpeta
"Downloads" (`1BBB4vyr9WW1TowC6Uemtd3K6V7_NitAh`). No mover ni borrar esas copias desde Drive: afectaría la carpeta local.
- Google Drive para escritorio está montado en `~/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/`:
sirve para dejar archivos en Drive (así se subieron los CSV del SAT).
### 6.8 clasp
- clasp está instalado **local** en el proyecto (instalar global pedía permisos de administrador): usar siempre `npx clasp`.
- **No volver a correr `clasp create`**: crea otro proyecto y sobrescribe `appsscript.json` (pone zona de Nueva York y
quita la configuración de web app). Ya pasó y hubo que borrar un proyecto duplicado.
- Publicar siempre sobre el mismo deployment (misma URL para el equipo). Nunca `clasp deploy` sin `--deploymentId`.
- Si se agrega un servicio nuevo de Google o `UrlFetchApp`, el dueño debe autorizar el permiso una vez desde el editor.
## 7. Reglas del proyecto (no romper)
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa.
2. Redondeo a 2 decimales por renglón (`redondear`). IVA = subtotal × tasa.
3. PDF con **tablas e inline CSS**, sin flex/grid y **sin depender de colores de fondo** (§6.3).
4. Todo texto del usuario que va al PDF pasa por `escaparHtml`.
5. Colores de marca (Brand Guidelines de Helgen en Canva): azul `#2C3C5B` y aqua `#51F3DA` (primarios), teal `#0AC5A8`
(secundario), negro/blanco; fondo neutro `#F2F4F7`. Tipografías: League Spartan Bold (títulos), Roboto Mono (texto).
No usar los colores viejos `#0B1C3F` / `#4CAF50`.
6. Toda cotización imprime Términos y Condiciones según la línea de negocio:
- **Helgen** (`TERMINOS_HELGEN`): solo "Precios vigentes por 15 días o hasta agotar existencias, sujetos a variaciones en costos de suministro de componentes."
- **Helgen Energy** (`TERMINOS_ENERGY`): metraje adicional, centro de carga, obra civil + vigencia.
No agregar términos por cuenta propia (se quitó uno de "fuera de alcance" que el usuario no quería).
7. Texto del usuario que va a Sheets pasa por `textoCelda` (un "+52…" se leería como fórmula → `#ERROR!`).
8. Textos de la interfaz en español de México.
9. Las claves SAT son internas: nunca en el PDF.
## 8. Cómo trabajar
```bash
# Pruebas (Node, servicios de Google simulados). Correr las tres antes de subir.
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.gs /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes; deja helgen.html / energy.html
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
node tests/probar-busqueda-sat.js Code.gs "<ruta>/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras
node tests/sesion.test.js <clon de PRUEBAS con Code.js y Logo.js> # Fase 2: token en doPost, vendedor_* (ver §10)
# Subir y publicar (misma URL)
npx clasp push --force
npx clasp deploy --deploymentId AKfycbxahS3PbizPrRrQIC1m2uz-2G8PoT64BqPdtGLu0Ms5t9RKs2ucxH_c3tpJXkj8uLE -d "descripción del cambio"
npx clasp list-deployments
# Interfaz web en Cloudflare: ver §10
npx --yes wrangler@4 deploy
```
- Antes de `push`, confirmar que `.clasp.json` apunta a `1X-qSKgv…`.
- Revisar sintaxis del JS de la interfaz: extraer el `<script>` de `Index.html` y `node --check`.
- La app exige sesión @helgen.tech: no se puede probar la URL publicada desde un navegador sin sesión. Pedir al usuario
que pruebe y mande captura; los errores del servidor se ven con `npx clasp logs` o en el editor (Ejecuciones).
- Al agregar funciones que llamen servicios de Google, extender los simulacros de `tests/`.
## 9. Pendientes e ideas
- **Fase 2 probada y funcionando en PRUEBAS (19/10/2026, confirmado por el usuario)** (§10.5). Siguiente: decidir si se lleva al proyecto real.
- La IA para claves SAT no se ha probado contra la API real; requiere `ANTHROPIC_API_KEY` y correr `probarIaSat()` en el editor.
- Existen archivos previos de Helgen Energy en Mi unidad (no movidos): "Catálogo Maestro de Precios - Helgen Energy" (Sheets),
"Catálogo Helgen Energy - Soluciones 2026" (Slides), "COTIZADOR HELGEN ENERGY.html". Se ofreció importar esos precios al catálogo.
- Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones.
- Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
## 10. Interfaz web en Cloudflare (proyecto de PRUEBAS)
Objetivo: servir la interfaz desde Cloudflare (fuera de script.google.com) y que hable con Apps Script por `doPost`.
Se trabaja **solo contra un proyecto de PRUEBAS** con datos inventados; el proyecto real (`1X-qSKgv…`) no se tocó.
### 10.1 Estado (19/10/2026)
- **Fase 1: probada y funcionando** (confirmado por el usuario). Interfaz publicada en Cloudflare y conectada a la API de PRUEBAS.
- **Fase 2: probada y funcionando** (API @2 + Cloudflare, 19/10/2026). El usuario inició sesión, guardó una cotización y vio el vendedor (§10.5).
### 10.2 Direcciones y archivos
| Qué | Valor |
|---|---|
| Interfaz (para probar) | `https://cotizador-helgen.cotizador-helgen.workers.dev/cotizador/` |
| Portada (botón "Abrir el cotizador" → `cotizador/`) | `https://cotizador-helgen.cotizador-helgen.workers.dev` |
| Worker de Cloudflare | `cotizador-helgen` (solo archivos estáticos de `web/`), cuenta Cloudflare de efrain@helgen.tech `badd8bca60b008822a73ca24e983ff43`, subdominio `cotizador-helgen.workers.dev` |
| Carpeta en Drive "PRUEBAS — Cotizador web" | `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p` (Mi unidad; contiene los 4 de abajo) |
| Proyecto Apps Script "Cotizador Helgen — PRUEBAS interfaz web" | `1GsH8DjaFEZ9dltqBUTUEFE_frblSYimRRCvPkqKdRB5UhmBwwYHLSwyS` |
| Deployment de la API (`API_URL` en `web/cotizador/index.html`) | `AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2` (@2 Fase 2, `access: ANYONE_ANONYMOUS`) |
| Client ID de OAuth (`CLIENT_ID_GOOGLE`, en `web/cotizador/index.html` y en el `Code.js` de PRUEBAS) | `884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com` (no es secreto) |
| "PRUEBAS — Cotizador Helgen — Base de datos" (`SPREADSHEET_ID`) | `10WLYHF2vB2TDmoTXwfAxnG8bp7gDOPPzoj9p6B0tUGY` |
| "PRUEBAS — Catálogo Helgen" (`CATALOGO_ID`) | `1GQBK0_WXSQRuCOeE-Pv8AA6PFA4tjZwCqae4L7WZsH0` |
| "PRUEBAS — Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1iDk4faAM557SZh67EA122dj6eU4RJpsH` |
El usuario confirmó en las Script Properties del proyecto de PRUEBAS que todo apunta a estos archivos de prueba.
Como la app usa IDs (§6.7), moverlos a la carpeta "PRUEBAS — Cotizador web" no rompió nada.
### 10.3 Cómo funciona y cómo se publica
- `web/cotizador/index.html` es `Index.html` con el logo ya incrustado (sin etiquetas `<? ?>`) y un `<script id="api-web">`
que, si no existe `google.script.run`, lo imita con `fetch(API_URL)` (POST `text/plain` para evitar la consulta CORS previa;
cuerpo `{"funcion", "args", "token"}`, respuesta `{"ok", "datos" | "error", "codigo"?}`; `codigo: "SESION"` = falta sesión o no es válida).
- En el proyecto de PRUEBAS, `doPost` primero verifica el token (§10.5) y luego solo acepta las funciones de `API_FUNCIONES`
(las mismas que usa la interfaz).
- Publicar (desde la carpeta del proyecto; usa `wrangler.jsonc`):
```bash
npx --yes wrangler@4 deploy
```
Si pide sesión: `npx --yes wrangler@4 login` (el usuario autoriza en su navegador). Wrangler se usa con `npx`, sin instalarlo.
- Wrangler 4 ya delega Cloudflare Pages "clásico" a Workers: se eligió **Worker con assets** (`wrangler.jsonc`), no `wrangler pages`.
- Probar local: `python3 -m http.server 8788 --bind 127.0.0.1` dentro de `web/`.
- Para comprobar la API con `curl`, usar `-d` sin `-X POST`: con `-X POST -L` curl repite el POST en la redirección
de Google y sale "No se encontró la página" aunque la API funcione.
### 10.4 Riesgo aceptado y Fase 2
- **Riesgo aceptado por el usuario (19/10/2026, Fase 1):** la API de PRUEBAS no pedía identidad. Desde la Fase 2 (@2) exige token
de @helgen.tech en cada llamada. Aun así, en PRUEBAS solo van **datos inventados**.
- **Nunca** apuntar `API_URL` al proyecto real (`1X-qSKgv…`) ni publicar ahí un `doPost` sin la Fase 2.
- **Fase 2 (hecha en PRUEBAS):** login con Google restringido a cuentas @helgen.tech, con el token verificado en `doPost`. Ver §10.5.
### 10.5 Fase 2: sesión con Google (solo en PRUEBAS)
Probada y funcionando (19/10/2026): el usuario inició sesión, guardó una cotización y vio su nombre en el historial.
**Google Cloud (cliente OAuth)**
- Client ID (tipo "Aplicación web"): `884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com`. No es secreto;
está en dos lugares que deben coincidir: `CLIENT_ID_GOOGLE` en `web/cotizador/index.html` y en el `Code.js` de PRUEBAS.
El secreto del cliente no se usa ni se guarda en ningún lado.
- Pantalla de consentimiento: **External en modo Testing, con usuarios de prueba agregados a mano** (decisión del usuario;
no se usó "Interna"). Consecuencias:
- Solo pueden entrar las cuentas de la lista de usuarios de prueba. **Para dar acceso a otro vendedor hay que agregarlo** en
Google Cloud Console → Pantalla de consentimiento de OAuth (Google Auth Platform) → Público → Usuarios de prueba.
- Con External, Google **no** limita por dominio: una cuenta @gmail en la lista podría obtener un token. Por eso el
servidor exige `hd` y `email_verified` (y la prueba lo cubre). No quitar esas revisiones.
- Antes de usarlo con todo el equipo: cambiar a "Interna" (requiere que el proyecto de Cloud esté en la organización helgen.tech).
- Orígenes autorizados de JavaScript: `https://cotizador-helgen.cotizador-helgen.workers.dev` y `http://127.0.0.1:8788`.
Sin URIs de redireccionamiento. Un origen nuevo tarda de minutos a horas; mientras, el botón da `origin_mismatch`.
**Interfaz (`web/cotizador/index.html`, `<script id="api-web">`)**
- Carga Google Identity Services (`https://accounts.google.com/gsi/client`). La pantalla `#login` tapa la app hasta tener token;
botón "Acceder con Google" + One Tap, con `hd: 'helgen.tech'` (solo filtra la lista de cuentas; quien decide es el servidor).
- El ID token va en **cada** llamada como `"token"`. Se guarda en `sessionStorage` y se reutiliza hasta 1 min antes de expirar
(~1 h); las llamadas sin sesión vigente esperan en cola hasta que se inicia sesión.
- Si el servidor responde `codigo: "SESION"`, se borra el token, se muestra el login y la llamada se repite **una vez** al entrar
(guardar es idempotente por `idSolicitud`: no duplica folio).
- Encabezado: nombre del vendedor (del token, solo para mostrar) + botón "Cerrar sesión" (`disableAutoSelect`).
- Tras publicar, el navegador puede mostrar la versión anterior en caché: recargar la página.
**Verificación del token en `doPost` (`Code.js` de PRUEBAS)**
1. `doPost` lee el cuerpo y, **antes de buscar la función**, llama `verificarTokenGoogle_(cuerpo.token)`.
Sin token o con token inválido no se ejecuta nada: responde `{"ok": false, "error": "…", "codigo": "SESION"}`.
2. `verificarTokenGoogle_()`: busca en `CacheService` por la huella SHA-256 del token (`TOK_…`). Si no está, consulta
`https://oauth2.googleapis.com/tokeninfo?id_token=…` con `UrlFetchApp`: Google revisa **firma y expiración**; si no
responde 200, es sesión inválida.
3. `validarClaimsToken(claims, clientId, dominio, ahora)` (lógica pura, en la sección 5, probada en Node) exige:
`aud === CLIENT_ID_GOOGLE`, `iss` = `accounts.google.com` o `https://accounts.google.com`, `exp` en el futuro,
`hd === 'helgen.tech'`, `email_verified` verdadero y correo terminado en `@helgen.tech`. Devuelve
`{email, nombre, apellido}` (de `given_name`/`family_name`; si faltan, se parte `name`). Todo error lleva `sesion = true`.
4. El resultado se guarda en caché hasta que el token expire (máx. 10 min) para no consultar a Google en cada clic.
5. El vendedor queda en la variable global `VENDEDOR_` **solo durante esa petición** (se limpia en `finally`).
`usuarioActual_()` reemplaza a `Session.getActiveUser()`, que con `ANYONE_ANONYMOUS` viene vacío.
- `UrlFetchApp` ya tenía permiso en PRUEBAS (comprobado). Si se cambia de proyecto, el dueño debe autorizarlo una vez desde el editor.
- **`doGet` ya no sirve la app** en PRUEBAS: con `ANYONE_ANONYMOUS`, una página de HtmlService permitiría llamar funciones con
`google.script.run` sin token. Ahora solo responde un texto. (`Index.html` sigue en el proyecto pero no se usa.)
**Columnas de vendedor (hoja `Cotizaciones` de PRUEBAS, 19 columnas)**
- Se agregaron al final (Q:S) `vendedor_email`, `vendedor_nombre`, `vendedor_apellido`. Se llenan con el token verificado,
**nunca** con lo que mande el navegador (la prueba manda un vendedor falso y comprueba que se ignora). Pasan por `textoCelda`.
- Se migran solas: `guardarCotizacion` reescribe los encabezados si no coinciden con `ENCABEZADOS_COTIZACIONES`.
- "Elaboró" (columna K y el PDF) = correo del token. `guardarCotizacion` sin vendedor verificado lanza error de sesión.
- "Actualizó" del catálogo (`guardarProducto`) también usa `usuarioActual_()`.
- Historial: la columna se llama **"Vendedor"**: `listarCotizaciones` devuelve `vendedor` = nombre + apellido; si la fila es vieja
(sin esas columnas), el correo de `vendedor_email` o de "Elaboró".
- `getDatosIniciales` devuelve `usuario` (correo) y `vendedor` (`{email, nombre, apellido}`).
**Trabajar con el código de PRUEBAS** (no vive en esta carpeta)
```bash
npx clasp clone 1GsH8DjaFEZ9dltqBUTUEFE_frblSYimRRCvPkqKdRB5UhmBwwYHLSwyS # en una carpeta temporal (no aquí)
node <ruta>/cotizador-helgen/tests/sesion.test.js <esa carpeta>
npx clasp push --force # desde esa carpeta
npx clasp deploy --deploymentId AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2 -d "descripción"
```
- Antes de `push`, confirmar que el `.clasp.json` de esa carpeta apunta a `1GsH8Dja…` y que nadie cambió el proyecto
(volver a clonar en otra carpeta y comparar).
- Comprobar con `curl` (ver §10.3): sin token → `{"ok":false,"error":"Inicia sesión con tu cuenta @helgen.tech.","codigo":"SESION"}`;
token falso → `"Tu sesión expiró o no es válida…"`.

1037
anterior/Code.gs Normal file

File diff suppressed because it is too large Load Diff

426
anterior/Index.html Normal file
View File

@ -0,0 +1,426 @@
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* Brand Guidelines Helgen (Canva): primarios #2C3C5B / #51F3DA, secundario #0AC5A8 */
:root { --azul:#2C3C5B; --aqua:#51F3DA; --teal:#0AC5A8; --cielo:#F2F4F7; --gris:#6B7280; --borde:#E5E7EB; }
* { box-sizing:border-box; }
body { margin:0; font-family:'Roboto Mono',monospace; background:var(--cielo); color:#1F2937; font-size:13px; }
h1, h2, .btn, nav button { font-family:'League Spartan',Arial,sans-serif; }
header { background:var(--azul); color:#fff; padding:14px 24px; display:flex; align-items:center; gap:24px; flex-wrap:wrap;
border-bottom:3px solid var(--aqua); }
header img { height:30px; display:block; }
header h1 { font-size:16px; margin:0; letter-spacing:2px; color:var(--aqua); }
nav button { background:none; border:none; color:#A9B4C8; font-size:14px; cursor:pointer; padding:6px 10px; border-radius:6px; }
nav button.activo { background:rgba(255,255,255,.12); color:#fff; }
main { max-width:1100px; margin:24px auto; padding:0 16px; }
.modulo { background:#fff; border-radius:10px; padding:20px; margin-bottom:16px; border:1px solid var(--borde); }
.modulo h2 { margin:0 0 14px; font-size:15px; color:var(--azul); border-bottom:2px solid var(--teal); padding-bottom:6px; display:inline-block; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
label { display:block; font-size:12px; color:var(--gris); margin-bottom:4px; }
input, textarea, select { width:100%; padding:8px 10px; border:1px solid var(--borde); border-radius:6px; font:inherit; }
select { background:#fff; }
input:focus, textarea:focus, select:focus { outline:2px solid var(--teal); border-color:transparent; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--gris); padding:6px; border-bottom:1px solid var(--borde); }
td { padding:4px; }
td.num, th.num { text-align:right; white-space:nowrap; }
.btn { background:var(--teal); color:var(--azul); border:none; padding:10px 18px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:14px; }
.btn:hover { background:var(--aqua); }
.btn:disabled { opacity:.5; cursor:wait; }
.btn-sec { background:#fff; color:var(--azul); border:1px solid var(--azul); }
.btn-sec:hover { background:var(--cielo); }
.quitar { background:none; border:none; color:#DC2626; cursor:pointer; font-size:18px; }
.totales { margin-left:auto; width:320px; }
.totales div { display:flex; justify-content:space-between; padding:6px 0; }
.totales .total { background:var(--azul); color:var(--aqua); padding:10px 12px; border-radius:6px; font-size:18px; font-weight:bold; }
.aviso { padding:12px 14px; border-radius:6px; margin-bottom:16px; display:none; }
.aviso.ok { background:#E6FBF7; border:1px solid var(--teal); display:block; }
.aviso.error { background:#FEE2E2; border:1px solid #DC2626; display:block; }
.oculto { display:none; }
.fila-cat { cursor:pointer; }
.fila-cat:hover td { background:var(--cielo); }
.fila-cat.editando td { background:#E6FBF7; }
.acciones { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:12px; }
.nota { font-size:12px; color:var(--gris); }
.sat { margin-top:16px; padding:14px; border:1px dashed var(--borde); border-radius:8px; background:#FAFBFC; }
.sat-titulo { font-size:12px; font-weight:bold; color:var(--azul); margin-bottom:10px; }
.sat-op { display:flex; gap:12px; align-items:center; padding:7px 10px; border:1px solid var(--borde); border-radius:6px;
margin-top:6px; cursor:pointer; background:#fff; }
.sat-op:hover { border-color:var(--teal); background:#E6FBF7; }
.sat-op b { min-width:80px; }
.sat-razon { display:block; color:#5B6B85; font-size:12px; margin-top:2px; }
a { color:var(--azul); font-weight:bold; }
</style>
</head>
<body>
<header>
<img src="<?!= LOGO_HELGEN_BLANCO ?>" alt="Helgen">
<h1>COTIZADOR</h1>
<nav>
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
<button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
</nav>
</header>
<main>
<div id="aviso" class="aviso"></div>
<!-- ===== NUEVA COTIZACIÓN ===== -->
<section id="vistaNueva">
<div class="modulo">
<h2>1 · Datos del cliente</h2>
<div class="grid">
<div><label>Línea de negocio *</label>
<select id="marca">
<option value="helgen">Helgen</option>
<option value="energy">Helgen Energy (paneles solares)</option>
</select></div>
<div><label>Nombre / Contacto *</label>
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
<datalist id="clientesList"></datalist>
<div id="cInfo" class="nota" style="margin-top:4px"></div></div>
<div><label>Empresa</label><input id="cEmpresa"></div>
<div><label>RFC</label><input id="cRfc" maxlength="13"></div>
<div><label>Correo</label><input id="cEmail" type="email"></div>
<div><label>Teléfono</label><input id="cTel"></div>
<div><label>Dirección / Obra</label><input id="cDir"></div>
</div>
</div>
<div class="modulo">
<h2>2 · Conceptos</h2>
<datalist id="catalogo"></datalist>
<datalist id="claves"></datalist>
<table>
<thead><tr>
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">P. Unitario</th>
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
</tr></thead>
<tbody id="filas"></tbody>
</table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
</div>
<div class="modulo">
<h2>3 · Resumen</h2>
<div style="display:flex; gap:24px; flex-wrap:wrap;">
<div style="flex:1; min-width:260px;">
<label>Notas para el cliente (opcional)</label>
<textarea id="notas" rows="4"></textarea>
</div>
<div class="totales">
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
<div class="total"><span>TOTAL</span><span id="tTot">$0.00</span></div>
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
</div>
</div>
</div>
</section>
<!-- ===== HISTORIAL ===== -->
<section id="vistaHist" class="oculto">
<div class="modulo">
<h2>Historial de cotizaciones</h2>
<input id="buscar" placeholder="Buscar por folio, cliente o empresa…" oninput="buscarDebounce()" style="margin-bottom:12px">
<table>
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Elaboró</th><th>PDF</th></tr></thead>
<tbody id="hist"></tbody>
</table>
</div>
</section>
<!-- ===== CATÁLOGO ===== -->
<section id="vistaCat" class="oculto">
<div class="modulo">
<h2 id="tituloProd">Agregar producto</h2>
<div class="grid">
<div><label>Clave *</label><input id="pClave" maxlength="30" placeholder="Ej. CAR-7KW"></div>
<div style="grid-column:span 2"><label>Descripción *</label><input id="pDesc"></div>
<div><label>Unidad</label><input id="pUni" value="PZA"></div>
<div><label>Precio unitario sin IVA *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
</div>
<div class="sat">
<div class="sat-titulo">Datos para facturación (SAT) · uso interno de contabilidad, no aparecen en el PDF</div>
<div class="grid">
<div><label>Clave SAT (producto/servicio)</label><input id="pSat" maxlength="8" inputmode="numeric" placeholder="8 dígitos"></div>
<div><label>Unidad SAT</label><input id="pUniSat" list="unidadesSat" maxlength="3" placeholder="Ej. H87, E48"><datalist id="unidadesSat"></datalist></div>
<div style="grid-column:span 2"><label>Buscar en el catálogo del SAT</label>
<div style="display:flex; gap:8px"><input id="satBusca" placeholder="Se toma de la descripción; puedes cambiar las palabras">
<button class="btn btn-sec" onclick="sugerirSat()" style="white-space:nowrap">Sugerir clave SAT</button></div></div>
</div>
<div id="satRes"></div>
</div>
<div class="acciones">
<button id="btnProd" class="btn" onclick="guardarProd()">Guardar en catálogo</button>
<button class="btn btn-sec" onclick="limpiarProd()">Nuevo / Cancelar</button>
<span class="nota">Haz clic en un producto de la lista para editarlo.</span>
</div>
</div>
<div class="modulo">
<h2 id="tituloLista">Productos</h2>
<div class="acciones" style="margin:0 0 12px">
<input id="buscarCat" placeholder="Buscar por clave o descripción…" oninput="pintarCatalogo()" style="flex:1; min-width:220px">
<a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
</div>
<table>
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th style="width:110px">Clave SAT</th></tr></thead>
<tbody id="listaCat"></tbody>
</table>
</div>
</section>
</main>
<script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [];
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId();
const $ = id => document.getElementById(id);
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---- Arranque ----
google.script.run.withSuccessHandler(d => {
IVA = d.ivaTasa; CATALOGO = d.catalogo;
recibirClientes(d.clientes);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists();
agregarFila();
}).withFailureHandler(mostrarError).getDatosIniciales();
// ---- Conceptos ----
function agregarFila() {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><input class="clave" list="claves"></td>' +
'<td><input class="desc" list="catalogo" placeholder="Escribe o elige del catálogo"></td>' +
'<td><input class="uni" value="PZA"></td>' +
'<td><input class="cant" type="number" min="0" step="any" value="1" style="text-align:right"></td>' +
'<td><input class="pu" type="number" min="0" step="0.01" value="0" style="text-align:right"></td>' +
'<td class="num imp">$0.00</td>' +
'<td><button class="quitar" title="Quitar">×</button></td>';
// Al elegir por descripción o por clave se llenan clave, unidad y precio desde el catálogo
const aplicar = p => {
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.pu').value = p.precio;
};
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalcular));
tr.querySelector('.quitar').onclick = () => { tr.remove(); recalcular(); };
$('filas').appendChild(tr);
tr.querySelector('.desc').focus();
}
function leerItems() {
return [...$('filas').querySelectorAll('tr')].map(tr => ({
clave: tr.querySelector('.clave').value,
descripcion: tr.querySelector('.desc').value,
unidad: tr.querySelector('.uni').value,
cantidad: Number(tr.querySelector('.cant').value) || 0,
precio: Number(tr.querySelector('.pu').value) || 0,
_tr: tr,
}));
}
function recalcular() {
let sub = 0;
leerItems().forEach(it => {
const imp = r2(it.cantidad * r2(it.precio));
it._tr.querySelector('.imp').textContent = mxn(imp);
sub += imp;
});
sub = r2(sub);
const iva = r2(sub * IVA);
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva);
}
// ---- Guardar ----
function guardar() {
const payload = {
cliente: { nombre:$('cNombre').value, empresa:$('cEmpresa').value, rfc:$('cRfc').value,
email:$('cEmail').value, telefono:$('cTel').value, direccion:$('cDir').value },
items: leerItems().map(({_tr, ...it}) => it),
notas: $('notas').value,
marca: $('marca').value,
idSolicitud: ID_SOLICITUD,
};
if ($('btnGuardar').disabled) return;
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run
.withSuccessHandler(r => {
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar();
})
.withFailureHandler(mostrarError)
.guardarCotizacion(payload);
}
function limpiar() {
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = '';
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
ID_SOLICITUD = nuevoId();
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
// ---- Historial ----
let tBusq;
function buscarDebounce() { clearTimeout(tBusq); tBusq = setTimeout(cargarHistorial, 300); }
function cargarHistorial() {
$('hist').innerHTML = '<tr><td colspan="7">Cargando…</td></tr>';
google.script.run.withSuccessHandler(lista => {
$('hist').innerHTML = lista.length ? lista.map(c =>
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.elaboro) + '</td><td>' +
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="7">Sin resultados.</td></tr>';
}).withFailureHandler(mostrarError).listarCotizaciones($('buscar').value);
}
// ---- Clientes que ya cotizaron (salen de la hoja Cotizaciones) ----
const CAMPOS_CLIENTE = { cNombre:'nombre', cEmpresa:'empresa', cRfc:'rfc', cEmail:'email', cTel:'telefono', cDir:'direccion' };
function recibirClientes(lista) {
CLIENTES = lista || [];
$('clientesList').innerHTML = CLIENTES.map(c =>
'<option value="' + esc(c.etiqueta) + '" label="' + esc([c.empresa, c.rfc].filter(Boolean).join(' · ') || 'Cliente registrado') + '">').join('');
}
function autorrellenarCliente() {
const v = $('cNombre').value.trim().toLowerCase();
const c = v && CLIENTES.find(x => x.etiqueta.toLowerCase() === v);
if (!c) { $('cInfo').textContent = ''; return; }
Object.keys(CAMPOS_CLIENTE).forEach(id => $(id).value = c[CAMPOS_CLIENTE[id]] || '');
$('cInfo').textContent = 'Cliente registrado · ' + c.cotizaciones + (c.cotizaciones === 1 ? ' cotización' : ' cotizaciones') +
(c.ultimaFecha ? ' · última ' + c.ultimaFecha : '');
}
$('cNombre').addEventListener('change', autorrellenarCliente);
$('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); });
// ---- Catálogo ----
let CLAVE_EDITANDO = '';
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
function pintarDatalists() {
$('catalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + mxn(p.precio)) + '">').join('');
$('claves').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + mxn(p.precio)) + '">').join('');
}
function cargarCatalogo() {
$('listaCat').innerHTML = '<tr><td colspan="5">Cargando…</td></tr>';
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
}
function recibirCatalogo(r) {
CATALOGO = r.productos;
$('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
if (r.unidadesSat && r.unidadesSat.length) {
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join('');
}
pintarDatalists(); pintarCatalogo();
}
function pintarCatalogo() {
const q = $('buscarCat').value.toLowerCase().trim();
const lista = CATALOGO.filter(p => !q || (p.clave + ' ' + p.descripcion).toLowerCase().includes(q));
$('tituloLista').textContent = 'Productos (' + CATALOGO.length + ')';
$('listaCat').innerHTML = lista.length ? lista.map(p =>
'<tr class="fila-cat' + (p.clave === CLAVE_EDITANDO ? ' editando' : '') + '" data-clave="' + esc(p.clave) + '">' +
'<td><b>' + esc(p.clave) + '</b></td><td>' + esc(p.descripcion) + '</td><td>' + esc(p.unidad) + '</td>' +
'<td class="num">' + mxn(p.precio) + '</td>' +
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
: '<tr><td colspan="5">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
$('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
}
function editarProd(clave) {
const p = buscarClave(clave); if (!p) return;
CLAVE_EDITANDO = p.clave;
$('pClave').value = p.clave; $('pDesc').value = p.descripcion; $('pUni').value = p.unidad; $('pPrecio').value = p.precio;
$('pSat').value = p.claveSat || ''; $('pUniSat').value = p.unidadSat || ''; $('satBusca').value = ''; $('satRes').innerHTML = '';
$('tituloProd').textContent = 'Editar producto ' + p.clave;
$('btnProd').textContent = 'Guardar cambios';
pintarCatalogo(); window.scrollTo(0, 0); $('pDesc').focus();
}
function limpiarProd() {
CLAVE_EDITANDO = '';
['pClave','pDesc','pPrecio','pSat','pUniSat','satBusca'].forEach(id => $(id).value = ''); $('pUni').value = 'PZA';
$('satRes').innerHTML = '';
$('tituloProd').textContent = 'Agregar producto';
$('btnProd').textContent = 'Guardar en catálogo'; $('btnProd').disabled = false;
pintarCatalogo();
}
function guardarProd() {
if ($('btnProd').disabled) return;
const prod = { clave:$('pClave').value, descripcion:$('pDesc').value, unidad:$('pUni').value,
precio:$('pPrecio').value, claveSat:$('pSat').value, unidadSat:$('pUniSat').value,
claveOriginal:CLAVE_EDITANDO };
const editando = !!CLAVE_EDITANDO;
$('btnProd').disabled = true;
google.script.run.withSuccessHandler(r => {
recibirCatalogo(r);
aviso('ok', 'Producto <b>' + esc(r.clave) + '</b> ' + (editando ? 'actualizado' : 'agregado') + ' en el catálogo.');
limpiarProd();
}).withFailureHandler(err => { $('btnProd').disabled = false; mostrarError(err); }).guardarProducto(prod);
}
// ---- Sugerencias de clave SAT ----
function sugerirSat() {
const q = $('satBusca').value.trim() || $('pDesc').value.trim();
if (!q) { aviso('error', 'Escribe la descripción del producto o palabras para buscar en el catálogo del SAT.'); return; }
$('satBusca').value = q;
$('satRes').innerHTML = '<div class="nota" style="margin-top:8px">Buscando en el catálogo del SAT…</div>';
google.script.run.withSuccessHandler(lista => {
$('satRes').innerHTML = lista.length
? '<div class="nota" style="margin-top:10px">' + (lista[0].razon ? 'Sugeridas con IA. ' : '') +
'Elige la que mejor describa el producto (contabilidad debe validarla):</div>' +
lista.map(o => '<div class="sat-op" data-clave="' + esc(o.clave) + '"><b>' + esc(o.clave) + '</b><span>' + esc(o.descripcion) +
(o.razon ? '<small class="sat-razon">' + esc(o.razon) + '</small>' : '') + '</span></div>').join('')
: '<div class="nota" style="margin-top:8px">Sin coincidencias. Prueba con otras palabras (p. ej. el tipo de producto o "servicio de …").</div>';
$('satRes').querySelectorAll('.sat-op').forEach(d => d.onclick = () => {
$('pSat').value = d.dataset.clave; $('satRes').innerHTML = '';
});
}).withFailureHandler(err => { $('satRes').innerHTML = ''; mostrarError(err); }).sugerirClavesSat(q);
}
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
// ---- UI ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva');
$('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo();
$('aviso').className = 'aviso';
}
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
function mostrarError(err) {
aviso('error', esc(err && err.message ? err.message : err));
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
</script>
</body>
</html>

10
anterior/LEEME.md Normal file
View File

@ -0,0 +1,10 @@
# Código del proyecto anterior (EN DESUSO desde el 19/10/2026)
Proyecto Apps Script `1X-qSKgvqKwuDj4iPZBhXlakR5udau9ibVMevhwvlLXgtSkjV4IoQxqwU` ("Cotizador Helgen (código de la app)").
Este es el código de la versión **@9** (la última que usó el equipo). Se guarda solo como referencia y para poder regresar.
- El deployment `AKfycbxahS3PbizPrRrQIC1m2uz-2G8PoT64BqPdtGLu0Ms5t9RKs2ucxH_c3tpJXkj8uLE` está en **@10**:
muestra "El cotizador se mudó" y `guardarCotizacion` / `guardarProducto` lanzan error (para que solo un proyecto escriba en la hoja).
- **Regresar (solo si hace falta):** primero apuntar SPREADSHEET_ID, FOLDER_ID y CATALOGO_ID del proyecto nuevo a los IDs de prueba (no borrarlas: crearía otra base de datos), luego, desde esta carpeta:
`npx clasp deploy --deploymentId AKfycbxahS3PbizPrRrQIC1m2uz-2G8PoT64BqPdtGLu0Ms5t9RKs2ucxH_c3tpJXkj8uLE -V 9 -d "regreso a @9"`
- No usar `clasp push` desde aquí salvo para ese regreso. Nunca correr los dos proyectos guardando a la vez.

5
anterior/Logo.gs Normal file

File diff suppressed because one or more lines are too long

10
anterior/appsscript.json Normal file
View File

@ -0,0 +1,10 @@
{
"timeZone": "America/Mexico_City",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "DOMAIN"
}
}

10
appsscript.json Normal file
View File

@ -0,0 +1,10 @@
{
"timeZone": "America/Mexico_City",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
}
}

View File

@ -0,0 +1,13 @@
const X=require('xlsx'),fs=require('fs');
const [,,xls,out]=process.argv;
const wb=X.readFile(xls,{sheets:['c_ClaveProdServ','c_ClaveUnidad']});
const filas=n=>X.utils.sheet_to_json(wb.Sheets[n],{header:1,defval:''});
const csv=v=>{const s=String(v).replace(/\s+/g,' ').trim();return /[",]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s};
// Productos y servicios: desde el renglón de encabezados; solo vigentes (sin fecha fin)
let p=filas('c_ClaveProdServ'); let i=p.findIndex(r=>r[0]==='c_ClaveProdServ');
const prod=p.slice(i+1).filter(r=>r[0]!==''&&r[1]&&r[6]==='').map(r=>[String(r[0]).padStart(8,'0'),r[1]]);
fs.writeFileSync(out+'/SAT_c_ClaveProdServ.csv','Clave,Descripcion\n'+prod.map(r=>r.map(csv).join(',')).join('\n')+'\n');
let u=filas('c_ClaveUnidad'); i=u.findIndex(r=>r[0]==='c_ClaveUnidad');
const uni=u.slice(i+1).filter(r=>r[0]!==''&&r[1]&&r[5]==='').map(r=>[String(r[0]),r[1],r[6]]);
fs.writeFileSync(out+'/SAT_c_ClaveUnidad.csv','Clave,Nombre,Simbolo\n'+uni.map(r=>r.map(csv).join(',')).join('\n')+'\n');
console.log('prodserv vigentes',prod.length,'de',p.length-i-1,'| unidades vigentes',uni.length);

4097
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"devDependencies": {
"@google/clasp": "^3.4.1"
}
}

56
tests/catalogo.test.js Normal file
View File

@ -0,0 +1,56 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const dir=process.argv[2];
// Acepta Code.js (código actual) o Code.gs (código anterior).
const ext=fs.existsSync(dir+'/Code.js')?'js':'gs';
const src=fs.readFileSync(dir+'/Logo.'+ext,'utf8')+'\n'+fs.readFileSync(dir+'/Code.'+ext,'utf8');
const chain=new Proxy({}, {get:(t,k)=>k==='build'?()=>({}):()=>chain});
function hoja(nombre,m){const h={m,nombre,getName:()=>nombre,setName(n){nombre=n;return h},getLastRow:()=>m.length,
getDataRange:()=>({getValues:()=>m.map(r=>r.slice())}),appendRow:a=>m.push(a),setFrozenRows(){},setColumnWidth(){},
getParent:()=>({getUrl:()=>'https://sheets/catalogo'}),
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({
getValues:()=>m.slice(r-1,r-1+nr).map(x=>x.slice(c-1,c-1+nc)),getValue:()=>(m[r-1]||[])[c-1],
setValue(v){(m[r-1]=m[r-1]||[])[c-1]=v;return this},
setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this},
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this}})};return h}
const vieja=hoja('Catalogo',[['Clave','Descripción','Unidad','Precio unitario'],['EJ-001','Producto de ejemplo (bórrame)','PZA',1000],['car-7','Cargador 7 kW','PZA',15000]]);
const dbSheets={Catalogo:vieja,Config:hoja('Config',[['Clave','Valor']].concat([])),Cotizaciones:hoja('Cotizaciones',[['Folio']])};
let borradas=[],creados=0,catSS;
const db={getId:()=>'db',getSheetByName:n=>dbSheets[n]||null,getSheets:()=>Object.values(dbSheets),deleteSheet:h=>{borradas.push(h.getName());delete dbSheets[h.getName()]}};
const props={SPREADSHEET_ID:'db',FOLDER_ID:'f'};
const ctx={console,Date,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
SpreadsheetApp:{flush(){},newDataValidation:()=>chain,
create:n=>{creados++;const h=hoja('Hoja 1',[]);catSS={getId:()=>'cat',getSheets:()=>[h],getSheetByName:x=>h.getName()===x?h:null,h};return catSS},
openById:id=>id==='cat'?catSS:db},
DriveApp:{getFileById:id=>({getParents:()=>({hasNext:()=>true,next:()=>'carpeta'}),moveTo(){}})},
};
vm.createContext(ctx); vm.runInContext(src,ctx);
// Lógica pura
assert.throws(()=>ctx.normalizarProducto({descripcion:'x',precio:1}),/clave/);
assert.throws(()=>ctx.normalizarProducto({clave:'a',precio:1}),/descripción/);
assert.throws(()=>ctx.normalizarProducto({clave:'a',descripcion:'x',precio:''}),/obligatorio/);
assert.throws(()=>ctx.normalizarProducto({clave:'a',descripcion:'x',precio:-1}),/negativo/);
assert.deepEqual({...ctx.normalizarProducto({clave:' ab-1 ',descripcion:' Panel ',unidad:'',precio:'10.005'})},{clave:'AB-1',descripcion:'Panel',unidad:'PZA',precio:10.01,claveSat:'',unidadSat:''});
assert.equal(ctx.indiceDeClave(['X','Ab-1 '],'ab-1'),1); assert.equal(ctx.indiceDeClave(['X'],''),-1);
// Creación + migración
let r=ctx.getCatalogo();
assert.equal(creados,1); assert.equal(props.CATALOGO_ID,'cat'); assert.deepEqual(borradas,['Catalogo']);
assert.deepEqual(r.productos.map(p=>p.clave),['CAR-7'],'migra sin el ejemplo y normaliza clave');
ctx.getCatalogo(); assert.equal(creados,1,'no crea otro archivo');
// Alta
r=ctx.guardarProducto({clave:'pan-550',descripcion:'Panel solar 550 W',unidad:'pza',precio:'4200'});
assert.equal(r.clave,'PAN-550'); assert.equal(r.productos.length,2);
// Duplicado (otra capitalización)
assert.throws(()=>ctx.guardarProducto({clave:'Pan-550',descripcion:'Otro',precio:1}),/ya existe/);
// Edición de precio
r=ctx.guardarProducto({clave:'PAN-550',descripcion:'Panel solar 550 W',precio:4100,claveOriginal:'PAN-550'});
assert.equal(r.productos.find(p=>p.clave==='PAN-550').precio,4100); assert.equal(r.productos.length,2);
// Cambio de clave hacia una existente: error; hacia una nueva: ok sin duplicar
assert.throws(()=>ctx.guardarProducto({clave:'CAR-7',descripcion:'x',precio:1,claveOriginal:'PAN-550'}),/ya existe/);
r=ctx.guardarProducto({clave:'PAN-550M',descripcion:'Panel solar 550 W mono',precio:4100,claveOriginal:'PAN-550'});
assert.deepEqual(r.productos.map(p=>p.clave),['CAR-7','PAN-550M']);
assert.throws(()=>ctx.guardarProducto({clave:'Z',descripcion:'x',precio:1,claveOriginal:'NOEXISTE'}),/ya no existe/);
assert.equal(catSS.h.m[2][5],'efrain@helgen.tech'); assert.equal(catSS.h.m[0][3],'Precio unitario (sin IVA)');
console.log('OK catálogo', r.productos.map(p=>p.clave+' '+p.precio).join(', '));

74
tests/cotizacion.test.js Normal file
View File

@ -0,0 +1,74 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert');
// Acepta Code.js (código actual) o Code.gs (código anterior); Logo con la misma extensión.
const src=fs.readFileSync(process.argv[2].replace(/Code\.(js|gs)$/,'Logo.$1'),'utf8')+'\n'+fs.readFileSync(process.argv[2],'utf8');
// Hoja simulada (matriz) con getRange(fila,col,nf,nc) / getRange('B:B')
function hoja(m){return{m,getLastRow:()=>m.length,getDataRange:()=>({getValues:()=>m.map(r=>r.slice())}),
appendRow:a=>m.push(a),
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){}}:({
getValues:()=>m.slice(r-1,r-1+nr).map(x=>x.slice(c-1,c-1+nc)),
getValue:()=>(m[r-1]||[])[c-1],
setValue(v){m[r-1][c-1]=v;return this},setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this},
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this}})}}
const cfg=hoja([['Clave','Valor'],['EMPRESA_NOMBRE','Helgen Technologies'],['EMPRESA_RFC','XAXX010101000'],['EMPRESA_WEB','helgen.tech'],['EMPRESA_TELEFONO','#ERROR!'],['EMPRESA_EMAIL','contacto@helgen.tech'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16],
['TERMINOS','Metraje adicional...\nCualquier trabajo o material fuera del alcance descrito se cotizará por separado.']]);
const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas']]);
const props={SPREADSHEET_ID:'x',FOLDER_ID:'y'},cache={},pdfs=[];
const ctx={console,Date,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''},
SpreadsheetApp:{flush(){},openById:()=>({getSheetByName:n=>n==='Config'?cfg:cot})},
DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:b=>{pdfs.push(b);return{getUrl:()=>'u'}}})})})},
};
vm.createContext(ctx); vm.runInContext(src,ctx);
// Código con sesión (Fase 2): guardar exige un vendedor verificado; aquí se simula uno.
if (/let VENDEDOR_\b/.test(src)) vm.runInContext("VENDEDOR_ = {email:'efrain@helgen.tech',nombre:'Efrain',apellido:'Sosa'}", ctx);
const base={cliente:{nombre:'Ana',telefono:'+52 55 1234 5678'},items:[{descripcion:'Servicio',cantidad:1,precio:100}]};
ctx.guardarCotizacion({...base,idSolicitud:'h1'});
ctx.guardarCotizacion({...base,marca:'energy',idSolicitud:'e1'});
// Migración de Config
const claves=cfg.m.map(r=>r[0]);
assert(!claves.includes('TERMINOS')&&claves.includes('TERMINOS_HELGEN')&&claves.includes('TERMINOS_ENERGY'));
const val=k=>cfg.m.find(r=>r[0]===k)[1];
assert.equal(val('EMPRESA_TELEFONO'),'+52 442 644 5391'); assert.equal(val('EMPRESA_RFC'),'HSE240304PC1');
assert.equal(val('EMPRESA_NOMBRE'),'Helgen Servicios Especializados');
// Migrar de nuevo no duplica
ctx.guardarCotizacion({...base,idSolicitud:'h2'}); assert.equal(cfg.m.filter(r=>r[0]==='TERMINOS_HELGEN').length,1);
// PDF Helgen: solo vigencia
const [h,e]=pdfs.map(p=>p.html);
const li=x=>(x.match(/<li>/g)||[]).length;
assert.equal(li(h),1); assert(h.includes('Precios vigentes por 15 días o hasta agotar existencias'));
assert(!h.includes('Metraje')&&!h.includes('alcance')&&!h.includes('HELGEN ENERGY')&&!h.includes('#ERROR'));
assert.equal(li(e),4); assert(e.includes('Metraje adicional')&&e.includes('Obra civil')&&e.includes('HELGEN ENERGY')&&!e.includes('alcance'));
// Hoja Cotizaciones
assert.equal(cot.m[0][14],'Marca'); assert.equal(cot.m[0][15],'Dirección'); assert.equal(cot.m[1][14],'Helgen'); assert.equal(cot.m[2][14],'Helgen Energy');
assert.equal(cot.m[1][6],"'+52 55 1234 5678");
assert.deepEqual(cot.m.slice(1).map(r=>r[0]),['HLG-2026-0001','HLG-2026-0002','HLG-2026-0003']);
assert.equal(ctx.guardarCotizacion({...base,idSolicitud:'h1'}).folio,'HLG-2026-0001','reenvío = mismo folio');
delete props.CONSECUTIVO_2026; assert.equal(ctx.guardarCotizacion({...base,idSolicitud:'z'}).folio,'HLG-2026-0004','contador borrado no repite');
fs.writeFileSync(process.argv[3]+'/helgen.html',h); fs.writeFileSync(process.argv[3]+'/energy.html',e);
assert(h.includes('data:image/png;base64,iVBOR')&&h.includes('#2C3C5B')&&h.includes('#51F3DA')&&!/#0B1C3F|#4CAF50/.test(h));
assert(h.includes('HSE240304PC1')&&h.includes('Sendero del Regocijo 32 int 1'));
// Clientes
const F=(fecha,n,e,rfc,mail,tel,dir)=>{const r=['F',fecha,n,e,rfc,mail,tel];r[15]=dir;return r};
let cl=ctx.clientesDesdeFilas([
F('01/10/2026','Juan Pérez','Acme','', 'j@a.com','', 'Calle 1'),
F('02/10/2026','juan perez','ACME','AAA010101AAA','', '442 1', ''), // mismo cliente (acentos/mayúsculas/espacios)
F('03/10/2026','Juan Pérez','Otra SA','', '', '', ''), // mismo nombre, otra empresa
F('04/10/2026','Ana','','', '', '', ''),
F('05/10/2026','','SinNombre','', '', '', ''),
]);
assert.equal(cl.length,3);
assert.deepEqual(cl.map(c=>c.etiqueta),['Ana','Juan Pérez — Otra SA','juan perez — ACME']);
const acme=cl.find(c=>/acme/i.test(c.empresa));
assert.equal(acme.cotizaciones,2); assert.equal(acme.rfc,'AAA010101AAA'); assert.equal(acme.email,'j@a.com','dato vacío no borra el anterior');
assert.equal(acme.direccion,'Calle 1'); assert.equal(acme.telefono,'442 1'); assert.equal(acme.ultimaFecha,'02/10/2026');
assert.deepEqual([...ctx.clientesDesdeFilas([F('x','Solo','','','','','')])].map(c=>c.etiqueta),['Solo']);
// Desde la hoja: el cliente guardado aparece con su dirección; si se borran sus filas desaparece
ctx.guardarCotizacion({cliente:{nombre:'Luis',empresa:'Solar MX',direccion:'Av. 5'},items:[{descripcion:'x',cantidad:1,precio:1}],idSolicitud:'cl1'});
let lc=ctx.getClientes(); const luis=lc.find(c=>c.nombre==='Luis');
assert(luis&&luis.direccion==='Av. 5'&&luis.empresa==='Solar MX'); assert.equal(lc[0].nombre,'Luis','más reciente primero');
cot.m.splice(1); assert.equal(ctx.getClientes().length,0,'sin filas = sin sugerencias');
console.log('OK');

View File

@ -0,0 +1,10 @@
const fs=require('fs'),vm=require('vm');
const ctx={console};vm.createContext(ctx);vm.runInContext(fs.readFileSync(process.argv[2],'utf8'),ctx);
function csv(t){return t.trim().split('\n').slice(1).map(l=>{const m=l.match(/^([^,]*),(.*)$/);return [m[1],m[2].replace(/^"|"$/g,'').replace(/""/g,'"')]})}
const filas=csv(fs.readFileSync(process.argv[3],'utf8'));
const qs=['Panel solar 550 W','Inversor híbrido 5 kW','Cargador para vehículo eléctrico 7 kW','Estación de carga para auto eléctrico',
'Instalación de paneles solares','Mantenimiento preventivo','Cable de cobre calibre 10','Servicio de rastreo GPS vehicular',
'Limpieza de patios','Batería de litio','Estructura de aluminio para paneles','Centro de carga','Mano de obra eléctrica'];
console.time('13 búsquedas');
for(const q of qs){const r=ctx.buscarClavesSat(q,filas,5);console.log('\n» '+q);r.forEach(x=>console.log(' '+x.clave+' '+x.puntaje.toFixed(2)+' '+x.descripcion))}
console.timeEnd('13 búsquedas');

61
tests/sat.test.js Normal file
View File

@ -0,0 +1,61 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const dir=process.argv[2], csvDir=process.argv[3];
// Acepta Code.js (código actual) o Code.gs (código anterior).
const ext=fs.existsSync(dir+'/Code.js')?'js':'gs';
const src=fs.readFileSync(dir+'/Logo.'+ext,'utf8')+'\n'+fs.readFileSync(dir+'/Code.'+ext,'utf8');
const chain=new Proxy({}, {get:(t,k)=>k==='build'?()=>({}):()=>chain});
function hoja(nombre,m){const h={m,getName:()=>nombre,setName(n){nombre=n;return h},getLastRow:()=>m.length,getMaxRows:()=>Math.max(1000,m.length),
insertRowsAfter(){},getDataRange:()=>({getValues:()=>m.map(r=>r.slice())}),appendRow:a=>m.push(a),setFrozenRows(){},setColumnWidth(){},
getParent:()=>({getUrl:()=>'https://sheets/x'}),
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({
getValues:()=>Array.from({length:nr},(_,i)=>Array.from({length:nc},(_,j)=>((m[r-1+i]||[])[c-1+j]??''))),
getValue:()=>(m[r-1]||[])[c-1],
setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this},
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},
createTextFinder:t=>({matchEntireCell:()=>({findNext:()=>m.slice(r-1,r-1+nr).some(x=>String(x[c-1])===t)?{}:null})})})};return h}
function libro(id,hojas){return{getId:()=>id,getSheets:()=>hojas,getSheetByName:n=>hojas.find(h=>h.getName()===n)||null,
insertSheet(n){const h=hoja(n||'Hoja',[]);hojas.push(h);return h}}}
const parseCsv=t=>t.trim().split('\n').map(l=>{const out=[];let cur='',q=false;for(let i=0;i<l.length;i++){const ch=l[i];
if(q){if(ch=='"'&&l[i+1]=='"'){cur+='"';i++}else if(ch=='"')q=false;else cur+=ch}else if(ch=='"')q=true;else if(ch==','){out.push(cur);cur=''}else cur+=ch}
out.push(cur);return out});
const cat=hoja('Catalogo',[['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó'],['PAN-550','Panel solar 550 W','PZA',4200,'','x']]);
const cot=hoja('Cotizaciones',[['Folio']]), cfg=hoja('Config',[['Clave','Valor']]);
const libros={db:libro('db',[cot,cfg]),cat:libro('cat',[cat])}; let nuevo=0;
const props={SPREADSHEET_ID:'db',FOLDER_ID:'f',CATALOGO_ID:'cat'},cache={};
const archivos={'SAT_c_ClaveProdServ.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveProdServ.csv','utf8'),'SAT_c_ClaveUnidad.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveUnidad.csv','utf8')};
const ctx={console,Date,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
Utilities:{parseCsv,formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''},
SpreadsheetApp:{flush(){},newDataValidation:()=>chain,create:n=>{nuevo++;const l=libro('sat',[hoja('Hoja 1',[])]);libros.sat=l;return l},openById:id=>libros[id]},
DriveApp:{getFilesByName:n=>{let d=false;return{hasNext:()=>!d&&!!archivos[n],next:()=>{d=true;return{isTrashed:()=>false,getParents:()=>({next:()=>({})}),getBlob:()=>({getDataAsString:()=>archivos[n]})}}}},
getFileById:()=>({moveTo(){},getParents:()=>({hasNext:()=>false})}),
getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:b=>({getUrl:()=>'u'})})})})},
};
vm.createContext(ctx); vm.runInContext(src,ctx);
// Código con sesión (Fase 2): guardar exige un vendedor verificado; aquí se simula uno.
if (/let VENDEDOR_\b/.test(src)) vm.runInContext("VENDEDOR_ = {email:'efrain@helgen.tech',nombre:'Efrain',apellido:'Sosa'}", ctx);
// Columnas SAT agregadas a un catálogo existente
let r=ctx.getCatalogo(); assert.equal(cat.m[0][6],'Clave SAT'); assert.equal(cat.m[0][7],'Unidad SAT');
assert.equal(nuevo,1,'importa el catálogo SAT una vez'); assert(r.unidadesSat.length>2000);
assert.equal(libros.sat.getSheetByName('ClaveProdServ').m.length,52514); assert.equal(libros.sat.getSheetByName('ClaveProdServ').m[1][0],'01010101');
ctx.getCatalogo(); assert.equal(nuevo,1);
// Sugerencias
let s=ctx.sugerirClavesSat('Panel solar 550 W'); assert(s.some(x=>x.clave==='26111607'),'sugiere generadores solares');
assert.deepEqual(s.map(x=>x.clave).length,8);
assert.equal(ctx.sugerirClavesSat('26111607')[0].descripcion,'Generadores solares');
assert.equal(ctx.sugerirClavesSat('1010101')[0].clave,'01010101','repone el cero');
// Validación al guardar
assert.throws(()=>ctx.guardarProducto({clave:'PAN-550',descripcion:'Panel',precio:1,claveSat:'99999999',claveOriginal:'PAN-550'}),/no existe/);
assert.throws(()=>ctx.guardarProducto({clave:'PAN-550',descripcion:'Panel',precio:1,unidadSat:'ZZZ',claveOriginal:'PAN-550'}),/no existe/);
assert.throws(()=>ctx.guardarProducto({clave:'PAN-550',descripcion:'Panel',precio:1,claveSat:'12ab'}),/8 dígitos/);
r=ctx.guardarProducto({clave:'PAN-550',descripcion:'Panel solar 550 W',precio:4200,claveSat:'26111607',unidadSat:'h87',claveOriginal:'PAN-550'});
const p=r.productos[0]; assert.equal(p.claveSat,'26111607'); assert.equal(p.unidadSat,'H87'); assert.equal(cat.m[1][6],'26111607');
// Sin datos SAT sigue siendo válido
ctx.guardarProducto({clave:'SERV-1',descripcion:'Servicio sin clasificar',precio:100});
// Cotización: el concepto del catálogo guarda su clave SAT en el JSON, y el PDF no la muestra
const res=ctx.guardarCotizacion({cliente:{nombre:'Ana'},items:[{clave:'pan-550',descripcion:'Panel solar 550 W',cantidad:2,precio:4200},{descripcion:'Libre',cantidad:1,precio:5}],idSolicitud:'s1'});
const items=JSON.parse(cot.m[1][12]); assert.equal(items[0].claveSat,'26111607'); assert.equal(items[0].unidadSat,'H87'); assert(!('claveSat' in items[1]));
console.log('OK SAT', res.folio);

89
tests/sesion.test.js Normal file
View File

@ -0,0 +1,89 @@
// Sesión con Google en doPost y columnas vendedor_*.
// Uso: node tests/sesion.test.js . (carpeta con Code.js y Logo.js)
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
const dir=process.argv[2];
let src=fs.readFileSync(path.join(dir,'Logo.js'),'utf8')+'\n'+fs.readFileSync(path.join(dir,'Code.js'),'utf8');
const CID='123-abc.apps.googleusercontent.com';
src=src.replace(/const CLIENT_ID_GOOGLE = '[^']*';/,"const CLIENT_ID_GOOGLE = '"+CID+"';");
function hoja(m){return{m,getLastRow:()=>m.length,getDataRange:()=>({getValues:()=>m.map(r=>r.slice()),getDisplayValues:()=>m.map(r=>r.map(x=>x instanceof Date?'19/10/2026':String(x??'')))}),
appendRow:a=>m.push(a),
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){}}:({
getValues:()=>m.slice(r-1,r-1+nr).map(x=>{const f=x.slice(c-1,c-1+nc);while(f.length<nc)f.push('');return f}),
setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this},
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this}})}}
const cfg=hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]);
// Hoja con una fila vieja (sin columnas vendedor_*)
const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas','Marca','Dirección'],
['HLG-2026-0001','x','Viejo','','','','',1,0.16,1.16,'antes@helgen.tech','u','[]','','Helgen','']]);
const props={SPREADSHEET_ID:'x',FOLDER_ID:'y',CATALOGO_ID:'c'},cache={};
const ahora=Math.floor(Date.now()/1000);
const BUENO={aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email:'Ana.Lopez@helgen.tech',email_verified:'true',hd:'helgen.tech',given_name:'Ana',family_name:'López Díaz'};
const tokens={'t-bueno':BUENO,'t-gmail':{...BUENO,email:'ana@gmail.com',hd:undefined},'t-otro-aud':{...BUENO,aud:'otro.apps.googleusercontent.com'},
't-otro-dominio':{...BUENO,email:'x@otra.com',hd:'otra.com'}};
let fetches=0;
const ctx={console,Date,JSON,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>''})}, // despliegue anónimo: Google no da el correo
Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'19/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),
base64Encode:b=>Buffer.from(b).toString('base64'),DigestAlgorithm:{SHA_256:'sha256'},
computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]},
UrlFetchApp:{fetch:url=>{fetches++;const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t];
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})},
SpreadsheetApp:{flush(){},openById:id=>id==='c'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:{getSheetByName:n=>n==='Config'?cfg:cot}},
DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:()=>({getUrl:()=>'u'})})})})},
};
vm.createContext(ctx); vm.runInContext(src,ctx);
const post=(cuerpo)=>JSON.parse(ctx.doPost({postData:{contents:typeof cuerpo==='string'?cuerpo:JSON.stringify(cuerpo)}}).t);
const base={cliente:{nombre:'Cliente Uno'},items:[{descripcion:'Servicio',cantidad:1,precio:100}]};
// --- validarClaimsToken (lógica pura) ---
const v=(c)=>ctx.validarClaimsToken(c,CID,'helgen.tech',ahora);
assert.deepEqual({...v(BUENO)},{email:'ana.lopez@helgen.tech',nombre:'Ana',apellido:'López Díaz'});
assert.deepEqual({...v({...BUENO,given_name:'',family_name:'',name:'Luis Pérez Gómez'})},{email:'ana.lopez@helgen.tech',nombre:'Luis',apellido:'Pérez Gómez'});
assert.equal(v({...BUENO,email_verified:true}).email,'ana.lopez@helgen.tech');
for(const [malo,msg] of [[{aud:'otro'},/no corresponde/],[{iss:'evil.com'},/inválida/],[{exp:String(ahora-1)},/expiró/],
[{hd:undefined,email:'ana@gmail.com'},/@helgen.tech/],[{hd:'otra.com'},/@helgen.tech/],[{email_verified:'false'},/@helgen.tech/],
[{email:'ana@helgen.tech.mx'},/@helgen.tech/]]){
assert.throws(()=>v({...BUENO,...malo}),e=>e.sesion===true&&msg.test(e.message),JSON.stringify(malo));
}
// --- doPost rechaza sin token válido y no ejecuta nada ---
const filasAntes=cot.m.length;
for(const tok of [undefined,'','t-falso','t-gmail','t-otro-aud','t-otro-dominio']){
const r=post({funcion:'guardarCotizacion',args:[{...base,idSolicitud:'x-'+tok}],token:tok});
assert.equal(r.ok,false,String(tok)); assert.equal(r.codigo,'SESION',String(tok));
}
assert.equal(cot.m.length,filasAntes,'sin sesión no se guarda nada');
assert.equal(post('no es json').ok,false);
assert.equal(post({funcion:'setup',token:'t-bueno'}).error,'Función no permitida: setup.');
assert.equal(post({funcion:'guardarCotizacion',args:[base]}).codigo,'SESION');
// --- Guardar con token válido: vendedor del token, aunque el navegador mande otro ---
fetches=0;
const r=post({funcion:'guardarCotizacion',token:'t-bueno',
args:[{...base,idSolicitud:'ok1',vendedor_email:'impostor@helgen.tech',vendedor:{email:'impostor@helgen.tech',nombre:'X'},elaboro:'impostor@helgen.tech'}]});
assert(r.ok,r.error); assert.equal(r.datos.folio,'HLG-2026-0002');
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido']);
const fila=cot.m[cot.m.length-1];
assert.equal(fila[10],'ana.lopez@helgen.tech','Elaboró = correo del token');
assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz']);
assert(!JSON.stringify(fila).includes('impostor'));
// Caché: el token ya se verificó (en la llamada a setup); no se vuelve a consultar a Google
assert.equal(post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).ok,true); assert.equal(fetches,0);
// Reenvío del mismo formulario = mismo folio (idempotencia intacta)
assert.equal(post({funcion:'guardarCotizacion',args:[{...base,idSolicitud:'ok1'}],token:'t-bueno'}).datos.folio,'HLG-2026-0002');
// --- Historial con el nombre del vendedor; filas viejas muestran el correo de "Elaboró" ---
const h=post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).datos;
assert.equal(h[0].vendedor,'Ana López Díaz'); assert.equal(h[1].vendedor,'antes@helgen.tech');
// Datos iniciales: usuario = correo del token
const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos;
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
// Fuera de doPost (sin token) guardar se rechaza
assert.throws(()=>ctx.guardarCotizacion({...base,idSolicitud:'directo'}),/Inicia sesión/);
// doGet ya no sirve la app
assert(/solo la API/.test(ctx.doGet().t));
console.log('OK');

2
web/.assetsignore Normal file
View File

@ -0,0 +1,2 @@
Icon*
.DS_Store

615
web/cotizador/index.html Normal file
View File

@ -0,0 +1,615 @@
<!DOCTYPE html>
<html lang="es-MX">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Cotizador Helgen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* Brand Guidelines Helgen (Canva): primarios #2C3C5B / #51F3DA, secundario #0AC5A8 */
:root { --azul:#2C3C5B; --aqua:#51F3DA; --teal:#0AC5A8; --cielo:#F2F4F7; --gris:#6B7280; --borde:#E5E7EB; }
* { box-sizing:border-box; }
body { margin:0; font-family:'Roboto Mono',monospace; background:var(--cielo); color:#1F2937; font-size:13px; }
h1, h2, .btn, nav button { font-family:'League Spartan',Arial,sans-serif; }
header { background:var(--azul); color:#fff; padding:14px 24px; display:flex; align-items:center; gap:24px; flex-wrap:wrap;
border-bottom:3px solid var(--aqua); }
header img { height:30px; display:block; }
header h1 { font-size:16px; margin:0; letter-spacing:2px; color:var(--aqua); }
nav button { background:none; border:none; color:#A9B4C8; font-size:14px; cursor:pointer; padding:6px 10px; border-radius:6px; }
nav button.activo { background:rgba(255,255,255,.12); color:#fff; }
main { max-width:1100px; margin:24px auto; padding:0 16px; }
.modulo { background:#fff; border-radius:10px; padding:20px; margin-bottom:16px; border:1px solid var(--borde); }
.modulo h2 { margin:0 0 14px; font-size:15px; color:var(--azul); border-bottom:2px solid var(--teal); padding-bottom:6px; display:inline-block; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
label { display:block; font-size:12px; color:var(--gris); margin-bottom:4px; }
input, textarea, select { width:100%; padding:8px 10px; border:1px solid var(--borde); border-radius:6px; font:inherit; }
select { background:#fff; }
input:focus, textarea:focus, select:focus { outline:2px solid var(--teal); border-color:transparent; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--gris); padding:6px; border-bottom:1px solid var(--borde); }
td { padding:4px; }
td.num, th.num { text-align:right; white-space:nowrap; }
.btn { background:var(--teal); color:var(--azul); border:none; padding:10px 18px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:14px; }
.btn:hover { background:var(--aqua); }
.btn:disabled { opacity:.5; cursor:wait; }
.btn-sec { background:#fff; color:var(--azul); border:1px solid var(--azul); }
.btn-sec:hover { background:var(--cielo); }
.quitar { background:none; border:none; color:#DC2626; cursor:pointer; font-size:18px; }
.totales { margin-left:auto; width:320px; }
.totales div { display:flex; justify-content:space-between; padding:6px 0; }
.totales .total { background:var(--azul); color:var(--aqua); padding:10px 12px; border-radius:6px; font-size:18px; font-weight:bold; }
.aviso { padding:12px 14px; border-radius:6px; margin-bottom:16px; display:none; }
.aviso.ok { background:#E6FBF7; border:1px solid var(--teal); display:block; }
.aviso.error { background:#FEE2E2; border:1px solid #DC2626; display:block; }
.oculto { display:none; }
.fila-cat { cursor:pointer; }
.fila-cat:hover td { background:var(--cielo); }
.fila-cat.editando td { background:#E6FBF7; }
.acciones { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:12px; }
.nota { font-size:12px; color:var(--gris); }
.sat { margin-top:16px; padding:14px; border:1px dashed var(--borde); border-radius:8px; background:#FAFBFC; }
.sat-titulo { font-size:12px; font-weight:bold; color:var(--azul); margin-bottom:10px; }
.sat-op { display:flex; gap:12px; align-items:center; padding:7px 10px; border:1px solid var(--borde); border-radius:6px;
margin-top:6px; cursor:pointer; background:#fff; }
.sat-op:hover { border-color:var(--teal); background:#E6FBF7; }
.sat-op b { min-width:80px; }
.sat-razon { display:block; color:#5B6B85; font-size:12px; margin-top:2px; }
a { color:var(--azul); font-weight:bold; }
/* Sesión con Google (solo cuentas @helgen.tech) */
.usuario { margin-left:auto; display:flex; align-items:center; gap:12px; font-size:12px; color:#D5DCE8; }
.usuario.oculto { display:none; }
.usuario button { background:none; border:1px solid #A9B4C8; color:#fff; border-radius:6px; padding:5px 10px; cursor:pointer;
font-family:'League Spartan',Arial,sans-serif; font-size:13px; }
.usuario button:hover { border-color:var(--aqua); color:var(--aqua); }
#login { position:fixed; inset:0; z-index:10; background:var(--azul); display:flex; align-items:center; justify-content:center;
padding:16px; overflow-y:auto; }
#login.oculto { display:none; }
.login-envoltura { width:100%; max-width:380px; text-align:center; }
.login-envoltura > img { height:34px; margin-bottom:18px; }
.login-caja { background:#fff; border-radius:12px; border-top:4px solid var(--aqua); padding:28px 24px; }
.login-caja h2 { font-family:'League Spartan',Arial,sans-serif; color:var(--azul); font-size:18px; margin:0 0 6px; }
.login-caja p { color:var(--gris); margin:0 0 18px; }
#gisBoton { display:flex; justify-content:center; min-height:44px; }
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
#loginMsg:empty { display:none; }
</style>
</head>
<body>
<header>
<img src="data:image/png;base64,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" alt="Helgen">
<h1>COTIZADOR</h1>
<nav>
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
<button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
</nav>
<div id="usuario" class="usuario oculto">
<span id="usuarioNombre"></span>
<button type="button" onclick="cerrarSesion()">Cerrar sesión</button>
</div>
</header>
<!-- Inicio de sesión: tapa la app hasta tener un token de Google de @helgen.tech -->
<div id="login">
<div class="login-envoltura">
<img id="loginLogo" alt="Helgen">
<div class="login-caja">
<h2>Cotizador Helgen</h2>
<p>Inicia sesión con tu cuenta @helgen.tech</p>
<div id="gisBoton"></div>
<p id="loginMsg"></p>
</div>
</div>
</div>
<main>
<div id="aviso" class="aviso"></div>
<!-- ===== NUEVA COTIZACIÓN ===== -->
<section id="vistaNueva">
<div class="modulo">
<h2>1 · Datos del cliente</h2>
<div class="grid">
<div><label>Línea de negocio *</label>
<select id="marca">
<option value="helgen">Helgen</option>
<option value="energy">Helgen Energy (paneles solares)</option>
</select></div>
<div><label>Nombre / Contacto *</label>
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
<datalist id="clientesList"></datalist>
<div id="cInfo" class="nota" style="margin-top:4px"></div></div>
<div><label>Empresa</label><input id="cEmpresa"></div>
<div><label>RFC</label><input id="cRfc" maxlength="13"></div>
<div><label>Correo</label><input id="cEmail" type="email"></div>
<div><label>Teléfono</label><input id="cTel"></div>
<div><label>Dirección / Obra</label><input id="cDir"></div>
</div>
</div>
<div class="modulo">
<h2>2 · Conceptos</h2>
<datalist id="catalogo"></datalist>
<datalist id="claves"></datalist>
<table>
<thead><tr>
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">P. Unitario</th>
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
</tr></thead>
<tbody id="filas"></tbody>
</table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
</div>
<div class="modulo">
<h2>3 · Resumen</h2>
<div style="display:flex; gap:24px; flex-wrap:wrap;">
<div style="flex:1; min-width:260px;">
<label>Notas para el cliente (opcional)</label>
<textarea id="notas" rows="4"></textarea>
</div>
<div class="totales">
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
<div class="total"><span>TOTAL</span><span id="tTot">$0.00</span></div>
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
</div>
</div>
</div>
</section>
<!-- ===== HISTORIAL ===== -->
<section id="vistaHist" class="oculto">
<div class="modulo">
<h2>Historial de cotizaciones</h2>
<input id="buscar" placeholder="Buscar por folio, cliente o empresa…" oninput="buscarDebounce()" style="margin-bottom:12px">
<table>
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Vendedor</th><th>PDF</th></tr></thead>
<tbody id="hist"></tbody>
</table>
</div>
</section>
<!-- ===== CATÁLOGO ===== -->
<section id="vistaCat" class="oculto">
<div class="modulo">
<h2 id="tituloProd">Agregar producto</h2>
<div class="grid">
<div><label>Clave *</label><input id="pClave" maxlength="30" placeholder="Ej. CAR-7KW"></div>
<div style="grid-column:span 2"><label>Descripción *</label><input id="pDesc"></div>
<div><label>Unidad</label><input id="pUni" value="PZA"></div>
<div><label>Precio unitario sin IVA *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
</div>
<div class="sat">
<div class="sat-titulo">Datos para facturación (SAT) · uso interno de contabilidad, no aparecen en el PDF</div>
<div class="grid">
<div><label>Clave SAT (producto/servicio)</label><input id="pSat" maxlength="8" inputmode="numeric" placeholder="8 dígitos"></div>
<div><label>Unidad SAT</label><input id="pUniSat" list="unidadesSat" maxlength="3" placeholder="Ej. H87, E48"><datalist id="unidadesSat"></datalist></div>
<div style="grid-column:span 2"><label>Buscar en el catálogo del SAT</label>
<div style="display:flex; gap:8px"><input id="satBusca" placeholder="Se toma de la descripción; puedes cambiar las palabras">
<button class="btn btn-sec" onclick="sugerirSat()" style="white-space:nowrap">Sugerir clave SAT</button></div></div>
</div>
<div id="satRes"></div>
</div>
<div class="acciones">
<button id="btnProd" class="btn" onclick="guardarProd()">Guardar en catálogo</button>
<button class="btn btn-sec" onclick="limpiarProd()">Nuevo / Cancelar</button>
<span class="nota">Haz clic en un producto de la lista para editarlo.</span>
</div>
</div>
<div class="modulo">
<h2 id="tituloLista">Productos</h2>
<div class="acciones" style="margin:0 0 12px">
<input id="buscarCat" placeholder="Buscar por clave o descripción…" oninput="pintarCatalogo()" style="flex:1; min-width:220px">
<a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
</div>
<table>
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th style="width:110px">Clave SAT</th></tr></thead>
<tbody id="listaCat"></tbody>
</table>
</div>
</section>
</main>
<script id="api-web">
// Fuera de Apps Script (Cloudflare) no existe google.script.run: esta capa lo imita con fetch al doPost.
// Cada llamada lleva el ID token de Google; el servidor lo verifica (aud, dominio) antes de ejecutar nada.
// Dentro de Apps Script no hace nada y se usa el original.
(function () {
if (window.google && google.script && google.script.run) return;
const API_URL = 'https://script.google.com/macros/s/AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2/exec';
// Client ID de OAuth (no es secreto). Debe ser el mismo que CLIENT_ID_GOOGLE en Code.js (proyecto de Apps Script 1GsH8Dja…).
const CLIENT_ID_GOOGLE = '884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com';
const DOMINIO = 'helgen.tech';
const CLAVE_SESION = 'cotizadorHelgenSesion';
const $ = id => document.getElementById(id);
// ---- Sesión ----
let token = null, expira = 0, gisListo = false;
const pendientes = []; // llamadas que esperan a que se inicie sesión
// Solo para mostrar el nombre y saber cuándo expira; quien decide es el servidor al verificar el token
function leerToken(t) {
const b = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(b + '==='.slice((b.length + 3) % 4)), c => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const vigente = () => !!token && expira * 1000 > Date.now() + 60000; // margen de 1 min
function pintarUsuario(datos) {
const nombre = [datos.given_name, datos.family_name].filter(Boolean).join(' ') || datos.name || datos.email;
$('usuarioNombre').textContent = nombre;
$('usuarioNombre').title = datos.email || '';
$('usuario').classList.remove('oculto');
}
function guardarSesion(t) {
const datos = leerToken(t);
token = t; expira = Number(datos.exp) || 0;
try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {}
pintarUsuario(datos);
}
function olvidarSesion() {
token = null; expira = 0;
try { sessionStorage.removeItem(CLAVE_SESION); } catch (e) {}
$('usuario').classList.add('oculto');
}
function mostrarLogin(mensaje) {
const yaVisible = !$('login').classList.contains('oculto');
$('loginMsg').textContent = mensaje || '';
$('login').classList.remove('oculto');
if (gisListo && !yaVisible) google.accounts.id.prompt(); // One Tap; el botón queda como respaldo
}
function pedirSesion(mensaje) {
olvidarSesion();
mostrarLogin(mensaje);
}
// Respuesta de Google Identity Services al elegir cuenta
function alIniciarSesion(resp) {
let datos;
try { datos = leerToken(resp.credential); } catch (e) { mostrarLogin('No se pudo leer la sesión. Intenta de nuevo.'); return; }
if (datos.hd !== DOMINIO) { mostrarLogin('Solo se permiten cuentas @' + DOMINIO + '.'); return; }
guardarSesion(resp.credential);
$('login').classList.add('oculto');
pendientes.splice(0).forEach(f => f());
}
window.cerrarSesion = function () {
if (gisListo) google.accounts.id.disableAutoSelect();
pedirSesion('Cerraste sesión.');
};
// ---- Llamadas al servidor ----
function llamar(funcion, args, alExito, alFallar, esReintento) {
const fallar = msg => { if (alFallar) alFallar(new Error(msg)); else console.error(msg); };
if (!/^https:\/\//.test(API_URL)) { fallar('Falta configurar la URL del servidor (API_URL).'); return; }
if (!vigente()) {
pendientes.push(() => llamar(funcion, args, alExito, alFallar, esReintento));
if (token) pedirSesion('Tu sesión expiró. Vuelve a iniciar sesión.'); else mostrarLogin();
return;
}
// text/plain: es una "solicitud simple" y el navegador no hace la consulta CORS previa (Apps Script no la responde)
fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' },
body: JSON.stringify({ funcion: funcion, args: args, token: token }) })
.then(r => {
if (!r.ok) return { ok: false, error: 'El servidor respondió ' + r.status + '.' };
return r.json().catch(() => ({ ok: false, error: 'Respuesta inválida del servidor.' }));
}, err => ({ ok: false, error: 'No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo. (' + err.message + ')' }))
.then(res => {
if (res && res.ok) { if (alExito) alExito(res.datos); return; }
if (res && res.codigo === 'SESION') {
// Una vez: se pide sesión de nuevo y la llamada se repite al entrar (guardar es idempotente por idSolicitud)
if (!esReintento) {
pendientes.push(() => llamar(funcion, args, alExito, alFallar, true));
pedirSesion(res.error);
return;
}
pedirSesion(res.error);
}
fallar((res && res.error) || 'Respuesta inválida del servidor.');
});
}
// Igual que google.script.run: withSuccessHandler / withFailureHandler encadenables y luego la función
function corredor(alExito, alFallar) {
return new Proxy({}, {
get(_, nombre) {
if (nombre === 'withSuccessHandler') return f => corredor(f, alFallar);
if (nombre === 'withFailureHandler') return f => corredor(alExito, f);
if (typeof nombre !== 'string' || nombre === 'then') return undefined;
return (...args) => llamar(nombre, args, alExito, alFallar);
},
});
}
const script = { run: corredor(null, null) };
window.google = window.google || {};
google.script = script;
// ---- Google Identity Services (se carga con el <script> de accounts.google.com, abajo) ----
window.iniciarGis = function () {
if (!google.script) google.script = script; // por si la biblioteca de Google reemplazó window.google
if (CLIENT_ID_GOOGLE.indexOf('PEGAR_AQUI') === 0) { mostrarLogin('Falta configurar el Client ID de Google (CLIENT_ID_GOOGLE).'); return; }
google.accounts.id.initialize({
client_id: CLIENT_ID_GOOGLE,
callback: alIniciarSesion,
hd: DOMINIO, // Google solo ofrece cuentas del dominio (el servidor lo vuelve a revisar)
auto_select: true, // si ya entró antes, vuelve a entrar sin clics
cancel_on_tap_outside: false,
use_fedcm_for_prompt: true,
context: 'signin',
});
google.accounts.id.renderButton($('gisBoton'), {
theme: 'outline', size: 'large', shape: 'pill', text: 'signin_with', locale: 'es-419', width: 280,
});
gisListo = true;
if (!$('login').classList.contains('oculto')) google.accounts.id.prompt();
};
window.errorGis = function () {
mostrarLogin('No se pudo cargar el inicio de sesión de Google. Revisa tu conexión y recarga la página.');
};
// Al cargar: el logo de la pantalla de entrada es el mismo del encabezado; se reutiliza una sesión vigente
$('loginLogo').src = document.querySelector('header img').src;
try {
const guardado = sessionStorage.getItem(CLAVE_SESION);
if (guardado) guardarSesion(guardado);
} catch (e) { olvidarSesion(); }
if (vigente()) $('login').classList.add('oculto'); else olvidarSesion();
})();
</script>
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
<script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [];
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId();
const $ = id => document.getElementById(id);
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---- Arranque ----
google.script.run.withSuccessHandler(d => {
IVA = d.ivaTasa; CATALOGO = d.catalogo;
recibirClientes(d.clientes);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists();
agregarFila();
}).withFailureHandler(mostrarError).getDatosIniciales();
// ---- Conceptos ----
function agregarFila() {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><input class="clave" list="claves"></td>' +
'<td><input class="desc" list="catalogo" placeholder="Escribe o elige del catálogo"></td>' +
'<td><input class="uni" value="PZA"></td>' +
'<td><input class="cant" type="number" min="0" step="any" value="1" style="text-align:right"></td>' +
'<td><input class="pu" type="number" min="0" step="0.01" value="0" style="text-align:right"></td>' +
'<td class="num imp">$0.00</td>' +
'<td><button class="quitar" title="Quitar">×</button></td>';
// Al elegir por descripción o por clave se llenan clave, unidad y precio desde el catálogo
const aplicar = p => {
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.pu').value = p.precio;
};
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalcular));
tr.querySelector('.quitar').onclick = () => { tr.remove(); recalcular(); };
$('filas').appendChild(tr);
tr.querySelector('.desc').focus();
}
function leerItems() {
return [...$('filas').querySelectorAll('tr')].map(tr => ({
clave: tr.querySelector('.clave').value,
descripcion: tr.querySelector('.desc').value,
unidad: tr.querySelector('.uni').value,
cantidad: Number(tr.querySelector('.cant').value) || 0,
precio: Number(tr.querySelector('.pu').value) || 0,
_tr: tr,
}));
}
function recalcular() {
let sub = 0;
leerItems().forEach(it => {
const imp = r2(it.cantidad * r2(it.precio));
it._tr.querySelector('.imp').textContent = mxn(imp);
sub += imp;
});
sub = r2(sub);
const iva = r2(sub * IVA);
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva);
}
// ---- Guardar ----
function guardar() {
const payload = {
cliente: { nombre:$('cNombre').value, empresa:$('cEmpresa').value, rfc:$('cRfc').value,
email:$('cEmail').value, telefono:$('cTel').value, direccion:$('cDir').value },
items: leerItems().map(({_tr, ...it}) => it),
notas: $('notas').value,
marca: $('marca').value,
idSolicitud: ID_SOLICITUD,
};
if ($('btnGuardar').disabled) return;
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run
.withSuccessHandler(r => {
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar();
})
.withFailureHandler(mostrarError)
.guardarCotizacion(payload);
}
function limpiar() {
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = '';
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
ID_SOLICITUD = nuevoId();
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
// ---- Historial ----
let tBusq;
function buscarDebounce() { clearTimeout(tBusq); tBusq = setTimeout(cargarHistorial, 300); }
function cargarHistorial() {
$('hist').innerHTML = '<tr><td colspan="7">Cargando…</td></tr>';
google.script.run.withSuccessHandler(lista => {
$('hist').innerHTML = lista.length ? lista.map(c =>
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.vendedor || c.elaboro) + '</td><td>' +
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="7">Sin resultados.</td></tr>';
}).withFailureHandler(mostrarError).listarCotizaciones($('buscar').value);
}
// ---- Clientes que ya cotizaron (salen de la hoja Cotizaciones) ----
const CAMPOS_CLIENTE = { cNombre:'nombre', cEmpresa:'empresa', cRfc:'rfc', cEmail:'email', cTel:'telefono', cDir:'direccion' };
function recibirClientes(lista) {
CLIENTES = lista || [];
$('clientesList').innerHTML = CLIENTES.map(c =>
'<option value="' + esc(c.etiqueta) + '" label="' + esc([c.empresa, c.rfc].filter(Boolean).join(' · ') || 'Cliente registrado') + '">').join('');
}
function autorrellenarCliente() {
const v = $('cNombre').value.trim().toLowerCase();
const c = v && CLIENTES.find(x => x.etiqueta.toLowerCase() === v);
if (!c) { $('cInfo').textContent = ''; return; }
Object.keys(CAMPOS_CLIENTE).forEach(id => $(id).value = c[CAMPOS_CLIENTE[id]] || '');
$('cInfo').textContent = 'Cliente registrado · ' + c.cotizaciones + (c.cotizaciones === 1 ? ' cotización' : ' cotizaciones') +
(c.ultimaFecha ? ' · última ' + c.ultimaFecha : '');
}
$('cNombre').addEventListener('change', autorrellenarCliente);
$('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); });
// ---- Catálogo ----
let CLAVE_EDITANDO = '';
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
function pintarDatalists() {
$('catalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + mxn(p.precio)) + '">').join('');
$('claves').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + mxn(p.precio)) + '">').join('');
}
function cargarCatalogo() {
$('listaCat').innerHTML = '<tr><td colspan="5">Cargando…</td></tr>';
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
}
function recibirCatalogo(r) {
CATALOGO = r.productos;
$('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
if (r.unidadesSat && r.unidadesSat.length) {
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join('');
}
pintarDatalists(); pintarCatalogo();
}
function pintarCatalogo() {
const q = $('buscarCat').value.toLowerCase().trim();
const lista = CATALOGO.filter(p => !q || (p.clave + ' ' + p.descripcion).toLowerCase().includes(q));
$('tituloLista').textContent = 'Productos (' + CATALOGO.length + ')';
$('listaCat').innerHTML = lista.length ? lista.map(p =>
'<tr class="fila-cat' + (p.clave === CLAVE_EDITANDO ? ' editando' : '') + '" data-clave="' + esc(p.clave) + '">' +
'<td><b>' + esc(p.clave) + '</b></td><td>' + esc(p.descripcion) + '</td><td>' + esc(p.unidad) + '</td>' +
'<td class="num">' + mxn(p.precio) + '</td>' +
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
: '<tr><td colspan="5">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
$('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
}
function editarProd(clave) {
const p = buscarClave(clave); if (!p) return;
CLAVE_EDITANDO = p.clave;
$('pClave').value = p.clave; $('pDesc').value = p.descripcion; $('pUni').value = p.unidad; $('pPrecio').value = p.precio;
$('pSat').value = p.claveSat || ''; $('pUniSat').value = p.unidadSat || ''; $('satBusca').value = ''; $('satRes').innerHTML = '';
$('tituloProd').textContent = 'Editar producto ' + p.clave;
$('btnProd').textContent = 'Guardar cambios';
pintarCatalogo(); window.scrollTo(0, 0); $('pDesc').focus();
}
function limpiarProd() {
CLAVE_EDITANDO = '';
['pClave','pDesc','pPrecio','pSat','pUniSat','satBusca'].forEach(id => $(id).value = ''); $('pUni').value = 'PZA';
$('satRes').innerHTML = '';
$('tituloProd').textContent = 'Agregar producto';
$('btnProd').textContent = 'Guardar en catálogo'; $('btnProd').disabled = false;
pintarCatalogo();
}
function guardarProd() {
if ($('btnProd').disabled) return;
const prod = { clave:$('pClave').value, descripcion:$('pDesc').value, unidad:$('pUni').value,
precio:$('pPrecio').value, claveSat:$('pSat').value, unidadSat:$('pUniSat').value,
claveOriginal:CLAVE_EDITANDO };
const editando = !!CLAVE_EDITANDO;
$('btnProd').disabled = true;
google.script.run.withSuccessHandler(r => {
recibirCatalogo(r);
aviso('ok', 'Producto <b>' + esc(r.clave) + '</b> ' + (editando ? 'actualizado' : 'agregado') + ' en el catálogo.');
limpiarProd();
}).withFailureHandler(err => { $('btnProd').disabled = false; mostrarError(err); }).guardarProducto(prod);
}
// ---- Sugerencias de clave SAT ----
function sugerirSat() {
const q = $('satBusca').value.trim() || $('pDesc').value.trim();
if (!q) { aviso('error', 'Escribe la descripción del producto o palabras para buscar en el catálogo del SAT.'); return; }
$('satBusca').value = q;
$('satRes').innerHTML = '<div class="nota" style="margin-top:8px">Buscando en el catálogo del SAT…</div>';
google.script.run.withSuccessHandler(lista => {
$('satRes').innerHTML = lista.length
? '<div class="nota" style="margin-top:10px">' + (lista[0].razon ? 'Sugeridas con IA. ' : '') +
'Elige la que mejor describa el producto (contabilidad debe validarla):</div>' +
lista.map(o => '<div class="sat-op" data-clave="' + esc(o.clave) + '"><b>' + esc(o.clave) + '</b><span>' + esc(o.descripcion) +
(o.razon ? '<small class="sat-razon">' + esc(o.razon) + '</small>' : '') + '</span></div>').join('')
: '<div class="nota" style="margin-top:8px">Sin coincidencias. Prueba con otras palabras (p. ej. el tipo de producto o "servicio de …").</div>';
$('satRes').querySelectorAll('.sat-op').forEach(d => d.onclick = () => {
$('pSat').value = d.dataset.clave; $('satRes').innerHTML = '';
});
}).withFailureHandler(err => { $('satRes').innerHTML = ''; mostrarError(err); }).sugerirClavesSat(q);
}
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
// ---- UI ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva');
$('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo();
$('aviso').className = 'aviso';
}
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
function mostrarError(err) {
aviso('error', esc(err && err.message ? err.message : err));
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
</script>
</body>
</html>

105
web/index.html Normal file
View File

@ -0,0 +1,105 @@
<!doctype html>
<html lang="es-MX">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cotizador Helgen</title>
<meta name="description" content="Acceso al cotizador interno de Helgen Servicios Especializados.">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" type="image/png" href="logo-helgen-blanco.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--azul: #2C3C5B;
--aqua: #51F3DA;
--teal: #0AC5A8;
--fondo: #F2F4F7;
--texto: #2C3C5B;
--tarjeta: #FFFFFF;
--suave: #5B6B87;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fondo: #1A2438;
--texto: #F2F4F7;
--tarjeta: #2C3C5B;
--suave: #B8C3D6;
}
}
:root[data-theme="dark"] {
--fondo: #1A2438;
--texto: #F2F4F7;
--tarjeta: #2C3C5B;
--suave: #B8C3D6;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--fondo);
color: var(--texto);
font-family: 'Roboto Mono', monospace;
}
header {
background: var(--azul);
padding: 20px 16px;
border-bottom: 4px solid var(--aqua);
}
header img { display: block; height: 40px; width: auto; margin: 0 auto; }
main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.tarjeta {
width: 100%;
max-width: 440px;
background: var(--tarjeta);
border-radius: 12px;
padding: 32px 24px;
text-align: center;
box-shadow: 0 4px 20px rgba(44, 60, 91, .12);
}
h1 {
font-family: 'League Spartan', Arial, sans-serif;
font-size: 32px;
margin: 0 0 12px;
}
p { margin: 0 0 24px; line-height: 1.6; font-size: 14px; color: var(--suave); }
.btn {
display: inline-block;
font-family: 'League Spartan', Arial, sans-serif;
font-size: 18px;
text-decoration: none;
background: var(--aqua);
color: var(--azul);
padding: 14px 28px;
border-radius: 8px;
transition: background .15s;
}
.btn:hover, .btn:focus-visible { background: var(--teal); color: #FFFFFF; }
.nota { margin: 20px 0 0; font-size: 12px; }
footer { text-align: center; font-size: 12px; padding: 16px; color: var(--suave); }
</style>
</head>
<body>
<header>
<img src="logo-helgen-blanco.png" alt="Helgen">
</header>
<main>
<div class="tarjeta">
<h1>Cotizador Helgen</h1>
<p>Crea cotizaciones de Helgen y Helgen Energy, consulta el historial y el catálogo de productos.</p>
<a class="btn" href="cotizador/">Abrir el cotizador</a>
<p class="nota">Entra con tu cuenta @helgen.tech.</p>
</div>
</main>
<footer>Helgen Servicios Especializados</footer>
</body>
</html>

BIN
web/logo-helgen-blanco.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

9
wrangler.jsonc Normal file
View File

@ -0,0 +1,9 @@
// Página de acceso al cotizador en Cloudflare (Worker solo con archivos estáticos, sin código).
// Publicar: npx wrangler@4 deploy
{
"name": "cotizador-helgen",
"compatibility_date": "2026-10-19",
"assets": {
"directory": "./web"
}
}