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:
Efrain (Helgen) 2026-10-20 18:49:07 -06:00
parent 985df1f52d
commit 1a02e2df3f
9 changed files with 1415 additions and 70 deletions

123
CLAUDE.md
View File

@ -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**
(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).
**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`) |
| "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 — 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 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 |
@ -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.
- `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.
- 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)
- `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`,
`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).
- 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`)
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**
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`),
@ -226,8 +258,8 @@ Capa de sesión y `fetch` en `<script id="api-web">` (§10.2).
├─ 📊 Cotizador Helgen — Base de datos (Cotizaciones, Config)
├─ 📊 Catálogo Helgen (Catalogo)
├─ 📁 Cotizaciones Helgen (PDF) / 📁 2026 / HLG-2026-0001 - Cliente.pdf (cotizaciones "Sin proyecto")
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones, Órdenes de compra
│ (los proyectos creados antes de Cobranza tienen además "Pagos", vacía)
├─ 📁 Proyectos / 📁 2026 / 📁 PRY-2026-0001 — Cliente — Nombre / Cotizaciones
│ (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)
├─ 📁 Respaldos 2026-10-19 (copias antes de la migración)
└─ 📜 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 (datos) (COBRANZA_DB_ID: Contratos, Esquemas, Pagos, Bitacora)
└─ 📁 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"),
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),
`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`),
`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"),
`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,
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):
`SPREADSHEET_ID`, `FOLDER_ID`, `CATALOGO_ID`, `SAT_ID`, `CONSECUTIVO_AAAA` (p. ej. `CONSECUTIVO_2026`),
`PROYECTOS_FOLDER_ID` y `CONSECUTIVO_PROY_AAAA` (se crean solos con el primer proyecto),
`ANTHROPIC_API_KEY` (opcional; activa la IA para claves SAT; el proyecto anterior no la tenía). Nunca poner la API key en el código.
Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `ADMIN_DESBLOQUEO` (efrain@), `COBRANZA_FOLDER_ID` y
`COBRANZA_DB_ID` (se crean solos), `CONSECUTIVO_PAG_AAAA`. **Sin `ROL_COBRANZA` nadie tiene cobranza** (se niega por omisión).
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é)
@ -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:…`.
- Folio propio con contador `CONSECUTIVO_PROY_AAAA` bajo el **mismo** candado de script que las cotizaciones.
- **Subcarpetas que se reparan solas:** las subcarpetas de un proyecto se buscan **por nombre**. Si alguien borra (Papelera) o
renombra `Cotizaciones` 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
vuelve a crear (con sus subcarpetas) y se actualiza `carpeta_id` en la hoja. Igual con `Proyectos` y su carpeta de año.
Renombrar la carpeta **del proyecto** sí se puede (se usa por ID).
- Las cotizaciones viejas no se mueven ni se ligan a proyectos.
- Desde Cobranza, `SUBCARPETAS_PROYECTO` = `Cotizaciones`, `Órdenes de compra` (sin `Pagos`, solo para proyectos nuevos; los
viejos conservan la suya, vacía). Un proyecto con contrato firmado **no admite cotizaciones nuevas** y sale del buscador de
- Desde Compras, `SUBCARPETAS_PROYECTO` = solo `Cotizaciones` (sin `Pagos` desde Cobranza ni `Órdenes de compra` desde Compras;
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).
### 6.10 Modelo futuro (resto de la fase 2; NO construido)
- Órdenes de compra y gastos: folio (`OC-`, `GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`
(`siguienteFolio_(prefijo, hoja, 'CONSECUTIVO_OC_')`), columna **`proyecto_folio`** y `vendedor_*` del token, con encabezados
que se migran solos. Antes de elegir dónde van, decidir quién debe verlos: la Base de datos la lee todo el dominio (§6.11).
- Se comparan contra el monto contratado (`Contratos.total` del libro de cobranza).
### 6.10 Modelo futuro (fase 4 y después; NO construido)
- Gastos, nómina y horas por proyecto: folio (`GAS-AAAA-NNNN`), contador `CONSECUTIVO_<TIPO>_AAAA`, columna **`proyecto_folio`**.
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
`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**:
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
@ -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**
(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)
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.
@ -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.
11. **Responder siempre en español** (de México) al usuario, en el chat y en los mensajes de commit.
12. Datos de cobranza (contratos, esquemas, pagos, comprobantes) **solo** en el libro y la carpeta de cobranza, y cada función
de cobranza revisa `ROL_COBRANZA` en el servidor (§6.11). Nunca sumar importes de monedas distintas.
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
Claude in Chrome, donde ya tiene su sesión de @helgen.tech. Detalle en §8 "Pruebas en el navegador".
## 8. Cómo trabajar
```bash
# Pruebas (Node, servicios de Google simulados). Correr las siete antes de subir.
mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes, USD; deja helgen.html / energy.html / usd.html
# 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 / oc.html
node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados
node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT"
node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora)
node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan
node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado, 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/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
# 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.
- `GUIA.md` describe el proyecto anterior; actualizarla si se va a usar.
- Ideas mencionadas: enviar PDF por correo, duplicar cotización, estatus (Enviada/Aceptada/Rechazada), descuentos por renglón.
- Proyectos fase 2: órdenes de compra, 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()`
(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;

684
Code.js
View File

@ -104,6 +104,12 @@ const API_FUNCIONES = {
desbloquearContrato: desbloquearContrato,
registrarPago: registrarPago,
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 =
'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). */
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' +
'Obra civil: se cotiza por separado según las condiciones arquitectónicas del inmueble.\n' +
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. */
@ -637,8 +651,8 @@ function getDatosIniciales() {
proyectos: proyectosParaInicio_(),
usuario: usuarioActual_(),
vendedor: VENDEDOR_,
// Solo para mostrar u ocultar la pestaña Cobranza; cada función de cobranza lo vuelve a revisar
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_() },
// Solo para mostrar u ocultar las pestañas Cobranza y Compras; cada función lo vuelve a revisar
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_(), compras: esCompras_() },
};
}
@ -826,9 +840,9 @@ const ENCABEZADOS_PROYECTOS = [
const COL_ESTADO_PROYECTO = 7; // G
const COL_CARPETA_PROYECTO = 8; // H
const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado'];
// Sin "Pagos": los comprobantes van en la carpeta de cobranza (no visible para todo el dominio). Los proyectos
// creados antes la conservan; no se borra.
const SUBCARPETAS_PROYECTO = ['Cotizaciones', 'Órdenes de compra'];
// Sin "Pagos" ni "Órdenes de compra": comprobantes y órdenes van en las carpetas de cobranza y compras (no visibles para
// todo el dominio). Los proyectos creados antes conservan las suyas, vacías; no se borran.
const SUBCARPETAS_PROYECTO = ['Cotizaciones'];
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
* carpeta_id en la hoja.
*/
@ -1030,18 +1044,21 @@ function errorPermiso_(mensaje) {
return e;
}
/** ¿El usuario del token está en ROL_COBRANZA? Sin token (editor) o sin la propiedad: no. */
function esCobranza_() {
/** ¿El usuario del token está en la lista de correos de la propiedad (rol)? Sin token (editor) o sin la propiedad: no. */
function enRol_(propiedad) {
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;
}
/** ¿El usuario del token está en ROL_COBRANZA? */
function esCobranza_() {
return enRol_('ROL_COBRANZA');
}
/** ¿Puede desbloquear contratos firmados? (ADMIN_DESBLOQUEO; sin la propiedad, nadie). */
function esAdminDesbloqueo_() {
if (!VENDEDOR_) return false;
const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty('ADMIN_DESBLOQUEO'), DOMINIO_HELGEN);
return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1;
return enRol_('ADMIN_DESBLOQUEO');
}
function exigirCobranza_() {
@ -1071,65 +1088,80 @@ function privado_(item) {
return item;
}
/** Carpeta de cobranza. Se crea sola en Mi unidad (restringida). Si se borró, error: no se crea otra a escondidas. */
function carpetaCobranza_() {
// Áreas privadas (cobranza, compras): carpeta en Mi unidad + libro dentro de ella, restringidos, con sus IDs en
// 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 id = props.getProperty('COBRANZA_FOLDER_ID');
const id = props.getProperty(area.propCarpeta);
if (!id) {
const carpeta = privado_(DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA));
props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId());
const carpeta = privado_(DriveApp.createFolder(area.carpeta));
props.setProperty(area.propCarpeta, carpeta.getId());
return carpeta;
}
let carpeta = null;
try { carpeta = DriveApp.getFolderById(id); } catch (e) { carpeta = null; }
if (!carpeta || carpeta.isTrashed()) {
throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.');
throw new Error('No se encontró la carpeta de ' + area.que + ' (' + area.propCarpeta + '). Revisa la Papelera de Drive.');
}
return privado_(carpeta);
}
/** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */
function libroCobranza_() {
/** Libro del área; la primera vez lo crea dentro de su carpeta (bajo candado). */
function libroArea_(area) {
const props = PropertiesService.getScriptProperties();
if (!props.getProperty('COBRANZA_DB_ID')) {
if (!props.getProperty(area.propLibro)) {
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 {
if (!props.getProperty('COBRANZA_DB_ID')) crearLibroCobranza_();
if (!props.getProperty(area.propLibro)) crearLibroArea_(area);
} finally {
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). */
function libroCobranzaSiExiste_() {
const id = PropertiesService.getScriptProperties().getProperty('COBRANZA_DB_ID');
return id ? abrirLibroCobranza_(id) : null;
/** Libro del área si ya existe; null si todavía no se crea. */
function libroAreaSiExiste_(area) {
const id = PropertiesService.getScriptProperties().getProperty(area.propLibro);
return id ? abrirLibroArea_(area, id) : null;
}
function abrirLibroCobranza_(id) {
function abrirLibroArea_(area, id) {
try {
return SpreadsheetApp.openById(id);
} catch (e) {
throw new Error('No se pudo abrir el libro de cobranza (COBRANZA_DB_ID). Revisa que no esté en la Papelera.');
throw new Error('No se pudo abrir el libro de ' + area.que + ' (' + area.propLibro + '). Revisa que no esté en la Papelera.');
}
}
function crearLibroCobranza_() {
const carpeta = carpetaCobranza_();
const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA);
function crearLibroArea_(area) {
const carpeta = carpetaArea_(area);
const ss = SpreadsheetApp.create(area.libro);
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');
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_). */
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);
if (!hoja) {
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. */
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() : [];
}
@ -1207,19 +1243,24 @@ function datosCobranza_(libro, folio) {
return { hojas: { contratos: hC, esquemas: hE, pagos: hP }, contrato: contrato, etapas: etapas, pagos: pagos };
}
/** {"PRY-2026-0001": true, …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */
function foliosConContratoFirmado_() {
/** {"PRY-2026-0001": <contrato>, …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */
function contratosFirmados_() {
const libro = libroCobranzaSiExiste_();
const mapa = {};
if (!libro) return mapa;
const hoja = libro.getSheetByName('Contratos');
if (!hoja) return mapa;
filasCobranza_(hoja, 'Contratos').forEach(r => {
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = true;
filasCobranza_(hoja, 'Contratos').forEach((r, i) => {
if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = contratoDesdeFila_(r, i + 2);
});
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. */
function cotizacionesDeProyecto_(folio) {
const f = String(folio).toUpperCase();
@ -1241,9 +1282,10 @@ function proyectoParaCobranza_(folio) {
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;
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 : ''),
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,
puedePagar: !!c && c.estado === ESTADO_FIRMADO,
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,
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. */
function conCandado_(f) {
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 ' +
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
// 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)'));
}
// ====== 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) ======
function redondear(n) {
@ -1968,6 +2307,158 @@ function nombreComprobante(folio, metodo, referencia, 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) ----
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>' +
'</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) + ' &nbsp;·&nbsp; ' + e(cfg.EMPRESA_EMAIL) + ' &nbsp;·&nbsp; ' + e(cfg.EMPRESA_TELEFONO) +
'</div>' +
'</body></html>';
}

View File

@ -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 ---
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');
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']]];
@ -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');
// --- Tania (rol de cobranza, sin desbloqueo) ---
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false});
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true});
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false});
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:false});
const lista=ok('tania','listarProyectosCobranza','');
assert.deepEqual(lista.map(p=>p.folio+':'+p.contrato),[p2+':',p1+':']);
// Carpeta y libro: fuera de "Cotizador Helgen", libro dentro de la carpeta de cobranza

295
tests/compras.test.js Normal file
View 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(/&lt;script&gt;/.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');

View File

@ -107,4 +107,18 @@ if (ctx.monedaDeCotizacion) {
assert(bloque(u).includes('$300 MXN por metro'));
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');

View File

@ -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.
// 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)
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');
@ -30,6 +31,7 @@ function montar(datosIniciales){
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
document.getElementById('login').classList.add('oculto');
document.getElementById('tabCob').classList.add('oculto'); // como en el HTML
document.getElementById('tabCom').classList.add('oculto');
const almacen={cotizadorHelgenSesion:token};
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}};
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);
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($('tabCom').classList.contains('oculto'),'sin permiso no ve la pestaña Compras');
// Elegir el proyecto
$('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}});
await esperar(5);
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.run("verTab('cob')"); await esperar();
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((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.$('cobMargen').classList.contains('oculto'),'sin contrato no hay margen');
// 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'},
@ -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});
B.run('pintarCobranza('+JSON.stringify(firmada)+')');
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(/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';
@ -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
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
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');
})().catch(e=>{console.error(e);process.exit(1)});

View File

@ -74,10 +74,10 @@ const f1=pry.m[1];
assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']);
assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
assert(!JSON.stringify(pry.m).includes('impostor'));
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ con 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]];
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.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
// Idempotencia
@ -135,7 +135,7 @@ c1.trashed=true;
ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0001',idSolicitud:'c7'});
const c1b=carpetas[pry.m[1][7]];
assert(c1b!==c1&&!c1b.trashed&&c1b.hijos.find(h=>h.nombre==='Cotizaciones').archivos.length===1);
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones','Órdenes de compra']);
assert.deepEqual(c1b.hijos.map(h=>h.nombre),['Cotizaciones']);
// --- Fuera de doPost (sin vendedor) no se crean proyectos ---
vm.runInContext('VENDEDOR_ = null',ctx);

View File

@ -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.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
assert.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false},'sin ROL_COBRANZA nadie tiene cobranza');
assert.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.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión

View File

@ -93,6 +93,7 @@
.tile { border:1px solid var(--borde); border-radius:8px; padding:10px 12px; }
.tile span { display:block; font-size:11px; color:var(--gris); text-transform:uppercase; }
.tile b { font-size:16px; color:var(--azul); }
.tile b.negativo { color:#B91C1C; }
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
.barra i { display:block; height:100%; background:var(--teal); }
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
@ -109,6 +110,7 @@
<button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
<button id="tabCom" class="oculto" onclick="verTab('com')">Compras</button>
</nav>
<div id="usuario" class="usuario oculto">
<span id="usuarioNombre"></span>
@ -287,6 +289,8 @@
<tbody id="cobCots"></tbody>
</table>
<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>
@ -328,6 +332,86 @@
</table>
</div>
</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>
<script id="api-web">
@ -508,9 +592,10 @@
IVA = d.ivaTasa; CATALOGO = d.catalogo; TC = d.tipoCambio || TC;
recibirClientes(d.clientes);
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 || {};
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
$('tabCom').classList.toggle('oculto', !PERMISOS.compras);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists();
agregarFila();
@ -778,6 +863,11 @@
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + precio(p)) + '">').join('');
$('claves').innerHTML = CATALOGO.map(p =>
'<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() {
@ -940,6 +1030,16 @@
}
$('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
$('cobModEsquema').classList.toggle('oculto', !c);
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
@ -1084,12 +1184,184 @@
}, 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 ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('vistaCob').classList.toggle('oculto', t !== 'cob');
$('vistaCom').classList.toggle('oculto', t !== 'com');
$('tabCom').classList.toggle('activo', t === 'com');
$('tabCob').classList.toggle('activo', t === 'cob');
$('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva');
@ -1097,6 +1369,7 @@
if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo();
if (t === 'cob') cargarProyectosCob();
if (t === 'com') cargarProyectosCom();
$('aviso').className = 'aviso';
}
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }