mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
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:
parent
1d49ee171e
commit
6fb43f84a1
26
CLAUDE.md
26
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
|
- `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
102
Code.js
@ -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]];
|
||||||
|
|||||||
@ -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(', '));
|
||||||
|
|||||||
@ -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:''}};
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user