mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-26 23:56:32 +00:00
Proyectos: folio PRY-AAAA-NNNN, carpeta en Drive y cotizaciones ligadas por proyecto_folio
- Pestaña "Proyectos" (se crea sola) con cliente_id (RFC o SIN-RFC:nombre|empresa), estado con lista desplegable y vendedor del token. Folio con contador propio CONSECUTIVO_PROY_AAAA bajo el mismo candado. - Carpeta Cotizador Helgen/Proyectos/AAAA/<folio> — <cliente> — <nombre>/ con Cotizaciones, Órdenes de compra y Pagos; las subcarpetas borradas o renombradas se vuelven a crear. - Cotizaciones: columna T proyecto_folio (las viejas quedan vacías = "Sin proyecto"); con proyecto, el PDF va a su carpeta Cotizaciones. Proyecto inexistente o no Activo se rechaza sin gastar folio. - Interfaz: campo Proyecto opcional con buscador y "Nuevo proyecto" en el módulo 1; columna en Historial. - Pruebas: tests/proyectos.test.js (Drive simulado con árbol de carpetas) y sesion.test.js ampliada. - CLAUDE.md: §6.9 Proyectos y §6.10 modelo futuro (OC, Pagos, Gastos, cotización "Aceptada"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
55950b3762
commit
aae01d6918
75
CLAUDE.md
75
CLAUDE.md
@ -13,7 +13,7 @@ Desde el **19/10/2026** (§11) la interfaz se sirve desde **Cloudflare** y habla
|
||||
2. El servidor **recalcula** subtotal, IVA y total, asigna un **folio único** `HLG-AAAA-0001` y genera el **PDF**.
|
||||
3. El PDF se guarda en Drive (carpeta por año) y la cotización queda como una fila en Google Sheets, con el vendedor del token.
|
||||
|
||||
Además: 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), historial con búsqueda, catálogo de productos con precios (Google Sheets aparte), clientes sugeridos con
|
||||
autorrelleno, y claves SAT internas por producto para contabilidad (con sugerencias por palabras o con IA).
|
||||
|
||||
**Helgen Energy** es la submarca de paneles solares: mismo folio y diseño, pero sus propios Términos y Condiciones.
|
||||
@ -56,7 +56,7 @@ para que un `clasp push` desde ella no pise la API con código viejo; no volver
|
||||
| `.claspignore` | Lista blanca: solo se suben `Code.js`, `Logo.js`, `Index.html`, `appsscript.json` (excluye `anterior/`). |
|
||||
| `web/` | Portada (`index.html`, `logo-helgen-blanco.png`) y la interfaz real `web/cotizador/index.html` (§10). No se sube a Apps Script. `.assetsignore` excluye `Icon`/`.DS_Store`. |
|
||||
| `wrangler.jsonc` | Publica `web/` en Cloudflare como Worker solo de archivos estáticos. |
|
||||
| `tests/` | Pruebas en Node con servicios de Google simulados (ver §8). No se suben. Aceptan `Code.js` (actual) o `Code.gs` (anterior). |
|
||||
| `tests/` | Pruebas en Node con servicios de Google simulados (ver §8). No se suben. `cotizacion.test.js` acepta `Code.js` (actual) o `Code.gs` (anterior). |
|
||||
| `anterior/` | Código del proyecto viejo (@9), su `.clasp.json` (`1X-qSKgv…`), `LEEME.md` con cómo regresar y el CLAUDE.md previo a la migración. No tocar salvo para regresar. |
|
||||
| `herramientas/extraer-catalogos-sat.js` | Convierte el Excel oficial del SAT en los CSV que importa la app (ver §6.5). |
|
||||
| `GUIA.md` | Guía para personas (instalación inicial). Desactualizada: describe el proyecto anterior. |
|
||||
@ -123,17 +123,36 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
||||
- `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT
|
||||
a cada concepto que venga del catálogo (si falla, guarda igual), toma el candado, revisa idempotencia (`payload.idSolicitud`
|
||||
en `CacheService`, 6 h), asigna folio, genera PDF, lo guarda en la carpeta del año, reescribe encabezados si no coinciden con
|
||||
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total}`.
|
||||
- `siguienteFolio_(prefijo, hojaCot)` — `max(CONSECUTIVO_AAAA, mayor folio del año en la hoja) + 1`.
|
||||
`ENCABEZADOS_COTIZACIONES` y agrega la fila. Devuelve `{folio, url, total, proyectoFolio}`.
|
||||
Si viene `payload.proyectoFolio`, dentro del candado valida que el proyecto exista y esté `Activo` (si no, error y no se
|
||||
gasta folio) y guarda el PDF en `<carpeta del proyecto>/Cotizaciones` en lugar de la carpeta del año.
|
||||
- `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).
|
||||
- `carpetaDelAnio_(anio)` — subcarpeta `2026`, `2027`… dentro de la carpeta de PDF (la busca **por nombre**).
|
||||
- `logoDataUrl_(fileId)` — logo de Drive si `LOGO_FILE_ID` tiene valor; si no, `LOGO_HELGEN_AZUL`.
|
||||
|
||||
### 4.5.1 Proyectos (sección 4.1 del archivo)
|
||||
- `hojaProyectos_()` — pestaña `Proyectos` de la base de datos; la crea sola (lista desplegable de Estado en G) y reescribe
|
||||
solo la fila 1 si faltan encabezados (`ENCABEZADOS_PROYECTOS`).
|
||||
- `listarProyectos(busqueda, incluirInactivos)` *(UI)* — busca por folio, cliente o nombre; por omisión solo `Activo`; máx. 50,
|
||||
recientes primero. No devuelve `carpeta_id`.
|
||||
- `crearProyecto(p)` *(UI)* — exige vendedor; `normalizarProyecto`; candado + idempotencia (`PRY_<idSolicitud>`, 6 h);
|
||||
folio `PRY-AAAA-NNNN`; **primero** crea la carpeta y **después** la fila (estado `Activo`). Devuelve `{folio, …, url}`.
|
||||
- `buscarProyecto_(hoja, folio)`, `carpetaProyectos_()` (`PROYECTOS_FOLDER_ID`; si falta o está en la Papelera la crea en la
|
||||
carpeta maestra = padre de la base de datos), `subcarpeta_(padre, nombre)` (por nombre, ignora la Papelera, crea si falta),
|
||||
`crearCarpetaProyecto_`, `subcarpetaProyecto_(proyecto, nombreSub)` (ver §6.9).
|
||||
- `getDatosIniciales()` incluye `proyectos` (activos; si falla, `[]` y la app carga igual).
|
||||
|
||||
### 4.6 Lógica pura (sin Google; se prueba en Node)
|
||||
- `redondear(n)`, `formatoMoneda(n)`, `escaparHtml(s)`, `importeConLetra(n)` ("MIL CIENTO SESENTA PESOS 02/100 M.N.").
|
||||
- `normalizarCotizacion(p, ivaTasa)` — limpia cliente/conceptos, exige nombre y ≥1 concepto, calcula importes y totales, `marca`.
|
||||
- `formatearFolio`, `maxConsecutivo(folios, prefijo, anio)`.
|
||||
- `clientesDesdeFilas(filas)` — agrupa por nombre+empresa normalizados (sin acentos/mayúsculas/espacios), la fila más
|
||||
reciente manda, un dato vacío no borra uno anterior; `etiqueta` = nombre, o "nombre — empresa" si el nombre se repite.
|
||||
- `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;
|
||||
`id` = `idCliente`.
|
||||
- `idCliente(c)` — RFC válido y no genérico (`RFC_GENERICOS`: XAXX010101000, XEXX010101000); si no, `SIN-RFC:nombre|empresa`
|
||||
normalizados. `normalizarProyecto(p)`, `nombreCarpetaProyecto(folio, cliente, nombre)` (quita `/ \ : * ? " < > |`),
|
||||
`proyectosDesdeFilas`, `filtrarProyectos`.
|
||||
- `normalizarProducto(p)`, `indiceDeClave(claves, clave)`.
|
||||
- `terminosParaPdf(cfg, marca)` — renglones de T&C de la marca; agrega la línea de vigencia si falta.
|
||||
- `textoCelda(s)` — antepone `'` a texto que empiece con `= + - @` para que Sheets no lo lea como fórmula.
|
||||
@ -146,7 +165,10 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
||||
Tres pestañas: **Nueva cotización**, **Historial**, **Catálogo**. Funciones principales: `agregarFila`, `leerItems`,
|
||||
`recalcular` (vista previa; el servidor recalcula), `guardar`, `limpiar`, `cargarHistorial`, `recibirClientes`,
|
||||
`autorrellenarCliente`, `pintarDatalists`, `cargarCatalogo`, `pintarCatalogo`, `editarProd`, `guardarProd`, `sugerirSat`, `verTab`.
|
||||
Autocompletado con `<datalist>`: clientes (en "Nombre"), productos por descripción y por clave, unidades SAT.
|
||||
Proyectos (módulo "1 · Cliente y proyecto"): `recibirProyectos`, `proyectoElegido`, `alElegirProyecto` (si "Nombre" está vacío,
|
||||
lo llena buscando el cliente por `cliente_id`), `abrirNuevoProyecto`, `crearProyecto` (usa el cliente de los campos de arriba).
|
||||
Autocompletado con `<datalist>`: clientes (en "Nombre"), proyectos activos (`PRY-… — cliente — nombre`), productos por
|
||||
descripción y por clave, unidades SAT.
|
||||
Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
|
||||
|
||||
## 5. Dónde se guardan los datos
|
||||
@ -156,7 +178,8 @@ Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
|
||||
📁 Cotizador Helgen
|
||||
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
|
||||
├─ 📊 Catálogo Helgen (Catalogo)
|
||||
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf
|
||||
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf (cotizaciones "Sin proyecto")
|
||||
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones, Órdenes de compra, Pagos
|
||||
├─ 📁 Catálogos SAT / SAT_c_ClaveProdServ.csv, SAT_c_ClaveUnidad.csv, 📊 Catálogos SAT (CFDI 4.0)
|
||||
├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
|
||||
└─ 📜 Cotizador Helgen (código de la app) ← proyecto ANTERIOR, en desuso
|
||||
@ -164,10 +187,11 @@ Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
|
||||
El proyecto de la API (`1GsH8Dja…`) está en la carpeta `1w-T05ql62SXxsm3z4u-xCpMjcdXc0b0p` (antes "PRUEBAS — Cotizador web"),
|
||||
junto con los archivos de prueba que ya no se usan (§9).
|
||||
|
||||
**Pestaña `Cotizaciones`** (19 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono,
|
||||
**Pestaña `Cotizaciones`** (20 columnas, 1 fila por cotización): Folio, Fecha, Cliente, Empresa, RFC, Correo, Teléfono,
|
||||
Subtotal, IVA, Total, Elaboró, PDF (link), Conceptos (JSON: clave, descripcion, unidad, cantidad, precio, importe y, si vienen
|
||||
del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Dirección, `vendedor_email`, `vendedor_nombre`,
|
||||
`vendedor_apellido`. Las columnas P:S se agregaron el 19/10/2026 (§11); las filas 0001–0004 no las tienen llenas.
|
||||
`vendedor_apellido`, `proyecto_folio` (T; vacío = "Sin proyecto"). Las columnas P:S se agregaron el 19/10/2026 (§11); las
|
||||
filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026): las filas anteriores quedan vacías y no se mueven.
|
||||
`guardarCotizacion` reescribe la fila de encabezados si no coincide con `ENCABEZADOS_COTIZACIONES`.
|
||||
|
||||
**Pestaña `Config`** (Clave | Valor, columna B en formato texto): `EMPRESA_NOMBRE` (Helgen Servicios Especializados),
|
||||
@ -175,11 +199,16 @@ del catálogo, claveSat/unidadSat), Notas, Marca (Helgen / Helgen Energy), Direc
|
||||
`PREFIJO_FOLIO` (HLG), `IVA_TASA` (0.16), `VIGENCIA_DIAS` (15), `MONEDA`, `LOGO_FILE_ID` (vacío = logo de `Logo.js`),
|
||||
`TERMINOS_HELGEN`, `TERMINOS_ENERGY` (un término por renglón dentro de la celda).
|
||||
|
||||
**Pestaña `Proyectos`** (1 fila por proyecto): Folio (`PRY-AAAA-NNNN`), Fecha, Nombre, Cliente ("nombre — empresa"),
|
||||
`cliente_id` (ver `idCliente`), Ubicación, Estado (Activo/Cerrado/Cancelado; **se cambia con la lista desplegable en la hoja**,
|
||||
no hay pantalla), `carpeta_id`, `vendedor_email`, `vendedor_nombre`, `vendedor_apellido`.
|
||||
|
||||
**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA),
|
||||
Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
||||
|
||||
**Script Properties** del proyecto `1GsH8Dja…` (Configuración del proyecto en el editor):
|
||||
`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
|
||||
`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.
|
||||
|
||||
## 6. Decisiones tomadas (y por qué)
|
||||
@ -242,6 +271,28 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
||||
- Publicar siempre sobre el mismo deployment (misma `API_URL`). Nunca `clasp deploy` sin `--deploymentId`.
|
||||
- Si se agrega un servicio nuevo de Google, el dueño debe autorizar el permiso una vez desde el editor (`UrlFetchApp` ya está autorizado).
|
||||
|
||||
### 6.9 Proyectos
|
||||
- **Opcionales al cotizar** ("Sin proyecto" por omisión). Solo se pueden elegir proyectos `Activo`; el servidor lo vuelve a revisar.
|
||||
- **No hay hoja de clientes**: el cliente del proyecto es el de "Datos del cliente" (mismo datalist y autorrelleno; uno nuevo se
|
||||
escribe ahí, como siempre). Se guarda como texto + `cliente_id` (RFC o `SIN-RFC:nombre|empresa`, ver `idCliente`). Si después
|
||||
se le agrega RFC al cliente, su `id` cambia a ese RFC y los proyectos viejos conservan el `SIN-RFC:…`.
|
||||
- Folio propio con contador `CONSECUTIVO_PROY_AAAA` bajo el **mismo** candado de script que las cotizaciones.
|
||||
- **Subcarpetas que se reparan solas:** las subcarpetas de un proyecto se buscan **por nombre**. Si alguien borra (Papelera) o
|
||||
renombra `Cotizaciones`, `Órdenes de compra` o `Pagos`, la próxima vez que se necesite el sistema **crea una nueva** con el
|
||||
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.
|
||||
Renombrar la carpeta **del proyecto** sí se puede (se usa por ID).
|
||||
- Las cotizaciones viejas no se mueven ni se ligan a proyectos.
|
||||
|
||||
### 6.10 Modelo futuro (fase 2; NO construido)
|
||||
- Pestañas `OrdenesCompra`, `Pagos` y `Gastos` en la base de datos, cada una con su folio (`OC-`, `PAG-`, `GAS-AAAA-NNNN`),
|
||||
contador `CONSECUTIVO_<TIPO>_AAAA` (usar `siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`**
|
||||
y `vendedor_*` del token. Crear las pestañas con el patrón de `hojaProyectos_()` (encabezados que se migran solos).
|
||||
- Archivos en `subcarpetaProyecto_(proyecto, 'Órdenes de compra' | 'Pagos')`. Gastos aún no tiene subcarpeta: agregarla a
|
||||
`SUBCARPETAS_PROYECTO` cuando se construya (los proyectos viejos la obtienen sola por §6.9).
|
||||
- **Monto contratado:** la fase 2 necesitará marcar una cotización como **"Aceptada"** (columna de estatus en `Cotizaciones`)
|
||||
para definir el monto contratado del proyecto, contra el que se comparan órdenes de compra, pagos y gastos.
|
||||
|
||||
## 7. Reglas del proyecto (no romper)
|
||||
1. Los totales SIEMPRE se recalculan en el servidor (`normalizarCotizacion`). El navegador solo muestra una vista previa.
|
||||
2. Redondeo a 2 decimales por renglón (`redondear`). IVA = subtotal × tasa.
|
||||
@ -263,11 +314,12 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto.
|
||||
## 8. Cómo trabajar
|
||||
|
||||
```bash
|
||||
# Pruebas (Node, servicios de Google simulados). Correr las cuatro antes de subir.
|
||||
# Pruebas (Node, servicios de Google simulados). Correr las cinco antes de subir.
|
||||
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes; deja helgen.html / energy.html
|
||||
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
|
||||
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
|
||||
node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
|
||||
node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan
|
||||
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)
|
||||
@ -310,6 +362,7 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio
|
||||
- Si Helgen Energy tiene logo/colores propios, aplicarlos solo a sus cotizaciones.
|
||||
- `GUIA.md` describe el proyecto anterior; actualizarla si se va a usar.
|
||||
- Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
|
||||
- Proyectos fase 2: órdenes de compra, pagos, gastos y cotización "Aceptada" (§6.10).
|
||||
|
||||
## 10. Interfaz web y sesión con Google
|
||||
|
||||
|
||||
288
Code.js
288
Code.js
@ -93,6 +93,8 @@ const API_FUNCIONES = {
|
||||
guardarProducto: guardarProducto,
|
||||
guardarCotizacion: guardarCotizacion,
|
||||
sugerirClavesSat: sugerirClavesSat,
|
||||
listarProyectos: listarProyectos,
|
||||
crearProyecto: crearProyecto,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -168,7 +170,7 @@ const CONFIG_EJEMPLO_ANTERIOR = {
|
||||
const ENCABEZADOS_COTIZACIONES = [
|
||||
'Folio', 'Fecha', 'Cliente', 'Empresa', 'RFC', 'Correo', 'Teléfono',
|
||||
'Subtotal', 'IVA', 'Total', 'Elaboró', 'PDF', 'Conceptos (JSON)', 'Notas', 'Marca', 'Dirección',
|
||||
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido'
|
||||
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'proyecto_folio'
|
||||
];
|
||||
|
||||
function setup() {
|
||||
@ -546,11 +548,22 @@ function getDatosIniciales() {
|
||||
marcas: MARCAS,
|
||||
catalogo: getCatalogo_(),
|
||||
clientes: getClientes(),
|
||||
proyectos: proyectosParaInicio_(),
|
||||
usuario: usuarioActual_(),
|
||||
vendedor: VENDEDOR_,
|
||||
};
|
||||
}
|
||||
|
||||
/** Proyectos activos para el buscador. Si fallan, la app carga igual (se puede cotizar "Sin proyecto"). */
|
||||
function proyectosParaInicio_() {
|
||||
try {
|
||||
return listarProyectos('', false);
|
||||
} catch (e) {
|
||||
console.warn('Sin lista de proyectos: ' + e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clientes que ya cotizaron, tomados de la hoja Cotizaciones.
|
||||
* Si se borran sus filas de la base de datos, dejan de sugerirse.
|
||||
@ -571,12 +584,14 @@ function listarCotizaciones(busqueda) {
|
||||
const datos = hoja.getDataRange().getDisplayValues().slice(1).reverse();
|
||||
const q = String(busqueda || '').toLowerCase().trim();
|
||||
return datos
|
||||
.filter(r => !q || (r[0] + ' ' + r[2] + ' ' + r[3]).toLowerCase().includes(q))
|
||||
.filter(r => !q || (r[0] + ' ' + r[2] + ' ' + r[3] + ' ' + (r[19] || '')).toLowerCase().includes(q))
|
||||
.slice(0, 100)
|
||||
.map(r => ({
|
||||
folio: r[0], fecha: r[1], cliente: r[2], empresa: r[3], total: r[9], elaboro: r[10], pdf: r[11],
|
||||
// Filas anteriores a la sesión con Google no tienen vendedor: se muestra el correo de "Elaboró"
|
||||
vendedor: [r[17], r[18]].filter(Boolean).join(' ') || r[16] || r[10] || '',
|
||||
// Filas anteriores a Proyectos (o cotizadas sin proyecto)
|
||||
proyecto: r[19] || 'Sin proyecto',
|
||||
}));
|
||||
}
|
||||
|
||||
@ -603,6 +618,8 @@ function guardarCotizacion(payload) {
|
||||
const idSolicitud = String((payload && payload.idSolicitud) || '').slice(0, 64);
|
||||
const cache = CacheService.getScriptCache();
|
||||
const claveCache = idSolicitud ? 'REQ_' + idSolicitud : '';
|
||||
// Proyecto opcional: vacío = "Sin proyecto" (el PDF va a la carpeta del año, como antes)
|
||||
const proyectoFolio = String((payload && payload.proyectoFolio) || '').trim().toUpperCase();
|
||||
|
||||
const lock = LockService.getScriptLock();
|
||||
// Evita folios duplicados si dos personas guardan a la vez
|
||||
@ -615,6 +632,18 @@ function guardarCotizacion(payload) {
|
||||
if (previo) return JSON.parse(previo);
|
||||
}
|
||||
|
||||
// El proyecto se valida dentro del candado: que exista y siga Activo
|
||||
let carpetaPdf = null;
|
||||
if (proyectoFolio) {
|
||||
const proyecto = buscarProyecto_(hojaProyectos_(), proyectoFolio);
|
||||
if (!proyecto) throw new Error('No existe el proyecto ' + proyectoFolio + '. Elige otro o cotiza sin proyecto.');
|
||||
if (proyecto.estado !== 'Activo') {
|
||||
throw new Error('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() +
|
||||
': ya no se le pueden agregar cotizaciones.');
|
||||
}
|
||||
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
|
||||
}
|
||||
|
||||
const hojaCot = db_().getSheetByName('Cotizaciones');
|
||||
const folio = siguienteFolio_(cfg.PREFIJO_FOLIO || 'HLG', hojaCot);
|
||||
const ahora = new Date();
|
||||
@ -628,9 +657,10 @@ function guardarCotizacion(payload) {
|
||||
const html = construirHtmlPdf(datos, cfg, logoDataUrl_(cfg.LOGO_FILE_ID));
|
||||
const nombre = folio + ' - ' + (datos.cliente.empresa || datos.cliente.nombre) + '.pdf';
|
||||
const pdf = Utilities.newBlob(html, 'text/html', folio + '.html').getAs('application/pdf').setName(nombre);
|
||||
const archivo = carpetaDelAnio_(ahora.getFullYear()).createFile(pdf);
|
||||
const archivo = (carpetaPdf || carpetaDelAnio_(ahora.getFullYear())).createFile(pdf);
|
||||
|
||||
// Registro (Marca, Dirección y vendedor_* se agregaron después: se ponen los encabezados que falten)
|
||||
// Registro (Marca, Dirección, vendedor_* y proyecto_folio se agregaron después: se reescribe solo la fila 1
|
||||
// de encabezados; las filas de datos no se mueven y las viejas quedan vacías en las columnas nuevas)
|
||||
const nCols = ENCABEZADOS_COTIZACIONES.length;
|
||||
const actuales = hojaCot.getRange(1, 1, 1, nCols).getValues()[0];
|
||||
if (ENCABEZADOS_COTIZACIONES.some((h, i) => actuales[i] !== h)) {
|
||||
@ -643,11 +673,11 @@ function guardarCotizacion(payload) {
|
||||
t(datos.cliente.email), t(datos.cliente.telefono),
|
||||
datos.subtotal, datos.iva, datos.total, datos.elaboro, archivo.getUrl(),
|
||||
JSON.stringify(datos.items), t(datos.notas), MARCAS[datos.marca], t(datos.cliente.direccion),
|
||||
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido),
|
||||
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), proyectoFolio,
|
||||
]);
|
||||
SpreadsheetApp.flush();
|
||||
|
||||
const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total };
|
||||
const resultado = { folio: folio, url: archivo.getUrl(), total: datos.total, proyectoFolio: proyectoFolio };
|
||||
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||
return resultado;
|
||||
} finally {
|
||||
@ -660,11 +690,12 @@ function guardarCotizacion(payload) {
|
||||
* Toma el mayor entre el contador guardado y el último folio registrado en la hoja,
|
||||
* así no se repite aunque alguien borre o edite el contador en Script Properties.
|
||||
*/
|
||||
function siguienteFolio_(prefijo, hojaCot) {
|
||||
function siguienteFolio_(prefijo, hoja, baseContador) {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const anio = Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy');
|
||||
const clave = 'CONSECUTIVO_' + anio;
|
||||
const filas = hojaCot.getLastRow() > 1 ? hojaCot.getRange(2, 1, hojaCot.getLastRow() - 1, 1).getValues() : [];
|
||||
// Cotizaciones: CONSECUTIVO_AAAA; proyectos: CONSECUTIVO_PROY_AAAA (cada tipo de folio lleva su contador)
|
||||
const clave = (baseContador || 'CONSECUTIVO_') + anio;
|
||||
const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 1).getValues() : [];
|
||||
const enHoja = maxConsecutivo(filas.map(r => r[0]), prefijo, anio);
|
||||
const n = Math.max(Number(props.getProperty(clave) || 0), enHoja) + 1;
|
||||
props.setProperty(clave, String(n));
|
||||
@ -688,6 +719,174 @@ function logoDataUrl_(fileId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 4.1 PROYECTOS ======
|
||||
// Pestaña "Proyectos" en la base de datos + carpeta "Cotizador Helgen/Proyectos/AAAA/<folio> — <cliente> — <nombre>/".
|
||||
// Lo que se agregue después (OrdenesCompra, Pagos, Gastos) se liga por proyecto_folio (ver CLAUDE.md).
|
||||
|
||||
const ENCABEZADOS_PROYECTOS = [
|
||||
'Folio', 'Fecha', 'Nombre', 'Cliente', 'cliente_id', 'Ubicación', 'Estado', 'carpeta_id',
|
||||
'vendedor_email', 'vendedor_nombre', 'vendedor_apellido'
|
||||
];
|
||||
const COL_ESTADO_PROYECTO = 7; // G
|
||||
const COL_CARPETA_PROYECTO = 8; // H
|
||||
const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado'];
|
||||
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra', 'Pagos'];
|
||||
const PREFIJO_PROYECTO = 'PRY';
|
||||
|
||||
/**
|
||||
* Pestaña "Proyectos". La primera vez la crea (encabezados + lista desplegable de Estado);
|
||||
* si le faltan encabezados nuevos, reescribe solo la fila 1.
|
||||
*/
|
||||
function hojaProyectos_() {
|
||||
const ss = db_();
|
||||
let hoja = ss.getSheetByName('Proyectos');
|
||||
if (!hoja) {
|
||||
try {
|
||||
hoja = ss.insertSheet('Proyectos');
|
||||
} catch (e) {
|
||||
// Otra petición la acaba de crear
|
||||
hoja = ss.getSheetByName('Proyectos');
|
||||
if (!hoja) throw e;
|
||||
}
|
||||
hoja.setFrozenRows(1);
|
||||
hoja.getRange('A:H').setNumberFormat('@');
|
||||
hoja.getRange('B:B').setNumberFormat('dd/MM/yyyy HH:mm');
|
||||
hoja.getRange(2, COL_ESTADO_PROYECTO, hoja.getMaxRows() - 1, 1).setDataValidation(
|
||||
SpreadsheetApp.newDataValidation().requireValueInList(ESTADOS_PROYECTO, true).setAllowInvalid(false)
|
||||
.setHelpText('Estado del proyecto: Activo, Cerrado o Cancelado.').build());
|
||||
hoja.setColumnWidth(3, 260); hoja.setColumnWidth(4, 260); hoja.setColumnWidth(6, 220);
|
||||
}
|
||||
const n = ENCABEZADOS_PROYECTOS.length;
|
||||
const actuales = hoja.getRange(1, 1, 1, n).getValues()[0];
|
||||
if (ENCABEZADOS_PROYECTOS.some((h, i) => actuales[i] !== h)) {
|
||||
hoja.getRange(1, 1, 1, n).setValues([ENCABEZADOS_PROYECTOS])
|
||||
.setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF');
|
||||
}
|
||||
return hoja;
|
||||
}
|
||||
|
||||
/** Proyectos con búsqueda por folio, cliente o nombre. Por omisión solo los Activos; los más recientes primero. */
|
||||
function listarProyectos(busqueda, incluirInactivos) {
|
||||
const hoja = hojaProyectos_();
|
||||
const filas = hoja.getLastRow() > 1
|
||||
? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
|
||||
return filtrarProyectos(proyectosDesdeFilas(filas), busqueda, incluirInactivos, 50)
|
||||
.map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId,
|
||||
ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }));
|
||||
}
|
||||
|
||||
/** Proyecto por folio (con su número de fila), o null. */
|
||||
function buscarProyecto_(hoja, folio) {
|
||||
const buscado = String(folio || '').trim().toUpperCase();
|
||||
if (!buscado || hoja.getLastRow() < 2) return null;
|
||||
const filas = hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues();
|
||||
const lista = proyectosDesdeFilas(filas);
|
||||
for (let i = 0; i < lista.length; i++) {
|
||||
if (lista[i].folio.toUpperCase() === buscado) return Object.assign(lista[i], { hoja: hoja, fila: i + 2 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alta de proyecto (desde "Nuevo proyecto" en la interfaz). p = {nombre, ubicacion, cliente:{nombre, empresa, rfc},
|
||||
* idSolicitud}. Folio PRY-AAAA-NNNN bajo el mismo candado que las cotizaciones; primero crea la carpeta y luego la
|
||||
* fila, para que no quede un proyecto registrado sin carpeta.
|
||||
*/
|
||||
function crearProyecto(p) {
|
||||
const vendedor = VENDEDOR_;
|
||||
if (!vendedor) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + ' para crear proyectos.');
|
||||
const datos = normalizarProyecto(p);
|
||||
|
||||
const idSolicitud = String((p && p.idSolicitud) || '').slice(0, 64);
|
||||
const cache = CacheService.getScriptCache();
|
||||
const claveCache = idSolicitud ? 'PRY_' + idSolicitud : '';
|
||||
|
||||
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 {
|
||||
if (claveCache) {
|
||||
const previo = cache.get(claveCache);
|
||||
if (previo) return JSON.parse(previo);
|
||||
}
|
||||
|
||||
const hoja = hojaProyectos_();
|
||||
const folio = siguienteFolio_(PREFIJO_PROYECTO, hoja, 'CONSECUTIVO_PROY_');
|
||||
const ahora = new Date();
|
||||
const carpeta = crearCarpetaProyecto_(folio, datos, ahora.getFullYear());
|
||||
|
||||
const t = textoCelda;
|
||||
hoja.appendRow([
|
||||
folio, ahora, t(datos.nombre), t(datos.cliente), t(datos.clienteId), t(datos.ubicacion), 'Activo', carpeta.getId(),
|
||||
t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido),
|
||||
]);
|
||||
SpreadsheetApp.flush();
|
||||
|
||||
const resultado = { folio: folio, nombre: datos.nombre, cliente: datos.cliente, clienteId: datos.clienteId,
|
||||
ubicacion: datos.ubicacion, estado: 'Activo', url: carpeta.getUrl() };
|
||||
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||
return resultado;
|
||||
} finally {
|
||||
lock.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Carpeta "Proyectos" junto a la base de datos (en la carpeta maestra). Se crea sola y se guarda en PROYECTOS_FOLDER_ID. */
|
||||
function carpetaProyectos_() {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const id = props.getProperty('PROYECTOS_FOLDER_ID');
|
||||
if (id) {
|
||||
try {
|
||||
const f = DriveApp.getFolderById(id);
|
||||
if (!f.isTrashed()) return f;
|
||||
} catch (e) { /* se borró: se vuelve a crear abajo */ }
|
||||
}
|
||||
const padres = DriveApp.getFileById(props.getProperty('SPREADSHEET_ID')).getParents();
|
||||
const maestra = padres.hasNext() ? padres.next() : DriveApp.getRootFolder();
|
||||
const carpeta = subcarpeta_(maestra, 'Proyectos');
|
||||
props.setProperty('PROYECTOS_FOLDER_ID', carpeta.getId());
|
||||
return carpeta;
|
||||
}
|
||||
|
||||
/** Subcarpeta por nombre (ignora las que están en la Papelera); si no existe, la crea. */
|
||||
function subcarpeta_(padre, nombre) {
|
||||
const it = padre.getFoldersByName(nombre);
|
||||
while (it.hasNext()) {
|
||||
const f = it.next();
|
||||
if (!f.isTrashed()) return f;
|
||||
}
|
||||
return padre.createFolder(nombre);
|
||||
}
|
||||
|
||||
/** Proyectos/AAAA/<folio> — <cliente> — <nombre>/ con sus subcarpetas. */
|
||||
function crearCarpetaProyecto_(folio, datos, anio) {
|
||||
const carpetaAnio = subcarpeta_(carpetaProyectos_(), String(anio));
|
||||
const carpeta = carpetaAnio.createFolder(nombreCarpetaProyecto(folio, datos.cliente, datos.nombre));
|
||||
SUBCARPETAS_PROYECTO.forEach(s => carpeta.createFolder(s));
|
||||
return carpeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra", "Pagos"…). Se busca por nombre: si alguien la
|
||||
* borró o la renombró, se crea una nueva. Si falta la carpeta del proyecto, se vuelve a crear y se actualiza
|
||||
* carpeta_id en la hoja.
|
||||
*/
|
||||
function subcarpetaProyecto_(proyecto, nombreSub) {
|
||||
let carpeta = null;
|
||||
try {
|
||||
carpeta = proyecto.carpetaId ? DriveApp.getFolderById(proyecto.carpetaId) : null;
|
||||
if (carpeta && carpeta.isTrashed()) carpeta = null;
|
||||
} catch (e) { carpeta = null; }
|
||||
if (!carpeta) {
|
||||
const anio = Number(String(proyecto.folio).split('-')[1]) || new Date().getFullYear();
|
||||
carpeta = crearCarpetaProyecto_(proyecto.folio, proyecto, anio);
|
||||
proyecto.carpetaId = carpeta.getId();
|
||||
if (proyecto.hoja && proyecto.fila) proyecto.hoja.getRange(proyecto.fila, COL_CARPETA_PROYECTO).setValue(carpeta.getId());
|
||||
}
|
||||
return subcarpeta_(carpeta, nombreSub);
|
||||
}
|
||||
|
||||
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
|
||||
|
||||
function redondear(n) {
|
||||
@ -751,8 +950,7 @@ function maxConsecutivo(folios, prefijo, anio) {
|
||||
* Columnas: 0 Folio, 1 Fecha, 2 Cliente, 3 Empresa, 4 RFC, 5 Correo, 6 Teléfono, 15 Dirección.
|
||||
*/
|
||||
function clientesDesdeFilas(filas) {
|
||||
const normal = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
const normal = textoNormal;
|
||||
const mapa = {};
|
||||
(filas || []).forEach((r, i) => {
|
||||
const nombre = String(r[2] || '').trim();
|
||||
@ -773,11 +971,79 @@ function clientesDesdeFilas(filas) {
|
||||
lista.forEach(c => { const n = normal(c.nombre); porNombre[n] = (porNombre[n] || 0) + 1; });
|
||||
lista.forEach(c => {
|
||||
c.etiqueta = porNombre[normal(c.nombre)] > 1 && c.empresa ? c.nombre + ' — ' + c.empresa : c.nombre;
|
||||
c.id = idCliente(c);
|
||||
delete c.orden;
|
||||
});
|
||||
return lista;
|
||||
}
|
||||
|
||||
/** Texto sin acentos, en minúsculas y con espacios simples (para comparar nombres). */
|
||||
function textoNormal(s) {
|
||||
return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// RFC genéricos del SAT (público en general / extranjeros): los comparten muchos clientes, no identifican a nadie
|
||||
const RFC_GENERICOS = ['XAXX010101000', 'XEXX010101000'];
|
||||
|
||||
/**
|
||||
* Identificador de un cliente (no hay hoja de clientes: salen de Cotizaciones). El RFC si es válido y no genérico;
|
||||
* si no, "SIN-RFC:" + nombre|empresa normalizados (la misma llave con que clientesDesdeFilas agrupa).
|
||||
*/
|
||||
function idCliente(c) {
|
||||
const rfc = String((c && c.rfc) || '').replace(/[\s-]/g, '').toUpperCase();
|
||||
if (/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/.test(rfc) && RFC_GENERICOS.indexOf(rfc) === -1) return rfc;
|
||||
return 'SIN-RFC:' + textoNormal(c && c.nombre) + '|' + textoNormal(c && c.empresa);
|
||||
}
|
||||
|
||||
/** Valida y limpia un proyecto nuevo. El cliente se guarda como "nombre — empresa" más su cliente_id. */
|
||||
function normalizarProyecto(p) {
|
||||
const nombre = String((p && p.nombre) || '').trim().replace(/\s+/g, ' ');
|
||||
const ubicacion = String((p && p.ubicacion) || '').trim();
|
||||
const c = (p && p.cliente) || {};
|
||||
const cli = { nombre: String(c.nombre || '').trim(), empresa: String(c.empresa || '').trim(),
|
||||
rfc: String(c.rfc || '').trim().toUpperCase() };
|
||||
if (!nombre) throw new Error('El nombre del proyecto es obligatorio.');
|
||||
if (nombre.length > 80) throw new Error('El nombre del proyecto debe tener máximo 80 caracteres.');
|
||||
if (ubicacion.length > 200) throw new Error('La ubicación debe tener máximo 200 caracteres.');
|
||||
if (!cli.nombre) throw new Error('Elige o escribe el cliente del proyecto (en "Datos del cliente").');
|
||||
return {
|
||||
nombre: nombre, ubicacion: ubicacion,
|
||||
cliente: cli.empresa && textoNormal(cli.empresa) !== textoNormal(cli.nombre) ? cli.nombre + ' — ' + cli.empresa : cli.nombre,
|
||||
clienteId: idCliente(cli),
|
||||
};
|
||||
}
|
||||
|
||||
/** "PRY-2026-0001 — Cliente — Nombre", sin caracteres que Drive o Windows no acepten, máximo 150 caracteres. */
|
||||
function nombreCarpetaProyecto(folio, cliente, nombre) {
|
||||
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const cli = limpio(cliente).slice(0, 60);
|
||||
return [limpio(folio), cli, limpio(nombre)].filter(Boolean).join(' — ').slice(0, 150).trim();
|
||||
}
|
||||
|
||||
/** Filas de la pestaña Proyectos (sin encabezado) → objetos. Columnas en ENCABEZADOS_PROYECTOS. */
|
||||
function proyectosDesdeFilas(filas) {
|
||||
return (filas || []).filter(r => String(r[0] || '').trim()).map(r => ({
|
||||
folio: String(r[0]).trim(),
|
||||
fecha: r[1] instanceof Date
|
||||
? [r[1].getDate(), r[1].getMonth() + 1].map(x => String(x).padStart(2, '0')).join('/') + '/' + r[1].getFullYear()
|
||||
: String(r[1] || ''),
|
||||
nombre: String(r[2] || ''), cliente: String(r[3] || ''), clienteId: String(r[4] || ''),
|
||||
ubicacion: String(r[5] || ''),
|
||||
estado: String(r[6] || '').trim() || 'Activo',
|
||||
carpetaId: String(r[7] || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Busca por folio, cliente o nombre (sin acentos ni mayúsculas); más recientes primero; máximo n. */
|
||||
function filtrarProyectos(lista, busqueda, incluirInactivos, n) {
|
||||
const q = textoNormal(busqueda);
|
||||
return (lista || []).slice().reverse()
|
||||
.filter(p => incluirInactivos || p.estado === 'Activo')
|
||||
.filter(p => !q || textoNormal(p.folio + ' ' + p.cliente + ' ' + p.nombre).includes(q))
|
||||
.slice(0, n || 50);
|
||||
}
|
||||
|
||||
// ---- 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 ' +
|
||||
|
||||
141
tests/proyectos.test.js
Normal file
141
tests/proyectos.test.js
Normal file
@ -0,0 +1,141 @@
|
||||
// Proyectos: folio PRY-AAAA-NNNN, carpeta en Drive con subcarpetas, cotización ligada por proyecto_folio.
|
||||
// Uso: node tests/proyectos.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];
|
||||
const src=fs.readFileSync(path.join(dir,'Logo.js'),'utf8')+'\n'+fs.readFileSync(path.join(dir,'Code.js'),'utf8');
|
||||
|
||||
// Hoja simulada (matriz). getValues siempre devuelve nr filas, como Sheets (celdas vacías = '').
|
||||
function hoja(m){return{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),
|
||||
getRange:(r,c=1,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){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},
|
||||
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this}})}}
|
||||
|
||||
// Drive simulado: árbol de carpetas con Papelera
|
||||
let nId=0; const carpetas={};
|
||||
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=>{f.archivos.push(b);return{getUrl:()=>'https://drive/pdf/'+b.name}}};
|
||||
carpetas[f.id]=f;return f}
|
||||
const maestra=carpeta('Cotizador Helgen',null), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)');
|
||||
const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre;
|
||||
const vivos=f=>f.hijos.filter(h=>!h.trashed);
|
||||
|
||||
const cfg=hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]);
|
||||
// Cotizaciones con encabezados viejos (19 columnas, sin proyecto_folio) y una fila real
|
||||
const ENC19=['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas','Marca','Dirección','vendedor_email','vendedor_nombre','vendedor_apellido'];
|
||||
const filaVieja=['HLG-2026-0004','x','Viejo','Vieja SA','','','',1,0.16,1.16,'antes@helgen.tech','u','[]','','Helgen','','a@helgen.tech','A','B'];
|
||||
const cot=hoja([ENC19.slice(),filaVieja.slice()]);
|
||||
let pry=null;
|
||||
const db={getSheetByName:n=>n==='Config'?cfg:n==='Cotizaciones'?cot:n==='Proyectos'?pry:null,
|
||||
insertSheet:n=>{if(pry)throw new Error('ya existe');return pry=hoja([])},getId:()=>'DB'};
|
||||
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT'},cache={};
|
||||
const dv=()=>{const b={requireValueInList:(l)=>{b.lista=l;return b},setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b};
|
||||
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:()=>''})},
|
||||
Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'20/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''},
|
||||
SpreadsheetApp:{flush(){},newDataValidation:dv,
|
||||
openById:id=>id==='CAT'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:db},
|
||||
DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]},
|
||||
getFileById:()=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return maestra}}}}),
|
||||
getRootFolder:()=>{throw new Error('no debe usar Mi unidad')}},
|
||||
};
|
||||
vm.createContext(ctx); vm.runInContext(src,ctx);
|
||||
vm.runInContext("VENDEDOR_ = {email:'ana@helgen.tech',nombre:'Ana',apellido:'López'}",ctx);
|
||||
|
||||
// --- Lógica pura ---
|
||||
assert.equal(ctx.idCliente({nombre:'Juan',rfc:'abc-010101-ab1'}),'ABC010101AB1');
|
||||
assert.equal(ctx.idCliente({nombre:'Juan',rfc:'XAXX010101000'}),'SIN-RFC:juan|','RFC genérico no identifica');
|
||||
assert.equal(ctx.idCliente({nombre:'José Pérez',empresa:'Ácme',rfc:'123'}),'SIN-RFC:jose perez|acme');
|
||||
assert.throws(()=>ctx.normalizarProyecto({nombre:'',cliente:{nombre:'X'}}),/nombre del proyecto/);
|
||||
assert.throws(()=>ctx.normalizarProyecto({nombre:'P',cliente:{}}),/cliente del proyecto/);
|
||||
assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Luis',empresa:'Solar MX'}}).cliente,'Luis — Solar MX');
|
||||
assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Solar MX',empresa:'solar mx'}}).cliente,'Solar MX');
|
||||
assert.equal(ctx.nombreCarpetaProyecto('PRY-2026-0001','A/B: "C"','Techo*1?'),'PRY-2026-0001 — A B C — Techo 1');
|
||||
// El cliente_id de clientesDesdeFilas es el mismo que el del proyecto
|
||||
const F=(n,e,rfc)=>{const r=['F','x',n,e,rfc,'','',0,0,0,'','','','','',''];return r};
|
||||
assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','')])[0].id,'SIN-RFC:luis|solar mx');
|
||||
assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','LUSM800101AB1')])[0].id,'LUSM800101AB1');
|
||||
|
||||
// --- Crear proyecto ---
|
||||
const nuevo=(extra)=>({nombre:'Techo solar planta',ubicacion:'Querétaro',cliente:{nombre:'Luis',empresa:'Solar MX',rfc:'LUSM800101AB1'},...extra});
|
||||
const p1=ctx.crearProyecto(nuevo({idSolicitud:'p1',vendedor:{email:'impostor@helgen.tech'}}));
|
||||
assert.equal(p1.folio,'PRY-2026-0001'); assert.equal(p1.estado,'Activo'); assert.equal(p1.clienteId,'LUSM800101AB1');
|
||||
assert.equal(pry.m[0].join(','),'Folio,Fecha,Nombre,Cliente,cliente_id,Ubicación,Estado,carpeta_id,vendedor_email,vendedor_nombre,vendedor_apellido');
|
||||
const f1=pry.m[1];
|
||||
assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']);
|
||||
assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
|
||||
assert(!JSON.stringify(pry.m).includes('impostor'));
|
||||
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ con 3 subcarpetas
|
||||
const c1=carpetas[f1[7]];
|
||||
assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta');
|
||||
assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra','Pagos']);
|
||||
assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1');
|
||||
assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
|
||||
// Idempotencia
|
||||
assert.equal(ctx.crearProyecto(nuevo({idSolicitud:'p1'})).folio,'PRY-2026-0001'); assert.equal(pry.m.length,2);
|
||||
// Segundo proyecto: reutiliza Proyectos/2026; texto que parece fórmula va con apóstrofo
|
||||
const p2=ctx.crearProyecto({nombre:'=Bodega',cliente:{nombre:'+52 Ana'},idSolicitud:'p2'});
|
||||
assert.equal(p2.folio,'PRY-2026-0002'); assert.equal(pry.m[2][2],"'=Bodega"); assert.equal(pry.m[2][3],"'+52 Ana");
|
||||
assert.equal(vivos(maestra).filter(h=>h.nombre==='Proyectos').length,1); assert.equal(vivos(c1.padre).length,2);
|
||||
// Contador borrado no repite folio
|
||||
delete props.CONSECUTIVO_PROY_2026; assert.equal(ctx.crearProyecto(nuevo({nombre:'Tercero',idSolicitud:'p3'})).folio,'PRY-2026-0003');
|
||||
|
||||
// --- Listar / buscar (solo Activos por omisión) ---
|
||||
pry.m[3][6]='Cancelado';
|
||||
assert.deepEqual(ctx.listarProyectos('').map(p=>p.folio),['PRY-2026-0002','PRY-2026-0001']);
|
||||
assert.deepEqual(ctx.listarProyectos('',true).map(p=>p.folio),['PRY-2026-0003','PRY-2026-0002','PRY-2026-0001']);
|
||||
assert.deepEqual(ctx.listarProyectos('solar').map(p=>p.folio),['PRY-2026-0001'],'por cliente');
|
||||
assert.deepEqual(ctx.listarProyectos('TECHO').map(p=>p.folio),['PRY-2026-0001'],'por nombre');
|
||||
assert.deepEqual(ctx.listarProyectos('pry-2026-0002').map(p=>p.folio),['PRY-2026-0002'],'por folio');
|
||||
assert.equal(ctx.listarProyectos('')[0].carpetaId,undefined,'no expone IDs de Drive');
|
||||
|
||||
// --- Cotizar ---
|
||||
const base={cliente:{nombre:'Luis',empresa:'Solar MX'},items:[{descripcion:'Panel',cantidad:2,precio:100}]};
|
||||
const r1=ctx.guardarCotizacion({...base,proyectoFolio:'pry-2026-0001',idSolicitud:'c1'});
|
||||
assert.equal(r1.folio,'HLG-2026-0005'); assert.equal(r1.proyectoFolio,'PRY-2026-0001');
|
||||
const subCot=c1.hijos.find(h=>h.nombre==='Cotizaciones');
|
||||
assert.deepEqual(subCot.archivos.map(a=>a.name),['HLG-2026-0005 - Solar MX.pdf'],'PDF en Cotizaciones del proyecto');
|
||||
// Encabezados: solo cambia la fila 1; la fila vieja no se mueve y queda vacía en T
|
||||
assert.equal(cot.m[0].length,20); assert.equal(cot.m[0][19],'proyecto_folio'); assert.deepEqual(cot.m[0].slice(0,19),ENC19);
|
||||
assert.deepEqual(cot.m[1],filaVieja,'fila vieja intacta');
|
||||
assert.equal(cot.m[2][19],'PRY-2026-0001');
|
||||
// Sin proyecto: carpeta del año, columna T vacía
|
||||
ctx.guardarCotizacion({...base,idSolicitud:'c2'});
|
||||
assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],'');
|
||||
// Historial
|
||||
const h=ctx.listarCotizaciones('');
|
||||
assert.deepEqual(h.map(c=>c.proyecto),['Sin proyecto','PRY-2026-0001','Sin proyecto']);
|
||||
assert.deepEqual(ctx.listarCotizaciones('pry-2026-0001').map(c=>c.folio),['HLG-2026-0005'],'busca por proyecto');
|
||||
// Proyecto inexistente o cancelado: error y no se consume folio
|
||||
assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0099',idSolicitud:'c3'}),/No existe el proyecto PRY-2026-0099/);
|
||||
assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0003',idSolicitud:'c4'}),/está cancelado/);
|
||||
assert.equal(props.CONSECUTIVO_2026,'6'); assert.equal(cot.m.length,4);
|
||||
|
||||
// --- Subcarpetas borradas o renombradas: se crea una nueva ---
|
||||
subCot.trashed=true;
|
||||
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c5'});
|
||||
let nueva=vivos(c1).find(h=>h.nombre==='Cotizaciones');
|
||||
assert(nueva&&nueva!==subCot&&nueva.archivos.length===1,'Cotizaciones en Papelera -> nueva');
|
||||
nueva.nombre='Cotizaciones viejas';
|
||||
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c6'});
|
||||
assert.equal(vivos(c1).filter(h=>h.nombre==='Cotizaciones').length,1,'renombrada -> nueva');
|
||||
// Carpeta del proyecto en la Papelera: se vuelve a crear y se actualiza carpeta_id
|
||||
c1.trashed=true;
|
||||
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c7'});
|
||||
const c1b=carpetas[pry.m[1][7]];
|
||||
assert(c1b!==c1&&!c1b.trashed&&c1b.hijos.find(h=>h.nombre==='Cotizaciones').archivos.length===1);
|
||||
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra','Pagos']);
|
||||
|
||||
// --- Fuera de doPost (sin vendedor) no se crean proyectos ---
|
||||
vm.runInContext('VENDEDOR_ = null',ctx);
|
||||
assert.throws(()=>ctx.crearProyecto(nuevo({idSolicitud:'x'})),/Inicia sesión/);
|
||||
console.log('OK proyectos');
|
||||
@ -8,13 +8,14 @@ src=src.replace(/const CLIENT_ID_GOOGLE = '[^']*';/,"const CLIENT_ID_GOOGLE = '"
|
||||
function hoja(m){return{m,getLastRow:()=>m.length,getDataRange:()=>({getValues:()=>m.map(r=>r.slice()),getDisplayValues:()=>m.map(r=>r.map(x=>x instanceof Date?'19/10/2026':String(x??'')))}),
|
||||
appendRow:a=>m.push(a),
|
||||
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){}}:({
|
||||
getValues:()=>m.slice(r-1,r-1+nr).map(x=>{const f=x.slice(c-1,c-1+nc);while(f.length<nc)f.push('');return f}),
|
||||
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}),
|
||||
setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this},
|
||||
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this}})}}
|
||||
setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this}})}}
|
||||
const cfg=hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]);
|
||||
// Hoja con una fila vieja (sin columnas vendedor_*)
|
||||
const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas','Marca','Dirección'],
|
||||
['HLG-2026-0001','x','Viejo','','','','',1,0.16,1.16,'antes@helgen.tech','u','[]','','Helgen','']]);
|
||||
let pry=null;
|
||||
const props={SPREADSHEET_ID:'x',FOLDER_ID:'y',CATALOGO_ID:'c'},cache={};
|
||||
const ahora=Math.floor(Date.now()/1000);
|
||||
const BUENO={aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email:'Ana.Lopez@helgen.tech',email_verified:'true',hd:'helgen.tech',given_name:'Ana',family_name:'López Díaz'};
|
||||
@ -32,7 +33,9 @@ const ctx={console,Date,JSON,
|
||||
UrlFetchApp:{fetch:url=>{fetches++;const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t];
|
||||
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
|
||||
ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})},
|
||||
SpreadsheetApp:{flush(){},openById:id=>id==='c'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:{getSheetByName:n=>n==='Config'?cfg:cot}},
|
||||
SpreadsheetApp:{flush(){},openById:id=>id==='c'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:{getSheetByName:n=>n==='Config'?cfg:n==='Proyectos'?pry:cot,
|
||||
insertSheet:n=>pry=Object.assign(hoja([]),{setFrozenRows(){},getMaxRows:()=>1000,setColumnWidth(){}})},
|
||||
newDataValidation:()=>{const b={requireValueInList:()=>b,setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}},
|
||||
DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:()=>({getUrl:()=>'u'})})})})},
|
||||
};
|
||||
vm.createContext(ctx); vm.runInContext(src,ctx);
|
||||
@ -60,16 +63,18 @@ assert.equal(cot.m.length,filasAntes,'sin sesión no se guarda nada');
|
||||
assert.equal(post('no es json').ok,false);
|
||||
assert.equal(post({funcion:'setup',token:'t-bueno'}).error,'Función no permitida: setup.');
|
||||
assert.equal(post({funcion:'guardarCotizacion',args:[base]}).codigo,'SESION');
|
||||
assert.equal(post({funcion:'crearProyecto',args:[{nombre:'X',cliente:{nombre:'Y'}}]}).codigo,'SESION');
|
||||
assert.equal(post({funcion:'listarProyectos',args:['']}).codigo,'SESION');
|
||||
|
||||
// --- Guardar con token válido: vendedor del token, aunque el navegador mande otro ---
|
||||
fetches=0;
|
||||
const r=post({funcion:'guardarCotizacion',token:'t-bueno',
|
||||
args:[{...base,idSolicitud:'ok1',vendedor_email:'impostor@helgen.tech',vendedor:{email:'impostor@helgen.tech',nombre:'X'},elaboro:'impostor@helgen.tech'}]});
|
||||
assert(r.ok,r.error); assert.equal(r.datos.folio,'HLG-2026-0002');
|
||||
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido']);
|
||||
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido','proyecto_folio']);
|
||||
const fila=cot.m[cot.m.length-1];
|
||||
assert.equal(fila[10],'ana.lopez@helgen.tech','Elaboró = correo del token');
|
||||
assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz']);
|
||||
assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz',''],'sin proyecto = columna T vacía');
|
||||
assert(!JSON.stringify(fila).includes('impostor'));
|
||||
// Caché: el token ya se verificó (en la llamada a setup); no se vuelve a consultar a Google
|
||||
assert.equal(post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).ok,true); assert.equal(fetches,0);
|
||||
@ -82,6 +87,10 @@ assert.equal(h[0].vendedor,'Ana López Díaz'); assert.equal(h[1].vendedor,'ante
|
||||
// Datos iniciales: usuario = correo del token
|
||||
const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos;
|
||||
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
|
||||
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(h[1].proyecto,'Sin proyecto');
|
||||
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
|
||||
// Fuera de doPost (sin token) guardar se rechaza
|
||||
assert.throws(()=>ctx.guardarCotizacion({...base,idSolicitud:'directo'}),/Inicia sesión/);
|
||||
// doGet ya no sirve la app
|
||||
|
||||
@ -62,6 +62,10 @@
|
||||
.sat-op:hover { border-color:var(--teal); background:#E6FBF7; }
|
||||
.sat-op b { min-width:80px; }
|
||||
.sat-razon { display:block; color:#5B6B85; font-size:12px; margin-top:2px; }
|
||||
/* Proyecto de la cotización (opcional) */
|
||||
.proy { margin-top:16px; padding-top:14px; border-top:1px dashed var(--borde); }
|
||||
.proy-fila { display:flex; gap:8px; align-items:flex-end; flex-wrap:wrap; }
|
||||
.proy-fila > div { flex:1; min-width:240px; }
|
||||
a { color:var(--azul); font-weight:bold; }
|
||||
/* Sesión con Google (solo cuentas @helgen.tech) */
|
||||
.usuario { margin-left:auto; display:flex; align-items:center; gap:12px; font-size:12px; color:#D5DCE8; }
|
||||
@ -116,7 +120,7 @@
|
||||
<!-- ===== NUEVA COTIZACIÓN ===== -->
|
||||
<section id="vistaNueva">
|
||||
<div class="modulo">
|
||||
<h2>1 · Datos del cliente</h2>
|
||||
<h2>1 · Cliente y proyecto</h2>
|
||||
<div class="grid">
|
||||
<div><label>Línea de negocio *</label>
|
||||
<select id="marca">
|
||||
@ -133,6 +137,29 @@
|
||||
<div><label>Teléfono</label><input id="cTel"></div>
|
||||
<div><label>Dirección / Obra</label><input id="cDir"></div>
|
||||
</div>
|
||||
|
||||
<!-- Proyecto (opcional): vacío = "Sin proyecto" -->
|
||||
<div class="proy">
|
||||
<div class="proy-fila">
|
||||
<div><label>Proyecto (opcional)</label>
|
||||
<input id="proyecto" list="proyectosList" autocomplete="off" placeholder="Sin proyecto · busca por folio, cliente o nombre">
|
||||
<datalist id="proyectosList"></datalist></div>
|
||||
<button type="button" class="btn btn-sec" onclick="abrirNuevoProyecto()">+ Nuevo proyecto</button>
|
||||
</div>
|
||||
<div id="proyInfo" class="nota" style="margin-top:4px"></div>
|
||||
<div id="nuevoProy" class="sat oculto">
|
||||
<div class="sat-titulo">Nuevo proyecto · cliente: <span id="npCliente"></span></div>
|
||||
<div class="grid">
|
||||
<div><label>Nombre del proyecto *</label><input id="npNombre" maxlength="80" placeholder="Ej. Techo solar planta 2"></div>
|
||||
<div><label>Ubicación</label><input id="npUbicacion" maxlength="200" placeholder="Ciudad, dirección u obra"></div>
|
||||
</div>
|
||||
<div class="acciones">
|
||||
<button type="button" id="btnProy" class="btn" onclick="crearProyecto()">Crear proyecto</button>
|
||||
<button type="button" class="btn btn-sec" onclick="cerrarNuevoProyecto()">Cancelar</button>
|
||||
<span class="nota">El cliente es el de "Nombre / Contacto": elígelo de la lista o escribe uno nuevo.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modulo">
|
||||
@ -171,9 +198,9 @@
|
||||
<section id="vistaHist" class="oculto">
|
||||
<div class="modulo">
|
||||
<h2>Historial de cotizaciones</h2>
|
||||
<input id="buscar" placeholder="Buscar por folio, cliente o empresa…" oninput="buscarDebounce()" style="margin-bottom:12px">
|
||||
<input id="buscar" placeholder="Buscar por folio, cliente, empresa o proyecto…" oninput="buscarDebounce()" style="margin-bottom:12px">
|
||||
<table>
|
||||
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Vendedor</th><th>PDF</th></tr></thead>
|
||||
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Vendedor</th><th>Proyecto</th><th>PDF</th></tr></thead>
|
||||
<tbody id="hist"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -375,10 +402,11 @@
|
||||
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
|
||||
|
||||
<script>
|
||||
let IVA = 0.16, CATALOGO = [], CLIENTES = [];
|
||||
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [];
|
||||
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
|
||||
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
let ID_SOLICITUD = nuevoId();
|
||||
let ID_PROYECTO = nuevoId(); // igual que ID_SOLICITUD, para "Crear proyecto"
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
||||
@ -389,6 +417,7 @@
|
||||
google.script.run.withSuccessHandler(d => {
|
||||
IVA = d.ivaTasa; CATALOGO = d.catalogo;
|
||||
recibirClientes(d.clientes);
|
||||
recibirProyectos(d.proyectos);
|
||||
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
||||
pintarDatalists();
|
||||
agregarFila();
|
||||
@ -450,13 +479,20 @@
|
||||
items: leerItems().map(({_tr, ...it}) => it),
|
||||
notas: $('notas').value,
|
||||
marca: $('marca').value,
|
||||
proyectoFolio: '',
|
||||
idSolicitud: ID_SOLICITUD,
|
||||
};
|
||||
if ($('btnGuardar').disabled) return;
|
||||
if ($('proyecto').value.trim()) {
|
||||
const p = proyectoElegido();
|
||||
if (!p) { mostrarError('Elige un proyecto de la lista o deja el campo vacío para cotizar sin proyecto.'); return; }
|
||||
payload.proyectoFolio = p.folio;
|
||||
}
|
||||
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
|
||||
google.script.run
|
||||
.withSuccessHandler(r => {
|
||||
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
|
||||
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
|
||||
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
||||
limpiar();
|
||||
})
|
||||
@ -468,7 +504,9 @@
|
||||
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
|
||||
$('filas').innerHTML = ''; agregarFila(); recalcular();
|
||||
$('cInfo').textContent = '';
|
||||
$('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto();
|
||||
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
|
||||
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores
|
||||
ID_SOLICITUD = nuevoId();
|
||||
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
|
||||
}
|
||||
@ -477,13 +515,14 @@
|
||||
let tBusq;
|
||||
function buscarDebounce() { clearTimeout(tBusq); tBusq = setTimeout(cargarHistorial, 300); }
|
||||
function cargarHistorial() {
|
||||
$('hist').innerHTML = '<tr><td colspan="7">Cargando…</td></tr>';
|
||||
$('hist').innerHTML = '<tr><td colspan="8">Cargando…</td></tr>';
|
||||
google.script.run.withSuccessHandler(lista => {
|
||||
$('hist').innerHTML = lista.length ? lista.map(c =>
|
||||
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
|
||||
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.vendedor || c.elaboro) + '</td><td>' +
|
||||
(c.proyecto === 'Sin proyecto' ? '<span class="nota">Sin proyecto</span>' : esc(c.proyecto)) + '</td><td>' +
|
||||
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
|
||||
: '<tr><td colspan="7">Sin resultados.</td></tr>';
|
||||
: '<tr><td colspan="8">Sin resultados.</td></tr>';
|
||||
}).withFailureHandler(mostrarError).listarCotizaciones($('buscar').value);
|
||||
}
|
||||
|
||||
@ -507,6 +546,83 @@
|
||||
$('cNombre').addEventListener('change', autorrellenarCliente);
|
||||
$('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); });
|
||||
|
||||
// ---- Proyectos (opcional al cotizar) ----
|
||||
// La opción de la lista es "PRY-2026-0001 — Cliente — Nombre" (así se puede buscar por cualquiera de los tres)
|
||||
const etiquetaProyecto = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre;
|
||||
|
||||
function recibirProyectos(lista) {
|
||||
PROYECTOS = lista || [];
|
||||
$('proyectosList').innerHTML = PROYECTOS.map(p =>
|
||||
'<option value="' + esc(etiquetaProyecto(p)) + '" label="' + esc(p.ubicacion || 'Proyecto activo') + '">').join('');
|
||||
pintarProyInfo();
|
||||
}
|
||||
|
||||
/** Proyecto que corresponde a lo escrito (etiqueta completa o solo el folio); null si no hay. */
|
||||
function proyectoElegido() {
|
||||
const v = $('proyecto').value.trim();
|
||||
const folio = (v.match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
||||
return (folio && PROYECTOS.find(p => p.folio === folio)) || null;
|
||||
}
|
||||
|
||||
function pintarProyInfo() {
|
||||
const v = $('proyecto').value.trim(), p = proyectoElegido();
|
||||
$('proyInfo').textContent = p ? 'Proyecto activo' + (p.ubicacion ? ' · ' + p.ubicacion : '') + ' · desde ' + p.fecha
|
||||
: v ? 'Elige un proyecto de la lista (solo aparecen los activos) o deja el campo vacío.'
|
||||
: 'Sin proyecto: el PDF se guarda en la carpeta del año, como siempre.';
|
||||
}
|
||||
|
||||
/** Al elegir un proyecto con "Nombre / Contacto" vacío, se llena con su cliente. */
|
||||
function alElegirProyecto() {
|
||||
pintarProyInfo();
|
||||
const p = proyectoElegido();
|
||||
if (!p) return;
|
||||
$('proyecto').value = etiquetaProyecto(p);
|
||||
if ($('cNombre').value.trim()) return;
|
||||
const c = CLIENTES.find(x => x.id === p.clienteId);
|
||||
if (c) { $('cNombre').value = c.etiqueta; autorrellenarCliente(); return; }
|
||||
const [nombre, empresa] = p.cliente.split(' — ');
|
||||
$('cNombre').value = nombre || ''; $('cEmpresa').value = empresa || '';
|
||||
}
|
||||
$('proyecto').addEventListener('change', alElegirProyecto);
|
||||
$('proyecto').addEventListener('input', () => { if (proyectoElegido() && $('proyecto').value.includes(' — ')) alElegirProyecto(); else pintarProyInfo(); });
|
||||
|
||||
function abrirNuevoProyecto() {
|
||||
$('nuevoProy').classList.remove('oculto');
|
||||
pintarClienteProyecto();
|
||||
$('npNombre').focus();
|
||||
}
|
||||
function cerrarNuevoProyecto() {
|
||||
$('nuevoProy').classList.add('oculto');
|
||||
$('npNombre').value = ''; $('npUbicacion').value = '';
|
||||
}
|
||||
function pintarClienteProyecto() {
|
||||
const n = $('cNombre').value.trim(), e = $('cEmpresa').value.trim();
|
||||
$('npCliente').textContent = n ? n + (e ? ' — ' + e : '') : '(primero escribe o elige el cliente arriba)';
|
||||
}
|
||||
['cNombre', 'cEmpresa'].forEach(id => $(id).addEventListener('input', pintarClienteProyecto));
|
||||
|
||||
function crearProyecto() {
|
||||
if (!$('cNombre').value.trim()) { mostrarError('Primero elige o escribe el cliente en "Nombre / Contacto".'); return; }
|
||||
if (!$('npNombre').value.trim()) { mostrarError('Escribe el nombre del proyecto.'); return; }
|
||||
if ($('btnProy').disabled) return;
|
||||
$('btnProy').disabled = true; $('btnProy').textContent = 'Creando carpeta…';
|
||||
const listo = () => { $('btnProy').disabled = false; $('btnProy').textContent = 'Crear proyecto'; };
|
||||
google.script.run
|
||||
.withSuccessHandler(p => {
|
||||
listo(); ID_PROYECTO = nuevoId();
|
||||
recibirProyectos([p].concat(PROYECTOS.filter(x => x.folio !== p.folio)));
|
||||
$('proyecto').value = etiquetaProyecto(p); pintarProyInfo(); cerrarNuevoProyecto();
|
||||
aviso('ok', 'Proyecto <b>' + esc(p.folio) + '</b> creado y elegido para esta cotización · ' +
|
||||
'<a href="' + esc(p.url) + '" target="_blank">Abrir carpeta</a>');
|
||||
})
|
||||
.withFailureHandler(err => { listo(); mostrarError(err); })
|
||||
.crearProyecto({
|
||||
nombre: $('npNombre').value, ubicacion: $('npUbicacion').value,
|
||||
cliente: { nombre: $('cNombre').value, empresa: $('cEmpresa').value, rfc: $('cRfc').value },
|
||||
idSolicitud: ID_PROYECTO,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Catálogo ----
|
||||
let CLAVE_EDITANDO = '';
|
||||
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user