Catálogo: precio en USD automático con el tipo de cambio del día

- 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 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-20 17:01:57 -06:00
parent 1d49ee171e
commit 6fb43f84a1
5 changed files with 237 additions and 41 deletions

View File

@ -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.

102
Code.js
View File

@ -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]];

View File

@ -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<nc)x.push('');return x}),
getValue:()=>(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(', '));

View File

@ -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;i<n;i++) await new Promise(r=>setImmedia
(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;i<n;i++) await new Promise(r=>setImmedia
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:''}};

View File

@ -235,7 +235,7 @@
<div><label>Clave *</label><input id="pClave" maxlength="30" placeholder="Ej. CAR-7KW"></div>
<div style="grid-column:span 2"><label>Descripción *</label><input id="pDesc"></div>
<div><label>Unidad</label><input id="pUni" value="PZA"></div>
<div><label>Precio unitario sin IVA *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
<div><label>Precio unitario sin IVA (MXN) *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
</div>
<div class="sat">
@ -260,10 +260,11 @@
<h2 id="tituloLista">Productos</h2>
<div class="acciones" style="margin:0 0 12px">
<input id="buscarCat" placeholder="Buscar por clave o descripción…" oninput="pintarCatalogo()" style="flex:1; min-width:220px">
<span id="tcCat" class="nota"></span>
<a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
</div>
<table>
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th style="width:110px">Clave SAT</th></tr></thead>
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th class="num" style="width:150px">Precio USD</th><th style="width:110px">Clave SAT</th></tr></thead>
<tbody id="listaCat"></tbody>
</table>
</div>
@ -487,6 +488,7 @@
<script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {};
let TC = { valor: null, fuente: '' }, MONEDA_ANT = 'MXN'; // tipo de cambio del catálogo (pestaña "Tipo de cambio")
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId();
@ -503,7 +505,7 @@
// ---- Arranque ----
google.script.run.withSuccessHandler(d => {
IVA = d.ivaTasa; CATALOGO = d.catalogo;
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
@ -530,9 +532,11 @@
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad;
// El catálogo está en pesos: en una cotización en USD el precio se captura a mano
if (monedaActual() === 'USD') { tr.querySelector('.pu').value = ''; tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); }
else tr.querySelector('.pu').value = p.precio;
// En USD: precio del catálogo convertido con el tipo de cambio del día; sin tipo de cambio, se captura a mano
const precio = precioCatalogo(p, monedaActual());
tr.querySelector('.pu').value = precio == null ? '' : precio;
if (precio == null) { tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); }
tr._cat = p.clave; // al cambiar de moneda, este renglón se convierte si su precio no se tocó
};
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
@ -566,14 +570,33 @@
$('lblTot').textContent = 'TOTAL ' + m;
}
/** Precio de un producto del catálogo en la moneda pedida; null si es USD y no hay tipo de cambio. */
function precioCatalogo(p, moneda) {
if (moneda !== 'USD') return p.precio;
return p.precioUsd != null ? p.precioUsd : null;
}
function cambiarMoneda() {
const usd = monedaActual() === 'USD';
const conPrecio = leerItems().some(it => it.precio > 0);
$('notaMoneda').innerHTML = usd
? '<span class="aviso-moneda">Cotización en USD:</span> el catálogo llena clave y descripción; captura los precios en dólares.' +
(conPrecio ? ' <span class="aviso-moneda">Revisa los precios que ya capturaste: los del catálogo están en pesos.</span>' : '')
: '';
recalcular();
const m = monedaActual(), antes = MONEDA_ANT;
MONEDA_ANT = m;
// Renglones elegidos del catálogo cuyo precio no se modificó: se pasan a la nueva moneda
let manuales = 0;
if (m !== antes) leerItems().forEach(it => {
const p = it._tr._cat && buscarClave(it._tr._cat);
const pu = it._tr.querySelector('.pu');
if (p && Number(pu.value) === Number(precioCatalogo(p, antes))) {
const nuevo = precioCatalogo(p, m);
pu.value = nuevo == null ? '' : nuevo;
} else if (it.precio > 0) manuales++;
});
const tc = TC && TC.valor ? 'tipo de cambio ' + TC.valor.toFixed(4) + ' pesos por dólar (' + (TC.fuente === 'manual' ? 'capturado en el catálogo' : 'Google Finance') + ')' : '';
$('notaMoneda').innerHTML = m === 'USD'
? '<span class="aviso-moneda">Cotización en USD:</span> ' + (tc
? 'los precios del catálogo se convierten con el ' + esc(tc) + '. Puedes ajustarlos.'
: 'no hay tipo de cambio en el catálogo; captura los precios en dólares.') +
(manuales ? ' <span class="aviso-moneda">Revisa los precios que capturaste a mano: no se convierten.</span>' : '')
: (manuales ? '<span class="aviso-moneda">Revisa los precios que capturaste a mano: no se convierten.</span>' : '');
pintarDatalists(); recalcular();
}
// ---- Guardar ----
@ -746,19 +769,24 @@
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
function pintarDatalists() {
// La etiqueta muestra el precio en la moneda de la cotización
const m = monedaActual();
const precio = p => { const x = precioCatalogo(p, m); return x == null ? 'precio en USD a mano' : dinero(x, m); };
$('catalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + mxn(p.precio)) + '">').join('');
'<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 + ' · ' + mxn(p.precio)) + '">').join('');
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + precio(p)) + '">').join('');
}
function cargarCatalogo() {
$('listaCat').innerHTML = '<tr><td colspan="5">Cargando…</td></tr>';
$('listaCat').innerHTML = '<tr><td colspan="6">Cargando…</td></tr>';
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
}
function recibirCatalogo(r) {
CATALOGO = r.productos;
CATALOGO = r.productos; TC = r.tipoCambio || TC;
$('tcCat').textContent = TC && TC.valor ? 'Tipo de cambio: ' + TC.valor.toFixed(4) + ' MXN/USD · ' +
(TC.fuente === 'manual' ? 'capturado a mano' : 'Google Finance') : 'Sin tipo de cambio (revisa la pestaña "Tipo de cambio" del catálogo)';
$('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
if (r.unidadesSat && r.unidadesSat.length) {
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join('');
@ -774,8 +802,9 @@
'<tr class="fila-cat' + (p.clave === CLAVE_EDITANDO ? ' editando' : '') + '" data-clave="' + esc(p.clave) + '">' +
'<td><b>' + esc(p.clave) + '</b></td><td>' + esc(p.descripcion) + '</td><td>' + esc(p.unidad) + '</td>' +
'<td class="num">' + mxn(p.precio) + '</td>' +
'<td class="num">' + (p.precioUsd != null ? dinero(p.precioUsd, 'USD') : '<span class="nota">—</span>') + '</td>' +
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
: '<tr><td colspan="5">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
: '<tr><td colspan="6">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
$('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
}