mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
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:
parent
f31f88f363
commit
745c9e3a66
145
CLAUDE.md
145
CLAUDE.md
@ -13,7 +13,9 @@ Desde el **19/10/2026** (§11) la interfaz se sirve desde **Cloudflare** y habla
|
|||||||
2. El servidor **recalcula** subtotal, IVA y total, asigna un **folio único** `HLG-AAAA-0001` y genera el **PDF**.
|
2. El servidor **recalcula** subtotal, IVA y total, asigna un **folio único** `HLG-AAAA-0001` y genera el **PDF**.
|
||||||
3. El PDF se guarda en Drive (carpeta por año) y la cotización queda como una fila en Google Sheets, con el vendedor del token.
|
3. El PDF se guarda en Drive (carpeta por año) y la cotización queda como una fila en Google Sheets, con el vendedor del token.
|
||||||
|
|
||||||
Además: **proyectos** (opcionales: folio `PRY-AAAA-0001` y carpeta propia en Drive; §6.9), historial con búsqueda, catálogo de productos con precios (Google Sheets aparte), clientes sugeridos con
|
Además: **proyectos** (opcionales: folio `PRY-AAAA-0001` y carpeta propia en Drive; §6.9), **cobranza por proyecto**
|
||||||
|
(cotización aceptada, contrato firmado, esquema de pagos y pagos `PAG-AAAA-0001` con comprobante; solo rol de cobranza; §6.11),
|
||||||
|
cotizaciones en **MXN o USD**, historial con búsqueda, catálogo de productos con precios (Google Sheets aparte), clientes sugeridos con
|
||||||
autorrelleno, y claves SAT internas por producto para contabilidad (con sugerencias por palabras o con IA).
|
autorrelleno, y claves SAT internas por producto para contabilidad (con sugerencias por palabras o con IA).
|
||||||
|
|
||||||
**Helgen Energy** es la submarca de paneles solares: mismo folio y diseño, pero sus propios Términos y Condiciones.
|
**Helgen Energy** es la submarca de paneles solares: mismo folio y diseño, pero sus propios Términos y Condiciones.
|
||||||
@ -31,6 +33,7 @@ autorrelleno, y claves SAT internas por producto para contabilidad (con sugerenc
|
|||||||
| Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` |
|
| Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` |
|
||||||
| Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) |
|
| Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) |
|
||||||
| "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) |
|
| "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) |
|
||||||
|
| Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | Fuera de la carpeta maestra, en Mi unidad. Se crea con `prepararCobranza()` (ID pendiente: anotarlo aquí al crearla en la API real). Lectores: tania@ y leo@ (§6.11) |
|
||||||
| Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) |
|
| Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) |
|
||||||
| Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) |
|
| Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) |
|
||||||
| Worker de Cloudflare | `cotizador-helgen`, cuenta de efrain@helgen.tech `badd8bca60b008822a73ca24e983ff43` |
|
| Worker de Cloudflare | `cotizador-helgen`, cuenta de efrain@helgen.tech `badd8bca60b008822a73ca24e983ff43` |
|
||||||
@ -116,29 +119,32 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
|||||||
- `existeEnSat_(pestana, clave)` — valida una clave con `TextFinder` (celda completa).
|
- `existeEnSat_(pestana, clave)` — valida una clave con `TextFinder` (celda completa).
|
||||||
|
|
||||||
### 4.4 Datos para la interfaz
|
### 4.4 Datos para la interfaz
|
||||||
- `getDatosIniciales()` *(UI, al cargar)* — IVA, vigencia, marcas, catálogo, clientes, `usuario` (correo) y `vendedor` (`{email, nombre, apellido}`).
|
- `getDatosIniciales()` *(UI, al cargar)* — IVA, vigencia, marcas, catálogo, clientes, `usuario` (correo), `vendedor` (`{email, nombre, apellido}`)
|
||||||
|
y `permisos` (`{cobranza, desbloqueo}`; solo para mostrar la pestaña Cobranza: cada función lo vuelve a revisar).
|
||||||
- `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`).
|
- `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`).
|
||||||
- `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa/proyecto; `vendedor` =
|
- `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa/proyecto; `vendedor` =
|
||||||
`vendedorDeFila`: nombre + apellido de la fila; en filas viejas (sin Q:S), el nombre que ese correo tiene en otras filas
|
`vendedorDeFila`: nombre + apellido de la fila; en filas viejas (sin Q:S), el nombre que ese correo tiene en otras filas
|
||||||
(`nombresDeVendedores`, gana la más reciente); si no se conoce, el correo (`vendedor_email` o "Elaboró"). No modifica la hoja.
|
(`nombresDeVendedores`, gana la más reciente); si no se conoce, el correo (`vendedor_email` o "Elaboró"). No modifica la hoja.
|
||||||
|
`moneda` = columna U (vacía = MXN).
|
||||||
|
|
||||||
### 4.5 Guardar cotización
|
### 4.5 Guardar cotización
|
||||||
- `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT
|
- `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT
|
||||||
a cada concepto que venga del catálogo (si falla, guarda igual), toma el candado, revisa idempotencia (`payload.idSolicitud`
|
a cada concepto que venga del catálogo (si falla, guarda igual), toma el candado, revisa idempotencia (`payload.idSolicitud`
|
||||||
en `CacheService`, 6 h), asigna folio, genera PDF, lo guarda en la carpeta del año, reescribe encabezados si no coinciden con
|
en `CacheService`, 6 h), asigna folio, genera PDF, lo guarda en la carpeta del año, reescribe encabezados si no coinciden con
|
||||||
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total, proyectoFolio}`.
|
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total, moneda, proyectoFolio}`.
|
||||||
Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista y esté `Activo` (si no, error y no se
|
Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista, esté `Activo` y **no tenga contrato
|
||||||
gasta folio) y guarda el PDF en `<carpeta del proyecto>/Cotizaciones` en lugar de la carpeta del año.
|
firmado** (si no, error `codigo: "PROYECTO"` y no se gasta folio; el de contrato dice "Proyecto con contrato firmado: no admite
|
||||||
|
cotizaciones nuevas.") y guarda el PDF en `<carpeta del proyecto>/Cotizaciones` en lugar de la carpeta del año.
|
||||||
- `siguienteFolio_(prefijo, hoja, baseContador)` — `max(<baseContador>AAAA, mayor folio del año en la hoja) + 1`.
|
- `siguienteFolio_(prefijo, hoja, baseContador)` — `max(<baseContador>AAAA, mayor folio del año en la hoja) + 1`.
|
||||||
`baseContador` = `CONSECUTIVO_` (cotizaciones, por omisión) o `CONSECUTIVO_PROY_` (proyectos).
|
`baseContador` = `CONSECUTIVO_` (cotizaciones, por omisión), `CONSECUTIVO_PROY_` (proyectos) o `CONSECUTIVO_PAG_` (pagos).
|
||||||
- `carpetaDelAnio_(anio)` — subcarpeta `2026`, `2027`… dentro de la carpeta de PDF (la busca **por nombre**).
|
- `carpetaDelAnio_(anio)` — subcarpeta `2026`, `2027`… dentro de la carpeta de PDF (la busca **por nombre**).
|
||||||
- `logoDataUrl_(fileId)` — logo de Drive si `LOGO_FILE_ID` tiene valor; si no, `LOGO_HELGEN_AZUL`.
|
- `logoDataUrl_(fileId)` — logo de Drive si `LOGO_FILE_ID` tiene valor; si no, `LOGO_HELGEN_AZUL`.
|
||||||
|
|
||||||
### 4.5.1 Proyectos (sección 4.1 del archivo)
|
### 4.5.1 Proyectos (sección 4.1 del archivo)
|
||||||
- `hojaProyectos_()` — pestaña `Proyectos` de la base de datos; la crea sola (lista desplegable de Estado en G) y reescribe
|
- `hojaProyectos_()` — pestaña `Proyectos` de la base de datos; la crea sola (lista desplegable de Estado en G) y reescribe
|
||||||
solo la fila 1 si faltan encabezados (`ENCABEZADOS_PROYECTOS`).
|
solo la fila 1 si faltan encabezados (`ENCABEZADOS_PROYECTOS`).
|
||||||
- `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo`; máx. 50,
|
- `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo` y **sin
|
||||||
recientes primero. No devuelve `carpeta_id`.
|
contrato firmado** (el buscador de cotizaciones; `foliosConContratoFirmado_`); máx. 50, recientes primero. No devuelve `carpeta_id`.
|
||||||
- `crearProyecto(p)` *(UI)* — exige vendedor; `normalizarProyecto`; candado + idempotencia (`PRY_<idSolicitud>`, 6 h);
|
- `crearProyecto(p)` *(UI)* — exige vendedor; `normalizarProyecto`; candado + idempotencia (`PRY_<idSolicitud>`, 6 h);
|
||||||
folio `PRY-AAAA-NNNN`; **primero** crea la carpeta y **después** la fila (estado `Activo`). Devuelve `{folio, …, url}`.
|
folio `PRY-AAAA-NNNN`; **primero** crea la carpeta y **después** la fila (estado `Activo`). Devuelve `{folio, …, url}`.
|
||||||
- `buscarProyecto_(hoja, folio)`, `carpetaProyectos_()` (`PROYECTOS_FOLDER_ID`; si falta o está en la Papelera la crea en la
|
- `buscarProyecto_(hoja, folio)`, `carpetaProyectos_()` (`PROYECTOS_FOLDER_ID`; si falta o está en la Papelera la crea en la
|
||||||
@ -146,9 +152,29 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
|||||||
`crearCarpetaProyecto_`, `subcarpetaProyecto_(proyecto, nombreSub)` (ver §6.9).
|
`crearCarpetaProyecto_`, `subcarpetaProyecto_(proyecto, nombreSub)` (ver §6.9).
|
||||||
- `getDatosIniciales()` incluye `proyectos` (activos; si falla, `[]` y la app carga igual).
|
- `getDatosIniciales()` incluye `proyectos` (activos; si falla, `[]` y la app carga igual).
|
||||||
|
|
||||||
|
### 4.5.2 Cobranza (sección 4.2 del archivo; ver §6.11)
|
||||||
|
- Todas empiezan con `exigirCobranza_()` (token + correo en `ROL_COBRANZA`; si no, `errorPermiso_` → `codigo: "PERMISO"`).
|
||||||
|
- *(UI)* `listarProyectosCobranza(busqueda)` (todos los estados + estado del contrato), `getCobranzaProyecto(folio)`,
|
||||||
|
`marcarAceptada(proyecto, cotizacion, confirmado)` (si es Energy y reemplaza un esquema distinto al 60/30/10 devuelve
|
||||||
|
`{confirmar, mensaje}` sin cambiar nada), `guardarEsquema(proyecto, etapas)`, `firmarContrato(proyecto, 'AAAA-MM-DD')`,
|
||||||
|
`desbloquearContrato(proyecto, motivo)` (además `esAdminDesbloqueo_`), `registrarPago(p)` (con `comprobante.datos` en base64
|
||||||
|
e idempotencia `PAG_<idSolicitud>`), `cancelarPago(folio, motivo)`. Las que cambian algo usan `conCandado_` y devuelven la vista
|
||||||
|
(`vistaCobranza_`).
|
||||||
|
- Internas: `carpetaCobranza_` y `libroCobranza_` (se crean solas la primera vez; si el ID existe pero no se encuentra, **error**:
|
||||||
|
no se crea otra a escondidas), `libroCobranzaSiExiste_` (las cotizaciones no crean el libro), `hojaCobranza_(libro, nombre)`
|
||||||
|
(encabezados que se migran solos, `HOJAS_COBRANZA`), `datosCobranza_`, `escribirEsquema_`, `bitacora_`, `cotizacionesDeProyecto_`.
|
||||||
|
- `prepararCobranza()` — correr desde el editor: crea carpeta y libro (no comparte nada) y sirve para autorizar permisos.
|
||||||
|
|
||||||
### 4.6 Lógica pura (sin Google; se prueba en Node)
|
### 4.6 Lógica pura (sin Google; se prueba en Node)
|
||||||
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.").
|
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n, moneda)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.";
|
||||||
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`.
|
en USD "MIL CIENTO SESENTA DÓLARES 02/100 USD").
|
||||||
|
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`,
|
||||||
|
`moneda` (`monedaDeCotizacion`: vacía = MXN; solo MXN/USD).
|
||||||
|
- Cobranza: `esquemaPorOmision`, `validarEsquema` (1–10 etapas, nombres únicos, % > 0 con 2 decimales, suma exacta 100),
|
||||||
|
`montosDeEsquema` (la última etapa absorbe los centavos), `convertirMoneda`, `repartirPago` (etapa elegida → siguientes →
|
||||||
|
anteriores; en centavos), `resumenCobranza` (vuelve a repartir los pagos Vigentes en orden de registro), `validarFechaIso`,
|
||||||
|
`normalizarPago`, `validarComprobante` (tipo por los primeros bytes: PDF, JPG, PNG, HEIC; máx. 10 MB), `correosDeLista`,
|
||||||
|
`nombreComprobante`.
|
||||||
- `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`.
|
- `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`.
|
||||||
- `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (`textoNormal`), la fila más
|
- `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (`textoNormal`), la fila más
|
||||||
reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite;
|
reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite;
|
||||||
@ -166,7 +192,11 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
|||||||
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
|
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
|
||||||
|
|
||||||
### 4.7 Interfaz (`web/cotizador/index.html`)
|
### 4.7 Interfaz (`web/cotizador/index.html`)
|
||||||
Tres pestañas: **Nueva cotización**, **Historial**, **Catálogo**. Funciones principales: `agregarFila`, `leerItems`,
|
Pestañas: **Nueva cotización**, **Historial**, **Catálogo** y **Cobranza** (solo si `permisos.cobranza`). Moneda: selector
|
||||||
|
`#moneda`, `monedaActual`, `dinero(n, moneda)`, `cambiarMoneda` (en USD el catálogo llena clave/descripción/unidad pero **no**
|
||||||
|
el precio). Cobranza: `cargarProyectosCob`, `pintarCobranza(vista)`, `pintarEsquema`, `aceptarCot` (confirma con `window.confirm`
|
||||||
|
si el servidor lo pide), `guardarEsquemaCob`, `firmarCob`, `desbloquearCob`, `registrarPago` (lee el archivo con `FileReader`),
|
||||||
|
`cancelarPagoCob` (motivo con `window.prompt`). Funciones principales: `agregarFila`, `leerItems`,
|
||||||
`recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`,
|
`recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`,
|
||||||
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
|
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
|
||||||
Proyectos (módulo "1 · Cliente y proyecto"): `recibirProyectos`, `proyectoElegido`, `alElegirProyecto` (si "Nombre" está vacío,
|
Proyectos (módulo "1 · Cliente y proyecto"): `recibirProyectos`, `proyectoElegido`, `alElegirProyecto` (si "Nombre" está vacío,
|
||||||
@ -183,18 +213,25 @@ Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
|
|||||||
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
|
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
|
||||||
├─ 📊 Catálogo Helgen (Catalogo)
|
├─ 📊 Catálogo Helgen (Catalogo)
|
||||||
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf (cotizaciones "Sin proyecto")
|
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf (cotizaciones "Sin proyecto")
|
||||||
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones, Órdenes de compra, Pagos
|
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones, Órdenes de compra
|
||||||
|
│ (los proyectos creados antes de Cobranza tienen además "Pagos", vacía)
|
||||||
├─ 📁 Catálogos SAT / SAT_c_ClaveProdServ.csv, SAT_c_ClaveUnidad.csv, 📊 Catálogos SAT (CFDI 4.0)
|
├─ 📁 Catálogos SAT / SAT_c_ClaveProdServ.csv, SAT_c_ClaveUnidad.csv, 📊 Catálogos SAT (CFDI 4.0)
|
||||||
├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
|
├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
|
||||||
└─ 📜 Cotizador Helgen (código de la app) ← proyecto ANTERIOR, en desuso
|
└─ 📜 Cotizador Helgen (código de la app) ← proyecto ANTERIOR, en desuso
|
||||||
```
|
```
|
||||||
|
**Aparte, fuera de "Cotizador Helgen"** (todo el dominio puede leer la carpeta maestra; esta no):
|
||||||
|
```
|
||||||
|
📁 Cotizador Helgen — Cobranza (COBRANZA_FOLDER_ID; lectores tania@ y leo@)
|
||||||
|
├─ 📊 Cotizador Helgen — Cobranza (datos) (COBRANZA_DB_ID: Contratos, Esquemas, Pagos, Bitacora)
|
||||||
|
└─ 📁 2026 / 📁 PRY-2026-0002 / PAG-2026-0001 — Transferencia SPEI 123.pdf
|
||||||
|
```
|
||||||
El proyecto de la API (`1GsH8Dja…`) está en la carpeta `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p` (antes "PRUEBAS — Cotizador web"),
|
El proyecto de la API (`1GsH8Dja…`) está en la carpeta `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p` (antes "PRUEBAS — Cotizador web"),
|
||||||
junto con los archivos de prueba que ya no se usan (§9).
|
junto con los archivos de prueba que ya no se usan (§9).
|
||||||
|
|
||||||
**Pestaña `Cotizaciones`** (20 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono,
|
**Pestaña `Cotizaciones`** (21 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono,
|
||||||
Subtotal, IVA, Total, Elaboró, PDF (link), Conceptos (JSON: clave, descripcion, unidad, cantidad, precio, importe y, si vienen
|
Subtotal, IVA, Total, Elaboró, PDF (link), Conceptos (JSON: clave, descripcion, unidad, cantidad, precio, importe y, si vienen
|
||||||
del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Dirección, `vendedor_email`, `vendedor_nombre`,
|
del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Dirección, `vendedor_email`, `vendedor_nombre`,
|
||||||
`vendedor_apellido`, `proyecto_folio` (T; vacío = "Sin proyecto"). Las columnas P:S se agregaron el 19/10/2026 (§11); las
|
`vendedor_apellido`, `proyecto_folio` (T; vacío = "Sin proyecto"), `moneda` (U: MXN o USD; vacía en filas viejas = MXN). Las columnas P:S se agregaron el 19/10/2026 (§11); las
|
||||||
filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026): las filas anteriores quedan vacías y no se mueven.
|
filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026): las filas anteriores quedan vacías y no se mueven.
|
||||||
`guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`.
|
`guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`.
|
||||||
|
|
||||||
@ -218,10 +255,23 @@ no hay pantalla), `carpeta_id`, `vendedor_email`, `vendedor_nombre`, `vendedor_a
|
|||||||
**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA),
|
**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA),
|
||||||
Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
||||||
|
|
||||||
|
**Libro de cobranza** (`COBRANZA_DB_ID`; columnas en `HOJAS_COBRANZA`; texto salvo importes):
|
||||||
|
- `Contratos` (1 fila por proyecto): `proyecto_folio`, `cotizacion_aceptada`, `marca`, `moneda`, `subtotal`, `iva`, `total`
|
||||||
|
(= monto contratado, con IVA), `estado` (Aceptada / Firmado), `aceptada_por`, `fecha_aceptacion`, `fecha_firma`, `firmado_por`, `actualizado`.
|
||||||
|
- `Esquemas`: `proyecto_folio`, `orden`, `etapa`, `porcentaje`, `monto` (se reescriben las filas del proyecto al cambiar).
|
||||||
|
- `Pagos`: `folio` PAG, `fecha_pago` (AAAA-MM-DD), `fecha_registro`, `proyecto_folio`, `monto`, `moneda_pago`, `tipo_cambio`,
|
||||||
|
`monto_aplicado` (en la moneda del proyecto), `moneda_proyecto`, `metodo`, `referencia`, `etapa_orden`, `etapa`,
|
||||||
|
`aplicacion (JSON)` (reparto por etapa al registrar), `comprobante_id`, `comprobante_url`, `registro_email`, `registro_nombre`,
|
||||||
|
`estado` (Vigente / Cancelado), `cancelado_por`, `fecha_cancelacion`, `motivo_cancelacion`. Nunca se borran filas.
|
||||||
|
- `Bitacora`: `fecha`, `correo`, `accion`, `proyecto_folio`, `detalle`, `motivo` (aceptar, cambiar aceptada, editar esquema,
|
||||||
|
firmar, desbloquear, registrar y cancelar pago).
|
||||||
|
|
||||||
**Script Properties** del proyecto `1GsH8Dja…` (Configuración del proyecto en el editor):
|
**Script Properties** del proyecto `1GsH8Dja…` (Configuración del proyecto en el editor):
|
||||||
`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
|
`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
|
||||||
`PROYECTOS_FOLDER_ID` y `CONSECUTIVO_PROY_AAAA` (se crean solos con el primer proyecto),
|
`PROYECTOS_FOLDER_ID` y `CONSECUTIVO_PROY_AAAA` (se crean solos con el primer proyecto),
|
||||||
`ANTHROPIC_API_KEY` (opcional; activa la IA para claves SAT; el proyecto anterior no la tenía). Nunca poner la API key en el código.
|
`ANTHROPIC_API_KEY` (opcional; activa la IA para claves SAT; el proyecto anterior no la tenía). Nunca poner la API key en el código.
|
||||||
|
Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `ADMIN_DESBLOQUEO` (efrain@), `COBRANZA_FOLDER_ID` y
|
||||||
|
`COBRANZA_DB_ID` (se crean solos), `CONSECUTIVO_PAG_AAAA`. **Sin `ROL_COBRANZA` nadie tiene cobranza** (se niega por omisión).
|
||||||
|
|
||||||
## 6. Decisiones tomadas (y por qué)
|
## 6. Decisiones tomadas (y por qué)
|
||||||
|
|
||||||
@ -292,20 +342,47 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
|||||||
se le agrega RFC al cliente, su `id` cambia a ese RFC y los proyectos viejos conservan el `SIN-RFC:…`.
|
se le agrega RFC al cliente, su `id` cambia a ese RFC y los proyectos viejos conservan el `SIN-RFC:…`.
|
||||||
- Folio propio con contador `CONSECUTIVO_PROY_AAAA` bajo el **mismo** candado de script que las cotizaciones.
|
- Folio propio con contador `CONSECUTIVO_PROY_AAAA` bajo el **mismo** candado de script que las cotizaciones.
|
||||||
- **Subcarpetas que se reparan solas:** las subcarpetas de un proyecto se buscan **por nombre**. Si alguien borra (Papelera) o
|
- **Subcarpetas que se reparan solas:** las subcarpetas de un proyecto se buscan **por nombre**. Si alguien borra (Papelera) o
|
||||||
renombra `Cotizaciones`, `Órdenes de compra` o `Pagos`, la próxima vez que se necesite el sistema **crea una nueva** con el
|
renombra `Cotizaciones` u `Órdenes de compra`, la próxima vez que se necesite el sistema **crea una nueva** con el
|
||||||
nombre correcto (la renombrada se queda como está, con sus archivos). Si se borra la carpeta completa del proyecto, se
|
nombre correcto (la renombrada se queda como está, con sus archivos). Si se borra la carpeta completa del proyecto, se
|
||||||
vuelve a crear (con sus 3 subcarpetas) y se actualiza `carpeta_id` en la hoja. Igual con `Proyectos` y su carpeta de año.
|
vuelve a crear (con sus subcarpetas) y se actualiza `carpeta_id` en la hoja. Igual con `Proyectos` y su carpeta de año.
|
||||||
Renombrar la carpeta **del proyecto** sí se puede (se usa por ID).
|
Renombrar la carpeta **del proyecto** sí se puede (se usa por ID).
|
||||||
- Las cotizaciones viejas no se mueven ni se ligan a proyectos.
|
- Las cotizaciones viejas no se mueven ni se ligan a proyectos.
|
||||||
|
- Desde Cobranza, `SUBCARPETAS_PROYECTO` = `Cotizaciones`, `Órdenes de compra` (sin `Pagos`, solo para proyectos nuevos; los
|
||||||
|
viejos conservan la suya, vacía). Un proyecto con contrato firmado **no admite cotizaciones nuevas** y sale del buscador de
|
||||||
|
cotizaciones (se revisará con los adicionales).
|
||||||
|
|
||||||
### 6.10 Modelo futuro (fase 2; NO construido)
|
### 6.10 Modelo futuro (resto de la fase 2; NO construido)
|
||||||
- Pestañas `OrdenesCompra`, `Pagos` y `Gastos` en la base de datos, cada una con su folio (`OC-`, `PAG-`, `GAS-AAAA-NNNN`),
|
- Órdenes de compra y gastos: folio (`OC-`, `GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`
|
||||||
contador `CONSECUTIVO_<TIPO>_AAAA` (usar `siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`**
|
(`siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`** y `vendedor_*` del token, con encabezados
|
||||||
y `vendedor_*` del token. Crear las pestañas con el patrón de `hojaProyectos_()` (encabezados que se migran solos).
|
que se migran solos. Antes de elegir dónde van, decidir quién debe verlos: la Base de datos la lee todo el dominio (§6.11).
|
||||||
- Archivos en `subcarpetaProyecto_(proyecto, 'Órdenes de compra' | 'Pagos')`. Gastos aún no tiene subcarpeta: agregarla a
|
- Se comparan contra el monto contratado (`Contratos.total` del libro de cobranza).
|
||||||
`SUBCARPETAS_PROYECTO` cuando se construya (los proyectos viejos la obtienen sola por §6.9).
|
- **Adicionales después de firmar** (propuesta aprobada para después): función de adicionales **dentro del mismo proyecto**:
|
||||||
- **Monto contratado:** la fase 2 necesitará marcar una cotización como **"Aceptada"** (columna de estatus en `Cotizaciones`)
|
una cotización del proyecto se marca "Adicional", con su propio mini-esquema (por omisión 100% en una etapa), misma moneda que
|
||||||
para definir el monto contratado del proyecto, contra el que se comparan órdenes de compra, pagos y gastos.
|
el proyecto, y se suma al contratado como línea aparte sin tocar el contrato original. Al construirla, revisar la regla de
|
||||||
|
"contrato firmado no admite cotizaciones nuevas".
|
||||||
|
|
||||||
|
### 6.11 Cobranza (construida 10/2026)
|
||||||
|
- **Datos en un libro aparte** dentro de la carpeta "Cotizador Helgen — Cobranza", fuera de la carpeta maestra: la Base de
|
||||||
|
datos y la carpeta maestra están compartidas como **lector con todo el dominio helgen.tech** (revisado el 20/10/2026), así
|
||||||
|
que una pestaña Pagos ahí la vería cualquier vendedor. **Nunca poner datos de cobranza en la Base de datos.**
|
||||||
|
- **Permisos en el servidor:** `ROL_COBRANZA` (Script Properties, no en el código) para ver y cambiar todo lo de cobranza;
|
||||||
|
`ADMIN_DESBLOQUEO` solo para desbloquear. Los demás reciben `codigo: "PERMISO"` y `getDatosIniciales` no les manda nada de
|
||||||
|
cobranza (solo `permisos` en falso para ocultar la pestaña).
|
||||||
|
- **Aceptada:** una por proyecto; se puede cambiar hasta firmar. Monto contratado = total **con IVA**; subtotal de referencia.
|
||||||
|
**Línea de negocio y moneda del proyecto salen de la aceptada.** Si ya hay pagos (tras un desbloqueo), la nueva aceptada debe
|
||||||
|
tener la misma moneda.
|
||||||
|
- **Esquema:** Helgen Energy **fijo** 60% Anticipo / 30% Instalación / 10% Encendido del sistema (el servidor rechaza editarlo);
|
||||||
|
Helgen: mismas etapas como sugerencia, editables (nombres, % y número de etapas, 1–10) hasta firmar. Suma exacta 100%.
|
||||||
|
- **Contrato firmado** (fecha, no futura): bloquea aceptada, monto y esquema. No se puede firmar si lo pagado supera el nuevo
|
||||||
|
monto. **Desbloqueo:** solo `ADMIN_DESBLOQUEO`, motivo obligatorio; vuelve a "Aceptada", conserva los pagos y queda en Bitacora.
|
||||||
|
- **Pagos:** solo con contrato firmado; comprobante **obligatorio** (PDF/JPG/PNG/HEIC, máx. 10 MB, tipo revisado por contenido) en
|
||||||
|
el mismo envío, guardado en `Cobranza/<año del folio del proyecto>/<folio del proyecto>/` con el folio PAG en el nombre.
|
||||||
|
Referencia obligatoria salvo en efectivo. El excedente de una etapa pasa a la siguiente. Un pago que excede el saldo del
|
||||||
|
proyecto por más de **1.00** (moneda del proyecto) se rechaza. Si la moneda del pago es otra, tipo de cambio del día
|
||||||
|
obligatorio (pesos por dólar) y se guarda el monto aplicado en la moneda del proyecto: **nunca se suman MXN y USD**.
|
||||||
|
- **Cancelar pago:** rol de cobranza, con motivo; el pago queda como Cancelado (nunca se borra) y deja de contar.
|
||||||
|
- **USD en cotizaciones:** el catálogo está en pesos: en USD la interfaz llena clave y descripción, y el precio se captura a mano
|
||||||
|
(no se convierte con tipo de cambio). El PDF dice "Moneda: USD (dólares estadounidenses)", "TOTAL USD" e importe con letra en dólares.
|
||||||
|
|
||||||
## 7. Reglas del proyecto (no romper)
|
## 7. Reglas del proyecto (no romper)
|
||||||
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa.
|
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa.
|
||||||
@ -325,17 +402,20 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
|||||||
10. Toda llamada a la API exige token válido de @helgen.tech (`hd`, `email_verified`, `aud`); el vendedor sale **siempre**
|
10. Toda llamada a la API exige token válido de @helgen.tech (`hd`, `email_verified`, `aud`); el vendedor sale **siempre**
|
||||||
del token, nunca de lo que mande el navegador. No quitar esas revisiones.
|
del token, nunca de lo que mande el navegador. No quitar esas revisiones.
|
||||||
11. **Responder siempre en español** (de México) al usuario, en el chat y en los mensajes de commit.
|
11. **Responder siempre en español** (de México) al usuario, en el chat y en los mensajes de commit.
|
||||||
|
12. Datos de cobranza (contratos, esquemas, pagos, comprobantes) **solo** en el libro y la carpeta de cobranza, y cada función
|
||||||
|
de cobranza revisa `ROL_COBRANZA` en el servidor (§6.11). Nunca sumar importes de monedas distintas.
|
||||||
|
|
||||||
## 8. Cómo trabajar
|
## 8. Cómo trabajar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pruebas (Node, servicios de Google simulados). Correr las seis antes de subir.
|
# Pruebas (Node, servicios de Google simulados). Correr las siete antes de subir.
|
||||||
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes; deja helgen.html / energy.html
|
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes, USD; deja helgen.html / energy.html / usd.html
|
||||||
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
|
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
|
||||||
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
|
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
|
||||||
node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
|
node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
|
||||||
node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan
|
node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan
|
||||||
node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado se limpia
|
node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado, USD, pestaña Cobranza
|
||||||
|
node tests/cobranza.test.js . # cobranza por doPost: permisos, aceptada, esquema, firma, desbloqueo, pagos, monedas
|
||||||
node tests/probar-busqueda-sat.js Code.js "<ruta>/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras
|
node tests/probar-busqueda-sat.js Code.js "<ruta>/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras
|
||||||
|
|
||||||
# Subir y publicar la API (misma API_URL)
|
# Subir y publicar la API (misma API_URL)
|
||||||
@ -378,7 +458,15 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio
|
|||||||
- Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones.
|
- Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones.
|
||||||
- `GUIA.md` describe el proyecto anterior; actualizarla si se va a usar.
|
- `GUIA.md` describe el proyecto anterior; actualizarla si se va a usar.
|
||||||
- Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
|
- Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
|
||||||
- Proyectos fase 2: órdenes de compra, pagos, gastos y cotización "Aceptada" (§6.10).
|
- Proyectos fase 2: órdenes de compra, gastos y adicionales (§6.10). Cobranza ya construida (§6.11).
|
||||||
|
- **Cobranza, antes de usarla:** probar en el proyecto PRUEBAS (§10.5) con un usuario sin rol, correr `prepararCobranza()` en la
|
||||||
|
API real, capturar `ROL_COBRANZA` y `ADMIN_DESBLOQUEO`, compartir la carpeta de cobranza como lector con tania@ y leo@
|
||||||
|
(Google les manda correo: confirmar con el usuario antes) y anotar su ID en §2.
|
||||||
|
- **Mejora futura:** columna de precio en USD en el catálogo (hoy en cotizaciones USD el precio se captura a mano).
|
||||||
|
- **Acceso del dominio a la Base de datos y a la carpeta maestra** (lector para todo helgen.tech): se queda por ahora; revisarlo
|
||||||
|
junto con la idea "cada vendedor ve solo sus cotizaciones" (quitarlo impide abrir los PDF desde el Historial).
|
||||||
|
- Tania y Leo eran **editores** de la Base de datos (20/10/2026); el usuario los baja a lector.
|
||||||
|
- Los T&C de Helgen Energy dicen "$300 MXN por metro" también en cotizaciones USD: confirmar con el usuario si se ajusta.
|
||||||
- Mover el proyecto de prueba (ya sin deployment, §10.5) a la carpeta `1w-T05ql…` junto a los "ARCHIVO PRUEBAS" (lo hace el usuario).
|
- Mover el proyecto de prueba (ya sin deployment, §10.5) a la carpeta `1w-T05ql…` junto a los "ARCHIVO PRUEBAS" (lo hace el usuario).
|
||||||
|
|
||||||
## 10. Interfaz web y sesión con Google
|
## 10. Interfaz web y sesión con Google
|
||||||
@ -407,6 +495,7 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio
|
|||||||
(guardar es idempotente por `idSolicitud`: no duplica folio).
|
(guardar es idempotente por `idSolicitud`: no duplica folio).
|
||||||
- Otros códigos llegan a la interfaz en `err.codigo`. `codigo: "PROYECTO"` (proyecto cancelado, cerrado o inexistente al guardar,
|
- Otros códigos llegan a la interfaz en `err.codigo`. `codigo: "PROYECTO"` (proyecto cancelado, cerrado o inexistente al guardar,
|
||||||
`errorProyecto_`): `errorAlGuardar` limpia el campo Proyecto, refresca la lista y muestra el aviso.
|
`errorProyecto_`): `errorAlGuardar` limpia el campo Proyecto, refresca la lista y muestra el aviso.
|
||||||
|
`codigo: "PERMISO"` (sin rol de cobranza o de desbloqueo, `errorPermiso_`): se muestra el error.
|
||||||
- Encabezado: nombre del vendedor (del token, solo para mostrar) + botón "Cerrar sesión" (`disableAutoSelect`).
|
- Encabezado: nombre del vendedor (del token, solo para mostrar) + botón "Cerrar sesión" (`disableAutoSelect`).
|
||||||
- Tras publicar, el navegador puede mostrar la versión anterior en caché: recargar la página.
|
- Tras publicar, el navegador puede mostrar la versión anterior en caché: recargar la página.
|
||||||
- Wrangler 4 delega Cloudflare Pages "clásico" a Workers: se eligió **Worker con assets** (`wrangler.jsonc`), no `wrangler pages`.
|
- Wrangler 4 delega Cloudflare Pages "clásico" a Workers: se eligió **Worker con assets** (`wrangler.jsonc`), no `wrangler pages`.
|
||||||
|
|||||||
772
Code.js
772
Code.js
@ -95,6 +95,15 @@ const API_FUNCIONES = {
|
|||||||
sugerirClavesSat: sugerirClavesSat,
|
sugerirClavesSat: sugerirClavesSat,
|
||||||
listarProyectos: listarProyectos,
|
listarProyectos: listarProyectos,
|
||||||
crearProyecto: crearProyecto,
|
crearProyecto: crearProyecto,
|
||||||
|
// Cobranza (solo ROL_COBRANZA; cada función lo revisa)
|
||||||
|
listarProyectosCobranza: listarProyectosCobranza,
|
||||||
|
getCobranzaProyecto: getCobranzaProyecto,
|
||||||
|
marcarAceptada: marcarAceptada,
|
||||||
|
guardarEsquema: guardarEsquema,
|
||||||
|
firmarContrato: firmarContrato,
|
||||||
|
desbloquearContrato: desbloquearContrato,
|
||||||
|
registrarPago: registrarPago,
|
||||||
|
cancelarPago: cancelarPago,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -124,7 +133,7 @@ function doPost(e) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
|
respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
|
||||||
if (err && err.sesion) respuesta.codigo = 'SESION';
|
if (err && err.sesion) respuesta.codigo = 'SESION';
|
||||||
else if (err && err.codigo) respuesta.codigo = err.codigo; // p. ej. 'PROYECTO' (ver errorProyecto_)
|
else if (err && err.codigo) respuesta.codigo = err.codigo; // 'PROYECTO' (errorProyecto_), 'PERMISO' (errorPermiso_)
|
||||||
} finally {
|
} finally {
|
||||||
VENDEDOR_ = null;
|
VENDEDOR_ = null;
|
||||||
}
|
}
|
||||||
@ -171,7 +180,7 @@ const CONFIG_EJEMPLO_ANTERIOR = {
|
|||||||
const ENCABEZADOS_COTIZACIONES = [
|
const ENCABEZADOS_COTIZACIONES = [
|
||||||
'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono',
|
'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono',
|
||||||
'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección',
|
'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección',
|
||||||
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio'
|
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio', 'moneda'
|
||||||
];
|
];
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
@ -552,6 +561,8 @@ function getDatosIniciales() {
|
|||||||
proyectos: proyectosParaInicio_(),
|
proyectos: proyectosParaInicio_(),
|
||||||
usuario: usuarioActual_(),
|
usuario: usuarioActual_(),
|
||||||
vendedor: VENDEDOR_,
|
vendedor: VENDEDOR_,
|
||||||
|
// Solo para mostrar u ocultar la pestaña Cobranza; cada función de cobranza lo vuelve a revisar
|
||||||
|
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -595,6 +606,7 @@ function listarCotizaciones(busqueda) {
|
|||||||
vendedor: vendedorDeFila(r, nombres),
|
vendedor: vendedorDeFila(r, nombres),
|
||||||
// Filas anteriores a Proyectos (o cotizadas sin proyecto)
|
// Filas anteriores a Proyectos (o cotizadas sin proyecto)
|
||||||
proyecto: r[19] || 'Sin proyecto',
|
proyecto: r[19] || 'Sin proyecto',
|
||||||
|
moneda: r[20] || 'MXN', // filas anteriores a USD: pesos
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -644,6 +656,10 @@ function guardarCotizacion(payload) {
|
|||||||
throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() +
|
throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() +
|
||||||
': ya no se le pueden agregar cotizaciones.');
|
': ya no se le pueden agregar cotizaciones.');
|
||||||
}
|
}
|
||||||
|
// Fase 2: con contrato firmado ya no se cotiza dentro del proyecto (se revisará con los adicionales)
|
||||||
|
if (foliosConContratoFirmado_()[proyecto.folio.toUpperCase()]) {
|
||||||
|
throw errorProyecto_('Proyecto con contrato firmado: no admite cotizaciones nuevas.');
|
||||||
|
}
|
||||||
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
|
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -676,11 +692,12 @@ function guardarCotizacion(payload) {
|
|||||||
t(datos.cliente.email), t(datos.cliente.telefono),
|
t(datos.cliente.email), t(datos.cliente.telefono),
|
||||||
datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(),
|
datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(),
|
||||||
JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion),
|
JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion),
|
||||||
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio,
|
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio, datos.moneda,
|
||||||
]);
|
]);
|
||||||
SpreadsheetApp.flush();
|
SpreadsheetApp.flush();
|
||||||
|
|
||||||
const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total, proyectoFolio: proyectoFolio };
|
const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total, moneda: datos.moneda,
|
||||||
|
proyectoFolio: proyectoFolio };
|
||||||
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||||
return resultado;
|
return resultado;
|
||||||
} finally {
|
} finally {
|
||||||
@ -733,7 +750,9 @@ const ENCABEZADOS_PROYECTOS = [
|
|||||||
const COL_ESTADO_PROYECTO = 7; // G
|
const COL_ESTADO_PROYECTO = 7; // G
|
||||||
const COL_CARPETA_PROYECTO = 8; // H
|
const COL_CARPETA_PROYECTO = 8; // H
|
||||||
const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado'];
|
const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado'];
|
||||||
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra', 'Pagos'];
|
// Sin "Pagos": los comprobantes van en la carpeta de cobranza (no visible para todo el dominio). Los proyectos
|
||||||
|
// creados antes la conservan; no se borra.
|
||||||
|
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra'];
|
||||||
const PREFIJO_PROYECTO = 'PRY';
|
const PREFIJO_PROYECTO = 'PRY';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -773,7 +792,10 @@ function listarProyectos(busqueda, incluirInactivos) {
|
|||||||
const hoja = hojaProyectos_();
|
const hoja = hojaProyectos_();
|
||||||
const filas = hoja.getLastRow() > 1
|
const filas = hoja.getLastRow() > 1
|
||||||
? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
|
? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
|
||||||
return filtrarProyectos(proyectosDesdeFilas(filas), busqueda, incluirInactivos, 50)
|
// El buscador de cotizaciones (solo activos) no muestra proyectos con contrato firmado
|
||||||
|
const firmados = incluirInactivos ? {} : foliosConContratoFirmado_();
|
||||||
|
return filtrarProyectos(proyectosDesdeFilas(filas).filter(p => !firmados[p.folio.toUpperCase()]),
|
||||||
|
busqueda, incluirInactivos, 50)
|
||||||
.map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId,
|
.map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId,
|
||||||
ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }));
|
ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }));
|
||||||
}
|
}
|
||||||
@ -878,7 +900,7 @@ function crearCarpetaProyecto_(folio, datos, anio) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra", "Pagos"…). Se busca por nombre: si alguien la
|
* Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra"…). Se busca por nombre: si alguien la
|
||||||
* borró o la renombró, se crea una nueva. Si falta la carpeta del proyecto, se vuelve a crear y se actualiza
|
* borró o la renombró, se crea una nueva. Si falta la carpeta del proyecto, se vuelve a crear y se actualiza
|
||||||
* carpeta_id en la hoja.
|
* carpeta_id en la hoja.
|
||||||
*/
|
*/
|
||||||
@ -897,6 +919,507 @@ function subcarpetaProyecto_(proyecto, nombreSub) {
|
|||||||
return subcarpeta_(carpeta, nombreSub);
|
return subcarpeta_(carpeta, nombreSub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 4.2 COBRANZA (Fase 2) ======
|
||||||
|
// Libro aparte "Cotizador Helgen — Cobranza (datos)" dentro de la carpeta "Cotizador Helgen — Cobranza" (fuera de
|
||||||
|
// la carpeta maestra, que todo el dominio puede leer). Solo el rol de cobranza (ROL_COBRANZA) ve o cambia estos datos;
|
||||||
|
// el servidor lo revisa en cada llamada. Comprobantes en Cobranza/AAAA/<folio del proyecto>/.
|
||||||
|
|
||||||
|
const NOMBRE_CARPETA_COBRANZA = 'Cotizador Helgen — Cobranza';
|
||||||
|
const NOMBRE_LIBRO_COBRANZA = 'Cotizador Helgen — Cobranza (datos)';
|
||||||
|
const PREFIJO_PAGO = 'PAG';
|
||||||
|
|
||||||
|
/** Estructura del libro. numeros = columnas (1 = A) con formato de importe; las demás van como texto. */
|
||||||
|
const HOJAS_COBRANZA = {
|
||||||
|
Contratos: {
|
||||||
|
enc: ['proyecto_folio', 'cotizacion_aceptada', 'marca', 'moneda', 'subtotal', 'iva', 'total', 'estado',
|
||||||
|
'aceptada_por', 'fecha_aceptacion', 'fecha_firma', 'firmado_por', 'actualizado'],
|
||||||
|
numeros: [5, 6, 7],
|
||||||
|
},
|
||||||
|
Esquemas: { enc: ['proyecto_folio', 'orden', 'etapa', 'porcentaje', 'monto'], numeros: [2, 4, 5] },
|
||||||
|
Pagos: {
|
||||||
|
enc: ['folio', 'fecha_pago', 'fecha_registro', 'proyecto_folio', 'monto', 'moneda_pago', 'tipo_cambio',
|
||||||
|
'monto_aplicado', 'moneda_proyecto', 'metodo', 'referencia', 'etapa_orden', 'etapa', 'aplicacion (JSON)',
|
||||||
|
'comprobante_id', 'comprobante_url', 'registro_email', 'registro_nombre', 'estado', 'cancelado_por',
|
||||||
|
'fecha_cancelacion', 'motivo_cancelacion'],
|
||||||
|
numeros: [5, 7, 8, 12],
|
||||||
|
},
|
||||||
|
Bitacora: { enc: ['fecha', 'correo', 'accion', 'proyecto_folio', 'detalle', 'motivo'], numeros: [] },
|
||||||
|
};
|
||||||
|
const ESTADO_ACEPTADA = 'Aceptada', ESTADO_FIRMADO = 'Firmado';
|
||||||
|
|
||||||
|
/** Error de permisos: doPost lo manda con codigo 'PERMISO'. */
|
||||||
|
function errorPermiso_(mensaje) {
|
||||||
|
const e = new Error(mensaje);
|
||||||
|
e.codigo = 'PERMISO';
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ¿El usuario del token está en ROL_COBRANZA? Sin token (editor) o sin la propiedad: no. */
|
||||||
|
function esCobranza_() {
|
||||||
|
if (!VENDEDOR_) return false;
|
||||||
|
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA'), DOMINIO_HELGEN);
|
||||||
|
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ¿Puede desbloquear contratos firmados? (ADMIN_DESBLOQUEO; sin la propiedad, nadie). */
|
||||||
|
function esAdminDesbloqueo_() {
|
||||||
|
if (!VENDEDOR_) return false;
|
||||||
|
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ADMIN_DESBLOQUEO'), DOMINIO_HELGEN);
|
||||||
|
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exigirCobranza_() {
|
||||||
|
if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.');
|
||||||
|
if (!esCobranza_()) throw errorPermiso_('No tienes permiso de cobranza.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carpeta de cobranza. Se crea sola en Mi unidad (sin compartir). Si se borró, error: no se crea otra a escondidas. */
|
||||||
|
function carpetaCobranza_() {
|
||||||
|
const props = PropertiesService.getScriptProperties();
|
||||||
|
const id = props.getProperty('COBRANZA_FOLDER_ID');
|
||||||
|
if (!id) {
|
||||||
|
const carpeta = DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA);
|
||||||
|
props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId());
|
||||||
|
return carpeta;
|
||||||
|
}
|
||||||
|
let carpeta = null;
|
||||||
|
try { carpeta = DriveApp.getFolderById(id); } catch (e) { carpeta = null; }
|
||||||
|
if (!carpeta || carpeta.isTrashed()) {
|
||||||
|
throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.');
|
||||||
|
}
|
||||||
|
return carpeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */
|
||||||
|
function libroCobranza_() {
|
||||||
|
const props = PropertiesService.getScriptProperties();
|
||||||
|
if (!props.getProperty('COBRANZA_DB_ID')) {
|
||||||
|
const lock = LockService.getScriptLock();
|
||||||
|
if (!lock.tryLock(30000)) throw new Error('Se está preparando cobranza. Intenta de nuevo en unos segundos.');
|
||||||
|
try {
|
||||||
|
if (!props.getProperty('COBRANZA_DB_ID')) crearLibroCobranza_();
|
||||||
|
} finally {
|
||||||
|
lock.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abrirLibroCobranza_(props.getProperty('COBRANZA_DB_ID'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libro de cobranza si ya existe; null si todavía no se crea (las cotizaciones no lo crean). */
|
||||||
|
function libroCobranzaSiExiste_() {
|
||||||
|
const id = PropertiesService.getScriptProperties().getProperty('COBRANZA_DB_ID');
|
||||||
|
return id ? abrirLibroCobranza_(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirLibroCobranza_(id) {
|
||||||
|
try {
|
||||||
|
return SpreadsheetApp.openById(id);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('No se pudo abrir el libro de cobranza (COBRANZA_DB_ID). Revisa que no esté en la Papelera.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function crearLibroCobranza_() {
|
||||||
|
const carpeta = carpetaCobranza_();
|
||||||
|
const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA);
|
||||||
|
DriveApp.getFileById(ss.getId()).moveTo(carpeta);
|
||||||
|
Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre));
|
||||||
|
const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
|
||||||
|
if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante);
|
||||||
|
PropertiesService.getScriptProperties().setProperty('COBRANZA_DB_ID', ss.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pestaña del libro de cobranza; la crea o le repone los encabezados (patrón de hojaProyectos_). */
|
||||||
|
function hojaCobranza_(libro, nombre) {
|
||||||
|
const def = HOJAS_COBRANZA[nombre];
|
||||||
|
let hoja = libro.getSheetByName(nombre);
|
||||||
|
if (!hoja) {
|
||||||
|
hoja = libro.insertSheet(nombre);
|
||||||
|
hoja.setFrozenRows(1);
|
||||||
|
hoja.getRange(1, 1, hoja.getMaxRows(), def.enc.length).setNumberFormat('@'); // texto: nada se lee como fórmula
|
||||||
|
def.numeros.forEach(c => hoja.getRange(2, c, hoja.getMaxRows() - 1, 1).setNumberFormat('#,##0.00##'));
|
||||||
|
}
|
||||||
|
const actuales = hoja.getRange(1, 1, 1, def.enc.length).getValues()[0];
|
||||||
|
if (def.enc.some((h, i) => actuales[i] !== h)) {
|
||||||
|
hoja.getRange(1, 1, 1, def.enc.length).setValues([def.enc])
|
||||||
|
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
|
||||||
|
}
|
||||||
|
return hoja;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filas de datos (sin encabezado) de una pestaña, con el ancho de sus encabezados. */
|
||||||
|
function filasCobranza_(hoja, nombre) {
|
||||||
|
const n = HOJAS_COBRANZA[nombre].enc.length;
|
||||||
|
return hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, n).getValues() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const marcaDeTexto_ = t => (String(t || '').trim() === MARCAS.energy || t === 'energy') ? 'energy' : 'helgen';
|
||||||
|
|
||||||
|
function contratoDesdeFila_(r, fila) {
|
||||||
|
return {
|
||||||
|
proyectoFolio: String(r[0]), cotizacion: String(r[1]), marca: marcaDeTexto_(r[2]), moneda: String(r[3] || 'MXN'),
|
||||||
|
subtotal: Number(r[4]) || 0, iva: Number(r[5]) || 0, total: Number(r[6]) || 0, estado: String(r[7] || ''),
|
||||||
|
aceptadaPor: String(r[8] || ''), fechaAceptacion: fechaTexto_(r[9]), fechaFirma: String(r[10] || ''),
|
||||||
|
firmadoPor: String(r[11] || ''), fila: fila,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagoDesdeFila_(r, fila) {
|
||||||
|
return {
|
||||||
|
folio: String(r[0]), fecha: String(r[1] || ''), fechaRegistro: fechaTexto_(r[2]), proyectoFolio: String(r[3]),
|
||||||
|
monto: Number(r[4]) || 0, monedaPago: String(r[5] || ''), tipoCambio: r[6] === '' ? '' : Number(r[6]),
|
||||||
|
montoAplicado: Number(r[7]) || 0, monedaProyecto: String(r[8] || ''), metodo: String(r[9] || ''),
|
||||||
|
referencia: String(r[10] || ''), etapaOrden: Number(r[11]) || 1, etapa: String(r[12] || ''),
|
||||||
|
comprobanteUrl: String(r[15] || ''), registro: String(r[17] || r[16] || ''), estado: String(r[18] || 'Vigente'),
|
||||||
|
canceladoPor: String(r[19] || ''), motivoCancelacion: String(r[21] || ''), fila: fila,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fechaTexto_(v) {
|
||||||
|
return v instanceof Date ? Utilities.formatDate(v, 'America/Mexico_City', 'dd/MM/yyyy HH:mm') : String(v || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoyIso_ = () => Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
/** Todo lo de cobranza de un proyecto, leído del libro. */
|
||||||
|
function datosCobranza_(libro, folio) {
|
||||||
|
const f = String(folio).toUpperCase();
|
||||||
|
const hC = hojaCobranza_(libro, 'Contratos'), hE = hojaCobranza_(libro, 'Esquemas'), hP = hojaCobranza_(libro, 'Pagos');
|
||||||
|
let contrato = null;
|
||||||
|
filasCobranza_(hC, 'Contratos').forEach((r, i) => {
|
||||||
|
if (String(r[0]).toUpperCase() === f) contrato = contratoDesdeFila_(r, i + 2);
|
||||||
|
});
|
||||||
|
const etapas = filasCobranza_(hE, 'Esquemas')
|
||||||
|
.filter(r => String(r[0]).toUpperCase() === f)
|
||||||
|
.sort((a, b) => Number(a[1]) - Number(b[1]))
|
||||||
|
.map(r => ({ etapa: String(r[2]), porcentaje: Number(r[3]) }));
|
||||||
|
const pagos = [];
|
||||||
|
filasCobranza_(hP, 'Pagos').forEach((r, i) => {
|
||||||
|
if (String(r[3]).toUpperCase() === f) pagos.push(pagoDesdeFila_(r, i + 2));
|
||||||
|
});
|
||||||
|
return { hojas: { contratos: hC, esquemas: hE, pagos: hP }, contrato: contrato, etapas: etapas, pagos: pagos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {"PRY-2026-0001": true, …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */
|
||||||
|
function foliosConContratoFirmado_() {
|
||||||
|
const libro = libroCobranzaSiExiste_();
|
||||||
|
const mapa = {};
|
||||||
|
if (!libro) return mapa;
|
||||||
|
const hoja = libro.getSheetByName('Contratos');
|
||||||
|
if (!hoja) return mapa;
|
||||||
|
filasCobranza_(hoja, 'Contratos').forEach(r => {
|
||||||
|
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = true;
|
||||||
|
});
|
||||||
|
return mapa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cotizaciones ligadas a un proyecto (columna T), de la hoja Cotizaciones. */
|
||||||
|
function cotizacionesDeProyecto_(folio) {
|
||||||
|
const f = String(folio).toUpperCase();
|
||||||
|
const hoja = db_().getSheetByName('Cotizaciones');
|
||||||
|
return hoja.getDataRange().getValues().slice(1)
|
||||||
|
.filter(r => String(r[19] || '').toUpperCase() === f && String(r[0] || '').trim())
|
||||||
|
.map(r => ({
|
||||||
|
folio: String(r[0]), fecha: r[1] instanceof Date ? Utilities.formatDate(r[1], 'America/Mexico_City', 'dd/MM/yyyy') : String(r[1] || ''),
|
||||||
|
cliente: String(r[2] || ''), subtotal: Number(r[7]) || 0, iva: Number(r[8]) || 0, total: Number(r[9]) || 0,
|
||||||
|
pdf: String(r[11] || ''), marca: marcaDeTexto_(r[14]), moneda: String(r[20] || '') || 'MXN',
|
||||||
|
vendedor: vendedorDeFila(r, {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proyecto existente (o error). */
|
||||||
|
function proyectoParaCobranza_(folio) {
|
||||||
|
const proyecto = buscarProyecto_(hojaProyectos_(), folio);
|
||||||
|
if (!proyecto) throw new Error('No existe el proyecto ' + String(folio || '').toUpperCase() + '.');
|
||||||
|
return proyecto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitacora_(libro, accion, folio, detalle, motivo) {
|
||||||
|
const t = textoCelda;
|
||||||
|
hojaCobranza_(libro, 'Bitacora').appendRow([
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), t(VENDEDOR_ ? VENDEDOR_.email : ''),
|
||||||
|
t(accion), t(folio), t(detalle || ''), t(motivo || ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reescribe las etapas del proyecto en "Esquemas" (borra las anteriores) con sus montos. */
|
||||||
|
function escribirEsquema_(hoja, folio, etapas, total) {
|
||||||
|
const f = String(folio).toUpperCase();
|
||||||
|
const filas = filasCobranza_(hoja, 'Esquemas');
|
||||||
|
for (let i = filas.length - 1; i >= 0; i--) {
|
||||||
|
if (String(filas[i][0]).toUpperCase() === f) hoja.deleteRow(i + 2);
|
||||||
|
}
|
||||||
|
montosDeEsquema(etapas, total).forEach((e, i) => hoja.appendRow([f, i + 1, textoCelda(e.etapa), e.porcentaje, e.monto]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Respuesta para la pestaña Cobranza: proyecto, cotizaciones, contrato, esquema con avance y pagos. */
|
||||||
|
function vistaCobranza_(libro, proyecto) {
|
||||||
|
const d = datosCobranza_(libro, proyecto.folio);
|
||||||
|
const c = d.contrato;
|
||||||
|
const resumen = c && d.etapas.length ? resumenCobranza(c, d.etapas, d.pagos) : null;
|
||||||
|
return {
|
||||||
|
proyecto: { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado,
|
||||||
|
ubicacion: proyecto.ubicacion },
|
||||||
|
cotizaciones: cotizacionesDeProyecto_(proyecto.folio),
|
||||||
|
contrato: c ? { cotizacion: c.cotizacion, marca: c.marca, moneda: c.moneda, subtotal: c.subtotal, iva: c.iva,
|
||||||
|
total: c.total, estado: c.estado, aceptadaPor: c.aceptadaPor, fechaAceptacion: c.fechaAceptacion,
|
||||||
|
fechaFirma: c.fechaFirma, firmadoPor: c.firmadoPor } : null,
|
||||||
|
esquema: d.etapas,
|
||||||
|
resumen: resumen,
|
||||||
|
pagos: d.pagos.map(p => { const o = Object.assign({}, p); delete o.fila; return o; }).reverse(),
|
||||||
|
esquemaEditable: !!c && c.estado !== ESTADO_FIRMADO && c.marca !== 'energy',
|
||||||
|
puedePagar: !!c && c.estado === ESTADO_FIRMADO,
|
||||||
|
puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(),
|
||||||
|
metodos: METODOS_PAGO,
|
||||||
|
hoy: hoyIso_(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candado de script (el mismo de folios) alrededor de una función. */
|
||||||
|
function conCandado_(f) {
|
||||||
|
const lock = LockService.getScriptLock();
|
||||||
|
if (!lock.tryLock(30000)) throw new Error('El sistema está ocupado guardando otro registro. Intenta de nuevo en unos segundos.');
|
||||||
|
try {
|
||||||
|
return f();
|
||||||
|
} finally {
|
||||||
|
lock.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proyectos para el buscador de Cobranza (todos los estados), con el estado de su contrato. */
|
||||||
|
function listarProyectosCobranza(busqueda) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
const estados = {};
|
||||||
|
filasCobranza_(hojaCobranza_(libro, 'Contratos'), 'Contratos').forEach(r => { estados[String(r[0]).toUpperCase()] = String(r[7]); });
|
||||||
|
return listarProyectos(busqueda, true).map(p => Object.assign(p, { contrato: estados[p.folio.toUpperCase()] || '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCobranzaProyecto(folio) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
return vistaCobranza_(libro, proyectoParaCobranza_(folio));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca la cotización aceptada del proyecto (una sola; se puede cambiar hasta firmar). Copia sus montos, marca y
|
||||||
|
* moneda al contrato. Si la nueva es de Helgen Energy y el esquema actual no es el 60/30/10, pide confirmar
|
||||||
|
* (devuelve {confirmar, mensaje}) antes de reemplazarlo.
|
||||||
|
*/
|
||||||
|
function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const proyecto = proyectoParaCobranza_(proyectoFolio);
|
||||||
|
if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado.');
|
||||||
|
const folioCot = String(cotizacionFolio || '').trim().toUpperCase();
|
||||||
|
const cot = cotizacionesDeProyecto_(proyecto.folio).find(c => c.folio.toUpperCase() === folioCot);
|
||||||
|
if (!cot) throw new Error('La cotización ' + folioCot + ' no pertenece al proyecto ' + proyecto.folio + '.');
|
||||||
|
|
||||||
|
const d = datosCobranza_(libro, proyecto.folio);
|
||||||
|
const c = d.contrato;
|
||||||
|
if (c && c.estado === ESTADO_FIRMADO) throw new Error('Contrato firmado: la cotización aceptada ya no se puede cambiar.');
|
||||||
|
if (c && c.cotizacion === cot.folio) return vistaCobranza_(libro, proyecto);
|
||||||
|
const vigentes = d.pagos.filter(p => p.estado === 'Vigente');
|
||||||
|
if (vigentes.length && vigentes[0].monedaProyecto && vigentes[0].monedaProyecto !== cot.moneda) {
|
||||||
|
throw new Error('El proyecto ya tiene pagos en ' + vigentes[0].monedaProyecto + ': la cotización aceptada debe ser en ' +
|
||||||
|
vigentes[0].monedaProyecto + '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esquema: Helgen Energy siempre 60/30/10; Helgen conserva el que ya tenga (o el sugerido)
|
||||||
|
const porOmision = esquemaPorOmision();
|
||||||
|
const igualAlDeOmision = d.etapas.length === porOmision.length &&
|
||||||
|
d.etapas.every((e, i) => e.etapa === porOmision[i].etapa && e.porcentaje === porOmision[i].porcentaje);
|
||||||
|
if (cot.marca === 'energy' && d.etapas.length && !igualAlDeOmision && !confirmado) {
|
||||||
|
return { confirmar: true, mensaje: 'La cotización ' + cot.folio + ' es de Helgen Energy: su esquema de pagos es fijo ' +
|
||||||
|
'(60% Anticipo, 30% Instalación, 10% Encendido del sistema) y reemplazará el esquema actual. ¿Continuar?' };
|
||||||
|
}
|
||||||
|
const etapas = cot.marca === 'energy' || !d.etapas.length ? porOmision : d.etapas;
|
||||||
|
|
||||||
|
const t = textoCelda;
|
||||||
|
const fila = [proyecto.folio, cot.folio, MARCAS[cot.marca], cot.moneda, cot.subtotal, cot.iva, cot.total, ESTADO_ACEPTADA,
|
||||||
|
t(VENDEDOR_.email), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm'), '', '',
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')];
|
||||||
|
if (c) d.hojas.contratos.getRange(c.fila, 1, 1, fila.length).setValues([fila]);
|
||||||
|
else d.hojas.contratos.appendRow(fila);
|
||||||
|
escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, cot.total);
|
||||||
|
bitacora_(libro, c ? 'Cambiar cotización aceptada' : 'Marcar cotización aceptada', proyecto.folio,
|
||||||
|
cot.folio + ' · ' + MARCAS[cot.marca] + ' · total ' + formatoMoneda(cot.total) + ' ' + cot.moneda +
|
||||||
|
(c ? ' (antes ' + c.cotizacion + ')' : ''));
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCobranza_(libro, proyecto);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guarda el esquema de pagos (solo Helgen y solo antes de firmar). */
|
||||||
|
function guardarEsquema(proyectoFolio, etapas) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const limpio = validarEsquema(etapas);
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const proyecto = proyectoParaCobranza_(proyectoFolio);
|
||||||
|
const d = datosCobranza_(libro, proyecto.folio);
|
||||||
|
const c = d.contrato;
|
||||||
|
if (!c) throw new Error('Primero marca la cotización aceptada del proyecto.');
|
||||||
|
if (c.estado === ESTADO_FIRMADO) throw new Error('Contrato firmado: el esquema de pagos ya no se puede cambiar.');
|
||||||
|
if (c.marca === 'energy') throw new Error('El esquema de Helgen Energy es fijo (60% / 30% / 10%).');
|
||||||
|
escribirEsquema_(d.hojas.esquemas, proyecto.folio, limpio, c.total);
|
||||||
|
bitacora_(libro, 'Editar esquema de pagos', proyecto.folio, limpio.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · '));
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCobranza_(libro, proyecto);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contrato firmado (fecha AAAA-MM-DD): fija la aceptada, el monto contratado y el esquema. */
|
||||||
|
function firmarContrato(proyectoFolio, fecha) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const proyecto = proyectoParaCobranza_(proyectoFolio);
|
||||||
|
const fechaFirma = validarFechaIso(fecha, hoyIso_(), 'La fecha de firma');
|
||||||
|
const d = datosCobranza_(libro, proyecto.folio);
|
||||||
|
const c = d.contrato;
|
||||||
|
if (!c) throw new Error('Primero marca la cotización aceptada del proyecto.');
|
||||||
|
if (c.estado === ESTADO_FIRMADO) throw new Error('El contrato de ' + proyecto.folio + ' ya está firmado.');
|
||||||
|
if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado.');
|
||||||
|
const etapas = validarEsquema(d.etapas);
|
||||||
|
// Tras un desbloqueo puede haber pagos: el nuevo monto no puede quedar por debajo de lo pagado
|
||||||
|
const r = resumenCobranza(c, etapas, d.pagos);
|
||||||
|
if (r.saldo < -TOLERANCIA_PAGO) {
|
||||||
|
throw new Error('Lo pagado (' + formatoMoneda(r.pagado) + ' ' + c.moneda + ') supera el monto contratado (' +
|
||||||
|
formatoMoneda(r.contratado) + ').');
|
||||||
|
}
|
||||||
|
escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, c.total);
|
||||||
|
d.hojas.contratos.getRange(c.fila, 8, 1, 6).setValues([[ESTADO_FIRMADO, c.aceptadaPor, c.fechaAceptacion, fechaFirma,
|
||||||
|
textoCelda(VENDEDOR_.email), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]);
|
||||||
|
bitacora_(libro, 'Contrato firmado', proyecto.folio, 'Fecha de firma ' + fechaFirma + ' · ' + c.cotizacion + ' · ' +
|
||||||
|
formatoMoneda(c.total) + ' ' + c.moneda + ' · ' + etapas.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · '));
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCobranza_(libro, proyecto);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desbloqueo por error: solo ADMIN_DESBLOQUEO, con motivo. El contrato vuelve a "Aceptada"; los pagos se conservan. */
|
||||||
|
function desbloquearContrato(proyectoFolio, motivo) {
|
||||||
|
exigirCobranza_();
|
||||||
|
if (!esAdminDesbloqueo_()) throw errorPermiso_('Solo el administrador puede desbloquear un contrato firmado.');
|
||||||
|
const m = String(motivo || '').trim();
|
||||||
|
if (m.length < 5) throw new Error('Escribe el motivo del desbloqueo.');
|
||||||
|
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const proyecto = proyectoParaCobranza_(proyectoFolio);
|
||||||
|
const d = datosCobranza_(libro, proyecto.folio);
|
||||||
|
const c = d.contrato;
|
||||||
|
if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('El contrato de ' + proyecto.folio + ' no está firmado.');
|
||||||
|
d.hojas.contratos.getRange(c.fila, 8, 1, 6).setValues([[ESTADO_ACEPTADA, c.aceptadaPor, c.fechaAceptacion, '', '',
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]);
|
||||||
|
bitacora_(libro, 'Desbloquear contrato', proyecto.folio,
|
||||||
|
'Estaba firmado el ' + c.fechaFirma + ' por ' + c.firmadoPor + ' · ' + c.cotizacion, m);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCobranza_(libro, proyecto);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra un pago con su comprobante (obligatorio). p = {proyectoFolio, fecha, monto, monedaPago, tipoCambio, metodo,
|
||||||
|
* referencia, etapaOrden, comprobante: {datos (base64)}, idSolicitud}. Solo con contrato firmado; si el pago excede su
|
||||||
|
* etapa, el excedente pasa a la siguiente; si excede el saldo del proyecto (más la tolerancia), se rechaza.
|
||||||
|
*/
|
||||||
|
function registrarPago(p) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const d0 = p || {};
|
||||||
|
let bytes;
|
||||||
|
try {
|
||||||
|
bytes = d0.comprobante && d0.comprobante.datos ? Utilities.base64Decode(String(d0.comprobante.datos)) : null;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('No se pudo leer el comprobante. Vuelve a adjuntarlo.');
|
||||||
|
}
|
||||||
|
const tipo = validarComprobante(bytes);
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
|
||||||
|
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
|
||||||
|
const cache = CacheService.getScriptCache();
|
||||||
|
const claveCache = idSolicitud ? 'PAG_' + idSolicitud : '';
|
||||||
|
|
||||||
|
return conCandado_(() => {
|
||||||
|
if (claveCache) {
|
||||||
|
const previo = cache.get(claveCache);
|
||||||
|
if (previo) return JSON.parse(previo);
|
||||||
|
}
|
||||||
|
const proyecto = proyectoParaCobranza_(d0.proyectoFolio);
|
||||||
|
const d = datosCobranza_(libro, proyecto.folio);
|
||||||
|
const c = d.contrato;
|
||||||
|
if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('Solo se registran pagos en proyectos con contrato firmado.');
|
||||||
|
const pago = normalizarPago(d0, { moneda: c.moneda, etapas: d.etapas.length, hoy: hoyIso_() });
|
||||||
|
|
||||||
|
const resumen = resumenCobranza(c, d.etapas, d.pagos);
|
||||||
|
if (aCentavos(pago.montoAplicado) > aCentavos(resumen.saldo) + aCentavos(TOLERANCIA_PAGO)) {
|
||||||
|
throw new Error('El pago (' + formatoMoneda(pago.montoAplicado) + ' ' + c.moneda + ') excede el saldo del proyecto (' +
|
||||||
|
formatoMoneda(Math.max(resumen.saldo, 0)) + ' ' + c.moneda + ').');
|
||||||
|
}
|
||||||
|
const reparto = repartirPago(resumen.etapas, pago.etapaOrden - 1, pago.montoAplicado);
|
||||||
|
if (reparto.sobranteCts) reparto.aplicacion.push({ orden: pago.etapaOrden, centavos: reparto.sobranteCts });
|
||||||
|
const aplicacion = reparto.aplicacion.map(a => ({ etapa: d.etapas[a.orden - 1].etapa, orden: a.orden, monto: a.centavos / 100 }));
|
||||||
|
|
||||||
|
const hojaPagos = d.hojas.pagos;
|
||||||
|
const folio = siguienteFolio_(PREFIJO_PAGO, hojaPagos, 'CONSECUTIVO_PAG_');
|
||||||
|
const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4);
|
||||||
|
const carpeta = subcarpeta_(subcarpeta_(carpetaCobranza_(), anio), proyecto.folio);
|
||||||
|
const archivo = carpeta.createFile(Utilities.newBlob(bytes, tipo.mime,
|
||||||
|
nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext)));
|
||||||
|
|
||||||
|
const t = textoCelda;
|
||||||
|
hojaPagos.appendRow([
|
||||||
|
folio, pago.fecha, Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), proyecto.folio,
|
||||||
|
pago.monto, pago.monedaPago, pago.tipoCambio, pago.montoAplicado, c.moneda, pago.metodo, t(pago.referencia),
|
||||||
|
pago.etapaOrden, t(d.etapas[pago.etapaOrden - 1].etapa), JSON.stringify(aplicacion), archivo.getId(), archivo.getUrl(),
|
||||||
|
t(VENDEDOR_.email), t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ')), 'Vigente', '', '', '',
|
||||||
|
]);
|
||||||
|
bitacora_(libro, 'Registrar pago', proyecto.folio, folio + ' · ' + formatoMoneda(pago.monto) + ' ' + pago.monedaPago +
|
||||||
|
(pago.tipoCambio ? ' · TC ' + pago.tipoCambio + ' = ' + formatoMoneda(pago.montoAplicado) + ' ' + c.moneda : '') +
|
||||||
|
' · ' + pago.metodo + ' · ' + aplicacion.map(a => a.etapa + ' ' + formatoMoneda(a.monto)).join(' + '));
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
|
||||||
|
const resultado = { folio: folio, url: archivo.getUrl(), vista: vistaCobranza_(libro, proyecto) };
|
||||||
|
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||||
|
return resultado;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancela un pago (no se borra): rol de cobranza, con motivo; queda en la Bitácora. */
|
||||||
|
function cancelarPago(folioPago, motivo) {
|
||||||
|
exigirCobranza_();
|
||||||
|
const m = String(motivo || '').trim();
|
||||||
|
if (m.length < 5) throw new Error('Escribe el motivo de la cancelación.');
|
||||||
|
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const folio = String(folioPago || '').trim().toUpperCase();
|
||||||
|
const hoja = hojaCobranza_(libro, 'Pagos');
|
||||||
|
let pago = null;
|
||||||
|
filasCobranza_(hoja, 'Pagos').forEach((r, i) => { if (String(r[0]).toUpperCase() === folio) pago = pagoDesdeFila_(r, i + 2); });
|
||||||
|
if (!pago) throw new Error('No existe el pago ' + folio + '.');
|
||||||
|
if (pago.estado !== 'Vigente') throw new Error('El pago ' + folio + ' ya está cancelado.');
|
||||||
|
hoja.getRange(pago.fila, 19, 1, 4).setValues([['Cancelado', textoCelda(VENDEDOR_.email),
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
|
||||||
|
bitacora_(libro, 'Cancelar pago', pago.proyectoFolio, folio + ' · ' + formatoMoneda(pago.monto) + ' ' + pago.monedaPago, m);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCobranza_(libro, proyectoParaCobranza_(pago.proyectoFolio));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correr UNA vez desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad, sin compartir) y su
|
||||||
|
* libro. También sirve para autorizar permisos antes de publicar (§6.8). No comparte nada.
|
||||||
|
*/
|
||||||
|
function prepararCobranza() {
|
||||||
|
const libro = libroCobranza_();
|
||||||
|
Logger.log('Carpeta de cobranza: ' + carpetaCobranza_().getUrl());
|
||||||
|
Logger.log('Libro de cobranza: ' + libro.getUrl());
|
||||||
|
Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)'));
|
||||||
|
}
|
||||||
|
|
||||||
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
|
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
|
||||||
|
|
||||||
function redondear(n) {
|
function redondear(n) {
|
||||||
@ -936,6 +1459,7 @@ function normalizarCotizacion(p, ivaTasa) {
|
|||||||
cliente: cliente, items: items, subtotal: subtotal, iva: iva, total: total, ivaTasa: ivaTasa,
|
cliente: cliente, items: items, subtotal: subtotal, iva: iva, total: total, ivaTasa: ivaTasa,
|
||||||
notas: String((p && p.notas) || '').trim(),
|
notas: String((p && p.notas) || '').trim(),
|
||||||
marca: p && p.marca === 'energy' ? 'energy' : 'helgen',
|
marca: p && p.marca === 'energy' ? 'energy' : 'helgen',
|
||||||
|
moneda: monedaDeCotizacion(p && p.moneda),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1079,6 +1603,224 @@ function filtrarProyectos(lista, busqueda, incluirInactivos, n) {
|
|||||||
.slice(0, n || 50);
|
.slice(0, n || 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Monedas (MXN / USD) ----
|
||||||
|
// Cada cotización lleva su moneda (columna U); nunca se suman importes de monedas distintas.
|
||||||
|
|
||||||
|
const MONEDAS = ['MXN', 'USD'];
|
||||||
|
|
||||||
|
/** Moneda de una cotización nueva: vacía = MXN (como antes); otra que no sea MXN/USD es error. */
|
||||||
|
function monedaDeCotizacion(m) {
|
||||||
|
const s = String(m == null ? '' : m).trim().toUpperCase();
|
||||||
|
if (!s) return 'MXN';
|
||||||
|
if (MONEDAS.indexOf(s) === -1) throw new Error('Moneda no válida: ' + s + '. Usa MXN o USD.');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cobranza: esquema de pagos, reparto, comprobantes y roles ----
|
||||||
|
|
||||||
|
const ESQUEMA_POR_OMISION = [['Anticipo', 60], ['Instalación', 30], ['Encendido del sistema', 10]];
|
||||||
|
const MAX_ETAPAS = 10;
|
||||||
|
const METODOS_PAGO = ['Transferencia', 'Efectivo', 'Cheque'];
|
||||||
|
const TOLERANCIA_PAGO = 1.00; // en la moneda del proyecto (diferencias de redondeo)
|
||||||
|
const MAX_COMPROBANTE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
/** Etapas por omisión: Helgen Energy 60/30/10 (fijo); Helgen, las mismas como sugerencia editable. */
|
||||||
|
function esquemaPorOmision() {
|
||||||
|
return ESQUEMA_POR_OMISION.map(([etapa, porcentaje]) => ({ etapa: etapa, porcentaje: porcentaje }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pesos/dólares -> centavos enteros (evita errores de punto flotante al sumar). */
|
||||||
|
function aCentavos(n) {
|
||||||
|
return Math.round(redondear(n) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un esquema [{etapa, porcentaje}]: de 1 a MAX_ETAPAS etapas, nombres únicos (máx. 40 caracteres),
|
||||||
|
* porcentajes mayores a 0 con 2 decimales y suma exacta de 100%. Devuelve el esquema limpio.
|
||||||
|
*/
|
||||||
|
function validarEsquema(etapas) {
|
||||||
|
if (!Array.isArray(etapas) || !etapas.length) throw new Error('El esquema de pagos necesita al menos una etapa.');
|
||||||
|
if (etapas.length > MAX_ETAPAS) throw new Error('El esquema de pagos admite máximo ' + MAX_ETAPAS + ' etapas.');
|
||||||
|
const vistos = {};
|
||||||
|
const limpio = etapas.map((e, i) => {
|
||||||
|
const etapa = String((e && e.etapa) || '').trim().replace(/\s+/g, ' ');
|
||||||
|
const txt = e && e.porcentaje;
|
||||||
|
const porcentaje = Number(txt);
|
||||||
|
if (!etapa) throw new Error('La etapa ' + (i + 1) + ' no tiene nombre.');
|
||||||
|
if (etapa.length > 40) throw new Error('El nombre de la etapa "' + etapa.slice(0, 20) + '…" debe tener máximo 40 caracteres.');
|
||||||
|
const llave = textoNormal(etapa);
|
||||||
|
if (vistos[llave]) throw new Error('La etapa "' + etapa + '" está repetida.');
|
||||||
|
vistos[llave] = true;
|
||||||
|
if (txt === '' || txt == null || !isFinite(porcentaje) || porcentaje <= 0) {
|
||||||
|
throw new Error('El porcentaje de "' + etapa + '" debe ser mayor a 0.');
|
||||||
|
}
|
||||||
|
if (Math.abs(redondear(porcentaje) - porcentaje) > 1e-9) {
|
||||||
|
throw new Error('El porcentaje de "' + etapa + '" admite máximo 2 decimales.');
|
||||||
|
}
|
||||||
|
return { etapa: etapa, porcentaje: redondear(porcentaje) };
|
||||||
|
});
|
||||||
|
const suma = limpio.reduce((s, e) => s + Math.round(e.porcentaje * 100), 0);
|
||||||
|
if (suma !== 10000) {
|
||||||
|
throw new Error('Los porcentajes deben sumar 100% (suman ' + redondear(suma / 100) + '%).');
|
||||||
|
}
|
||||||
|
return limpio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monto de cada etapa = total × %, redondeado; la última etapa se queda con la diferencia de centavos. */
|
||||||
|
function montosDeEsquema(etapas, total) {
|
||||||
|
const totalCts = aCentavos(total);
|
||||||
|
let acumulado = 0;
|
||||||
|
return etapas.map((e, i) => {
|
||||||
|
const cts = i === etapas.length - 1 ? totalCts - acumulado : Math.round(totalCts * e.porcentaje / 100);
|
||||||
|
acumulado += cts;
|
||||||
|
return { etapa: e.etapa, porcentaje: e.porcentaje, monto: cts / 100 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte el monto de un pago a la moneda del proyecto. Si las monedas son distintas, el tipo de cambio
|
||||||
|
* (pesos por dólar) es obligatorio: MXN -> USD divide, USD -> MXN multiplica.
|
||||||
|
*/
|
||||||
|
function convertirMoneda(monto, monedaPago, monedaProyecto, tipoCambio) {
|
||||||
|
if (monedaPago === monedaProyecto) return redondear(monto);
|
||||||
|
const tc = Number(tipoCambio);
|
||||||
|
if (!isFinite(tc) || tc <= 0) throw new Error('Captura el tipo de cambio del día (pesos por dólar).');
|
||||||
|
if (monedaPago === 'MXN' && monedaProyecto === 'USD') return redondear(monto / tc);
|
||||||
|
if (monedaPago === 'USD' && monedaProyecto === 'MXN') return redondear(monto * tc);
|
||||||
|
throw new Error('Moneda no válida.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reparte un monto entre las etapas: primero la elegida; el excedente pasa a la siguiente con saldo y, al llegar
|
||||||
|
* a la última, a las anteriores en orden. etapas = [{monto, pagado}]. Devuelve {aplicacion: [{orden, centavos}],
|
||||||
|
* sobranteCts} sin modificar las etapas.
|
||||||
|
*/
|
||||||
|
function repartirPago(etapas, indice, monto) {
|
||||||
|
const n = etapas.length;
|
||||||
|
let restante = aCentavos(monto);
|
||||||
|
const aplicacion = [];
|
||||||
|
for (let k = 0; k < n && restante > 0; k++) {
|
||||||
|
const i = (indice + k) % n;
|
||||||
|
const saldo = aCentavos(etapas[i].monto) - aCentavos(etapas[i].pagado || 0);
|
||||||
|
if (saldo <= 0) continue;
|
||||||
|
const toma = Math.min(saldo, restante);
|
||||||
|
aplicacion.push({ orden: i + 1, centavos: toma });
|
||||||
|
restante -= toma;
|
||||||
|
}
|
||||||
|
return { aplicacion: aplicacion, sobranteCts: restante };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de cobranza de un proyecto. contrato = {total, subtotal, moneda}; etapas = [{etapa, porcentaje}];
|
||||||
|
* pagos = [{estado, etapaOrden, montoAplicado}] en orden de registro (solo cuentan los Vigentes, se vuelven
|
||||||
|
* a repartir en ese orden). Lo que no cabe en ninguna etapa (dentro de la tolerancia) se suma a la etapa elegida.
|
||||||
|
*/
|
||||||
|
function resumenCobranza(contrato, etapas, pagos) {
|
||||||
|
const lista = montosDeEsquema(etapas, contrato.total).map(e => Object.assign(e, { pagado: 0 }));
|
||||||
|
let pagadoCts = 0;
|
||||||
|
(pagos || []).filter(p => p.estado === 'Vigente').forEach(p => {
|
||||||
|
const indice = Math.min(Math.max((Number(p.etapaOrden) || 1) - 1, 0), lista.length - 1);
|
||||||
|
const r = repartirPago(lista, indice, p.montoAplicado);
|
||||||
|
r.aplicacion.forEach(a => { lista[a.orden - 1].pagado = (aCentavos(lista[a.orden - 1].pagado) + a.centavos) / 100; });
|
||||||
|
if (r.sobranteCts) lista[indice].pagado = (aCentavos(lista[indice].pagado) + r.sobranteCts) / 100;
|
||||||
|
pagadoCts += aCentavos(p.montoAplicado);
|
||||||
|
});
|
||||||
|
lista.forEach(e => {
|
||||||
|
e.saldo = (aCentavos(e.monto) - aCentavos(e.pagado)) / 100;
|
||||||
|
e.avance = e.monto > 0 ? Math.min(100, Math.round(e.pagado / e.monto * 1000) / 10) : 0;
|
||||||
|
});
|
||||||
|
const contratadoCts = aCentavos(contrato.total);
|
||||||
|
return {
|
||||||
|
moneda: contrato.moneda, contratado: contratadoCts / 100, subtotal: redondear(contrato.subtotal),
|
||||||
|
pagado: pagadoCts / 100, saldo: (contratadoCts - pagadoCts) / 100,
|
||||||
|
avance: contratadoCts > 0 ? Math.min(100, Math.round(pagadoCts / contratadoCts * 1000) / 10) : 0,
|
||||||
|
etapas: lista,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fecha "AAAA-MM-DD" válida, no futura (hoy = "AAAA-MM-DD") y no anterior a 2020. */
|
||||||
|
function validarFechaIso(s, hoy, campo) {
|
||||||
|
const v = String(s || '').trim();
|
||||||
|
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
const nombre = campo || 'La fecha';
|
||||||
|
if (!m) throw new Error(nombre + ' es obligatoria (AAAA-MM-DD).');
|
||||||
|
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
||||||
|
if (d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) {
|
||||||
|
throw new Error(nombre + ' no es válida.');
|
||||||
|
}
|
||||||
|
if (v < '2020-01-01') throw new Error(nombre + ' no es válida.');
|
||||||
|
if (hoy && v > hoy) throw new Error(nombre + ' no puede ser futura.');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida y limpia un pago. proyecto = {moneda, etapas (número de etapas), hoy "AAAA-MM-DD"}.
|
||||||
|
* Devuelve {fecha, monto, monedaPago, tipoCambio, montoAplicado, metodo, referencia, etapaOrden}.
|
||||||
|
*/
|
||||||
|
function normalizarPago(p, proyecto) {
|
||||||
|
const d = p || {};
|
||||||
|
const fecha = validarFechaIso(d.fecha, proyecto.hoy, 'La fecha del pago');
|
||||||
|
const montoTxt = d.monto, monto = Number(montoTxt);
|
||||||
|
if (montoTxt === '' || montoTxt == null || !isFinite(monto) || monto <= 0) throw new Error('El monto del pago debe ser mayor a 0.');
|
||||||
|
if (Math.abs(redondear(monto) - monto) > 1e-9) throw new Error('El monto admite máximo 2 decimales.');
|
||||||
|
if (monto > 1e10) throw new Error('El monto del pago no es válido.');
|
||||||
|
const monedaPago = String(d.monedaPago || proyecto.moneda).trim().toUpperCase();
|
||||||
|
if (MONEDAS.indexOf(monedaPago) === -1) throw new Error('Moneda del pago no válida. Usa MXN o USD.');
|
||||||
|
const metodo = METODOS_PAGO.find(m => textoNormal(m) === textoNormal(d.metodo));
|
||||||
|
if (!metodo) throw new Error('Elige el método de pago: ' + METODOS_PAGO.join(', ') + '.');
|
||||||
|
const referencia = String(d.referencia || '').trim().replace(/\s+/g, ' ');
|
||||||
|
if (!referencia && metodo !== 'Efectivo') {
|
||||||
|
throw new Error('Captura la referencia (' + (metodo === 'Cheque' ? 'número de cheque' : 'clave de rastreo o folio') + ').');
|
||||||
|
}
|
||||||
|
if (referencia.length > 100) throw new Error('La referencia debe tener máximo 100 caracteres.');
|
||||||
|
const etapaOrden = Number(d.etapaOrden);
|
||||||
|
if (!Number.isInteger(etapaOrden) || etapaOrden < 1 || etapaOrden > proyecto.etapas) throw new Error('Elige la etapa del pago.');
|
||||||
|
let tipoCambio = '';
|
||||||
|
if (monedaPago !== proyecto.moneda) {
|
||||||
|
tipoCambio = Number(d.tipoCambio);
|
||||||
|
if (d.tipoCambio === '' || d.tipoCambio == null || !isFinite(tipoCambio) || tipoCambio <= 0 || tipoCambio > 1000) {
|
||||||
|
throw new Error('El proyecto es en ' + proyecto.moneda + ' y el pago en ' + monedaPago +
|
||||||
|
': captura el tipo de cambio del día (pesos por dólar).');
|
||||||
|
}
|
||||||
|
tipoCambio = Math.round(tipoCambio * 10000) / 10000;
|
||||||
|
}
|
||||||
|
return { fecha: fecha, monto: redondear(monto), monedaPago: monedaPago, tipoCambio: tipoCambio,
|
||||||
|
montoAplicado: convertirMoneda(monto, monedaPago, proyecto.moneda, tipoCambio),
|
||||||
|
metodo: metodo, referencia: referencia, etapaOrden: etapaOrden };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo real de un comprobante por sus primeros bytes (no por la extensión). bytes = arreglo de enteros; en Apps
|
||||||
|
* Script vienen con signo (-128…127). Acepta PDF, JPG, PNG y HEIC; máximo MAX_COMPROBANTE. Devuelve {mime, ext}.
|
||||||
|
*/
|
||||||
|
function validarComprobante(bytes) {
|
||||||
|
if (!bytes || !bytes.length) throw new Error('Adjunta el comprobante de pago (PDF o imagen).');
|
||||||
|
if (bytes.length > MAX_COMPROBANTE) throw new Error('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.');
|
||||||
|
const b = i => (bytes[i] || 0) & 0xFF;
|
||||||
|
const txt = (desde, n) => String.fromCharCode.apply(null, Array.from({ length: n }, (_, i) => b(desde + i)));
|
||||||
|
if (txt(0, 5) === '%PDF-') return { mime: 'application/pdf', ext: 'pdf' };
|
||||||
|
if (b(0) === 0xFF && b(1) === 0xD8 && b(2) === 0xFF) return { mime: 'image/jpeg', ext: 'jpg' };
|
||||||
|
if (b(0) === 0x89 && txt(1, 3) === 'PNG') return { mime: 'image/png', ext: 'png' };
|
||||||
|
if (txt(4, 4) === 'ftyp' && ['heic', 'heix', 'hevc', 'heim', 'heis', 'mif1', 'msf1'].indexOf(txt(8, 4)) !== -1) {
|
||||||
|
return { mime: 'image/heic', ext: 'heic' };
|
||||||
|
}
|
||||||
|
throw new Error('El comprobante debe ser PDF o imagen (JPG, PNG o HEIC).');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Correos de una propiedad ("a@helgen.tech, b@helgen.tech"), en minúsculas; solo del dominio. */
|
||||||
|
function correosDeLista(texto, dominio) {
|
||||||
|
return String(texto || '').split(/[\s,;]+/).map(s => s.trim().toLowerCase())
|
||||||
|
.filter(s => s && s.endsWith('@' + dominio) && s.indexOf('@') > 0)
|
||||||
|
.filter((s, i, a) => a.indexOf(s) === i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre del archivo del comprobante: "PAG-2026-0001 — <método> <referencia>.pdf", sin caracteres raros. */
|
||||||
|
function nombreComprobante(folio, metodo, referencia, ext) {
|
||||||
|
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return (limpio(folio) + ' — ' + [limpio(metodo), limpio(referencia).slice(0, 60)].filter(Boolean).join(' ')).slice(0, 140) +
|
||||||
|
'.' + ext;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ----
|
// ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ----
|
||||||
|
|
||||||
const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' +
|
const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' +
|
||||||
@ -1341,8 +2083,11 @@ function escaparHtml(s) {
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 1234.5 -> "MIL DOSCIENTOS TREINTA Y CUATRO PESOS 50/100 M.N." */
|
/**
|
||||||
function importeConLetra(n) {
|
* 1234.5 -> "MIL DOSCIENTOS TREINTA Y CUATRO PESOS 50/100 M.N."
|
||||||
|
* En USD: "MIL DOSCIENTOS TREINTA Y CUATRO DÓLARES 50/100 USD".
|
||||||
|
*/
|
||||||
|
function importeConLetra(n, moneda) {
|
||||||
const UNI = ['', 'UN', 'DOS', 'TRES', 'CUATRO', 'CINCO', 'SEIS', 'SIETE', 'OCHO', 'NUEVE', 'DIEZ',
|
const UNI = ['', 'UN', 'DOS', 'TRES', 'CUATRO', 'CINCO', 'SEIS', 'SIETE', 'OCHO', 'NUEVE', 'DIEZ',
|
||||||
'ONCE', 'DOCE', 'TRECE', 'CATORCE', 'QUINCE', 'DIECISÉIS', 'DIECISIETE', 'DIECIOCHO', 'DIECINUEVE', 'VEINTE',
|
'ONCE', 'DOCE', 'TRECE', 'CATORCE', 'QUINCE', 'DIECISÉIS', 'DIECISIETE', 'DIECIOCHO', 'DIECINUEVE', 'VEINTE',
|
||||||
'VEINTIÚN', 'VEINTIDÓS', 'VEINTITRÉS', 'VEINTICUATRO', 'VEINTICINCO', 'VEINTISÉIS', 'VEINTISIETE', 'VEINTIOCHO', 'VEINTINUEVE'];
|
'VEINTIÚN', 'VEINTIDÓS', 'VEINTITRÉS', 'VEINTICUATRO', 'VEINTICINCO', 'VEINTISÉIS', 'VEINTISIETE', 'VEINTIOCHO', 'VEINTINUEVE'];
|
||||||
@ -1376,7 +2121,9 @@ function importeConLetra(n) {
|
|||||||
const cent = Math.round((v - ent) * 100);
|
const cent = Math.round((v - ent) * 100);
|
||||||
const base = entero(ent);
|
const base = entero(ent);
|
||||||
const de = (ent >= 1e6 && ent % 1e6 === 0) ? ' DE' : '';
|
const de = (ent >= 1e6 && ent % 1e6 === 0) ? ' DE' : '';
|
||||||
return base + de + (ent === 1 ? ' PESO ' : ' PESOS ') + String(cent).padStart(2, '0') + '/100 M.N.';
|
const cts = String(cent).padStart(2, '0') + '/100';
|
||||||
|
if (moneda === 'USD') return base + de + (ent === 1 ? ' DÓLAR ' : ' DÓLARES ') + cts + ' USD';
|
||||||
|
return base + de + (ent === 1 ? ' PESO ' : ' PESOS ') + cts + ' M.N.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1434,6 +2181,7 @@ function construirHtmlPdf(d, cfg, logoUrl) {
|
|||||||
'<span style="color:' + AZUL + ';font-weight:bold;letter-spacing:1px;border:1.5px solid ' + TEAL + ';padding:2px 8px;">HELGEN ENERGY</span></td></tr>'
|
'<span style="color:' + AZUL + ';font-weight:bold;letter-spacing:1px;border:1.5px solid ' + TEAL + ';padding:2px 8px;">HELGEN ENERGY</span></td></tr>'
|
||||||
: '') +
|
: '') +
|
||||||
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Vigencia', d.vigencia) +
|
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Vigencia', d.vigencia) +
|
||||||
|
(d.moneda === 'USD' ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
|
||||||
'</table></td></tr></table>' +
|
'</table></td></tr></table>' +
|
||||||
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
|
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
|
||||||
'<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' +
|
'<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' +
|
||||||
@ -1461,13 +2209,13 @@ function construirHtmlPdf(d, cfg, logoUrl) {
|
|||||||
'<table><tr>' +
|
'<table><tr>' +
|
||||||
'<td style="width:58%;padding-left:0;">' +
|
'<td style="width:58%;padding-left:0;">' +
|
||||||
'<div style="color:' + GRIS + ';">Importe con letra</div>' +
|
'<div style="color:' + GRIS + ';">Importe con letra</div>' +
|
||||||
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total) + ')</div>' +
|
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total, d.moneda) + ')</div>' +
|
||||||
(d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') +
|
(d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') +
|
||||||
'</td><td style="width:42%;padding-right:0;"><table>' +
|
'</td><td style="width:42%;padding-right:0;"><table>' +
|
||||||
'<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' +
|
'<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' +
|
||||||
'<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' +
|
'<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' +
|
||||||
'<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' +
|
'<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' +
|
||||||
'<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(cfg.MONEDA || 'MXN') + '</td>' +
|
'<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(d.moneda || 'MXN') + '</td>' +
|
||||||
'<td class="r" style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">' + formatoMoneda(d.total) + '</td></tr>' +
|
'<td class="r" style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">' + formatoMoneda(d.total) + '</td></tr>' +
|
||||||
'</table></td></tr></table>' +
|
'</table></td></tr></table>' +
|
||||||
|
|
||||||
|
|||||||
296
tests/cobranza.test.js
Normal file
296
tests/cobranza.test.js
Normal 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');
|
||||||
@ -71,4 +71,25 @@ ctx.guardarCotizacion({cliente:{nombre:'Luis',empresa:'Solar MX',direccion:'Av.
|
|||||||
let lc=ctx.getClientes(); const luis=lc.find(c=>c.nombre==='Luis');
|
let lc=ctx.getClientes(); const luis=lc.find(c=>c.nombre==='Luis');
|
||||||
assert(luis&&luis.direccion==='Av. 5'&&luis.empresa==='Solar MX'); assert.equal(lc[0].nombre,'Luis','más reciente primero');
|
assert(luis&&luis.direccion==='Av. 5'&&luis.empresa==='Solar MX'); assert.equal(lc[0].nombre,'Luis','más reciente primero');
|
||||||
cot.m.splice(1); assert.equal(ctx.getClientes().length,0,'sin filas = sin sugerencias');
|
cot.m.splice(1); assert.equal(ctx.getClientes().length,0,'sin filas = sin sugerencias');
|
||||||
|
// Moneda: USD en etiqueta, total e importe con letra; MXN igual que antes; columna U
|
||||||
|
if (ctx.monedaDeCotizacion) {
|
||||||
|
assert.equal(ctx.importeConLetra(1160.02),'MIL CIENTO SESENTA PESOS 02/100 M.N.');
|
||||||
|
assert.equal(ctx.importeConLetra(1160.02,'USD'),'MIL CIENTO SESENTA DÓLARES 02/100 USD');
|
||||||
|
assert.equal(ctx.importeConLetra(1,'USD'),'UN DÓLAR 00/100 USD');
|
||||||
|
assert.equal(ctx.importeConLetra(2000000.5,'USD'),'DOS MILLONES DE DÓLARES 50/100 USD');
|
||||||
|
assert.equal(ctx.monedaDeCotizacion(''),'MXN'); assert.equal(ctx.monedaDeCotizacion('usd'),'USD');
|
||||||
|
assert.throws(()=>ctx.monedaDeCotizacion('EUR'),/Moneda no válida/);
|
||||||
|
assert.throws(()=>ctx.guardarCotizacion({...base,moneda:'EUR',idSolicitud:'eur'}),/Moneda no válida/);
|
||||||
|
const n=pdfs.length;
|
||||||
|
const rU=ctx.guardarCotizacion({cliente:{nombre:'Solar Corp',empresa:'Solar Corp LLC',direccion:'Austin, TX'},moneda:'USD',marca:'energy',
|
||||||
|
items:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:12,precio:180},{descripcion:'Inversor 8 kW',cantidad:1,precio:1450.5}],
|
||||||
|
notas:'Precios en dólares estadounidenses.',idSolicitud:'usd1'});
|
||||||
|
assert.equal(rU.moneda,'USD');
|
||||||
|
const u=pdfs[n].html, fila=cot.m[cot.m.length-1];
|
||||||
|
assert.equal(fila[20],'USD'); assert.equal(cot.m[0][20],'moneda');
|
||||||
|
assert(u.includes('TOTAL USD')&&!u.includes('TOTAL MXN')&&u.includes('USD (dólares estadounidenses)'));
|
||||||
|
assert(u.includes('DÓLARES')&&u.includes('/100 USD)')&&!u.includes('PESOS'));
|
||||||
|
assert(h.includes('TOTAL MXN')&&h.includes('M.N.')&&!h.includes('dólares'),'MXN sin cambios');
|
||||||
|
fs.writeFileSync(process.argv[3]+'/usd.html',u);
|
||||||
|
}
|
||||||
console.log('OK');
|
console.log('OK');
|
||||||
|
|||||||
@ -1,50 +1,59 @@
|
|||||||
// Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados.
|
// Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados.
|
||||||
// Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa.
|
// Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa;
|
||||||
|
// moneda USD (el catálogo no pone precio); pestaña Cobranza solo con permiso; aceptar con confirmación; pago con comprobante.
|
||||||
// Uso: node tests/interfaz.test.js . (carpeta del proyecto)
|
// Uso: node tests/interfaz.test.js . (carpeta del proyecto)
|
||||||
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
||||||
const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8');
|
const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8');
|
||||||
const scripts=[...html.matchAll(/<script(?: id="([^"]*)")?>([\s\S]*?)<\/script>/g)].map(m=>({id:m[1],src:m[2]}));
|
const scripts=[...html.matchAll(/<script(?: id="([^"]*)")?>([\s\S]*?)<\/script>/g)].map(m=>({id:m[1],src:m[2]}));
|
||||||
const apiWeb=scripts.find(s=>s.id==='api-web').src, app=scripts.find(s=>!s.id).src;
|
const apiWeb=scripts.find(s=>s.id==='api-web').src, app=scripts.find(s=>!s.id).src;
|
||||||
|
|
||||||
// ---- DOM simulado (solo lo que usa la app) ----
|
// ---- Sesión vigente (token falso; quien valida es el servidor) ----
|
||||||
function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',disabled:false,hijos:[],oyentes:{},
|
const b64=o=>Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||||
|
const token='h.'+b64({email:'ana@helgen.tech',given_name:'Ana',exp:Math.floor(Date.now()/1000)+3600})+'.f';
|
||||||
|
const PRY={folio:'PRY-2026-0001',nombre:'Techo',cliente:'Luis — Solar MX',clienteId:'LUSM800101AB1',ubicacion:'Qro',estado:'Activo',fecha:'20/10/2026'};
|
||||||
|
const CAT=[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',precio:4100}];
|
||||||
|
|
||||||
|
/** App cargada con un DOM y un servidor simulados nuevos. */
|
||||||
|
function montar(datosIniciales){
|
||||||
|
function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{},
|
||||||
_clases:new Set(),porClase:{},
|
_clases:new Set(),porClase:{},
|
||||||
classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)},
|
classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)},
|
||||||
addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f),
|
addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f),
|
||||||
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
|
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
|
||||||
focus(){},remove(){},appendChild:h=>e.hijos.push(h),
|
focus(){},remove(){},appendChild:h=>e.hijos.push(h),
|
||||||
querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')),
|
querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')),
|
||||||
querySelectorAll:sel=>sel==='tr'?e.hijos:[]};return e}
|
querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]};return e}
|
||||||
const porId={};
|
const porId={};
|
||||||
const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
|
const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
|
||||||
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
|
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
|
||||||
document.getElementById('login').classList.add('oculto');
|
document.getElementById('login').classList.add('oculto');
|
||||||
|
document.getElementById('tabCob').classList.add('oculto'); // como en el HTML
|
||||||
// ---- Sesión vigente (token falso; quien valida es el servidor) ----
|
|
||||||
const b64=o=>Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
||||||
const token='h.'+b64({email:'ana@helgen.tech',given_name:'Ana',exp:Math.floor(Date.now()/1000)+3600})+'.f';
|
|
||||||
const almacen={cotizadorHelgenSesion:token};
|
const almacen={cotizadorHelgenSesion:token};
|
||||||
|
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}};
|
||||||
// ---- Servidor simulado ----
|
|
||||||
const PRY={folio:'PRY-2026-0001',nombre:'Techo',cliente:'Luis — Solar MX',clienteId:'LUSM800101AB1',ubicacion:'Qro',estado:'Activo',fecha:'20/10/2026'};
|
|
||||||
const llamadas=[]; let respuestas={};
|
|
||||||
async function fetch(url,opts){const c=JSON.parse(opts.body);llamadas.push(c);
|
async function fetch(url,opts){const c=JSON.parse(opts.body);llamadas.push(c);
|
||||||
const r=respuestas[c.funcion];const datos=typeof r==='function'?r(c):r;
|
const r=respuestas[c.funcion];const datos=typeof r==='function'?r(c):r;
|
||||||
return{ok:true,json:async()=>datos}}
|
return{ok:true,json:async()=>datos||{ok:false,error:'sin respuesta simulada: '+c.funcion}}}
|
||||||
respuestas.getDatosIniciales={ok:true,datos:{ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[PRY],marcas:{}}};
|
const dialogos={confirmar:true,prompt:null,vistos:[]};
|
||||||
|
class FileReader{readAsDataURL(f){this.result='data:'+f.type+';base64,'+f.b64;setImmediate(()=>this.onload())}}
|
||||||
const ctx={document,fetch,console,JSON,Date,Math,Number,String,Array,Object,Promise,Proxy,Error,Uint8Array,TextDecoder,
|
const ctx={document,fetch,console,JSON,Date,Math,Number,String,Array,Object,Promise,Proxy,Error,Uint8Array,TextDecoder,FileReader,
|
||||||
atob:s=>Buffer.from(s,'base64').toString('binary'),crypto:{randomUUID:()=>'id-'+Math.random()},
|
atob:s=>Buffer.from(s,'base64').toString('binary'),crypto:{randomUUID:()=>'id-'+Math.random()},
|
||||||
sessionStorage:{getItem:k=>almacen[k]??null,setItem:(k,v)=>almacen[k]=v,removeItem:k=>delete almacen[k]},
|
sessionStorage:{getItem:k=>almacen[k]??null,setItem:(k,v)=>almacen[k]=v,removeItem:k=>delete almacen[k]},
|
||||||
|
confirm:m=>{dialogos.vistos.push(m);return dialogos.confirmar},prompt:m=>{dialogos.vistos.push(m);return dialogos.prompt},
|
||||||
setTimeout,clearTimeout,scrollTo(){}};
|
setTimeout,clearTimeout,scrollTo(){}};
|
||||||
ctx.window=ctx;
|
ctx.window=ctx;
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx);
|
vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx);
|
||||||
const $=id=>document.getElementById(id), esperar=()=>new Promise(r=>setImmediate(r));
|
return {ctx,$:id=>document.getElementById(id),llamadas,respuestas,dialogos,run:c=>vm.runInContext(c,ctx)};
|
||||||
|
}
|
||||||
|
const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmediate(r))};
|
||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
for(let i=0;i<5;i++) await esperar();
|
// ===== Vendedora (sin rol de cobranza) =====
|
||||||
assert.equal(vm.runInContext('PROYECTOS.length',ctx),1,'carga los proyectos activos');
|
const A=montar({ivaTasa:0.16,catalogo:CAT,clientes:[],proyectos:[PRY],marcas:{},permisos:{cobranza:false,desbloqueo:false}});
|
||||||
|
const {$,llamadas,respuestas}=A;
|
||||||
|
await esperar(5);
|
||||||
|
assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos');
|
||||||
|
assert($('tabCob').classList.contains('oculto'),'sin permiso no ve la pestaña Cobranza');
|
||||||
|
|
||||||
// Elegir el proyecto
|
// Elegir el proyecto
|
||||||
$('cNombre').value='Luis';
|
$('cNombre').value='Luis';
|
||||||
@ -54,28 +63,118 @@ const $=id=>document.getElementById(id), esperar=()=>new Promise(r=>setImmediate
|
|||||||
// El servidor lo rechaza (se canceló mientras tanto): codigo PROYECTO
|
// El servidor lo rechaza (se canceló mientras tanto): codigo PROYECTO
|
||||||
respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'El proyecto PRY-2026-0001 está cancelado: ya no se le pueden agregar cotizaciones.'};
|
respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'El proyecto PRY-2026-0001 está cancelado: ya no se le pueden agregar cotizaciones.'};
|
||||||
respuestas.listarProyectos={ok:true,datos:[]};
|
respuestas.listarProyectos={ok:true,datos:[]};
|
||||||
vm.runInContext('guardar()',ctx);
|
A.run('guardar()');
|
||||||
for(let i=0;i<10;i++) await esperar();
|
await esperar();
|
||||||
const g=llamadas.find(c=>c.funcion==='guardarCotizacion');
|
const g=llamadas.find(c=>c.funcion==='guardarCotizacion');
|
||||||
assert.equal(g.args[0].proyectoFolio,'PRY-2026-0001'); assert.equal(g.token,token);
|
assert.equal(g.args[0].proyectoFolio,'PRY-2026-0001'); assert.equal(g.token,token); assert.equal(g.args[0].moneda,'MXN');
|
||||||
assert.equal($('proyecto').value,'','se limpia el campo Proyecto');
|
assert.equal($('proyecto').value,'','se limpia el campo Proyecto');
|
||||||
assert(/^Sin proyecto/.test($('proyInfo').textContent),'la etiqueta ya no dice "Proyecto activo": '+$('proyInfo').textContent);
|
assert(/^Sin proyecto/.test($('proyInfo').textContent),'la etiqueta ya no dice "Proyecto activo": '+$('proyInfo').textContent);
|
||||||
assert(llamadas.some(c=>c.funcion==='listarProyectos'),'se refresca la lista de proyectos');
|
assert(llamadas.some(c=>c.funcion==='listarProyectos'),'se refresca la lista de proyectos');
|
||||||
assert.equal(vm.runInContext('PROYECTOS.length',ctx),0); assert.equal($('proyectosList').innerHTML,'');
|
assert.equal(A.run('PROYECTOS.length'),0); assert.equal($('proyectosList').innerHTML,'');
|
||||||
assert.equal($('aviso').className,'aviso error');
|
assert.equal($('aviso').className,'aviso error');
|
||||||
assert(/está cancelado/.test($('aviso').innerHTML)&&/Se quitó el proyecto/.test($('aviso').innerHTML));
|
assert(/está cancelado/.test($('aviso').innerHTML)&&/Se quitó el proyecto/.test($('aviso').innerHTML));
|
||||||
assert.equal($('btnGuardar').disabled,false,'se puede volver a guardar');
|
assert.equal($('btnGuardar').disabled,false,'se puede volver a guardar');
|
||||||
|
|
||||||
|
// Contrato firmado: mismo trato (codigo PROYECTO)
|
||||||
|
A.run("recibirProyectos(["+JSON.stringify(PRY)+"])");
|
||||||
|
$('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change');
|
||||||
|
respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'Proyecto con contrato firmado: no admite cotizaciones nuevas.'};
|
||||||
|
A.run('guardar()'); await esperar();
|
||||||
|
assert.equal($('proyecto').value,''); assert(/contrato firmado: no admite/.test($('aviso').innerHTML));
|
||||||
|
|
||||||
// Otro error (no de proyecto): el campo se queda como estaba
|
// Otro error (no de proyecto): el campo se queda como estaba
|
||||||
respuestas.listarProyectos={ok:true,datos:[PRY]}; vm.runInContext("recibirProyectos([PRY_])".replace('PRY_',JSON.stringify(PRY)),ctx);
|
respuestas.listarProyectos={ok:true,datos:[PRY]}; A.run("recibirProyectos(["+JSON.stringify(PRY)+"])");
|
||||||
$('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change');
|
$('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change');
|
||||||
respuestas.guardarCotizacion={ok:false,error:'El sistema está ocupado guardando otra cotización.'};
|
respuestas.guardarCotizacion={ok:false,error:'El sistema está ocupado guardando otra cotización.'};
|
||||||
const antes=llamadas.filter(c=>c.funcion==='listarProyectos').length;
|
const antes=llamadas.filter(c=>c.funcion==='listarProyectos').length;
|
||||||
vm.runInContext('guardar()',ctx);
|
A.run('guardar()'); await esperar();
|
||||||
for(let i=0;i<10;i++) await esperar();
|
|
||||||
assert.equal($('proyecto').value,'PRY-2026-0001 — Luis — Solar MX — Techo');
|
assert.equal($('proyecto').value,'PRY-2026-0001 — Luis — Solar MX — Techo');
|
||||||
assert(/^Proyecto activo/.test($('proyInfo').textContent));
|
assert(/^Proyecto activo/.test($('proyInfo').textContent));
|
||||||
assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes);
|
assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes);
|
||||||
assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML));
|
assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML));
|
||||||
|
|
||||||
|
// Moneda: en MXN el catálogo pone el precio; en USD lo deja vacío para capturarlo a mano
|
||||||
|
const fila=$('filas').hijos[0];
|
||||||
|
fila.querySelector('.desc').value='Panel solar 550 W'; fila.querySelector('.desc').disparar('change');
|
||||||
|
assert.equal(fila.querySelector('.pu').value,4100);
|
||||||
|
$('moneda').value='USD'; A.run('cambiarMoneda()');
|
||||||
|
assert(/captura los precios en dólares/.test($('notaMoneda').innerHTML)&&/Revisa los precios/.test($('notaMoneda').innerHTML));
|
||||||
|
assert.equal($('lblTot').textContent,'TOTAL USD');
|
||||||
|
fila.querySelector('.desc').disparar('change');
|
||||||
|
assert.equal(fila.querySelector('.pu').value,'','en USD el catálogo no pone precio'); assert.equal(fila.querySelector('.clave').value,'PAN-550');
|
||||||
|
fila.querySelector('.cant').value='1'; fila.querySelector('.pu').value='180'; A.run('recalcular()');
|
||||||
|
assert.equal($('tTot').textContent,'$208.80 USD');
|
||||||
|
respuestas.guardarCotizacion={ok:true,datos:{folio:'HLG-2026-0009',url:'u',total:208.8,moneda:'USD',proyectoFolio:''}};
|
||||||
|
respuestas.getClientes={ok:true,datos:[]};
|
||||||
|
$('proyecto').value=''; A.run('guardar()'); await esperar();
|
||||||
|
assert.equal(llamadas.filter(c=>c.funcion==='guardarCotizacion').pop().args[0].moneda,'USD');
|
||||||
|
assert(/\$208\.80 USD/.test($('aviso').innerHTML)); assert.equal($('moneda').value,'MXN','al limpiar vuelve a MXN');
|
||||||
|
|
||||||
|
// ===== Cobranza (con permiso) =====
|
||||||
|
const B=montar({ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[],marcas:{},permisos:{cobranza:true,desbloqueo:false}});
|
||||||
|
await esperar(5);
|
||||||
|
assert(!B.$('tabCob').classList.contains('oculto'),'con permiso ve la pestaña');
|
||||||
|
B.respuestas.listarProyectosCobranza={ok:true,datos:[{...PRY,contrato:''}]};
|
||||||
|
B.run("verTab('cob')"); await esperar();
|
||||||
|
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML));
|
||||||
|
const cots=[{folio:'HLG-2026-0010',fecha:'20/10/2026',marca:'helgen',moneda:'MXN',subtotal:1000,total:1160,pdf:'p'},
|
||||||
|
{folio:'HLG-2026-0011',fecha:'20/10/2026',marca:'energy',moneda:'MXN',subtotal:2000,total:2320,pdf:'p'}];
|
||||||
|
const vista=(extra)=>({proyecto:PRY,cotizaciones:cots,contrato:null,esquema:[],resumen:null,pagos:[],esquemaEditable:false,
|
||||||
|
puedePagar:false,puedeDesbloquear:false,metodos:['Transferencia','Efectivo','Cheque'],hoy:'2026-10-20',...extra});
|
||||||
|
B.respuestas.getCobranzaProyecto={ok:true,datos:vista()};
|
||||||
|
B.$('cobProyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; B.$('cobProyecto').disparar('change'); await esperar();
|
||||||
|
assert.equal(B.llamadas.find(c=>c.funcion==='getCobranzaProyecto').args[0],'PRY-2026-0001');
|
||||||
|
assert((B.$('cobCots').innerHTML.match(/Marcar aceptada/g)||[]).length===2);
|
||||||
|
assert(B.$('cobModEsquema').classList.contains('oculto')&&B.$('cobModPagos').classList.contains('oculto'),'sin aceptada no hay esquema ni pagos');
|
||||||
|
|
||||||
|
// Aceptar la de Energy: el servidor pide confirmar; si se cancela no se repite
|
||||||
|
B.respuestas.marcarAceptada=c=>c.args[2]?{ok:true,datos:vista({contrato:{cotizacion:'HLG-2026-0011',marca:'energy',moneda:'MXN',subtotal:2000,iva:320,total:2320,estado:'Aceptada'},
|
||||||
|
esquema:[{etapa:'Anticipo',porcentaje:60},{etapa:'Instalación',porcentaje:30},{etapa:'Encendido del sistema',porcentaje:10}]})}
|
||||||
|
:{ok:true,datos:{confirmar:true,mensaje:'Reemplazará el esquema actual. ¿Continuar?'}};
|
||||||
|
B.dialogos.confirmar=false; B.run("aceptarCot('HLG-2026-0011')"); await esperar();
|
||||||
|
assert.equal(B.llamadas.filter(c=>c.funcion==='marcarAceptada').length,1); assert(/Reemplazará/.test(B.dialogos.vistos.pop()));
|
||||||
|
B.dialogos.confirmar=true; B.run("aceptarCot('HLG-2026-0011')"); await esperar();
|
||||||
|
const ma=B.llamadas.filter(c=>c.funcion==='marcarAceptada');
|
||||||
|
assert.equal(ma.length,3); assert.deepEqual(ma[2].args,['PRY-2026-0001','HLG-2026-0011',true]);
|
||||||
|
assert(/Energy: esquema fijo/.test(B.$('cobEsqNota').textContent)&&!/input/.test(B.$('cobEsquema').innerHTML),'Energy no editable');
|
||||||
|
assert(/\$1,392\.00 MXN/.test(B.$('cobEsquema').innerHTML),'monto 60% de 2320');
|
||||||
|
assert(/Contrato firmado/.test(B.$('cobContrato').innerHTML)&&B.$('cobFormPago').classList.contains('oculto'));
|
||||||
|
|
||||||
|
// Firmado: formulario de pago; sin comprobante no se envía
|
||||||
|
const res={moneda:'MXN',contratado:2320,subtotal:2000,pagado:0,saldo:2320,avance:0,
|
||||||
|
etapas:[{etapa:'Anticipo',porcentaje:60,monto:1392,pagado:0,saldo:1392,avance:0},{etapa:'Instalación',porcentaje:30,monto:696,pagado:0,saldo:696,avance:0},
|
||||||
|
{etapa:'Encendido del sistema',porcentaje:10,monto:232,pagado:0,saldo:232,avance:0}]};
|
||||||
|
const firmada=vista({contrato:{cotizacion:'HLG-2026-0011',marca:'energy',moneda:'MXN',subtotal:2000,iva:320,total:2320,estado:'Firmado',fechaFirma:'2026-10-20',firmadoPor:'tania@helgen.tech'},
|
||||||
|
esquema:res.etapas,resumen:res,puedePagar:true});
|
||||||
|
B.run('pintarCobranza('+JSON.stringify(firmada)+')');
|
||||||
|
assert(!B.$('cobFormPago').classList.contains('oculto')); assert(!/Desbloquear/.test(B.$('cobContrato').innerHTML),'Tania no ve desbloquear');
|
||||||
|
assert(!/Marcar aceptada/.test(B.$('cobCots').innerHTML),'firmado: ya no se cambia la aceptada');
|
||||||
|
assert(/Transferencia/.test(B.$('pgMetodo').innerHTML)&&/saldo \$1,392\.00 MXN/.test(B.$('pgEtapa').innerHTML));
|
||||||
|
B.$('pgMonto').value='800'; B.$('pgRef').value='SPEI 1'; B.$('pgMetodo').value='Transferencia';
|
||||||
|
B.$('pgArchivo').files=[]; B.run('registrarPago()'); await esperar();
|
||||||
|
assert(/Adjunta el comprobante/.test(B.$('aviso').innerHTML)); assert(!B.llamadas.some(c=>c.funcion==='registrarPago'));
|
||||||
|
B.$('pgArchivo').files=[{name:'x.pdf',type:'application/pdf',size:11*1024*1024,b64:'JVBERi0='}]; B.run('registrarPago()'); await esperar();
|
||||||
|
assert(/más de 10 MB/.test(B.$('aviso').innerHTML));
|
||||||
|
// Moneda distinta: aparece el tipo de cambio
|
||||||
|
B.$('pgMoneda').value='USD'; B.run('pintarTipoCambio()'); assert(!B.$('pgTcCaja').classList.contains('oculto'));
|
||||||
|
B.$('pgMoneda').value='MXN'; B.run('pintarTipoCambio()'); assert(B.$('pgTcCaja').classList.contains('oculto'));
|
||||||
|
B.respuestas.registrarPago={ok:true,datos:{folio:'PAG-2026-0001',url:'https://drive/c',vista:firmada}};
|
||||||
|
B.$('pgArchivo').files=[{name:'comprobante.pdf',type:'application/pdf',size:1200,b64:'JVBERi0='}];
|
||||||
|
const idAntes=B.run('ID_PAGO'); B.run('registrarPago()'); await esperar();
|
||||||
|
const rp=B.llamadas.find(c=>c.funcion==='registrarPago').args[0];
|
||||||
|
assert.deepEqual([rp.proyectoFolio,rp.monto,rp.monedaPago,rp.metodo,rp.referencia,rp.etapaOrden,rp.comprobante.datos,rp.idSolicitud],
|
||||||
|
['PRY-2026-0001','800','MXN','Transferencia','SPEI 1',1,'JVBERi0=',idAntes]);
|
||||||
|
assert(/PAG-2026-0001/.test(B.$('aviso').innerHTML)&&B.run('ID_PAGO')!==idAntes,'id nuevo para el siguiente pago');
|
||||||
|
// Cancelar pago: pide motivo; sin motivo no llama
|
||||||
|
B.dialogos.prompt=''; B.run("cancelarPagoCob('PAG-2026-0001')"); await esperar();
|
||||||
|
assert(!B.llamadas.some(c=>c.funcion==='cancelarPago'));
|
||||||
|
// Permiso revocado mientras tanto: el servidor responde PERMISO y se muestra el error
|
||||||
|
B.respuestas.cancelarPago={ok:false,codigo:'PERMISO',error:'No tienes permiso de cobranza.'};
|
||||||
|
B.dialogos.prompt='Se capturó dos veces'; B.run("cancelarPagoCob('PAG-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(B.llamadas.find(c=>c.funcion==='cancelarPago').args,['PAG-2026-0001','Se capturó dos veces']);
|
||||||
|
assert(/No tienes permiso de cobranza/.test(B.$('aviso').innerHTML));
|
||||||
|
// Administrador: ve el desbloqueo
|
||||||
|
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
|
||||||
|
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));
|
||||||
console.log('OK interfaz');
|
console.log('OK interfaz');
|
||||||
})().catch(e=>{console.error(e);process.exit(1)});
|
})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|||||||
@ -74,10 +74,10 @@ const f1=pry.m[1];
|
|||||||
assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']);
|
assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']);
|
||||||
assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
|
assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
|
||||||
assert(!JSON.stringify(pry.m).includes('impostor'));
|
assert(!JSON.stringify(pry.m).includes('impostor'));
|
||||||
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ con 3 subcarpetas
|
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ con 2 subcarpetas (Pagos ya no: va en cobranza)
|
||||||
const c1=carpetas[f1[7]];
|
const c1=carpetas[f1[7]];
|
||||||
assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta');
|
assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta');
|
||||||
assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra','Pagos']);
|
assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra']);
|
||||||
assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1');
|
assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1');
|
||||||
assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
|
assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
|
||||||
// Idempotencia
|
// Idempotencia
|
||||||
@ -105,9 +105,9 @@ assert.equal(r1.folio,'HLG-2026-0005'); assert.equal(r1.proyectoFolio,'PRY-2026-
|
|||||||
const subCot=c1.hijos.find(h=>h.nombre==='Cotizaciones');
|
const subCot=c1.hijos.find(h=>h.nombre==='Cotizaciones');
|
||||||
assert.deepEqual(subCot.archivos.map(a=>a.name),['HLG-2026-0005 - Solar MX.pdf'],'PDF en Cotizaciones del proyecto');
|
assert.deepEqual(subCot.archivos.map(a=>a.name),['HLG-2026-0005 - Solar MX.pdf'],'PDF en Cotizaciones del proyecto');
|
||||||
// Encabezados: solo cambia la fila 1; la fila vieja no se mueve y queda vacía en T
|
// Encabezados: solo cambia la fila 1; la fila vieja no se mueve y queda vacía en T
|
||||||
assert.equal(cot.m[0].length,20); assert.equal(cot.m[0][19],'proyecto_folio'); assert.deepEqual(cot.m[0].slice(0,19),ENC19);
|
assert.equal(cot.m[0].length,21); assert.equal(cot.m[0][19],'proyecto_folio'); assert.equal(cot.m[0][20],'moneda'); assert.deepEqual(cot.m[0].slice(0,19),ENC19);
|
||||||
assert.deepEqual(cot.m[1],filaVieja,'fila vieja intacta');
|
assert.deepEqual(cot.m[1],filaVieja,'fila vieja intacta');
|
||||||
assert.equal(cot.m[2][19],'PRY-2026-0001');
|
assert.equal(cot.m[2][19],'PRY-2026-0001'); assert.equal(cot.m[2][20],'MXN');
|
||||||
// Sin proyecto: carpeta del año, columna T vacía
|
// Sin proyecto: carpeta del año, columna T vacía
|
||||||
ctx.guardarCotizacion({...base,idSolicitud:'c2'});
|
ctx.guardarCotizacion({...base,idSolicitud:'c2'});
|
||||||
assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],'');
|
assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],'');
|
||||||
@ -135,7 +135,7 @@ c1.trashed=true;
|
|||||||
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c7'});
|
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c7'});
|
||||||
const c1b=carpetas[pry.m[1][7]];
|
const c1b=carpetas[pry.m[1][7]];
|
||||||
assert(c1b!==c1&&!c1b.trashed&&c1b.hijos.find(h=>h.nombre==='Cotizaciones').archivos.length===1);
|
assert(c1b!==c1&&!c1b.trashed&&c1b.hijos.find(h=>h.nombre==='Cotizaciones').archivos.length===1);
|
||||||
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra','Pagos']);
|
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra']);
|
||||||
|
|
||||||
// --- Fuera de doPost (sin vendedor) no se crean proyectos ---
|
// --- Fuera de doPost (sin vendedor) no se crean proyectos ---
|
||||||
vm.runInContext('VENDEDOR_ = null',ctx);
|
vm.runInContext('VENDEDOR_ = null',ctx);
|
||||||
|
|||||||
@ -71,10 +71,10 @@ fetches=0;
|
|||||||
const r=post({funcion:'guardarCotizacion',token:'t-bueno',
|
const r=post({funcion:'guardarCotizacion',token:'t-bueno',
|
||||||
args:[{...base,idSolicitud:'ok1',vendedor_email:'impostor@helgen.tech',vendedor:{email:'impostor@helgen.tech',nombre:'X'},elaboro:'impostor@helgen.tech'}]});
|
args:[{...base,idSolicitud:'ok1',vendedor_email:'impostor@helgen.tech',vendedor:{email:'impostor@helgen.tech',nombre:'X'},elaboro:'impostor@helgen.tech'}]});
|
||||||
assert(r.ok,r.error); assert.equal(r.datos.folio,'HLG-2026-0002');
|
assert(r.ok,r.error); assert.equal(r.datos.folio,'HLG-2026-0002');
|
||||||
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido','proyecto_folio']);
|
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido','proyecto_folio','moneda']);
|
||||||
const fila=cot.m[cot.m.length-1];
|
const fila=cot.m[cot.m.length-1];
|
||||||
assert.equal(fila[10],'ana.lopez@helgen.tech','Elaboró = correo del token');
|
assert.equal(fila[10],'ana.lopez@helgen.tech','Elaboró = correo del token');
|
||||||
assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz',''],'sin proyecto = columna T vacía');
|
assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz','','MXN'],'sin proyecto = columna T vacía; sin moneda = MXN');
|
||||||
assert(!JSON.stringify(fila).includes('impostor'));
|
assert(!JSON.stringify(fila).includes('impostor'));
|
||||||
// Caché: el token ya se verificó (en la llamada a setup); no se vuelve a consultar a Google
|
// Caché: el token ya se verificó (en la llamada a setup); no se vuelve a consultar a Google
|
||||||
assert.equal(post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).ok,true); assert.equal(fetches,0);
|
assert.equal(post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).ok,true); assert.equal(fetches,0);
|
||||||
@ -102,6 +102,7 @@ const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos;
|
|||||||
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
|
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
|
||||||
assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
|
assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
|
||||||
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
|
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
|
||||||
|
assert.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false},'sin ROL_COBRANZA nadie tiene cobranza');
|
||||||
assert.equal(h[1].proyecto,'Sin proyecto');
|
assert.equal(h[1].proyecto,'Sin proyecto');
|
||||||
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
|
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
|
||||||
// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión
|
// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión
|
||||||
|
|||||||
@ -84,6 +84,20 @@
|
|||||||
#gisBoton { display:flex; justify-content:center; min-height:44px; }
|
#gisBoton { display:flex; justify-content:center; min-height:44px; }
|
||||||
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
|
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
|
||||||
#loginMsg:empty { display:none; }
|
#loginMsg:empty { display:none; }
|
||||||
|
/* Cobranza */
|
||||||
|
.chip { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; border:1px solid var(--borde); }
|
||||||
|
.chip.firmado { background:#E6FBF7; border-color:var(--teal); color:var(--azul); }
|
||||||
|
.chip.aceptada { background:#FFF7E6; border-color:#F5B544; color:#7A4B00; }
|
||||||
|
.chip.cancelado { background:#FEE2E2; border-color:#DC2626; color:#991B1B; }
|
||||||
|
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:14px; }
|
||||||
|
.tile { border:1px solid var(--borde); border-radius:8px; padding:10px 12px; }
|
||||||
|
.tile span { display:block; font-size:11px; color:var(--gris); text-transform:uppercase; }
|
||||||
|
.tile b { font-size:16px; color:var(--azul); }
|
||||||
|
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
|
||||||
|
.barra i { display:block; height:100%; background:var(--teal); }
|
||||||
|
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
|
||||||
|
.fila-cancelada td:last-child { text-decoration:none; }
|
||||||
|
.aviso-moneda { color:#B45309; font-weight:bold; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -94,6 +108,7 @@
|
|||||||
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
|
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
|
||||||
<button id="tabHist" onclick="verTab('hist')">Historial</button>
|
<button id="tabHist" onclick="verTab('hist')">Historial</button>
|
||||||
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
|
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
|
||||||
|
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="usuario" class="usuario oculto">
|
<div id="usuario" class="usuario oculto">
|
||||||
<span id="usuarioNombre"></span>
|
<span id="usuarioNombre"></span>
|
||||||
@ -127,6 +142,11 @@
|
|||||||
<option value="helgen">Helgen</option>
|
<option value="helgen">Helgen</option>
|
||||||
<option value="energy">Helgen Energy (paneles solares)</option>
|
<option value="energy">Helgen Energy (paneles solares)</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
|
<div><label>Moneda *</label>
|
||||||
|
<select id="moneda" onchange="cambiarMoneda()">
|
||||||
|
<option value="MXN">MXN · pesos mexicanos</option>
|
||||||
|
<option value="USD">USD · dólares estadounidenses</option>
|
||||||
|
</select></div>
|
||||||
<div><label>Nombre / Contacto *</label>
|
<div><label>Nombre / Contacto *</label>
|
||||||
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
|
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
|
||||||
<datalist id="clientesList"></datalist>
|
<datalist id="clientesList"></datalist>
|
||||||
@ -175,6 +195,7 @@
|
|||||||
<tbody id="filas"></tbody>
|
<tbody id="filas"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
|
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
|
||||||
|
<div id="notaMoneda" class="nota" style="margin-top:8px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modulo">
|
<div class="modulo">
|
||||||
@ -187,7 +208,7 @@
|
|||||||
<div class="totales">
|
<div class="totales">
|
||||||
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
|
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
|
||||||
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
|
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
|
||||||
<div class="total"><span>TOTAL</span><span id="tTot">$0.00</span></div>
|
<div class="total"><span id="lblTot">TOTAL MXN</span><span id="tTot">$0.00</span></div>
|
||||||
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
|
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -247,6 +268,65 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== COBRANZA (solo rol de cobranza; el servidor lo vuelve a revisar en cada llamada) ===== -->
|
||||||
|
<section id="vistaCob" class="oculto">
|
||||||
|
<div class="modulo">
|
||||||
|
<h2>1 · Proyecto y contrato</h2>
|
||||||
|
<div class="proy-fila">
|
||||||
|
<div><label>Proyecto</label>
|
||||||
|
<input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
|
||||||
|
<datalist id="cobProyectosList"></datalist></div>
|
||||||
|
<button type="button" class="btn btn-sec" onclick="cargarProyectosCob()">Actualizar lista</button>
|
||||||
|
</div>
|
||||||
|
<div id="cobInfo" class="nota" style="margin-top:6px">Elige un proyecto para ver su contrato, esquema y pagos.</div>
|
||||||
|
<div id="cobDetalle" class="oculto">
|
||||||
|
<table style="margin-top:12px">
|
||||||
|
<thead><tr><th>Cotización</th><th>Fecha</th><th>Línea</th><th>Moneda</th><th class="num">Subtotal</th><th class="num">Total con IVA</th><th>PDF</th><th></th></tr></thead>
|
||||||
|
<tbody id="cobCots"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="cobContrato" class="sat"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cobModEsquema" class="modulo oculto">
|
||||||
|
<h2>2 · Esquema de pagos</h2>
|
||||||
|
<div id="cobEsqNota" class="nota" style="margin-bottom:8px"></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:30px">#</th><th>Etapa</th><th class="num" style="width:110px">%</th><th class="num">Monto</th>
|
||||||
|
<th class="num">Pagado</th><th class="num">Saldo</th><th style="width:120px">Avance</th><th style="width:30px"></th></tr></thead>
|
||||||
|
<tbody id="cobEsquema"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="cobEsqAcciones" class="acciones"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cobModPagos" class="modulo oculto">
|
||||||
|
<h2>3 · Pagos</h2>
|
||||||
|
<div id="cobResumen" class="tiles"></div>
|
||||||
|
<div id="cobFormPago" class="sat oculto">
|
||||||
|
<div class="sat-titulo">Registrar pago · el comprobante es obligatorio (PDF o imagen, máx. 10 MB)</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Fecha del pago *</label><input id="pgFecha" type="date"></div>
|
||||||
|
<div><label>Monto *</label><input id="pgMonto" type="number" min="0" step="0.01" style="text-align:right"></div>
|
||||||
|
<div><label>Moneda del pago *</label><select id="pgMoneda" onchange="pintarTipoCambio()"><option>MXN</option><option>USD</option></select></div>
|
||||||
|
<div id="pgTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label><input id="pgTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
|
||||||
|
<div><label>Método *</label><select id="pgMetodo"></select></div>
|
||||||
|
<div><label>Referencia</label><input id="pgRef" maxlength="100" placeholder="Clave de rastreo, folio o núm. de cheque"></div>
|
||||||
|
<div><label>Etapa *</label><select id="pgEtapa"></select></div>
|
||||||
|
<div><label>Comprobante *</label><input id="pgArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.pdf,.jpg,.jpeg,.png,.heic"></div>
|
||||||
|
</div>
|
||||||
|
<div class="acciones">
|
||||||
|
<button type="button" id="btnPago" class="btn" onclick="registrarPago()">Registrar pago</button>
|
||||||
|
<span class="nota">Si el pago excede su etapa, el excedente pasa a la siguiente.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cobSinPagos" class="nota"></div>
|
||||||
|
<table style="margin-top:12px">
|
||||||
|
<thead><tr><th>Folio</th><th>Fecha</th><th class="num">Monto</th><th class="num">Aplicado</th><th>Método / referencia</th><th>Etapa</th><th>Registró</th><th>Comprobante</th><th></th></tr></thead>
|
||||||
|
<tbody id="cobPagos"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script id="api-web">
|
<script id="api-web">
|
||||||
@ -406,7 +486,7 @@
|
|||||||
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
|
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [];
|
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {};
|
||||||
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
|
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
|
||||||
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
|
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||||
let ID_SOLICITUD = nuevoId();
|
let ID_SOLICITUD = nuevoId();
|
||||||
@ -415,6 +495,10 @@
|
|||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
||||||
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
|
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
|
||||||
|
// Importe con su moneda ("$1,160.00 MXN" / "$1,000.00 USD"); nunca se suman monedas distintas
|
||||||
|
const dinero = (n, moneda) => r2(n).toLocaleString('es-MX', { minimumFractionDigits:2, maximumFractionDigits:2 })
|
||||||
|
.replace(/^(-?)/, '$1$') + ' ' + (moneda || 'MXN');
|
||||||
|
const monedaActual = () => $('moneda').value === 'USD' ? 'USD' : 'MXN';
|
||||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|
||||||
// ---- Arranque ----
|
// ---- Arranque ----
|
||||||
@ -422,6 +506,9 @@
|
|||||||
IVA = d.ivaTasa; CATALOGO = d.catalogo;
|
IVA = d.ivaTasa; CATALOGO = d.catalogo;
|
||||||
recibirClientes(d.clientes);
|
recibirClientes(d.clientes);
|
||||||
recibirProyectos(d.proyectos);
|
recibirProyectos(d.proyectos);
|
||||||
|
// La pestaña Cobranza solo se muestra con permiso; el servidor lo revisa en cada llamada
|
||||||
|
PERMISOS = d.permisos || {};
|
||||||
|
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
|
||||||
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
||||||
pintarDatalists();
|
pintarDatalists();
|
||||||
agregarFila();
|
agregarFila();
|
||||||
@ -442,7 +529,10 @@
|
|||||||
const aplicar = p => {
|
const aplicar = p => {
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
|
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
|
||||||
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.pu').value = p.precio;
|
tr.querySelector('.uni').value = p.unidad;
|
||||||
|
// El catálogo está en pesos: en una cotización en USD el precio se captura a mano
|
||||||
|
if (monedaActual() === 'USD') { tr.querySelector('.pu').value = ''; tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); }
|
||||||
|
else tr.querySelector('.pu').value = p.precio;
|
||||||
};
|
};
|
||||||
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
|
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
|
||||||
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
|
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
|
||||||
@ -467,12 +557,23 @@
|
|||||||
let sub = 0;
|
let sub = 0;
|
||||||
leerItems().forEach(it => {
|
leerItems().forEach(it => {
|
||||||
const imp = r2(it.cantidad * r2(it.precio));
|
const imp = r2(it.cantidad * r2(it.precio));
|
||||||
it._tr.querySelector('.imp').textContent = mxn(imp);
|
it._tr.querySelector('.imp').textContent = dinero(imp, monedaActual());
|
||||||
sub += imp;
|
sub += imp;
|
||||||
});
|
});
|
||||||
sub = r2(sub);
|
sub = r2(sub);
|
||||||
const iva = r2(sub * IVA);
|
const iva = r2(sub * IVA), m = monedaActual();
|
||||||
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva);
|
$('tSub').textContent = dinero(sub, m); $('tIva').textContent = dinero(iva, m); $('tTot').textContent = dinero(sub + iva, m);
|
||||||
|
$('lblTot').textContent = 'TOTAL ' + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cambiarMoneda() {
|
||||||
|
const usd = monedaActual() === 'USD';
|
||||||
|
const conPrecio = leerItems().some(it => it.precio > 0);
|
||||||
|
$('notaMoneda').innerHTML = usd
|
||||||
|
? '<span class="aviso-moneda">Cotización en USD:</span> el catálogo llena clave y descripción; captura los precios en dólares.' +
|
||||||
|
(conPrecio ? ' <span class="aviso-moneda">Revisa los precios que ya capturaste: los del catálogo están en pesos.</span>' : '')
|
||||||
|
: '';
|
||||||
|
recalcular();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Guardar ----
|
// ---- Guardar ----
|
||||||
@ -483,6 +584,7 @@
|
|||||||
items: leerItems().map(({_tr, ...it}) => it),
|
items: leerItems().map(({_tr, ...it}) => it),
|
||||||
notas: $('notas').value,
|
notas: $('notas').value,
|
||||||
marca: $('marca').value,
|
marca: $('marca').value,
|
||||||
|
moneda: monedaActual(),
|
||||||
proyectoFolio: '',
|
proyectoFolio: '',
|
||||||
idSolicitud: ID_SOLICITUD,
|
idSolicitud: ID_SOLICITUD,
|
||||||
};
|
};
|
||||||
@ -495,7 +597,7 @@
|
|||||||
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
|
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
|
||||||
google.script.run
|
google.script.run
|
||||||
.withSuccessHandler(r => {
|
.withSuccessHandler(r => {
|
||||||
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
|
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + dinero(r.total, r.moneda) +
|
||||||
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
|
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
|
||||||
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
||||||
limpiar();
|
limpiar();
|
||||||
@ -519,6 +621,7 @@
|
|||||||
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
|
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
|
||||||
$('filas').innerHTML = ''; agregarFila(); recalcular();
|
$('filas').innerHTML = ''; agregarFila(); recalcular();
|
||||||
$('cInfo').textContent = '';
|
$('cInfo').textContent = '';
|
||||||
|
$('moneda').value = 'MXN'; cambiarMoneda();
|
||||||
$('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto();
|
$('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto();
|
||||||
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
|
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
|
||||||
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores
|
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores
|
||||||
@ -534,7 +637,7 @@
|
|||||||
google.script.run.withSuccessHandler(lista => {
|
google.script.run.withSuccessHandler(lista => {
|
||||||
$('hist').innerHTML = lista.length ? lista.map(c =>
|
$('hist').innerHTML = lista.length ? lista.map(c =>
|
||||||
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
|
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
|
||||||
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.vendedor || c.elaboro) + '</td><td>' +
|
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + ' ' + esc(c.moneda || 'MXN') + '</td><td>' + esc(c.vendedor || c.elaboro) + '</td><td>' +
|
||||||
(c.proyecto === 'Sin proyecto' ? '<span class="nota">Sin proyecto</span>' : esc(c.proyecto)) + '</td><td>' +
|
(c.proyecto === 'Sin proyecto' ? '<span class="nota">Sin proyecto</span>' : esc(c.proyecto)) + '</td><td>' +
|
||||||
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
|
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
|
||||||
: '<tr><td colspan="8">Sin resultados.</td></tr>';
|
: '<tr><td colspan="8">Sin resultados.</td></tr>';
|
||||||
@ -729,16 +832,230 @@
|
|||||||
}
|
}
|
||||||
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
|
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Cobranza (solo rol de cobranza) ----
|
||||||
|
let COB_PROYECTOS = [], COB = null, ESQ = [], ID_PAGO = nuevoId();
|
||||||
|
const MARCA_TXT = { helgen: 'Helgen', energy: 'Helgen Energy' };
|
||||||
|
const etiquetaCob = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre;
|
||||||
|
|
||||||
|
function cargarProyectosCob() {
|
||||||
|
google.script.run.withSuccessHandler(lista => {
|
||||||
|
COB_PROYECTOS = lista || [];
|
||||||
|
$('cobProyectosList').innerHTML = COB_PROYECTOS.map(p =>
|
||||||
|
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc((p.contrato ? 'Contrato: ' + p.contrato : 'Sin aceptada') +
|
||||||
|
(p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
|
||||||
|
}).withFailureHandler(mostrarError).listarProyectosCobranza('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function proyectoCobElegido() {
|
||||||
|
const folio = ($('cobProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
||||||
|
return (folio && COB_PROYECTOS.find(p => p.folio === folio)) || null;
|
||||||
|
}
|
||||||
|
$('cobProyecto').addEventListener('change', () => { const p = proyectoCobElegido(); if (p) abrirProyectoCob(p.folio); });
|
||||||
|
|
||||||
|
function abrirProyectoCob(folio) {
|
||||||
|
$('cobInfo').textContent = 'Cargando…';
|
||||||
|
google.script.run.withSuccessHandler(pintarCobranza).withFailureHandler(mostrarError).getCobranzaProyecto(folio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pinta todo a partir de la respuesta del servidor (vistaCobranza_). */
|
||||||
|
function pintarCobranza(v) {
|
||||||
|
COB = v;
|
||||||
|
const c = v.contrato, r = v.resumen, firmado = c && c.estado === 'Firmado';
|
||||||
|
const mon = c ? c.moneda : 'MXN';
|
||||||
|
$('cobProyecto').value = etiquetaCob(v.proyecto);
|
||||||
|
$('cobInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
|
||||||
|
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '') +
|
||||||
|
' · Contrato: ' + (c ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">' + esc(c.estado) + '</span>' : 'sin cotización aceptada');
|
||||||
|
$('cobDetalle').classList.remove('oculto');
|
||||||
|
|
||||||
|
// Cotizaciones del proyecto: una sola aceptada
|
||||||
|
$('cobCots').innerHTML = v.cotizaciones.length ? v.cotizaciones.map(q => {
|
||||||
|
const esAceptada = c && c.cotizacion === q.folio;
|
||||||
|
return '<tr><td><b>' + esc(q.folio) + '</b></td><td>' + esc(q.fecha) + '</td><td>' + esc(MARCA_TXT[q.marca]) + '</td><td>' + esc(q.moneda) +
|
||||||
|
'</td><td class="num">' + dinero(q.subtotal, q.moneda) + '</td><td class="num">' + dinero(q.total, q.moneda) + '</td><td>' +
|
||||||
|
(q.pdf ? '<a href="' + esc(q.pdf) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
|
||||||
|
(esAceptada ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">Aceptada</span>'
|
||||||
|
: firmado ? '' : '<button type="button" class="btn btn-sec" onclick="aceptarCot(\'' + esc(q.folio) + '\')">Marcar aceptada</button>') +
|
||||||
|
'</td></tr>';
|
||||||
|
}).join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene cotizaciones.</td></tr>';
|
||||||
|
|
||||||
|
// Contrato
|
||||||
|
let h = '';
|
||||||
|
if (!c) h = '<span class="nota">Marca la cotización aceptada para definir el monto contratado.</span>';
|
||||||
|
else {
|
||||||
|
h = '<div class="sat-titulo">Monto contratado: ' + dinero(c.total, mon) + ' (con IVA) · subtotal ' + dinero(c.subtotal, mon) +
|
||||||
|
' · ' + esc(MARCA_TXT[c.marca]) + '</div>';
|
||||||
|
if (firmado) {
|
||||||
|
h += '<div>Contrato firmado el <b>' + esc(c.fechaFirma) + '</b> · registró ' + esc(c.firmadoPor) +
|
||||||
|
'. La cotización aceptada, el monto y el esquema ya no se pueden cambiar.</div>';
|
||||||
|
if (v.puedeDesbloquear) {
|
||||||
|
h += '<div style="margin-top:10px"><label>Motivo del desbloqueo (queda registrado) *</label>' +
|
||||||
|
'<input id="cobMotivo" maxlength="500" placeholder="Ej. Se firmó con la cotización equivocada"></div>' +
|
||||||
|
'<div class="acciones"><button type="button" class="btn btn-sec" id="btnDesbloquear" onclick="desbloquearCob()">Desbloquear contrato</button></div>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h += '<div class="proy-fila" style="margin-top:8px"><div style="max-width:220px"><label>Fecha de firma *</label>' +
|
||||||
|
'<input id="cobFechaFirma" type="date" max="' + esc(v.hoy) + '" value="' + esc(v.hoy) + '"></div>' +
|
||||||
|
'<button type="button" class="btn" id="btnFirmar" onclick="firmarCob()">Contrato firmado</button></div>' +
|
||||||
|
'<div class="nota" style="margin-top:6px">Al firmar se bloquean la cotización aceptada, el monto contratado y el esquema de pagos. ' +
|
||||||
|
'Después ya no se podrán agregar cotizaciones al proyecto.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('cobContrato').innerHTML = h;
|
||||||
|
|
||||||
|
// Esquema
|
||||||
|
$('cobModEsquema').classList.toggle('oculto', !c);
|
||||||
|
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
|
||||||
|
$('cobEsqNota').textContent = !c ? '' : v.esquemaEditable
|
||||||
|
? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.'
|
||||||
|
: c.marca === 'energy' && !firmado ? 'Helgen Energy: esquema fijo (60% / 30% / 10%).' : 'Esquema bloqueado por el contrato firmado.';
|
||||||
|
pintarEsquema();
|
||||||
|
|
||||||
|
// Pagos
|
||||||
|
$('cobModPagos').classList.toggle('oculto', !c);
|
||||||
|
$('cobResumen').innerHTML = r ? [['Contratado (con IVA)', dinero(r.contratado, mon)], ['Subtotal (referencia)', dinero(r.subtotal, mon)],
|
||||||
|
['Pagado', dinero(r.pagado, mon)], ['Saldo', dinero(r.saldo, mon)], ['Avance', r.avance + '%']]
|
||||||
|
.map(([k, x]) => '<div class="tile"><span>' + k + '</span><b>' + x + '</b></div>').join('') : '';
|
||||||
|
$('cobFormPago').classList.toggle('oculto', !v.puedePagar);
|
||||||
|
$('cobSinPagos').textContent = c && !v.puedePagar ? 'Los pagos se registran cuando el contrato está firmado.' : '';
|
||||||
|
if (v.puedePagar) {
|
||||||
|
$('pgFecha').max = v.hoy; if (!$('pgFecha').value) $('pgFecha').value = v.hoy;
|
||||||
|
$('pgMoneda').value = mon;
|
||||||
|
$('pgMetodo').innerHTML = (v.metodos || []).map(m => '<option>' + esc(m) + '</option>').join('');
|
||||||
|
$('pgEtapa').innerHTML = (r ? r.etapas : []).map((e, i) => '<option value="' + (i + 1) + '">' + (i + 1) + ' · ' + esc(e.etapa) +
|
||||||
|
' (saldo ' + dinero(e.saldo, mon) + ')</option>').join('');
|
||||||
|
const pendiente = r && r.etapas.findIndex(e => e.saldo > 0);
|
||||||
|
if (pendiente >= 0) $('pgEtapa').value = String(pendiente + 1);
|
||||||
|
pintarTipoCambio();
|
||||||
|
}
|
||||||
|
$('cobPagos').innerHTML = (v.pagos || []).map(p => '<tr' + (p.estado === 'Cancelado' ? ' class="fila-cancelada" title="' +
|
||||||
|
esc('Cancelado por ' + p.canceladoPor + ': ' + p.motivoCancelacion) + '"' : '') + '><td><b>' + esc(p.folio) + '</b></td><td>' + esc(p.fecha) +
|
||||||
|
'</td><td class="num">' + dinero(p.monto, p.monedaPago) + (p.tipoCambio ? '<div class="nota">TC ' + esc(p.tipoCambio) + '</div>' : '') +
|
||||||
|
'</td><td class="num">' + dinero(p.montoAplicado, p.monedaProyecto) + '</td><td>' + esc(p.metodo) + (p.referencia ? ' · ' + esc(p.referencia) : '') +
|
||||||
|
'</td><td>' + esc(p.etapa) + '</td><td>' + esc(p.registro) + '</td><td>' +
|
||||||
|
(p.comprobanteUrl ? '<a href="' + esc(p.comprobanteUrl) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
|
||||||
|
(p.estado === 'Vigente' ? '<button type="button" class="quitar" title="Cancelar pago" onclick="cancelarPagoCob(\'' + esc(p.folio) + '\')">×</button>'
|
||||||
|
: '<span class="chip cancelado">Cancelado</span>') + '</td></tr>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pintarEsquema() {
|
||||||
|
const v = COB, edit = v && v.esquemaEditable, mon = v && v.contrato ? v.contrato.moneda : 'MXN';
|
||||||
|
const total = v && v.contrato ? v.contrato.total : 0;
|
||||||
|
const res = v && v.resumen ? v.resumen.etapas : [];
|
||||||
|
const suma = r2(ESQ.reduce((s, e) => s + (Number(e.porcentaje) || 0), 0));
|
||||||
|
// Vista previa de montos (el servidor los recalcula): la última etapa se queda con los centavos
|
||||||
|
let acum = 0;
|
||||||
|
const montos = ESQ.map((e, i) => { const m = i === ESQ.length - 1 ? r2(total - acum) : r2(total * (Number(e.porcentaje) || 0) / 100); acum = r2(acum + m); return m; });
|
||||||
|
$('cobEsquema').innerHTML = ESQ.map((e, i) => {
|
||||||
|
const x = res[i] || {};
|
||||||
|
return '<tr><td>' + (i + 1) + '</td><td>' + (edit ? '<input value="' + esc(e.etapa) + '" maxlength="40" oninput="editarEtapa(' + i + ',\'etapa\',this.value)">' : esc(e.etapa)) +
|
||||||
|
'</td><td class="num">' + (edit ? '<input type="number" min="0" max="100" step="0.01" style="text-align:right" value="' + esc(e.porcentaje) +
|
||||||
|
'" oninput="editarEtapa(' + i + ',\'porcentaje\',this.value)">' : esc(e.porcentaje) + '%') +
|
||||||
|
'</td><td class="num">' + dinero(montos[i], mon) + '</td><td class="num">' + (x.pagado != null ? dinero(x.pagado, mon) : '') +
|
||||||
|
'</td><td class="num">' + (x.saldo != null ? dinero(x.saldo, mon) : '') + '</td><td>' +
|
||||||
|
(x.avance != null ? '<div class="barra" title="' + x.avance + '%"><i style="width:' + Math.max(0, Math.min(100, x.avance)) + '%"></i></div>' : '') +
|
||||||
|
'</td><td>' + (edit && ESQ.length > 1 ? '<button type="button" class="quitar" title="Quitar etapa" onclick="quitarEtapa(' + i + ')">×</button>' : '') + '</td></tr>';
|
||||||
|
}).join('') + (ESQ.length ? '<tr><td></td><td><b>Suma</b></td><td class="num"><b' + (Math.abs(suma - 100) > 0.001 ? ' class="aviso-moneda"' : '') + '>' +
|
||||||
|
suma + '%</b></td><td class="num"><b>' + dinero(total, mon) + '</b></td><td colspan="4"></td></tr>' : '');
|
||||||
|
$('cobEsqAcciones').innerHTML = edit ? '<button type="button" class="btn btn-sec" onclick="agregarEtapa()">+ Agregar etapa</button>' +
|
||||||
|
'<button type="button" class="btn" id="btnEsquema" onclick="guardarEsquemaCob()">Guardar esquema</button>' : '';
|
||||||
|
}
|
||||||
|
// Al editar un nombre no se vuelve a pintar (no se pierde el cursor); el % sí actualiza montos y suma
|
||||||
|
function editarEtapa(i, campo, valor) { ESQ[i][campo] = valor; if (campo === 'porcentaje') pintarEsquemaSinFoco(); }
|
||||||
|
function pintarEsquemaSinFoco() { const a = document.activeElement, idx = a && a.closest && a.closest('tr') ? [...$('cobEsquema').children].indexOf(a.closest('tr')) : -1;
|
||||||
|
pintarEsquema(); if (idx >= 0) { const inp = $('cobEsquema').children[idx].querySelector('input[type=number]'); if (inp) { inp.focus(); const l = inp.value.length; try { inp.setSelectionRange(l, l); } catch (e) {} } } }
|
||||||
|
function agregarEtapa() { if (ESQ.length >= 10) { mostrarError('Máximo 10 etapas.'); return; } ESQ.push({ etapa: '', porcentaje: 0 }); pintarEsquema(); }
|
||||||
|
function quitarEtapa(i) { ESQ.splice(i, 1); pintarEsquema(); }
|
||||||
|
|
||||||
|
// Botones que llaman al servidor: se desactivan mientras responde
|
||||||
|
function accionCob(boton, funcion, args, alExito) {
|
||||||
|
const b = boton && $(boton);
|
||||||
|
if (b) { if (b.disabled) return; b.disabled = true; }
|
||||||
|
const listo = () => { if (b) b.disabled = false; };
|
||||||
|
google.script.run.withSuccessHandler(x => { listo(); (alExito || pintarCobranza)(x); })
|
||||||
|
.withFailureHandler(err => { listo(); mostrarError(err); })[funcion](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceptarCot(folio, confirmado) {
|
||||||
|
accionCob(null, 'marcarAceptada', [COB.proyecto.folio, folio, !!confirmado], v => {
|
||||||
|
if (v && v.confirmar) { if (window.confirm(v.mensaje)) aceptarCot(folio, true); return; }
|
||||||
|
pintarCobranza(v); aviso('ok', 'Cotización <b>' + esc(folio) + '</b> marcada como aceptada.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function guardarEsquemaCob() {
|
||||||
|
accionCob('btnEsquema', 'guardarEsquema', [COB.proyecto.folio, ESQ.map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }))],
|
||||||
|
v => { pintarCobranza(v); aviso('ok', 'Esquema de pagos guardado.'); });
|
||||||
|
}
|
||||||
|
function firmarCob() {
|
||||||
|
const fecha = $('cobFechaFirma').value;
|
||||||
|
if (!fecha) { mostrarError('Captura la fecha de firma.'); return; }
|
||||||
|
if (!window.confirm('¿Registrar el contrato de ' + COB.proyecto.folio + ' como firmado el ' + fecha + '? ' +
|
||||||
|
'Después ya no se podrán cambiar la cotización aceptada, el monto ni el esquema de pagos.')) return;
|
||||||
|
accionCob('btnFirmar', 'firmarContrato', [COB.proyecto.folio, fecha], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato firmado.'); });
|
||||||
|
}
|
||||||
|
function desbloquearCob() {
|
||||||
|
const motivo = $('cobMotivo').value.trim();
|
||||||
|
if (!motivo) { mostrarError('Escribe el motivo del desbloqueo.'); return; }
|
||||||
|
accionCob('btnDesbloquear', 'desbloquearContrato', [COB.proyecto.folio, motivo], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato desbloqueado. Quedó registrado el motivo.'); });
|
||||||
|
}
|
||||||
|
function cancelarPagoCob(folio) {
|
||||||
|
const motivo = window.prompt('Motivo para cancelar el pago ' + folio + ' (queda registrado; el pago no se borra):');
|
||||||
|
if (motivo == null) return;
|
||||||
|
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||||||
|
accionCob(null, 'cancelarPago', [folio, motivo], v => { pintarCobranza(v); aviso('ok', 'Pago <b>' + esc(folio) + '</b> cancelado.'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pintarTipoCambio() {
|
||||||
|
const distinta = COB && COB.contrato && $('pgMoneda').value !== COB.contrato.moneda;
|
||||||
|
$('pgTcCaja').classList.toggle('oculto', !distinta);
|
||||||
|
if (!distinta) $('pgTc').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee el archivo como base64 (sin el prefijo data:). */
|
||||||
|
function leerArchivo(archivo) {
|
||||||
|
return new Promise((ok, mal) => {
|
||||||
|
const lector = new FileReader();
|
||||||
|
lector.onload = () => ok(String(lector.result).split(',')[1] || '');
|
||||||
|
lector.onerror = () => mal(new Error('No se pudo leer el comprobante.'));
|
||||||
|
lector.readAsDataURL(archivo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registrarPago() {
|
||||||
|
const archivo = $('pgArchivo').files && $('pgArchivo').files[0];
|
||||||
|
if (!archivo) { mostrarError('Adjunta el comprobante de pago (PDF o imagen).'); return; }
|
||||||
|
if (archivo.size > 10 * 1024 * 1024) { mostrarError('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
|
||||||
|
if ($('btnPago').disabled) return;
|
||||||
|
$('btnPago').disabled = true; $('btnPago').textContent = 'Subiendo comprobante…';
|
||||||
|
const listo = () => { $('btnPago').disabled = false; $('btnPago').textContent = 'Registrar pago'; };
|
||||||
|
leerArchivo(archivo).then(datos => {
|
||||||
|
google.script.run.withSuccessHandler(r => {
|
||||||
|
listo(); ID_PAGO = nuevoId();
|
||||||
|
['pgMonto', 'pgTc', 'pgRef', 'pgArchivo'].forEach(id => $(id).value = '');
|
||||||
|
pintarCobranza(r.vista);
|
||||||
|
aviso('ok', 'Pago <b>' + esc(r.folio) + '</b> registrado · <a href="' + esc(r.url) + '" target="_blank">Ver comprobante</a>');
|
||||||
|
}).withFailureHandler(err => { listo(); mostrarError(err); }).registrarPago({
|
||||||
|
proyectoFolio: COB.proyecto.folio, fecha: $('pgFecha').value, monto: $('pgMonto').value, monedaPago: $('pgMoneda').value,
|
||||||
|
tipoCambio: $('pgTc').value, metodo: $('pgMetodo').value, referencia: $('pgRef').value, etapaOrden: Number($('pgEtapa').value),
|
||||||
|
comprobante: { nombre: archivo.name, datos: datos }, idSolicitud: ID_PAGO,
|
||||||
|
});
|
||||||
|
}, err => { listo(); mostrarError(err); });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- UI ----
|
// ---- UI ----
|
||||||
function verTab(t) {
|
function verTab(t) {
|
||||||
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
|
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
|
||||||
$('vistaHist').classList.toggle('oculto', t !== 'hist');
|
$('vistaHist').classList.toggle('oculto', t !== 'hist');
|
||||||
$('vistaCat').classList.toggle('oculto', t !== 'cat');
|
$('vistaCat').classList.toggle('oculto', t !== 'cat');
|
||||||
|
$('vistaCob').classList.toggle('oculto', t !== 'cob');
|
||||||
|
$('tabCob').classList.toggle('activo', t === 'cob');
|
||||||
$('tabCat').classList.toggle('activo', t === 'cat');
|
$('tabCat').classList.toggle('activo', t === 'cat');
|
||||||
$('tabNueva').classList.toggle('activo', t === 'nueva');
|
$('tabNueva').classList.toggle('activo', t === 'nueva');
|
||||||
$('tabHist').classList.toggle('activo', t === 'hist');
|
$('tabHist').classList.toggle('activo', t === 'hist');
|
||||||
if (t === 'hist') cargarHistorial();
|
if (t === 'hist') cargarHistorial();
|
||||||
if (t === 'cat') cargarCatalogo();
|
if (t === 'cat') cargarCatalogo();
|
||||||
|
if (t === 'cob') cargarProyectosCob();
|
||||||
$('aviso').className = 'aviso';
|
$('aviso').className = 'aviso';
|
||||||
}
|
}
|
||||||
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
|
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user