mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
Respaldo inicial del Cotizador Helgen (19/10/2026)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bbb34768b4
16
.clasp.json
Normal file
16
.clasp.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"scriptId": "1GsH8DjaFEZ9dltqBUTUEFE_frblSYimRRCvPkqKdRB5UhmBwwYHLSwyS",
|
||||
"rootDir": "",
|
||||
"scriptExtensions": [
|
||||
".js",
|
||||
".gs"
|
||||
],
|
||||
"htmlExtensions": [
|
||||
".html"
|
||||
],
|
||||
"jsonExtensions": [
|
||||
".json"
|
||||
],
|
||||
"filePushOrder": [],
|
||||
"skipSubdirectories": false
|
||||
}
|
||||
5
.claspignore
Normal file
5
.claspignore
Normal file
@ -0,0 +1,5 @@
|
||||
**/**
|
||||
!Code.js
|
||||
!Logo.js
|
||||
!Index.html
|
||||
!appsscript.json
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
363
CLAUDE.md
Normal 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).
|
||||
122
GUIA.md
Normal file
122
GUIA.md
Normal 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
466
Index.html
Normal 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,iVBORw0KGgoAAAANSUhEUgAAAMgAAAAuCAYAAABtRVYBAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACqxJREFUeNrsXQm0VVMYPjezlZSZkldEKE1mqkcJ8coUGaqXZFhJWMZWkiXLkBYyLCypRMsQFS2ieu9FhtKI1cpQvVQrsZKZDO/6fue/+t+29xnuPeee87T/tb519t1nz3v/e/j/f5+bcVJE2Wx2dzxa889DgZ2BP4EvgJ+B1ZlMZpVjyVKRKJMwQ9DjVOAEoBPDr0wfAwuBmcDrYJjvbDda+l8RGKMhMAT4NlsY1QBjgBLbqpbq/ArCK8YgYCTQUBNkI68QvwLzhP/+QHOgGWBihmeAG7GifG271VJdXDUOABZrVoEvgFuBZgHT2Qu4AJivSWsT0Nu2tqW6xhyn8OCVtAjoVGC6LYCnNIzyPK9WliylnjkGKoN3LXBpxHl0AGYp+VTQWcf2gKU0M0c/ZdC+AxwcU17bAjdqVqmG/+P2rTAIL46yoy8a2jbGzuuKxzjhNRXoH5dYFumSvmQU8v0Gz0cdV4fSDngRfr3w/nuf8rbF4wHNqyWIe12ecalcJyUgZNnBDu10z2z7A6vFjPZqkfM/EvhJ5D8hQJxSw2xcWUDcbMz1rDRke6IdhdFQvZjSfQRoyu5FQI9iVgqz9gI8rhJefTFozrDdbSlxBsFAHCAYgrZTXTBgi14x5DkRjzuF10SUbTvb5ZaSXkGGCffAJE1BkPdwx1U8EjUCxmwtu1w7tFPIICy+LeGfCzBAJ6egjkOFuzfKaA+wlhJbQQYJ9/1pqCCYdDoeb/FPEvkOtN1uqegMgpm5CR7t+SeZp09R3h9QtP1FNttA8Ror3JfZbreUxApysnC/gJn7dzFg6bEKz7IiMEdHPNYo3i867n0SojYIs5/tekvFZpA+wj1bedfScZVar2JwlsfIHEPweBtoAHdGbLPo8ZwI2tp2vaUgFKUm/UB+/uG4F5ok7SPc41jLfhUG7o8RMQadLR4DLhTedCNxmfj9AXA5u3sCbybd+GQe47gicTq7kTk/bUNrgA+Br4An0EYzIs6TJqshPEkcz94k6VsO3I38lvB2uDdPoBkGbQPewPvFHmmX4HElcCxA5i5kzUAWDBRnDjAe8as94l+Lx/bCi9wrEWcS93F/4CLgSGAz8D736xNe6aaCpL2V5p1O07weODmCfLsDazTpd1bCneilHffQhtdwWVcCXwEb+Pdazpd+/xJWk45Xl3N6frTKpBkPo0mH3zbA+AD5UZo3Gd4NN535gGcDXnKbDDTyGUNqeahvvvZJd4op3TQwR+MQg0+9RfhUPgo8GnvA4x4NpjLIjuLd/BAMUhAZyv5kHkn1y5dB8HtnoCqC6gzXlIHu+awImc4yndDGEJauSfwYMN3FUVv3RHUGaS7cG33C0hIsNdykeV/OBn9BmaMNHp8BV7AXGSre5dRWUv4k42AJ/k38bJ/gZPK4YxY10+q71PDuacTtmWe2ZLzZOYa67IlHpdL/OaItYhWwTvOOtr9TEX/7ANnQ1qp+wCLRGBqVRgaR6egaRGrTD2UNN3VYtWCw2UGYhMO8AbRgL0qjG9IcpuxflxRJOBFmQPUQTC2JLJ2boQ6dAKofWQB/qinz3XnkSTZocQlGHnLca9C1suQzziFsydxcmbjkYB4aIq+fmRlfAj7xCHdJGrdYx4tlbpZhO5Sjl4T/dsA08W4BUM8jn5147y/3nfXF+5XsX+2zx92cxBbLsBVZYyjrwYYky8JssciS2qN4s3K3OknqBwzW3PzUbrHg7mYIc5OhPmM1YVcH2GJluV/bKGHHeYTfNW1SrBXCvY36ksSsdLnHcXUl58J9GPyWASTx6snnllKgg+N++qfKkA+tOo3ZPR3xz1Zm59xspjsHyQPcvBB1Ww886Wy5e6GzvGzmN3PxJSbdVuQbYs4Q5ekCvBYi/JkG/5lov26ij4iZH0ZZXmOpk99FM9Pd/98N9flS49cUYZsj75U+efVAmE+UMdUfcalue2jC78vSs1RKsT42vL/YdJDH79vFu5M88pDhSqWYF/hcvGuiids5TylWJPdBlLIXQpUizQqvFURZ2VVq7FGfewKsIHMiqk+pzwrykUc5ZxriHJa2MwjR5/xsxbJ2dRUhRd1c/lnK26mjgeOEfoJog0cec4V7NDNGe14xDmL/schrrSauXKLnJzCH1I8hTb97BMeYVkW00TqPeJMC5N0xojqU+Lzf6HMuyaddEmEQ+UnQww1hBosKd+CtzntAzvSDFEnLPPKYw9KrnCRqk+MqJduKrdMVhrhyVpmRAIMkIRgwic9/84lXEwFzFqNdsnWp054V7tO0LZrJkGSpKzOFSmQa73n3m++d91LOPP+KQYFTEeYvzVJMJu7SFGZRAoPVZDVwXyYchbnjbtJ6l/gcZLsWkHbTkPV5Os0MEqWpyUzhJglHA1T+Bx2TsAadDtgtuZKL4B/o4IlwH9FnfuAkxdlu7F0F/yqPaOc4rtnDP6uQ3wccYqIPDP7dgZs99tmk+5iBMm/OI8+5HrP/CN2ExCYd1wRIm8TQ7TT+1LcTDHWh7VQT1GVuzFvMRL857XVYfUcclMpTVC4p6jw35EE7so82GExiiB5UZ3Q+X70sviVWrirWAop53/Y4II+RGm24u5CQJaCY9yJDmD90ghYWkuRMa6aqYluPQ3plwH6V1CqtDFIuCrkkJWXqLu2/8hjkUTLIAI/B9z0zygjWGdRownzK5vxhGKQ8QrXOcKU+FR5hl3JdRrBARkevFMgg0+oUg3Chl4uC3pyC8iwU5bktSQbhsG8VMEC/lJfBQthiVcbEIAcxY+dL98bEIJFdZ4hDsiLNIW4p5k1CTQOOdrbYXa3A3vfOFMwh57NAIh8pYZnuXBeA6LwX+YqOstDNUVLe5vNhjmcQv9AJtE5JsXKNNkEMADrwTUmIOUizfr3wSsVX3/krLxcAt4aIRrZaRyHu0gLypHPB9IBR3g+RNjFeKw+BgEokIBmEeP2i6Oa6JMWSdD1LOOgSVTsMVvpGVV82ZygGc3RQJCkj+WNyXlQN3GHwd/KMmzUMKtIzkLb6eZbGlTruJaCcMjH3Pykk+XpONbMQNN5xdUMqrTYwSRnyPMvZculIGhrO4VWGlISkVzouBJOQ0rEj0iZpYRn3vTyEE2PTakNGpq8g/CZDUmHbf5Kjt37e4KSd+ILSX2JfOFHzMYU48m2rGNzRBZ0dnTpA/AHuTBHzq6czOdd8BDxHQ0Ok/Y9xqWPJs5H6KA1M4r29Y8yvl8Ic8+1fIPxn0Pbxm6jwfraBQc6zrRh9p1ymiCzXAadHnAfpDB7T/NVCA9sD/64Uffn6bq5tGhrCDvOQOrW1rRlPB5Vq7hmQuLNFBDPiYM013snF3KqkvO378aRkusc9gPvnXsFAOlpvWzPejipRdBI5mkfmFGHOCZwWKaA2Kmn9ClxnW7tWW90VkQ7k6q2x/ZL4l1sybScFkW6JpzvZZP9Pf4JDkhEyod+JJSL0bM0SEt0ffr7uuOLDassWtbefjnsdoJDt0VR5Oc1S/J3WiLSywGcRzGzTQt7I2xrbe78Cvmoy1usatKV4O47EmmcD9MWO6hCdRrY99HdrR9hWDNzWuwA38N9uB6EKVrZu1ZRJWSce7bj3tlvqXgOk7FuO5X6FHfJ5tzH1eWfG7k7tz7C+67ia7gq08ULbWpYsWfKkvwUYAPUGAHHeqGd4AAAAAElFTkSuQmCC" 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
16
anterior/.clasp.json
Normal file
16
anterior/.clasp.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"scriptId": "1X-qSKgvqKwuDj4iPZBhXlakR5udau9ibVMevhwvlLXgtSkjV4IoQxqwU",
|
||||
"rootDir": "",
|
||||
"scriptExtensions": [
|
||||
".js",
|
||||
".gs"
|
||||
],
|
||||
"htmlExtensions": [
|
||||
".html"
|
||||
],
|
||||
"jsonExtensions": [
|
||||
".json"
|
||||
],
|
||||
"filePushOrder": [],
|
||||
"skipSubdirectories": false
|
||||
}
|
||||
5
anterior/.claspignore
Normal file
5
anterior/.claspignore
Normal file
@ -0,0 +1,5 @@
|
||||
**/**
|
||||
!Code.gs
|
||||
!Logo.gs
|
||||
!Index.html
|
||||
!appsscript.json
|
||||
374
anterior/CLAUDE-antes-de-migrar.md
Normal file
374
anterior/CLAUDE-antes-de-migrar.md
Normal 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
1037
anterior/Code.gs
Normal file
File diff suppressed because it is too large
Load Diff
426
anterior/Index.html
Normal file
426
anterior/Index.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
10
anterior/LEEME.md
Normal 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
5
anterior/Logo.gs
Normal file
File diff suppressed because one or more lines are too long
10
anterior/appsscript.json
Normal file
10
anterior/appsscript.json
Normal 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
10
appsscript.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"timeZone": "America/Mexico_City",
|
||||
"dependencies": {},
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"runtimeVersion": "V8",
|
||||
"webapp": {
|
||||
"executeAs": "USER_DEPLOYING",
|
||||
"access": "ANYONE_ANONYMOUS"
|
||||
}
|
||||
}
|
||||
13
herramientas/extraer-catalogos-sat.js
Normal file
13
herramientas/extraer-catalogos-sat.js
Normal 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
4097
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@google/clasp": "^3.4.1"
|
||||
}
|
||||
}
|
||||
56
tests/catalogo.test.js
Normal file
56
tests/catalogo.test.js
Normal 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
74
tests/cotizacion.test.js
Normal 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');
|
||||
10
tests/probar-busqueda-sat.js
Normal file
10
tests/probar-busqueda-sat.js
Normal 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
61
tests/sat.test.js
Normal 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
89
tests/sesion.test.js
Normal 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
2
web/.assetsignore
Normal file
@ -0,0 +1,2 @@
|
||||
Icon*
|
||||
.DS_Store
|
||||
615
web/cotizador/index.html
Normal file
615
web/cotizador/index.html
Normal 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,iVBORw0KGgoAAAANSUhEUgAAAMgAAAAuCAYAAABtRVYBAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACqxJREFUeNrsXQm0VVMYPjezlZSZkldEKE1mqkcJ8coUGaqXZFhJWMZWkiXLkBYyLCypRMsQFS2ieu9FhtKI1cpQvVQrsZKZDO/6fue/+t+29xnuPeee87T/tb519t1nz3v/e/j/f5+bcVJE2Wx2dzxa889DgZ2BP4EvgJ+B1ZlMZpVjyVKRKJMwQ9DjVOAEoBPDr0wfAwuBmcDrYJjvbDda+l8RGKMhMAT4NlsY1QBjgBLbqpbq/ArCK8YgYCTQUBNkI68QvwLzhP/+QHOgGWBihmeAG7GifG271VJdXDUOABZrVoEvgFuBZgHT2Qu4AJivSWsT0Nu2tqW6xhyn8OCVtAjoVGC6LYCnNIzyPK9WliylnjkGKoN3LXBpxHl0AGYp+VTQWcf2gKU0M0c/ZdC+AxwcU17bAjdqVqmG/+P2rTAIL46yoy8a2jbGzuuKxzjhNRXoH5dYFumSvmQU8v0Gz0cdV4fSDngRfr3w/nuf8rbF4wHNqyWIe12ecalcJyUgZNnBDu10z2z7A6vFjPZqkfM/EvhJ5D8hQJxSw2xcWUDcbMz1rDRke6IdhdFQvZjSfQRoyu5FQI9iVgqz9gI8rhJefTFozrDdbSlxBsFAHCAYgrZTXTBgi14x5DkRjzuF10SUbTvb5ZaSXkGGCffAJE1BkPdwx1U8EjUCxmwtu1w7tFPIICy+LeGfCzBAJ6egjkOFuzfKaA+wlhJbQQYJ9/1pqCCYdDoeb/FPEvkOtN1uqegMgpm5CR7t+SeZp09R3h9QtP1FNttA8Ror3JfZbreUxApysnC/gJn7dzFg6bEKz7IiMEdHPNYo3i867n0SojYIs5/tekvFZpA+wj1bedfScZVar2JwlsfIHEPweBtoAHdGbLPo8ZwI2tp2vaUgFKUm/UB+/uG4F5ok7SPc41jLfhUG7o8RMQadLR4DLhTedCNxmfj9AXA5u3sCbybd+GQe47gicTq7kTk/bUNrgA+Br4An0EYzIs6TJqshPEkcz94k6VsO3I38lvB2uDdPoBkGbQPewPvFHmmX4HElcCxA5i5kzUAWDBRnDjAe8as94l+Lx/bCi9wrEWcS93F/4CLgSGAz8D736xNe6aaCpL2V5p1O07weODmCfLsDazTpd1bCneilHffQhtdwWVcCXwEb+Pdazpd+/xJWk45Xl3N6frTKpBkPo0mH3zbA+AD5UZo3Gd4NN535gGcDXnKbDDTyGUNqeahvvvZJd4op3TQwR+MQg0+9RfhUPgo8GnvA4x4NpjLIjuLd/BAMUhAZyv5kHkn1y5dB8HtnoCqC6gzXlIHu+awImc4yndDGEJauSfwYMN3FUVv3RHUGaS7cG33C0hIsNdykeV/OBn9BmaMNHp8BV7AXGSre5dRWUv4k42AJ/k38bJ/gZPK4YxY10+q71PDuacTtmWe2ZLzZOYa67IlHpdL/OaItYhWwTvOOtr9TEX/7ANnQ1qp+wCLRGBqVRgaR6egaRGrTD2UNN3VYtWCw2UGYhMO8AbRgL0qjG9IcpuxflxRJOBFmQPUQTC2JLJ2boQ6dAKofWQB/qinz3XnkSTZocQlGHnLca9C1suQzziFsydxcmbjkYB4aIq+fmRlfAj7xCHdJGrdYx4tlbpZhO5Sjl4T/dsA08W4BUM8jn5147y/3nfXF+5XsX+2zx92cxBbLsBVZYyjrwYYky8JssciS2qN4s3K3OknqBwzW3PzUbrHg7mYIc5OhPmM1YVcH2GJluV/bKGHHeYTfNW1SrBXCvY36ksSsdLnHcXUl58J9GPyWASTx6snnllKgg+N++qfKkA+tOo3ZPR3xz1Zm59xspjsHyQPcvBB1Ww886Wy5e6GzvGzmN3PxJSbdVuQbYs4Q5ekCvBYi/JkG/5lov26ij4iZH0ZZXmOpk99FM9Pd/98N9flS49cUYZsj75U+efVAmE+UMdUfcalue2jC78vSs1RKsT42vL/YdJDH79vFu5M88pDhSqWYF/hcvGuiids5TylWJPdBlLIXQpUizQqvFURZ2VVq7FGfewKsIHMiqk+pzwrykUc5ZxriHJa2MwjR5/xsxbJ2dRUhRd1c/lnK26mjgeOEfoJog0cec4V7NDNGe14xDmL/schrrSauXKLnJzCH1I8hTb97BMeYVkW00TqPeJMC5N0xojqU+Lzf6HMuyaddEmEQ+UnQww1hBosKd+CtzntAzvSDFEnLPPKYw9KrnCRqk+MqJduKrdMVhrhyVpmRAIMkIRgwic9/84lXEwFzFqNdsnWp054V7tO0LZrJkGSpKzOFSmQa73n3m++d91LOPP+KQYFTEeYvzVJMJu7SFGZRAoPVZDVwXyYchbnjbtJ6l/gcZLsWkHbTkPV5Os0MEqWpyUzhJglHA1T+Bx2TsAadDtgtuZKL4B/o4IlwH9FnfuAkxdlu7F0F/yqPaOc4rtnDP6uQ3wccYqIPDP7dgZs99tmk+5iBMm/OI8+5HrP/CN2ExCYd1wRIm8TQ7TT+1LcTDHWh7VQT1GVuzFvMRL857XVYfUcclMpTVC4p6jw35EE7so82GExiiB5UZ3Q+X70sviVWrirWAop53/Y4II+RGm24u5CQJaCY9yJDmD90ghYWkuRMa6aqYluPQ3plwH6V1CqtDFIuCrkkJWXqLu2/8hjkUTLIAI/B9z0zygjWGdRownzK5vxhGKQ8QrXOcKU+FR5hl3JdRrBARkevFMgg0+oUg3Chl4uC3pyC8iwU5bktSQbhsG8VMEC/lJfBQthiVcbEIAcxY+dL98bEIJFdZ4hDsiLNIW4p5k1CTQOOdrbYXa3A3vfOFMwh57NAIh8pYZnuXBeA6LwX+YqOstDNUVLe5vNhjmcQv9AJtE5JsXKNNkEMADrwTUmIOUizfr3wSsVX3/krLxcAt4aIRrZaRyHu0gLypHPB9IBR3g+RNjFeKw+BgEokIBmEeP2i6Oa6JMWSdD1LOOgSVTsMVvpGVV82ZygGc3RQJCkj+WNyXlQN3GHwd/KMmzUMKtIzkLb6eZbGlTruJaCcMjH3Pykk+XpONbMQNN5xdUMqrTYwSRnyPMvZculIGhrO4VWGlISkVzouBJOQ0rEj0iZpYRn3vTyEE2PTakNGpq8g/CZDUmHbf5Kjt37e4KSd+ILSX2JfOFHzMYU48m2rGNzRBZ0dnTpA/AHuTBHzq6czOdd8BDxHQ0Ok/Y9xqWPJs5H6KA1M4r29Y8yvl8Ic8+1fIPxn0Pbxm6jwfraBQc6zrRh9p1ymiCzXAadHnAfpDB7T/NVCA9sD/64Uffn6bq5tGhrCDvOQOrW1rRlPB5Vq7hmQuLNFBDPiYM013snF3KqkvO378aRkusc9gPvnXsFAOlpvWzPejipRdBI5mkfmFGHOCZwWKaA2Kmn9ClxnW7tWW90VkQ7k6q2x/ZL4l1sybScFkW6JpzvZZP9Pf4JDkhEyod+JJSL0bM0SEt0ffr7uuOLDassWtbefjnsdoJDt0VR5Oc1S/J3WiLSywGcRzGzTQt7I2xrbe78Cvmoy1usatKV4O47EmmcD9MWO6hCdRrY99HdrR9hWDNzWuwA38N9uB6EKVrZu1ZRJWSce7bj3tlvqXgOk7FuO5X6FHfJ5tzH1eWfG7k7tz7C+67ia7gq08ULbWpYsWfKkvwUYAPUGAHHeqGd4AAAAAElFTkSuQmCC" 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
105
web/index.html
Normal 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
BIN
web/logo-helgen-blanco.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
9
wrangler.jsonc
Normal file
9
wrangler.jsonc
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user