Fase 2: cobranza por proyecto y cotizaciones en USD

- Cobranza en un libro y una carpeta aparte ("Cotizador Helgen — Cobranza"),
  fuera de la carpeta maestra, porque la Base de datos la lee todo el dominio.
- Cotización aceptada (una por proyecto; línea y moneda salen de ella),
  esquema de pagos (Energy fijo 60/30/10; Helgen editable, suma 100%),
  contrato firmado que bloquea todo, desbloqueo solo del administrador con
  motivo, pagos PAG-AAAA-NNNN con comprobante obligatorio (PDF/imagen,
  revisado por contenido), excedente a la siguiente etapa, tolerancia de 1.00,
  tipo de cambio cuando la moneda del pago es otra, cancelación con motivo y
  bitácora.
- Permisos en el servidor: ROL_COBRANZA y ADMIN_DESBLOQUEO en Script
  Properties; sin rol, codigo PERMISO y ningún dato de cobranza.
- Un proyecto con contrato firmado no admite cotizaciones nuevas y sale del
  buscador de cotizaciones.
- Cotizaciones en MXN o USD (columna U, PDF e importe con letra en dólares);
  en USD el catálogo no pone el precio.
- Proyectos nuevos sin subcarpeta "Pagos".
- Pruebas: nueva cobranza.test.js; interfaz, cotización, sesión y proyectos
  ampliadas. CLAUDE.md actualizado (§4.5.2, §5, §6.10, §6.11, regla 12, §9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-20 14:54:23 -06:00
parent f31f88f363
commit 745c9e3a66
8 changed files with 1666 additions and 95 deletions

145
CLAUDE.md
View File

@ -13,7 +13,9 @@ Desde el **19/10/2026** (§11) la interfaz se sirve desde **Cloudflare** y habla
2. El servidor **recalcula** subtotal, IVA y total, asigna un **folio único** `HLG-AAAA-0001` y genera el **PDF**. 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. 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: **proyectos** (opcionales: folio `PRY-AAAA-0001` y carpeta propia en Drive; §6.9), historial con búsqueda, catálogo de productos con precios (Google Sheets aparte), clientes sugeridos con Además: **proyectos** (opcionales: folio `PRY-AAAA-0001` y carpeta propia en Drive; §6.9), **cobranza por proyecto**
(cotización aceptada, contrato firmado, esquema de pagos y pagos `PAG-AAAA-0001` con comprobante; solo rol de cobranza; §6.11),
cotizaciones en **MXN o USD**, 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). 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. **Helgen Energy** es la submarca de paneles solares: mismo folio y diseño, pero sus propios Términos y Condiciones.
@ -31,6 +33,7 @@ autorrelleno, y claves SAT internas por producto para contabilidad (con sugerenc
| Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` | | Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` |
| Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) | | 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`) | | "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) |
| Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | Fuera de la carpeta maestra, en Mi unidad. Se crea con `prepararCobranza()` (ID pendiente: anotarlo aquí al crearla en la API real). Lectores: tania@ y leo@ (§6.11) |
| Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) | | Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) |
| Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) | | Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) |
| Worker de Cloudflare | `cotizador-helgen`, cuenta de efrain@helgen.tech `badd8bca60b008822a73ca24e983ff43` | | Worker de Cloudflare | `cotizador-helgen`, cuenta de efrain@helgen.tech `badd8bca60b008822a73ca24e983ff43` |
@ -116,29 +119,32 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
- `existeEnSat_(pestana, clave)` — valida una clave con `TextFinder` (celda completa). - `existeEnSat_(pestana, clave)` — valida una clave con `TextFinder` (celda completa).
### 4.4 Datos para la interfaz ### 4.4 Datos para la interfaz
- `getDatosIniciales()` *(UI, al cargar)* — IVA, vigencia, marcas, catálogo, clientes, `usuario` (correo) y `vendedor` (`{email, nombre, apellido}`). - `getDatosIniciales()` *(UI, al cargar)* — IVA, vigencia, marcas, catálogo, clientes, `usuario` (correo), `vendedor` (`{email, nombre, apellido}`)
y `permisos` (`{cobranza, desbloqueo}`; solo para mostrar la pestaña Cobranza: cada función lo vuelve a revisar).
- `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`). - `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`).
- `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa/proyecto; `vendedor` = - `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa/proyecto; `vendedor` =
`vendedorDeFila`: nombre + apellido de la fila; en filas viejas (sin Q:S), el nombre que ese correo tiene en otras filas `vendedorDeFila`: nombre + apellido de la fila; en filas viejas (sin Q:S), el nombre que ese correo tiene en otras filas
(`nombresDeVendedores`, gana la más reciente); si no se conoce, el correo (`vendedor_email` o "Elaboró"). No modifica la hoja. (`nombresDeVendedores`, gana la más reciente); si no se conoce, el correo (`vendedor_email` o "Elaboró"). No modifica la hoja.
`moneda` = columna U (vacía = MXN).
### 4.5 Guardar cotización ### 4.5 Guardar cotización
- `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT - `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` 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 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, proyectoFolio}`. `ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total, moneda, proyectoFolio}`.
Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista y esté `Activo` (si no, error y no se Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista, esté `Activo` y **no tenga contrato
gasta folio) y guarda el PDF en `<carpeta del proyecto>/Cotizaciones` en lugar de la carpeta del año. firmado** (si no, error `codigo: "PROYECTO"` y no se gasta folio; el de contrato dice "Proyecto con contrato firmado: no admite
cotizaciones nuevas.") y guarda el PDF en `<carpeta del proyecto>/Cotizaciones` en lugar de la carpeta del año.
- `siguienteFolio_(prefijo, hoja, baseContador)` — `max(<baseContador>AAAA, mayor folio del año en la hoja) + 1`. - `siguienteFolio_(prefijo, hoja, baseContador)` — `max(<baseContador>AAAA, mayor folio del año en la hoja) + 1`.
`baseContador` = `CONSECUTIVO_` (cotizaciones, por omisión) o `CONSECUTIVO_PROY_` (proyectos). `baseContador` = `CONSECUTIVO_` (cotizaciones, por omisión), `CONSECUTIVO_PROY_` (proyectos) o `CONSECUTIVO_PAG_` (pagos).
- `carpetaDelAnio_(anio)` — subcarpeta `2026`, `2027`… dentro de la carpeta de PDF (la busca **por nombre**). - `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`. - `logoDataUrl_(fileId)` — logo de Drive si `LOGO_FILE_ID` tiene valor; si no, `LOGO_HELGEN_AZUL`.
### 4.5.1 Proyectos (sección 4.1 del archivo) ### 4.5.1 Proyectos (sección 4.1 del archivo)
- `hojaProyectos_()` — pestaña `Proyectos` de la base de datos; la crea sola (lista desplegable de Estado en G) y reescribe - `hojaProyectos_()` — pestaña `Proyectos` de la base de datos; la crea sola (lista desplegable de Estado en G) y reescribe
solo la fila 1 si faltan encabezados (`ENCABEZADOS_PROYECTOS`). solo la fila 1 si faltan encabezados (`ENCABEZADOS_PROYECTOS`).
- `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo`; máx. 50, - `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo` y **sin
recientes primero. No devuelve `carpeta_id`. contrato firmado** (el buscador de cotizaciones; `foliosConContratoFirmado_`); máx. 50, recientes primero. No devuelve `carpeta_id`.
- `crearProyecto(p)` *(UI)* — exige vendedor; `normalizarProyecto`; candado + idempotencia (`PRY_<idSolicitud>`, 6 h); - `crearProyecto(p)` *(UI)* — exige vendedor; `normalizarProyecto`; candado + idempotencia (`PRY_<idSolicitud>`, 6 h);
folio `PRY-AAAA-NNNN`; **primero** crea la carpeta y **después** la fila (estado `Activo`). Devuelve `{folio, …, url}`. folio `PRY-AAAA-NNNN`; **primero** crea la carpeta y **después** la fila (estado `Activo`). Devuelve `{folio, …, url}`.
- `buscarProyecto_(hoja, folio)`, `carpetaProyectos_()` (`PROYECTOS_FOLDER_ID`; si falta o está en la Papelera la crea en la - `buscarProyecto_(hoja, folio)`, `carpetaProyectos_()` (`PROYECTOS_FOLDER_ID`; si falta o está en la Papelera la crea en la
@ -146,9 +152,29 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
`crearCarpetaProyecto_`, `subcarpetaProyecto_(proyecto, nombreSub)` (ver §6.9). `crearCarpetaProyecto_`, `subcarpetaProyecto_(proyecto, nombreSub)` (ver §6.9).
- `getDatosIniciales()` incluye `proyectos` (activos; si falla, `[]` y la app carga igual). - `getDatosIniciales()` incluye `proyectos` (activos; si falla, `[]` y la app carga igual).
### 4.5.2 Cobranza (sección 4.2 del archivo; ver §6.11)
- Todas empiezan con `exigirCobranza_()` (token + correo en `ROL_COBRANZA`; si no, `errorPermiso_` → `codigo: "PERMISO"`).
- *(UI)* `listarProyectosCobranza(busqueda)` (todos los estados + estado del contrato), `getCobranzaProyecto(folio)`,
`marcarAceptada(proyecto, cotizacion, confirmado)` (si es Energy y reemplaza un esquema distinto al 60/30/10 devuelve
`{confirmar, mensaje}` sin cambiar nada), `guardarEsquema(proyecto, etapas)`, `firmarContrato(proyecto, 'AAAA-MM-DD')`,
`desbloquearContrato(proyecto, motivo)` (además `esAdminDesbloqueo_`), `registrarPago(p)` (con `comprobante.datos` en base64
e idempotencia `PAG_<idSolicitud>`), `cancelarPago(folio, motivo)`. Las que cambian algo usan `conCandado_` y devuelven la vista
(`vistaCobranza_`).
- Internas: `carpetaCobranza_` y `libroCobranza_` (se crean solas la primera vez; si el ID existe pero no se encuentra, **error**:
no se crea otra a escondidas), `libroCobranzaSiExiste_` (las cotizaciones no crean el libro), `hojaCobranza_(libro, nombre)`
(encabezados que se migran solos, `HOJAS_COBRANZA`), `datosCobranza_`, `escribirEsquema_`, `bitacora_`, `cotizacionesDeProyecto_`.
- `prepararCobranza()` — correr desde el editor: crea carpeta y libro (no comparte nada) y sirve para autorizar permisos.
### 4.6 Lógica pura (sin Google; se prueba en Node) ### 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."). - `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n, moneda)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.";
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`. en USD "MIL CIENTO SESENTA DÓLARES 02/100 USD").
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`,
`moneda` (`monedaDeCotizacion`: vacía = MXN; solo MXN/USD).
- Cobranza: `esquemaPorOmision`, `validarEsquema` (1–10 etapas, nombres únicos, % > 0 con 2 decimales, suma exacta 100),
`montosDeEsquema` (la última etapa absorbe los centavos), `convertirMoneda`, `repartirPago` (etapa elegida → siguientes →
anteriores; en centavos), `resumenCobranza` (vuelve a repartir los pagos Vigentes en orden de registro), `validarFechaIso`,
`normalizarPago`, `validarComprobante` (tipo por los primeros bytes: PDF, JPG, PNG, HEIC; máx. 10 MB), `correosDeLista`,
`nombreComprobante`.
- `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`. - `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`.
- `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (`textoNormal`), la fila más - `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (`textoNormal`), la fila más
reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite; reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite;
@ -166,7 +192,11 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4). - `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
### 4.7 Interfaz (`web/cotizador/index.html`) ### 4.7 Interfaz (`web/cotizador/index.html`)
Tres pestañas: **Nueva cotización**, **Historial**, **Catálogo**. Funciones principales: `agregarFila`, `leerItems`, Pestañas: **Nueva cotización**, **Historial**, **Catálogo** y **Cobranza** (solo si `permisos.cobranza`). Moneda: selector
`#moneda`, `monedaActual`, `dinero(n, moneda)`, `cambiarMoneda` (en USD el catálogo llena clave/descripción/unidad pero **no**
el precio). Cobranza: `cargarProyectosCob`, `pintarCobranza(vista)`, `pintarEsquema`, `aceptarCot` (confirma con `window.confirm`
si el servidor lo pide), `guardarEsquemaCob`, `firmarCob`, `desbloquearCob`, `registrarPago` (lee el archivo con `FileReader`),
`cancelarPagoCob` (motivo con `window.prompt`). Funciones principales: `agregarFila`, `leerItems`,
`recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`, `recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`,
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`. `autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
Proyectos (módulo "1 · Cliente y proyecto"): `recibirProyectos`, `proyectoElegido`, `alElegirProyecto` (si "Nombre" está vacío, Proyectos (módulo "1 · Cliente y proyecto"): `recibirProyectos`, `proyectoElegido`, `alElegirProyecto` (si "Nombre" está vacío,
@ -183,18 +213,25 @@ Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config) ├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
├─ 📊 Catálogo Helgen (Catalogo) ├─ 📊 Catálogo Helgen (Catalogo)
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf (cotizaciones "Sin proyecto") ├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf (cotizaciones "Sin proyecto")
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones, Órdenes de compra, Pagos ├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones, Órdenes de compra
│ (los proyectos creados antes de Cobranza tienen además "Pagos", vacía)
├─ 📁 Catálogos SAT / SAT_c_ClaveProdServ.csv, SAT_c_ClaveUnidad.csv, 📊 Catálogos SAT (CFDI 4.0) ├─ 📁 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) ├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
└─ 📜 Cotizador Helgen (código de la app) ← proyecto ANTERIOR, en desuso └─ 📜 Cotizador Helgen (código de la app) ← proyecto ANTERIOR, en desuso
``` ```
**Aparte, fuera de "Cotizador Helgen"** (todo el dominio puede leer la carpeta maestra; esta no):
```
📁 Cotizador Helgen — Cobranza (COBRANZA_FOLDER_ID; lectores tania@ y leo@)
├─ 📊 Cotizador Helgen — Cobranza (datos) (COBRANZA_DB_ID: Contratos, Esquemas, Pagos, Bitacora)
└─ 📁 2026 / 📁 PRY-2026-0002 / PAG-2026-0001 — Transferencia SPEI 123.pdf
```
El proyecto de la API (`1GsH8Dja…`) está en la carpeta `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p` (antes "PRUEBAS — Cotizador web"), 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). junto con los archivos de prueba que ya no se usan (§9).
**Pestaña `Cotizaciones`** (20 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono, **Pestaña `Cotizaciones`** (21 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 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`, del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Dirección, `vendedor_email`, `vendedor_nombre`,
`vendedor_apellido`, `proyecto_folio` (T; vacío = "Sin proyecto"). Las columnas P:S se agregaron el 19/10/2026 (§11); las `vendedor_apellido`, `proyecto_folio` (T; vacío = "Sin proyecto"), `moneda` (U: MXN o USD; vacía en filas viejas = MXN). Las columnas P:S se agregaron el 19/10/2026 (§11); las
filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026): las filas anteriores quedan vacías y no se mueven. filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026): las filas anteriores quedan vacías y no se mueven.
`guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`. `guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`.
@ -218,10 +255,23 @@ no hay pantalla), `carpeta_id`, `vendedor_email`, `vendedor_nombre`, `vendedor_a
**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA), **"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. Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
**Libro de cobranza** (`COBRANZA_DB_ID`; columnas en `HOJAS_COBRANZA`; texto salvo importes):
- `Contratos` (1 fila por proyecto): `proyecto_folio`, `cotizacion_aceptada`, `marca`, `moneda`, `subtotal`, `iva`, `total`
(= monto contratado, con IVA), `estado` (Aceptada / Firmado), `aceptada_por`, `fecha_aceptacion`, `fecha_firma`, `firmado_por`, `actualizado`.
- `Esquemas`: `proyecto_folio`, `orden`, `etapa`, `porcentaje`, `monto` (se reescriben las filas del proyecto al cambiar).
- `Pagos`: `folio` PAG, `fecha_pago` (AAAA-MM-DD), `fecha_registro`, `proyecto_folio`, `monto`, `moneda_pago`, `tipo_cambio`,
`monto_aplicado` (en la moneda del proyecto), `moneda_proyecto`, `metodo`, `referencia`, `etapa_orden`, `etapa`,
`aplicacion (JSON)` (reparto por etapa al registrar), `comprobante_id`, `comprobante_url`, `registro_email`, `registro_nombre`,
`estado` (Vigente / Cancelado), `cancelado_por`, `fecha_cancelacion`, `motivo_cancelacion`. Nunca se borran filas.
- `Bitacora`: `fecha`, `correo`, `accion`, `proyecto_folio`, `detalle`, `motivo` (aceptar, cambiar aceptada, editar esquema,
firmar, desbloquear, registrar y cancelar pago).
**Script Properties** del proyecto `1GsH8Dja…` (Configuración del proyecto en el editor): **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`), `SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
`PROYECTOS_FOLDER_ID` y `CONSECUTIVO_PROY_AAAA` (se crean solos con el primer proyecto), `PROYECTOS_FOLDER_ID` y `CONSECUTIVO_PROY_AAAA` (se crean solos con el primer proyecto),
`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. `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.
Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `ADMIN_DESBLOQUEO` (efrain@), `COBRANZA_FOLDER_ID` y
`COBRANZA_DB_ID` (se crean solos), `CONSECUTIVO_PAG_AAAA`. **Sin `ROL_COBRANZA` nadie tiene cobranza** (se niega por omisión).
## 6. Decisiones tomadas (y por qué) ## 6. Decisiones tomadas (y por qué)
@ -292,20 +342,47 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
se le agrega RFC al cliente, su `id` cambia a ese RFC y los proyectos viejos conservan el `SIN-RFC:…`. se le agrega RFC al cliente, su `id` cambia a ese RFC y los proyectos viejos conservan el `SIN-RFC:…`.
- Folio propio con contador `CONSECUTIVO_PROY_AAAA` bajo el **mismo** candado de script que las cotizaciones. - Folio propio con contador `CONSECUTIVO_PROY_AAAA` bajo el **mismo** candado de script que las cotizaciones.
- **Subcarpetas que se reparan solas:** las subcarpetas de un proyecto se buscan **por nombre**. Si alguien borra (Papelera) o - **Subcarpetas que se reparan solas:** las subcarpetas de un proyecto se buscan **por nombre**. Si alguien borra (Papelera) o
renombra `Cotizaciones`, `Órdenes de compra` o `Pagos`, la próxima vez que se necesite el sistema **crea una nueva** con el renombra `Cotizaciones` u `Órdenes de compra`, la próxima vez que se necesite el sistema **crea una nueva** con el
nombre correcto (la renombrada se queda como está, con sus archivos). Si se borra la carpeta completa del proyecto, se nombre correcto (la renombrada se queda como está, con sus archivos). Si se borra la carpeta completa del proyecto, se
vuelve a crear (con sus 3 subcarpetas) y se actualiza `carpeta_id` en la hoja. Igual con `Proyectos` y su carpeta de año. vuelve a crear (con sus subcarpetas) y se actualiza `carpeta_id` en la hoja. Igual con `Proyectos` y su carpeta de año.
Renombrar la carpeta **del proyecto** sí se puede (se usa por ID). Renombrar la carpeta **del proyecto** sí se puede (se usa por ID).
- Las cotizaciones viejas no se mueven ni se ligan a proyectos. - Las cotizaciones viejas no se mueven ni se ligan a proyectos.
- Desde Cobranza, `SUBCARPETAS_PROYECTO` = `Cotizaciones`, `Órdenes de compra` (sin `Pagos`, solo para proyectos nuevos; los
viejos conservan la suya, vacía). Un proyecto con contrato firmado **no admite cotizaciones nuevas** y sale del buscador de
cotizaciones (se revisará con los adicionales).
### 6.10 Modelo futuro (fase 2; NO construido) ### 6.10 Modelo futuro (resto de la fase 2; NO construido)
- Pestañas `OrdenesCompra`, `Pagos` y `Gastos` en la base de datos, cada una con su folio (`OC-`, `PAG-`, `GAS-AAAA-NNNN`), - Órdenes de compra y gastos: folio (`OC-`, `GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`
contador `CONSECUTIVO_<TIPO>_AAAA` (usar `siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`** (`siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`** y `vendedor_*` del token, con encabezados
y `vendedor_*` del token. Crear las pestañas con el patrón de `hojaProyectos_()` (encabezados que se migran solos). que se migran solos. Antes de elegir dónde van, decidir quién debe verlos: la Base de datos la lee todo el dominio (§6.11).
- Archivos en `subcarpetaProyecto_(proyecto, 'Órdenes de compra' | 'Pagos')`. Gastos aún no tiene subcarpeta: agregarla a - Se comparan contra el monto contratado (`Contratos.total` del libro de cobranza).
`SUBCARPETAS_PROYECTO` cuando se construya (los proyectos viejos la obtienen sola por §6.9). - **Adicionales después de firmar** (propuesta aprobada para después): función de adicionales **dentro del mismo proyecto**:
- **Monto contratado:** la fase 2 necesitará marcar una cotización como **"Aceptada"** (columna de estatus en `Cotizaciones`) una cotización del proyecto se marca "Adicional", con su propio mini-esquema (por omisión 100% en una etapa), misma moneda que
para definir el monto contratado del proyecto, contra el que se comparan órdenes de compra, pagos y gastos. el proyecto, y se suma al contratado como línea aparte sin tocar el contrato original. Al construirla, revisar la regla de
"contrato firmado no admite cotizaciones nuevas".
### 6.11 Cobranza (construida 10/2026)
- **Datos en un libro aparte** dentro de la carpeta "Cotizador Helgen — Cobranza", fuera de la carpeta maestra: la Base de
datos y la carpeta maestra están compartidas como **lector con todo el dominio helgen.tech** (revisado el 20/10/2026), así
que una pestaña Pagos ahí la vería cualquier vendedor. **Nunca poner datos de cobranza en la Base de datos.**
- **Permisos en el servidor:** `ROL_COBRANZA` (Script Properties, no en el código) para ver y cambiar todo lo de cobranza;
`ADMIN_DESBLOQUEO` solo para desbloquear. Los demás reciben `codigo: "PERMISO"` y `getDatosIniciales` no les manda nada de
cobranza (solo `permisos` en falso para ocultar la pestaña).
- **Aceptada:** una por proyecto; se puede cambiar hasta firmar. Monto contratado = total **con IVA**; subtotal de referencia.
**Línea de negocio y moneda del proyecto salen de la aceptada.** Si ya hay pagos (tras un desbloqueo), la nueva aceptada debe
tener la misma moneda.
- **Esquema:** Helgen Energy **fijo** 60% Anticipo / 30% Instalación / 10% Encendido del sistema (el servidor rechaza editarlo);
Helgen: mismas etapas como sugerencia, editables (nombres, % y número de etapas, 1–10) hasta firmar. Suma exacta 100%.
- **Contrato firmado** (fecha, no futura): bloquea aceptada, monto y esquema. No se puede firmar si lo pagado supera el nuevo
monto. **Desbloqueo:** solo `ADMIN_DESBLOQUEO`, motivo obligatorio; vuelve a "Aceptada", conserva los pagos y queda en Bitacora.
- **Pagos:** solo con contrato firmado; comprobante **obligatorio** (PDF/JPG/PNG/HEIC, máx. 10 MB, tipo revisado por contenido) en
el mismo envío, guardado en `Cobranza/<año del folio del proyecto>/<folio del proyecto>/` con el folio PAG en el nombre.
Referencia obligatoria salvo en efectivo. El excedente de una etapa pasa a la siguiente. Un pago que excede el saldo del
proyecto por más de **1.00** (moneda del proyecto) se rechaza. Si la moneda del pago es otra, tipo de cambio del día
obligatorio (pesos por dólar) y se guarda el monto aplicado en la moneda del proyecto: **nunca se suman MXN y USD**.
- **Cancelar pago:** rol de cobranza, con motivo; el pago queda como Cancelado (nunca se borra) y deja de contar.
- **USD en cotizaciones:** el catálogo está en pesos: en USD la interfaz llena clave y descripción, y el precio se captura a mano
(no se convierte con tipo de cambio). El PDF dice "Moneda: USD (dólares estadounidenses)", "TOTAL USD" e importe con letra en dólares.
## 7. Reglas del proyecto (no romper) ## 7. Reglas del proyecto (no romper)
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa. 1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa.
@ -325,17 +402,20 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
10. Toda llamada a la API exige token válido de @helgen.tech (`hd`, `email_verified`, `aud`); el vendedor sale **siempre** 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. del token, nunca de lo que mande el navegador. No quitar esas revisiones.
11. **Responder siempre en español** (de México) al usuario, en el chat y en los mensajes de commit. 11. **Responder siempre en español** (de México) al usuario, en el chat y en los mensajes de commit.
12. Datos de cobranza (contratos, esquemas, pagos, comprobantes) **solo** en el libro y la carpeta de cobranza, y cada función
de cobranza revisa `ROL_COBRANZA` en el servidor (§6.11). Nunca sumar importes de monedas distintas.
## 8. Cómo trabajar ## 8. Cómo trabajar
```bash ```bash
# Pruebas (Node, servicios de Google simulados). Correr las seis antes de subir. # Pruebas (Node, servicios de Google simulados). Correr las siete 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 mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes, USD; deja helgen.html / energy.html / usd.html
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados 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/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/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan
node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado se limpia node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado, USD, pestaña Cobranza
node tests/cobranza.test.js . # cobranza por doPost: permisos, aceptada, esquema, firma, desbloqueo, pagos, monedas
node tests/probar-busqueda-sat.js Code.js "<ruta>/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras 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) # Subir y publicar la API (misma API_URL)
@ -378,7 +458,15 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio
- Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones. - Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones.
- `GUIA.md` describe el proyecto anterior; actualizarla si se va a usar. - `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. - Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
- Proyectos fase 2: órdenes de compra, pagos, gastos y cotización "Aceptada" (§6.10). - Proyectos fase 2: órdenes de compra, gastos y adicionales (§6.10). Cobranza ya construida (§6.11).
- **Cobranza, antes de usarla:** probar en el proyecto PRUEBAS (§10.5) con un usuario sin rol, correr `prepararCobranza()` en la
API real, capturar `ROL_COBRANZA` y `ADMIN_DESBLOQUEO`, compartir la carpeta de cobranza como lector con tania@ y leo@
(Google les manda correo: confirmar con el usuario antes) y anotar su ID en §2.
- **Mejora futura:** columna de precio en USD en el catálogo (hoy en cotizaciones USD el precio se captura a mano).
- **Acceso del dominio a la Base de datos y a la carpeta maestra** (lector para todo helgen.tech): se queda por ahora; revisarlo
junto con la idea "cada vendedor ve solo sus cotizaciones" (quitarlo impide abrir los PDF desde el Historial).
- Tania y Leo eran **editores** de la Base de datos (20/10/2026); el usuario los baja a lector.
- Los T&C de Helgen Energy dicen "$300 MXN por metro" también en cotizaciones USD: confirmar con el usuario si se ajusta.
- Mover el proyecto de prueba (ya sin deployment, §10.5) a la carpeta `1w-T05ql…` junto a los "ARCHIVO PRUEBAS" (lo hace el usuario). - Mover el proyecto de prueba (ya sin deployment, §10.5) a la carpeta `1w-T05ql…` junto a los "ARCHIVO PRUEBAS" (lo hace el usuario).
## 10. Interfaz web y sesión con Google ## 10. Interfaz web y sesión con Google
@ -407,6 +495,7 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio
(guardar es idempotente por `idSolicitud`: no duplica folio). (guardar es idempotente por `idSolicitud`: no duplica folio).
- Otros códigos llegan a la interfaz en `err.codigo`. `codigo: "PROYECTO"` (proyecto cancelado, cerrado o inexistente al guardar, - Otros códigos llegan a la interfaz en `err.codigo`. `codigo: "PROYECTO"` (proyecto cancelado, cerrado o inexistente al guardar,
`errorProyecto_`): `errorAlGuardar` limpia el campo Proyecto, refresca la lista y muestra el aviso. `errorProyecto_`): `errorAlGuardar` limpia el campo Proyecto, refresca la lista y muestra el aviso.
`codigo: "PERMISO"` (sin rol de cobranza o de desbloqueo, `errorPermiso_`): se muestra el error.
- Encabezado: nombre del vendedor (del token, solo para mostrar) + botón "Cerrar sesión" (`disableAutoSelect`). - 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. - 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`. - Wrangler 4 delega Cloudflare Pages "clásico" a Workers: se eligió **Worker con assets** (`wrangler.jsonc`), no `wrangler pages`.

772
Code.js
View File

@ -95,6 +95,15 @@ const API_FUNCIONES = {
sugerirClavesSat: sugerirClavesSat, sugerirClavesSat: sugerirClavesSat,
listarProyectos: listarProyectos, listarProyectos: listarProyectos,
crearProyecto: crearProyecto, crearProyecto: crearProyecto,
// Cobranza (solo ROL_COBRANZA; cada función lo revisa)
listarProyectosCobranza: listarProyectosCobranza,
getCobranzaProyecto: getCobranzaProyecto,
marcarAceptada: marcarAceptada,
guardarEsquema: guardarEsquema,
firmarContrato: firmarContrato,
desbloquearContrato: desbloquearContrato,
registrarPago: registrarPago,
cancelarPago: cancelarPago,
}; };
/** /**
@ -124,7 +133,7 @@ function doPost(e) {
} catch (err) { } catch (err) {
respuesta = { ok: false, error: err && err.message ? err.message : String(err) }; respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
if (err && err.sesion) respuesta.codigo = 'SESION'; if (err && err.sesion) respuesta.codigo = 'SESION';
else if (err && err.codigo) respuesta.codigo = err.codigo; // p. ej. 'PROYECTO' (ver errorProyecto_) else if (err && err.codigo) respuesta.codigo = err.codigo; // 'PROYECTO' (errorProyecto_), 'PERMISO' (errorPermiso_)
} finally { } finally {
VENDEDOR_ = null; VENDEDOR_ = null;
} }
@ -171,7 +180,7 @@ const CONFIG_EJEMPLO_ANTERIOR = {
const ENCABEZADOS_COTIZACIONES = [ const ENCABEZADOS_COTIZACIONES = [
'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono', 'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono',
'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección', 'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección',
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio' 'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio', 'moneda'
]; ];
function setup() { function setup() {
@ -552,6 +561,8 @@ function getDatosIniciales() {
proyectos: proyectosParaInicio_(), proyectos: proyectosParaInicio_(),
usuario: usuarioActual_(), usuario: usuarioActual_(),
vendedor: VENDEDOR_, vendedor: VENDEDOR_,
// Solo para mostrar u ocultar la pestaña Cobranza; cada función de cobranza lo vuelve a revisar
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_() },
}; };
} }
@ -595,6 +606,7 @@ function listarCotizaciones(busqueda) {
vendedor: vendedorDeFila(r, nombres), vendedor: vendedorDeFila(r, nombres),
// Filas anteriores a Proyectos (o cotizadas sin proyecto) // Filas anteriores a Proyectos (o cotizadas sin proyecto)
proyecto: r[19] || 'Sin proyecto', proyecto: r[19] || 'Sin proyecto',
moneda: r[20] || 'MXN', // filas anteriores a USD: pesos
})); }));
} }
@ -644,6 +656,10 @@ function guardarCotizacion(payload) {
throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() + throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() +
': ya no se le pueden agregar cotizaciones.'); ': ya no se le pueden agregar cotizaciones.');
} }
// Fase 2: con contrato firmado ya no se cotiza dentro del proyecto (se revisará con los adicionales)
if (foliosConContratoFirmado_()[proyecto.folio.toUpperCase()]) {
throw errorProyecto_('Proyecto con contrato firmado: no admite cotizaciones nuevas.');
}
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones'); carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
} }
@ -676,11 +692,12 @@ function guardarCotizacion(payload) {
t(datos.cliente.email), t(datos.cliente.telefono), t(datos.cliente.email), t(datos.cliente.telefono),
datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(), datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(),
JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion), JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion),
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio, t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio, datos.moneda,
]); ]);
SpreadsheetApp.flush(); SpreadsheetApp.flush();
const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total, proyectoFolio: proyectoFolio }; const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total, moneda: datos.moneda,
proyectoFolio: proyectoFolio };
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
return resultado; return resultado;
} finally { } finally {
@ -733,7 +750,9 @@ const ENCABEZADOS_PROYECTOS = [
const COL_ESTADO_PROYECTO = 7; // G const COL_ESTADO_PROYECTO = 7; // G
const COL_CARPETA_PROYECTO = 8; // H const COL_CARPETA_PROYECTO = 8; // H
const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado']; const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado'];
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra', 'Pagos']; // Sin "Pagos": los comprobantes van en la carpeta de cobranza (no visible para todo el dominio). Los proyectos
// creados antes la conservan; no se borra.
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra'];
const PREFIJO_PROYECTO = 'PRY'; const PREFIJO_PROYECTO = 'PRY';
/** /**
@ -773,7 +792,10 @@ function listarProyectos(busqueda, incluirInactivos) {
const hoja = hojaProyectos_(); const hoja = hojaProyectos_();
const filas = hoja.getLastRow() > 1 const filas = hoja.getLastRow() > 1
? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []; ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
return filtrarProyectos(proyectosDesdeFilas(filas), busqueda, incluirInactivos, 50) // El buscador de cotizaciones (solo activos) no muestra proyectos con contrato firmado
const firmados = incluirInactivos ? {} : foliosConContratoFirmado_();
return filtrarProyectos(proyectosDesdeFilas(filas).filter(p => !firmados[p.folio.toUpperCase()]),
busqueda, incluirInactivos, 50)
.map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId, .map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId,
ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha })); ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }));
} }
@ -878,7 +900,7 @@ function crearCarpetaProyecto_(folio, datos, anio) {
} }
/** /**
* Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra", "Pagos"…). Se busca por nombre: si alguien la * Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra"…). Se busca por nombre: si alguien la
* borró o la renombró, se crea una nueva. Si falta la carpeta del proyecto, se vuelve a crear y se actualiza * borró o la renombró, se crea una nueva. Si falta la carpeta del proyecto, se vuelve a crear y se actualiza
* carpeta_id en la hoja. * carpeta_id en la hoja.
*/ */
@ -897,6 +919,507 @@ function subcarpetaProyecto_(proyecto, nombreSub) {
return subcarpeta_(carpeta, nombreSub); return subcarpeta_(carpeta, nombreSub);
} }
// ====== 4.2 COBRANZA (Fase 2) ======
// Libro aparte "Cotizador Helgen — Cobranza (datos)" dentro de la carpeta "Cotizador Helgen — Cobranza" (fuera de
// la carpeta maestra, que todo el dominio puede leer). Solo el rol de cobranza (ROL_COBRANZA) ve o cambia estos datos;
// el servidor lo revisa en cada llamada. Comprobantes en Cobranza/AAAA/<folio del proyecto>/.
const NOMBRE_CARPETA_COBRANZA = 'Cotizador Helgen — Cobranza';
const NOMBRE_LIBRO_COBRANZA = 'Cotizador Helgen — Cobranza (datos)';
const PREFIJO_PAGO = 'PAG';
/** Estructura del libro. numeros = columnas (1 = A) con formato de importe; las demás van como texto. */
const HOJAS_COBRANZA = {
Contratos: {
enc: ['proyecto_folio', 'cotizacion_aceptada', 'marca', 'moneda', 'subtotal', 'iva', 'total', 'estado',
'aceptada_por', 'fecha_aceptacion', 'fecha_firma', 'firmado_por', 'actualizado'],
numeros: [5, 6, 7],
},
Esquemas: { enc: ['proyecto_folio', 'orden', 'etapa', 'porcentaje', 'monto'], numeros: [2, 4, 5] },
Pagos: {
enc: ['folio', 'fecha_pago', 'fecha_registro', 'proyecto_folio', 'monto', 'moneda_pago', 'tipo_cambio',
'monto_aplicado', 'moneda_proyecto', 'metodo', 'referencia', 'etapa_orden', 'etapa', 'aplicacion (JSON)',
'comprobante_id', 'comprobante_url', 'registro_email', 'registro_nombre', 'estado', 'cancelado_por',
'fecha_cancelacion', 'motivo_cancelacion'],
numeros: [5, 7, 8, 12],
},
Bitacora: { enc: ['fecha', 'correo', 'accion', 'proyecto_folio', 'detalle', 'motivo'], numeros: [] },
};
const ESTADO_ACEPTADA = 'Aceptada', ESTADO_FIRMADO = 'Firmado';
/** Error de permisos: doPost lo manda con codigo 'PERMISO'. */
function errorPermiso_(mensaje) {
const e = new Error(mensaje);
e.codigo = 'PERMISO';
return e;
}
/** ¿El usuario del token está en ROL_COBRANZA? Sin token (editor) o sin la propiedad: no. */
function esCobranza_() {
if (!VENDEDOR_) return false;
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA'), DOMINIO_HELGEN);
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
}
/** ¿Puede desbloquear contratos firmados? (ADMIN_DESBLOQUEO; sin la propiedad, nadie). */
function esAdminDesbloqueo_() {
if (!VENDEDOR_) return false;
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ADMIN_DESBLOQUEO'), DOMINIO_HELGEN);
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
}
function exigirCobranza_() {
if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.');
if (!esCobranza_()) throw errorPermiso_('No tienes permiso de cobranza.');
}
/** Carpeta de cobranza. Se crea sola en Mi unidad (sin compartir). Si se borró, error: no se crea otra a escondidas. */
function carpetaCobranza_() {
const props = PropertiesService.getScriptProperties();
const id = props.getProperty('COBRANZA_FOLDER_ID');
if (!id) {
const carpeta = DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA);
props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId());
return carpeta;
}
let carpeta = null;
try { carpeta = DriveApp.getFolderById(id); } catch (e) { carpeta = null; }
if (!carpeta || carpeta.isTrashed()) {
throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.');
}
return carpeta;
}
/** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */
function libroCobranza_() {
const props = PropertiesService.getScriptProperties();
if (!props.getProperty('COBRANZA_DB_ID')) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('Se está preparando cobranza. Intenta de nuevo en unos segundos.');
try {
if (!props.getProperty('COBRANZA_DB_ID')) crearLibroCobranza_();
} finally {
lock.releaseLock();
}
}
return abrirLibroCobranza_(props.getProperty('COBRANZA_DB_ID'));
}
/** Libro de cobranza si ya existe; null si todavía no se crea (las cotizaciones no lo crean). */
function libroCobranzaSiExiste_() {
const id = PropertiesService.getScriptProperties().getProperty('COBRANZA_DB_ID');
return id ? abrirLibroCobranza_(id) : null;
}
function abrirLibroCobranza_(id) {
try {
return SpreadsheetApp.openById(id);
} catch (e) {
throw new Error('No se pudo abrir el libro de cobranza (COBRANZA_DB_ID). Revisa que no esté en la Papelera.');
}
}
function crearLibroCobranza_() {
const carpeta = carpetaCobranza_();
const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA);
DriveApp.getFileById(ss.getId()).moveTo(carpeta);
Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre));
const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante);
PropertiesService.getScriptProperties().setProperty('COBRANZA_DB_ID', ss.getId());
}
/** Pestaña del libro de cobranza; la crea o le repone los encabezados (patrón de hojaProyectos_). */
function hojaCobranza_(libro, nombre) {
const def = HOJAS_COBRANZA[nombre];
let hoja = libro.getSheetByName(nombre);
if (!hoja) {
hoja = libro.insertSheet(nombre);
hoja.setFrozenRows(1);
hoja.getRange(1, 1, hoja.getMaxRows(), def.enc.length).setNumberFormat('@'); // texto: nada se lee como fórmula
def.numeros.forEach(c => hoja.getRange(2, c, hoja.getMaxRows() - 1, 1).setNumberFormat('#,##0.00##'));
}
const actuales = hoja.getRange(1, 1, 1, def.enc.length).getValues()[0];
if (def.enc.some((h, i) => actuales[i] !== h)) {
hoja.getRange(1, 1, 1, def.enc.length).setValues([def.enc])
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
}
return hoja;
}
/** Filas de datos (sin encabezado) de una pestaña, con el ancho de sus encabezados. */
function filasCobranza_(hoja, nombre) {
const n = HOJAS_COBRANZA[nombre].enc.length;
return hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, n).getValues() : [];
}
const marcaDeTexto_ = t => (String(t || '').trim() === MARCAS.energy || t === 'energy') ? 'energy' : 'helgen';
function contratoDesdeFila_(r, fila) {
return {
proyectoFolio: String(r[0]), cotizacion: String(r[1]), marca: marcaDeTexto_(r[2]), moneda: String(r[3] || 'MXN'),
subtotal: Number(r[4]) || 0, iva: Number(r[5]) || 0, total: Number(r[6]) || 0, estado: String(r[7] || ''),
aceptadaPor: String(r[8] || ''), fechaAceptacion: fechaTexto_(r[9]), fechaFirma: String(r[10] || ''),
firmadoPor: String(r[11] || ''), fila: fila,
};
}
function pagoDesdeFila_(r, fila) {
return {
folio: String(r[0]), fecha: String(r[1] || ''), fechaRegistro: fechaTexto_(r[2]), proyectoFolio: String(r[3]),
monto: Number(r[4]) || 0, monedaPago: String(r[5] || ''), tipoCambio: r[6] === '' ? '' : Number(r[6]),
montoAplicado: Number(r[7]) || 0, monedaProyecto: String(r[8] || ''), metodo: String(r[9] || ''),
referencia: String(r[10] || ''), etapaOrden: Number(r[11]) || 1, etapa: String(r[12] || ''),
comprobanteUrl: String(r[15] || ''), registro: String(r[17] || r[16] || ''), estado: String(r[18] || 'Vigente'),
canceladoPor: String(r[19] || ''), motivoCancelacion: String(r[21] || ''), fila: fila,
};
}
function fechaTexto_(v) {
return v instanceof Date ? Utilities.formatDate(v, 'America/Mexico_City', 'dd/MM/yyyy HH:mm') : String(v || '');
}
const hoyIso_ = () => Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd');
/** Todo lo de cobranza de un proyecto, leído del libro. */
function datosCobranza_(libro, folio) {
const f = String(folio).toUpperCase();
const hC = hojaCobranza_(libro, 'Contratos'), hE = hojaCobranza_(libro, 'Esquemas'), hP = hojaCobranza_(libro, 'Pagos');
let contrato = null;
filasCobranza_(hC, 'Contratos').forEach((r, i) => {
if (String(r[0]).toUpperCase() === f) contrato = contratoDesdeFila_(r, i + 2);
});
const etapas = filasCobranza_(hE, 'Esquemas')
.filter(r => String(r[0]).toUpperCase() === f)
.sort((a, b) => Number(a[1]) - Number(b[1]))
.map(r => ({ etapa: String(r[2]), porcentaje: Number(r[3]) }));
const pagos = [];
filasCobranza_(hP, 'Pagos').forEach((r, i) => {
if (String(r[3]).toUpperCase() === f) pagos.push(pagoDesdeFila_(r, i + 2));
});
return { hojas: { contratos: hC, esquemas: hE, pagos: hP }, contrato: contrato, etapas: etapas, pagos: pagos };
}
/** {"PRY-2026-0001": true, …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */
function foliosConContratoFirmado_() {
const libro = libroCobranzaSiExiste_();
const mapa = {};
if (!libro) return mapa;
const hoja = libro.getSheetByName('Contratos');
if (!hoja) return mapa;
filasCobranza_(hoja, 'Contratos').forEach(r => {
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = true;
});
return mapa;
}
/** Cotizaciones ligadas a un proyecto (columna T), de la hoja Cotizaciones. */
function cotizacionesDeProyecto_(folio) {
const f = String(folio).toUpperCase();
const hoja = db_().getSheetByName('Cotizaciones');
return hoja.getDataRange().getValues().slice(1)
.filter(r => String(r[19] || '').toUpperCase() === f && String(r[0] || '').trim())
.map(r => ({
folio: String(r[0]), fecha: r[1] instanceof Date ? Utilities.formatDate(r[1], 'America/Mexico_City', 'dd/MM/yyyy') : String(r[1] || ''),
cliente: String(r[2] || ''), subtotal: Number(r[7]) || 0, iva: Number(r[8]) || 0, total: Number(r[9]) || 0,
pdf: String(r[11] || ''), marca: marcaDeTexto_(r[14]), moneda: String(r[20] || '') || 'MXN',
vendedor: vendedorDeFila(r, {}),
}));
}
/** Proyecto existente (o error). */
function proyectoParaCobranza_(folio) {
const proyecto = buscarProyecto_(hojaProyectos_(), folio);
if (!proyecto) throw new Error('No existe el proyecto ' + String(folio || '').toUpperCase() + '.');
return proyecto;
}
function bitacora_(libro, accion, folio, detalle, motivo) {
const t = textoCelda;
hojaCobranza_(libro, 'Bitacora').appendRow([
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), t(VENDEDOR_ ? VENDEDOR_.email : ''),
t(accion), t(folio), t(detalle || ''), t(motivo || ''),
]);
}
/** Reescribe las etapas del proyecto en "Esquemas" (borra las anteriores) con sus montos. */
function escribirEsquema_(hoja, folio, etapas, total) {
const f = String(folio).toUpperCase();
const filas = filasCobranza_(hoja, 'Esquemas');
for (let i = filas.length - 1; i >= 0; i--) {
if (String(filas[i][0]).toUpperCase() === f) hoja.deleteRow(i + 2);
}
montosDeEsquema(etapas, total).forEach((e, i) => hoja.appendRow([f, i + 1, textoCelda(e.etapa), e.porcentaje, e.monto]));
}
/** Respuesta para la pestaña Cobranza: proyecto, cotizaciones, contrato, esquema con avance y pagos. */
function vistaCobranza_(libro, proyecto) {
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
const resumen = c && d.etapas.length ? resumenCobranza(c, d.etapas, d.pagos) : null;
return {
proyecto: { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado,
ubicacion: proyecto.ubicacion },
cotizaciones: cotizacionesDeProyecto_(proyecto.folio),
contrato: c ? { cotizacion: c.cotizacion, marca: c.marca, moneda: c.moneda, subtotal: c.subtotal, iva: c.iva,
total: c.total, estado: c.estado, aceptadaPor: c.aceptadaPor, fechaAceptacion: c.fechaAceptacion,
fechaFirma: c.fechaFirma, firmadoPor: c.firmadoPor } : null,
esquema: d.etapas,
resumen: resumen,
pagos: d.pagos.map(p => { const o = Object.assign({}, p); delete o.fila; return o; }).reverse(),
esquemaEditable: !!c && c.estado !== ESTADO_FIRMADO && c.marca !== 'energy',
puedePagar: !!c && c.estado === ESTADO_FIRMADO,
puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(),
metodos: METODOS_PAGO,
hoy: hoyIso_(),
};
}
/** Candado de script (el mismo de folios) alrededor de una función. */
function conCandado_(f) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('El sistema está ocupado guardando otro registro. Intenta de nuevo en unos segundos.');
try {
return f();
} finally {
lock.releaseLock();
}
}
/** Proyectos para el buscador de Cobranza (todos los estados), con el estado de su contrato. */
function listarProyectosCobranza(busqueda) {
exigirCobranza_();
const libro = libroCobranza_();
const estados = {};
filasCobranza_(hojaCobranza_(libro, 'Contratos'), 'Contratos').forEach(r => { estados[String(r[0]).toUpperCase()] = String(r[7]); });
return listarProyectos(busqueda, true).map(p => Object.assign(p, { contrato: estados[p.folio.toUpperCase()] || '' }));
}
function getCobranzaProyecto(folio) {
exigirCobranza_();
const libro = libroCobranza_();
return vistaCobranza_(libro, proyectoParaCobranza_(folio));
}
/**
* Marca la cotización aceptada del proyecto (una sola; se puede cambiar hasta firmar). Copia sus montos, marca y
* moneda al contrato. Si la nueva es de Helgen Energy y el esquema actual no es el 60/30/10, pide confirmar
* (devuelve {confirmar, mensaje}) antes de reemplazarlo.
*/
function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) {
exigirCobranza_();
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado.');
const folioCot = String(cotizacionFolio || '').trim().toUpperCase();
const cot = cotizacionesDeProyecto_(proyecto.folio).find(c => c.folio.toUpperCase() === folioCot);
if (!cot) throw new Error('La cotización ' + folioCot + ' no pertenece al proyecto ' + proyecto.folio + '.');
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (c && c.estado === ESTADO_FIRMADO) throw new Error('Contrato firmado: la cotización aceptada ya no se puede cambiar.');
if (c && c.cotizacion === cot.folio) return vistaCobranza_(libro, proyecto);
const vigentes = d.pagos.filter(p => p.estado === 'Vigente');
if (vigentes.length && vigentes[0].monedaProyecto && vigentes[0].monedaProyecto !== cot.moneda) {
throw new Error('El proyecto ya tiene pagos en ' + vigentes[0].monedaProyecto + ': la cotización aceptada debe ser en ' +
vigentes[0].monedaProyecto + '.');
}
// Esquema: Helgen Energy siempre 60/30/10; Helgen conserva el que ya tenga (o el sugerido)
const porOmision = esquemaPorOmision();
const igualAlDeOmision = d.etapas.length === porOmision.length &&
d.etapas.every((e, i) => e.etapa === porOmision[i].etapa && e.porcentaje === porOmision[i].porcentaje);
if (cot.marca === 'energy' && d.etapas.length && !igualAlDeOmision && !confirmado) {
return { confirmar: true, mensaje: 'La cotización ' + cot.folio + ' es de Helgen Energy: su esquema de pagos es fijo ' +
'(60% Anticipo, 30% Instalación, 10% Encendido del sistema) y reemplazará el esquema actual. ¿Continuar?' };
}
const etapas = cot.marca === 'energy' || !d.etapas.length ? porOmision : d.etapas;
const t = textoCelda;
const fila = [proyecto.folio, cot.folio, MARCAS[cot.marca], cot.moneda, cot.subtotal, cot.iva, cot.total, ESTADO_ACEPTADA,
t(VENDEDOR_.email), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm'), '', '',
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')];
if (c) d.hojas.contratos.getRange(c.fila, 1, 1, fila.length).setValues([fila]);
else d.hojas.contratos.appendRow(fila);
escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, cot.total);
bitacora_(libro, c ? 'Cambiar cotización aceptada' : 'Marcar cotización aceptada', proyecto.folio,
cot.folio + ' · ' + MARCAS[cot.marca] + ' · total ' + formatoMoneda(cot.total) + ' ' + cot.moneda +
(c ? ' (antes ' + c.cotizacion + ')' : ''));
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/** Guarda el esquema de pagos (solo Helgen y solo antes de firmar). */
function guardarEsquema(proyectoFolio, etapas) {
exigirCobranza_();
const limpio = validarEsquema(etapas);
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (!c) throw new Error('Primero marca la cotización aceptada del proyecto.');
if (c.estado === ESTADO_FIRMADO) throw new Error('Contrato firmado: el esquema de pagos ya no se puede cambiar.');
if (c.marca === 'energy') throw new Error('El esquema de Helgen Energy es fijo (60% / 30% / 10%).');
escribirEsquema_(d.hojas.esquemas, proyecto.folio, limpio, c.total);
bitacora_(libro, 'Editar esquema de pagos', proyecto.folio, limpio.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · '));
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/** Contrato firmado (fecha AAAA-MM-DD): fija la aceptada, el monto contratado y el esquema. */
function firmarContrato(proyectoFolio, fecha) {
exigirCobranza_();
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
const fechaFirma = validarFechaIso(fecha, hoyIso_(), 'La fecha de firma');
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (!c) throw new Error('Primero marca la cotización aceptada del proyecto.');
if (c.estado === ESTADO_FIRMADO) throw new Error('El contrato de ' + proyecto.folio + ' ya está firmado.');
if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado.');
const etapas = validarEsquema(d.etapas);
// Tras un desbloqueo puede haber pagos: el nuevo monto no puede quedar por debajo de lo pagado
const r = resumenCobranza(c, etapas, d.pagos);
if (r.saldo < -TOLERANCIA_PAGO) {
throw new Error('Lo pagado (' + formatoMoneda(r.pagado) + ' ' + c.moneda + ') supera el monto contratado (' +
formatoMoneda(r.contratado) + ').');
}
escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, c.total);
d.hojas.contratos.getRange(c.fila, 8, 1, 6).setValues([[ESTADO_FIRMADO, c.aceptadaPor, c.fechaAceptacion, fechaFirma,
textoCelda(VENDEDOR_.email), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]);
bitacora_(libro, 'Contrato firmado', proyecto.folio, 'Fecha de firma ' + fechaFirma + ' · ' + c.cotizacion + ' · ' +
formatoMoneda(c.total) + ' ' + c.moneda + ' · ' + etapas.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · '));
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/** Desbloqueo por error: solo ADMIN_DESBLOQUEO, con motivo. El contrato vuelve a "Aceptada"; los pagos se conservan. */
function desbloquearContrato(proyectoFolio, motivo) {
exigirCobranza_();
if (!esAdminDesbloqueo_()) throw errorPermiso_('Solo el administrador puede desbloquear un contrato firmado.');
const m = String(motivo || '').trim();
if (m.length < 5) throw new Error('Escribe el motivo del desbloqueo.');
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
const libro = libroCobranza_();
return conCandado_(() => {
const proyecto = proyectoParaCobranza_(proyectoFolio);
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('El contrato de ' + proyecto.folio + ' no está firmado.');
d.hojas.contratos.getRange(c.fila, 8, 1, 6).setValues([[ESTADO_ACEPTADA, c.aceptadaPor, c.fechaAceptacion, '', '',
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]);
bitacora_(libro, 'Desbloquear contrato', proyecto.folio,
'Estaba firmado el ' + c.fechaFirma + ' por ' + c.firmadoPor + ' · ' + c.cotizacion, m);
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyecto);
});
}
/**
* Registra un pago con su comprobante (obligatorio). p = {proyectoFolio, fecha, monto, monedaPago, tipoCambio, metodo,
* referencia, etapaOrden, comprobante: {datos (base64)}, idSolicitud}. Solo con contrato firmado; si el pago excede su
* etapa, el excedente pasa a la siguiente; si excede el saldo del proyecto (más la tolerancia), se rechaza.
*/
function registrarPago(p) {
exigirCobranza_();
const d0 = p || {};
let bytes;
try {
bytes = d0.comprobante && d0.comprobante.datos ? Utilities.base64Decode(String(d0.comprobante.datos)) : null;
} catch (e) {
throw new Error('No se pudo leer el comprobante. Vuelve a adjuntarlo.');
}
const tipo = validarComprobante(bytes);
const libro = libroCobranza_();
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
const cache = CacheService.getScriptCache();
const claveCache = idSolicitud ? 'PAG_' + idSolicitud : '';
return conCandado_(() => {
if (claveCache) {
const previo = cache.get(claveCache);
if (previo) return JSON.parse(previo);
}
const proyecto = proyectoParaCobranza_(d0.proyectoFolio);
const d = datosCobranza_(libro, proyecto.folio);
const c = d.contrato;
if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('Solo se registran pagos en proyectos con contrato firmado.');
const pago = normalizarPago(d0, { moneda: c.moneda, etapas: d.etapas.length, hoy: hoyIso_() });
const resumen = resumenCobranza(c, d.etapas, d.pagos);
if (aCentavos(pago.montoAplicado) > aCentavos(resumen.saldo) + aCentavos(TOLERANCIA_PAGO)) {
throw new Error('El pago (' + formatoMoneda(pago.montoAplicado) + ' ' + c.moneda + ') excede el saldo del proyecto (' +
formatoMoneda(Math.max(resumen.saldo, 0)) + ' ' + c.moneda + ').');
}
const reparto = repartirPago(resumen.etapas, pago.etapaOrden - 1, pago.montoAplicado);
if (reparto.sobranteCts) reparto.aplicacion.push({ orden: pago.etapaOrden, centavos: reparto.sobranteCts });
const aplicacion = reparto.aplicacion.map(a => ({ etapa: d.etapas[a.orden - 1].etapa, orden: a.orden, monto: a.centavos / 100 }));
const hojaPagos = d.hojas.pagos;
const folio = siguienteFolio_(PREFIJO_PAGO, hojaPagos, 'CONSECUTIVO_PAG_');
const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4);
const carpeta = subcarpeta_(subcarpeta_(carpetaCobranza_(), anio), proyecto.folio);
const archivo = carpeta.createFile(Utilities.newBlob(bytes, tipo.mime,
nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext)));
const t = textoCelda;
hojaPagos.appendRow([
folio, pago.fecha, Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), proyecto.folio,
pago.monto, pago.monedaPago, pago.tipoCambio, pago.montoAplicado, c.moneda, pago.metodo, t(pago.referencia),
pago.etapaOrden, t(d.etapas[pago.etapaOrden - 1].etapa), JSON.stringify(aplicacion), archivo.getId(), archivo.getUrl(),
t(VENDEDOR_.email), t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ')), 'Vigente', '', '', '',
]);
bitacora_(libro, 'Registrar pago', proyecto.folio, folio + ' · ' + formatoMoneda(pago.monto) + ' ' + pago.monedaPago +
(pago.tipoCambio ? ' · TC ' + pago.tipoCambio + ' = ' + formatoMoneda(pago.montoAplicado) + ' ' + c.moneda : '') +
' · ' + pago.metodo + ' · ' + aplicacion.map(a => a.etapa + ' ' + formatoMoneda(a.monto)).join(' + '));
SpreadsheetApp.flush();
const resultado = { folio: folio, url: archivo.getUrl(), vista: vistaCobranza_(libro, proyecto) };
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
return resultado;
});
}
/** Cancela un pago (no se borra): rol de cobranza, con motivo; queda en la Bitácora. */
function cancelarPago(folioPago, motivo) {
exigirCobranza_();
const m = String(motivo || '').trim();
if (m.length < 5) throw new Error('Escribe el motivo de la cancelación.');
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
const libro = libroCobranza_();
return conCandado_(() => {
const folio = String(folioPago || '').trim().toUpperCase();
const hoja = hojaCobranza_(libro, 'Pagos');
let pago = null;
filasCobranza_(hoja, 'Pagos').forEach((r, i) => { if (String(r[0]).toUpperCase() === folio) pago = pagoDesdeFila_(r, i + 2); });
if (!pago) throw new Error('No existe el pago ' + folio + '.');
if (pago.estado !== 'Vigente') throw new Error('El pago ' + folio + ' ya está cancelado.');
hoja.getRange(pago.fila, 19, 1, 4).setValues([['Cancelado', textoCelda(VENDEDOR_.email),
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
bitacora_(libro, 'Cancelar pago', pago.proyectoFolio, folio + ' · ' + formatoMoneda(pago.monto) + ' ' + pago.monedaPago, m);
SpreadsheetApp.flush();
return vistaCobranza_(libro, proyectoParaCobranza_(pago.proyectoFolio));
});
}
/**
* Correr UNA vez desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad, sin compartir) y su
* libro. También sirve para autorizar permisos antes de publicar (§6.8). No comparte nada.
*/
function prepararCobranza() {
const libro = libroCobranza_();
Logger.log('Carpeta de cobranza: ' + carpetaCobranza_().getUrl());
Logger.log('Libro de cobranza: ' + libro.getUrl());
Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)'));
}
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ====== // ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
function redondear(n) { function redondear(n) {
@ -936,6 +1459,7 @@ function normalizarCotizacion(p, ivaTasa) {
cliente: cliente, items: items, subtotal: subtotal, iva: iva, total: total, ivaTasa: ivaTasa, cliente: cliente, items: items, subtotal: subtotal, iva: iva, total: total, ivaTasa: ivaTasa,
notas: String((p && p.notas) || '').trim(), notas: String((p && p.notas) || '').trim(),
marca: p && p.marca === 'energy' ? 'energy' : 'helgen', marca: p && p.marca === 'energy' ? 'energy' : 'helgen',
moneda: monedaDeCotizacion(p && p.moneda),
}; };
} }
@ -1079,6 +1603,224 @@ function filtrarProyectos(lista, busqueda, incluirInactivos, n) {
.slice(0, n || 50); .slice(0, n || 50);
} }
// ---- Monedas (MXN / USD) ----
// Cada cotización lleva su moneda (columna U); nunca se suman importes de monedas distintas.
const MONEDAS = ['MXN', 'USD'];
/** Moneda de una cotización nueva: vacía = MXN (como antes); otra que no sea MXN/USD es error. */
function monedaDeCotizacion(m) {
const s = String(m == null ? '' : m).trim().toUpperCase();
if (!s) return 'MXN';
if (MONEDAS.indexOf(s) === -1) throw new Error('Moneda no válida: ' + s + '. Usa MXN o USD.');
return s;
}
// ---- Cobranza: esquema de pagos, reparto, comprobantes y roles ----
const ESQUEMA_POR_OMISION = [['Anticipo', 60], ['Instalación', 30], ['Encendido del sistema', 10]];
const MAX_ETAPAS = 10;
const METODOS_PAGO = ['Transferencia', 'Efectivo', 'Cheque'];
const TOLERANCIA_PAGO = 1.00; // en la moneda del proyecto (diferencias de redondeo)
const MAX_COMPROBANTE = 10 * 1024 * 1024; // 10 MB
/** Etapas por omisión: Helgen Energy 60/30/10 (fijo); Helgen, las mismas como sugerencia editable. */
function esquemaPorOmision() {
return ESQUEMA_POR_OMISION.map(([etapa, porcentaje]) => ({ etapa: etapa, porcentaje: porcentaje }));
}
/** Pesos/dólares -> centavos enteros (evita errores de punto flotante al sumar). */
function aCentavos(n) {
return Math.round(redondear(n) * 100);
}
/**
* Valida un esquema [{etapa, porcentaje}]: de 1 a MAX_ETAPAS etapas, nombres únicos (máx. 40 caracteres),
* porcentajes mayores a 0 con 2 decimales y suma exacta de 100%. Devuelve el esquema limpio.
*/
function validarEsquema(etapas) {
if (!Array.isArray(etapas) || !etapas.length) throw new Error('El esquema de pagos necesita al menos una etapa.');
if (etapas.length > MAX_ETAPAS) throw new Error('El esquema de pagos admite máximo ' + MAX_ETAPAS + ' etapas.');
const vistos = {};
const limpio = etapas.map((e, i) => {
const etapa = String((e && e.etapa) || '').trim().replace(/\s+/g, ' ');
const txt = e && e.porcentaje;
const porcentaje = Number(txt);
if (!etapa) throw new Error('La etapa ' + (i + 1) + ' no tiene nombre.');
if (etapa.length > 40) throw new Error('El nombre de la etapa "' + etapa.slice(0, 20) + '…" debe tener máximo 40 caracteres.');
const llave = textoNormal(etapa);
if (vistos[llave]) throw new Error('La etapa "' + etapa + '" está repetida.');
vistos[llave] = true;
if (txt === '' || txt == null || !isFinite(porcentaje) || porcentaje <= 0) {
throw new Error('El porcentaje de "' + etapa + '" debe ser mayor a 0.');
}
if (Math.abs(redondear(porcentaje) - porcentaje) > 1e-9) {
throw new Error('El porcentaje de "' + etapa + '" admite máximo 2 decimales.');
}
return { etapa: etapa, porcentaje: redondear(porcentaje) };
});
const suma = limpio.reduce((s, e) => s + Math.round(e.porcentaje * 100), 0);
if (suma !== 10000) {
throw new Error('Los porcentajes deben sumar 100% (suman ' + redondear(suma / 100) + '%).');
}
return limpio;
}
/** Monto de cada etapa = total × %, redondeado; la última etapa se queda con la diferencia de centavos. */
function montosDeEsquema(etapas, total) {
const totalCts = aCentavos(total);
let acumulado = 0;
return etapas.map((e, i) => {
const cts = i === etapas.length - 1 ? totalCts - acumulado : Math.round(totalCts * e.porcentaje / 100);
acumulado += cts;
return { etapa: e.etapa, porcentaje: e.porcentaje, monto: cts / 100 };
});
}
/**
* Convierte el monto de un pago a la moneda del proyecto. Si las monedas son distintas, el tipo de cambio
* (pesos por dólar) es obligatorio: MXN -> USD divide, USD -> MXN multiplica.
*/
function convertirMoneda(monto, monedaPago, monedaProyecto, tipoCambio) {
if (monedaPago === monedaProyecto) return redondear(monto);
const tc = Number(tipoCambio);
if (!isFinite(tc) || tc <= 0) throw new Error('Captura el tipo de cambio del día (pesos por dólar).');
if (monedaPago === 'MXN' && monedaProyecto === 'USD') return redondear(monto / tc);
if (monedaPago === 'USD' && monedaProyecto === 'MXN') return redondear(monto * tc);
throw new Error('Moneda no válida.');
}
/**
* Reparte un monto entre las etapas: primero la elegida; el excedente pasa a la siguiente con saldo y, al llegar
* a la última, a las anteriores en orden. etapas = [{monto, pagado}]. Devuelve {aplicacion: [{orden, centavos}],
* sobranteCts} sin modificar las etapas.
*/
function repartirPago(etapas, indice, monto) {
const n = etapas.length;
let restante = aCentavos(monto);
const aplicacion = [];
for (let k = 0; k < n && restante > 0; k++) {
const i = (indice + k) % n;
const saldo = aCentavos(etapas[i].monto) - aCentavos(etapas[i].pagado || 0);
if (saldo <= 0) continue;
const toma = Math.min(saldo, restante);
aplicacion.push({ orden: i + 1, centavos: toma });
restante -= toma;
}
return { aplicacion: aplicacion, sobranteCts: restante };
}
/**
* Resumen de cobranza de un proyecto. contrato = {total, subtotal, moneda}; etapas = [{etapa, porcentaje}];
* pagos = [{estado, etapaOrden, montoAplicado}] en orden de registro (solo cuentan los Vigentes, se vuelven
* a repartir en ese orden). Lo que no cabe en ninguna etapa (dentro de la tolerancia) se suma a la etapa elegida.
*/
function resumenCobranza(contrato, etapas, pagos) {
const lista = montosDeEsquema(etapas, contrato.total).map(e => Object.assign(e, { pagado: 0 }));
let pagadoCts = 0;
(pagos || []).filter(p => p.estado === 'Vigente').forEach(p => {
const indice = Math.min(Math.max((Number(p.etapaOrden) || 1) - 1, 0), lista.length - 1);
const r = repartirPago(lista, indice, p.montoAplicado);
r.aplicacion.forEach(a => { lista[a.orden - 1].pagado = (aCentavos(lista[a.orden - 1].pagado) + a.centavos) / 100; });
if (r.sobranteCts) lista[indice].pagado = (aCentavos(lista[indice].pagado) + r.sobranteCts) / 100;
pagadoCts += aCentavos(p.montoAplicado);
});
lista.forEach(e => {
e.saldo = (aCentavos(e.monto) - aCentavos(e.pagado)) / 100;
e.avance = e.monto > 0 ? Math.min(100, Math.round(e.pagado / e.monto * 1000) / 10) : 0;
});
const contratadoCts = aCentavos(contrato.total);
return {
moneda: contrato.moneda, contratado: contratadoCts / 100, subtotal: redondear(contrato.subtotal),
pagado: pagadoCts / 100, saldo: (contratadoCts - pagadoCts) / 100,
avance: contratadoCts > 0 ? Math.min(100, Math.round(pagadoCts / contratadoCts * 1000) / 10) : 0,
etapas: lista,
};
}
/** Fecha "AAAA-MM-DD" válida, no futura (hoy = "AAAA-MM-DD") y no anterior a 2020. */
function validarFechaIso(s, hoy, campo) {
const v = String(s || '').trim();
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const nombre = campo || 'La fecha';
if (!m) throw new Error(nombre + ' es obligatoria (AAAA-MM-DD).');
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
if (d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) {
throw new Error(nombre + ' no es válida.');
}
if (v < '2020-01-01') throw new Error(nombre + ' no es válida.');
if (hoy && v > hoy) throw new Error(nombre + ' no puede ser futura.');
return v;
}
/**
* Valida y limpia un pago. proyecto = {moneda, etapas (número de etapas), hoy "AAAA-MM-DD"}.
* Devuelve {fecha, monto, monedaPago, tipoCambio, montoAplicado, metodo, referencia, etapaOrden}.
*/
function normalizarPago(p, proyecto) {
const d = p || {};
const fecha = validarFechaIso(d.fecha, proyecto.hoy, 'La fecha del pago');
const montoTxt = d.monto, monto = Number(montoTxt);
if (montoTxt === '' || montoTxt == null || !isFinite(monto) || monto <= 0) throw new Error('El monto del pago debe ser mayor a 0.');
if (Math.abs(redondear(monto) - monto) > 1e-9) throw new Error('El monto admite máximo 2 decimales.');
if (monto > 1e10) throw new Error('El monto del pago no es válido.');
const monedaPago = String(d.monedaPago || proyecto.moneda).trim().toUpperCase();
if (MONEDAS.indexOf(monedaPago) === -1) throw new Error('Moneda del pago no válida. Usa MXN o USD.');
const metodo = METODOS_PAGO.find(m => textoNormal(m) === textoNormal(d.metodo));
if (!metodo) throw new Error('Elige el método de pago: ' + METODOS_PAGO.join(', ') + '.');
const referencia = String(d.referencia || '').trim().replace(/\s+/g, ' ');
if (!referencia && metodo !== 'Efectivo') {
throw new Error('Captura la referencia (' + (metodo === 'Cheque' ? 'número de cheque' : 'clave de rastreo o folio') + ').');
}
if (referencia.length > 100) throw new Error('La referencia debe tener máximo 100 caracteres.');
const etapaOrden = Number(d.etapaOrden);
if (!Number.isInteger(etapaOrden) || etapaOrden < 1 || etapaOrden > proyecto.etapas) throw new Error('Elige la etapa del pago.');
let tipoCambio = '';
if (monedaPago !== proyecto.moneda) {
tipoCambio = Number(d.tipoCambio);
if (d.tipoCambio === '' || d.tipoCambio == null || !isFinite(tipoCambio) || tipoCambio <= 0 || tipoCambio > 1000) {
throw new Error('El proyecto es en ' + proyecto.moneda + ' y el pago en ' + monedaPago +
': captura el tipo de cambio del día (pesos por dólar).');
}
tipoCambio = Math.round(tipoCambio * 10000) / 10000;
}
return { fecha: fecha, monto: redondear(monto), monedaPago: monedaPago, tipoCambio: tipoCambio,
montoAplicado: convertirMoneda(monto, monedaPago, proyecto.moneda, tipoCambio),
metodo: metodo, referencia: referencia, etapaOrden: etapaOrden };
}
/**
* Tipo real de un comprobante por sus primeros bytes (no por la extensión). bytes = arreglo de enteros; en Apps
* Script vienen con signo (-128…127). Acepta PDF, JPG, PNG y HEIC; máximo MAX_COMPROBANTE. Devuelve {mime, ext}.
*/
function validarComprobante(bytes) {
if (!bytes || !bytes.length) throw new Error('Adjunta el comprobante de pago (PDF o imagen).');
if (bytes.length > MAX_COMPROBANTE) throw new Error('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.');
const b = i => (bytes[i] || 0) & 0xFF;
const txt = (desde, n) => String.fromCharCode.apply(null, Array.from({ length: n }, (_, i) => b(desde + i)));
if (txt(0, 5) === '%PDF-') return { mime: 'application/pdf', ext: 'pdf' };
if (b(0) === 0xFF && b(1) === 0xD8 && b(2) === 0xFF) return { mime: 'image/jpeg', ext: 'jpg' };
if (b(0) === 0x89 && txt(1, 3) === 'PNG') return { mime: 'image/png', ext: 'png' };
if (txt(4, 4) === 'ftyp' && ['heic', 'heix', 'hevc', 'heim', 'heis', 'mif1', 'msf1'].indexOf(txt(8, 4)) !== -1) {
return { mime: 'image/heic', ext: 'heic' };
}
throw new Error('El comprobante debe ser PDF o imagen (JPG, PNG o HEIC).');
}
/** Correos de una propiedad ("a@helgen.tech, b@helgen.tech"), en minúsculas; solo del dominio. */
function correosDeLista(texto, dominio) {
return String(texto || '').split(/[\s,;]+/).map(s => s.trim().toLowerCase())
.filter(s => s && s.endsWith('@' + dominio) && s.indexOf('@') > 0)
.filter((s, i, a) => a.indexOf(s) === i);
}
/** Nombre del archivo del comprobante: "PAG-2026-0001 — <método> <referencia>.pdf", sin caracteres raros. */
function nombreComprobante(folio, metodo, referencia, ext) {
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
return (limpio(folio) + ' — ' + [limpio(metodo), limpio(referencia).slice(0, 60)].filter(Boolean).join(' ')).slice(0, 140) +
'.' + ext;
}
// ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ---- // ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ----
const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' + const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' +
@ -1341,8 +2083,11 @@ function escaparHtml(s) {
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
/** 1234.5 -> "MIL DOSCIENTOS TREINTA Y CUATRO PESOS 50/100 M.N." */ /**
function importeConLetra(n) { * 1234.5 -> "MIL DOSCIENTOS TREINTA Y CUATRO PESOS 50/100 M.N."
* En USD: "MIL DOSCIENTOS TREINTA Y CUATRO DÓLARES 50/100 USD".
*/
function importeConLetra(n, moneda) {
const UNI = ['', 'UN', 'DOS', 'TRES', 'CUATRO', 'CINCO', 'SEIS', 'SIETE', 'OCHO', 'NUEVE', 'DIEZ', const UNI = ['', 'UN', 'DOS', 'TRES', 'CUATRO', 'CINCO', 'SEIS', 'SIETE', 'OCHO', 'NUEVE', 'DIEZ',
'ONCE', 'DOCE', 'TRECE', 'CATORCE', 'QUINCE', 'DIECISÉIS', 'DIECISIETE', 'DIECIOCHO', 'DIECINUEVE', 'VEINTE', 'ONCE', 'DOCE', 'TRECE', 'CATORCE', 'QUINCE', 'DIECISÉIS', 'DIECISIETE', 'DIECIOCHO', 'DIECINUEVE', 'VEINTE',
'VEINTIÚN', 'VEINTIDÓS', 'VEINTITRÉS', 'VEINTICUATRO', 'VEINTICINCO', 'VEINTISÉIS', 'VEINTISIETE', 'VEINTIOCHO', 'VEINTINUEVE']; 'VEINTIÚN', 'VEINTIDÓS', 'VEINTITRÉS', 'VEINTICUATRO', 'VEINTICINCO', 'VEINTISÉIS', 'VEINTISIETE', 'VEINTIOCHO', 'VEINTINUEVE'];
@ -1376,7 +2121,9 @@ function importeConLetra(n) {
const cent = Math.round((v - ent) * 100); const cent = Math.round((v - ent) * 100);
const base = entero(ent); const base = entero(ent);
const de = (ent >= 1e6 && ent % 1e6 === 0) ? ' DE' : ''; const de = (ent >= 1e6 && ent % 1e6 === 0) ? ' DE' : '';
return base + de + (ent === 1 ? ' PESO ' : ' PESOS ') + String(cent).padStart(2, '0') + '/100 M.N.'; const cts = String(cent).padStart(2, '0') + '/100';
if (moneda === 'USD') return base + de + (ent === 1 ? ' DÓLAR ' : ' DÓLARES ') + cts + ' USD';
return base + de + (ent === 1 ? ' PESO ' : ' PESOS ') + cts + ' M.N.';
} }
/** /**
@ -1434,6 +2181,7 @@ function construirHtmlPdf(d, cfg, logoUrl) {
'<span style="color:' + AZUL + ';font-weight:bold;letter-spacing:1px;border:1.5px solid ' + TEAL + ';padding:2px 8px;">HELGEN ENERGY</span></td></tr>' '<span style="color:' + AZUL + ';font-weight:bold;letter-spacing:1px;border:1.5px solid ' + TEAL + ';padding:2px 8px;">HELGEN ENERGY</span></td></tr>'
: '') + : '') +
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Vigencia', d.vigencia) + docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Vigencia', d.vigencia) +
(d.moneda === 'USD' ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
'</table></td></tr></table>' + '</table></td></tr></table>' +
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' + '<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
'<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' + '<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' +
@ -1461,13 +2209,13 @@ function construirHtmlPdf(d, cfg, logoUrl) {
'<table><tr>' + '<table><tr>' +
'<td style="width:58%;padding-left:0;">' + '<td style="width:58%;padding-left:0;">' +
'<div style="color:' + GRIS + ';">Importe con letra</div>' + '<div style="color:' + GRIS + ';">Importe con letra</div>' +
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total) + ')</div>' + '<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total, d.moneda) + ')</div>' +
(d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') + (d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') +
'</td><td style="width:42%;padding-right:0;"><table>' + '</td><td style="width:42%;padding-right:0;"><table>' +
'<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' + '<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' +
'<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' + '<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' +
'<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' + '<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' +
'<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(cfg.MONEDA || 'MXN') + '</td>' + '<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(d.moneda || 'MXN') + '</td>' +
'<td class="r" style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">' + formatoMoneda(d.total) + '</td></tr>' + '<td class="r" style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">' + formatoMoneda(d.total) + '</td></tr>' +
'</table></td></tr></table>' + '</table></td></tr></table>' +

296
tests/cobranza.test.js Normal file
View File

@ -0,0 +1,296 @@
// Cobranza (fase 2): cotización aceptada, esquema de pagos, contrato firmado, desbloqueo, pagos con comprobante,
// monedas y permisos (ROL_COBRANZA validado en el servidor). Todo por doPost con tokens simulados.
// Uso: node tests/cobranza.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+"';");
const ANIO=String(new Date().getFullYear());
const pad=n=>String(n).padStart(2,'0');
const HOY=new Date(), HOY_ISO=HOY.getFullYear()+'-'+pad(HOY.getMonth()+1)+'-'+pad(HOY.getDate());
// ---- Hojas y libros simulados ----
function hoja(m){const h={m,getLastRow:()=>m.length,getMaxRows:()=>1000,setFrozenRows(){},setColumnWidth(){},
getDataRange:()=>({getValues:()=>m.map(r=>r.slice()),getDisplayValues:()=>m.map(r=>r.map(x=>x instanceof Date?'20/10/2026':String(x??'')))}),
appendRow:a=>m.push(a),deleteRow:n=>m.splice(n-1,1),
getRange:(r,c=1,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({
getValues:()=>Array.from({length:nr},(_,i)=>{const f=(m[r-1+i]||[]).slice(c-1,c-1+nc);while(f.length<nc)f.push('');return f}),
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},
setNumberFormat(){return this},setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this}})};
return h}
const libros={};
function libro(id,nombre,pestanas){const L={id,nombre,pest:pestanas||{},getId:()=>id,getUrl:()=>'https://sheets/'+id,
getSheetByName:n=>L.pest[n]||null,getSheets:()=>Object.values(L.pest),
insertSheet:n=>{if(L.pest[n])throw new Error('ya existe');return L.pest[n]=hoja([])},
deleteSheet:h=>{for(const k in L.pest)if(L.pest[k]===h)delete L.pest[k]}};
libros[id]=L;return L}
// ---- Drive simulado ----
let nId=0; const carpetas={}, archivos={};
function carpeta(nombre,padre){const f={id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false,
getId:()=>f.id,getName:()=>f.nombre,getUrl:()=>'https://drive/'+f.id,isTrashed:()=>f.trashed,
getFoldersByName:n=>{const l=f.hijos.filter(h=>h.nombre===n);let i=0;return{hasNext:()=>i<l.length,next:()=>l[i++]}},
createFolder:n=>{const h=carpeta(n,f);f.hijos.push(h);return h},
createFile:b=>{const id='A'+(++nId);const a={id,blob:b,padre:f};archivos[id]=a;f.archivos.push(a);return{getId:()=>id,getUrl:()=>'https://drive/file/'+id}}};
carpetas[f.id]=f;return f}
const miUnidad=carpeta('Mi unidad',null), maestra=miUnidad.createFolder('Cotizador Helgen'), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)');
const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre;
archivos.DB={id:'DB',padre:maestra};
const db=libro('DB','Base de datos',{Config:hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]),Cotizaciones:hoja([])});
libro('CAT','Catálogo',{Catalogo:hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']])});
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT',
ROL_COBRANZA:'efrain@helgen.tech, Tania@helgen.tech; leo@helgen.tech, intruso@gmail.com',ADMIN_DESBLOQUEO:'efrain@helgen.tech'},cache={};
// ---- Tokens: efrain (cobranza + admin), tania (cobranza), ana (vendedora sin rol) ----
const ahora=Math.floor(Date.now()/1000);
const claims=(email,n,a)=>({aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email,email_verified:'true',hd:'helgen.tech',given_name:n,family_name:a});
const tokens={efrain:claims('efrain@helgen.tech','Efrain','Sosa'),tania:claims('tania@helgen.tech','Tania','R'),
ana:claims('ana@helgen.tech','Ana','López'),intruso:{...claims('intruso@gmail.com','X','Y'),hd:undefined}};
const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMonth()+1)).replace('dd',pad(d.getDate()))
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo
const ctx={console:{...console,warn(){}},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:()=>''})},
Logger:{log(){}},
Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c},
base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')),
DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]},
UrlFetchApp:{fetch:url=>{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=>{if(!libros[id])throw new Error('No encontrado');return libros[id]},
create:n=>{const L=libro('S'+(++nId),n,{'Hoja 1':hoja([])});archivos[L.id]={id:L.id,padre:miUnidad};return L},
newDataValidation:()=>{const b={requireValueInList:()=>b,setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}},
DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]},
createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad,
getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}},
moveTo:f=>{archivos[id].padre=f}})},
};
vm.createContext(ctx); vm.runInContext(src,ctx);
const post=(quien,funcion,...args)=>JSON.parse(ctx.doPost({postData:{contents:JSON.stringify({funcion,args,token:quien})}}).t);
const ok=(quien,funcion,...args)=>{const r=post(quien,funcion,...args);assert(r.ok,funcion+': '+r.error);return r.datos};
const falla=(quien,funcion,args,re,codigo)=>{const r=post(quien,funcion,...args);assert.equal(r.ok,false,funcion+' debía fallar');
assert(re.test(r.error),funcion+': '+r.error);if(codigo)assert.equal(r.codigo,codigo,funcion+' codigo');return r};
// ================= Lógica pura =================
const P=ctx;
// Esquema
assert.deepEqual(P.esquemaPorOmision().map(e=>e.etapa+' '+e.porcentaje),['Anticipo 60','Instalación 30','Encendido del sistema 10']);
assert.deepEqual(P.validarEsquema([{etapa:' Anticipo ',porcentaje:'33.33'},{etapa:'Fin',porcentaje:66.67}]).map(e=>e.etapa+e.porcentaje),['Anticipo33.33','Fin66.67']);
for(const [mal,re] of [[[],/al menos una/],[[{etapa:'A',porcentaje:99}],/suman 99%/],[[{etapa:'',porcentaje:100}],/no tiene nombre/],
[[{etapa:'A',porcentaje:50},{etapa:'a',porcentaje:50}],/repetida/],[[{etapa:'A',porcentaje:100},{etapa:'B',porcentaje:0}],/mayor a 0/],
[[{etapa:'A',porcentaje:-10},{etapa:'B',porcentaje:110}],/mayor a 0/],[[{etapa:'A',porcentaje:33.333},{etapa:'B',porcentaje:66.667}],/2 decimales/],
[Array.from({length:11},(_,i)=>({etapa:'E'+i,porcentaje:i?9:10})),/máximo 10/]]){
assert.throws(()=>P.validarEsquema(mal),re,JSON.stringify(mal));
}
// Montos: la última etapa absorbe los centavos
assert.deepEqual(P.montosDeEsquema(P.esquemaPorOmision(),1000.01).map(e=>e.monto),[600.01,300,100]);
assert.deepEqual(P.montosDeEsquema([{etapa:'A',porcentaje:33.33},{etapa:'B',porcentaje:33.33},{etapa:'C',porcentaje:33.34}],100).map(e=>e.monto),[33.33,33.33,33.34]);
// Monedas
assert.equal(P.convertirMoneda(18500,'MXN','USD',18.5),1000); assert.equal(P.convertirMoneda(100,'USD','MXN',18.25),1825);
assert.equal(P.convertirMoneda(99.999,'MXN','MXN'),100); assert.throws(()=>P.convertirMoneda(1,'MXN','USD',''),/tipo de cambio/);
// Reparto: excedente a la siguiente; al final, a las anteriores
const E=[{monto:600,pagado:0},{monto:300,pagado:0},{monto:100,pagado:0}];
assert.deepEqual(P.repartirPago(E,0,700).aplicacion.map(a=>[a.orden,a.centavos]),[[1,60000],[2,10000]]);
assert.deepEqual(P.repartirPago(E,2,250).aplicacion.map(a=>[a.orden,a.centavos]),[[3,10000],[1,15000]]);
assert.equal(P.repartirPago(E,0,1000.5).sobranteCts,50);
// Resumen
const R=P.resumenCobranza({total:1000,subtotal:862.07,moneda:'MXN'},P.esquemaPorOmision(),
[{estado:'Vigente',etapaOrden:1,montoAplicado:700},{estado:'Cancelado',etapaOrden:1,montoAplicado:300},{estado:'Vigente',etapaOrden:2,montoAplicado:250}]);
assert.deepEqual([R.contratado,R.pagado,R.saldo,R.avance],[1000,950,50,95]);
assert.deepEqual(R.etapas.map(e=>[e.pagado,e.saldo,e.avance]),[[600,0,100],[300,0,100],[50,50,50]]);
// Pago
const PR={moneda:'MXN',etapas:3,hoy:'2026-10-20'};
const pg=P.normalizarPago({fecha:'2026-10-19',monto:'1500.5',metodo:'transferencia',referencia:' ABC 123 ',etapaOrden:1},PR);
assert.deepEqual([pg.metodo,pg.referencia,pg.montoAplicado,pg.tipoCambio,pg.monedaPago],['Transferencia','ABC 123',1500.5,'','MXN']);
assert.equal(P.normalizarPago({fecha:'2026-10-19',monto:10,metodo:'Efectivo',etapaOrden:3},PR).referencia,'','efectivo sin referencia');
for(const [mal,re] of [[{fecha:'2026-10-21'},/futura/],[{fecha:'2026-02-30'},/no es válida/],[{fecha:''},/obligatoria/],[{monto:0},/mayor a 0/],
[{monto:1.234},/2 decimales/],[{metodo:'Tarjeta'},/método/],[{referencia:''},/referencia/],[{metodo:'Cheque',referencia:''},/número de cheque/],
[{etapaOrden:4},/etapa/],[{monedaPago:'USD'},/tipo de cambio/],[{monedaPago:'EUR'},/no válida/]]){
assert.throws(()=>P.normalizarPago({fecha:'2026-10-19',monto:100,metodo:'Transferencia',referencia:'R',etapaOrden:1,...mal},PR),re,JSON.stringify(mal));
}
assert.equal(P.normalizarPago({fecha:'2026-10-19',monto:18500,monedaPago:'MXN',tipoCambio:'18.5',metodo:'Efectivo',etapaOrden:1},{...PR,moneda:'USD'}).montoAplicado,1000);
// Comprobante: por contenido, no por extensión
const PDF=Buffer.from('%PDF-1.7\n...'), JPG=Buffer.from([0xFF,0xD8,0xFF,0xE0,1,2]), PNG=Buffer.from([0x89,0x50,0x4E,0x47,13,10]),
HEIC=Buffer.concat([Buffer.from([0,0,0,24]),Buffer.from('ftypheic')]), EXE=Buffer.from('MZ\x90\x00');
assert.equal(P.validarComprobante(firmados(PDF)).mime,'application/pdf'); assert.equal(P.validarComprobante(firmados(JPG)).ext,'jpg');
assert.equal(P.validarComprobante(firmados(PNG)).ext,'png'); assert.equal(P.validarComprobante(firmados(HEIC)).mime,'image/heic');
assert.throws(()=>P.validarComprobante(firmados(EXE)),/PDF o imagen/); assert.throws(()=>P.validarComprobante(null),/Adjunta/);
assert.throws(()=>P.validarComprobante({length:10*1024*1024+1}),/10 MB/);
assert.deepEqual([...P.correosDeLista('A@helgen.tech; b@helgen.tech,,a@helgen.tech x@gmail.com','helgen.tech')],['a@helgen.tech','b@helgen.tech']);
assert.equal(P.nombreComprobante('PAG-2026-0001','Transferencia','SPEI/123:"x"','pdf'),'PAG-2026-0001 — Transferencia SPEI 123 x.pdf');
// ================= Servidor (doPost) =================
// Proyectos y cotizaciones (los crea una vendedora)
const p1=ok('ana','crearProyecto',{nombre:'Techo solar',cliente:{nombre:'Luis',empresa:'Solar MX'},idSolicitud:'p1'}).folio;
const p2=ok('ana','crearProyecto',{nombre:'Planta Texas',cliente:{nombre:'Solar Corp'},idSolicitud:'p2'}).folio;
assert.equal(p1,'PRY-'+ANIO+'-0001');
const cot=(proyectoFolio,precio,extra)=>ok('ana','guardarCotizacion',{cliente:{nombre:'Luis',empresa:'Solar MX'},
items:[{descripcion:'Servicio',cantidad:1,precio}],proyectoFolio,idSolicitud:'c'+(++nId),...extra}).folio;
const cH=cot(p1,1000); // Helgen MXN: total 1160
const cE=cot(p1,2000,{marca:'energy'}); // Energy MXN: total 2320
const cU=cot(p1,500,{moneda:'USD'}); // USD en el mismo proyecto
const cBarata=cot(p1,100); // 116
const cOtro=cot(p2,862.07,{moneda:'USD'}); // Proyecto USD: total 1000.00
const sinProy=cot('',50);
// --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
const dAna=ok('ana','getDatosIniciales');
assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false});
assert(!/contrato|pagos|saldo/i.test(JSON.stringify(dAna)),'datos iniciales sin cobranza');
const COB=[['listarProyectosCobranza',['']],['getCobranzaProyecto',[p1]],['marcarAceptada',[p1,cH]],['guardarEsquema',[p1,P.esquemaPorOmision()]],
['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]];
for(const [f,args] of COB) falla('ana',f,args,/No tienes permiso de cobranza/,'PERMISO');
assert.equal(props.COBRANZA_DB_ID,undefined,'una llamada sin rol no crea nada');
for(const [f,args] of COB) falla('intruso',f,args,/@helgen.tech/,'SESION');
assert.throws(()=>ctx.getCobranzaProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
// --- Tania (rol de cobranza, sin desbloqueo) ---
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false});
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true});
const lista=ok('tania','listarProyectosCobranza','');
assert.deepEqual(lista.map(p=>p.folio+':'+p.contrato),[p2+':',p1+':']);
// Carpeta y libro: fuera de "Cotizador Helgen", libro dentro de la carpeta de cobranza
const carpCob=carpetas[props.COBRANZA_FOLDER_ID];
assert.equal(ruta(carpCob),'Mi unidad/Cotizador Helgen — Cobranza');
const LC=libros[props.COBRANZA_DB_ID];
assert.equal(LC.nombre,'Cotizador Helgen — Cobranza (datos)'); assert.equal(archivos[LC.id].padre,carpCob);
assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','Contratos','Esquemas','Pagos']);
const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1);
let v=ok('tania','getCobranzaProyecto',p1.toLowerCase());
assert.equal(v.contrato,null); assert.deepEqual(v.cotizaciones.map(c=>c.folio),[cH,cE,cU,cBarata]);
assert.deepEqual(v.cotizaciones.map(c=>c.moneda+'/'+c.marca),['MXN/helgen','MXN/energy','USD/helgen','MXN/helgen']);
assert.equal(v.puedePagar,false);
falla('tania','firmarContrato',[p1,HOY_ISO],/Primero marca la cotización aceptada/);
falla('tania','guardarEsquema',[p1,P.esquemaPorOmision()],/Primero marca/);
falla('tania','registrarPago',[{proyectoFolio:p1,comprobante:{datos:PDF.toString('base64')}}],/contrato firmado/);
falla('tania','marcarAceptada',[p1,cOtro],/no pertenece al proyecto/);
// Aceptada Helgen: esquema sugerido 60/30/10 sobre el total CON IVA
v=ok('tania','marcarAceptada',p1,cH);
assert.deepEqual([v.contrato.cotizacion,v.contrato.total,v.contrato.subtotal,v.contrato.moneda,v.contrato.estado],[cH,1160,1000,'MXN','Aceptada']);
assert.deepEqual(v.resumen.etapas.map(e=>e.monto),[696,348,116]); assert.equal(v.esquemaEditable,true);
assert.equal(H('Contratos').length,2); assert.equal(H('Contratos')[1][8],'tania@helgen.tech');
// Helgen: esquema editable (nombres, % y número de etapas)
v=ok('tania','guardarEsquema',p1,[{etapa:'Anticipo',porcentaje:50},{etapa:'Entrega',porcentaje:25},{etapa:'Cierre',porcentaje:20},{etapa:'Garantía',porcentaje:5}]);
assert.deepEqual(v.resumen.etapas.map(e=>e.etapa+':'+e.monto),['Anticipo:580','Entrega:290','Cierre:232','Garantía:58']);
assert.equal(H('Esquemas').length,5,'4 etapas, sin duplicar');
falla('tania','guardarEsquema',[p1,[{etapa:'A',porcentaje:50},{etapa:'B',porcentaje:49}]],/suman 99%/);
// Cambiar a Energy: pide confirmar porque reemplaza el esquema; sin confirmar no cambia nada
v=ok('tania','marcarAceptada',p1,cE);
assert.equal(v.confirmar,true); assert(/60% Anticipo/.test(v.mensaje));
assert.equal(H('Contratos')[1][1],cH,'sin confirmar no cambia');
v=ok('tania','marcarAceptada',p1,cE,true);
assert.deepEqual([v.contrato.cotizacion,v.contrato.marca,v.contrato.total],[cE,'energy',2320]);
assert.deepEqual(v.resumen.etapas.map(e=>e.etapa+':'+e.monto),['Anticipo:1392','Instalación:696','Encendido del sistema:232']);
assert.equal(v.esquemaEditable,false);
falla('tania','guardarEsquema',[p1,[{etapa:'A',porcentaje:100}]],/Helgen Energy es fijo/);
// De regreso a Helgen: conserva el 60/30/10 (ahora editable), sin pedir confirmación
v=ok('tania','marcarAceptada',p1,cH);
assert.equal(v.confirmar,undefined); assert.deepEqual(v.resumen.etapas.map(e=>e.monto),[696,348,116]);
assert.equal(H('Contratos').length,2,'una sola aceptada por proyecto');
// Firma: fecha obligatoria y no futura
falla('tania','firmarContrato',[p1,'2099-01-01'],/no puede ser futura/);
falla('tania','firmarContrato',[p1,''],/obligatoria/);
v=ok('tania','firmarContrato',p1,HOY_ISO);
assert.deepEqual([v.contrato.estado,v.contrato.fechaFirma,v.contrato.firmadoPor],['Firmado',HOY_ISO,'tania@helgen.tech']);
assert.equal(v.puedePagar,true); assert.equal(v.esquemaEditable,false); assert.equal(v.puedeDesbloquear,false,'Tania no desbloquea');
falla('tania','firmarContrato',[p1,HOY_ISO],/ya está firmado/);
falla('tania','marcarAceptada',[p1,cBarata],/Contrato firmado/);
falla('tania','guardarEsquema',[p1,P.esquemaPorOmision()],/Contrato firmado/);
// Regla: un proyecto con contrato firmado no admite cotizaciones nuevas y sale del buscador de cotizaciones
const nCot=db.pest.Cotizaciones.m.length, consec=props['CONSECUTIVO_'+ANIO];
falla('ana','guardarCotizacion',[{cliente:{nombre:'Luis'},items:[{descripcion:'X',cantidad:1,precio:1}],proyectoFolio:p1,idSolicitud:'firm'}],
/^Proyecto con contrato firmado: no admite cotizaciones nuevas/,'PROYECTO');
assert.equal(db.pest.Cotizaciones.m.length,nCot); assert.equal(props['CONSECUTIVO_'+ANIO],consec,'no gasta folio');
assert.deepEqual(ok('ana','listarProyectos','',false).map(p=>p.folio),[p2],'buscador de cotizaciones sin el firmado');
assert.deepEqual(ok('ana','getDatosIniciales').proyectos.map(p=>p.folio),[p2]);
assert.deepEqual(ok('ana','listarProyectos','',true).map(p=>p.folio),[p2,p1]);
assert.equal(ok('tania','listarProyectosCobranza','').find(p=>p.folio===p1).contrato,'Firmado');
// --- Pagos ---
const pago=(extra)=>({proyectoFolio:p1,fecha:HOY_ISO,monto:800,metodo:'Transferencia',referencia:'SPEI 123',etapaOrden:1,
comprobante:{datos:PDF.toString('base64')},idSolicitud:'g'+(++nId),...extra});
falla('tania','registrarPago',[pago({comprobante:null})],/Adjunta el comprobante/);
falla('tania','registrarPago',[pago({comprobante:{datos:EXE.toString('base64')}})],/PDF o imagen/);
falla('tania','registrarPago',[pago({metodo:'Tarjeta'})],/método/);
let r=ok('tania','registrarPago',pago({idSolicitud:'pag1'}));
assert.equal(r.folio,'PAG-'+ANIO+'-0001');
// 800 sobre Anticipo de 696: el excedente (104) pasa a Instalación
assert.deepEqual(r.vista.resumen.etapas.map(e=>[e.pagado,e.saldo]),[[696,0],[104,244],[0,116]]);
assert.deepEqual([r.vista.resumen.pagado,r.vista.resumen.saldo],[800,360]);
const fp=H('Pagos')[1];
assert.deepEqual([fp[0],fp[1],fp[3],fp[4],fp[5],fp[7],fp[8],fp[9],fp[10],fp[11],fp[12],fp[16],fp[18]],
[r.folio,HOY_ISO,p1,800,'MXN',800,'MXN','Transferencia','SPEI 123',1,'Anticipo','tania@helgen.tech','Vigente']);
assert.deepEqual(JSON.parse(fp[13]).map(a=>a.etapa+':'+a.monto),['Anticipo:696','Instalación:104']);
// Comprobante en Cotizador Helgen — Cobranza/AAAA/<folio del proyecto>/, ligado al folio
const comp=archivos[fp[14]];
assert.equal(ruta(comp.padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p1);
assert.equal(comp.blob.name,r.folio+' — Transferencia SPEI 123.pdf'); assert.equal(comp.blob.mime,'application/pdf');
assert.deepEqual(Buffer.from(comp.blob.bytes),PDF); assert.equal(fp[15],r.url);
assert(!Object.values(carpetas).some(f=>f.nombre==='Pagos')&&vivos(maestra).every(f=>!/Cobranza/.test(f.nombre)),'nada de cobranza en la carpeta maestra');
function vivos(f){return f.hijos.filter(h=>!h.trashed)}
// Idempotencia: el mismo envío devuelve el mismo folio sin duplicar
assert.equal(ok('tania','registrarPago',pago({idSolicitud:'pag1'})).folio,r.folio); assert.equal(H('Pagos').length,2);
// Excede el saldo (360) por más de la tolerancia (1.00): rechazado; dentro de la tolerancia: aceptado
falla('tania','registrarPago',[pago({monto:361.01})],/excede el saldo del proyecto \(\$360\.00 MXN\)/);
assert.equal(props['CONSECUTIVO_PAG_'+ANIO],'1','el rechazo no gasta folio');
r=ok('tania','registrarPago',pago({monto:360.5,etapaOrden:3,metodo:'Efectivo',referencia:'',
comprobante:{datos:JPG.toString('base64')}}));
assert.equal(r.folio,'PAG-'+ANIO+'-0002'); assert.deepEqual([r.vista.resumen.pagado,r.vista.resumen.saldo],[1160.5,-0.5]);
assert.equal(archivos[H('Pagos')[2][14]].blob.name,r.folio+' — Efectivo.jpg');
// Cancelar: motivo obligatorio; nunca se borra
falla('tania','cancelarPago',[r.folio,''],/motivo/);
v=ok('tania','cancelarPago',r.folio,'Se capturó dos veces');
assert.deepEqual([v.resumen.pagado,v.resumen.saldo],[800,360]);
assert.deepEqual(H('Pagos')[2].slice(18,22).map((x,i)=>i===2?!!x:x),['Cancelado','tania@helgen.tech',true,'Se capturó dos veces']);
assert.equal(H('Pagos').length,3,'el pago cancelado sigue en la hoja');
falla('tania','cancelarPago',[r.folio,'otra vez'],/ya está cancelado/);
assert.equal(v.pagos[0].estado,'Cancelado','los pagos se listan del más reciente al más antiguo');
// --- Desbloqueo: solo el administrador y con motivo ---
falla('tania','desbloquearContrato',[p1,'error de captura'],/Solo el administrador/,'PERMISO');
falla('efrain','desbloquearContrato',[p1,''],/motivo/);
v=ok('efrain','desbloquearContrato',p1,'Se firmó con la cotización equivocada');
assert.deepEqual([v.contrato.estado,v.contrato.fechaFirma],['Aceptada','']); assert.equal(v.pagos.length,2,'los pagos se conservan');
const ub=bit().find(b=>b[2]==='Desbloquear contrato');
assert.deepEqual([ub[1],ub[3],ub[5]],['efrain@helgen.tech',p1,'Se firmó con la cotización equivocada']);
assert(ub[4].includes('tania@helgen.tech')&&ub[4].includes(HOY_ISO)&&/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(ub[0]),'quién, cuándo y qué estaba');
falla('tania','registrarPago',[pago()],/contrato firmado/,undefined);
// Con pagos en MXN no se puede aceptar una cotización en USD (nunca mezclar monedas)
falla('tania','marcarAceptada',[p1,cU],/pagos en MXN/);
// Aceptada más barata que lo pagado: se puede marcar, pero no firmar
ok('tania','marcarAceptada',p1,cBarata);
falla('tania','firmarContrato',[p1,HOY_ISO],/supera el monto contratado/);
ok('tania','marcarAceptada',p1,cH); ok('tania','firmarContrato',p1,HOY_ISO);
assert.equal(ok('ana','listarProyectos','',false).length,1);
// Bitácora completa, con quién y qué
const acciones=bit().map(b=>b[2]);
for(const a of ['Marcar cotización aceptada','Editar esquema de pagos','Cambiar cotización aceptada','Contrato firmado','Registrar pago','Cancelar pago','Desbloquear contrato'])
assert(acciones.includes(a),'bitácora: '+a);
assert(bit().every(b=>/@helgen\.tech$/.test(b[1])));
// --- Proyecto en USD pagado en MXN: tipo de cambio obligatorio, saldo en USD ---
v=ok('efrain','marcarAceptada',p2,cOtro); assert.deepEqual([v.contrato.moneda,v.contrato.total],['USD',1000]);
ok('efrain','firmarContrato',p2,HOY_ISO);
falla('efrain','registrarPago',[pago({proyectoFolio:p2,monedaPago:'MXN',monto:18500})],/tipo de cambio/);
r=ok('efrain','registrarPago',pago({proyectoFolio:p2,monedaPago:'MXN',monto:18500,tipoCambio:18.5,comprobante:{datos:PNG.toString('base64')}}));
const fu=H('Pagos').find(x=>x[0]===r.folio);
assert.deepEqual([fu[4],fu[5],fu[6],fu[7],fu[8]],[18500,'MXN',18.5,1000,'USD']);
assert.deepEqual([r.vista.resumen.moneda,r.vista.resumen.pagado,r.vista.resumen.saldo],['USD',1000,0]);
falla('efrain','registrarPago',[pago({proyectoFolio:p2,monto:1.01,monedaPago:'USD'})],/excede el saldo/);
assert.equal(ruta(archivos[fu[14]].padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p2);
// Los datos de cobranza nunca van en la Base de datos (todo el dominio la puede leer)
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos']);
assert(!JSON.stringify(db.pest).includes('PAG-'));
console.log('OK cobranza');

View File

@ -71,4 +71,25 @@ ctx.guardarCotizacion({cliente:{nombre:'Luis',empresa:'Solar MX',direccion:'Av.
let lc=ctx.getClientes(); const luis=lc.find(c=>c.nombre==='Luis'); 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'); 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'); cot.m.splice(1); assert.equal(ctx.getClientes().length,0,'sin filas = sin sugerencias');
// Moneda: USD en etiqueta, total e importe con letra; MXN igual que antes; columna U
if (ctx.monedaDeCotizacion) {
assert.equal(ctx.importeConLetra(1160.02),'MIL CIENTO SESENTA PESOS 02/100 M.N.');
assert.equal(ctx.importeConLetra(1160.02,'USD'),'MIL CIENTO SESENTA DÓLARES 02/100 USD');
assert.equal(ctx.importeConLetra(1,'USD'),'UN DÓLAR 00/100 USD');
assert.equal(ctx.importeConLetra(2000000.5,'USD'),'DOS MILLONES DE DÓLARES 50/100 USD');
assert.equal(ctx.monedaDeCotizacion(''),'MXN'); assert.equal(ctx.monedaDeCotizacion('usd'),'USD');
assert.throws(()=>ctx.monedaDeCotizacion('EUR'),/Moneda no válida/);
assert.throws(()=>ctx.guardarCotizacion({...base,moneda:'EUR',idSolicitud:'eur'}),/Moneda no válida/);
const n=pdfs.length;
const rU=ctx.guardarCotizacion({cliente:{nombre:'Solar Corp',empresa:'Solar Corp LLC',direccion:'Austin, TX'},moneda:'USD',marca:'energy',
items:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:12,precio:180},{descripcion:'Inversor 8 kW',cantidad:1,precio:1450.5}],
notas:'Precios en dólares estadounidenses.',idSolicitud:'usd1'});
assert.equal(rU.moneda,'USD');
const u=pdfs[n].html, fila=cot.m[cot.m.length-1];
assert.equal(fila[20],'USD'); assert.equal(cot.m[0][20],'moneda');
assert(u.includes('TOTAL USD')&&!u.includes('TOTAL MXN')&&u.includes('USD (dólares estadounidenses)'));
assert(u.includes('DÓLARES')&&u.includes('/100 USD)')&&!u.includes('PESOS'));
assert(h.includes('TOTAL MXN')&&h.includes('M.N.')&&!h.includes('dólares'),'MXN sin cambios');
fs.writeFileSync(process.argv[3]+'/usd.html',u);
}
console.log('OK'); console.log('OK');

View File

@ -1,50 +1,59 @@
// Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados. // Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados.
// Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa. // Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa;
// moneda USD (el catálogo no pone precio); pestaña Cobranza solo con permiso; aceptar con confirmación; pago con comprobante.
// Uso: node tests/interfaz.test.js . (carpeta del proyecto) // Uso: node tests/interfaz.test.js . (carpeta del proyecto)
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path'); const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8'); const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8');
const scripts=[...html.matchAll(/<script(?: id="([^"]*)")?>([\s\S]*?)<\/script>/g)].map(m=>({id:m[1],src:m[2]})); const scripts=[...html.matchAll(/<script(?: id="([^"]*)")?>([\s\S]*?)<\/script>/g)].map(m=>({id:m[1],src:m[2]}));
const apiWeb=scripts.find(s=>s.id==='api-web').src, app=scripts.find(s=>!s.id).src; const apiWeb=scripts.find(s=>s.id==='api-web').src, app=scripts.find(s=>!s.id).src;
// ---- DOM simulado (solo lo que usa la app) ---- // ---- Sesión vigente (token falso; quien valida es el servidor) ----
function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',disabled:false,hijos:[],oyentes:{}, const b64=o=>Buffer.from(JSON.stringify(o)).toString('base64url');
const token='h.'+b64({email:'ana@helgen.tech',given_name:'Ana',exp:Math.floor(Date.now()/1000)+3600})+'.f';
const PRY={folio:'PRY-2026-0001',nombre:'Techo',cliente:'Luis — Solar MX',clienteId:'LUSM800101AB1',ubicacion:'Qro',estado:'Activo',fecha:'20/10/2026'};
const CAT=[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',precio:4100}];
/** App cargada con un DOM y un servidor simulados nuevos. */
function montar(datosIniciales){
function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{},
_clases:new Set(),porClase:{}, _clases:new Set(),porClase:{},
classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)}, classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)},
addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f), addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f),
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})), disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
focus(){},remove(){},appendChild:h=>e.hijos.push(h), focus(){},remove(){},appendChild:h=>e.hijos.push(h),
querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')), querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')),
querySelectorAll:sel=>sel==='tr'?e.hijos:[]};return e} querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]};return e}
const porId={}; const porId={};
const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')), const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)}; querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
document.getElementById('login').classList.add('oculto'); document.getElementById('login').classList.add('oculto');
document.getElementById('tabCob').classList.add('oculto'); // como en el HTML
// ---- Sesión vigente (token falso; quien valida es el servidor) ----
const b64=o=>Buffer.from(JSON.stringify(o)).toString('base64url');
const token='h.'+b64({email:'ana@helgen.tech',given_name:'Ana',exp:Math.floor(Date.now()/1000)+3600})+'.f';
const almacen={cotizadorHelgenSesion:token}; const almacen={cotizadorHelgenSesion:token};
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}};
// ---- Servidor simulado ----
const PRY={folio:'PRY-2026-0001',nombre:'Techo',cliente:'Luis — Solar MX',clienteId:'LUSM800101AB1',ubicacion:'Qro',estado:'Activo',fecha:'20/10/2026'};
const llamadas=[]; let respuestas={};
async function fetch(url,opts){const c=JSON.parse(opts.body);llamadas.push(c); async function fetch(url,opts){const c=JSON.parse(opts.body);llamadas.push(c);
const r=respuestas[c.funcion];const datos=typeof r==='function'?r(c):r; const r=respuestas[c.funcion];const datos=typeof r==='function'?r(c):r;
return{ok:true,json:async()=>datos}} return{ok:true,json:async()=>datos||{ok:false,error:'sin respuesta simulada: '+c.funcion}}}
respuestas.getDatosIniciales={ok:true,datos:{ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[PRY],marcas:{}}}; const dialogos={confirmar:true,prompt:null,vistos:[]};
class FileReader{readAsDataURL(f){this.result='data:'+f.type+';base64,'+f.b64;setImmediate(()=>this.onload())}}
const ctx={document,fetch,console,JSON,Date,Math,Number,String,Array,Object,Promise,Proxy,Error,Uint8Array,TextDecoder, const ctx={document,fetch,console,JSON,Date,Math,Number,String,Array,Object,Promise,Proxy,Error,Uint8Array,TextDecoder,FileReader,
atob:s=>Buffer.from(s,'base64').toString('binary'),crypto:{randomUUID:()=>'id-'+Math.random()}, atob:s=>Buffer.from(s,'base64').toString('binary'),crypto:{randomUUID:()=>'id-'+Math.random()},
sessionStorage:{getItem:k=>almacen[k]??null,setItem:(k,v)=>almacen[k]=v,removeItem:k=>delete almacen[k]}, sessionStorage:{getItem:k=>almacen[k]??null,setItem:(k,v)=>almacen[k]=v,removeItem:k=>delete almacen[k]},
confirm:m=>{dialogos.vistos.push(m);return dialogos.confirmar},prompt:m=>{dialogos.vistos.push(m);return dialogos.prompt},
setTimeout,clearTimeout,scrollTo(){}}; setTimeout,clearTimeout,scrollTo(){}};
ctx.window=ctx; ctx.window=ctx;
vm.createContext(ctx); vm.createContext(ctx);
vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx); vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx);
const $=id=>document.getElementById(id), esperar=()=>new Promise(r=>setImmediate(r)); return {ctx,$:id=>document.getElementById(id),llamadas,respuestas,dialogos,run:c=>vm.runInContext(c,ctx)};
}
const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmediate(r))};
(async()=>{ (async()=>{
for(let i=0;i<5;i++) await esperar(); // ===== Vendedora (sin rol de cobranza) =====
assert.equal(vm.runInContext('PROYECTOS.length',ctx),1,'carga los proyectos activos'); const A=montar({ivaTasa:0.16,catalogo:CAT,clientes:[],proyectos:[PRY],marcas:{},permisos:{cobranza:false,desbloqueo:false}});
const {$,llamadas,respuestas}=A;
await esperar(5);
assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos');
assert($('tabCob').classList.contains('oculto'),'sin permiso no ve la pestaña Cobranza');
// Elegir el proyecto // Elegir el proyecto
$('cNombre').value='Luis'; $('cNombre').value='Luis';
@ -54,28 +63,118 @@ const $=id=>document.getElementById(id), esperar=()=>new Promise(r=>setImmediate
// El servidor lo rechaza (se canceló mientras tanto): codigo PROYECTO // El servidor lo rechaza (se canceló mientras tanto): codigo PROYECTO
respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'El proyecto PRY-2026-0001 está cancelado: ya no se le pueden agregar cotizaciones.'}; respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'El proyecto PRY-2026-0001 está cancelado: ya no se le pueden agregar cotizaciones.'};
respuestas.listarProyectos={ok:true,datos:[]}; respuestas.listarProyectos={ok:true,datos:[]};
vm.runInContext('guardar()',ctx); A.run('guardar()');
for(let i=0;i<10;i++) await esperar(); await esperar();
const g=llamadas.find(c=>c.funcion==='guardarCotizacion'); const g=llamadas.find(c=>c.funcion==='guardarCotizacion');
assert.equal(g.args[0].proyectoFolio,'PRY-2026-0001'); assert.equal(g.token,token); assert.equal(g.args[0].proyectoFolio,'PRY-2026-0001'); assert.equal(g.token,token); assert.equal(g.args[0].moneda,'MXN');
assert.equal($('proyecto').value,'','se limpia el campo Proyecto'); assert.equal($('proyecto').value,'','se limpia el campo Proyecto');
assert(/^Sin proyecto/.test($('proyInfo').textContent),'la etiqueta ya no dice "Proyecto activo": '+$('proyInfo').textContent); assert(/^Sin proyecto/.test($('proyInfo').textContent),'la etiqueta ya no dice "Proyecto activo": '+$('proyInfo').textContent);
assert(llamadas.some(c=>c.funcion==='listarProyectos'),'se refresca la lista de proyectos'); assert(llamadas.some(c=>c.funcion==='listarProyectos'),'se refresca la lista de proyectos');
assert.equal(vm.runInContext('PROYECTOS.length',ctx),0); assert.equal($('proyectosList').innerHTML,''); assert.equal(A.run('PROYECTOS.length'),0); assert.equal($('proyectosList').innerHTML,'');
assert.equal($('aviso').className,'aviso error'); assert.equal($('aviso').className,'aviso error');
assert(/está cancelado/.test($('aviso').innerHTML)&&/Se quitó el proyecto/.test($('aviso').innerHTML)); assert(/está cancelado/.test($('aviso').innerHTML)&&/Se quitó el proyecto/.test($('aviso').innerHTML));
assert.equal($('btnGuardar').disabled,false,'se puede volver a guardar'); assert.equal($('btnGuardar').disabled,false,'se puede volver a guardar');
// Contrato firmado: mismo trato (codigo PROYECTO)
A.run("recibirProyectos(["+JSON.stringify(PRY)+"])");
$('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change');
respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'Proyecto con contrato firmado: no admite cotizaciones nuevas.'};
A.run('guardar()'); await esperar();
assert.equal($('proyecto').value,''); assert(/contrato firmado: no admite/.test($('aviso').innerHTML));
// Otro error (no de proyecto): el campo se queda como estaba // Otro error (no de proyecto): el campo se queda como estaba
respuestas.listarProyectos={ok:true,datos:[PRY]}; vm.runInContext("recibirProyectos([PRY_])".replace('PRY_',JSON.stringify(PRY)),ctx); respuestas.listarProyectos={ok:true,datos:[PRY]}; A.run("recibirProyectos(["+JSON.stringify(PRY)+"])");
$('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change'); $('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change');
respuestas.guardarCotizacion={ok:false,error:'El sistema está ocupado guardando otra cotización.'}; respuestas.guardarCotizacion={ok:false,error:'El sistema está ocupado guardando otra cotización.'};
const antes=llamadas.filter(c=>c.funcion==='listarProyectos').length; const antes=llamadas.filter(c=>c.funcion==='listarProyectos').length;
vm.runInContext('guardar()',ctx); A.run('guardar()'); await esperar();
for(let i=0;i<10;i++) await esperar();
assert.equal($('proyecto').value,'PRY-2026-0001 — Luis — Solar MX — Techo'); assert.equal($('proyecto').value,'PRY-2026-0001 — Luis — Solar MX — Techo');
assert(/^Proyecto activo/.test($('proyInfo').textContent)); assert(/^Proyecto activo/.test($('proyInfo').textContent));
assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes); assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes);
assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML)); assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML));
// Moneda: en MXN el catálogo pone el precio; en USD lo deja vacío para capturarlo a mano
const fila=$('filas').hijos[0];
fila.querySelector('.desc').value='Panel solar 550 W'; fila.querySelector('.desc').disparar('change');
assert.equal(fila.querySelector('.pu').value,4100);
$('moneda').value='USD'; A.run('cambiarMoneda()');
assert(/captura los precios en dólares/.test($('notaMoneda').innerHTML)&&/Revisa los precios/.test($('notaMoneda').innerHTML));
assert.equal($('lblTot').textContent,'TOTAL USD');
fila.querySelector('.desc').disparar('change');
assert.equal(fila.querySelector('.pu').value,'','en USD el catálogo no pone precio'); assert.equal(fila.querySelector('.clave').value,'PAN-550');
fila.querySelector('.cant').value='1'; fila.querySelector('.pu').value='180'; A.run('recalcular()');
assert.equal($('tTot').textContent,'$208.80 USD');
respuestas.guardarCotizacion={ok:true,datos:{folio:'HLG-2026-0009',url:'u',total:208.8,moneda:'USD',proyectoFolio:''}};
respuestas.getClientes={ok:true,datos:[]};
$('proyecto').value=''; A.run('guardar()'); await esperar();
assert.equal(llamadas.filter(c=>c.funcion==='guardarCotizacion').pop().args[0].moneda,'USD');
assert(/\$208\.80 USD/.test($('aviso').innerHTML)); assert.equal($('moneda').value,'MXN','al limpiar vuelve a MXN');
// ===== Cobranza (con permiso) =====
const B=montar({ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[],marcas:{},permisos:{cobranza:true,desbloqueo:false}});
await esperar(5);
assert(!B.$('tabCob').classList.contains('oculto'),'con permiso ve la pestaña');
B.respuestas.listarProyectosCobranza={ok:true,datos:[{...PRY,contrato:''}]};
B.run("verTab('cob')"); await esperar();
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML));
const cots=[{folio:'HLG-2026-0010',fecha:'20/10/2026',marca:'helgen',moneda:'MXN',subtotal:1000,total:1160,pdf:'p'},
{folio:'HLG-2026-0011',fecha:'20/10/2026',marca:'energy',moneda:'MXN',subtotal:2000,total:2320,pdf:'p'}];
const vista=(extra)=>({proyecto:PRY,cotizaciones:cots,contrato:null,esquema:[],resumen:null,pagos:[],esquemaEditable:false,
puedePagar:false,puedeDesbloquear:false,metodos:['Transferencia','Efectivo','Cheque'],hoy:'2026-10-20',...extra});
B.respuestas.getCobranzaProyecto={ok:true,datos:vista()};
B.$('cobProyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; B.$('cobProyecto').disparar('change'); await esperar();
assert.equal(B.llamadas.find(c=>c.funcion==='getCobranzaProyecto').args[0],'PRY-2026-0001');
assert((B.$('cobCots').innerHTML.match(/Marcar aceptada/g)||[]).length===2);
assert(B.$('cobModEsquema').classList.contains('oculto')&&B.$('cobModPagos').classList.contains('oculto'),'sin aceptada no hay esquema ni pagos');
// Aceptar la de Energy: el servidor pide confirmar; si se cancela no se repite
B.respuestas.marcarAceptada=c=>c.args[2]?{ok:true,datos:vista({contrato:{cotizacion:'HLG-2026-0011',marca:'energy',moneda:'MXN',subtotal:2000,iva:320,total:2320,estado:'Aceptada'},
esquema:[{etapa:'Anticipo',porcentaje:60},{etapa:'Instalación',porcentaje:30},{etapa:'Encendido del sistema',porcentaje:10}]})}
:{ok:true,datos:{confirmar:true,mensaje:'Reemplazará el esquema actual. ¿Continuar?'}};
B.dialogos.confirmar=false; B.run("aceptarCot('HLG-2026-0011')"); await esperar();
assert.equal(B.llamadas.filter(c=>c.funcion==='marcarAceptada').length,1); assert(/Reemplazará/.test(B.dialogos.vistos.pop()));
B.dialogos.confirmar=true; B.run("aceptarCot('HLG-2026-0011')"); await esperar();
const ma=B.llamadas.filter(c=>c.funcion==='marcarAceptada');
assert.equal(ma.length,3); assert.deepEqual(ma[2].args,['PRY-2026-0001','HLG-2026-0011',true]);
assert(/Energy: esquema fijo/.test(B.$('cobEsqNota').textContent)&&!/input/.test(B.$('cobEsquema').innerHTML),'Energy no editable');
assert(/\$1,392\.00 MXN/.test(B.$('cobEsquema').innerHTML),'monto 60% de 2320');
assert(/Contrato firmado/.test(B.$('cobContrato').innerHTML)&&B.$('cobFormPago').classList.contains('oculto'));
// Firmado: formulario de pago; sin comprobante no se envía
const res={moneda:'MXN',contratado:2320,subtotal:2000,pagado:0,saldo:2320,avance:0,
etapas:[{etapa:'Anticipo',porcentaje:60,monto:1392,pagado:0,saldo:1392,avance:0},{etapa:'Instalación',porcentaje:30,monto:696,pagado:0,saldo:696,avance:0},
{etapa:'Encendido del sistema',porcentaje:10,monto:232,pagado:0,saldo:232,avance:0}]};
const firmada=vista({contrato:{cotizacion:'HLG-2026-0011',marca:'energy',moneda:'MXN',subtotal:2000,iva:320,total:2320,estado:'Firmado',fechaFirma:'2026-10-20',firmadoPor:'tania@helgen.tech'},
esquema:res.etapas,resumen:res,puedePagar:true});
B.run('pintarCobranza('+JSON.stringify(firmada)+')');
assert(!B.$('cobFormPago').classList.contains('oculto')); assert(!/Desbloquear/.test(B.$('cobContrato').innerHTML),'Tania no ve desbloquear');
assert(!/Marcar aceptada/.test(B.$('cobCots').innerHTML),'firmado: ya no se cambia la aceptada');
assert(/Transferencia/.test(B.$('pgMetodo').innerHTML)&&/saldo \$1,392\.00 MXN/.test(B.$('pgEtapa').innerHTML));
B.$('pgMonto').value='800'; B.$('pgRef').value='SPEI 1'; B.$('pgMetodo').value='Transferencia';
B.$('pgArchivo').files=[]; B.run('registrarPago()'); await esperar();
assert(/Adjunta el comprobante/.test(B.$('aviso').innerHTML)); assert(!B.llamadas.some(c=>c.funcion==='registrarPago'));
B.$('pgArchivo').files=[{name:'x.pdf',type:'application/pdf',size:11*1024*1024,b64:'JVBERi0='}]; B.run('registrarPago()'); await esperar();
assert(/más de 10 MB/.test(B.$('aviso').innerHTML));
// Moneda distinta: aparece el tipo de cambio
B.$('pgMoneda').value='USD'; B.run('pintarTipoCambio()'); assert(!B.$('pgTcCaja').classList.contains('oculto'));
B.$('pgMoneda').value='MXN'; B.run('pintarTipoCambio()'); assert(B.$('pgTcCaja').classList.contains('oculto'));
B.respuestas.registrarPago={ok:true,datos:{folio:'PAG-2026-0001',url:'https://drive/c',vista:firmada}};
B.$('pgArchivo').files=[{name:'comprobante.pdf',type:'application/pdf',size:1200,b64:'JVBERi0='}];
const idAntes=B.run('ID_PAGO'); B.run('registrarPago()'); await esperar();
const rp=B.llamadas.find(c=>c.funcion==='registrarPago').args[0];
assert.deepEqual([rp.proyectoFolio,rp.monto,rp.monedaPago,rp.metodo,rp.referencia,rp.etapaOrden,rp.comprobante.datos,rp.idSolicitud],
['PRY-2026-0001','800','MXN','Transferencia','SPEI 1',1,'JVBERi0=',idAntes]);
assert(/PAG-2026-0001/.test(B.$('aviso').innerHTML)&&B.run('ID_PAGO')!==idAntes,'id nuevo para el siguiente pago');
// Cancelar pago: pide motivo; sin motivo no llama
B.dialogos.prompt=''; B.run("cancelarPagoCob('PAG-2026-0001')"); await esperar();
assert(!B.llamadas.some(c=>c.funcion==='cancelarPago'));
// Permiso revocado mientras tanto: el servidor responde PERMISO y se muestra el error
B.respuestas.cancelarPago={ok:false,codigo:'PERMISO',error:'No tienes permiso de cobranza.'};
B.dialogos.prompt='Se capturó dos veces'; B.run("cancelarPagoCob('PAG-2026-0001')"); await esperar();
assert.deepEqual(B.llamadas.find(c=>c.funcion==='cancelarPago').args,['PAG-2026-0001','Se capturó dos veces']);
assert(/No tienes permiso de cobranza/.test(B.$('aviso').innerHTML));
// Administrador: ve el desbloqueo
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));
console.log('OK interfaz'); console.log('OK interfaz');
})().catch(e=>{console.error(e);process.exit(1)}); })().catch(e=>{console.error(e);process.exit(1)});

