mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
Compras: órdenes de compra por proyecto, proveedores y margen (fase 3)
- Rol nuevo ROL_COMPRAS (Script Properties): solo ese rol crea, ve, recibe y cancela órdenes; el servidor lo revisa en cada llamada y los demás reciben codigo PERMISO. getDatosIniciales agrega permisos.compras. - Órdenes OC-AAAA-NNNN con el mismo candado e idempotencia, solo en proyectos Activos con contrato firmado. Estados Emitida / Recibida / Cancelada (con motivo; nunca se borran). La línea de negocio sale del contrato. - Libro y carpeta "Cotizador Helgen — Compras" aparte y restringidos con privado_(): ni en la Base de datos ni en la carpeta del proyecto ni en cobranza. Pestañas OrdenesCompra, Proveedores (se llena sola) y Bitacora. - PDF de la orden siempre con la razón social de Helgen y los términos TERMINOS_OC (se agregan solos a Config). Sin tipo de cambio ni línea. - Costo siempre capturado, nunca el precio de venta. Orden en otra moneda que el proyecto: tipo de cambio obligatorio y costo convertido. - Margen (contratado sin IVA − órdenes vigentes sin IVA) solo en la vista de cobranza; preparado para sumar nómina y horas. Con órdenes vigentes la aceptada no puede cambiar de moneda. - Cobranza y compras comparten la mecánica de área privada (carpetaArea_, libroArea_, enRol_). Los proyectos nuevos ya no crean "Órdenes de compra". - Interfaz: pestaña Compras y tarjeta de margen en Cobranza. - Pruebas: tests/compras.test.js nuevo; interfaz, cobranza, sesión, proyectos y cotización actualizadas (oc.html de ejemplo). Las ocho pasan. Sin publicar: falta respaldo, prueba en PRUEBAS, clasp y wrangler (CLAUDE.md §9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
985df1f52d
commit
1a02e2df3f
123
CLAUDE.md
123
CLAUDE.md
@ -15,7 +15,8 @@ Desde el **19/10/2026** (§11) la interfaz se sirve desde **Cloudflare** y habla
|
|||||||
|
|
||||||
Además: **proyectos** (opcionales: folio `PRY-AAAA-0001` y carpeta propia en Drive; §6.9), **cobranza por proyecto**
|
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),
|
(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
|
**órdenes de compra por proyecto** (`OC-AAAA-0001` con PDF, proveedores y margen del proyecto; solo rol de compras; el margen solo
|
||||||
|
lo ve cobranza; §6.12), 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.
|
||||||
@ -34,6 +35,7 @@ autorrelleno, y claves SAT internas por producto para contabilidad (con sugerenc
|
|||||||
| 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`) | `1A9uoRdhFdWOBacWCxx4Ktw1d9iQH7JRc` (creada el 20/10/2026 con `prepararCobranza()`; fuera de la carpeta maestra, en Mi unidad). Libro "Cotizador Helgen — Cobranza (datos)" (`COBRANZA_DB_ID`) `1uk9FNzTkQPjvWJw2R5wb8HXZl9c8ldYvyB64c8yjHL0`. Lectores: tania@ y leo@ (§6.11) |
|
| Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | `1A9uoRdhFdWOBacWCxx4Ktw1d9iQH7JRc` (creada el 20/10/2026 con `prepararCobranza()`; fuera de la carpeta maestra, en Mi unidad). Libro "Cotizador Helgen — Cobranza (datos)" (`COBRANZA_DB_ID`) `1uk9FNzTkQPjvWJw2R5wb8HXZl9c8ldYvyB64c8yjHL0`. Lectores: tania@ y leo@ (§6.11) |
|
||||||
|
| Carpeta "Cotizador Helgen — Compras" (`COMPRAS_FOLDER_ID`) | **Todavía no existe** (fase 3 construida, sin publicar; §9). La crea `prepararCompras()` en Mi unidad, fuera de la carpeta maestra, con su libro "Cotizador Helgen — Compras (datos)" (`COMPRAS_DB_ID`). Anotar aquí sus IDs al crearlos |
|
||||||
| 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) |
|
||||||
| Respaldos antes de Cobranza | Carpeta "Respaldos 2026-10-20 (antes de Cobranza)" `1C_yGbX0Gk_cZBwegdswPFvuzCVDdyTLF` (en la carpeta maestra): Base de datos `14C3d7w2ouaygtCGi0BP1QocGiqG4gf5Umh9O4KHgdow`, Catálogo `140_1sfwXF9u_aL1egDPYSbsE0rMwELGPZ5rkGVdzQnc` (antes de las columnas U:V, la columna I del catálogo y la pestaña "Tipo de cambio"). Verificadas contra los originales |
|
| Respaldos antes de Cobranza | Carpeta "Respaldos 2026-10-20 (antes de Cobranza)" `1C_yGbX0Gk_cZBwegdswPFvuzCVDdyTLF` (en la carpeta maestra): Base de datos `14C3d7w2ouaygtCGi0BP1QocGiqG4gf5Umh9O4KHgdow`, Catálogo `140_1sfwXF9u_aL1egDPYSbsE0rMwELGPZ5rkGVdzQnc` (antes de las columnas U:V, la columna I del catálogo y la pestaña "Tipo de cambio"). Verificadas contra los originales |
|
||||||
@ -177,6 +179,25 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
|||||||
(un permiso heredado no se puede quitar en el hijo: 403 `cannotDeletePermission`). Si falla, lanza error.
|
(un permiso heredado no se puede quitar en el hijo: 403 `cannotDeletePermission`). Si falla, lanza error.
|
||||||
- `prepararCobranza()` — correr desde el editor: crea carpeta y libro (o los repara si ya existen), restringe primero la
|
- `prepararCobranza()` — correr desde el editor: crea carpeta y libro (o los repara si ya existen), restringe primero la
|
||||||
carpeta y luego el libro, y registra `ROL_COBRANZA`. Sirve para autorizar permisos. No comparte nada.
|
carpeta y luego el libro, y registra `ROL_COBRANZA`. Sirve para autorizar permisos. No comparte nada.
|
||||||
|
- Cobranza y compras comparten la mecánica de "área privada": `AREA_COBRANZA` / `AREA_COMPRAS` + `carpetaArea_`, `libroArea_`,
|
||||||
|
`libroAreaSiExiste_`, `hojaDeArea_(libro, hojas, nombre)`, `filasDeArea_`; roles con `enRol_(propiedad)`.
|
||||||
|
`contratosFirmados_()` = mapa folio → contrato firmado (lo usan el buscador de cotizaciones y compras).
|
||||||
|
- `vistaCobranza_` incluye `margen` (`margenDeContrato_`: contratado sin IVA − órdenes de compra no canceladas; si algo falla,
|
||||||
|
`{error}` y la vista carga igual). `marcarAceptada` rechaza una aceptada de otra moneda si hay órdenes vigentes (como con pagos).
|
||||||
|
|
||||||
|
### 4.5.3 Compras (sección 4.3 del archivo; ver §6.12)
|
||||||
|
- Todas empiezan con `exigirCompras_()` (token + correo en `ROL_COMPRAS`; si no, `codigo: "PERMISO"`).
|
||||||
|
- *(UI)* `listarProyectosCompras(busqueda)` (solo `Activo` con contrato firmado; con `moneda` y `marca`, **sin montos**),
|
||||||
|
`getComprasProyecto(folio)` (`vistaCompras_`: proyecto, `moneda`, `marca`, `puedeEmitir`, órdenes, proveedores; sin contrato ni
|
||||||
|
margen), `guardarOrdenCompra(p)` (candado + idempotencia `OC_<idSolicitud>`; revisa dentro del candado proyecto Activo y firmado;
|
||||||
|
folio con `siguienteFolio_('OC', hoja, 'CONSECUTIVO_OC_')`; actualiza el proveedor; PDF; fila; Bitacora),
|
||||||
|
`marcarOcRecibida(folio, 'AAAA-MM-DD')` (solo Emitida; fecha no futura ni anterior a la orden), `cancelarOrdenCompra(folio, motivo)`
|
||||||
|
(solo Emitida; motivo ≥ 5).
|
||||||
|
- Internas: `carpetaCompras_`, `libroCompras_`, `libroComprasSiExiste_`, `hojaCompras_`, `ordenDesdeFila_`, `ordenesDeProyecto_`,
|
||||||
|
`proveedoresDelLibro_`, `guardarProveedor_` (alta o actualización por id; si llega con RFC y existía sin RFC, esa fila toma el RFC),
|
||||||
|
`buscarOrden_`, `bitacora_(…, HOJAS_COMPRAS)`.
|
||||||
|
- `prepararCompras()` — correr desde el editor: crea o repara carpeta y libro restringidos, agrega `TERMINOS_OC` a Config y
|
||||||
|
muestra `ROL_COMPRAS`. No comparte nada.
|
||||||
|
|
||||||
### 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, moneda)` ("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.";
|
||||||
@ -203,9 +224,20 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e
|
|||||||
- SAT: `palabrasSat`, `SINONIMOS_SAT`, `PALABRAS_SERVICIO_SAT`, `buscarClavesSat(consulta, filas, n)`, `candidatosSat`,
|
- SAT: `palabrasSat`, `SINONIMOS_SAT`, `PALABRAS_SERVICIO_SAT`, `buscarClavesSat(consulta, filas, n)`, `candidatosSat`,
|
||||||
`solicitudIaSat`, `leerRespuestaIaSat` (solo acepta claves que estaban entre los candidatos). Modelo: `MODELO_IA_SAT`.
|
`solicitudIaSat`, `leerRespuestaIaSat` (solo acepta claves que estaban entre los candidatos). Modelo: `MODELO_IA_SAT`.
|
||||||
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
|
- `construirHtmlPdf(d, cfg, logoUrl)` — HTML del PDF (ver reglas 3–4).
|
||||||
|
- Compras: `normalizarProveedor`, `proveedorId` (RFC válido no genérico o `SIN-RFC:razón social`), `fusionarProveedor` (un dato
|
||||||
|
vacío no borra uno anterior), `proveedoresDesdeFilas` (con `etiqueta` y `fila`), `normalizarOrdenCompra(p, {moneda, ubicacion, hoy,
|
||||||
|
ivaTasa})` (entrega obligatoria y no anterior a hoy; lugar = ubicación del proyecto si viene vacío; condiciones obligatorias; IVA
|
||||||
|
16/8/0 % (`IVA_OC`); renglones con costo capturado; tipo de cambio obligatorio si la moneda no es la del proyecto;
|
||||||
|
`subtotalProyecto` sin IVA en la moneda del proyecto), `margenProyecto(contratado, moneda, costos)` (lanza error si un costo
|
||||||
|
está en otra moneda; `porcentaje` con 1 decimal), `nombreArchivoOc`, `terminosOcParaPdf(cfg)` (si la celda está vacía, los
|
||||||
|
iniciales), `construirHtmlOrdenCompra(d, cfg, logoUrl)` (mismas reglas que el de cotización; sin tipo de cambio ni línea de negocio).
|
||||||
|
|
||||||
### 4.7 Interfaz (`web/cotizador/index.html`)
|
### 4.7 Interfaz (`web/cotizador/index.html`)
|
||||||
Pestañas: **Nueva cotización**, **Historial**, **Catálogo** y **Cobranza** (solo si `permisos.cobranza`). Moneda: selector
|
Pestañas: **Nueva cotización**, **Historial**, **Catálogo**, **Cobranza** (solo si `permisos.cobranza`; con tarjeta de margen
|
||||||
|
`#cobMargen`) y **Compras** (solo si `permisos.compras`): `cargarProyectosCom`, `pintarCompras(vista)` (al cambiar de proyecto
|
||||||
|
limpia el formulario), `recibirProveedores`/`autorrellenarProveedor`, `agregarRenglonOc` (el catálogo llena clave, descripción y
|
||||||
|
unidad; el costo queda vacío; listas `#ocCatalogo`/`#ocClaves` sin precio), `recalcularOc`, `pintarTcOc` (tipo de cambio si la
|
||||||
|
moneda no es la del proyecto), `limpiarOc`, `guardarOc`, `recibirOc` (fecha con `window.prompt`), `cancelarOc` (motivo). Moneda: selector
|
||||||
`#moneda`, `monedaActual`, `dinero(n, moneda)`, `cambiarMoneda` (en USD el catálogo llena clave/descripción/unidad pero **no**
|
`#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`
|
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`),
|
si el servidor lo pide), `guardarEsquemaCob`, `firmarCob`, `desbloquearCob`, `registrarPago` (lee el archivo con `FileReader`),
|
||||||
@ -226,8 +258,8 @@ 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
|
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones
|
||||||
│ (los proyectos creados antes de Cobranza tienen además "Pagos", vacía)
|
│ (los creados antes de Compras tienen además "Órdenes de compra", vacía, y antes de Cobranza "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
|
||||||
@ -237,6 +269,9 @@ Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
|
|||||||
📁 Cotizador Helgen — Cobranza (COBRANZA_FOLDER_ID; lectores tania@ y leo@)
|
📁 Cotizador Helgen — Cobranza (COBRANZA_FOLDER_ID; lectores tania@ y leo@)
|
||||||
├─ 📊 Cotizador Helgen — Cobranza (datos) (COBRANZA_DB_ID: Contratos, Esquemas, Pagos, Bitacora)
|
├─ 📊 Cotizador Helgen — Cobranza (datos) (COBRANZA_DB_ID: Contratos, Esquemas, Pagos, Bitacora)
|
||||||
└─ 📁 2026 / 📁 PRY-2026-0002 / PAG-2026-0001 — Transferencia SPEI 123.pdf
|
└─ 📁 2026 / 📁 PRY-2026-0002 / PAG-2026-0001 — Transferencia SPEI 123.pdf
|
||||||
|
📁 Cotizador Helgen — Compras (COMPRAS_FOLDER_ID; se comparte por persona con quien deba abrir los PDF)
|
||||||
|
├─ 📊 Cotizador Helgen — Compras (datos) (COMPRAS_DB_ID: OrdenesCompra, Proveedores, Bitacora)
|
||||||
|
└─ 📁 2026 / 📁 PRY-2026-0003 / OC-2026-0001 — Proveedor.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).
|
||||||
@ -260,7 +295,8 @@ filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026):
|
|||||||
**Pestaña `Config`** (Clave | Valor, columna B en formato texto): `EMPRESA_NOMBRE` (Helgen Servicios Especializados),
|
**Pestaña `Config`** (Clave | Valor, columna B en formato texto): `EMPRESA_NOMBRE` (Helgen Servicios Especializados),
|
||||||
`EMPRESA_RFC` (HSE240304PC1), `EMPRESA_DIRECCION`, `EMPRESA_TELEFONO` (+52 442 644 5391), `EMPRESA_EMAIL`, `EMPRESA_WEB`,
|
`EMPRESA_RFC` (HSE240304PC1), `EMPRESA_DIRECCION`, `EMPRESA_TELEFONO` (+52 442 644 5391), `EMPRESA_EMAIL`, `EMPRESA_WEB`,
|
||||||
`PREFIJO_FOLIO` (HLG), `IVA_TASA` (0.16), `VIGENCIA_DIAS` (15), `MONEDA`, `LOGO_FILE_ID` (vacío = logo de `Logo.js`),
|
`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).
|
`TERMINOS_HELGEN`, `TERMINOS_ENERGY`, `TERMINOS_OC` (pie de las órdenes de compra; se agrega sola) (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"),
|
**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**,
|
`cliente_id` (ver `idCliente`), Ubicación, Estado (Activo/Cerrado/Cancelado; **se cambia con la lista desplegable en la hoja**,
|
||||||
@ -282,12 +318,25 @@ uso (`TIPO_CAMBIO`). Se crean solas (§6.2).
|
|||||||
- `Bitacora`: `fecha`, `correo`, `accion`, `proyecto_folio`, `detalle`, `motivo` (aceptar, cambiar aceptada, editar esquema,
|
- `Bitacora`: `fecha`, `correo`, `accion`, `proyecto_folio`, `detalle`, `motivo` (aceptar, cambiar aceptada, editar esquema,
|
||||||
firmar, desbloquear, registrar y cancelar pago).
|
firmar, desbloquear, registrar y cancelar pago).
|
||||||
|
|
||||||
|
**Libro de compras** (`COMPRAS_DB_ID`; columnas en `HOJAS_COMPRAS`; texto salvo importes):
|
||||||
|
- `OrdenesCompra` (1 fila por orden; nunca se borran): `folio` OC, `fecha` (AAAA-MM-DD), `proyecto_folio`, `marca` (línea de
|
||||||
|
negocio del contrato al emitir; no cambia después), `proveedor_id`, `proveedor`, `rfc`, `contacto`, `correo`, `telefono`,
|
||||||
|
`fecha_entrega`, `lugar_entrega`, `condiciones_pago`, `moneda`, `iva_tasa`, `subtotal`, `iva`, `total`, `moneda_proyecto`,
|
||||||
|
`tipo_cambio` (solo si la moneda es otra), `subtotal_proyecto` (sin IVA, en la moneda del proyecto: lo usa el margen),
|
||||||
|
`renglones (JSON)` (clave, descripcion, unidad, cantidad, costo, importe), `notas`, `pdf_id`, `pdf_url`, `elaboro_email`,
|
||||||
|
`elaboro_nombre`, `fecha_registro`, `estado` (Emitida / Recibida / Cancelada), `fecha_recepcion`, `recibio`, `cancelado_por`,
|
||||||
|
`fecha_cancelacion`, `motivo_cancelacion`.
|
||||||
|
- `Proveedores`: `id` (RFC o `SIN-RFC:razón social`), `razon_social`, `rfc`, `contacto`, `correo`, `telefono`, `actualizado`.
|
||||||
|
- `Bitacora`: mismas columnas que la de cobranza (emitir, recibir y cancelar orden).
|
||||||
|
|
||||||
**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: `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).
|
`COBRANZA_DB_ID` (se crean solos), `CONSECUTIVO_PAG_AAAA`. **Sin `ROL_COBRANZA` nadie tiene cobranza** (se niega por omisión).
|
||||||
|
Compras: `ROL_COMPRAS` (correos separados por coma; hoy efrain@, tania@, leo@ — se crea a mano al publicar), `COMPRAS_FOLDER_ID` y
|
||||||
|
`COMPRAS_DB_ID` (se crean solos), `CONSECUTIVO_OC_AAAA`. **Sin `ROL_COMPRAS` nadie tiene compras.**
|
||||||
|
|
||||||
## 6. Decisiones tomadas (y por qué)
|
## 6. Decisiones tomadas (y por qué)
|
||||||
|
|
||||||
@ -363,20 +412,21 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A
|
|||||||
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` u `Órdenes de compra`, la próxima vez que se necesite el sistema **crea una nueva** con el
|
renombra `Cotizaciones`, 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 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
|
- Desde Compras, `SUBCARPETAS_PROYECTO` = solo `Cotizaciones` (sin `Pagos` desde Cobranza ni `Órdenes de compra` desde Compras;
|
||||||
viejos conservan la suya, vacía). Un proyecto con contrato firmado **no admite cotizaciones nuevas** y sale del buscador de
|
solo para proyectos nuevos: los viejos conservan las suyas, vacías; **no se borran** a escondidas — si el usuario lo pide, revisar
|
||||||
|
antes que estén vacías). Un proyecto con contrato firmado **no admite cotizaciones nuevas** y sale del buscador de
|
||||||
cotizaciones (se revisará con los adicionales).
|
cotizaciones (se revisará con los adicionales).
|
||||||
|
|
||||||
### 6.10 Modelo futuro (resto de la fase 2; NO construido)
|
### 6.10 Modelo futuro (fase 4 y después; NO construido)
|
||||||
- Órdenes de compra y gastos: folio (`OC-`, `GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`
|
- Gastos, nómina y horas por proyecto: folio (`GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`, columna **`proyecto_folio`**.
|
||||||
(`siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`** y `vendedor_*` del token, con encabezados
|
Antes de elegir dónde van, decidir quién debe verlos: la Base de datos la lee todo el dominio (§6.11).
|
||||||
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).
|
- **Margen preparado:** `margenDeContrato_` arma una lista `costos` (`{concepto, moneda, monto, cantidad}`) que pasa a
|
||||||
- Se comparan contra el monto contratado (`Contratos.total` del libro de cobranza).
|
`margenProyecto`; nómina y horas se agregan como otros elementos de esa lista, siempre en la moneda del contrato.
|
||||||
- **Adicionales después de firmar** (propuesta aprobada para después): función de adicionales **dentro del mismo proyecto**:
|
- **Adicionales después de firmar** (propuesta aprobada para después): función de adicionales **dentro del mismo proyecto**:
|
||||||
una cotización del proyecto se marca "Adicional", con su propio mini-esquema (por omisión 100% en una etapa), misma moneda que
|
una cotización del proyecto se marca "Adicional", con su propio mini-esquema (por omisión 100% en una etapa), misma moneda que
|
||||||
el proyecto, y se suma al contratado como línea aparte sin tocar el contrato original. Al construirla, revisar la regla de
|
el proyecto, y se suma al contratado como línea aparte sin tocar el contrato original. Al construirla, revisar la regla de
|
||||||
@ -420,6 +470,31 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A
|
|||||||
"Importe (USD)", "TOTAL USD" e importe con letra en dólares. **Términos y condiciones: el mismo bloque que en MXN, sin cambios**
|
"Importe (USD)", "TOTAL USD" e importe con letra en dólares. **Términos y condiciones: el mismo bloque que en MXN, sin cambios**
|
||||||
(incluido "$300 MXN por metro" en Energy; decisión del usuario, 20/10/2026).
|
(incluido "$300 MXN por metro" en Energy; decisión del usuario, 20/10/2026).
|
||||||
|
|
||||||
|
### 6.12 Compras: órdenes de compra (construida 20/10/2026; sin publicar, §9)
|
||||||
|
- **Datos y PDF en un libro y carpeta aparte** ("Cotizador Helgen — Compras"), ni en la Base de datos ni en la carpeta del
|
||||||
|
proyecto (las lee todo el dominio) ni en el libro de cobranza (`ROL_COMPRAS` y `ROL_COBRANZA` son listas distintas: quien compra
|
||||||
|
no debe ver pagos). Todo pasa por `privado_()` de arriba abajo, como cobranza. **Nunca poner datos de compras en la Base de datos.**
|
||||||
|
- **Permisos en el servidor:** `ROL_COMPRAS` crea, ve, recibe y cancela órdenes; los demás reciben `codigo: "PERMISO"` y
|
||||||
|
`getDatosIniciales` solo les manda `permisos.compras` en falso. Compras **no** recibe montos del contrato ni margen (solo la moneda
|
||||||
|
y la línea de negocio del proyecto). El **margen solo lo ve `ROL_COBRANZA`** (va en `vistaCobranza_`). Agregar a alguien a
|
||||||
|
`ROL_COMPRAS` le da acceso en la app; para abrir los PDF también hay que compartirle la carpeta de compras en Drive (por persona).
|
||||||
|
- **Solo en proyectos `Activo` con contrato firmado** (decisión del usuario, 20/10/2026): así la moneda y el contratado ya están
|
||||||
|
fijos. Si después se desbloquea el contrato, el proyecto sale de la lista y no admite órdenes nuevas; las que tiene se conservan.
|
||||||
|
- **Datos de la orden:** proveedor (de la lista o nuevo), fecha de entrega (obligatoria, no anterior a hoy), lugar de entrega (por
|
||||||
|
omisión la ubicación del proyecto), condiciones de pago (obligatorias), moneda (por omisión la del proyecto), IVA 16 / 8 / 0 %
|
||||||
|
(por omisión `IVA_TASA`), notas. Renglones del catálogo (llena clave, descripción y unidad) o a mano; **el costo siempre se
|
||||||
|
captura: nunca se usa el precio de venta** (las listas del catálogo de Compras ni siquiera lo muestran).
|
||||||
|
- **Línea de negocio:** cada orden guarda la del contrato al emitirse (para separar reportes Helgen / Helgen Energy). El PDF
|
||||||
|
**siempre** es de Helgen Servicios Especializados con los datos fiscales de Config, también en Energy, y lleva `TERMINOS_OC`
|
||||||
|
(los 4 términos del usuario, 20/10/2026). El tipo de cambio y la línea de negocio no salen en el PDF. No se envía por correo.
|
||||||
|
- **Estados:** Emitida → Recibida (completa, con fecha) o Cancelada (con motivo, solo si está Emitida). Nunca se borran filas.
|
||||||
|
No hay recepciones parciales.
|
||||||
|
- **Proveedores:** pestaña propia que se llena sola al emitir (por RFC, o por razón social si no trae RFC; un dato vacío no borra
|
||||||
|
uno anterior; si un proveedor sin RFC llega después con RFC, su fila toma el RFC).
|
||||||
|
- **Margen** = contratado sin IVA (`Contratos.subtotal`) − suma de `subtotal_proyecto` de las órdenes Emitidas y Recibidas, en
|
||||||
|
monto y porcentaje. Una orden en otra moneda que el proyecto exige tipo de cambio al crearla y guarda su costo convertido;
|
||||||
|
**nunca se suman MXN y USD**: con órdenes vigentes, la aceptada no puede cambiar de moneda (igual que con pagos).
|
||||||
|
|
||||||
## 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.
|
||||||
2. Redondeo a 2 decimales por renglón (`redondear`). IVA = subtotal × tasa.
|
2. Redondeo a 2 decimales por renglón (`redondear`). IVA = subtotal × tasa.
|
||||||
@ -439,21 +514,23 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A
|
|||||||
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
|
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.
|
de cobranza revisa `ROL_COBRANZA` en el servidor (§6.11). Lo mismo con compras (órdenes, proveedores, PDF): solo en su libro y
|
||||||
|
carpeta, con `ROL_COMPRAS`; el margen solo para `ROL_COBRANZA` (§6.12). Nunca sumar importes de monedas distintas.
|
||||||
13. **Las pruebas en el navegador las hace Claude, no el usuario** (pedido del usuario, 20/10/2026): en el Chrome del usuario con
|
13. **Las pruebas en el navegador las hace Claude, no el usuario** (pedido del usuario, 20/10/2026): en el Chrome del usuario con
|
||||||
Claude in Chrome, donde ya tiene su sesión de @helgen.tech. Detalle en §8 "Pruebas en el navegador".
|
Claude in Chrome, donde ya tiene su sesión de @helgen.tech. Detalle en §8 "Pruebas en el navegador".
|
||||||
|
|
||||||
## 8. Cómo trabajar
|
## 8. Cómo trabajar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pruebas (Node, servicios de Google simulados). Correr las siete antes de subir.
|
# Pruebas (Node, servicios de Google simulados). Correr las ocho antes de subir.
|
||||||
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes, USD; deja helgen.html / energy.html / usd.html
|
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 / oc.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, USD, pestaña Cobranza
|
node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado, USD, pestañas Cobranza (con margen) y Compras
|
||||||
node tests/cobranza.test.js . # cobranza por doPost: permisos, aceptada, esquema, firma, desbloqueo, pagos, monedas
|
node tests/cobranza.test.js . # cobranza por doPost: permisos, aceptada, esquema, firma, desbloqueo, pagos, monedas
|
||||||
|
node tests/compras.test.js . # compras por doPost: ROL_COMPRAS, órdenes, PDF, proveedores, recibir/cancelar, margen, 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)
|
||||||
@ -518,7 +595,17 @@ 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, gastos y adicionales (§6.10). Cobranza ya construida (§6.11).
|
- Proyectos: gastos, nómina, horas (fase 4) y adicionales (§6.10). Cobranza (§6.11) y órdenes de compra (§6.12) ya construidas.
|
||||||
|
- **Compras (fase 3): construida el 20/10/2026, SIN PUBLICAR** (código y pruebas en Node; commit "Compras: órdenes de compra…").
|
||||||
|
Falta, en este orden: 1) **respaldo** (copias de la Base de datos, el Catálogo y el libro de cobranza en "Respaldos AAAA-MM-DD
|
||||||
|
(antes de Compras)", verificadas); 2) prueba de punta a punta en el proyecto PRUEBAS (§10.5) con `Pruebas.js` ampliado
|
||||||
|
("PRUEBAS — Compras", `ROL_COMPRAS` sin efrain@ para probar al usuario sin rol, y luego compras sin cobranza y con los dos);
|
||||||
|
3) el usuario crea `ROL_COMPRAS` (efrain@, tania@, leo@); 4) `clasp push` + comprobar con `clasp clone`; 5) el usuario corre
|
||||||
|
`prepararCompras()` (no hay permisos nuevos de Google: mismos servicios que cobranza; si el editor pide autorizar, aceptar);
|
||||||
|
6) el usuario comparte la carpeta de compras como lector con tania@ y leo@ (por persona) y Claude lo verifica; 7) `clasp deploy`
|
||||||
|
(@8); 8) `wrangler deploy`; 9) revisión en el cotizador real sin crear nada y anotar IDs en §2.
|
||||||
|
- Subcarpetas "Órdenes de compra" de los proyectos viejos: quedan vacías y sin uso. Si el usuario lo pide, comprobar con el
|
||||||
|
conector de Drive que estén vacías y que él las mande a la Papelera.
|
||||||
- **Cobranza: publicada el 20/10/2026** (API @6 + interfaz en Cloudflare). Hechos: respaldos, `clasp push`, `prepararCobranza()`
|
- **Cobranza: publicada el 20/10/2026** (API @6 + interfaz en Cloudflare). Hechos: respaldos, `clasp push`, `prepararCobranza()`
|
||||||
(carpeta y libro creados y restringidos, §2 y §6.11), `ROL_COBRANZA` = efrain@, tania@, leo@ y `ADMIN_DESBLOQUEO` = efrain@,
|
(carpeta y libro creados y restringidos, §2 y §6.11), `ROL_COBRANZA` = efrain@, tania@, leo@ y `ADMIN_DESBLOQUEO` = efrain@,
|
||||||
`clasp deploy`, `wrangler deploy` y carpeta compartida como lector con tania@ y leo@ (por persona, acceso general Restringido;
|
`clasp deploy`, `wrangler deploy` y carpeta compartida como lector con tania@ y leo@ (por persona, acceso general Restringido;
|
||||||
|
|||||||
684
Code.js
684
Code.js
@ -104,6 +104,12 @@ const API_FUNCIONES = {
|
|||||||
desbloquearContrato: desbloquearContrato,
|
desbloquearContrato: desbloquearContrato,
|
||||||
registrarPago: registrarPago,
|
registrarPago: registrarPago,
|
||||||
cancelarPago: cancelarPago,
|
cancelarPago: cancelarPago,
|
||||||
|
// Compras (solo ROL_COMPRAS; cada función lo revisa)
|
||||||
|
listarProyectosCompras: listarProyectosCompras,
|
||||||
|
getComprasProyecto: getComprasProyecto,
|
||||||
|
guardarOrdenCompra: guardarOrdenCompra,
|
||||||
|
marcarOcRecibida: marcarOcRecibida,
|
||||||
|
cancelarOrdenCompra: cancelarOrdenCompra,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -145,6 +151,12 @@ function doPost(e) {
|
|||||||
const TERMINO_VIGENCIA =
|
const TERMINO_VIGENCIA =
|
||||||
'Precios vigentes por 15 días o hasta agotar existencias, sujetos a variaciones en costos de suministro de componentes.';
|
'Precios vigentes por 15 días o hasta agotar existencias, sujetos a variaciones en costos de suministro de componentes.';
|
||||||
|
|
||||||
|
const TERMINOS_OC_INICIALES =
|
||||||
|
'Favor de indicar el folio de esta orden en su factura.\n' +
|
||||||
|
'Facturar a nombre de la razón social y RFC indicados arriba.\n' +
|
||||||
|
'Precios fijos según esta orden; cualquier cambio requiere una nueva orden.\n' +
|
||||||
|
'Entregar en el lugar y fecha indicados.';
|
||||||
|
|
||||||
/** Líneas de negocio: Helgen (general) y Helgen Energy (submarca de paneles solares). */
|
/** Líneas de negocio: Helgen (general) y Helgen Energy (submarca de paneles solares). */
|
||||||
const MARCAS = { helgen: 'Helgen', energy: 'Helgen Energy' };
|
const MARCAS = { helgen: 'Helgen', energy: 'Helgen Energy' };
|
||||||
|
|
||||||
@ -167,6 +179,8 @@ const CONFIG_INICIAL = [
|
|||||||
'Centro de carga: cambios o actualizaciones no incluidos (se cotizan por separado).\n' +
|
'Centro de carga: cambios o actualizaciones no incluidos (se cotizan por separado).\n' +
|
||||||
'Obra civil: se cotiza por separado según las condiciones arquitectónicas del inmueble.\n' +
|
'Obra civil: se cotiza por separado según las condiciones arquitectónicas del inmueble.\n' +
|
||||||
TERMINO_VIGENCIA],
|
TERMINO_VIGENCIA],
|
||||||
|
// Términos al pie de las órdenes de compra (iguales en Helgen y Helgen Energy)
|
||||||
|
['TERMINOS_OC', TERMINOS_OC_INICIALES],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Valores de ejemplo de versiones anteriores: si siguen en Config, se cambian por los datos reales. */
|
/** Valores de ejemplo de versiones anteriores: si siguen en Config, se cambian por los datos reales. */
|
||||||
@ -637,8 +651,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
|
// Solo para mostrar u ocultar las pestañas Cobranza y Compras; cada función lo vuelve a revisar
|
||||||
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_() },
|
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_(), compras: esCompras_() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -826,9 +840,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'];
|
||||||
// Sin "Pagos": los comprobantes van en la carpeta de cobranza (no visible para todo el dominio). Los proyectos
|
// Sin "Pagos" ni "Órdenes de compra": comprobantes y órdenes van en las carpetas de cobranza y compras (no visibles para
|
||||||
// creados antes la conservan; no se borra.
|
// todo el dominio). Los proyectos creados antes conservan las suyas, vacías; no se borran.
|
||||||
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra'];
|
const SUBCARPETAS_PROYECTO = ['Cotizaciones'];
|
||||||
const PREFIJO_PROYECTO = 'PRY';
|
const PREFIJO_PROYECTO = 'PRY';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -976,7 +990,7 @@ function crearCarpetaProyecto_(folio, datos, anio) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subcarpeta de un proyecto ("Cotizaciones", "Órdenes de compra"…). Se busca por nombre: si alguien la
|
* Subcarpeta de un proyecto ("Cotizaciones"). 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.
|
||||||
*/
|
*/
|
||||||
@ -1030,18 +1044,21 @@ function errorPermiso_(mensaje) {
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ¿El usuario del token está en ROL_COBRANZA? Sin token (editor) o sin la propiedad: no. */
|
/** ¿El usuario del token está en la lista de correos de la propiedad (rol)? Sin token (editor) o sin la propiedad: no. */
|
||||||
function esCobranza_() {
|
function enRol_(propiedad) {
|
||||||
if (!VENDEDOR_) return false;
|
if (!VENDEDOR_) return false;
|
||||||
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA'), DOMINIO_HELGEN);
|
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty(propiedad), DOMINIO_HELGEN);
|
||||||
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
|
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ¿El usuario del token está en ROL_COBRANZA? */
|
||||||
|
function esCobranza_() {
|
||||||
|
return enRol_('ROL_COBRANZA');
|
||||||
|
}
|
||||||
|
|
||||||
/** ¿Puede desbloquear contratos firmados? (ADMIN_DESBLOQUEO; sin la propiedad, nadie). */
|
/** ¿Puede desbloquear contratos firmados? (ADMIN_DESBLOQUEO; sin la propiedad, nadie). */
|
||||||
function esAdminDesbloqueo_() {
|
function esAdminDesbloqueo_() {
|
||||||
if (!VENDEDOR_) return false;
|
return enRol_('ADMIN_DESBLOQUEO');
|
||||||
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ADMIN_DESBLOQUEO'), DOMINIO_HELGEN);
|
|
||||||
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exigirCobranza_() {
|
function exigirCobranza_() {
|
||||||
@ -1071,65 +1088,80 @@ function privado_(item) {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Carpeta de cobranza. Se crea sola en Mi unidad (restringida). Si se borró, error: no se crea otra a escondidas. */
|
// Áreas privadas (cobranza, compras): carpeta en Mi unidad + libro dentro de ella, restringidos, con sus IDs en
|
||||||
function carpetaCobranza_() {
|
// Script Properties. Se crean solos la primera vez; si después no se encuentran, error (no se crean otros a escondidas).
|
||||||
|
const AREA_COBRANZA = { que: 'cobranza', propCarpeta: 'COBRANZA_FOLDER_ID', propLibro: 'COBRANZA_DB_ID',
|
||||||
|
carpeta: NOMBRE_CARPETA_COBRANZA, libro: NOMBRE_LIBRO_COBRANZA, hojas: HOJAS_COBRANZA };
|
||||||
|
|
||||||
|
/** Carpeta del área. Se crea sola en Mi unidad (restringida). Si se borró, error. */
|
||||||
|
function carpetaArea_(area) {
|
||||||
const props = PropertiesService.getScriptProperties();
|
const props = PropertiesService.getScriptProperties();
|
||||||
const id = props.getProperty('COBRANZA_FOLDER_ID');
|
const id = props.getProperty(area.propCarpeta);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
const carpeta = privado_(DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA));
|
const carpeta = privado_(DriveApp.createFolder(area.carpeta));
|
||||||
props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId());
|
props.setProperty(area.propCarpeta, carpeta.getId());
|
||||||
return carpeta;
|
return carpeta;
|
||||||
}
|
}
|
||||||
let carpeta = null;
|
let carpeta = null;
|
||||||
try { carpeta = DriveApp.getFolderById(id); } catch (e) { carpeta = null; }
|
try { carpeta = DriveApp.getFolderById(id); } catch (e) { carpeta = null; }
|
||||||
if (!carpeta || carpeta.isTrashed()) {
|
if (!carpeta || carpeta.isTrashed()) {
|
||||||
throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.');
|
throw new Error('No se encontró la carpeta de ' + area.que + ' (' + area.propCarpeta + '). Revisa la Papelera de Drive.');
|
||||||
}
|
}
|
||||||
return privado_(carpeta);
|
return privado_(carpeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */
|
/** Libro del área; la primera vez lo crea dentro de su carpeta (bajo candado). */
|
||||||
function libroCobranza_() {
|
function libroArea_(area) {
|
||||||
const props = PropertiesService.getScriptProperties();
|
const props = PropertiesService.getScriptProperties();
|
||||||
if (!props.getProperty('COBRANZA_DB_ID')) {
|
if (!props.getProperty(area.propLibro)) {
|
||||||
const lock = LockService.getScriptLock();
|
const lock = LockService.getScriptLock();
|
||||||
if (!lock.tryLock(30000)) throw new Error('Se está preparando cobranza. Intenta de nuevo en unos segundos.');
|
if (!lock.tryLock(30000)) throw new Error('Se está preparando ' + area.que + '. Intenta de nuevo en unos segundos.');
|
||||||
try {
|
try {
|
||||||
if (!props.getProperty('COBRANZA_DB_ID')) crearLibroCobranza_();
|
if (!props.getProperty(area.propLibro)) crearLibroArea_(area);
|
||||||
} finally {
|
} finally {
|
||||||
lock.releaseLock();
|
lock.releaseLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return abrirLibroCobranza_(props.getProperty('COBRANZA_DB_ID'));
|
return abrirLibroArea_(area, props.getProperty(area.propLibro));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Libro de cobranza si ya existe; null si todavía no se crea (las cotizaciones no lo crean). */
|
/** Libro del área si ya existe; null si todavía no se crea. */
|
||||||
function libroCobranzaSiExiste_() {
|
function libroAreaSiExiste_(area) {
|
||||||
const id = PropertiesService.getScriptProperties().getProperty('COBRANZA_DB_ID');
|
const id = PropertiesService.getScriptProperties().getProperty(area.propLibro);
|
||||||
return id ? abrirLibroCobranza_(id) : null;
|
return id ? abrirLibroArea_(area, id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function abrirLibroCobranza_(id) {
|
function abrirLibroArea_(area, id) {
|
||||||
try {
|
try {
|
||||||
return SpreadsheetApp.openById(id);
|
return SpreadsheetApp.openById(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('No se pudo abrir el libro de cobranza (COBRANZA_DB_ID). Revisa que no esté en la Papelera.');
|
throw new Error('No se pudo abrir el libro de ' + area.que + ' (' + area.propLibro + '). Revisa que no esté en la Papelera.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function crearLibroCobranza_() {
|
function crearLibroArea_(area) {
|
||||||
const carpeta = carpetaCobranza_();
|
const carpeta = carpetaArea_(area);
|
||||||
const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA);
|
const ss = SpreadsheetApp.create(area.libro);
|
||||||
privado_(DriveApp.getFileById(ss.getId())).moveTo(carpeta);
|
privado_(DriveApp.getFileById(ss.getId())).moveTo(carpeta);
|
||||||
Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre));
|
Object.keys(area.hojas).forEach(nombre => hojaDeArea_(ss, area.hojas, nombre));
|
||||||
const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
|
const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
|
||||||
if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante);
|
if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante);
|
||||||
PropertiesService.getScriptProperties().setProperty('COBRANZA_DB_ID', ss.getId());
|
PropertiesService.getScriptProperties().setProperty(area.propLibro, ss.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function carpetaCobranza_() { return carpetaArea_(AREA_COBRANZA); }
|
||||||
|
function libroCobranza_() { return libroArea_(AREA_COBRANZA); }
|
||||||
|
/** Libro de cobranza si ya existe; null si todavía no se crea (las cotizaciones no lo crean). */
|
||||||
|
function libroCobranzaSiExiste_() { return libroAreaSiExiste_(AREA_COBRANZA); }
|
||||||
|
|
||||||
/** Pestaña del libro de cobranza; la crea o le repone los encabezados (patrón de hojaProyectos_). */
|
/** Pestaña del libro de cobranza; la crea o le repone los encabezados (patrón de hojaProyectos_). */
|
||||||
function hojaCobranza_(libro, nombre) {
|
function hojaCobranza_(libro, nombre) {
|
||||||
const def = HOJAS_COBRANZA[nombre];
|
return hojaDeArea_(libro, HOJAS_COBRANZA, nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pestaña de un libro de área (estructura en `hojas`); la crea o le repone los encabezados. */
|
||||||
|
function hojaDeArea_(libro, hojas, nombre) {
|
||||||
|
const def = hojas[nombre];
|
||||||
let hoja = libro.getSheetByName(nombre);
|
let hoja = libro.getSheetByName(nombre);
|
||||||
if (!hoja) {
|
if (!hoja) {
|
||||||
hoja = libro.insertSheet(nombre);
|
hoja = libro.insertSheet(nombre);
|
||||||
@ -1147,7 +1179,11 @@ function hojaCobranza_(libro, nombre) {
|
|||||||
|
|
||||||
/** Filas de datos (sin encabezado) de una pestaña, con el ancho de sus encabezados. */
|
/** Filas de datos (sin encabezado) de una pestaña, con el ancho de sus encabezados. */
|
||||||
function filasCobranza_(hoja, nombre) {
|
function filasCobranza_(hoja, nombre) {
|
||||||
const n = HOJAS_COBRANZA[nombre].enc.length;
|
return filasDeArea_(hoja, HOJAS_COBRANZA, nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filasDeArea_(hoja, hojas, nombre) {
|
||||||
|
const n = hojas[nombre].enc.length;
|
||||||
return hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, n).getValues() : [];
|
return hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, n).getValues() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1207,19 +1243,24 @@ function datosCobranza_(libro, folio) {
|
|||||||
return { hojas: { contratos: hC, esquemas: hE, pagos: hP }, contrato: contrato, etapas: etapas, pagos: pagos };
|
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. */
|
/** {"PRY-2026-0001": <contrato>, …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */
|
||||||
function foliosConContratoFirmado_() {
|
function contratosFirmados_() {
|
||||||
const libro = libroCobranzaSiExiste_();
|
const libro = libroCobranzaSiExiste_();
|
||||||
const mapa = {};
|
const mapa = {};
|
||||||
if (!libro) return mapa;
|
if (!libro) return mapa;
|
||||||
const hoja = libro.getSheetByName('Contratos');
|
const hoja = libro.getSheetByName('Contratos');
|
||||||
if (!hoja) return mapa;
|
if (!hoja) return mapa;
|
||||||
filasCobranza_(hoja, 'Contratos').forEach(r => {
|
filasCobranza_(hoja, 'Contratos').forEach((r, i) => {
|
||||||
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = true;
|
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = contratoDesdeFila_(r, i + 2);
|
||||||
});
|
});
|
||||||
return mapa;
|
return mapa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {"PRY-2026-0001": <contrato>, …}: se usa como "¿tiene contrato firmado?". */
|
||||||
|
function foliosConContratoFirmado_() {
|
||||||
|
return contratosFirmados_();
|
||||||
|
}
|
||||||
|
|
||||||
/** Cotizaciones ligadas a un proyecto (columna T), de la hoja Cotizaciones. */
|
/** Cotizaciones ligadas a un proyecto (columna T), de la hoja Cotizaciones. */
|
||||||
function cotizacionesDeProyecto_(folio) {
|
function cotizacionesDeProyecto_(folio) {
|
||||||
const f = String(folio).toUpperCase();
|
const f = String(folio).toUpperCase();
|
||||||
@ -1241,9 +1282,10 @@ function proyectoParaCobranza_(folio) {
|
|||||||
return proyecto;
|
return proyecto;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bitacora_(libro, accion, folio, detalle, motivo) {
|
/** Renglón en la Bitacora del libro (cobranza por omisión; compras pasa HOJAS_COMPRAS: mismas columnas). */
|
||||||
|
function bitacora_(libro, accion, folio, detalle, motivo, hojas) {
|
||||||
const t = textoCelda;
|
const t = textoCelda;
|
||||||
hojaCobranza_(libro, 'Bitacora').appendRow([
|
hojaDeArea_(libro, hojas || HOJAS_COBRANZA, 'Bitacora').appendRow([
|
||||||
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), t(VENDEDOR_ ? VENDEDOR_.email : ''),
|
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 || ''),
|
t(accion), t(folio), t(detalle || ''), t(motivo || ''),
|
||||||
]);
|
]);
|
||||||
@ -1281,11 +1323,35 @@ function vistaCobranza_(libro, proyecto) {
|
|||||||
puedeFirmar: proyecto.estado !== 'Cancelado' && !!c && c.estado === ESTADO_ACEPTADA,
|
puedeFirmar: proyecto.estado !== 'Cancelado' && !!c && c.estado === ESTADO_ACEPTADA,
|
||||||
puedePagar: !!c && c.estado === ESTADO_FIRMADO,
|
puedePagar: !!c && c.estado === ESTADO_FIRMADO,
|
||||||
puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(),
|
puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(),
|
||||||
|
// Solo llega al rol de cobranza (vistaCobranza_ se llama después de exigirCobranza_)
|
||||||
|
margen: c ? margenDeContrato_(c) : null,
|
||||||
metodos: METODOS_PAGO,
|
metodos: METODOS_PAGO,
|
||||||
hoy: hoyIso_(),
|
hoy: hoyIso_(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Margen del proyecto: contratado sin IVA − costos (fase 3: órdenes de compra vigentes; fase 4: nómina y horas se
|
||||||
|
* agregan a la lista de costos). Si algo no cuadra (p. ej. una orden en otra moneda), {error} y la vista carga igual.
|
||||||
|
*/
|
||||||
|
function margenDeContrato_(contrato) {
|
||||||
|
try {
|
||||||
|
const ordenes = ordenesDeProyecto_(libroComprasSiExiste_(), contrato.proyectoFolio).filter(o => o.estado !== OC_CANCELADA);
|
||||||
|
const porMoneda = {};
|
||||||
|
ordenes.forEach(o => {
|
||||||
|
const m = o.monedaProyecto || contrato.moneda;
|
||||||
|
const x = porMoneda[m] || (porMoneda[m] = { concepto: 'Órdenes de compra', moneda: m, monto: 0, cantidad: 0 });
|
||||||
|
x.monto = (aCentavos(x.monto) + aCentavos(o.subtotalProyecto)) / 100;
|
||||||
|
x.cantidad++;
|
||||||
|
});
|
||||||
|
const costos = Object.keys(porMoneda).map(m => porMoneda[m]);
|
||||||
|
if (!costos.length) costos.push({ concepto: 'Órdenes de compra', moneda: contrato.moneda, monto: 0, cantidad: 0 });
|
||||||
|
return margenProyecto(contrato.subtotal, contrato.moneda, costos);
|
||||||
|
} catch (e) {
|
||||||
|
return { error: 'No se pudo calcular el margen: ' + e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Candado de script (el mismo de folios) alrededor de una función. */
|
/** Candado de script (el mismo de folios) alrededor de una función. */
|
||||||
function conCandado_(f) {
|
function conCandado_(f) {
|
||||||
const lock = LockService.getScriptLock();
|
const lock = LockService.getScriptLock();
|
||||||
@ -1336,6 +1402,13 @@ function marcarAceptada(proyectoFolio, cotizacionFolio, confirmado) {
|
|||||||
throw new Error('El proyecto ya tiene pagos en ' + vigentes[0].monedaProyecto + ': la cotización aceptada debe ser en ' +
|
throw new Error('El proyecto ya tiene pagos en ' + vigentes[0].monedaProyecto + ': la cotización aceptada debe ser en ' +
|
||||||
vigentes[0].monedaProyecto + '.');
|
vigentes[0].monedaProyecto + '.');
|
||||||
}
|
}
|
||||||
|
// Lo mismo con las órdenes de compra vigentes: su costo está en la moneda del proyecto (el margen nunca mezcla monedas)
|
||||||
|
const oc = ordenesDeProyecto_(libroComprasSiExiste_(), proyecto.folio)
|
||||||
|
.find(o => o.estado !== OC_CANCELADA && o.monedaProyecto && o.monedaProyecto !== cot.moneda);
|
||||||
|
if (oc) {
|
||||||
|
throw new Error('El proyecto ya tiene órdenes de compra en ' + oc.monedaProyecto + ': la cotización aceptada debe ser en ' +
|
||||||
|
oc.monedaProyecto + '.');
|
||||||
|
}
|
||||||
|
|
||||||
// Esquema: al pasar a Helgen Energy se pone el 60/30/10 (después solo el administrador lo cambia); entre cotizaciones
|
// Esquema: al pasar a Helgen Energy se pone el 60/30/10 (después solo el administrador lo cambia); entre cotizaciones
|
||||||
// de la misma línea, o de Energy a Helgen, se conserva el que ya tenga
|
// de la misma línea, o de Energy a Helgen, se conserva el que ya tenga
|
||||||
@ -1548,6 +1621,272 @@ function prepararCobranza() {
|
|||||||
Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)'));
|
Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 4.3 COMPRAS (Fase 3): órdenes de compra por proyecto ======
|
||||||
|
// Libro aparte "Cotizador Helgen — Compras (datos)" dentro de la carpeta "Cotizador Helgen — Compras" (fuera de la carpeta
|
||||||
|
// maestra y de la de cobranza). Solo el rol de compras (ROL_COMPRAS) crea, ve, recibe y cancela órdenes; el servidor lo
|
||||||
|
// revisa en cada llamada. PDF en Compras/AAAA/<folio del proyecto>/. El margen (contratado − órdenes) solo lo ve cobranza.
|
||||||
|
|
||||||
|
const NOMBRE_CARPETA_COMPRAS = 'Cotizador Helgen — Compras';
|
||||||
|
const NOMBRE_LIBRO_COMPRAS = 'Cotizador Helgen — Compras (datos)';
|
||||||
|
const PREFIJO_OC = 'OC';
|
||||||
|
const OC_EMITIDA = 'Emitida', OC_RECIBIDA = 'Recibida', OC_CANCELADA = 'Cancelada';
|
||||||
|
|
||||||
|
/** Estructura del libro de compras (mismo formato que HOJAS_COBRANZA). */
|
||||||
|
const HOJAS_COMPRAS = {
|
||||||
|
OrdenesCompra: {
|
||||||
|
enc: ['folio', 'fecha', 'proyecto_folio', 'marca', 'proveedor_id', 'proveedor', 'rfc', 'contacto', 'correo', 'telefono',
|
||||||
|
'fecha_entrega', 'lugar_entrega', 'condiciones_pago', 'moneda', 'iva_tasa', 'subtotal', 'iva', 'total', 'moneda_proyecto',
|
||||||
|
'tipo_cambio', 'subtotal_proyecto', 'renglones (JSON)', 'notas', 'pdf_id', 'pdf_url', 'elaboro_email', 'elaboro_nombre',
|
||||||
|
'fecha_registro', 'estado', 'fecha_recepcion', 'recibio', 'cancelado_por', 'fecha_cancelacion', 'motivo_cancelacion'],
|
||||||
|
numeros: [15, 16, 17, 18, 20, 21],
|
||||||
|
},
|
||||||
|
Proveedores: { enc: ['id', 'razon_social', 'rfc', 'contacto', 'correo', 'telefono', 'actualizado'], numeros: [] },
|
||||||
|
Bitacora: { enc: ['fecha', 'correo', 'accion', 'proyecto_folio', 'detalle', 'motivo'], numeros: [] },
|
||||||
|
};
|
||||||
|
const COL_ESTADO_OC = 29; // AC: estado, recepción (AD:AE) y cancelación (AF:AH)
|
||||||
|
|
||||||
|
const AREA_COMPRAS = { que: 'compras', propCarpeta: 'COMPRAS_FOLDER_ID', propLibro: 'COMPRAS_DB_ID',
|
||||||
|
carpeta: NOMBRE_CARPETA_COMPRAS, libro: NOMBRE_LIBRO_COMPRAS, hojas: HOJAS_COMPRAS };
|
||||||
|
|
||||||
|
function carpetaCompras_() { return carpetaArea_(AREA_COMPRAS); }
|
||||||
|
function libroCompras_() { return libroArea_(AREA_COMPRAS); }
|
||||||
|
/** Libro de compras si ya existe; null si todavía no se crea (cobranza no lo crea al calcular el margen). */
|
||||||
|
function libroComprasSiExiste_() { return libroAreaSiExiste_(AREA_COMPRAS); }
|
||||||
|
function hojaCompras_(libro, nombre) { return hojaDeArea_(libro, HOJAS_COMPRAS, nombre); }
|
||||||
|
|
||||||
|
/** ¿El usuario del token está en ROL_COMPRAS? */
|
||||||
|
function esCompras_() {
|
||||||
|
return enRol_('ROL_COMPRAS');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exigirCompras_() {
|
||||||
|
if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.');
|
||||||
|
if (!esCompras_()) throw errorPermiso_('No tienes permiso de compras.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordenDesdeFila_(r, fila) {
|
||||||
|
let renglones = [];
|
||||||
|
try { renglones = JSON.parse(String(r[21] || '[]')); } catch (e) { renglones = []; }
|
||||||
|
return {
|
||||||
|
folio: String(r[0]), fecha: fechaIso_(r[1]), proyectoFolio: String(r[2]), marca: marcaDeTexto_(r[3]),
|
||||||
|
proveedorId: String(r[4] || ''), proveedor: String(r[5] || ''), rfc: String(r[6] || ''), contacto: String(r[7] || ''),
|
||||||
|
correo: String(r[8] || ''), telefono: String(r[9] || ''), fechaEntrega: fechaIso_(r[10]), lugarEntrega: String(r[11] || ''),
|
||||||
|
condicionesPago: String(r[12] || ''), moneda: String(r[13] || 'MXN'), ivaTasa: Number(r[14]) || 0,
|
||||||
|
subtotal: Number(r[15]) || 0, iva: Number(r[16]) || 0, total: Number(r[17]) || 0, monedaProyecto: String(r[18] || ''),
|
||||||
|
tipoCambio: r[19] === '' ? '' : Number(r[19]), subtotalProyecto: Number(r[20]) || 0, renglones: renglones,
|
||||||
|
notas: String(r[22] || ''), pdfUrl: String(r[24] || ''), elaboro: String(r[26] || r[25] || ''),
|
||||||
|
estado: String(r[28] || OC_EMITIDA), fechaRecepcion: fechaIso_(r[29]), recibio: String(r[30] || ''),
|
||||||
|
canceladoPor: String(r[31] || ''), motivoCancelacion: String(r[33] || ''), fila: fila,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Órdenes de un proyecto, en orden de registro. Sin libro de compras: ninguna. */
|
||||||
|
function ordenesDeProyecto_(libro, folio) {
|
||||||
|
if (!libro) return [];
|
||||||
|
const f = String(folio || '').toUpperCase();
|
||||||
|
const lista = [];
|
||||||
|
filasDeArea_(hojaCompras_(libro, 'OrdenesCompra'), HOJAS_COMPRAS, 'OrdenesCompra').forEach((r, i) => {
|
||||||
|
if (String(r[2]).toUpperCase() === f && String(r[0] || '').trim()) lista.push(ordenDesdeFila_(r, i + 2));
|
||||||
|
});
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proveedores de la pestaña Proveedores (con su fila). */
|
||||||
|
function proveedoresDelLibro_(libro) {
|
||||||
|
return proveedoresDesdeFilas(filasDeArea_(hojaCompras_(libro, 'Proveedores'), HOJAS_COMPRAS, 'Proveedores'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alta o actualización del proveedor al emitir una orden (se llena sola, como los clientes). Se busca por id (RFC o
|
||||||
|
* SIN-RFC:razón social); si llega con RFC y solo existía sin RFC, esa fila toma el RFC. Un dato vacío no borra uno anterior.
|
||||||
|
*/
|
||||||
|
function guardarProveedor_(libro, prov) {
|
||||||
|
const hoja = hojaCompras_(libro, 'Proveedores');
|
||||||
|
const lista = proveedoresDelLibro_(libro);
|
||||||
|
const id = proveedorId(prov);
|
||||||
|
const existente = lista.find(p => p.id === id) ||
|
||||||
|
(id.indexOf('SIN-RFC:') !== 0 ? lista.find(p => p.id === proveedorId({ razonSocial: prov.razonSocial })) : null);
|
||||||
|
const t = textoCelda;
|
||||||
|
const datos = fusionarProveedor(existente, prov);
|
||||||
|
const fila = [t(id), t(datos.razonSocial), t(datos.rfc), t(datos.contacto), t(datos.correo), t(datos.telefono),
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')];
|
||||||
|
if (existente) hoja.getRange(existente.fila, 1, 1, fila.length).setValues([fila]);
|
||||||
|
else hoja.appendRow(fila);
|
||||||
|
return Object.assign(datos, { id: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proyecto existente (o error). */
|
||||||
|
function proyectoParaCompras_(folio) {
|
||||||
|
const proyecto = buscarProyecto_(hojaProyectos_(), folio);
|
||||||
|
if (!proyecto) throw new Error('No existe el proyecto ' + String(folio || '').toUpperCase() + '.');
|
||||||
|
return proyecto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Respuesta para la pestaña Compras: proyecto, moneda, órdenes y proveedores. Sin montos del contrato ni margen. */
|
||||||
|
function vistaCompras_(libro, proyecto) {
|
||||||
|
const c = contratosFirmados_()[proyecto.folio.toUpperCase()] || null;
|
||||||
|
return {
|
||||||
|
proyecto: { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado,
|
||||||
|
ubicacion: proyecto.ubicacion },
|
||||||
|
moneda: c ? c.moneda : '',
|
||||||
|
marca: c ? c.marca : '',
|
||||||
|
puedeEmitir: !!c && proyecto.estado === 'Activo',
|
||||||
|
ordenes: ordenesDeProyecto_(libro, proyecto.folio).map(o => { const x = Object.assign({}, o); delete x.fila; return x; }).reverse(),
|
||||||
|
proveedores: proveedoresDelLibro_(libro).map(p => { const x = Object.assign({}, p); delete x.fila; return x; }),
|
||||||
|
ivaTasas: IVA_OC,
|
||||||
|
hoy: hoyIso_(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proyectos donde se pueden emitir órdenes: Activos y con contrato firmado (con su moneda; sin montos). */
|
||||||
|
function listarProyectosCompras(busqueda) {
|
||||||
|
exigirCompras_();
|
||||||
|
const firmados = contratosFirmados_();
|
||||||
|
const hoja = hojaProyectos_();
|
||||||
|
const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
|
||||||
|
return filtrarProyectos(proyectosDesdeFilas(filas).filter(p => firmados[p.folio.toUpperCase()]), busqueda, false, 50)
|
||||||
|
.map(p => {
|
||||||
|
const c = firmados[p.folio.toUpperCase()];
|
||||||
|
return { folio: p.folio, nombre: p.nombre, cliente: p.cliente, ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha,
|
||||||
|
moneda: c.moneda, marca: c.marca };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComprasProyecto(folio) {
|
||||||
|
exigirCompras_();
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return vistaCompras_(libro, proyectoParaCompras_(folio));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emite una orden de compra con su PDF. p = {proyectoFolio, proveedor: {razonSocial, rfc, contacto, correo, telefono},
|
||||||
|
* fechaEntrega, lugarEntrega, condicionesPago, moneda, tipoCambio, ivaTasa, renglones: [{clave, descripcion, unidad,
|
||||||
|
* cantidad, costo}], notas, idSolicitud}. Solo en proyectos Activos con contrato firmado. La línea de negocio sale del
|
||||||
|
* contrato (nunca del navegador); el costo es el capturado (nunca el precio de venta del catálogo).
|
||||||
|
*/
|
||||||
|
function guardarOrdenCompra(p) {
|
||||||
|
exigirCompras_();
|
||||||
|
const d0 = p || {};
|
||||||
|
const cfg = getConfig_();
|
||||||
|
const libro = libroCompras_();
|
||||||
|
|
||||||
|
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
|
||||||
|
const cache = CacheService.getScriptCache();
|
||||||
|
const claveCache = idSolicitud ? 'OC_' + idSolicitud : '';
|
||||||
|
|
||||||
|
return conCandado_(() => {
|
||||||
|
if (claveCache) {
|
||||||
|
const previo = cache.get(claveCache);
|
||||||
|
if (previo) return JSON.parse(previo);
|
||||||
|
}
|
||||||
|
const proyecto = proyectoParaCompras_(d0.proyectoFolio);
|
||||||
|
if (proyecto.estado !== 'Activo') {
|
||||||
|
throw new Error('El proyecto ' + proyecto.folio + ' está ' + proyecto.estado.toLowerCase() + ': no admite órdenes de compra.');
|
||||||
|
}
|
||||||
|
const c = contratosFirmados_()[proyecto.folio.toUpperCase()];
|
||||||
|
if (!c) throw new Error('Solo se emiten órdenes de compra en proyectos con contrato firmado.');
|
||||||
|
const oc = normalizarOrdenCompra(d0, { moneda: c.moneda, ubicacion: proyecto.ubicacion, hoy: hoyIso_(), ivaTasa: cfg.IVA_TASA });
|
||||||
|
|
||||||
|
const hoja = hojaCompras_(libro, 'OrdenesCompra');
|
||||||
|
const folio = siguienteFolio_(PREFIJO_OC, hoja, 'CONSECUTIVO_OC_');
|
||||||
|
const proveedor = guardarProveedor_(libro, oc.proveedor);
|
||||||
|
const ahora = new Date();
|
||||||
|
const elaboro = [VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ') || VENDEDOR_.email;
|
||||||
|
|
||||||
|
// PDF (razón social y datos fiscales de Helgen, iguales en Helgen y Helgen Energy)
|
||||||
|
const html = construirHtmlOrdenCompra(Object.assign({}, oc, {
|
||||||
|
folio: folio, fecha: Utilities.formatDate(ahora, 'America/Mexico_City', 'dd/MM/yyyy'), proveedor: proveedor,
|
||||||
|
proyectoFolio: proyecto.folio, elaboro: elaboro + (elaboro === VENDEDOR_.email ? '' : ' (' + VENDEDOR_.email + ')'),
|
||||||
|
}), cfg, logoDataUrl_(cfg.LOGO_FILE_ID));
|
||||||
|
const pdf = Utilities.newBlob(html, 'text/html', folio + '.html').getAs('application/pdf')
|
||||||
|
.setName(nombreArchivoOc(folio, proveedor.razonSocial));
|
||||||
|
const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4);
|
||||||
|
// De arriba abajo: un permiso heredado de la carpeta no se puede quitar en el hijo
|
||||||
|
const carpeta = privado_(subcarpeta_(privado_(subcarpeta_(carpetaCompras_(), anio)), proyecto.folio));
|
||||||
|
const archivo = privado_(carpeta.createFile(pdf));
|
||||||
|
|
||||||
|
const t = textoCelda;
|
||||||
|
hoja.appendRow([
|
||||||
|
folio, textoFecha_(hoyIso_()), proyecto.folio, MARCAS[c.marca], t(proveedor.id), t(proveedor.razonSocial), t(proveedor.rfc),
|
||||||
|
t(proveedor.contacto), t(proveedor.correo), t(proveedor.telefono), textoFecha_(oc.fechaEntrega), t(oc.lugarEntrega),
|
||||||
|
t(oc.condicionesPago), oc.moneda, oc.ivaTasa, oc.subtotal, oc.iva, oc.total, oc.monedaProyecto, oc.tipoCambio,
|
||||||
|
oc.subtotalProyecto, JSON.stringify(oc.renglones), t(oc.notas), archivo.getId(), archivo.getUrl(), t(VENDEDOR_.email),
|
||||||
|
t(elaboro), Utilities.formatDate(ahora, 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), OC_EMITIDA, '', '', '', '', '',
|
||||||
|
]);
|
||||||
|
bitacora_(libro, 'Emitir orden de compra', proyecto.folio, folio + ' · ' + proveedor.razonSocial + ' · ' +
|
||||||
|
formatoMoneda(oc.total) + ' ' + oc.moneda + (oc.tipoCambio ? ' · TC ' + oc.tipoCambio + ' = ' +
|
||||||
|
formatoMoneda(oc.subtotalProyecto) + ' ' + oc.monedaProyecto + ' sin IVA' : '') + ' · ' + MARCAS[c.marca], '', HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
|
||||||
|
const resultado = { folio: folio, url: archivo.getUrl(), total: oc.total, moneda: oc.moneda, vista: vistaCompras_(libro, proyecto) };
|
||||||
|
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||||
|
return resultado;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca una orden por folio en el libro de compras (o error). */
|
||||||
|
function buscarOrden_(libro, folioOc) {
|
||||||
|
const folio = String(folioOc || '').trim().toUpperCase();
|
||||||
|
const hoja = hojaCompras_(libro, 'OrdenesCompra');
|
||||||
|
let orden = null;
|
||||||
|
filasDeArea_(hoja, HOJAS_COMPRAS, 'OrdenesCompra').forEach((r, i) => {
|
||||||
|
if (String(r[0]).toUpperCase() === folio) orden = ordenDesdeFila_(r, i + 2);
|
||||||
|
});
|
||||||
|
if (!orden) throw new Error('No existe la orden de compra ' + folio + '.');
|
||||||
|
return { hoja: hoja, orden: orden };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marca una orden Emitida como Recibida (completa), con la fecha de recepción (AAAA-MM-DD, no futura). */
|
||||||
|
function marcarOcRecibida(folioOc, fecha) {
|
||||||
|
exigirCompras_();
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const { hoja, orden } = buscarOrden_(libro, folioOc);
|
||||||
|
if (orden.estado !== OC_EMITIDA) throw new Error('La orden ' + orden.folio + ' ya está ' + orden.estado.toLowerCase() + '.');
|
||||||
|
const recepcion = validarFechaIso(fecha, hoyIso_(), 'La fecha de recepción');
|
||||||
|
if (orden.fecha && recepcion < orden.fecha) throw new Error('La fecha de recepción no puede ser anterior a la orden (' + orden.fecha + ').');
|
||||||
|
hoja.getRange(orden.fila, COL_ESTADO_OC, 1, 3).setValues([[OC_RECIBIDA, textoFecha_(recepcion), textoCelda(VENDEDOR_.email)]]);
|
||||||
|
bitacora_(libro, 'Recibir orden de compra', orden.proyectoFolio, orden.folio + ' · recibida el ' + recepcion, '', HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCompras_(libro, proyectoParaCompras_(orden.proyectoFolio));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancela una orden Emitida (no se borra), con motivo; queda en la Bitacora y deja de contar en el margen. */
|
||||||
|
function cancelarOrdenCompra(folioOc, motivo) {
|
||||||
|
exigirCompras_();
|
||||||
|
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 = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const { hoja, orden } = buscarOrden_(libro, folioOc);
|
||||||
|
if (orden.estado === OC_CANCELADA) throw new Error('La orden ' + orden.folio + ' ya está cancelada.');
|
||||||
|
if (orden.estado !== OC_EMITIDA) throw new Error('La orden ' + orden.folio + ' ya se recibió: no se puede cancelar.');
|
||||||
|
hoja.getRange(orden.fila, COL_ESTADO_OC).setValue(OC_CANCELADA);
|
||||||
|
hoja.getRange(orden.fila, COL_ESTADO_OC + 3, 1, 3).setValues([[textoCelda(VENDEDOR_.email),
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
|
||||||
|
bitacora_(libro, 'Cancelar orden de compra', orden.proyectoFolio, orden.folio + ' · ' + orden.proveedor + ' · ' +
|
||||||
|
formatoMoneda(orden.total) + ' ' + orden.moneda, m, HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaCompras_(libro, proyectoParaCompras_(orden.proyectoFolio));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correr desde el editor: crea la carpeta "Cotizador Helgen — Compras" (en Mi unidad) y su libro, y deja los dos en
|
||||||
|
* "Restringido" (también si ya existían). No comparte nada: a quien deba abrir los PDF se le comparte la carpeta en Drive.
|
||||||
|
*/
|
||||||
|
function prepararCompras() {
|
||||||
|
const libro = libroCompras_();
|
||||||
|
const carpeta = carpetaCompras_(); // primero la carpeta: el libro hereda su acceso y Drive no deja quitarlo en el hijo
|
||||||
|
const archivoLibro = privado_(DriveApp.getFileById(libro.getId()));
|
||||||
|
getConfig_(); // agrega TERMINOS_OC a la pestaña Config si falta
|
||||||
|
Logger.log('Carpeta de compras: ' + carpeta.getUrl() + ' (restringida)');
|
||||||
|
Logger.log('Libro de compras: ' + archivoLibro.getUrl() + ' (restringido)');
|
||||||
|
Logger.log('ROL_COMPRAS: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COMPRAS') || '(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) {
|
||||||
@ -1968,6 +2307,158 @@ function nombreComprobante(folio, metodo, referencia, ext) {
|
|||||||
'.' + ext;
|
'.' + ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Compras: órdenes de compra, proveedores y margen ----
|
||||||
|
|
||||||
|
const IVA_OC = [0.16, 0.08, 0]; // tasas de IVA que admite una orden (16% general, 8% frontera, 0% extranjero/exento)
|
||||||
|
const MAX_RENGLONES_OC = 100;
|
||||||
|
const RFC_VALIDO = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||||
|
|
||||||
|
/** Valida y limpia un proveedor {razonSocial, rfc, contacto, correo, telefono}. */
|
||||||
|
function normalizarProveedor(p) {
|
||||||
|
const d = p || {};
|
||||||
|
const limpio = s => String(s || '').trim().replace(/\s+/g, ' ');
|
||||||
|
const prov = {
|
||||||
|
razonSocial: limpio(d.razonSocial), rfc: String(d.rfc || '').replace(/[\s-]/g, '').toUpperCase(),
|
||||||
|
contacto: limpio(d.contacto), correo: String(d.correo || '').trim().toLowerCase(), telefono: limpio(d.telefono),
|
||||||
|
};
|
||||||
|
if (!prov.razonSocial) throw new Error('Escribe la razón social del proveedor.');
|
||||||
|
if (prov.razonSocial.length > 150) throw new Error('La razón social debe tener máximo 150 caracteres.');
|
||||||
|
if (prov.rfc && !RFC_VALIDO.test(prov.rfc)) throw new Error('El RFC del proveedor no es válido.');
|
||||||
|
if (prov.correo && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(prov.correo)) throw new Error('El correo del proveedor no es válido.');
|
||||||
|
if (prov.contacto.length > 100 || prov.correo.length > 100 || prov.telefono.length > 40) {
|
||||||
|
throw new Error('Contacto, correo y teléfono del proveedor son demasiado largos.');
|
||||||
|
}
|
||||||
|
return prov;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identificador de un proveedor: su RFC si es válido y no genérico; si no, "SIN-RFC:" + razón social normalizada. */
|
||||||
|
function proveedorId(p) {
|
||||||
|
const rfc = String((p && p.rfc) || '').replace(/[\s-]/g, '').toUpperCase();
|
||||||
|
if (RFC_VALIDO.test(rfc) && RFC_GENERICOS.indexOf(rfc) === -1) return rfc;
|
||||||
|
return 'SIN-RFC:' + textoNormal(p && p.razonSocial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datos de un proveedor ya registrado + los nuevos: lo nuevo manda, pero un dato vacío no borra uno anterior. */
|
||||||
|
function fusionarProveedor(anterior, nuevo) {
|
||||||
|
const r = {};
|
||||||
|
['razonSocial', 'rfc', 'contacto', 'correo', 'telefono'].forEach(k => {
|
||||||
|
r[k] = String((nuevo && nuevo[k]) || '').trim() || String((anterior && anterior[k]) || '').trim();
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filas de la pestaña Proveedores (sin encabezado) → [{id, razonSocial, rfc, contacto, correo, telefono, actualizado,
|
||||||
|
* etiqueta, fila}], por razón social. etiqueta = razón social, o "razón social — RFC" si la razón social se repite.
|
||||||
|
*/
|
||||||
|
function proveedoresDesdeFilas(filas) {
|
||||||
|
const lista = (filas || []).map((r, i) => ({
|
||||||
|
id: String(r[0] || '').trim(), razonSocial: String(r[1] || '').trim(), rfc: String(r[2] || '').trim(),
|
||||||
|
contacto: String(r[3] || '').trim(), correo: String(r[4] || '').trim(), telefono: String(r[5] || '').trim(),
|
||||||
|
actualizado: r[6] instanceof Date ? r[6].toISOString().slice(0, 10) : String(r[6] || ''), fila: i + 2,
|
||||||
|
})).filter(p => p.razonSocial);
|
||||||
|
const veces = {};
|
||||||
|
lista.forEach(p => { const n = textoNormal(p.razonSocial); veces[n] = (veces[n] || 0) + 1; });
|
||||||
|
lista.forEach(p => { p.etiqueta = veces[textoNormal(p.razonSocial)] > 1 && p.rfc ? p.razonSocial + ' — ' + p.rfc : p.razonSocial; });
|
||||||
|
return lista.sort((a, b) => textoNormal(a.razonSocial) < textoNormal(b.razonSocial) ? -1 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida y calcula una orden de compra. proyecto = {moneda, ubicacion, hoy "AAAA-MM-DD", ivaTasa (de Config)}.
|
||||||
|
* El costo de cada renglón es el capturado (nunca el precio de venta). Si la orden es en otra moneda que el proyecto,
|
||||||
|
* el tipo de cambio (pesos por dólar) es obligatorio y subtotalProyecto queda en la moneda del proyecto (para el margen).
|
||||||
|
*/
|
||||||
|
function normalizarOrdenCompra(p, proyecto) {
|
||||||
|
const d = p || {};
|
||||||
|
const limpio = s => String(s == null ? '' : s).trim();
|
||||||
|
const proveedor = normalizarProveedor(d.proveedor);
|
||||||
|
|
||||||
|
const fechaEntrega = validarFechaIso(d.fechaEntrega, null, 'La fecha de entrega');
|
||||||
|
if (proyecto.hoy && fechaEntrega < proyecto.hoy) throw new Error('La fecha de entrega no puede ser anterior a hoy.');
|
||||||
|
const lugarEntrega = limpio(d.lugarEntrega).replace(/\s+/g, ' ') || limpio(proyecto.ubicacion);
|
||||||
|
if (!lugarEntrega) throw new Error('Escribe el lugar de entrega.');
|
||||||
|
if (lugarEntrega.length > 200) throw new Error('El lugar de entrega debe tener máximo 200 caracteres.');
|
||||||
|
const condicionesPago = limpio(d.condicionesPago).replace(/\s+/g, ' ');
|
||||||
|
if (!condicionesPago) throw new Error('Escribe las condiciones de pago (p. ej. contado, crédito 30 días).');
|
||||||
|
if (condicionesPago.length > 200) throw new Error('Las condiciones de pago deben tener máximo 200 caracteres.');
|
||||||
|
const notas = limpio(d.notas);
|
||||||
|
if (notas.length > 1000) throw new Error('Las notas deben tener máximo 1000 caracteres.');
|
||||||
|
|
||||||
|
const moneda = limpio(d.moneda) ? monedaDeCotizacion(d.moneda) : proyecto.moneda;
|
||||||
|
const tasaTxt = d.ivaTasa;
|
||||||
|
const ivaTasa = tasaTxt === '' || tasaTxt == null ? Number(proyecto.ivaTasa) : Number(tasaTxt);
|
||||||
|
if (!IVA_OC.some(t => Math.abs(t - ivaTasa) < 1e-9)) throw new Error('Tasa de IVA no válida: usa 16%, 8% o 0%.');
|
||||||
|
|
||||||
|
const renglones = (Array.isArray(d.renglones) ? d.renglones : [])
|
||||||
|
.filter(r => limpio(r && r.descripcion))
|
||||||
|
.map(r => {
|
||||||
|
const descripcion = limpio(r.descripcion);
|
||||||
|
const cantidad = Number(r.cantidad), costoTxt = r.costo, costo = Number(costoTxt);
|
||||||
|
if (descripcion.length > 500) throw new Error('La descripción "' + descripcion.slice(0, 30) + '…" debe tener máximo 500 caracteres.');
|
||||||
|
if (!isFinite(cantidad) || cantidad <= 0) throw new Error('La cantidad de "' + descripcion.slice(0, 40) + '" debe ser mayor a 0.');
|
||||||
|
if (costoTxt === '' || costoTxt == null || !isFinite(costo)) throw new Error('Captura el costo unitario de "' + descripcion.slice(0, 40) + '".');
|
||||||
|
if (costo < 0) throw new Error('El costo de "' + descripcion.slice(0, 40) + '" no puede ser negativo.');
|
||||||
|
const c = redondear(costo);
|
||||||
|
return { clave: limpio(r.clave).toUpperCase().slice(0, 30), descripcion: descripcion,
|
||||||
|
unidad: limpio(r.unidad).toUpperCase().slice(0, 20) || 'PZA', cantidad: cantidad, costo: c, importe: redondear(cantidad * c) };
|
||||||
|
});
|
||||||
|
if (!renglones.length) throw new Error('Agrega al menos un renglón con descripción, cantidad y costo.');
|
||||||
|
if (renglones.length > MAX_RENGLONES_OC) throw new Error('Una orden admite máximo ' + MAX_RENGLONES_OC + ' renglones.');
|
||||||
|
|
||||||
|
const subtotal = redondear(renglones.reduce((s, r) => s + r.importe, 0));
|
||||||
|
if (subtotal <= 0) throw new Error('El total de la orden debe ser mayor a 0.');
|
||||||
|
const iva = redondear(subtotal * ivaTasa);
|
||||||
|
|
||||||
|
let tipoCambio = '';
|
||||||
|
if (moneda !== proyecto.moneda) {
|
||||||
|
tipoCambio = Number(d.tipoCambio);
|
||||||
|
if (d.tipoCambio === '' || d.tipoCambio == null || !isFinite(tipoCambio) || tipoCambio < 1 || tipoCambio > 1000) {
|
||||||
|
throw new Error('El proyecto es en ' + proyecto.moneda + ' y la orden en ' + moneda +
|
||||||
|
': captura el tipo de cambio del día (pesos por dólar).');
|
||||||
|
}
|
||||||
|
tipoCambio = Math.round(tipoCambio * 10000) / 10000;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
proveedor: proveedor, fechaEntrega: fechaEntrega, lugarEntrega: lugarEntrega, condicionesPago: condicionesPago,
|
||||||
|
moneda: moneda, ivaTasa: ivaTasa, renglones: renglones, subtotal: subtotal, iva: iva, total: redondear(subtotal + iva),
|
||||||
|
monedaProyecto: proyecto.moneda, tipoCambio: tipoCambio,
|
||||||
|
subtotalProyecto: convertirMoneda(subtotal, moneda, proyecto.moneda, tipoCambio), notas: notas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Margen de un proyecto: contratado (sin IVA) − costos (sin IVA), en la moneda del contrato. costos = [{concepto, monto,
|
||||||
|
* moneda, cantidad?}] (fase 3: órdenes de compra; fase 4: nómina y horas). Un costo en otra moneda es error: nunca se
|
||||||
|
* suman MXN y USD. Devuelve {moneda, contratado, costos, totalCostos, margen, porcentaje (sobre el contratado, 1 decimal)}.
|
||||||
|
*/
|
||||||
|
function margenProyecto(contratado, moneda, costos) {
|
||||||
|
const lista = (costos || []).map(c => {
|
||||||
|
if (c.moneda !== moneda) {
|
||||||
|
throw new Error('hay costos en ' + c.moneda + ' y el contrato es en ' + moneda + ' (no se suman monedas distintas).');
|
||||||
|
}
|
||||||
|
return { concepto: c.concepto, moneda: c.moneda, monto: redondear(c.monto), cantidad: c.cantidad || 0 };
|
||||||
|
});
|
||||||
|
const contratadoCts = aCentavos(contratado);
|
||||||
|
const costosCts = lista.reduce((s, c) => s + aCentavos(c.monto), 0);
|
||||||
|
const margenCts = contratadoCts - costosCts;
|
||||||
|
return {
|
||||||
|
moneda: moneda, contratado: contratadoCts / 100, costos: lista, totalCostos: costosCts / 100, margen: margenCts / 100,
|
||||||
|
porcentaje: contratadoCts > 0 ? Math.round(margenCts / contratadoCts * 1000) / 10 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre del PDF de una orden: "OC-2026-0001 — <proveedor>.pdf", sin caracteres raros. */
|
||||||
|
function nombreArchivoOc(folio, proveedor) {
|
||||||
|
const limpio = s => String(s || '').replace(/[\/\\:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return (limpio(folio) + ' — ' + limpio(proveedor).slice(0, 80)).slice(0, 140) + '.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renglones de los términos de las órdenes de compra (TERMINOS_OC de Config; si la celda está vacía, los iniciales). */
|
||||||
|
function terminosOcParaPdf(cfg) {
|
||||||
|
const lineas = String((cfg && cfg.TERMINOS_OC) || '').split('\n').map(t => t.trim()).filter(Boolean);
|
||||||
|
return lineas.length ? lineas : TERMINOS_OC_INICIALES.split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 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 ' +
|
||||||
@ -2379,3 +2870,110 @@ function construirHtmlPdf(d, cfg, logoUrl) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</body></html>';
|
'</body></html>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML del PDF de una orden de compra (mismas reglas que construirHtmlPdf: tablas, inline CSS, sin depender de fondos).
|
||||||
|
* d = normalizarOrdenCompra(...) + {folio, fecha "dd/MM/yyyy", proveedor (con id), proyectoFolio, elaboro}.
|
||||||
|
* Siempre con la razón social y los datos fiscales de Helgen (Config), también en proyectos de Helgen Energy.
|
||||||
|
* El tipo de cambio y la línea de negocio son internos: no salen en el PDF.
|
||||||
|
*/
|
||||||
|
function construirHtmlOrdenCompra(d, cfg, logoUrl) {
|
||||||
|
const e = escaparHtml;
|
||||||
|
const AZUL = '#2C3C5B', AQUA = '#51F3DA', TEAL = '#0AC5A8', CIELO = '#F2F4F7', GRIS = '#6B7280';
|
||||||
|
const usd = d.moneda === 'USD';
|
||||||
|
|
||||||
|
const logo = logoUrl
|
||||||
|
? '<img src="' + logoUrl + '" style="height:44px;">'
|
||||||
|
: '<div style="font-size:26px;font-weight:bold;color:' + AZUL + ';">Helgen</div>';
|
||||||
|
|
||||||
|
const filas = d.renglones.map((it, i) =>
|
||||||
|
'<tr' + (i % 2 ? ' bgcolor="' + CIELO + '"' : '') + '>' +
|
||||||
|
'<td class="c">' + (i + 1) + '</td>' +
|
||||||
|
'<td>' + e(it.clave) + '</td>' +
|
||||||
|
'<td>' + e(it.descripcion).replace(/\n/g, '<br>') + '</td>' +
|
||||||
|
'<td class="c">' + e(it.unidad) + '</td>' +
|
||||||
|
'<td class="r">' + e(it.cantidad) + '</td>' +
|
||||||
|
'<td class="r">' + formatoMoneda(it.costo) + '</td>' +
|
||||||
|
'<td class="r">' + formatoMoneda(it.importe) + '</td></tr>').join('');
|
||||||
|
|
||||||
|
const terminos = terminosOcParaPdf(cfg).map(t => '<li>' + e(t) + '</li>').join('');
|
||||||
|
const p = d.proveedor || {};
|
||||||
|
const dato = (k, v) => v ? '<tr><td class="k">' + k + '</td><td>' + e(v) + '</td></tr>' : '';
|
||||||
|
const docFila = (k, v, fuerte) =>
|
||||||
|
'<tr><td style="padding:1px 10px 1px 0;color:' + GRIS + ';text-align:right;">' + k + '</td>' +
|
||||||
|
'<td style="padding:1px 0;text-align:right;color:' + AZUL + ';' + (fuerte ? 'font-weight:bold;font-size:12px;' : '') + '">' + e(v) + '</td></tr>';
|
||||||
|
const fechaLarga = iso => { const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); return m ? m[3] + '/' + m[2] + '/' + m[1] : String(iso || ''); };
|
||||||
|
|
||||||
|
return '<!DOCTYPE html><html><head><meta charset="utf-8"><style>' +
|
||||||
|
'body{font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#1F2937;margin:0;}' +
|
||||||
|
'table{border-collapse:collapse;width:100%;}' +
|
||||||
|
'td,th{padding:5px 6px;vertical-align:top;}' +
|
||||||
|
'.c{text-align:center;}.r{text-align:right;white-space:nowrap;}' +
|
||||||
|
'.k{color:' + GRIS + ';width:110px;}' +
|
||||||
|
'.mod{font-size:11px;font-weight:bold;color:' + AZUL + ';border-bottom:2px solid ' + TEAL + ';padding:0 0 3px 0;margin:16px 0 6px 0;}' +
|
||||||
|
'.conceptos th{color:' + AZUL + ';font-size:9px;text-transform:uppercase;text-align:left;' +
|
||||||
|
'border-top:2px solid ' + AZUL + ';border-bottom:2px solid ' + AZUL + ';}' +
|
||||||
|
'.conceptos td{border-bottom:1px solid #E5E7EB;}' +
|
||||||
|
'.conceptos th.r{text-align:right;}.conceptos th.c{text-align:center;}' +
|
||||||
|
'</style></head><body>' +
|
||||||
|
|
||||||
|
// Encabezado: logo azul + datos del documento
|
||||||
|
'<table><tr>' +
|
||||||
|
'<td style="width:55%;padding:4px 0;vertical-align:middle;">' + logo + '</td>' +
|
||||||
|
'<td style="width:45%;padding:0;text-align:right;vertical-align:middle;">' +
|
||||||
|
'<table style="width:auto;margin-left:auto;">' +
|
||||||
|
'<tr><td colspan="2" style="font-size:20px;font-weight:bold;letter-spacing:2px;color:' + AZUL + ';padding:0 0 3px;text-align:right;">ORDEN DE COMPRA</td></tr>' +
|
||||||
|
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Proyecto', d.proyectoFolio) +
|
||||||
|
(usd ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
|
||||||
|
'</table></td></tr></table>' +
|
||||||
|
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
|
||||||
|
'<div style="padding:6px 0 0;color:#4B5563;line-height:1.5;">' +
|
||||||
|
'<b style="color:' + AZUL + ';">' + e(cfg.EMPRESA_NOMBRE) + '</b> · RFC ' + e(cfg.EMPRESA_RFC) + '<br>' + e(cfg.EMPRESA_DIRECCION) + '<br>' +
|
||||||
|
e(cfg.EMPRESA_TELEFONO) + ' · ' + e(cfg.EMPRESA_EMAIL) + ' · ' + e(cfg.EMPRESA_WEB) + '</div>' +
|
||||||
|
|
||||||
|
// Módulo 1: Proveedor y entrega
|
||||||
|
'<div class="mod">1 · PROVEEDOR Y ENTREGA</div>' +
|
||||||
|
'<table><tr><td style="width:50%;padding:0;"><table>' +
|
||||||
|
dato('Proveedor', p.razonSocial) + dato('RFC', p.rfc) + dato('Contacto', p.contacto) + dato('Correo', p.correo) +
|
||||||
|
dato('Teléfono', p.telefono) +
|
||||||
|
'</table></td><td style="width:50%;padding:0;"><table>' +
|
||||||
|
dato('Fecha de entrega', fechaLarga(d.fechaEntrega)) + dato('Lugar de entrega', d.lugarEntrega) +
|
||||||
|
dato('Condiciones de pago', d.condicionesPago) +
|
||||||
|
'</table></td></tr></table>' +
|
||||||
|
|
||||||
|
// Módulo 2: Conceptos
|
||||||
|
'<div class="mod">2 · CONCEPTOS</div>' +
|
||||||
|
'<table class="conceptos"><tr>' +
|
||||||
|
'<th class="c" style="width:22px;">#</th><th style="width:60px;">Clave</th><th>Descripción</th>' +
|
||||||
|
'<th class="c" style="width:45px;">Unidad</th><th class="r" style="width:45px;">Cant.</th>' +
|
||||||
|
(usd
|
||||||
|
? '<th class="r" style="width:95px;">Costo unit. (USD)</th><th class="r" style="width:95px;">Importe (USD)</th></tr>'
|
||||||
|
: '<th class="r" style="width:85px;">Costo unit.</th><th class="r" style="width:85px;">Importe</th></tr>') +
|
||||||
|
filas + '</table>' +
|
||||||
|
|
||||||
|
// Módulo 3: Totales
|
||||||
|
'<div class="mod">3 · RESUMEN</div>' +
|
||||||
|
'<table><tr>' +
|
||||||
|
'<td style="width:58%;padding-left:0;">' +
|
||||||
|
'<div style="color:' + GRIS + ';">Importe con letra</div>' +
|
||||||
|
'<div style="font-weight:bold;color:' + AZUL + ';margin:2px 0 8px;">(' + importeConLetra(d.total, d.moneda) + ')</div>' +
|
||||||
|
(d.notas ? '<div style="color:' + GRIS + ';">Notas</div><div>' + e(d.notas).replace(/\n/g, '<br>') + '</div>' : '') +
|
||||||
|
'</td><td style="width:42%;padding-right:0;"><table>' +
|
||||||
|
'<tr><td>Subtotal</td><td class="r">' + formatoMoneda(d.subtotal) + '</td></tr>' +
|
||||||
|
'<tr><td>IVA (' + Math.round(d.ivaTasa * 100) + '%)</td><td class="r">' + formatoMoneda(d.iva) + '</td></tr>' +
|
||||||
|
'<tr bgcolor="#E6FBF7" style="color:' + AZUL + ';font-weight:bold;font-size:14px;">' +
|
||||||
|
'<td style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">TOTAL ' + e(d.moneda || 'MXN') + '</td>' +
|
||||||
|
'<td class="r" style="border-top:2px solid ' + AZUL + ';border-bottom:3px solid ' + AQUA + ';padding:7px 6px;">' + formatoMoneda(d.total) + '</td></tr>' +
|
||||||
|
'</table></td></tr></table>' +
|
||||||
|
|
||||||
|
// Términos
|
||||||
|
'<table style="margin-top:18px;"><tr><td bgcolor="' + CIELO + '" style="border-left:3px solid ' + TEAL + ';padding:8px 10px;">' +
|
||||||
|
'<div style="font-weight:bold;color:' + AZUL + ';margin-bottom:4px;">TÉRMINOS</div>' +
|
||||||
|
'<ul style="margin:0;padding-left:16px;line-height:1.5;">' + terminos + '</ul></td></tr></table>' +
|
||||||
|
|
||||||
|
// Pie
|
||||||
|
'<div style="margin-top:18px;border-top:1px solid #D1D5DB;padding:6px 0 0;color:' + GRIS + ';font-size:9px;">' +
|
||||||
|
'Elaboró: ' + e(d.elaboro) + ' · ' + e(cfg.EMPRESA_EMAIL) + ' · ' + e(cfg.EMPRESA_TELEFONO) +
|
||||||
|
'</div>' +
|
||||||
|
'</body></html>';
|
||||||
|
}
|
||||||
|
|||||||
@ -164,7 +164,7 @@ const sinProy=cot('',50);
|
|||||||
|
|
||||||
// --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
// --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
||||||
const dAna=ok('ana','getDatosIniciales');
|
const dAna=ok('ana','getDatosIniciales');
|
||||||
assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false});
|
assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false,compras:false});
|
||||||
assert(!/contrato|pagos|saldo/i.test(JSON.stringify(dAna)),'datos iniciales sin cobranza');
|
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()]],
|
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']]];
|
['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]];
|
||||||
@ -174,8 +174,8 @@ 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');
|
assert.throws(()=>ctx.getCobranzaProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
|
||||||
|
|
||||||
// --- Tania (rol de cobranza, sin desbloqueo) ---
|
// --- Tania (rol de cobranza, sin desbloqueo) ---
|
||||||
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false});
|
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false});
|
||||||
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true});
|
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:false});
|
||||||
const lista=ok('tania','listarProyectosCobranza','');
|
const lista=ok('tania','listarProyectosCobranza','');
|
||||||
assert.deepEqual(lista.map(p=>p.folio+':'+p.contrato),[p2+':',p1+':']);
|
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
|
// Carpeta y libro: fuera de "Cotizador Helgen", libro dentro de la carpeta de cobranza
|
||||||
|
|||||||
295
tests/compras.test.js
Normal file
295
tests/compras.test.js
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
// Compras (fase 3): órdenes de compra por proyecto, proveedores, PDF y margen. Permisos con ROL_COMPRAS validados en el
|
||||||
|
// servidor; el margen solo lo ve ROL_COBRANZA. Todo por doPost con tokens simulados (mismos simulacros que cobranza.test.js).
|
||||||
|
// Uso: node tests/compras.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 ----
|
||||||
|
// Como Sheets: un texto que parece fecha ("2026-10-20", "2026-10-20 17:30") se guarda como fecha; con apóstrofo
|
||||||
|
// inicial se guarda como texto (y getValues lo devuelve sin el apóstrofo).
|
||||||
|
const comoSheets=v=>{if(typeof v!=='string')return v;if(v[0]==="'")return v.slice(1);
|
||||||
|
const x=v.match(/^(\d{4})-(\d\d)-(\d\d)(?: (\d\d):(\d\d)(?::(\d\d))?)?$/);
|
||||||
|
return x?new Date(+x[1],+x[2]-1,+x[3],+(x[4]||0),+(x[5]||0),+(x[6]||0)):v};
|
||||||
|
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.map(comoSheets)),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]=comoSheets(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]=comoSheets(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={};
|
||||||
|
// Como Workspace de helgen.tech: todo lo nuevo nace compartido con el dominio (acceso 'DOMAIN')
|
||||||
|
// DriveApp.setSharing da "Access denied" en helgen.tech: el código usa la API de Drive (permissions) con el token del script
|
||||||
|
const compartible=o=>Object.assign(o,{acceso:'DOMAIN',setSharing:()=>{throw new Error('Access denied: DriveApp.')}});
|
||||||
|
function apiDrive(url,op){const m=url.match(/drive\/v3\/files\/([^/?]+)\/permissions(?:\/([^?]+))?/);
|
||||||
|
const it=carpetas[decodeURIComponent(m[1])]||archivos[decodeURIComponent(m[1])];
|
||||||
|
assert(op&&op.headers&&op.headers.Authorization==='Bearer TOKEN-SCRIPT','API de Drive con el token del script');
|
||||||
|
const resp=(c,b)=>({getResponseCode:()=>c,getContentText:()=>JSON.stringify(b||{})});
|
||||||
|
if(!it)return resp(404);
|
||||||
|
// Como Drive: el acceso del dominio se hereda de las carpetas y no se puede quitar en el hijo mientras el padre lo tenga
|
||||||
|
let heredado=false;for(let f=it.padre;f;f=f.padre)if(f.acceso==='DOMAIN')heredado=true;
|
||||||
|
if(op.method==='delete'){assert.equal(m[2],'dom');if(heredado)return resp(403,{error:{reason:'cannotDeletePermission'}});it.acceso='PRIVATE';return resp(204)}
|
||||||
|
return resp(200,{permissions:[{id:'own',type:'user'}].concat(it.acceso==='DOMAIN'||heredado?[{id:'dom',type:'domain'}]:[])})}
|
||||||
|
function carpeta(nombre,padre){const f=compartible({id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false}); Object.assign(f,{
|
||||||
|
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=compartible({id,blob:b,padre:f});archivos[id]=a;f.archivos.push(a);return Object.assign(a,{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)');
|
||||||
|
miUnidad.acceso='PRIVATE'; // la raíz de Mi unidad no se comparte; la carpeta maestra sí (todo el dominio, §6.11)
|
||||||
|
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',ADMIN_DESBLOQUEO:'efrain@helgen.tech',
|
||||||
|
ROL_COMPRAS:'efrain@helgen.tech; Pedro@helgen.tech, intruso@gmail.com'},cache={};
|
||||||
|
|
||||||
|
// ---- Tokens: efrain (cobranza + compras + admin), tania (cobranza sin compras), pedro (compras sin 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'),pedro:claims('pedro@helgen.tech','Pedro','Ruiz'),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()]},
|
||||||
|
ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'},
|
||||||
|
UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);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]=compartible({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},setSharing:()=>archivos[id].setSharing(),getId:()=>id,getUrl:()=>'https://drive/file/'+id}),
|
||||||
|
Access:{PRIVATE:'PRIVATE',DOMAIN:'DOMAIN'},Permission:{NONE:'NONE',VIEW:'VIEW'}},
|
||||||
|
};
|
||||||
|
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};
|
||||||
|
|
||||||
|
const MANANA=(()=>{const d=new Date(HOY.getTime()+86400000);return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())})();
|
||||||
|
const AYER=(()=>{const d=new Date(HOY.getTime()-86400000);return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())})();
|
||||||
|
function vivos(f){return f.hijos.filter(h=>!h.trashed)}
|
||||||
|
|
||||||
|
// ================= Lógica pura =================
|
||||||
|
const P=ctx;
|
||||||
|
// Proveedor
|
||||||
|
const pv=P.normalizarProveedor({razonSocial:' Paneles del Bajío ',rfc:'pdb-200101-ab1',correo:' Ventas@PDB.mx ',telefono:'+52 442 000 0000'});
|
||||||
|
assert.deepEqual([pv.razonSocial,pv.rfc,pv.correo],['Paneles del Bajío','PDB200101AB1','ventas@pdb.mx']);
|
||||||
|
for(const [mal,re] of [[{},/razón social/],[{razonSocial:'X',rfc:'ABC'},/RFC del proveedor no es válido/],
|
||||||
|
[{razonSocial:'X',correo:'no-es-correo'},/correo del proveedor/],[{razonSocial:'X'.repeat(151)},/150 caracteres/]]){
|
||||||
|
assert.throws(()=>P.normalizarProveedor(mal),re,JSON.stringify(mal));
|
||||||
|
}
|
||||||
|
assert.equal(P.proveedorId({razonSocial:'X',rfc:'PDB200101AB1'}),'PDB200101AB1');
|
||||||
|
assert.equal(P.proveedorId({razonSocial:'Pánéles del Bajío',rfc:'XEXX010101000'}),'SIN-RFC:paneles del bajio','RFC genérico no identifica');
|
||||||
|
assert.deepEqual({...P.fusionarProveedor({razonSocial:'A',contacto:'Luis',correo:'a@a.mx'},{razonSocial:'A SA',contacto:'',correo:'b@b.mx'})},
|
||||||
|
{razonSocial:'A SA',rfc:'',contacto:'Luis',correo:'b@b.mx',telefono:''},'un dato vacío no borra uno anterior');
|
||||||
|
const pl=P.proveedoresDesdeFilas([['X1','Zeta','','','','',''],['R1','Acme','AAA010101AA1','','','',''],['R2','acme','BBB010101BB1','','','',''],['','','','','','','']]);
|
||||||
|
assert.deepEqual(pl.map(p=>p.etiqueta+'@'+p.fila),['Acme — AAA010101AA1@3','acme — BBB010101BB1@4','Zeta@2']);
|
||||||
|
// Orden de compra
|
||||||
|
const PRO={moneda:'MXN',ubicacion:'Querétaro, Qro.',hoy:'2026-10-20',ivaTasa:0.16};
|
||||||
|
const base={proveedor:{razonSocial:'Paneles del Bajío'},fechaEntrega:'2026-10-25',condicionesPago:' Crédito 30 días ',
|
||||||
|
renglones:[{clave:'pan-550',descripcion:'Panel solar 550 W',unidad:'pza',cantidad:'10',costo:'2500.5'},{descripcion:'',cantidad:1,costo:''}]};
|
||||||
|
let o=P.normalizarOrdenCompra(base,PRO);
|
||||||
|
assert.deepEqual([o.lugarEntrega,o.condicionesPago,o.moneda,o.ivaTasa,o.subtotal,o.iva,o.total,o.tipoCambio,o.subtotalProyecto],
|
||||||
|
['Querétaro, Qro.','Crédito 30 días','MXN',0.16,25005,4000.8,29005.8,'',25005],'por omisión: ubicación del proyecto, IVA de Config, moneda del proyecto');
|
||||||
|
assert.deepEqual({...o.renglones[0]},{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:10,costo:2500.5,importe:25005});
|
||||||
|
assert.equal(o.renglones.length,1,'los renglones vacíos se ignoran');
|
||||||
|
o=P.normalizarOrdenCompra({...base,moneda:'USD',tipoCambio:'18.5',ivaTasa:0,lugarEntrega:'Bodega Norte'},PRO);
|
||||||
|
assert.deepEqual([o.moneda,o.iva,o.tipoCambio,o.subtotalProyecto,o.monedaProyecto,o.lugarEntrega],['USD',0,18.5,462592.5,'MXN','Bodega Norte']);
|
||||||
|
assert.equal(P.normalizarOrdenCompra({...base,moneda:'MXN',tipoCambio:18.5,renglones:[{descripcion:'A',cantidad:1,costo:1850}]},{...PRO,moneda:'USD'}).subtotalProyecto,100);
|
||||||
|
assert.equal(P.normalizarOrdenCompra({...base,ivaTasa:'0.08'},PRO).iva,2000.4);
|
||||||
|
for(const [mal,re] of [[{fechaEntrega:''},/fecha de entrega es obligatoria/],[{fechaEntrega:'2026-10-19'},/anterior a hoy/],
|
||||||
|
[{condicionesPago:''},/condiciones de pago/],[{moneda:'EUR'},/Moneda no válida/],[{moneda:'USD'},/tipo de cambio/],
|
||||||
|
[{moneda:'USD',tipoCambio:'0.5'},/tipo de cambio/],[{ivaTasa:0.1},/Tasa de IVA/],[{renglones:[]},/al menos un renglón/],
|
||||||
|
[{renglones:[{descripcion:'A',cantidad:0,costo:1}]},/cantidad de "A"/],[{renglones:[{descripcion:'A',cantidad:1,costo:''}]},/costo unitario de "A"/],
|
||||||
|
[{renglones:[{descripcion:'A',cantidad:1,costo:-1}]},/negativo/],[{renglones:[{descripcion:'A',cantidad:1,costo:0}]},/mayor a 0/],
|
||||||
|
[{proveedor:{}},/razón social/]]){
|
||||||
|
assert.throws(()=>P.normalizarOrdenCompra({...base,...mal},PRO),re,JSON.stringify(mal));
|
||||||
|
}
|
||||||
|
assert.throws(()=>P.normalizarOrdenCompra({...base,lugarEntrega:''},{...PRO,ubicacion:''}),/lugar de entrega/);
|
||||||
|
// Margen: contratado sin IVA − costos; nunca mezcla monedas
|
||||||
|
const M=P.margenProyecto(10000,'MXN',[{concepto:'Órdenes de compra',moneda:'MXN',monto:3850.005,cantidad:2}]);
|
||||||
|
assert.deepEqual([M.contratado,M.totalCostos,M.margen,M.porcentaje],[10000,3850.01,6149.99,61.5]);
|
||||||
|
assert.deepEqual([P.margenProyecto(1000,'USD',[{concepto:'X',moneda:'USD',monto:1200}]).margen,P.margenProyecto(0,'MXN',[]).porcentaje],[-200,0]);
|
||||||
|
assert.throws(()=>P.margenProyecto(1000,'MXN',[{concepto:'X',moneda:'USD',monto:1}]),/no se suman monedas/);
|
||||||
|
assert.equal(P.nombreArchivoOc('OC-2026-0001','Paneles: "del" Bajío/SA'),'OC-2026-0001 — Paneles del Bajío SA.pdf');
|
||||||
|
assert.equal(P.terminosOcParaPdf({TERMINOS_OC:''}).length,4,'celda vacía: los términos iniciales');
|
||||||
|
assert.deepEqual([...P.terminosOcParaPdf({TERMINOS_OC:'Uno\n\n Dos '})],['Uno','Dos']);
|
||||||
|
|
||||||
|
// ================= Servidor (doPost) =================
|
||||||
|
// Proyectos y cotizaciones (los crea una vendedora); cobranza acepta y firma
|
||||||
|
const proy=(nombre,id,ubicacion)=>ok('ana','crearProyecto',{nombre,ubicacion,cliente:{nombre:'Luis',empresa:'Solar MX'},idSolicitud:id}).folio;
|
||||||
|
const p1=proy('Techo solar','p1','Querétaro, Qro.'), p2=proy('Planta Texas','p2','Austin, TX'), p3=proy('Sin firmar','p3',''), p4=proy('Se cancela','p4','');
|
||||||
|
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 c1=cot(p1,10000), c1usd=cot(p1,100,{moneda:'USD'}), c2=cot(p2,5000,{moneda:'USD',marca:'energy'}), c3=cot(p3,100), c4=cot(p4,100);
|
||||||
|
for(const [p,c] of [[p1,c1],[p2,c2],[p4,c4]]){ok('efrain','marcarAceptada',p,c,true);ok('efrain','firmarContrato',p,HOY_ISO)}
|
||||||
|
ok('efrain','marcarAceptada',p3,c3);
|
||||||
|
const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado';
|
||||||
|
// Los proyectos nuevos ya no tienen subcarpeta "Órdenes de compra"
|
||||||
|
const carpP1=carpetas[db.pest.Proyectos.m.find(r=>r[0]===p1)[7]];
|
||||||
|
assert.deepEqual(carpP1.hijos.map(h=>h.nombre),['Cotizaciones']);
|
||||||
|
|
||||||
|
// --- Sin rol de compras: no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
||||||
|
assert.deepEqual({...ok('ana','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:false});
|
||||||
|
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false});
|
||||||
|
assert.deepEqual({...ok('pedro','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:true});
|
||||||
|
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:true});
|
||||||
|
const OC=f=>'OC-'+ANIO+'-'+f;
|
||||||
|
const COMPRAS=[['listarProyectosCompras',['']],['getComprasProyecto',[p1]],['guardarOrdenCompra',[{proyectoFolio:p1,...base}]],
|
||||||
|
['marcarOcRecibida',[OC('0001'),HOY_ISO]],['cancelarOrdenCompra',[OC('0001'),'motivo largo']]];
|
||||||
|
for(const quien of ['ana','tania']) for(const [f,args] of COMPRAS) falla(quien,f,args,/No tienes permiso de compras/,'PERMISO');
|
||||||
|
for(const [f,args] of COMPRAS) falla('intruso',f,args,/@helgen.tech/,'SESION');
|
||||||
|
assert.equal(props.COMPRAS_DB_ID,undefined,'una llamada sin rol no crea nada');
|
||||||
|
assert.throws(()=>ctx.getComprasProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
|
||||||
|
// Compras sin cobranza: no ve cobranza (ni el margen)
|
||||||
|
falla('pedro','getCobranzaProyecto',[p1],/No tienes permiso de cobranza/,'PERMISO');
|
||||||
|
|
||||||
|
// --- Lista: solo Activos con contrato firmado, con su moneda y sin montos ---
|
||||||
|
const lista=ok('pedro','listarProyectosCompras','');
|
||||||
|
assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca),[p2+':USD:energy',p1+':MXN:helgen']);
|
||||||
|
assert(!/total|subtotal|contrat/i.test(JSON.stringify(lista)),'sin montos del contrato');
|
||||||
|
assert.deepEqual(ok('pedro','listarProyectosCompras','texas').map(p=>p.folio),[p2]);
|
||||||
|
|
||||||
|
// --- Vista: sin montos del contrato ni margen; crea el libro y la carpeta privados ---
|
||||||
|
let v=ok('pedro','getComprasProyecto',p1.toLowerCase());
|
||||||
|
assert.deepEqual([v.proyecto.folio,v.moneda,v.marca,v.puedeEmitir,v.ordenes.length,v.proyecto.ubicacion],[p1,'MXN','helgen',true,0,'Querétaro, Qro.']);
|
||||||
|
assert(!('margen' in v)&&!('contrato' in v)&&!/11600|10000/.test(JSON.stringify(v)),'compras no ve el contratado ni el margen');
|
||||||
|
const carpCom=carpetas[props.COMPRAS_FOLDER_ID], LC=libros[props.COMPRAS_DB_ID];
|
||||||
|
assert.equal(ruta(carpCom),'Mi unidad/Cotizador Helgen — Compras');
|
||||||
|
assert.equal(LC.nombre,'Cotizador Helgen — Compras (datos)'); assert.equal(archivos[LC.id].padre,carpCom);
|
||||||
|
assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','OrdenesCompra','Proveedores']);
|
||||||
|
assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'carpeta y libro de compras restringidos');
|
||||||
|
const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1);
|
||||||
|
assert.equal(ok('pedro','getComprasProyecto',p3).puedeEmitir,false,'sin contrato firmado no se emite');
|
||||||
|
|
||||||
|
// --- Emitir: folio, línea de negocio del contrato (no del navegador), costo capturado, PDF privado ---
|
||||||
|
const oc=(extra)=>({proyectoFolio:p1,...base,fechaEntrega:HOY_ISO,proveedor:{razonSocial:'Paneles del Bajío',contacto:'Luis Pérez',telefono:'+52 442 111 2233'},
|
||||||
|
renglones:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:2,costo:1000,precio:4100}],
|
||||||
|
notas:'<script>x</script>',marca:'energy',idSolicitud:'o'+(++nId),...extra});
|
||||||
|
let r=ok('pedro','guardarOrdenCompra',oc({idSolicitud:'oc1'}));
|
||||||
|
assert.deepEqual([r.folio,r.total,r.moneda],[OC('0001'),2320,'MXN']);
|
||||||
|
let f=H('OrdenesCompra')[1];
|
||||||
|
assert.deepEqual([f[0],f[1],f[2],f[3],f[4],f[5],f[7],f[9],f[10],f[11],f[12],f[13],f[14],f[15],f[16],f[17],f[18],f[19],f[20],f[25],f[26],f[28]],
|
||||||
|
[r.folio,HOY_ISO,p1,'Helgen','SIN-RFC:paneles del bajio','Paneles del Bajío','Luis Pérez','+52 442 111 2233',HOY_ISO,'Querétaro, Qro.',
|
||||||
|
'Crédito 30 días','MXN',0.16,2000,320,2320,'MXN','',2000,'pedro@helgen.tech','Pedro Ruiz','Emitida']);
|
||||||
|
assert.deepEqual(JSON.parse(f[21]).map(x=>x.costo+'/'+x.importe+'/'+('precio' in x)),['1000/2000/false'],'el costo es el capturado; el precio de venta no se guarda');
|
||||||
|
const pdfOc=archivos[f[23]];
|
||||||
|
assert.equal(ruta(pdfOc.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1);
|
||||||
|
assert.equal(pdfOc.blob.name,r.folio+' — Paneles del Bajío.pdf'); assert.equal(f[24],r.url);
|
||||||
|
assert.deepEqual([pdfOc.acceso,pdfOc.padre.acceso,pdfOc.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE'],'PDF y subcarpetas restringidos');
|
||||||
|
const html=pdfOc.blob.html;
|
||||||
|
assert(/ORDEN DE COMPRA/.test(html)&&/Helgen Servicios Especializados/.test(html)&&/RFC HSE240304PC1/.test(html));
|
||||||
|
for(const t of ['Favor de indicar el folio de esta orden en su factura.','Facturar a nombre de la razón social y RFC indicados arriba.',
|
||||||
|
'Precios fijos según esta orden; cualquier cambio requiere una nueva orden.','Entregar en el lugar y fecha indicados.']) assert(html.includes(t),'término: '+t);
|
||||||
|
assert(/<script>/.test(html)&&!/<script>x/.test(html),'notas escapadas');
|
||||||
|
assert(!/4,100|4100/.test(html),'sin precio de venta'); assert(html.includes('DOS MIL TRESCIENTOS VEINTE PESOS 00/100 M.N.'));
|
||||||
|
// Idempotencia
|
||||||
|
assert.equal(ok('pedro','guardarOrdenCompra',oc({idSolicitud:'oc1'})).folio,r.folio); assert.equal(H('OrdenesCompra').length,2);
|
||||||
|
// Rechazos que no gastan folio
|
||||||
|
falla('pedro','guardarOrdenCompra',[oc({proyectoFolio:p3})],/contrato firmado/);
|
||||||
|
falla('pedro','guardarOrdenCompra',[oc({proyectoFolio:p4})],/está cancelado: no admite órdenes/);
|
||||||
|
falla('pedro','guardarOrdenCompra',[oc({moneda:'USD'})],/proyecto es en MXN y la orden en USD: captura el tipo de cambio/);
|
||||||
|
falla('pedro','guardarOrdenCompra',[oc({renglones:[{descripcion:'Inversor',cantidad:1,costo:''}]})],/costo unitario de "Inversor"/);
|
||||||
|
falla('pedro','guardarOrdenCompra',[oc({proyectoFolio:'PRY-2000-0001'})],/No existe el proyecto/);
|
||||||
|
assert.equal(props['CONSECUTIVO_OC_'+ANIO],'1','los rechazos no gastan folio'); assert.equal(H('OrdenesCompra').length,2);
|
||||||
|
// Orden en USD en un proyecto MXN: tipo de cambio obligatorio; el costo para el margen queda en pesos
|
||||||
|
r=ok('efrain','guardarOrdenCompra',oc({moneda:'USD',tipoCambio:18.5,ivaTasa:0,renglones:[{descripcion:'Inversor',cantidad:1,costo:100}],
|
||||||
|
proveedor:{razonSocial:'Paneles del Bajío',rfc:'PDB200101AB1',correo:'ventas@pdb.mx'},lugarEntrega:'Bodega Norte',fechaEntrega:MANANA}));
|
||||||
|
f=H('OrdenesCompra')[2];
|
||||||
|
assert.deepEqual([r.folio,f[4],f[13],f[14],f[16],f[17],f[18],f[19],f[20],f[11]],[OC('0002'),'PDB200101AB1','USD',0,0,100,'MXN',18.5,1850,'Bodega Norte']);
|
||||||
|
assert(/P\. Unitario|Costo unit\. \(USD\)/.test(archivos[f[23]].blob.html)&&/Moneda/.test(archivos[f[23]].blob.html)&&!/18\.5/.test(archivos[f[23]].blob.html),'USD en el PDF; el tipo de cambio no sale');
|
||||||
|
// Proveedor: el que tenía solo razón social toma el RFC (una sola fila) y conserva el contacto
|
||||||
|
assert.equal(H('Proveedores').length,2,'un solo proveedor');
|
||||||
|
assert.deepEqual(H('Proveedores')[1].slice(0,6),['PDB200101AB1','Paneles del Bajío','PDB200101AB1','Luis Pérez','ventas@pdb.mx','+52 442 111 2233']);
|
||||||
|
v=ok('pedro','getComprasProyecto',p1);
|
||||||
|
assert.deepEqual(v.proveedores.map(p=>p.etiqueta+'|'+p.rfc+'|'+p.contacto),['Paneles del Bajío|PDB200101AB1|Luis Pérez']);
|
||||||
|
assert.deepEqual(v.ordenes.map(x=>x.folio),[OC('0002'),OC('0001')],'más reciente primero');
|
||||||
|
// Proyecto USD de Helgen Energy pagado en pesos: costo en dólares; el PDF dice Helgen (no Helgen Energy)
|
||||||
|
r=ok('pedro','guardarOrdenCompra',oc({proyectoFolio:p2,moneda:'MXN',tipoCambio:18.5,renglones:[{descripcion:'Cable',cantidad:1,costo:1850}],
|
||||||
|
proveedor:{razonSocial:'Cables SA'}}));
|
||||||
|
f=H('OrdenesCompra')[3];
|
||||||
|
assert.deepEqual([f[3],f[13],f[18],f[20],f[11]],['Helgen Energy','MXN','USD',100,'Austin, TX'],'la línea de negocio sale del contrato');
|
||||||
|
assert(/Helgen Servicios Especializados/.test(archivos[f[23]].blob.html)&&!/HELGEN ENERGY|Helgen Energy/.test(archivos[f[23]].blob.html));
|
||||||
|
// Una tercera orden en p1 que se cancela
|
||||||
|
const r3=ok('pedro','guardarOrdenCompra',oc({renglones:[{descripcion:'Estructura',cantidad:1,costo:5000}]}));
|
||||||
|
|
||||||
|
// --- Recibida y cancelada (nunca se borran) ---
|
||||||
|
falla('pedro','marcarOcRecibida',[OC('0002'),'2099-01-01'],/no puede ser futura/);
|
||||||
|
falla('pedro','marcarOcRecibida',[OC('0002'),AYER],/anterior a la orden/);
|
||||||
|
v=ok('pedro','marcarOcRecibida',OC('0002'),HOY_ISO);
|
||||||
|
assert.deepEqual(v.ordenes.find(x=>x.folio===OC('0002')).estado+'|'+v.ordenes.find(x=>x.folio===OC('0002')).fechaRecepcion,'Recibida|'+HOY_ISO);
|
||||||
|
falla('pedro','marcarOcRecibida',[OC('0002'),HOY_ISO],/ya está recibida/);
|
||||||
|
falla('pedro','cancelarOrdenCompra',[OC('0002'),'Ya no se necesita'],/ya se recibió: no se puede cancelar/);
|
||||||
|
falla('pedro','cancelarOrdenCompra',[r3.folio,''],/motivo/);
|
||||||
|
v=ok('pedro','cancelarOrdenCompra',r3.folio,'El proveedor no tiene existencias');
|
||||||
|
const fc=H('OrdenesCompra').find(x=>x[0]===r3.folio);
|
||||||
|
assert.deepEqual([fc[28],fc[31],!!fc[32],fc[33]],['Cancelada','pedro@helgen.tech',true,'El proveedor no tiene existencias']);
|
||||||
|
falla('pedro','cancelarOrdenCompra',[r3.folio,'otra vez'],/ya está cancelada/);
|
||||||
|
falla('pedro','marcarOcRecibida',[r3.folio,HOY_ISO],/ya está cancelada/);
|
||||||
|
assert.equal(H('OrdenesCompra').length,5,'la cancelada sigue en la hoja');
|
||||||
|
|
||||||
|
// --- Margen (solo cobranza): contratado sin IVA − órdenes vigentes sin IVA, en la moneda del proyecto ---
|
||||||
|
v=ok('tania','getCobranzaProyecto',p1);
|
||||||
|
assert.deepEqual([v.margen.moneda,v.margen.contratado,v.margen.totalCostos,v.margen.margen,v.margen.porcentaje],['MXN',10000,3850,6150,61.5]);
|
||||||
|
assert.deepEqual(v.margen.costos.map(c=>c.concepto+':'+c.cantidad),['Órdenes de compra:2'],'emitida + recibida; la cancelada no cuenta');
|
||||||
|
v=ok('tania','getCobranzaProyecto',p2);
|
||||||
|
assert.deepEqual([v.margen.moneda,v.margen.contratado,v.margen.totalCostos,v.margen.margen,v.margen.porcentaje],['USD',5000,100,4900,98]);
|
||||||
|
v=ok('tania','getCobranzaProyecto',p3); assert.deepEqual([v.margen.totalCostos,v.margen.margen],[0,100],'sin órdenes');
|
||||||
|
|
||||||
|
// --- Con órdenes vigentes en MXN, la aceptada no puede cambiar a USD (tras desbloquear) ---
|
||||||
|
ok('efrain','desbloquearContrato',p1,'Prueba de moneda con órdenes');
|
||||||
|
falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/);
|
||||||
|
// Sin contrato firmado: sale de la lista y no se emiten órdenes; las que tiene se siguen viendo
|
||||||
|
assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio),[p2]);
|
||||||
|
v=ok('pedro','getComprasProyecto',p1); assert.deepEqual([v.puedeEmitir,v.ordenes.length],[false,3]);
|
||||||
|
falla('pedro','guardarOrdenCompra',[oc()],/contrato firmado/);
|
||||||
|
ok('efrain','firmarContrato',p1,HOY_ISO);
|
||||||
|
|
||||||
|
// --- Bitácora, separación de datos y reparación del acceso ---
|
||||||
|
const acciones=bit().map(b=>b[2]);
|
||||||
|
for(const a of ['Emitir orden de compra','Recibir orden de compra','Cancelar orden de compra']) assert(acciones.includes(a),'bitácora: '+a);
|
||||||
|
assert(bit().every(b=>/@helgen\.tech$/.test(b[1])));
|
||||||
|
assert.equal(bit().find(b=>b[2]==='Cancelar orden de compra')[5],'El proveedor no tiene existencias');
|
||||||
|
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de compras en la Base de datos');
|
||||||
|
assert(!JSON.stringify(db.pest.Cotizaciones.m).includes('OC-')&&!JSON.stringify(db.pest.Proyectos.m).includes('OC-'));
|
||||||
|
assert(db.pest.Config.m.some(x=>x[0]==='TERMINOS_OC'),'Config tiene TERMINOS_OC');
|
||||||
|
assert.deepEqual(Object.keys(libros[props.COBRANZA_DB_ID].pest).sort(),['Bitacora','Contratos','Esquemas','Pagos'],'nada de compras en cobranza');
|
||||||
|
assert(vivos(maestra).every(x=>!/Compras/.test(x.nombre)),'nada de compras en la carpeta maestra');
|
||||||
|
carpCom.acceso='DOMAIN'; archivos[LC.id].acceso='DOMAIN'; ctx.prepararCompras();
|
||||||
|
assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'prepararCompras repara el acceso');
|
||||||
|
console.log('OK compras');
|
||||||
@ -107,4 +107,18 @@ if (ctx.monedaDeCotizacion) {
|
|||||||
assert(bloque(u).includes('$300 MXN por metro'));
|
assert(bloque(u).includes('$300 MXN por metro'));
|
||||||
fs.writeFileSync(process.argv[3]+'/usd.html',u);
|
fs.writeFileSync(process.argv[3]+'/usd.html',u);
|
||||||
}
|
}
|
||||||
|
// Orden de compra (fase 3): PDF de ejemplo con los datos de Config (TERMINOS_OC se agrega sola)
|
||||||
|
if (typeof ctx.construirHtmlOrdenCompra === 'function') {
|
||||||
|
ctx.getConfig_(); assert(cfg.m.some(r=>r[0]==='TERMINOS_OC'),'Config recibe TERMINOS_OC');
|
||||||
|
const datosCfg={}; cfg.m.slice(1).forEach(([k,v])=>datosCfg[k]=v);
|
||||||
|
const oc=ctx.normalizarOrdenCompra({proveedor:{razonSocial:'Paneles del Bajío SA de CV',rfc:'PDB200101AB1',contacto:'Luis Pérez',
|
||||||
|
correo:'ventas@pdb.mx',telefono:'+52 442 111 2233'},fechaEntrega:'2026-10-30',condicionesPago:'Crédito 30 días',notas:'Entregar en horario de 9 a 14 h.',
|
||||||
|
renglones:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:12,costo:2350},{descripcion:'Estructura de aluminio',unidad:'JGO',cantidad:3,costo:4200}]},
|
||||||
|
{moneda:'MXN',ubicacion:'Av. Constituyentes 100, Querétaro, Qro.',hoy:'2026-10-20',ivaTasa:0.16});
|
||||||
|
const o=ctx.construirHtmlOrdenCompra({...oc,folio:'OC-2026-0001',fecha:'20/10/2026',proyectoFolio:'PRY-2026-0003',
|
||||||
|
elaboro:'Efrain Sosa (efrain@helgen.tech)'},datosCfg,vm.runInContext('LOGO_HELGEN_AZUL',ctx));
|
||||||
|
assert(o.includes('ORDEN DE COMPRA')&&o.includes('Helgen Servicios Especializados')&&o.includes('Entregar en el lugar y fecha indicados.'));
|
||||||
|
assert(!/display:\s*(flex|grid)/.test(o),'sin flex/grid en el PDF');
|
||||||
|
fs.writeFileSync(process.argv[3]+'/oc.html',o);
|
||||||
|
}
|
||||||
console.log('OK');
|
console.log('OK');
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// 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.
|
// moneda USD (el catálogo no pone precio); pestaña Cobranza solo con permiso; aceptar con confirmación; pago con comprobante;
|
||||||
|
// margen en Cobranza; pestaña Compras solo con permiso, renglones del catálogo sin costo, tipo de cambio, recibir y cancelar.
|
||||||
// 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');
|
||||||
@ -30,6 +31,7 @@ function montar(datosIniciales){
|
|||||||
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
|
document.getElementById('tabCob').classList.add('oculto'); // como en el HTML
|
||||||
|
document.getElementById('tabCom').classList.add('oculto');
|
||||||
const almacen={cotizadorHelgenSesion:token};
|
const almacen={cotizadorHelgenSesion:token};
|
||||||
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}};
|
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}};
|
||||||
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);
|
||||||
@ -56,6 +58,7 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
|
|||||||
await esperar(5);
|
await esperar(5);
|
||||||
assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos');
|
assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos');
|
||||||
assert($('tabCob').classList.contains('oculto'),'sin permiso no ve la pestaña Cobranza');
|
assert($('tabCob').classList.contains('oculto'),'sin permiso no ve la pestaña Cobranza');
|
||||||
|
assert($('tabCom').classList.contains('oculto'),'sin permiso no ve la pestaña Compras');
|
||||||
|
|
||||||
// Elegir el proyecto
|
// Elegir el proyecto
|
||||||
$('cNombre').value='Luis';
|
$('cNombre').value='Luis';
|
||||||
@ -132,6 +135,7 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
|
|||||||
const B=montar({ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[],marcas:{},permisos:{cobranza:true,desbloqueo:false}});
|
const B=montar({ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[],marcas:{},permisos:{cobranza:true,desbloqueo:false}});
|
||||||
await esperar(5);
|
await esperar(5);
|
||||||
assert(!B.$('tabCob').classList.contains('oculto'),'con permiso ve la pestaña');
|
assert(!B.$('tabCob').classList.contains('oculto'),'con permiso ve la pestaña');
|
||||||
|
assert(B.$('tabCom').classList.contains('oculto'),'cobranza sin compras no ve Compras');
|
||||||
B.respuestas.listarProyectosCobranza={ok:true,datos:[{...PRY,contrato:''}]};
|
B.respuestas.listarProyectosCobranza={ok:true,datos:[{...PRY,contrato:''}]};
|
||||||
B.run("verTab('cob')"); await esperar();
|
B.run("verTab('cob')"); await esperar();
|
||||||
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML));
|
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML));
|
||||||
@ -148,6 +152,7 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
|
|||||||
assert.equal(B.llamadas.find(c=>c.funcion==='getCobranzaProyecto').args[0],'PRY-2026-0001');
|
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.$('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');
|
assert(B.$('cobModEsquema').classList.contains('oculto')&&B.$('cobModPagos').classList.contains('oculto'),'sin aceptada no hay esquema ni pagos');
|
||||||
|
assert(B.$('cobMargen').classList.contains('oculto'),'sin contrato no hay margen');
|
||||||
|
|
||||||
// Aceptar la de Energy: el servidor pide confirmar; si se cancela no se repite
|
// 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'},
|
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'},
|
||||||
@ -170,6 +175,15 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
|
|||||||
esquema:res.etapas,resumen:res,puedePagar:true});
|
esquema:res.etapas,resumen:res,puedePagar:true});
|
||||||
B.run('pintarCobranza('+JSON.stringify(firmada)+')');
|
B.run('pintarCobranza('+JSON.stringify(firmada)+')');
|
||||||
assert(!B.$('cobFormPago').classList.contains('oculto')); assert(!/Desbloquear/.test(B.$('cobContrato').innerHTML),'Tania no ve desbloquear');
|
assert(!B.$('cobFormPago').classList.contains('oculto')); assert(!/Desbloquear/.test(B.$('cobContrato').innerHTML),'Tania no ve desbloquear');
|
||||||
|
// Margen (lo manda el servidor solo a cobranza): contratado sin IVA − órdenes de compra vigentes
|
||||||
|
B.run('pintarCobranza('+JSON.stringify({...firmada,margen:{moneda:'MXN',contratado:2000,costos:[{concepto:'Órdenes de compra',moneda:'MXN',monto:2500,cantidad:2}],
|
||||||
|
totalCostos:2500,margen:-500,porcentaje:-25}})+')');
|
||||||
|
const mg=B.$('cobMargen').innerHTML;
|
||||||
|
assert(!B.$('cobMargen').classList.contains('oculto')&&/Margen del proyecto \(sin IVA\)/.test(mg)&&/Contratado sin IVA<\/span><b>\$2,000\.00 MXN/.test(mg));
|
||||||
|
assert(/Órdenes de compra \(2\)<\/span><b>− \$2,500\.00 MXN/.test(mg)&&/class="negativo">-\$500\.00 MXN/.test(mg)&&/class="negativo">-25%/.test(mg),mg);
|
||||||
|
B.run('pintarCobranza('+JSON.stringify({...firmada,margen:{error:'No se pudo calcular el margen: hay costos en USD'}})+')');
|
||||||
|
assert(/No se pudo calcular el margen/.test(B.$('cobMargen').innerHTML));
|
||||||
|
B.run('pintarCobranza('+JSON.stringify(firmada)+')');
|
||||||
assert(!/Marcar aceptada/.test(B.$('cobCots').innerHTML),'firmado: ya no se cambia la aceptada');
|
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));
|
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.$('pgMonto').value='800'; B.$('pgRef').value='SPEI 1'; B.$('pgMetodo').value='Transferencia';
|
||||||
@ -220,5 +234,69 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
|
|||||||
// Administrador: ve el desbloqueo
|
// Administrador: ve el desbloqueo
|
||||||
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
|
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
|
||||||
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));
|
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));
|
||||||
|
|
||||||
|
// ===== Compras (con permiso, sin cobranza) =====
|
||||||
|
const C=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[],marcas:{},permisos:{cobranza:false,desbloqueo:false,compras:true}});
|
||||||
|
await esperar(5);
|
||||||
|
assert(!C.$('tabCom').classList.contains('oculto')&&C.$('tabCob').classList.contains('oculto'),'compras ve Compras y no Cobranza');
|
||||||
|
assert(/PAN-550 · PZA/.test(C.$('ocCatalogo').innerHTML)&&!/4,100|4100|222/.test(C.$('ocCatalogo').innerHTML+C.$('ocClaves').innerHTML),'listas sin precio de venta');
|
||||||
|
C.respuestas.listarProyectosCompras={ok:true,datos:[{...PRY,moneda:'MXN',marca:'helgen'}]};
|
||||||
|
C.run("verTab('com')"); await esperar();
|
||||||
|
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(C.$('comProyectosList').innerHTML)&&/MXN · Helgen/.test(C.$('comProyectosList').innerHTML));
|
||||||
|
const PROV={id:'PDB200101AB1',razonSocial:'Paneles del Bajío',rfc:'PDB200101AB1',contacto:'Luis Pérez',correo:'v@pdb.mx',telefono:'442',etiqueta:'Paneles del Bajío'};
|
||||||
|
const vistaCom=(extra={})=>({proyecto:PRY,moneda:'MXN',marca:'helgen',puedeEmitir:true,ordenes:[],proveedores:[PROV],ivaTasas:[0.16,0.08,0],hoy:'2026-10-20',...extra});
|
||||||
|
C.respuestas.getComprasProyecto={ok:true,datos:vistaCom()};
|
||||||
|
C.$('comProyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; C.$('comProyecto').disparar('change'); await esperar();
|
||||||
|
assert.equal(C.llamadas.find(c=>c.funcion==='getComprasProyecto').args[0],'PRY-2026-0001');
|
||||||
|
assert(!C.$('comModRenglones').classList.contains('oculto')&&!C.$('comForm').classList.contains('oculto')&&!C.$('comProveedor').classList.contains('oculto'));
|
||||||
|
assert.deepEqual([C.$('ocLugar').value,C.$('ocMoneda').value,C.$('ocIva').value,C.$('ocEntrega').min],['Qro','MXN','0.16','2026-10-20'],'por omisión: ubicación, moneda del proyecto, IVA');
|
||||||
|
assert(/Moneda del proyecto: <b>MXN<\/b>/.test(C.$('comInfo').innerHTML)&&/no tiene órdenes/.test(C.$('ocLista').innerHTML));
|
||||||
|
// Proveedor registrado: se autorrellena
|
||||||
|
C.$('prvRazon').value='Paneles del Bajío'; C.$('prvRazon').disparar('change');
|
||||||
|
assert.deepEqual([C.$('prvRfc').value,C.$('prvContacto').value,C.$('prvCorreo').value],['PDB200101AB1','Luis Pérez','v@pdb.mx']);
|
||||||
|
// Renglón del catálogo: clave, descripción y unidad; el costo queda vacío
|
||||||
|
const ren=C.$('ocFilas').hijos[C.$('ocFilas').hijos.length-1];
|
||||||
|
ren.querySelector('.desc').value='Panel solar 550 W'; ren.querySelector('.desc').disparar('change');
|
||||||
|
assert.deepEqual([ren.querySelector('.clave').value,ren.querySelector('.uni').value,ren.querySelector('.costo').value],['PAN-550','PZA',''],'sin precio de venta');
|
||||||
|
ren.querySelector('.cant').value='2'; ren.querySelector('.costo').value='1000'; C.run('recalcularOc()');
|
||||||
|
assert.equal(C.$('ocTot').textContent,'$2,320.00 MXN');
|
||||||
|
C.$('ocIva').value='0'; C.run('recalcularOc()');
|
||||||
|
assert.deepEqual([C.$('ocTot').textContent,C.$('ocLblIva').textContent],['$2,000.00 MXN','IVA (0%)']);
|
||||||
|
// Moneda distinta a la del proyecto: aparece el tipo de cambio
|
||||||
|
assert(C.$('ocTcCaja').classList.contains('oculto'));
|
||||||
|
C.$('ocMoneda').value='USD'; C.run('pintarTcOc()');
|
||||||
|
assert(!C.$('ocTcCaja').classList.contains('oculto')); assert.equal(C.$('ocLblTot').textContent,'TOTAL USD');
|
||||||
|
C.$('ocTc').value='18.5'; C.$('ocEntrega').value='2026-10-25'; C.$('ocCondiciones').value='Contado';
|
||||||
|
const ORD={folio:'OC-2026-0001',fecha:'2026-10-20',proveedor:'Paneles del Bajío',moneda:'USD',total:2000,tipoCambio:18.5,fechaEntrega:'2026-10-25',
|
||||||
|
lugarEntrega:'Qro',estado:'Emitida',pdfUrl:'https://drive/oc'};
|
||||||
|
C.respuestas.guardarOrdenCompra={ok:true,datos:{folio:'OC-2026-0001',url:'https://drive/oc',total:2000,moneda:'USD',vista:vistaCom({ordenes:[ORD]})}};
|
||||||
|
const idOc=C.run('ID_OC'); C.run('guardarOc()'); await esperar();
|
||||||
|
const go=C.llamadas.find(c=>c.funcion==='guardarOrdenCompra').args[0];
|
||||||
|
assert.deepEqual([go.proyectoFolio,go.proveedor.razonSocial,go.proveedor.rfc,go.moneda,go.tipoCambio,go.ivaTasa,go.fechaEntrega,go.lugarEntrega,go.condicionesPago,go.idSolicitud],
|
||||||
|
['PRY-2026-0001','Paneles del Bajío','PDB200101AB1','USD','18.5','0','2026-10-25','Qro','Contado',idOc]);
|
||||||
|
const rr=go.renglones.find(x=>x.descripcion==='Panel solar 550 W');
|
||||||
|
assert.deepEqual([rr.clave,rr.cantidad,rr.costo,'precio' in rr,'_tr' in rr],['PAN-550',2,'1000',false,false]);
|
||||||
|
assert(/OC-2026-0001/.test(C.$('aviso').innerHTML)&&C.run('ID_OC')!==idOc,'id nuevo para la siguiente orden');
|
||||||
|
assert.deepEqual([C.$('ocMoneda').value,C.$('ocTc').value,C.$('prvRazon').value],['MXN','',''],'se limpia el formulario');
|
||||||
|
assert(C.$('ocTcCaja').classList.contains('oculto'));
|
||||||
|
const li=C.$('ocLista').innerHTML;
|
||||||
|
assert(/OC-2026-0001/.test(li)&&/\$2,000\.00 USD/.test(li)&&/TC 18\.5/.test(li)&&/25\/10\/2026/.test(li)&&/recibirOc/.test(li)&&/cancelarOc/.test(li));
|
||||||
|
// Recibida: pide fecha; si se cancela el diálogo no llama
|
||||||
|
C.dialogos.prompt=null; C.run("recibirOc('OC-2026-0001')"); await esperar();
|
||||||
|
assert(!C.llamadas.some(c=>c.funcion==='marcarOcRecibida'));
|
||||||
|
C.respuestas.marcarOcRecibida={ok:true,datos:vistaCom({ordenes:[{...ORD,estado:'Recibida',fechaRecepcion:'2026-10-20',recibio:'pedro@helgen.tech'}]})};
|
||||||
|
C.dialogos.prompt=' 2026-10-20 '; C.run("recibirOc('OC-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='marcarOcRecibida').args,['OC-2026-0001','2026-10-20']);
|
||||||
|
assert(/chip firmado/.test(C.$('ocLista').innerHTML)&&!/recibirOc|cancelarOc/.test(C.$('ocLista').innerHTML),'recibida: sin acciones');
|
||||||
|
// Cancelar: motivo obligatorio; permiso revocado → error del servidor
|
||||||
|
C.dialogos.prompt=' '; C.run("cancelarOc('OC-2026-0001')"); await esperar();
|
||||||
|
assert(!C.llamadas.some(c=>c.funcion==='cancelarOrdenCompra'));
|
||||||
|
C.respuestas.cancelarOrdenCompra={ok:false,codigo:'PERMISO',error:'No tienes permiso de compras.'};
|
||||||
|
C.dialogos.prompt='Sin existencias'; C.run("cancelarOc('OC-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='cancelarOrdenCompra').args,['OC-2026-0001','Sin existencias']);
|
||||||
|
assert(/No tienes permiso de compras/.test(C.$('aviso').innerHTML));
|
||||||
|
// Proyecto que ya no admite órdenes (p. ej. contrato desbloqueado): solo la lista
|
||||||
|
C.run('pintarCompras('+JSON.stringify(vistaCom({proyecto:{...PRY,folio:'PRY-2026-0002'},puedeEmitir:false,moneda:''}))+')');
|
||||||
|
assert(C.$('comModRenglones').classList.contains('oculto')&&C.$('comForm').classList.contains('oculto')&&/no admite órdenes nuevas/.test(C.$('comNoEmite').textContent));
|
||||||
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 2 subcarpetas (Pagos ya no: va en cobranza)
|
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ solo con Cotizaciones (Pagos y Órdenes de compra ya no: van en cobranza y compras)
|
||||||
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']);
|
assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones']);
|
||||||
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
|
||||||
@ -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']);
|
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones']);
|
||||||
|
|
||||||
// --- 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);
|
||||||
|
|||||||
@ -102,7 +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.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false,compras:false},'sin ROL_COBRANZA ni ROL_COMPRAS nadie tiene cobranza ni compras');
|
||||||
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
|
||||||
|
|||||||
@ -93,6 +93,7 @@
|
|||||||
.tile { border:1px solid var(--borde); border-radius:8px; padding:10px 12px; }
|
.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 span { display:block; font-size:11px; color:var(--gris); text-transform:uppercase; }
|
||||||
.tile b { font-size:16px; color:var(--azul); }
|
.tile b { font-size:16px; color:var(--azul); }
|
||||||
|
.tile b.negativo { color:#B91C1C; }
|
||||||
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
|
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
|
||||||
.barra i { display:block; height:100%; background:var(--teal); }
|
.barra i { display:block; height:100%; background:var(--teal); }
|
||||||
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
|
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
|
||||||
@ -109,6 +110,7 @@
|
|||||||
<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>
|
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
|
||||||
|
<button id="tabCom" class="oculto" onclick="verTab('com')">Compras</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="usuario" class="usuario oculto">
|
<div id="usuario" class="usuario oculto">
|
||||||
<span id="usuarioNombre"></span>
|
<span id="usuarioNombre"></span>
|
||||||
@ -287,6 +289,8 @@
|
|||||||
<tbody id="cobCots"></tbody>
|
<tbody id="cobCots"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div id="cobContrato" class="sat"></div>
|
<div id="cobContrato" class="sat"></div>
|
||||||
|
<!-- Margen: contratado sin IVA − órdenes de compra vigentes sin IVA (solo rol de cobranza) -->
|
||||||
|
<div id="cobMargen" class="tiles oculto" style="margin-top:14px"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -328,6 +332,86 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== COMPRAS (solo rol de compras; el servidor lo vuelve a revisar en cada llamada) ===== -->
|
||||||
|
<section id="vistaCom" class="oculto">
|
||||||
|
<div class="modulo">
|
||||||
|
<h2>1 · Proyecto y proveedor</h2>
|
||||||
|
<div class="proy-fila">
|
||||||
|
<div><label>Proyecto (activo y con contrato firmado)</label>
|
||||||
|
<input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
|
||||||
|
<datalist id="comProyectosList"></datalist></div>
|
||||||
|
<button type="button" class="btn btn-sec" onclick="cargarProyectosCom()">Actualizar lista</button>
|
||||||
|
</div>
|
||||||
|
<div id="comInfo" class="nota" style="margin-top:6px">Elige un proyecto para emitir órdenes de compra y ver las que tiene.</div>
|
||||||
|
<div id="comProveedor" class="grid oculto" style="margin-top:14px">
|
||||||
|
<div><label>Proveedor (razón social) *</label>
|
||||||
|
<input id="prvRazon" list="proveedoresList" autocomplete="off" maxlength="150" placeholder="Escribe o elige un proveedor">
|
||||||
|
<datalist id="proveedoresList"></datalist></div>
|
||||||
|
<div><label>RFC</label><input id="prvRfc" maxlength="13"></div>
|
||||||
|
<div><label>Contacto</label><input id="prvContacto" maxlength="100"></div>
|
||||||
|
<div><label>Correo</label><input id="prvCorreo" type="email" maxlength="100"></div>
|
||||||
|
<div><label>Teléfono</label><input id="prvTel" maxlength="40"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="comModRenglones" class="modulo oculto">
|
||||||
|
<h2>2 · Renglones</h2>
|
||||||
|
<datalist id="ocCatalogo"></datalist>
|
||||||
|
<datalist id="ocClaves"></datalist>
|
||||||
|
<div class="grid" style="margin-bottom:12px">
|
||||||
|
<div><label>Moneda de la orden *</label>
|
||||||
|
<select id="ocMoneda" onchange="pintarTcOc()">
|
||||||
|
<option value="MXN">MXN · pesos mexicanos</option>
|
||||||
|
<option value="USD">USD · dólares estadounidenses</option>
|
||||||
|
</select></div>
|
||||||
|
<div id="ocTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label>
|
||||||
|
<input id="ocTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
|
||||||
|
<div><label>IVA *</label>
|
||||||
|
<select id="ocIva" onchange="recalcularOc()">
|
||||||
|
<option value="0.16">16%</option>
|
||||||
|
<option value="0.08">8% (región fronteriza)</option>
|
||||||
|
<option value="0">0% (extranjero o exento)</option>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
|
||||||
|
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">Costo unitario</th>
|
||||||
|
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="ocFilas"></tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarRenglonOc()">+ Agregar renglón</button>
|
||||||
|
<div class="nota" style="margin-top:8px">Del catálogo se llenan clave, descripción y unidad. El costo se captura a mano: nunca es el precio de venta.</div>
|
||||||
|
<div class="totales" style="margin-top:12px">
|
||||||
|
<div><span>Subtotal</span><span id="ocSub">$0.00</span></div>
|
||||||
|
<div><span id="ocLblIva">IVA (16%)</span><span id="ocIvaMonto">$0.00</span></div>
|
||||||
|
<div class="total"><span id="ocLblTot">TOTAL MXN</span><span id="ocTot">$0.00</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="comModOrdenes" class="modulo oculto">
|
||||||
|
<h2>3 · Entrega y órdenes</h2>
|
||||||
|
<div id="comForm">
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Fecha de entrega *</label><input id="ocEntrega" type="date"></div>
|
||||||
|
<div style="grid-column:span 2"><label>Lugar de entrega *</label><input id="ocLugar" maxlength="200"></div>
|
||||||
|
<div><label>Condiciones de pago *</label><input id="ocCondiciones" maxlength="200" placeholder="Ej. Contado, crédito 30 días"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px"><label>Notas para el proveedor (opcional)</label><textarea id="ocNotas" rows="3" maxlength="1000"></textarea></div>
|
||||||
|
<div class="acciones">
|
||||||
|
<button type="button" id="btnOc" class="btn" onclick="guardarOc()">Emitir orden y generar PDF</button>
|
||||||
|
<span class="nota">El PDF se guarda en la carpeta privada de compras; no se envía por correo.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="comNoEmite" class="nota"></div>
|
||||||
|
<table style="margin-top:14px">
|
||||||
|
<thead><tr><th>Folio</th><th>Fecha</th><th>Proveedor</th><th class="num">Total</th><th>Entrega</th><th>Estado</th><th>PDF</th><th></th></tr></thead>
|
||||||
|
<tbody id="ocLista"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script id="api-web">
|
<script id="api-web">
|
||||||
@ -508,9 +592,10 @@
|
|||||||
IVA = d.ivaTasa; CATALOGO = d.catalogo; TC = d.tipoCambio || TC;
|
IVA = d.ivaTasa; CATALOGO = d.catalogo; TC = d.tipoCambio || TC;
|
||||||
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
|
// Las pestañas Cobranza y Compras solo se muestran con permiso; el servidor lo revisa en cada llamada
|
||||||
PERMISOS = d.permisos || {};
|
PERMISOS = d.permisos || {};
|
||||||
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
|
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
|
||||||
|
$('tabCom').classList.toggle('oculto', !PERMISOS.compras);
|
||||||
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
||||||
pintarDatalists();
|
pintarDatalists();
|
||||||
agregarFila();
|
agregarFila();
|
||||||
@ -778,6 +863,11 @@
|
|||||||
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + precio(p)) + '">').join('');
|
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + precio(p)) + '">').join('');
|
||||||
$('claves').innerHTML = CATALOGO.map(p =>
|
$('claves').innerHTML = CATALOGO.map(p =>
|
||||||
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + precio(p)) + '">').join('');
|
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + precio(p)) + '">').join('');
|
||||||
|
// Órdenes de compra: sin precio de venta (el costo se captura a mano)
|
||||||
|
$('ocCatalogo').innerHTML = CATALOGO.map(p =>
|
||||||
|
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + p.unidad) + '">').join('');
|
||||||
|
$('ocClaves').innerHTML = CATALOGO.map(p =>
|
||||||
|
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion) + '">').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cargarCatalogo() {
|
function cargarCatalogo() {
|
||||||
@ -940,6 +1030,16 @@
|
|||||||
}
|
}
|
||||||
$('cobContrato').innerHTML = h;
|
$('cobContrato').innerHTML = h;
|
||||||
|
|
||||||
|
// Margen (sin IVA, en la moneda del proyecto): el servidor solo lo manda al rol de cobranza
|
||||||
|
const mg = v.margen;
|
||||||
|
$('cobMargen').classList.toggle('oculto', !mg);
|
||||||
|
$('cobMargen').innerHTML = !mg ? '' : mg.error ? '<div class="nota aviso-moneda">' + esc(mg.error) + '</div>'
|
||||||
|
: '<div class="sat-titulo" style="grid-column:1/-1;margin:0">Margen del proyecto (sin IVA)</div>' +
|
||||||
|
[['Contratado sin IVA', dinero(mg.contratado, mg.moneda)]]
|
||||||
|
.concat(mg.costos.map(x => [x.concepto + ' (' + x.cantidad + ')', '− ' + dinero(x.monto, mg.moneda)]))
|
||||||
|
.concat([['Margen', dinero(mg.margen, mg.moneda), mg.margen < 0], ['Margen %', mg.porcentaje + '%', mg.margen < 0]])
|
||||||
|
.map(([k, x, neg]) => '<div class="tile"><span>' + esc(k) + '</span><b' + (neg ? ' class="negativo"' : '') + '>' + x + '</b></div>').join('');
|
||||||
|
|
||||||
// Esquema
|
// Esquema
|
||||||
$('cobModEsquema').classList.toggle('oculto', !c);
|
$('cobModEsquema').classList.toggle('oculto', !c);
|
||||||
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
|
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
|
||||||
@ -1084,12 +1184,184 @@
|
|||||||
}, err => { listo(); mostrarError(err); });
|
}, err => { listo(); mostrarError(err); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Compras (solo rol de compras) ----
|
||||||
|
let COM_PROYECTOS = [], COM = null, PROVEEDORES = [], ID_OC = nuevoId();
|
||||||
|
const CHIP_OC = { Emitida: 'aceptada', Recibida: 'firmado', Cancelada: 'cancelado' };
|
||||||
|
const CAMPOS_PROV = { prvRazon: 'razonSocial', prvRfc: 'rfc', prvContacto: 'contacto', prvCorreo: 'correo', prvTel: 'telefono' };
|
||||||
|
const fechaMx = iso => { const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); return m ? m[3] + '/' + m[2] + '/' + m[1] : String(iso || ''); };
|
||||||
|
|
||||||
|
function cargarProyectosCom() {
|
||||||
|
google.script.run.withSuccessHandler(lista => {
|
||||||
|
COM_PROYECTOS = lista || [];
|
||||||
|
$('comProyectosList').innerHTML = COM_PROYECTOS.map(p =>
|
||||||
|
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (MARCA_TXT[p.marca] || '')) + '">').join('');
|
||||||
|
if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos con contrato firmado.';
|
||||||
|
}).withFailureHandler(mostrarError).listarProyectosCompras('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function proyectoComElegido() {
|
||||||
|
const folio = ($('comProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
||||||
|
return (folio && COM_PROYECTOS.find(p => p.folio === folio)) || null;
|
||||||
|
}
|
||||||
|
$('comProyecto').addEventListener('change', () => { const p = proyectoComElegido(); if (p) abrirProyectoCom(p.folio); });
|
||||||
|
|
||||||
|
function abrirProyectoCom(folio) {
|
||||||
|
$('comInfo').textContent = 'Cargando…';
|
||||||
|
google.script.run.withSuccessHandler(pintarCompras).withFailureHandler(mostrarError).getComprasProyecto(folio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pinta la pestaña a partir de la respuesta del servidor (vistaCompras_). Al cambiar de proyecto, limpia el formulario. */
|
||||||
|
function pintarCompras(v) {
|
||||||
|
const cambio = !COM || COM.proyecto.folio !== v.proyecto.folio;
|
||||||
|
COM = v;
|
||||||
|
$('comProyecto').value = etiquetaCob(v.proyecto);
|
||||||
|
$('comInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
|
||||||
|
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') +
|
||||||
|
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '');
|
||||||
|
recibirProveedores(v.proveedores);
|
||||||
|
['comProveedor', 'comModRenglones', 'comForm'].forEach(id => $(id).classList.toggle('oculto', !v.puedeEmitir));
|
||||||
|
$('comModOrdenes').classList.remove('oculto');
|
||||||
|
$('comNoEmite').textContent = v.puedeEmitir ? '' : 'Este proyecto no admite órdenes nuevas: debe estar Activo y con contrato firmado.';
|
||||||
|
if (v.puedeEmitir && cambio) limpiarOc();
|
||||||
|
$('ocLista').innerHTML = (v.ordenes || []).length ? v.ordenes.map(o => '<tr' + (o.estado === 'Cancelada' ? ' class="fila-cancelada" title="' +
|
||||||
|
esc('Cancelada por ' + o.canceladoPor + ': ' + o.motivoCancelacion) + '"' : '') + '><td style="white-space:nowrap"><b>' + esc(o.folio) + '</b></td><td>' + esc(fechaMx(o.fecha)) +
|
||||||
|
'</td><td>' + esc(o.proveedor) + '</td><td class="num">' + dinero(o.total, o.moneda) +
|
||||||
|
(o.tipoCambio ? '<div class="nota">TC ' + esc(o.tipoCambio) + '</div>' : '') + '</td><td>' + esc(fechaMx(o.fechaEntrega)) +
|
||||||
|
'<div class="nota">' + esc(o.lugarEntrega) + '</div></td><td><span class="chip ' + (CHIP_OC[o.estado] || '') + '"' +
|
||||||
|
(o.estado === 'Recibida' ? ' title="' + esc('Recibida el ' + fechaMx(o.fechaRecepcion) + ' · ' + o.recibio) + '"' : '') + '>' + esc(o.estado) + '</span></td><td>' +
|
||||||
|
(o.pdfUrl ? '<a href="' + esc(o.pdfUrl) + '" target="_blank">Ver</a>' : '') + '</td><td style="white-space:nowrap">' +
|
||||||
|
(o.estado === 'Emitida' ? '<button type="button" class="btn btn-sec" style="padding:4px 10px;font-size:12px" onclick="recibirOc(\'' + esc(o.folio) + '\')">Recibida</button>' +
|
||||||
|
'<button type="button" class="quitar" title="Cancelar orden" onclick="cancelarOc(\'' + esc(o.folio) + '\')">×</button>' : '') +
|
||||||
|
'</td></tr>').join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene órdenes de compra.</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proveedores (se llenan solos al emitir órdenes, como los clientes)
|
||||||
|
function recibirProveedores(lista) {
|
||||||
|
PROVEEDORES = lista || [];
|
||||||
|
$('proveedoresList').innerHTML = PROVEEDORES.map(p => '<option value="' + esc(p.etiqueta) + '" label="' +
|
||||||
|
esc([p.rfc, p.contacto].filter(Boolean).join(' · ') || 'Proveedor registrado') + '">').join('');
|
||||||
|
}
|
||||||
|
function autorrellenarProveedor() {
|
||||||
|
const v = $('prvRazon').value.trim().toLowerCase();
|
||||||
|
const p = v && PROVEEDORES.find(x => x.etiqueta.toLowerCase() === v);
|
||||||
|
if (p) Object.keys(CAMPOS_PROV).forEach(id => $(id).value = p[CAMPOS_PROV[id]] || '');
|
||||||
|
}
|
||||||
|
$('prvRazon').addEventListener('change', autorrellenarProveedor);
|
||||||
|
$('prvRazon').addEventListener('input', () => { if (PROVEEDORES.some(x => x.etiqueta === $('prvRazon').value)) autorrellenarProveedor(); });
|
||||||
|
|
||||||
|
// Renglones: del catálogo se toman clave, descripción y unidad; el costo nunca es el precio de venta
|
||||||
|
function agregarRenglonOc() {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td><input class="clave" list="ocClaves"></td>' +
|
||||||
|
'<td><input class="desc" list="ocCatalogo" placeholder="Escribe o elige del catálogo"></td>' +
|
||||||
|
'<td><input class="uni" value="PZA"></td>' +
|
||||||
|
'<td><input class="cant" type="number" min="0" step="any" value="1" style="text-align:right"></td>' +
|
||||||
|
'<td><input class="costo" type="number" min="0" step="0.01" placeholder="Costo" style="text-align:right"></td>' +
|
||||||
|
'<td class="num imp">$0.00</td>' +
|
||||||
|
'<td><button class="quitar" title="Quitar">×</button></td>';
|
||||||
|
const aplicar = p => {
|
||||||
|
if (!p) return;
|
||||||
|
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
|
||||||
|
tr.querySelector('.uni').value = p.unidad;
|
||||||
|
if (!tr.querySelector('.costo').value) tr.querySelector('.costo').focus();
|
||||||
|
};
|
||||||
|
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcularOc(); });
|
||||||
|
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcularOc(); });
|
||||||
|
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalcularOc));
|
||||||
|
tr.querySelector('.quitar').onclick = () => { tr.remove(); recalcularOc(); };
|
||||||
|
$('ocFilas').appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leerRenglonesOc() {
|
||||||
|
return [...$('ocFilas').querySelectorAll('tr')].map(tr => ({
|
||||||
|
clave: tr.querySelector('.clave').value,
|
||||||
|
descripcion: tr.querySelector('.desc').value,
|
||||||
|
unidad: tr.querySelector('.uni').value,
|
||||||
|
cantidad: Number(tr.querySelector('.cant').value) || 0,
|
||||||
|
costo: tr.querySelector('.costo').value, // vacío = sin capturar (el servidor lo rechaza)
|
||||||
|
_tr: tr,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vista previa (el servidor recalcula). */
|
||||||
|
function recalcularOc() {
|
||||||
|
const m = $('ocMoneda').value === 'USD' ? 'USD' : 'MXN', tasa = Number($('ocIva').value) || 0;
|
||||||
|
let sub = 0;
|
||||||
|
leerRenglonesOc().forEach(r => {
|
||||||
|
const imp = r2(r.cantidad * r2(Number(r.costo) || 0));
|
||||||
|
r._tr.querySelector('.imp').textContent = dinero(imp, m);
|
||||||
|
sub += imp;
|
||||||
|
});
|
||||||
|
sub = r2(sub);
|
||||||
|
const iva = r2(sub * tasa);
|
||||||
|
$('ocSub').textContent = dinero(sub, m); $('ocIvaMonto').textContent = dinero(iva, m); $('ocTot').textContent = dinero(sub + iva, m);
|
||||||
|
$('ocLblIva').textContent = 'IVA (' + Math.round(tasa * 100) + '%)'; $('ocLblTot').textContent = 'TOTAL ' + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orden en otra moneda que el proyecto: tipo de cambio obligatorio. */
|
||||||
|
function pintarTcOc() {
|
||||||
|
const distinta = !!COM && !!COM.moneda && $('ocMoneda').value !== COM.moneda;
|
||||||
|
$('ocTcCaja').classList.toggle('oculto', !distinta);
|
||||||
|
if (!distinta) $('ocTc').value = '';
|
||||||
|
recalcularOc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function limpiarOc() {
|
||||||
|
Object.keys(CAMPOS_PROV).concat(['ocTc', 'ocEntrega', 'ocCondiciones', 'ocNotas']).forEach(id => $(id).value = '');
|
||||||
|
$('ocFilas').innerHTML = ''; agregarRenglonOc();
|
||||||
|
$('ocMoneda').value = (COM && COM.moneda) || 'MXN';
|
||||||
|
$('ocIva').value = [0.16, 0.08, 0].indexOf(IVA) !== -1 ? String(IVA) : '0.16';
|
||||||
|
$('ocLugar').value = (COM && COM.proyecto.ubicacion) || '';
|
||||||
|
if (COM) $('ocEntrega').min = COM.hoy;
|
||||||
|
ID_OC = nuevoId();
|
||||||
|
$('btnOc').disabled = false; $('btnOc').textContent = 'Emitir orden y generar PDF';
|
||||||
|
pintarTcOc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardarOc() {
|
||||||
|
if (!COM || $('btnOc').disabled) return;
|
||||||
|
const payload = {
|
||||||
|
proyectoFolio: COM.proyecto.folio,
|
||||||
|
proveedor: { razonSocial: $('prvRazon').value, rfc: $('prvRfc').value, contacto: $('prvContacto').value,
|
||||||
|
correo: $('prvCorreo').value, telefono: $('prvTel').value },
|
||||||
|
renglones: leerRenglonesOc().map(({_tr, ...r}) => r),
|
||||||
|
moneda: $('ocMoneda').value, tipoCambio: $('ocTc').value, ivaTasa: $('ocIva').value,
|
||||||
|
fechaEntrega: $('ocEntrega').value, lugarEntrega: $('ocLugar').value, condicionesPago: $('ocCondiciones').value,
|
||||||
|
notas: $('ocNotas').value, idSolicitud: ID_OC,
|
||||||
|
};
|
||||||
|
$('btnOc').disabled = true; $('btnOc').textContent = 'Generando PDF…';
|
||||||
|
const listo = () => { $('btnOc').disabled = false; $('btnOc').textContent = 'Emitir orden y generar PDF'; };
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(r => {
|
||||||
|
pintarCompras(r.vista); limpiarOc();
|
||||||
|
aviso('ok', 'Orden de compra <b>' + esc(r.folio) + '</b> emitida · Total ' + dinero(r.total, r.moneda) +
|
||||||
|
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
||||||
|
})
|
||||||
|
.withFailureHandler(err => { listo(); mostrarError(err); })
|
||||||
|
.guardarOrdenCompra(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recibirOc(folio) {
|
||||||
|
const fecha = window.prompt('Fecha en que se recibió completa la orden ' + folio + ' (AAAA-MM-DD):', COM.hoy);
|
||||||
|
if (fecha == null) return;
|
||||||
|
accionCob(null, 'marcarOcRecibida', [folio, fecha.trim()], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> marcada como recibida.'); });
|
||||||
|
}
|
||||||
|
function cancelarOc(folio) {
|
||||||
|
const motivo = window.prompt('Motivo para cancelar la orden ' + folio + ' (queda registrado; la orden no se borra):');
|
||||||
|
if (motivo == null) return;
|
||||||
|
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||||||
|
accionCob(null, 'cancelarOrdenCompra', [folio, motivo], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> cancelada.'); });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 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');
|
$('vistaCob').classList.toggle('oculto', t !== 'cob');
|
||||||
|
$('vistaCom').classList.toggle('oculto', t !== 'com');
|
||||||
|
$('tabCom').classList.toggle('activo', t === 'com');
|
||||||
$('tabCob').classList.toggle('activo', 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');
|
||||||
@ -1097,6 +1369,7 @@
|
|||||||
if (t === 'hist') cargarHistorial();
|
if (t === 'hist') cargarHistorial();
|
||||||
if (t === 'cat') cargarCatalogo();
|
if (t === 'cat') cargarCatalogo();
|
||||||
if (t === 'cob') cargarProyectosCob();
|
if (t === 'cob') cargarProyectosCob();
|
||||||
|
if (t === 'com') cargarProyectosCom();
|
||||||
$('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