a

/**
* ============================================================
* BACKEND «ENTRADA — PIZZA LIBRE» — v3 OPTIMIZADO
* Google Apps Script / Web App
* ============================================================
*
* Mejoras respecto de v2:
* – Cache de hoja y encabezados.
* – Cache de búsquedas.
* – Cache breve del dashboard.
* – Menos lecturas de Spreadsheet.
* – Menos llamadas a getDataRange().
* – Escritura de columnas nuevas en bloque.
* – Invalidación de caché al modificar datos.
* – Mantiene compatibilidad con el frontend actual.
*/

// ====================== CONFIG ======================

const PRECIO_MAYOR = 18000;
const PRECIO_MENOR = 14000;

const COL_NOMBRE = ‘Nombre y apellido’;
const COL_GENERAL = ‘Entrada general’;
const COL_MENORES = ‘Entrada menores de 12’;
const COL_PAGO = ‘¿PAGO?’;
const COL_MONTO = ‘MONTO’;

const HOJA_VENTAS = ‘VentasPuerta’;

const TOKEN = ‘pizza2026’;

const MET_EFECTIVO = ‘Efectivo’;
const MET_MP = ‘Mercado Pago’;

const EXTRA_COLS = [
‘ASISTIO’,
‘CHECKIN’,
‘ESTADO_PAGO’,
‘COBRADO_PUERTA’,
‘METODO_PUERTA’,
‘PENDIENTE’
];

// Tiempo de caché.
// Dashboard: muy corto porque muestra caja.
// Búsquedas: un poco más largo para evitar consultas repetidas.
const CACHE_DASHBOARD = 5;
const CACHE_SEARCH = 15;

const PROP_SHEET_ID = ‘PIZZA_PRE_SHEET_ID’;
const PROP_HEADER_ROW = ‘PIZZA_PRE_HEADER_ROW’;

// ====================================================
// UTILIDADES
// ====================================================

function _ss() {
return SpreadsheetApp.getActiveSpreadsheet();
}

function _cache() {
return CacheService.getScriptCache();
}

function _num(v) {
if (typeof v === ‘number’) return v;

const s = String(v == null ? » : v)
.replace(/[^0-9.-]/g, »);

const n = Number(s);

return isNaN(n) ? 0 : n;
}

function _esSi(v) {
const s = String(v || »)
.trim()
.toUpperCase();

return s === ‘SÍ’ || s === ‘SI’;
}

function _norm(s) {
return String(s || »)
.normalize(‘NFD’)
.replace(/[\u0300-\u036f]/g, »)
.toLowerCase()
.trim();
}

function _json(data) {
return JSON.stringify(data);
}

function _invalidateCache() {
const cache = _cache();

cache.remove(‘pizza_dashboard’);

// Las búsquedas dependen de los datos de asistencia.
// Borramos las búsquedas más habituales si existen.
// Las claves exactas de búsqueda se generan dinámicamente,
// por eso usamos una versión global.
PropertiesService.getScriptProperties()
.setProperty(‘PIZZA_CACHE_VERSION’, String(Date.now()));
}

function _cacheVersion() {
return PropertiesService.getScriptProperties()
.getProperty(‘PIZZA_CACHE_VERSION’) || ‘1’;
}

// ====================================================
// UBICACIÓN DE LA HOJA DE PREINSCRIPTOS
// ====================================================

/**
* Busca la hoja solamente la primera vez.
* Después utiliza ScriptProperties para evitar recorrer
* todas las hojas en cada consulta.
*/
function _hojaPre() {

const props = PropertiesService.getScriptProperties();

const savedId = props.getProperty(PROP_SHEET_ID);
const savedHeaderRow = props.getProperty(PROP_HEADER_ROW);

if (savedId && savedHeaderRow) {

const ss = _ss();

try {

const sh = ss.getSheets().find(
s => String(s.getSheetId()) === String(savedId)
);

if (sh) {

const headerRow = Number(savedHeaderRow);

const lastColumn = sh.getLastColumn();

if (lastColumn > 0) {

const header = sh
.getRange(headerRow, 1, 1, lastColumn)
.getValues()[0]
.map(x => String(x).trim());

if (header.includes(COL_NOMBRE)) {

return {
sheet: sh,
headerRow: headerRow – 1,
header: header
};

}

}

}

} catch (err) {
// Si algo cambió, hacemos una búsqueda completa abajo.
}
}

// Primera búsqueda.

const sheets = _ss().getSheets();

for (const sh of sheets) {

const lastRow = sh.getLastRow();
const lastColumn = sh.getLastColumn();

if (!lastRow || !lastColumn) continue;

const rowsToCheck = Math.min(lastRow, 8);

const values = sh
.getRange(1, 1, rowsToCheck, lastColumn)
.getValues();

for (let r = 0; r < values.length; r++) { const header = values[r].map( x => String(x).trim()
);

if (header.includes(COL_NOMBRE)) {

const headerRowNumber = r + 1;

props.setProperty(
PROP_SHEET_ID,
String(sh.getSheetId())
);

props.setProperty(
PROP_HEADER_ROW,
String(headerRowNumber)
);

return {
sheet: sh,
headerRow: r,
header: header
};
}
}
}

throw new Error(
‘No encontré la hoja de preinscriptos. Falta el encabezado «‘ +
COL_NOMBRE +
‘».’
);
}

// ====================================================
// COLUMNAS DEL SISTEMA
// ====================================================

function _ensureCols(info) {

const sh = info.sheet;

let header = info.header.slice();

const headerRowNumber = info.headerRow + 1;

const idx = {};

const faltantes = [];

EXTRA_COLS.forEach(name => {

const pos = header.indexOf(name);

if (pos === -1) {
faltantes.push(name);
}

});

// Agregar todas las columnas faltantes de una sola vez.

if (faltantes.length > 0) {

const startColumn = header.length + 1;

sh.getRange(
headerRowNumber,
startColumn,
1,
faltantes.length
).setValues([faltantes]);

header = header.concat(faltantes);

// Actualizamos info para esta ejecución.
info.header = header;
}

EXTRA_COLS.forEach(name => {
idx[name] = header.indexOf(name);
});

return idx;
}

// ====================================================
// LECTURA DE PREINSCRIPTOS
// ====================================================

function _leerPreinscriptos() {

const info = _hojaPre();

const cols = _ensureCols(info);

const sh = info.sheet;

const lastRow = sh.getLastRow();
const lastColumn = sh.getLastColumn();

if (lastRow <= info.headerRow + 1) { return { info, cols, lista: [] }; } const cantidadFilas = lastRow - info.headerRow; const all = sh .getRange( info.headerRow + 1, 1, cantidadFilas, lastColumn ) .getValues(); const header = info.header; const iNom = header.indexOf(COL_NOMBRE); const iGen = header.indexOf(COL_GENERAL); const iMen = header.indexOf(COL_MENORES); const iPago = header.indexOf(COL_PAGO); const iMonto = header.indexOf(COL_MONTO); const lista = []; for (let r = 0; r < all.length; r++) { const row = all[r]; const nombre = String(row[iNom] || '').trim(); if (!nombre) continue; if (nombre.toUpperCase() === 'TOTAL') continue; const gen = iGen >= 0 ? _num(row[iGen]) : 0;

const men =
iMen >= 0 ? _num(row[iMen]) : 0;

if (gen === 0 && men === 0) continue;

const esperado =
gen * PRECIO_MAYOR +
men * PRECIO_MENOR;

const pagoPreventa =
iPago >= 0
? _esSi(row[iPago])
: false;

const montoPreventa =
iMonto >= 0
? _num(row[iMonto])
: 0;

let yaPagado = montoPreventa;

// Mantiene la lógica original:
// ¿PAGO? = SI y MONTO menor que esperado
// => se considera pagado el total.
if (
pagoPreventa &&
yaPagado < esperado ) { yaPagado = esperado; } const falta = Math.max( 0, esperado - yaPagado ); lista.push({ row: r + info.headerRow + 1, nombre, mayores: gen, menores: men, entradas: gen + men, esperado, yaPagado, falta, pagoPreventa, asistio: _esSi( row[cols.ASISTIO] ), estadoPago: String( row[cols.ESTADO_PAGO] || '' ), cobradoPuerta: _num( row[cols.COBRADO_PUERTA] ), metodoPuerta: String( row[cols.METODO_PUERTA] || '' ), pendiente: _num( row[cols.PENDIENTE] ) }); } return { info, cols, lista }; } // ==================================================== // VENTAS DE PUERTA // ==================================================== function _hojaVentas() { let sh = _ss().getSheetByName( HOJA_VENTAS ); if (!sh) { sh = _ss().insertSheet( HOJA_VENTAS ); sh.getRange(1, 1, 1, 6) .setValues([[ 'Timestamp', 'Mayores', 'Menores', 'Entradas', 'Monto', 'Metodo' ]]) .setFontWeight('bold'); return sh; } // Solo comprobamos el encabezado. if (sh.getLastColumn() < 6) { sh.getRange(1, 6) .setValue('Metodo'); } else { const metodoHeader = String( sh.getRange(1, 6).getValue() || '' ).trim(); if (metodoHeader !== 'Metodo') { sh.getRange(1, 6) .setValue('Metodo'); } } return sh; } function _leerVentasPuerta() { const sh = _hojaVentas(); const lastRow = sh.getLastRow(); if (lastRow <= 1) { return { totalEntradas: 0, totalMonto: 0, mp: 0, efvo: 0 }; } const vals = sh.getRange( 2, 1, lastRow - 1, 6 ).getValues(); let ent = 0; let monto = 0; let mp = 0; let efvo = 0; for (let r = 0; r < vals.length; r++) { const row = vals[r]; if ( !row[0] && !_num(row[3]) ) { continue; } ent += _num(row[3]); const m = _num(row[4]); monto += m; const met = String( row[5] || '' ).trim(); if (met === MET_MP) { mp += m; } else { efvo += m; } } return { totalEntradas: ent, totalMonto: monto, mp, efvo }; } // ==================================================== // DASHBOARD // ==================================================== function _dashboard() { const cache = _cache(); const cached = cache.get( 'pizza_dashboard' ); if (cached) { try { return JSON.parse(cached); } catch (err) { // Continuar y recalcular. } } const { lista } = _leerPreinscriptos(); let preTotal = 0; let presEnt = 0; let ausEnt = 0; let presPersonas = 0; let ckMP = 0; let ckEfvo = 0; let pendienteTotal = 0; for (const p of lista) { preTotal += p.entradas; if (p.asistio) { presEnt += p.entradas; presPersonas++; if ( p.metodoPuerta === MET_MP ) { ckMP += p.cobradoPuerta; } else { ckEfvo += p.cobradoPuerta; } pendienteTotal += p.pendiente; } else { ausEnt += p.entradas; } } const ventas = _leerVentasPuerta(); const cajaEfvo = ckEfvo + ventas.efvo; const cajaMP = ckMP + ventas.mp; const resultado = { total_entradas_vendidas: preTotal + ventas.totalEntradas, preventa_total: preTotal, preventa_presentes: presEnt, preventa_ausentes: ausEnt, puerta_entradas: ventas.totalEntradas, caja_efectivo: cajaEfvo, caja_mp: cajaMP, caja_total: cajaEfvo + cajaMP, pendiente_cobro: pendienteTotal, personas_total: lista.length, personas_presentes: presPersonas }; cache.put( 'pizza_dashboard', _json(resultado), CACHE_DASHBOARD ); return resultado; } // ==================================================== // BÚSQUEDA // ==================================================== function _search(q) { const nq = _norm(q); if (!nq) { return []; } const version = _cacheVersion(); const key = 'pizza_search_' + version + '_' + nq.substring(0, 80); const cache = _cache(); const cached = cache.get(key); if (cached) { try { return JSON.parse(cached); } catch (err) { // Recalcular. } } const { lista } = _leerPreinscriptos(); const resultados = lista .filter( p =>
_norm(p.nombre)
.includes(nq)
)
.slice(0, 30);

cache.put(
key,
_json(resultados),
CACHE_SEARCH
);

return resultados;
}

// ====================================================
// CHECK-IN
// ====================================================

function _checkin(
rowNum,
valor,
estado,
cobrado,
metodo,
pendiente
) {

rowNum =
Number(rowNum);

if (
!rowNum ||
rowNum < 1 ) { throw new Error( 'Fila inválida.' ); } const lock = LockService .getScriptLock(); lock.waitLock(10000); try { const info = _hojaPre(); const cols = _ensureCols(info); const sh = info.sheet; if (valor) { // Escribimos todas las columnas // en una sola operación. const values = [ 'SÍ', new Date(), estado || '', _num(cobrado), metodo || '', _num(pendiente) ]; const indexes = EXTRA_COLS.map( name => cols[name]
);

// Como EXTRA_COLS son consecutivas,
// podemos escribir directamente el rango.

const firstCol =
Math.min(…indexes) + 1;

sh.getRange(
rowNum,
firstCol,
1,
EXTRA_COLS.length
).setValues([values]);

} else {

const indexes =
EXTRA_COLS.map(
name => cols[name]
);

const firstCol =
Math.min(…indexes) + 1;

sh.getRange(
rowNum,
firstCol,
1,
EXTRA_COLS.length
).clearContent();
}

_invalidateCache();

} finally {

lock.releaseLock();
}

return true;
}

// ====================================================
// VENTA EN PUERTA
// ====================================================

function _venta(
mayores,
menores,
metodo
) {

mayores =
Math.max(
0,
parseInt(mayores, 10) || 0
);

menores =
Math.max(
0,
parseInt(menores, 10) || 0
);

if (
mayores + menores <= 0 ) { throw new Error( 'Tenés que vender al menos una entrada.' ); } const met = metodo === MET_MP ? MET_MP : MET_EFECTIVO; const monto = mayores * PRECIO_MAYOR + menores * PRECIO_MENOR; const lock = LockService .getScriptLock(); lock.waitLock(10000); try { const sh = _hojaVentas(); sh.appendRow([ new Date(), mayores, menores, mayores + menores, monto, met ]); _invalidateCache(); } finally { lock.releaseLock(); } return { mayores, menores, entradas: mayores + menores, monto, metodo: met }; } // ==================================================== // WEB APP // ==================================================== function doGet(e) { return _handle( (e && e.parameter) || {} ); } function doPost(e) { let p = (e && e.parameter) || {}; try { if ( e && e.postData && e.postData.contents ) { const body = JSON.parse( e.postData.contents ); p = Object.assign( {}, p, body ); } } catch (err) { // Si no es JSON válido, // utilizamos los parámetros normales. } return _handle(p); } // ==================================================== // ROUTER // ==================================================== function _handle(p) { const out = ContentService .createTextOutput(); out.setMimeType( ContentService.MimeType.JSON ); try { // Autorización. if ( TOKEN && String(p.token) !== TOKEN ) { throw new Error( 'No autorizado (token incorrecto).' ); } const action = p.action || 'dashboard'; let data; // ---------------------------- // DASHBOARD // ---------------------------- if ( action === 'dashboard' ) { data = _dashboard(); } // ---------------------------- // SEARCH // ---------------------------- else if ( action === 'search' ) { data = { resultados: _search(p.q) }; } // ---------------------------- // CHECK-IN // ---------------------------- else if ( action === 'checkin' ) { const valor = String(p.valor) === '1' || p.valor === true || String(p.valor) === 'true'; _checkin( Number(p.row), valor, p.estado, p.cobrado, p.metodo, p.pendiente ); data = _dashboard(); } // ---------------------------- // VENTA PUERTA // ---------------------------- else if ( action === 'venta' ) { const v = _venta( p.mayores, p.menores, p.metodo ); data = { venta: v, dashboard: _dashboard() }; } // ---------------------------- // ACCIÓN DESCONOCIDA // ---------------------------- else { throw new Error( 'Acción desconocida: ' + action ); } out.setContent( _json({ ok: true, data: data }) ); } catch (err) { out.setContent( _json({ ok: false, error: String( (err && err.message) || err ) }) ); } return out; } // ==================================================== // PRUEBA // ==================================================== function _test() { const resultado = _dashboard(); Logger.log( JSON.stringify( resultado, null, 2 ) ); } // ==================================================== // REINICIAR CONFIGURACIÓN DE HOJA // ==================================================== // // Ejecutar solamente si cambiás la estructura de la // planilla o querés que el sistema vuelva a buscar // la hoja de preinscriptos. // // ==================================================== function _resetConfiguracion() { const props = PropertiesService .getScriptProperties(); props.deleteProperty( PROP_SHEET_ID ); props.deleteProperty( PROP_HEADER_ROW ); props.deleteProperty( 'PIZZA_CACHE_VERSION' ); _cache().remove( 'pizza_dashboard' ); Logger.log( 'Configuración reiniciada correctamente.' ); }