From 6fb43f84a10b7691bb39832f222edbc4f221e72e Mon Sep 17 00:00:00 2001 From: "Efrain (Helgen)" Date: Tue, 20 Oct 2026 17:01:57 -0600 Subject: [PATCH] =?UTF-8?q?Cat=C3=A1logo:=20precio=20en=20USD=20autom?= =?UTF-8?q?=C3=A1tico=20con=20el=20tipo=20de=20cambio=20del=20d=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pestaña "Tipo de cambio" en el catálogo: Google Finance (USD→MXN), un valor manual opcional que manda, y el tipo de cambio en uso (rango TIPO_CAMBIO). Se crea sola y se recalcula cada hora. - Columna I "Precio USD (sin IVA, automático)": fórmula por renglón (no ARRAYFORMULA, para que appendRow no escriba al final de la hoja); los renglones sin fórmula se reparan solos; protegida con advertencia. - En cotizaciones USD, al elegir un producto se pone su precio en dólares; al cambiar de moneda se convierten los renglones del catálogo no modificados y se avisa de los capturados a mano. La pestaña Catálogo muestra el precio USD y el tipo de cambio en uso. - Pruebas de catálogo e interfaz ampliadas; CLAUDE.md actualizado. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 26 +++++++--- Code.js | 102 +++++++++++++++++++++++++++++++++++++-- tests/catalogo.test.js | 59 +++++++++++++++++++--- tests/interfaz.test.js | 26 ++++++++-- web/cotizador/index.html | 65 ++++++++++++++++++------- 5 files changed, 237 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 773897a..64af9b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,8 +100,14 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e - `crearCatalogo_()` — crea el archivo con formato, validaciones (clave única con `COUNTIF`, precio ≥ 0), migra la pestaña `Catalogo` vieja de la base de datos (sin el ejemplo `EJ-001`, luego la borra) y lo mueve junto a la base de datos. - `asegurarColumnasSat_(hoja)` — agrega encabezados `Clave SAT`/`Unidad SAT` (G:H, formato texto) a catálogos viejos. -- `getCatalogo_(hoja?)` — lista `{clave, descripcion, unidad, precio, claveSat, unidadSat}`. -- `getCatalogo()` *(UI)* — productos + URL del archivo + unidades SAT para la lista de selección. +- `asegurarPrecioUsd_(hoja)` — crea la pestaña "Tipo de cambio" (B2 `GOOGLEFINANCE("CURRENCY:USDMXN")`, B3 manual opcional, + B4 en uso = rango con nombre `TIPO_CAMBIO`, recálculo cada hora), el encabezado de la columna I (protegida con advertencia) y la + fórmula `=IF(ISNUMBER(Dn),IFERROR(ROUND(Dn/TIPO_CAMBIO,2),""),"")` en cada renglón con producto que no la tenga (se repara + sola). **Fórmula por renglón, no `ARRAYFORMULA`**: una que llene toda la columna haría que `appendRow` escriba al final de la hoja. + Si falla, el catálogo funciona igual. `tipoCambioCatalogo_(hoja)` → `{valor, fuente}`. +- `getCatalogo_(hoja?)` — lista `{clave, descripcion, unidad, precio, claveSat, unidadSat, precioUsd}`; `precioUsd` lo calcula el + servidor con el tipo de cambio en uso (mismo cálculo que la columna I; no espera a que Sheets recalcule); `null` sin tipo de cambio. +- `getCatalogo()` *(UI)* — productos + URL del archivo + unidades SAT para la lista de selección + `tipoCambio`. - `guardarProducto(p)` *(UI)* — alta o edición (`p.claveOriginal` = edición, permite cambiar la clave). Valida con `normalizarProducto`, rechaza claves duplicadas (sin distinguir mayúsculas) y claves/unidades SAT que no existan. "Actualizó" = `usuarioActual_()`. @@ -253,8 +259,10 @@ filas 0001–0004 no las tienen llenas. La T se agregó con Proyectos (10/2026): `cliente_id` (ver `idCliente`), Ubicación, Estado (Activo/Cerrado/Cancelado; **se cambia con la lista desplegable en la hoja**, no hay pantalla), `carpeta_id`, `vendedor_email`, `vendedor_nombre`, `vendedor_apellido`. -**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA), -Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto. +**"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA, MXN), +Actualizado, Actualizó, Clave SAT, Unidad SAT, Precio USD (sin IVA, automático; columna I con fórmula, no escribir ahí). +A:C y G:H en formato texto. Pestaña **`Tipo de cambio`**: B2 Google Finance, B3 manual opcional (si tiene valor, manda), B4 en +uso (`TIPO_CAMBIO`). Se crean solas (§6.2). **Libro de cobranza** (`COBRANZA_DB_ID`; columnas en `HOJAS_COBRANZA`; texto salvo importes): - `Contratos` (1 fila por proyecto): `proyecto_folio`, `cotizacion_aceptada`, `marca`, `moneda`, `subtotal`, `iva`, `total` @@ -383,8 +391,10 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A proyecto por más de **1.00** (moneda del proyecto) se rechaza. Si la moneda del pago es otra, tipo de cambio del día obligatorio (pesos por dólar) y se guarda el monto aplicado en la moneda del proyecto: **nunca se suman MXN y USD**. - **Cancelar pago:** rol de cobranza, con motivo; el pago queda como Cancelado (nunca se borra) y deja de contar. -- **USD en cotizaciones:** el catálogo está en pesos: en USD la interfaz llena clave y descripción, y el precio se captura a mano - (no se convierte con tipo de cambio). El PDF dice "Moneda: USD (dólares estadounidenses)", columnas "P. Unitario (USD)" e +- **USD en cotizaciones:** el catálogo guarda el precio en pesos y la columna I lo convierte a dólares con el tipo de cambio del + día (Google Finance, o el manual de la pestaña "Tipo de cambio"; **no es el FIX de Banxico**). En USD la interfaz pone el precio en + dólares al elegir un producto; al cambiar de moneda convierte los renglones del catálogo cuyo precio no se tocó (los capturados a + mano no se convierten y se avisa). Sin tipo de cambio válido, el precio se captura a mano. El precio se puede ajustar antes de guardar. El PDF dice "Moneda: USD (dólares estadounidenses)", columnas "P. Unitario (USD)" e "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). @@ -466,7 +476,9 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio - **Cobranza, antes de usarla:** probar en el proyecto PRUEBAS (§10.5) con un usuario sin rol, correr `prepararCobranza()` en la API real, capturar `ROL_COBRANZA` y `ADMIN_DESBLOQUEO`, compartir la carpeta de cobranza como lector con tania@ y leo@ (Google les manda correo: confirmar con el usuario antes) y anotar su ID en §2. -- **Mejora futura:** columna de precio en USD en el catálogo (hoy en cotizaciones USD el precio se captura a mano). +- Precio USD del catálogo (20/10/2026): la cotización no guarda el tipo de cambio que se usó; si contabilidad lo necesita, + agregar una columna en `Cotizaciones`. Confirmar que leer `GOOGLEFINANCE` desde Apps Script funciona en el catálogo real + (si no, usar el tipo de cambio manual de la pestaña). - **Acceso del dominio a la Base de datos y a la carpeta maestra** (lector para todo helgen.tech): se queda por ahora; revisarlo junto con la idea "cada vendedor ve solo sus cotizaciones" (quitarlo impide abrir los PDF desde el Historial). - Tania y Leo eran **editores** de la Base de datos (20/10/2026); el usuario los baja a lector. diff --git a/Code.js b/Code.js index 7b5c6ac..5867fac 100644 --- a/Code.js +++ b/Code.js @@ -317,9 +317,77 @@ function hojaCatalogo_() { const ss = SpreadsheetApp.openById(props.getProperty('CATALOGO_ID')); const hoja = ss.getSheetByName('Catalogo') || ss.getSheets()[0]; asegurarColumnasSat_(hoja); + // Precio en USD: dato extra; si falla, el catálogo funciona igual (en USD el precio se captura a mano) + try { asegurarPrecioUsd_(hoja); } catch (e) { console.warn('Sin columna de precio USD: ' + e.message); } return hoja; } +// ---- Precio en USD (columna I) y tipo de cambio del día (pestaña "Tipo de cambio" del catálogo) ---- +// Fórmula por renglón (no ARRAYFORMULA: una fórmula que llena toda la columna haría que appendRow escriba al final +// de la hoja). Los renglones sin fórmula (p. ej. capturados a mano en Sheets) se reparan solos al abrir el catálogo. + +const ENC_PRECIO_USD = 'Precio USD (sin IVA, automático)'; +const COL_PRECIO_USD = 9; // I +const HOJA_TIPO_CAMBIO = 'Tipo de cambio'; +const RANGO_TIPO_CAMBIO = 'TIPO_CAMBIO'; +const formulaPrecioUsd_ = fila => '=IF(ISNUMBER(D' + fila + '),IFERROR(ROUND(D' + fila + '/' + RANGO_TIPO_CAMBIO + ',2),""),"")'; + +/** + * Pestaña "Tipo de cambio": B2 = Google Finance (USD→MXN, automático), B3 = manual opcional (si tiene valor, manda), + * B4 = en uso (rango con nombre TIPO_CAMBIO). Columna I del catálogo = precio en pesos ÷ TIPO_CAMBIO. + */ +function asegurarPrecioUsd_(hoja) { + const ss = hoja.getParent(); + if (!ss.getSheetByName(HOJA_TIPO_CAMBIO)) { + const tc = ss.insertSheet(HOJA_TIPO_CAMBIO); + tc.getRange(1, 1, 5, 2).setValues([ + ['Concepto', 'Valor'], + ['Automático: Google Finance (pesos por dólar)', ''], + ['Manual (opcional): si escribes un valor aquí, se usa en lugar del automático', ''], + ['Tipo de cambio en uso', ''], + ['Nota', 'Los precios en USD del catálogo (columna I) se calculan solos con el tipo de cambio en uso. Google Finance ' + + 'se actualiza durante el día y puede tener unos minutos de retraso; no es el FIX de Banxico.'], + ]); + tc.getRange(2, 2).setFormula('=GOOGLEFINANCE("CURRENCY:USDMXN")'); + tc.getRange(4, 2).setFormula('=IF(ISNUMBER(B3),B3,IFERROR(ROUND(B2,4),""))'); + tc.getRange(2, 2, 3, 1).setNumberFormat('0.0000'); + tc.getRange(1, 1, 1, 2).setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF'); + tc.setColumnWidth(1, 470); tc.setColumnWidth(2, 160); + ss.setNamedRange(RANGO_TIPO_CAMBIO, tc.getRange(4, 2)); + // GOOGLEFINANCE se recalcula cada hora aunque nadie abra el archivo + ss.setRecalculationInterval(SpreadsheetApp.RecalculationInterval.HOUR); + } + if (hoja.getRange(1, COL_PRECIO_USD).getValue() !== ENC_PRECIO_USD) { + hoja.getRange(1, COL_PRECIO_USD).setValue(ENC_PRECIO_USD) + .setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF'); + hoja.getRange('I:I').setNumberFormat('$#,##0.00'); + hoja.setColumnWidth(COL_PRECIO_USD, 210); + hoja.getRange('I:I').protect().setDescription('Precio USD automático: no escribir aquí (cambia el precio en pesos ' + + 'o el tipo de cambio)').setWarningOnly(true); + } + // Renglones con producto y sin fórmula en I: se les pone (incluye los dados de alta a mano en Sheets) + const n = hoja.getLastRow() - 1; + if (n < 1) return; + const claves = hoja.getRange(2, 1, n, 1).getValues(); + const formulas = hoja.getRange(2, COL_PRECIO_USD, n, 1).getFormulas(); + claves.forEach((r, i) => { + if (String(r[0] || '').trim() && !formulas[i][0]) hoja.getRange(i + 2, COL_PRECIO_USD).setFormula(formulaPrecioUsd_(i + 2)); + }); +} + +/** Tipo de cambio en uso según la pestaña "Tipo de cambio" ({valor, fuente}); valor null si no hay uno válido. */ +function tipoCambioCatalogo_(hoja) { + try { + const tc = hoja.getParent().getSheetByName(HOJA_TIPO_CAMBIO); + if (!tc) return { valor: null, fuente: '' }; + const v = tc.getRange(2, 2, 2, 1).getValues(); + return tipoCambioDesdeValores(v[0][0], v[1][0]); + } catch (e) { + console.warn('Sin tipo de cambio: ' + e.message); + return { valor: null, fuente: '' }; + } +} + /** Catálogos creados antes de las columnas SAT: agrega los encabezados G:H con formato texto. */ function asegurarColumnasSat_(hoja) { const n = ENCABEZADOS_CATALOGO.length; @@ -375,11 +443,14 @@ function crearCatalogo_() { } function getCatalogo_(hoja) { - const filas = (hoja || hojaCatalogo_()).getDataRange().getValues().slice(1); + const h = hoja || hojaCatalogo_(); + const filas = h.getDataRange().getValues().slice(1); + // Precio en USD con el mismo cálculo que la columna I (no depende de que Sheets ya haya recalculado la fórmula) + const tc = tipoCambioCatalogo_(h).valor; return filas .filter(r => r[0] && r[1]) .map(r => ({ clave: String(r[0]), descripcion: String(r[1]), unidad: String(r[2] || 'PZA'), precio: Number(r[3]) || 0, - claveSat: String(r[6] || ''), unidadSat: String(r[7] || '') })); + claveSat: String(r[6] || ''), unidadSat: String(r[7] || ''), precioUsd: precioEnDolares(Number(r[3]) || 0, tc) })); } /** Datos para la pestaña Catálogo de la app (incluye las unidades del SAT para elegir). */ @@ -387,7 +458,8 @@ function getCatalogo() { const hoja = hojaCatalogo_(); let unidadesSat = []; try { unidadesSat = getUnidadesSat_(); } catch (e) { /* sin catálogo SAT: la app sigue funcionando */ } - return { productos: getCatalogo_(hoja), url: hoja.getParent().getUrl(), unidadesSat: unidadesSat }; + return { productos: getCatalogo_(hoja), url: hoja.getParent().getUrl(), unidadesSat: unidadesSat, + tipoCambio: tipoCambioCatalogo_(hoja) }; } // ====== CATÁLOGOS DEL SAT (CFDI 4.0): Google Sheets "Catálogos SAT (CFDI 4.0)", ligado por SAT_ID ====== @@ -542,8 +614,10 @@ function guardarProducto(p) { } else { hoja.appendRow(fila); } + try { asegurarPrecioUsd_(hoja); } catch (e) { console.warn('Sin columna de precio USD: ' + e.message); } SpreadsheetApp.flush(); - return { productos: getCatalogo_(hoja), url: hoja.getParent().getUrl(), clave: prod.clave }; + return { productos: getCatalogo_(hoja), url: hoja.getParent().getUrl(), clave: prod.clave, + tipoCambio: tipoCambioCatalogo_(hoja) }; } finally { lock.releaseLock(); } @@ -552,11 +626,13 @@ function guardarProducto(p) { /** Llamada por la app al cargar. */ function getDatosIniciales() { const cfg = getConfig_(); + const hojaCat = hojaCatalogo_(); return { ivaTasa: cfg.IVA_TASA, vigenciaDias: cfg.VIGENCIA_DIAS, marcas: MARCAS, - catalogo: getCatalogo_(), + catalogo: getCatalogo_(hojaCat), + tipoCambio: tipoCambioCatalogo_(hojaCat), clientes: getClientes(), proyectos: proyectosParaInicio_(), usuario: usuarioActual_(), @@ -1630,6 +1706,22 @@ function monedaDeCotizacion(m) { return s; } +/** + * Tipo de cambio en uso (pesos por dólar): el manual si es un número válido; si no, el automático (Google Finance). + * Fuera de 1–1000 se descarta (p. ej. "#N/A" o "Cargando…" de GOOGLEFINANCE). Devuelve {valor, fuente}. + */ +function tipoCambioDesdeValores(automatico, manual) { + const valido = v => typeof v === 'number' && isFinite(v) && v >= 1 && v <= 1000; + if (valido(manual)) return { valor: Math.round(manual * 10000) / 10000, fuente: 'manual' }; + if (valido(automatico)) return { valor: Math.round(automatico * 10000) / 10000, fuente: 'Google Finance' }; + return { valor: null, fuente: '' }; +} + +/** Precio en pesos -> dólares con el tipo de cambio (2 decimales); null si no hay tipo de cambio. */ +function precioEnDolares(precioMxn, tipoCambio) { + return tipoCambio > 0 ? redondear(Number(precioMxn) / tipoCambio) : null; +} + // ---- Cobranza: esquema de pagos, reparto, comprobantes y roles ---- const ESQUEMA_POR_OMISION = [['Anticipo', 60], ['Instalación', 30], ['Encendido del sistema', 10]]; diff --git a/tests/catalogo.test.js b/tests/catalogo.test.js index 54bc522..5e6bd97 100644 --- a/tests/catalogo.test.js +++ b/tests/catalogo.test.js @@ -4,14 +4,19 @@ const dir=process.argv[2]; const ext=fs.existsSync(dir+'/Code.js')?'js':'gs'; const src=fs.readFileSync(dir+'/Logo.'+ext,'utf8')+'\n'+fs.readFileSync(dir+'/Code.'+ext,'utf8'); const chain=new Proxy({}, {get:(t,k)=>k==='build'?()=>({}):()=>chain}); -function hoja(nombre,m){const h={m,nombre,getName:()=>nombre,setName(n){nombre=n;return h},getLastRow:()=>m.length, +// Hoja simulada; las fórmulas van aparte (h.f["fila,col"]), como en Sheets no son el valor de la celda +function hoja(nombre,m,padre){const h={m,nombre,f:{},getName:()=>nombre,setName(n){nombre=n;return h},getLastRow:()=>m.length, getDataRange:()=>({getValues:()=>m.map(r=>r.slice())}),appendRow:a=>m.push(a),setFrozenRows(){},setColumnWidth(){}, - getParent:()=>({getUrl:()=>'https://sheets/catalogo'}), - getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({ - getValues:()=>m.slice(r-1,r-1+nr).map(x=>x.slice(c-1,c-1+nc)),getValue:()=>(m[r-1]||[])[c-1], + getParent:()=>padre||{getUrl:()=>'https://sheets/catalogo'}, + getRange:(r,c=1,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}, + protect:()=>{h.protegida=r;return chain}}:({ + getValues:()=>Array.from({length:nr},(_,i)=>{const x=(m[r-1+i]||[]).slice(c-1,c-1+nc);while(x.length(m[r-1]||[])[c-1], + getFormulas:()=>Array.from({length:nr},(_,i)=>Array.from({length:nc},(_,j)=>h.f[(r+i)+','+(c+j)]||'')), + setFormula(v){h.f[r+','+c]=v;return this}, setValue(v){(m[r-1]=m[r-1]||[])[c-1]=v;return this}, setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this}, - setFontWeight(){return this},setBackground(){return this},setFontColor(){return this}})};return h} + setNumberFormat(){return this},setFontWeight(){return this},setBackground(){return this},setFontColor(){return this}})};return h} const vieja=hoja('Catalogo',[['Clave','Descripción','Unidad','Precio unitario'],['EJ-001','Producto de ejemplo (bórrame)','PZA',1000],['car-7','Cargador 7 kW','PZA',15000]]); const dbSheets={Catalogo:vieja,Config:hoja('Config',[['Clave','Valor']].concat([])),Cotizaciones:hoja('Cotizaciones',[['Folio']])}; let borradas=[],creados=0,catSS; @@ -22,7 +27,12 @@ const ctx={console,Date, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, SpreadsheetApp:{flush(){},newDataValidation:()=>chain, - create:n=>{creados++;const h=hoja('Hoja 1',[]);catSS={getId:()=>'cat',getSheets:()=>[h],getSheetByName:x=>h.getName()===x?h:null,h};return catSS}, + RecalculationInterval:{HOUR:'HOUR'}, + create:n=>{creados++;catSS={getId:()=>'cat',getUrl:()=>'https://sheets/catalogo',pest:{},nombres:{}, + getSheets:()=>Object.values(catSS.pest),getSheetByName:x=>Object.values(catSS.pest).find(h=>h.getName()===x)||null, + insertSheet:x=>{if(catSS.getSheetByName(x))throw new Error('ya existe');return catSS.pest[x]=hoja(x,[],catSS)}, + setNamedRange:(n,r)=>{catSS.nombres[n]=r},setRecalculationInterval:v=>{catSS.recalculo=v}}; + catSS.h=catSS.pest.h=hoja('Hoja 1',[],catSS);return catSS}, openById:id=>id==='cat'?catSS:db}, DriveApp:{getFileById:id=>({getParents:()=>({hasNext:()=>true,next:()=>'carpeta'}),moveTo(){}})}, }; @@ -53,4 +63,41 @@ r=ctx.guardarProducto({clave:'PAN-550M',descripcion:'Panel solar 550 W mono',pre assert.deepEqual(r.productos.map(p=>p.clave),['CAR-7','PAN-550M']); assert.throws(()=>ctx.guardarProducto({clave:'Z',descripcion:'x',precio:1,claveOriginal:'NOEXISTE'}),/ya no existe/); assert.equal(catSS.h.m[2][5],'efrain@helgen.tech'); assert.equal(catSS.h.m[0][3],'Precio unitario (sin IVA)'); + +// ---- Precio en USD con el tipo de cambio del día ---- +assert.deepEqual({...ctx.tipoCambioDesdeValores(18.4567891,'')},{valor:18.4568,fuente:'Google Finance'}); +assert.deepEqual({...ctx.tipoCambioDesdeValores(18.45,19.9)},{valor:19.9,fuente:'manual'},'el manual manda'); +assert.deepEqual({...ctx.tipoCambioDesdeValores('#N/A','')},{valor:null,fuente:''}); +assert.deepEqual({...ctx.tipoCambioDesdeValores('Cargando…',0.05)},{valor:null,fuente:''},'fuera de rango se descarta'); +assert.equal(ctx.precioEnDolares(15000,18.5),810.81); assert.equal(ctx.precioEnDolares(15000,null),null); +// Pestaña "Tipo de cambio": Google Finance, manual opcional, en uso = rango TIPO_CAMBIO; recalcula cada hora +const tc=catSS.getSheetByName('Tipo de cambio'); +assert(tc,'se crea sola'); assert.equal(tc.f['2,2'],'=GOOGLEFINANCE("CURRENCY:USDMXN")'); +assert.equal(tc.f['4,2'],'=IF(ISNUMBER(B3),B3,IFERROR(ROUND(B2,4),""))'); +assert(catSS.nombres.TIPO_CAMBIO,'rango con nombre'); assert.equal(catSS.recalculo,'HOUR'); +// Columna I: encabezado, fórmula por renglón (sin ARRAYFORMULA), protegida con advertencia +const cat=catSS.h; +assert.equal(cat.m[0][8],'Precio USD (sin IVA, automático)'); assert.equal(cat.protegida,'I:I'); +assert.equal(cat.f['2,9'],'=IF(ISNUMBER(D2),IFERROR(ROUND(D2/TIPO_CAMBIO,2),""),"")'); +assert.equal(cat.f['3,9'],'=IF(ISNUMBER(D3),IFERROR(ROUND(D3/TIPO_CAMBIO,2),""),"")'); +assert(!Object.values(cat.f).some(x=>/ARRAYFORMULA/i.test(x))); +// Sin tipo de cambio todavía (GOOGLEFINANCE cargando): sin precio USD, el catálogo funciona igual +r=ctx.getCatalogo(); assert.equal(r.productos[0].precioUsd,null); assert.equal(r.tipoCambio.valor,null); +// Con Google Finance +tc.m[1][1]=18.5; +r=ctx.getCatalogo(); +assert.deepEqual(r.productos.map(p=>p.clave+':'+p.precio+':'+p.precioUsd),['CAR-7:15000:810.81','PAN-550M:4100:221.62']); +assert.deepEqual({...r.tipoCambio},{valor:18.5,fuente:'Google Finance'}); +// Manual: manda sobre el automático +tc.m[2][1]=20; r=ctx.getCatalogo(); assert.equal(r.productos[0].precioUsd,750); assert.equal(r.tipoCambio.fuente,'manual'); +tc.m[2][1]=''; +// Alta desde la app: su renglón recibe la fórmula; no se escribe al final de la hoja +r=ctx.guardarProducto({clave:'INV-8',descripcion:'Inversor 8 kW',precio:'26000'}); +assert.equal(cat.m.length,4); assert.equal(cat.f['4,9'],'=IF(ISNUMBER(D4),IFERROR(ROUND(D4/TIPO_CAMBIO,2),""),"")'); +assert.equal(r.productos.find(p=>p.clave==='INV-8').precioUsd,1405.41); assert.equal(r.tipoCambio.valor,18.5); +// Renglón capturado a mano en Sheets (sin fórmula): se repara al abrir el catálogo; no se duplica la pestaña +cat.m.push(['BAT-5','Batería 5 kWh','PZA',30000]); +ctx.getCatalogo(); +assert.equal(cat.f['5,9'],'=IF(ISNUMBER(D5),IFERROR(ROUND(D5/TIPO_CAMBIO,2),""),"")'); +assert.equal(Object.keys(catSS.pest).length,2); console.log('OK catálogo', r.productos.map(p=>p.clave+' '+p.precio).join(', ')); diff --git a/tests/interfaz.test.js b/tests/interfaz.test.js index dba444e..4fd0268 100644 --- a/tests/interfaz.test.js +++ b/tests/interfaz.test.js @@ -11,7 +11,9 @@ const apiWeb=scripts.find(s=>s.id==='api-web').src, app=scripts.find(s=>!s.id).s const b64=o=>Buffer.from(JSON.stringify(o)).toString('base64url'); const token='h.'+b64({email:'ana@helgen.tech',given_name:'Ana',exp:Math.floor(Date.now()/1000)+3600})+'.f'; const PRY={folio:'PRY-2026-0001',nombre:'Techo',cliente:'Luis — Solar MX',clienteId:'LUSM800101AB1',ubicacion:'Qro',estado:'Activo',fecha:'20/10/2026'}; -const CAT=[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',precio:4100}]; +const CAT=[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',precio:4100,precioUsd:222.22}, + {clave:'SRV-1',descripcion:'Servicio sin USD',unidad:'SRV',precio:1000,precioUsd:null}]; +const TC={valor:18.45,fuente:'Google Finance'}; /** App cargada con un DOM y un servidor simulados nuevos. */ function montar(datosIniciales){ @@ -49,7 +51,7 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia (async()=>{ // ===== Vendedora (sin rol de cobranza) ===== - const A=montar({ivaTasa:0.16,catalogo:CAT,clientes:[],proyectos:[PRY],marcas:{},permisos:{cobranza:false,desbloqueo:false}}); + const A=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[PRY],marcas:{},permisos:{cobranza:false,desbloqueo:false}}); const {$,llamadas,respuestas}=A; await esperar(5); assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos'); @@ -93,15 +95,29 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes); assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML)); - // Moneda: en MXN el catálogo pone el precio; en USD lo deja vacío para capturarlo a mano + // Moneda: en MXN el catálogo pone el precio en pesos; en USD, su precio convertido con el tipo de cambio del día const fila=$('filas').hijos[0]; fila.querySelector('.desc').value='Panel solar 550 W'; fila.querySelector('.desc').disparar('change'); assert.equal(fila.querySelector('.pu').value,4100); + assert(/PAN-550 · \$4,100\.00 MXN/.test($('catalogo').innerHTML),'lista en pesos'); $('moneda').value='USD'; A.run('cambiarMoneda()'); - assert(/captura los precios en dólares/.test($('notaMoneda').innerHTML)&&/Revisa los precios/.test($('notaMoneda').innerHTML)); + assert.equal(fila.querySelector('.pu').value,222.22,'el renglón del catálogo se convierte a USD al cambiar de moneda'); + assert(/18\.4500 pesos por dólar \(Google Finance\)/.test($('notaMoneda').innerHTML)&&!/Revisa/.test($('notaMoneda').innerHTML)); + assert(/PAN-550 · \$222\.22 USD/.test($('catalogo').innerHTML)&&/precio en USD a mano/.test($('catalogo').innerHTML),'lista en dólares'); assert.equal($('lblTot').textContent,'TOTAL USD'); + $('moneda').value='MXN'; A.run('cambiarMoneda()'); assert.equal(fila.querySelector('.pu').value,4100,'de regreso a pesos'); + $('moneda').value='USD'; A.run('cambiarMoneda()'); assert.equal(fila.querySelector('.pu').value,222.22); fila.querySelector('.desc').disparar('change'); - assert.equal(fila.querySelector('.pu').value,'','en USD el catálogo no pone precio'); assert.equal(fila.querySelector('.clave').value,'PAN-550'); + assert.equal(fila.querySelector('.pu').value,222.22,'elegir en USD pone el precio en dólares'); assert.equal(fila.querySelector('.clave').value,'PAN-550'); + // Producto sin precio USD (sin tipo de cambio): se captura a mano + A.run('agregarFila()'); const fila2=$('filas').hijos[1]; + fila2.querySelector('.clave').value='SRV-1'; fila2.querySelector('.clave').disparar('change'); + assert.equal(fila2.querySelector('.pu').value,''); assert.equal(fila2.querySelector('.pu').placeholder,'USD'); + // Precio ajustado a mano: no se convierte al cambiar de moneda y se avisa + fila.querySelector('.pu').value='180'; + $('moneda').value='MXN'; A.run('cambiarMoneda()'); + assert.equal(fila.querySelector('.pu').value,'180'); assert(/Revisa los precios que capturaste a mano/.test($('notaMoneda').innerHTML)); + $('moneda').value='USD'; A.run('cambiarMoneda()'); fila.querySelector('.cant').value='1'; fila.querySelector('.pu').value='180'; A.run('recalcular()'); assert.equal($('tTot').textContent,'$208.80 USD'); respuestas.guardarCotizacion={ok:true,datos:{folio:'HLG-2026-0009',url:'u',total:208.8,moneda:'USD',proyectoFolio:''}}; diff --git a/web/cotizador/index.html b/web/cotizador/index.html index c2775d7..b4c4c84 100644 --- a/web/cotizador/index.html +++ b/web/cotizador/index.html @@ -235,7 +235,7 @@
-
+
@@ -260,10 +260,11 @@

Productos

- +
ClaveDescripciónUnidadPrecio sin IVAClave SAT
ClaveDescripciónUnidadPrecio sin IVAPrecio USDClave SAT
@@ -487,6 +488,7 @@