View File

@ -74,10 +74,10 @@ const f1=pry.m[1];
assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']); assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']);
assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor'); assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
assert(!JSON.stringify(pry.m).includes('impostor')); assert(!JSON.stringify(pry.m).includes('impostor'));
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ con 3 subcarpetas // Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ con 2 subcarpetas (Pagos ya no: va en cobranza)
const c1=carpetas[f1[7]]; const c1=carpetas[f1[7]];
assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta'); assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta');
assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra','Pagos']); assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra']);
assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1'); assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1');
assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia'); assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
// Idempotencia // Idempotencia
@ -105,9 +105,9 @@ assert.equal(r1.folio,'HLG-2026-0005'); assert.equal(r1.proyectoFolio,'PRY-2026-
const subCot=c1.hijos.find(h=>h.nombre==='Cotizaciones'); const subCot=c1.hijos.find(h=>h.nombre==='Cotizaciones');
assert.deepEqual(subCot.archivos.map(a=>a.name),['HLG-2026-0005 - Solar MX.pdf'],'PDF en Cotizaciones del proyecto'); assert.deepEqual(subCot.archivos.map(a=>a.name),['HLG-2026-0005 - Solar MX.pdf'],'PDF en Cotizaciones del proyecto');
// Encabezados: solo cambia la fila 1; la fila vieja no se mueve y queda vacía en T // Encabezados: solo cambia la fila 1; la fila vieja no se mueve y queda vacía en T
assert.equal(cot.m[0].length,20); assert.equal(cot.m[0][19],'proyecto_folio'); assert.deepEqual(cot.m[0].slice(0,19),ENC19); assert.equal(cot.m[0].length,21); assert.equal(cot.m[0][19],'proyecto_folio'); assert.equal(cot.m[0][20],'moneda'); assert.deepEqual(cot.m[0].slice(0,19),ENC19);
assert.deepEqual(cot.m[1],filaVieja,'fila vieja intacta'); assert.deepEqual(cot.m[1],filaVieja,'fila vieja intacta');
assert.equal(cot.m[2][19],'PRY-2026-0001'); assert.equal(cot.m[2][19],'PRY-2026-0001'); assert.equal(cot.m[2][20],'MXN');
// Sin proyecto: carpeta del año, columna T vacía // Sin proyecto: carpeta del año, columna T vacía
ctx.guardarCotizacion({...base,idSolicitud:'c2'}); ctx.guardarCotizacion({...base,idSolicitud:'c2'});
assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],''); assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],'');
@ -135,7 +135,7 @@ c1.trashed=true;
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c7'}); ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c7'});
const c1b=carpetas[pry.m[1][7]]; const c1b=carpetas[pry.m[1][7]];
assert(c1b!==c1&&!c1b.trashed&&c1b.hijos.find(h=>h.nombre==='Cotizaciones').archivos.length===1); assert(c1b!==c1&&!c1b.trashed&&c1b.hijos.find(h=>h.nombre==='Cotizaciones').archivos.length===1);
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra','Pagos']); assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra']);
// --- Fuera de doPost (sin vendedor) no se crean proyectos --- // --- Fuera de doPost (sin vendedor) no se crean proyectos ---
vm.runInContext('VENDEDOR_ = null',ctx); vm.runInContext('VENDEDOR_ = null',ctx);

View File

@ -71,10 +71,10 @@ fetches=0;
const r=post({funcion:'guardarCotizacion',token:'t-bueno', 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'}]}); 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'); 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','proyecto_folio']); const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido','proyecto_folio','moneda']);
const fila=cot.m[cot.m.length-1]; const fila=cot.m[cot.m.length-1];
assert.equal(fila[10],'ana.lopez@helgen.tech','Elaboró = correo del token'); 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',''],'sin proyecto = columna T vacía'); assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz','','MXN'],'sin proyecto = columna T vacía; sin moneda = MXN');
assert(!JSON.stringify(fila).includes('impostor')); assert(!JSON.stringify(fila).includes('impostor'));
// Caché: el token ya se verificó (en la llamada a setup); no se vuelve a consultar a Google // 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); assert.equal(post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).ok,true); assert.equal(fetches,0);
@ -102,6 +102,7 @@ const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos;
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana'); assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola'); assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos'); assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
assert.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false},'sin ROL_COBRANZA nadie tiene cobranza');
assert.equal(h[1].proyecto,'Sin proyecto'); assert.equal(h[1].proyecto,'Sin proyecto');
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/); assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión // Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión

View File

@ -84,6 +84,20 @@
#gisBoton { display:flex; justify-content:center; min-height:44px; } #gisBoton { display:flex; justify-content:center; min-height:44px; }
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; } #loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
#loginMsg:empty { display:none; } #loginMsg:empty { display:none; }
/* Cobranza */
.chip { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; border:1px solid var(--borde); }
.chip.firmado { background:#E6FBF7; border-color:var(--teal); color:var(--azul); }
.chip.aceptada { background:#FFF7E6; border-color:#F5B544; color:#7A4B00; }
.chip.cancelado { background:#FEE2E2; border-color:#DC2626; color:#991B1B; }
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:14px; }
.tile { border:1px solid var(--borde); border-radius:8px; padding:10px 12px; }
.tile span { display:block; font-size:11px; color:var(--gris); text-transform:uppercase; }
.tile b { font-size:16px; color:var(--azul); }
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
.barra i { display:block; height:100%; background:var(--teal); }
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
.fila-cancelada td:last-child { text-decoration:none; }
.aviso-moneda { color:#B45309; font-weight:bold; }
</style> </style>
</head> </head>
<body> <body>
@ -94,6 +108,7 @@
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button> <button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
<button id="tabHist" onclick="verTab('hist')">Historial</button> <button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button> <button id="tabCat" onclick="verTab('cat')">Catálogo</button>
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
</nav> </nav>
<div id="usuario" class="usuario oculto"> <div id="usuario" class="usuario oculto">
<span id="usuarioNombre"></span> <span id="usuarioNombre"></span>
@ -127,6 +142,11 @@
<option value="helgen">Helgen</option> <option value="helgen">Helgen</option>
<option value="energy">Helgen Energy (paneles solares)</option> <option value="energy">Helgen Energy (paneles solares)</option>
</select></div> </select></div>
<div><label>Moneda *</label>
<select id="moneda" onchange="cambiarMoneda()">
<option value="MXN">MXN · pesos mexicanos</option>
<option value="USD">USD · dólares estadounidenses</option>
</select></div>
<div><label>Nombre / Contacto *</label> <div><label>Nombre / Contacto *</label>
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente"> <input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
<datalist id="clientesList"></datalist> <datalist id="clientesList"></datalist>
@ -175,6 +195,7 @@
<tbody id="filas"></tbody> <tbody id="filas"></tbody>
</table> </table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button> <button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
<div id="notaMoneda" class="nota" style="margin-top:8px"></div>
</div> </div>
<div class="modulo"> <div class="modulo">
@ -187,7 +208,7 @@
<div class="totales"> <div class="totales">
<div><span>Subtotal</span><span id="tSub">$0.00</span></div> <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><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> <div class="total"><span id="lblTot">TOTAL MXN</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> <button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
</div> </div>
</div> </div>
@ -247,6 +268,65 @@
</table> </table>
</div> </div>
</section> </section>
<!-- ===== COBRANZA (solo rol de cobranza; el servidor lo vuelve a revisar en cada llamada) ===== -->
<section id="vistaCob" class="oculto">
<div class="modulo">
<h2>1 · Proyecto y contrato</h2>
<div class="proy-fila">
<div><label>Proyecto</label>
<input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
<datalist id="cobProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosCob()">Actualizar lista</button>
</div>
<div id="cobInfo" class="nota" style="margin-top:6px">Elige un proyecto para ver su contrato, esquema y pagos.</div>
<div id="cobDetalle" class="oculto">
<table style="margin-top:12px">
<thead><tr><th>Cotización</th><th>Fecha</th><th>Línea</th><th>Moneda</th><th class="num">Subtotal</th><th class="num">Total con IVA</th><th>PDF</th><th></th></tr></thead>
<tbody id="cobCots"></tbody>
</table>
<div id="cobContrato" class="sat"></div>
</div>
</div>
<div id="cobModEsquema" class="modulo oculto">
<h2>2 · Esquema de pagos</h2>
<div id="cobEsqNota" class="nota" style="margin-bottom:8px"></div>
<table>
<thead><tr><th style="width:30px">#</th><th>Etapa</th><th class="num" style="width:110px">%</th><th class="num">Monto</th>
<th class="num">Pagado</th><th class="num">Saldo</th><th style="width:120px">Avance</th><th style="width:30px"></th></tr></thead>
<tbody id="cobEsquema"></tbody>
</table>
<div id="cobEsqAcciones" class="acciones"></div>
</div>
<div id="cobModPagos" class="modulo oculto">
<h2>3 · Pagos</h2>
<div id="cobResumen" class="tiles"></div>
<div id="cobFormPago" class="sat oculto">
<div class="sat-titulo">Registrar pago · el comprobante es obligatorio (PDF o imagen, máx. 10 MB)</div>
<div class="grid">
<div><label>Fecha del pago *</label><input id="pgFecha" type="date"></div>
<div><label>Monto *</label><input id="pgMonto" type="number" min="0" step="0.01" style="text-align:right"></div>
<div><label>Moneda del pago *</label><select id="pgMoneda" onchange="pintarTipoCambio()"><option>MXN</option><option>USD</option></select></div>
<div id="pgTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label><input id="pgTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
<div><label>Método *</label><select id="pgMetodo"></select></div>
<div><label>Referencia</label><input id="pgRef" maxlength="100" placeholder="Clave de rastreo, folio o núm. de cheque"></div>
<div><label>Etapa *</label><select id="pgEtapa"></select></div>
<div><label>Comprobante *</label><input id="pgArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.pdf,.jpg,.jpeg,.png,.heic"></div>
</div>
<div class="acciones">
<button type="button" id="btnPago" class="btn" onclick="registrarPago()">Registrar pago</button>
<span class="nota">Si el pago excede su etapa, el excedente pasa a la siguiente.</span>
</div>
</div>
<div id="cobSinPagos" class="nota"></div>
<table style="margin-top:12px">
<thead><tr><th>Folio</th><th>Fecha</th><th class="num">Monto</th><th class="num">Aplicado</th><th>Método / referencia</th><th>Etapa</th><th>Registró</th><th>Comprobante</th><th></th></tr></thead>
<tbody id="cobPagos"></tbody>
</table>
</div>
</section>
</main> </main>
<script id="api-web"> <script id="api-web">
@ -406,7 +486,7 @@
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script> <script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
<script> <script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = []; let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {};
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro. // 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)); const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId(); let ID_SOLICITUD = nuevoId();
@ -415,6 +495,10 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100; const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' }); const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
// Importe con su moneda ("$1,160.00 MXN" / "$1,000.00 USD"); nunca se suman monedas distintas
const dinero = (n, moneda) => r2(n).toLocaleString('es-MX', { minimumFractionDigits:2, maximumFractionDigits:2 })
.replace(/^(-?)/, '$1$') + ' ' + (moneda || 'MXN');
const monedaActual = () => $('moneda').value === 'USD' ? 'USD' : 'MXN';
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---- Arranque ---- // ---- Arranque ----
@ -422,6 +506,9 @@
IVA = d.ivaTasa; CATALOGO = d.catalogo; IVA = d.ivaTasa; CATALOGO = d.catalogo;
recibirClientes(d.clientes); recibirClientes(d.clientes);
recibirProyectos(d.proyectos); recibirProyectos(d.proyectos);
// La pestaña Cobranza solo se muestra con permiso; el servidor lo revisa en cada llamada
PERMISOS = d.permisos || {};
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)'; $('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists(); pintarDatalists();
agregarFila(); agregarFila();
@ -442,7 +529,10 @@
const aplicar = p => { const aplicar = p => {
if (!p) return; if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion; 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('.uni').value = p.unidad;
// El catálogo está en pesos: en una cotización en USD el precio se captura a mano
if (monedaActual() === 'USD') { tr.querySelector('.pu').value = ''; tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); }
else tr.querySelector('.pu').value = p.precio;
}; };
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); }); 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.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
@ -467,12 +557,23 @@
let sub = 0; let sub = 0;
leerItems().forEach(it => { leerItems().forEach(it => {
const imp = r2(it.cantidad * r2(it.precio)); const imp = r2(it.cantidad * r2(it.precio));
it._tr.querySelector('.imp').textContent = mxn(imp); it._tr.querySelector('.imp').textContent = dinero(imp, monedaActual());
sub += imp; sub += imp;
}); });
sub = r2(sub); sub = r2(sub);
const iva = r2(sub * IVA); const iva = r2(sub * IVA), m = monedaActual();
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva); $('tSub').textContent = dinero(sub, m); $('tIva').textContent = dinero(iva, m); $('tTot').textContent = dinero(sub + iva, m);
$('lblTot').textContent = 'TOTAL ' + m;
}
function cambiarMoneda() {
const usd = monedaActual() === 'USD';
const conPrecio = leerItems().some(it => it.precio > 0);
$('notaMoneda').innerHTML = usd
? '<span class="aviso-moneda">Cotización en USD:</span> el catálogo llena clave y descripción; captura los precios en dólares.' +
(conPrecio ? ' <span class="aviso-moneda">Revisa los precios que ya capturaste: los del catálogo están en pesos.</span>' : '')
: '';
recalcular();
} }
// ---- Guardar ---- // ---- Guardar ----
@ -483,6 +584,7 @@
items: leerItems().map(({_tr, ...it}) => it), items: leerItems().map(({_tr, ...it}) => it),
notas: $('notas').value, notas: $('notas').value,
marca: $('marca').value, marca: $('marca').value,
moneda: monedaActual(),
proyectoFolio: '', proyectoFolio: '',
idSolicitud: ID_SOLICITUD, idSolicitud: ID_SOLICITUD,
}; };
@ -495,7 +597,7 @@
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…'; $('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run google.script.run
.withSuccessHandler(r => { .withSuccessHandler(r => {
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) + aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + dinero(r.total, r.moneda) +
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') + (r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>'); ' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar(); limpiar();
@ -519,6 +621,7 @@
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = ''); ['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular(); $('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = ''; $('cInfo').textContent = '';
$('moneda').value = 'MXN'; cambiarMoneda();
$('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto(); $('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto();
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores
@ -534,7 +637,7 @@
google.script.run.withSuccessHandler(lista => { google.script.run.withSuccessHandler(lista => {
$('hist').innerHTML = lista.length ? lista.map(c => $('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>' + '<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>' + esc(c.empresa) + '</td><td class="num">' + esc(c.total) + ' ' + esc(c.moneda || 'MXN') + '</td><td>' + esc(c.vendedor || c.elaboro) + '</td><td>' +
(c.proyecto === 'Sin proyecto' ? '<span class="nota">Sin proyecto</span>' : esc(c.proyecto)) + '</td><td>' + (c.proyecto === 'Sin proyecto' ? '<span class="nota">Sin proyecto</span>' : esc(c.proyecto)) + '</td><td>' +
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('') (c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="8">Sin resultados.</td></tr>'; : '<tr><td colspan="8">Sin resultados.</td></tr>';
@ -729,16 +832,230 @@
} }
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); }); $('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
// ---- Cobranza (solo rol de cobranza) ----
let COB_PROYECTOS = [], COB = null, ESQ = [], ID_PAGO = nuevoId();
const MARCA_TXT = { helgen: 'Helgen', energy: 'Helgen Energy' };
const etiquetaCob = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre;
function cargarProyectosCob() {
google.script.run.withSuccessHandler(lista => {
COB_PROYECTOS = lista || [];
$('cobProyectosList').innerHTML = COB_PROYECTOS.map(p =>
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc((p.contrato ? 'Contrato: ' + p.contrato : 'Sin aceptada') +
(p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
}).withFailureHandler(mostrarError).listarProyectosCobranza('');
}
function proyectoCobElegido() {
const folio = ($('cobProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
return (folio && COB_PROYECTOS.find(p => p.folio === folio)) || null;
}
$('cobProyecto').addEventListener('change', () => { const p = proyectoCobElegido(); if (p) abrirProyectoCob(p.folio); });
function abrirProyectoCob(folio) {
$('cobInfo').textContent = 'Cargando…';
google.script.run.withSuccessHandler(pintarCobranza).withFailureHandler(mostrarError).getCobranzaProyecto(folio);
}
/** Pinta todo a partir de la respuesta del servidor (vistaCobranza_). */
function pintarCobranza(v) {
COB = v;
const c = v.contrato, r = v.resumen, firmado = c && c.estado === 'Firmado';
const mon = c ? c.moneda : 'MXN';
$('cobProyecto').value = etiquetaCob(v.proyecto);
$('cobInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '') +
' · Contrato: ' + (c ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">' + esc(c.estado) + '</span>' : 'sin cotización aceptada');
$('cobDetalle').classList.remove('oculto');
// Cotizaciones del proyecto: una sola aceptada
$('cobCots').innerHTML = v.cotizaciones.length ? v.cotizaciones.map(q => {
const esAceptada = c && c.cotizacion === q.folio;
return '<tr><td><b>' + esc(q.folio) + '</b></td><td>' + esc(q.fecha) + '</td><td>' + esc(MARCA_TXT[q.marca]) + '</td><td>' + esc(q.moneda) +
'</td><td class="num">' + dinero(q.subtotal, q.moneda) + '</td><td class="num">' + dinero(q.total, q.moneda) + '</td><td>' +
(q.pdf ? '<a href="' + esc(q.pdf) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
(esAceptada ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">Aceptada</span>'
: firmado ? '' : '<button type="button" class="btn btn-sec" onclick="aceptarCot(\'' + esc(q.folio) + '\')">Marcar aceptada</button>') +
'</td></tr>';
}).join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene cotizaciones.</td></tr>';
// Contrato
let h = '';
if (!c) h = '<span class="nota">Marca la cotización aceptada para definir el monto contratado.</span>';
else {
h = '<div class="sat-titulo">Monto contratado: ' + dinero(c.total, mon) + ' (con IVA) · subtotal ' + dinero(c.subtotal, mon) +
' · ' + esc(MARCA_TXT[c.marca]) + '</div>';
if (firmado) {
h += '<div>Contrato firmado el <b>' + esc(c.fechaFirma) + '</b> · registró ' + esc(c.firmadoPor) +
'. La cotización aceptada, el monto y el esquema ya no se pueden cambiar.</div>';
if (v.puedeDesbloquear) {
h += '<div style="margin-top:10px"><label>Motivo del desbloqueo (queda registrado) *</label>' +
'<input id="cobMotivo" maxlength="500" placeholder="Ej. Se firmó con la cotización equivocada"></div>' +
'<div class="acciones"><button type="button" class="btn btn-sec" id="btnDesbloquear" onclick="desbloquearCob()">Desbloquear contrato</button></div>';
}
} else {
h += '<div class="proy-fila" style="margin-top:8px"><div style="max-width:220px"><label>Fecha de firma *</label>' +
'<input id="cobFechaFirma" type="date" max="' + esc(v.hoy) + '" value="' + esc(v.hoy) + '"></div>' +
'<button type="button" class="btn" id="btnFirmar" onclick="firmarCob()">Contrato firmado</button></div>' +
'<div class="nota" style="margin-top:6px">Al firmar se bloquean la cotización aceptada, el monto contratado y el esquema de pagos. ' +
'Después ya no se podrán agregar cotizaciones al proyecto.</div>';
}
}
$('cobContrato').innerHTML = h;
// Esquema
$('cobModEsquema').classList.toggle('oculto', !c);
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
$('cobEsqNota').textContent = !c ? '' : v.esquemaEditable
? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.'
: c.marca === 'energy' && !firmado ? 'Helgen Energy: esquema fijo (60% / 30% / 10%).' : 'Esquema bloqueado por el contrato firmado.';
pintarEsquema();
// Pagos
$('cobModPagos').classList.toggle('oculto', !c);
$('cobResumen').innerHTML = r ? [['Contratado (con IVA)', dinero(r.contratado, mon)], ['Subtotal (referencia)', dinero(r.subtotal, mon)],
['Pagado', dinero(r.pagado, mon)], ['Saldo', dinero(r.saldo, mon)], ['Avance', r.avance + '%']]
.map(([k, x]) => '<div class="tile"><span>' + k + '</span><b>' + x + '</b></div>').join('') : '';
$('cobFormPago').classList.toggle('oculto', !v.puedePagar);
$('cobSinPagos').textContent = c && !v.puedePagar ? 'Los pagos se registran cuando el contrato está firmado.' : '';
if (v.puedePagar) {
$('pgFecha').max = v.hoy; if (!$('pgFecha').value) $('pgFecha').value = v.hoy;
$('pgMoneda').value = mon;
$('pgMetodo').innerHTML = (v.metodos || []).map(m => '<option>' + esc(m) + '</option>').join('');
$('pgEtapa').innerHTML = (r ? r.etapas : []).map((e, i) => '<option value="' + (i + 1) + '">' + (i + 1) + ' · ' + esc(e.etapa) +
' (saldo ' + dinero(e.saldo, mon) + ')</option>').join('');
const pendiente = r && r.etapas.findIndex(e => e.saldo > 0);
if (pendiente >= 0) $('pgEtapa').value = String(pendiente + 1);
pintarTipoCambio();
}
$('cobPagos').innerHTML = (v.pagos || []).map(p => '<tr' + (p.estado === 'Cancelado' ? ' class="fila-cancelada" title="' +
esc('Cancelado por ' + p.canceladoPor + ': ' + p.motivoCancelacion) + '"' : '') + '><td><b>' + esc(p.folio) + '</b></td><td>' + esc(p.fecha) +
'</td><td class="num">' + dinero(p.monto, p.monedaPago) + (p.tipoCambio ? '<div class="nota">TC ' + esc(p.tipoCambio) + '</div>' : '') +
'</td><td class="num">' + dinero(p.montoAplicado, p.monedaProyecto) + '</td><td>' + esc(p.metodo) + (p.referencia ? ' · ' + esc(p.referencia) : '') +
'</td><td>' + esc(p.etapa) + '</td><td>' + esc(p.registro) + '</td><td>' +
(p.comprobanteUrl ? '<a href="' + esc(p.comprobanteUrl) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
(p.estado === 'Vigente' ? '<button type="button" class="quitar" title="Cancelar pago" onclick="cancelarPagoCob(\'' + esc(p.folio) + '\')">×</button>'
: '<span class="chip cancelado">Cancelado</span>') + '</td></tr>').join('');
}
function pintarEsquema() {
const v = COB, edit = v && v.esquemaEditable, mon = v && v.contrato ? v.contrato.moneda : 'MXN';
const total = v && v.contrato ? v.contrato.total : 0;
const res = v && v.resumen ? v.resumen.etapas : [];
const suma = r2(ESQ.reduce((s, e) => s + (Number(e.porcentaje) || 0), 0));
// Vista previa de montos (el servidor los recalcula): la última etapa se queda con los centavos
let acum = 0;
const montos = ESQ.map((e, i) => { const m = i === ESQ.length - 1 ? r2(total - acum) : r2(total * (Number(e.porcentaje) || 0) / 100); acum = r2(acum + m); return m; });
$('cobEsquema').innerHTML = ESQ.map((e, i) => {
const x = res[i] || {};
return '<tr><td>' + (i + 1) + '</td><td>' + (edit ? '<input value="' + esc(e.etapa) + '" maxlength="40" oninput="editarEtapa(' + i + ',\'etapa\',this.value)">' : esc(e.etapa)) +
'</td><td class="num">' + (edit ? '<input type="number" min="0" max="100" step="0.01" style="text-align:right" value="' + esc(e.porcentaje) +
'" oninput="editarEtapa(' + i + ',\'porcentaje\',this.value)">' : esc(e.porcentaje) + '%') +
'</td><td class="num">' + dinero(montos[i], mon) + '</td><td class="num">' + (x.pagado != null ? dinero(x.pagado, mon) : '') +
'</td><td class="num">' + (x.saldo != null ? dinero(x.saldo, mon) : '') + '</td><td>' +
(x.avance != null ? '<div class="barra" title="' + x.avance + '%"><i style="width:' + Math.max(0, Math.min(100, x.avance)) + '%"></i></div>' : '') +
'</td><td>' + (edit && ESQ.length > 1 ? '<button type="button" class="quitar" title="Quitar etapa" onclick="quitarEtapa(' + i + ')">×</button>' : '') + '</td></tr>';
}).join('') + (ESQ.length ? '<tr><td></td><td><b>Suma</b></td><td class="num"><b' + (Math.abs(suma - 100) > 0.001 ? ' class="aviso-moneda"' : '') + '>' +
suma + '%</b></td><td class="num"><b>' + dinero(total, mon) + '</b></td><td colspan="4"></td></tr>' : '');
$('cobEsqAcciones').innerHTML = edit ? '<button type="button" class="btn btn-sec" onclick="agregarEtapa()">+ Agregar etapa</button>' +
'<button type="button" class="btn" id="btnEsquema" onclick="guardarEsquemaCob()">Guardar esquema</button>' : '';
}
// Al editar un nombre no se vuelve a pintar (no se pierde el cursor); el % sí actualiza montos y suma
function editarEtapa(i, campo, valor) { ESQ[i][campo] = valor; if (campo === 'porcentaje') pintarEsquemaSinFoco(); }
function pintarEsquemaSinFoco() { const a = document.activeElement, idx = a && a.closest && a.closest('tr') ? [...$('cobEsquema').children].indexOf(a.closest('tr')) : -1;
pintarEsquema(); if (idx >= 0) { const inp = $('cobEsquema').children[idx].querySelector('input[type=number]'); if (inp) { inp.focus(); const l = inp.value.length; try { inp.setSelectionRange(l, l); } catch (e) {} } } }
function agregarEtapa() { if (ESQ.length >= 10) { mostrarError('Máximo 10 etapas.'); return; } ESQ.push({ etapa: '', porcentaje: 0 }); pintarEsquema(); }
function quitarEtapa(i) { ESQ.splice(i, 1); pintarEsquema(); }
// Botones que llaman al servidor: se desactivan mientras responde
function accionCob(boton, funcion, args, alExito) {
const b = boton && $(boton);
if (b) { if (b.disabled) return; b.disabled = true; }
const listo = () => { if (b) b.disabled = false; };
google.script.run.withSuccessHandler(x => { listo(); (alExito || pintarCobranza)(x); })
.withFailureHandler(err => { listo(); mostrarError(err); })[funcion](...args);
}
function aceptarCot(folio, confirmado) {
accionCob(null, 'marcarAceptada', [COB.proyecto.folio, folio, !!confirmado], v => {
if (v && v.confirmar) { if (window.confirm(v.mensaje)) aceptarCot(folio, true); return; }
pintarCobranza(v); aviso('ok', 'Cotización <b>' + esc(folio) + '</b> marcada como aceptada.');
});
}
function guardarEsquemaCob() {
accionCob('btnEsquema', 'guardarEsquema', [COB.proyecto.folio, ESQ.map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }))],
v => { pintarCobranza(v); aviso('ok', 'Esquema de pagos guardado.'); });
}
function firmarCob() {
const fecha = $('cobFechaFirma').value;
if (!fecha) { mostrarError('Captura la fecha de firma.'); return; }
if (!window.confirm('¿Registrar el contrato de ' + COB.proyecto.folio + ' como firmado el ' + fecha + '? ' +
'Después ya no se podrán cambiar la cotización aceptada, el monto ni el esquema de pagos.')) return;
accionCob('btnFirmar', 'firmarContrato', [COB.proyecto.folio, fecha], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato firmado.'); });
}
function desbloquearCob() {
const motivo = $('cobMotivo').value.trim();
if (!motivo) { mostrarError('Escribe el motivo del desbloqueo.'); return; }
accionCob('btnDesbloquear', 'desbloquearContrato', [COB.proyecto.folio, motivo], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato desbloqueado. Quedó registrado el motivo.'); });
}
function cancelarPagoCob(folio) {
const motivo = window.prompt('Motivo para cancelar el pago ' + folio + ' (queda registrado; el pago no se borra):');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
accionCob(null, 'cancelarPago', [folio, motivo], v => { pintarCobranza(v); aviso('ok', 'Pago <b>' + esc(folio) + '</b> cancelado.'); });
}
function pintarTipoCambio() {
const distinta = COB && COB.contrato && $('pgMoneda').value !== COB.contrato.moneda;
$('pgTcCaja').classList.toggle('oculto', !distinta);
if (!distinta) $('pgTc').value = '';
}
/** Lee el archivo como base64 (sin el prefijo data:). */
function leerArchivo(archivo) {
return new Promise((ok, mal) => {
const lector = new FileReader();
lector.onload = () => ok(String(lector.result).split(',')[1] || '');
lector.onerror = () => mal(new Error('No se pudo leer el comprobante.'));
lector.readAsDataURL(archivo);
});
}
function registrarPago() {
const archivo = $('pgArchivo').files && $('pgArchivo').files[0];
if (!archivo) { mostrarError('Adjunta el comprobante de pago (PDF o imagen).'); return; }
if (archivo.size > 10 * 1024 * 1024) { mostrarError('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
if ($('btnPago').disabled) return;
$('btnPago').disabled = true; $('btnPago').textContent = 'Subiendo comprobante…';
const listo = () => { $('btnPago').disabled = false; $('btnPago').textContent = 'Registrar pago'; };
leerArchivo(archivo).then(datos => {
google.script.run.withSuccessHandler(r => {
listo(); ID_PAGO = nuevoId();
['pgMonto', 'pgTc', 'pgRef', 'pgArchivo'].forEach(id => $(id).value = '');
pintarCobranza(r.vista);
aviso('ok', 'Pago <b>' + esc(r.folio) + '</b> registrado · <a href="' + esc(r.url) + '" target="_blank">Ver comprobante</a>');
}).withFailureHandler(err => { listo(); mostrarError(err); }).registrarPago({
proyectoFolio: COB.proyecto.folio, fecha: $('pgFecha').value, monto: $('pgMonto').value, monedaPago: $('pgMoneda').value,
tipoCambio: $('pgTc').value, metodo: $('pgMetodo').value, referencia: $('pgRef').value, etapaOrden: Number($('pgEtapa').value),
comprobante: { nombre: archivo.name, datos: datos }, idSolicitud: ID_PAGO,
});
}, err => { listo(); mostrarError(err); });
}
// ---- UI ---- // ---- UI ----
function verTab(t) { function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva'); $('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist'); $('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat'); $('vistaCat').classList.toggle('oculto', t !== 'cat');
$('vistaCob').classList.toggle('oculto', t !== 'cob');
$('tabCob').classList.toggle('activo', t === 'cob');
$('tabCat').classList.toggle('activo', t === 'cat'); $('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva'); $('tabNueva').classList.toggle('activo', t === 'nueva');
$('tabHist').classList.toggle('activo', t === 'hist'); $('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial(); if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo(); if (t === 'cat') cargarCatalogo();
if (t === 'cob') cargarProyectosCob();
$('aviso').className = 'aviso'; $('aviso').className = 'aviso';
} }
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); } function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }