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 - `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. `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. - `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}`. - `asegurarPrecioUsd_(hoja)` — crea la pestaña "Tipo de cambio" (B2 `GOOGLEFINANCE("CURRENCY:USDMXN")`, B3 manual opcional,
- `getCatalogo()` *(UI)* — productos + URL del archivo + unidades SAT para la lista de selección. 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 - `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. `normalizarProducto`, rechaza claves duplicadas (sin distinguir mayúsculas) y claves/unidades SAT que no existan.
"Actualizó" = `usuarioActual_()`. "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**, `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`. 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), **"Catálogo Helgen" / pestaña `Catalogo`**: Clave (única, mayúsculas), Descripción, Unidad, Precio unitario (sin IVA, MXN),
Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto. 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): **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` - `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 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**. 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. - **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 - **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
(no se convierte con tipo de cambio). El PDF dice "Moneda: USD (dólares estadounidenses)", columnas "P. Unitario (USD)" e 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** "Importe (USD)", "TOTAL USD" e importe con letra en dólares. **Términos y condiciones: el mismo bloque que en MXN, sin cambios**
(incluido "$300 MXN por metro" en Energy; decisión del usuario, 20/10/2026). (incluido "$300 MXN por metro" en Energy; decisión del usuario, 20/10/2026).
@ -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 - **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@ 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. (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 - **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). 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. - 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 ss = SpreadsheetApp.openById(props.getProperty('CATALOGO_ID'));
const hoja = ss.getSheetByName('Catalogo') || ss.getSheets()[0]; const hoja = ss.getSheetByName('Catalogo') || ss.getSheets()[0];
asegurarColumnasSat_(hoja); 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; 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. */ /** Catálogos creados antes de las columnas SAT: agrega los encabezados G:H con formato texto. */
function asegurarColumnasSat_(hoja) { function asegurarColumnasSat_(hoja) {
const n = ENCABEZADOS_CATALOGO.length; const n = ENCABEZADOS_CATALOGO.length;
@ -375,11 +443,14 @@ function crearCatalogo_() {
} }
function getCatalogo_(hoja) { 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 return filas
.filter(r => r[0] && r[1]) .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, .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). */ /** 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_(); const hoja = hojaCatalogo_();
let unidadesSat = []; let unidadesSat = [];
try { unidadesSat = getUnidadesSat_(); } catch (e) { /* sin catálogo SAT: la app sigue funcionando */ } 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 ====== // ====== 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 { } else {
hoja.appendRow(fila); hoja.appendRow(fila);
} }
try { asegurarPrecioUsd_(hoja); } catch (e) { console.warn('Sin columna de precio USD: ' + e.message); }
SpreadsheetApp.flush(); 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 { } finally {
lock.releaseLock(); lock.releaseLock();
} }
@ -552,11 +626,13 @@ function guardarProducto(p) {
/** Llamada por la app al cargar. */ /** Llamada por la app al cargar. */
function getDatosIniciales() { function getDatosIniciales() {
const cfg = getConfig_(); const cfg = getConfig_();
const hojaCat = hojaCatalogo_();
return { return {
ivaTasa: cfg.IVA_TASA, ivaTasa: cfg.IVA_TASA,
vigenciaDias: cfg.VIGENCIA_DIAS, vigenciaDias: cfg.VIGENCIA_DIAS,
marcas: MARCAS, marcas: MARCAS,
catalogo: getCatalogo_(), catalogo: getCatalogo_(hojaCat),
tipoCambio: tipoCambioCatalogo_(hojaCat),
clientes: getClientes(), clientes: getClientes(),
proyectos: proyectosParaInicio_(), proyectos: proyectosParaInicio_(),
usuario: usuarioActual_(), usuario: usuarioActual_(),
@ -1630,6 +1706,22 @@ function monedaDeCotizacion(m) {
return s; 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 ---- // ---- Cobranza: esquema de pagos, reparto, comprobantes y roles ----
const ESQUEMA_POR_OMISION = [['Anticipo', 60], ['Instalación', 30], ['Encendido del sistema', 10]]; 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 ext=fs.existsSync(dir+'/Code.js')?'js':'gs';
const src=fs.readFileSync(dir+'/Logo.'+ext,'utf8')+'\n'+fs.readFileSync(dir+'/Code.'+ext,'utf8'); 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}); 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(){}, getDataRange:()=>({getValues:()=>m.map(r=>r.slice())}),appendRow:a=>m.push(a),setFrozenRows(){},setColumnWidth(){},
getParent:()=>({getUrl:()=>'https://sheets/catalogo'}), getParent:()=>padre||{getUrl:()=>'https://sheets/catalogo'},
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({ getRange:(r,c=1,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], 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}, 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}, 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 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']])}; const dbSheets={Catalogo:vieja,Config:hoja('Config',[['Clave','Valor']].concat([])),Cotizaciones:hoja('Cotizaciones',[['Folio']])};
let borradas=[],creados=0,catSS; let borradas=[],creados=0,catSS;
@ -22,7 +27,12 @@ const ctx={console,Date,
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
SpreadsheetApp:{flush(){},newDataValidation:()=>chain, 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}, openById:id=>id==='cat'?catSS:db},
DriveApp:{getFileById:id=>({getParents:()=>({hasNext:()=>true,next:()=>'carpeta'}),moveTo(){}})}, 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.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.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)'); 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(', ')); 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 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 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 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. */ /** App cargada con un DOM y un servidor simulados nuevos. */
function montar(datosIniciales){ 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()=>{ (async()=>{
// ===== Vendedora (sin rol de cobranza) ===== // ===== 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; const {$,llamadas,respuestas}=A;
await esperar(5); await esperar(5);
assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos'); assert.equal(A.run('PROYECTOS.length'),1,'carga los proyectos activos');
@ -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.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes);
assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML)); 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]; const fila=$('filas').hijos[0];
fila.querySelector('.desc').value='Panel solar 550 W'; fila.querySelector('.desc').disparar('change'); fila.querySelector('.desc').value='Panel solar 550 W'; fila.querySelector('.desc').disparar('change');
assert.equal(fila.querySelector('.pu').value,4100); 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()'); $('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'); 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'); 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()'); fila.querySelector('.cant').value='1'; fila.querySelector('.pu').value='180'; A.run('recalcular()');
assert.equal($('tTot').textContent,'$208.80 USD'); assert.equal($('tTot').textContent,'$208.80 USD');
respuestas.guardarCotizacion={ok:true,datos:{folio:'HLG-2026-0009',url:'u',total:208.8,moneda:'USD',proyectoFolio:''}}; 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><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 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>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>
<div class="sat"> <div class="sat">
@ -260,10 +260,11 @@
<h2 id="tituloLista">Productos</h2> <h2 id="tituloLista">Productos</h2>
<div class="acciones" style="margin:0 0 12px"> <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"> <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> <a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
</div> </div>
<table> <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> <tbody id="listaCat"></tbody>
</table> </table>
</div> </div>
@ -487,6 +488,7 @@
<script> <script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {}; 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. // 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)); const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId(); let ID_SOLICITUD = nuevoId();
@ -503,7 +505,7 @@
// ---- Arranque ---- // ---- Arranque ----
google.script.run.withSuccessHandler(d => { google.script.run.withSuccessHandler(d => {
IVA = d.ivaTasa; CATALOGO = d.catalogo; IVA = d.ivaTasa; CATALOGO = d.catalogo; TC = d.tipoCambio || TC;
recibirClientes(d.clientes); recibirClientes(d.clientes);
recibirProyectos(d.proyectos); recibirProyectos(d.proyectos);
// La pestaña Cobranza solo se muestra con permiso; el servidor lo revisa en cada llamada // La pestaña Cobranza solo se muestra con permiso; el servidor lo revisa en cada llamada
@ -530,9 +532,11 @@
if (!p) return; if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion; tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.uni').value = p.unidad;
// El catálogo está en pesos: en una cotización en USD el precio se captura a mano // En USD: precio del catálogo convertido con el tipo de cambio del día; sin tipo de cambio, se captura a mano
if (monedaActual() === 'USD') { tr.querySelector('.pu').value = ''; tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); } const precio = precioCatalogo(p, monedaActual());
else tr.querySelector('.pu').value = p.precio; 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('.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(); }); tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
@ -566,14 +570,33 @@
$('lblTot').textContent = 'TOTAL ' + m; $('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() { function cambiarMoneda() {
const usd = monedaActual() === 'USD'; const m = monedaActual(), antes = MONEDA_ANT;
const conPrecio = leerItems().some(it => it.precio > 0); MONEDA_ANT = m;
$('notaMoneda').innerHTML = usd // Renglones elegidos del catálogo cuyo precio no se modificó: se pasan a la nueva moneda
? '<span class="aviso-moneda">Cotización en USD:</span> el catálogo llena clave y descripción; captura los precios en dólares.' + let manuales = 0;
(conPrecio ? ' <span class="aviso-moneda">Revisa los precios que ya capturaste: los del catálogo están en pesos.</span>' : '') if (m !== antes) leerItems().forEach(it => {
: ''; const p = it._tr._cat && buscarClave(it._tr._cat);
recalcular(); 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 ---- // ---- Guardar ----
@ -746,19 +769,24 @@
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase()); const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
function pintarDatalists() { 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 => $('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 => $('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() { 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(); google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
} }
function recibirCatalogo(r) { 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'); $('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
if (r.unidadesSat && r.unidadesSat.length) { if (r.unidadesSat && r.unidadesSat.length) {
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join(''); $('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) + '">' + '<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><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">' + 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('') '<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)); $('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
} }