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**.
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).
**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` |
| 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`) |
| 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 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` |
@ -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).
### 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`).
- `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
(`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
- `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT
a cada concepto que venga del catálogo (si falla, guarda igual), toma el candado, revisa idempotencia (`payload.idSolicitud`
en `CacheService`, 6 h), asigna folio, genera PDF, lo guarda en la carpeta del año, reescribe encabezados si no coinciden con
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total, proyectoFolio}`.
Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista y esté `Activo` (si no, error y no se
gasta folio) y guarda el PDF en `<carpeta del proyecto>/Cotizaciones` en lugar de la carpeta del año.
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total, moneda, proyectoFolio}`.
Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista, esté `Activo` y **no tenga contrato
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`.
`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**).
- `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)
- `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`).
- `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo`; máx. 50,
recientes primero. No devuelve `carpeta_id`.
- `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo` y **sin
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);
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
@ -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).
- `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)
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.").
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`.
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n, moneda)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.";
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)`.
- `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;
@ -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).
### 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`,
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
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)
├─ 📊 Catálogo Helgen (Catalogo)
├─ 📁 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)
├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
└─ 📜 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"),
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
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.
`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),
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):
`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),
`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é)
@ -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:…`.
- 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
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
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).
- 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)
- Pestañas `OrdenesCompra`, `Pagos` y `Gastos` en la base de datos, cada una con su folio (`OC-`, `PAG-`, `GAS-AAAA-NNNN`),
contador `CONSECUTIVO_<TIPO>_AAAA` (usar `siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`**
y `vendedor_*` del token. Crear las pestañas con el patrón de `hojaProyectos_()` (encabezados que se migran solos).
- Archivos en `subcarpetaProyecto_(proyecto, 'Órdenes de compra' | 'Pagos')`. Gastos aún no tiene subcarpeta: agregarla a
`SUBCARPETAS_PROYECTO` cuando se construya (los proyectos viejos la obtienen sola por §6.9).
- **Monto contratado:** la fase 2 necesitará marcar una cotización como **"Aceptada"** (columna de estatus en `Cotizaciones`)
para definir el monto contratado del proyecto, contra el que se comparan órdenes de compra, pagos y gastos.
### 6.10 Modelo futuro (resto de la fase 2; NO construido)
- Órdenes de compra y gastos: folio (`OC-`, `GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`
(`siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`** y `vendedor_*` del token, con encabezados
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).
- Se comparan contra el monto contratado (`Contratos.total` del libro de cobranza).
- **Adicionales después de firmar** (propuesta aprobada para después): función de adicionales **dentro del mismo proyecto**:
una cotización del proyecto se marca "Adicional", con su propio mini-esquema (por omisión 100% en una etapa), misma moneda que
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)
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**
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.
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
```bash
# Pruebas (Node, servicios de Google simulados). Correr las seis 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
# 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, USD; deja helgen.html / energy.html / usd.html
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
node tests/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
# 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.
- `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.
- 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).
## 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).
- 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.
`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`).
- 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`.

772
Code.js
View File

@ -95,6 +95,15 @@ const API_FUNCIONES = {
sugerirClavesSat: sugerirClavesSat,
listarProyectos: listarProyectos,
crearProyecto: crearProyecto,
// Cobranza (solo ROL_COBRANZA; cada función lo revisa)
listarProyectosCobranza: listarProyectosCobranza,
getCobranzaProyecto: getCobranzaProyecto,
marcarAceptada: marcarAceptada,
guardarEsquema: guardarEsquema,
firmarContrato: firmarContrato,
desbloquearContrato: desbloquearContrato,
registrarPago: registrarPago,
cancelarPago: cancelarPago,
};
/**
@ -124,7 +133,7 @@ function doPost(e) {
} catch (err) {
respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
if (err && err.sesion) respuesta.codigo = 'SESION';
else if (err && err.codigo) respuesta.codigo = err.codigo; // p. ej. 'PROYECTO' (ver errorProyecto_)
else if (err && err.codigo) respuesta.codigo = err.codigo; // 'PROYECTO' (errorProyecto_), 'PERMISO' (errorPermiso_)
} finally {
VENDEDOR_ = null;
}
@ -171,7 +180,7 @@ const CONFIG_EJEMPLO_ANTERIOR = {
const ENCABEZADOS_COTIZACIONES = [
'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono',
'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección',
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio'
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio', 'moneda'
];
function setup() {
@ -552,6 +561,8 @@ function getDatosIniciales() {
proyectos: proyectosParaInicio_(),
usuario: usuarioActual_(),
vendedor: VENDEDOR_,
// Solo para mostrar u ocultar la pestaña Cobranza; cada función de cobranza lo vuelve a revisar
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_() },
};
}
@ -595,6 +606,7 @@ function listarCotizaciones(busqueda) {
vendedor: vendedorDeFila(r, nombres),
// Filas anteriores a Proyectos (o cotizadas sin proyecto)
proyecto: r[19] || 'Sin proyecto',
moneda: r[20] || 'MXN', // filas anteriores a USD: pesos
}));
}
@ -644,6 +656,10 @@ function guardarCotizacion(payload) {
throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() +
': ya no se le pueden agregar cotizaciones.');
}
// Fase 2: con contrato firmado ya no se cotiza dentro del proyecto (se revisará con los adicionales)
if (foliosConContratoFirmado_()[proyecto.folio.toUpperCase()]) {
throw errorProyecto_('Proyecto con contrato firmado: no admite cotizaciones nuevas.');
}
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
}
@ -676,11 +692,12 @@ function guardarCotizacion(payload) {
t(datos.cliente.email), t(datos.cliente.telefono),
datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(),
JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion),
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio,
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio, datos.moneda,
]);
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
return resultado;
} finally {
@ -733,7 +750,9 @@ const ENCABEZADOS_PROYECTOS = [
const COL_ESTADO_PROYECTO = 7; // G
const COL_CARPETA_PROYECTO = 8; // H
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';
/**
@ -773,7 +792,10 @@ function listarProyectos(busqueda, incluirInactivos) {
const hoja = hojaProyectos_();
const filas = hoja.getLastRow() > 1
? 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,
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
* carpeta_id en la hoja.
*/
@ -897,6 +919,507 @@ function subcarpetaProyecto_(proyecto, 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) ======
function redondear(n) {
@ -936,6 +1459,7 @@ function normalizarCotizacion(p, ivaTasa) {
cliente: cliente, items: items, subtotal: subtotal, iva: iva, total: total, ivaTasa: ivaTasa,
notas: String((p && p.notas) || '').trim(),
marca: p && p.marca === 'energy' ? 'energy' : 'helgen',
moneda: monedaDeCotizacion(p && p.moneda),
};
}
@ -1079,6 +1603,224 @@ function filtrarProyectos(lista, busqueda, incluirInactivos, n) {
.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) ----
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;');
}
/** 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',
'ONCE', 'DOCE', 'TRECE', 'CATORCE', 'QUINCE', 'DIECISÉIS', 'DIECISIETE', 'DIECIOCHO', 'DIECINUEVE', 'VEINTE',
'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 base = entero(ent);
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>'
: '') +
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Vigencia', d.vigencia) +
(d.moneda === 'USD' ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
'</table></td></tr></table>' +
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
'<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' +
@ -1461,13 +2209,13 @@ function construirHtmlPdf(d, cfg, logoUrl) {
'<table><tr>' +
'<td style="width:58%;padding-left:0;">' +
'<div style="color:' + GRIS + ';">Importe con letra</div>' +
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total) + ')</div>' +
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total, d.moneda) + ')</div>' +
(d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') +
'</td><td style="width:42%;padding-right:0;"><table>' +
'<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' +
'<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' +
'<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' +
'<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(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>' +
'</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');
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');
// 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');

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.
// 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)
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 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;
// ---- DOM simulado (solo lo que usa la app) ----
function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',disabled:false,hijos:[],oyentes:{},
// ---- 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 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:{},
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),
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
focus(){},remove(){},appendChild:h=>e.hijos.push(h),
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 document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
document.getElementById('login').classList.add('oculto');
// ---- 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';
document.getElementById('tabCob').classList.add('oculto'); // como en el HTML
const almacen={cotizadorHelgenSesion:token};
// ---- 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={};
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}};
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;
return{ok:true,json:async()=>datos}}
respuestas.getDatosIniciales={ok:true,datos:{ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[PRY],marcas:{}}};
const ctx={document,fetch,console,JSON,Date,Math,Number,String,Array,Object,Promise,Proxy,Error,Uint8Array,TextDecoder,
return{ok:true,json:async()=>datos||{ok:false,error:'sin respuesta simulada: '+c.funcion}}}
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,FileReader,
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]},
confirm:m=>{dialogos.vistos.push(m);return dialogos.confirmar},prompt:m=>{dialogos.vistos.push(m);return dialogos.prompt},
setTimeout,clearTimeout,scrollTo(){}};
ctx.window=ctx;
vm.createContext(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()=>{
for(let i=0;i<5;i++) await esperar();
assert.equal(vm.runInContext('PROYECTOS.length',ctx),1,'carga los proyectos activos');
// ===== Vendedora (sin rol de cobranza) =====
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
$('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
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:[]};
vm.runInContext('guardar()',ctx);
for(let i=0;i<10;i++) await esperar();
A.run('guardar()');
await esperar();
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(/^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.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(/está cancelado/.test($('aviso').innerHTML)&&/Se quitó el proyecto/.test($('aviso').innerHTML));
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
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');
respuestas.guardarCotizacion={ok:false,error:'El sistema está ocupado guardando otra cotización.'};
const antes=llamadas.filter(c=>c.funcion==='listarProyectos').length;
vm.runInContext('guardar()',ctx);
for(let i=0;i<10;i++) await esperar();
A.run('guardar()'); await esperar();
assert.equal($('proyecto').value,'PRY-2026-0001 — Luis — Solar MX — Techo');
assert(/^Proyecto activo/.test($('proyInfo').textContent));
assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes);
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');
})().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.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
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]];
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.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
// 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');
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
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.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
ctx.guardarCotizacion({...base,idSolicitud:'c2'});
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'});
const c1b=carpetas[pry.m[1][7]];
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 ---
vm.runInContext('VENDEDOR_ = null',ctx);

View File

@ -71,10 +71,10 @@ fetches=0;
const r=post({funcion:'guardarCotizacion',token:'t-bueno',
args:[{...base,idSolicitud:'ok1',vendedor_email:'impostor@helgen.tech',vendedor:{email:'impostor@helgen.tech',nombre:'X'},elaboro:'impostor@helgen.tech'}]});
assert(r.ok,r.error); assert.equal(r.datos.folio,'HLG-2026-0002');
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido','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];
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'));
// 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);
@ -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.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.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false},'sin ROL_COBRANZA nadie tiene cobranza');
assert.equal(h[1].proyecto,'Sin proyecto');
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

View File

@ -84,6 +84,20 @@
#gisBoton { display:flex; justify-content:center; min-height:44px; }
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
#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>
</head>
<body>
@ -94,6 +108,7 @@
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
<button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
</nav>
<div id="usuario" class="usuario oculto">
<span id="usuarioNombre"></span>
@ -127,6 +142,11 @@
<option value="helgen">Helgen</option>
<option value="energy">Helgen Energy (paneles solares)</option>
</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>
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
<datalist id="clientesList"></datalist>
@ -175,6 +195,7 @@
<tbody id="filas"></tbody>
</table>
<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 class="modulo">
@ -187,7 +208,7 @@
<div class="totales">
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
<div class="total"><span>TOTAL</span><span id="tTot">$0.00</span></div>
<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>
</div>
</div>
@ -247,6 +268,65 @@
</table>
</div>
</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>
<script id="api-web">
@ -406,7 +486,7 @@
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></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.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId();
@ -415,6 +495,10 @@
const $ = id => document.getElementById(id);
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
// 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]));
// ---- Arranque ----
@ -422,6 +506,9 @@
IVA = d.ivaTasa; CATALOGO = d.catalogo;
recibirClientes(d.clientes);
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) + '%)';
pintarDatalists();
agregarFila();
@ -442,7 +529,10 @@
const aplicar = p => {
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.pu').value = p.precio;
tr.querySelector('.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('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
@ -467,12 +557,23 @@
let sub = 0;
leerItems().forEach(it => {
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 = r2(sub);
const iva = r2(sub * IVA);
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva);
const iva = r2(sub * IVA), m = monedaActual();
$('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 ----
@ -483,6 +584,7 @@
items: leerItems().map(({_tr, ...it}) => it),
notas: $('notas').value,
marca: $('marca').value,
moneda: monedaActual(),
proyectoFolio: '',
idSolicitud: ID_SOLICITUD,
};
@ -495,7 +597,7 @@
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run
.withSuccessHandler(r => {
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + dinero(r.total, r.moneda) +
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar();
@ -519,6 +621,7 @@
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = '';
$('moneda').value = 'MXN'; cambiarMoneda();
$('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto();
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores
@ -534,7 +637,7 @@
google.script.run.withSuccessHandler(lista => {
$('hist').innerHTML = lista.length ? lista.map(c =>
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.vendedor || c.elaboro) + '</td><td>' +
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.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="8">Sin resultados.</td></tr>';
@ -729,16 +832,230 @@
}
$('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 ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('vistaCob').classList.toggle('oculto', t !== 'cob');
$('tabCob').classList.toggle('activo', t === 'cob');
$('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva');
$('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo();
if (t === 'cob') cargarProyectosCob();
$('aviso').className = 'aviso';
}
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }