/**
* ============================================================
* 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.'
);
}