/** ANVOR \u00b7 Google Sheets bridge v6 (compatible con esquema 1). Bound script: Extensions > Apps Script. */ const ANVOR_HEADERS = { articulos: ['Articulo','Nombre','Categoria','Color','Descripcion','Caracteristicas','Material','Medidas','Precio_ARS','Imagen_1','Imagen_2','Imagen_3','Imagen_4','Estado'], inventario: ['Articulo','Unidades_cargadas','Ajuste_acumulado','Reservadas','Vendidas','Disponible'], cupones: ['Cupon','Porcentaje','Habilitado'], venta: ['Id_linea','Nro_pedido','Fecha_hora','Articulo','Cliente','Domicilio','Email','Producto','Unidades','Precio_unitario_ARS','Porcentaje_descuento','Descuento_ARS','Venta_neta_ARS','Cupon','Estado','Telefono'] }; function onOpen() { SpreadsheetApp.getUi().createMenu('ANVOR').addItem('Preparar planilla','prepararAnvor').addToUi(); } function prepararAnvor() { const book = SpreadsheetApp.getActiveSpreadsheet(); if (!book) throw new Error('Ejecut\u00e1 desde una planilla de Google Sheets.'); const props = PropertiesService.getScriptProperties(); if (props.getProperty('SPREADSHEET_ID') && props.getProperty('SPREADSHEET_ID') !== book.getId()) throw new Error('Este script ya est\u00e1 vinculado a otra planilla.'); // Check all existing headers before making any changes. Never overwrite a populated worksheet. Object.keys(ANVOR_HEADERS).forEach(name => { const s=book.getSheetByName(name); if(s && s.getLastRow()) checkHeaders_(s,name); }); props.setProperty('SPREADSHEET_ID',book.getId()); if(!props.getProperty('ANVOR_SECRET')) props.setProperty('ANVOR_SECRET',Utilities.getUuid()+Utilities.getUuid()); book.setSpreadsheetTimeZone('America/Argentina/Buenos_Aires'); Object.keys(ANVOR_HEADERS).forEach(name => { const headers=ANVOR_HEADERS[name],sheet=book.getSheetByName(name)||book.insertSheet(name); if(!sheet.getLastRow()) sheet.getRange(1,1,1,headers.length).setValues([headers]); sheet.setFrozenRows(1);sheet.setRowHeight(1,44); sheet.getRange(1,1,1,headers.length).setBackground('#171717').setFontColor('#ffffff').setFontWeight('bold').setWrap(true); sheet.setColumnWidths(1,headers.length,180); sheet.getRange(2,1,sheet.getMaxRows()-1,headers.length).setFontFamily('Arial').setFontSize(11).setVerticalAlignment('top'); headers.forEach((h,i)=>{const range=sheet.getRange(2,i+1,sheet.getMaxRows()-1,1);if(h.endsWith('_ARS'))range.setNumberFormat('#,##0.00');if(['Articulo','Cupon','Id_linea','Nro_pedido'].includes(h))range.setNumberFormat('@');}); if(!sheet.getFilter())sheet.getRange(1,1,sheet.getMaxRows(),headers.length).createFilter(); }); const articles=book.getSheetByName('articulos'),inventory=book.getSheetByName('inventario'),coupons=book.getSheetByName('cupones'); const list=(sheet,col,values)=>sheet.getRange(2,col,sheet.getMaxRows()-1,1).setDataValidation(SpreadsheetApp.newDataValidation().requireValueInList(values,true).setAllowInvalid(false).build()); list(articles,3,['neceseres','bolsos','mochilas','morrales','portanotebooks']);list(articles,14,['Borrador','Publicado','Oculto']);list(coupons,3,['Si','No']); coupons.getRange(2,2,coupons.getMaxRows()-1,1).setNumberFormat('0').setDataValidation(SpreadsheetApp.newDataValidation().requireNumberBetween(1,100).setAllowInvalid(false).build()); articles.setColumnWidths(5,2,320);articles.setColumnWidths(10,4,280); inventory.getRange(2,2,inventory.getMaxRows()-1,5).setNumberFormat('0'); inventory.getRange(2,4,inventory.getMaxRows()-1,3).setBackground('#ededed'); // Warn on automatic output columns, without changing collaborators or permissions. [inventory.getRange('D:F'),book.getSheetByName('venta').getRange('A:O')].forEach(range=>{const s=range.getSheet();if(!s.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p=>p.getDescription()==='ANVOR: salida autom\u00e1tica'))range.protect().setDescription('ANVOR: salida autom\u00e1tica').setWarningOnly(true);}); SpreadsheetApp.getUi().alert('Planilla preparada. No contiene productos ni ventas de ejemplo. En cupones, escrib\u00ed 10 para un 10%. La clave de conexi\u00f3n est\u00e1 en Configuraci\u00f3n del proyecto > Propiedades del script > ANVOR_SECRET. No la compartas en el chat ni dentro de la planilla.'); } function checkHeaders_(sheet,name) { const expected=ANVOR_HEADERS[name],headers=sheet.getRange(1,1,1,expected.length).getValues()[0]; if(expected.some((h,i)=>h!==headers[i]))throw new Error('Columnas incompatibles en '+name+'. Conserv\u00e1 los encabezados del instalador.'); } function rows_(book,name) { const sheet=book.getSheetByName(name);if(!sheet)throw new Error('Falta hoja '+name);checkHeaders_(sheet,name); if(sheet.getLastRow()<2)return []; if(sheet.getLastRow()>20001)throw new Error('La hoja supera el l\u00edmite de 20000 filas.'); return sheet.getRange(2,1,sheet.getLastRow()-1,ANVOR_HEADERS[name].length).getValues().filter(r=>String(r[0]).trim()).map(r=>Object.fromEntries(ANVOR_HEADERS[name].map((h,i)=>[h,r[i]]))); } function safeCell_(value) {return typeof value==='string'&&/^[\s]*[=+@-]/.test(value)?"'"+value:value??'';} function write_(book,payload,props) { if(!Number.isSafeInteger(payload.revision)||payload.revision<=0)throw new Error('Revisi\u00f3n inv\u00e1lida'); if(payload.revision20000||payload.inventario.length>2000)throw new Error('Salida inv\u00e1lida'); const inventory=book.getSheetByName('inventario'),sales=book.getSheetByName('venta');checkHeaders_(inventory,'inventario');checkHeaders_(sales,'venta'); const existing=rows_(book,'venta'),seen=new Map(existing.map((r,i)=>[String(r.Id_linea),i])); for(const row of payload.venta){if(!row.Id_linea)throw new Error('Falta Id_linea');const key=String(row.Id_linea);if(seen.has(key))existing[seen.get(key)]=row;else{seen.set(key,existing.length);existing.push(row);}} if(existing.length>20000)throw new Error('La hoja venta supera el l\u00edmite de 20000 filas.'); // Replace the machine-owned sales region by unique Id_linea; retries cannot duplicate sales. const previousLastRow=sales.getLastRow(); if(existing.length){if(sales.getMaxRows()ANVOR_HEADERS.venta.map(h=>safeCell_(r[h]))));} if(previousLastRow>existing.length+1)sales.getRange(existing.length+2,1,previousLastRow-existing.length-1,ANVOR_HEADERS.venta.length).clearContent(); const updates=new Map(payload.inventario.map(r=>[String(r.Articulo),r])); if(inventory.getLastRow()>1){const keys=inventory.getRange(2,1,inventory.getLastRow()-1,1).getValues();keys.forEach((r,i)=>{const update=updates.get(String(r[0]));if(!update)return;const row=i+2;inventory.getRange(row,4,1,2).setValues([[update.Reservadas,update.Vendidas]]);inventory.getRange(row,6).setFormula('=B'+row+'+C'+row+'-D'+row+'-E'+row);});} SpreadsheetApp.flush();props.setProperty('LAST_WRITE_REVISION',String(payload.revision)); if(props.getProperty('AVISOS_EMAIL_ACTIVOS')==='1'){enqueueAvisos_(book,payload.venta);try{enviarAvisos_(book);enviarConfirmaciones_(book);}catch(error){console.error('No se complet\u00f3 el env\u00edo de avisos; se revisar\u00e1 en el pr\u00f3ximo ciclo.');}} const notice=avisosSheet_(book);const wanted=new Set(payload.venta.map(r=>String(r.Nro_pedido))); const notifications=notice.getLastRow()>1?notice.getRange(2,1,notice.getLastRow()-1,2).getValues().filter(r=>wanted.has(String(r[0]))).map(r=>({id:String(r[0]),status:String(r[1])})):[]; return {ok:true,notifications}; } function doPost(e) { let lock; try { const raw=e&&e.postData&&e.postData.contents;if(!raw||raw.length>5000000)throw new Error('Solicitud inv\u00e1lida'); const payload=JSON.parse(raw),props=PropertiesService.getScriptProperties(),secret=props.getProperty('ANVOR_SECRET'); if(!secret||secret.length<32||payload.secret!==secret)throw new Error('No autorizado'); if(!['read','write'].includes(payload.action))throw new Error('Acci\u00f3n inv\u00e1lida'); lock=LockService.getScriptLock();if(!lock.tryLock(10000))throw new Error('Planilla ocupada. Reintentar.'); const book=SpreadsheetApp.openById(props.getProperty('SPREADSHEET_ID')); const result=payload.action==='read'?{schemaVersion:1,features:{phoneEmail:props.getProperty('AVISOS_EMAIL_ACTIVOS')==='1'},articulos:rows_(book,'articulos'),inventario:rows_(book,'inventario'),cupones:rows_(book,'cupones')}:write_(book,payload,props); return ContentService.createTextOutput(JSON.stringify(result)).setMimeType(ContentService.MimeType.JSON); } catch(error) {return ContentService.createTextOutput(JSON.stringify({error:String(error.message).slice(0,250)})).setMimeType(ContentService.MimeType.JSON);} finally {if(lock&&lock.hasLock())lock.releaseLock();} } function doGet(){return ContentService.createTextOutput(JSON.stringify({service:'ANVOR',schemaVersion:1})).setMimeType(ContentService.MimeType.JSON);} // One durable queue entry per order, independent from subsequent sale updates. function avisosSheet_(book) { let sheet=book.getSheetByName('avisos_email'); if(!sheet){sheet=book.insertSheet('avisos_email');sheet.getRange(1,1,1,4).setValues([['Nro_pedido','Estado_email','Actualizado','Detalle']]);sheet.setFrozenRows(1);sheet.getRange('A1:D1').setBackground('#171717').setFontColor('#ffffff').setFontWeight('bold');sheet.setColumnWidths(1,4,220);sheet.getRange('A:D').protect().setDescription('ANVOR: avisos autom\u00e1ticos').setWarningOnly(true);} return sheet; } function enqueueAvisos_(book,rows,initialState) { const sheet=avisosSheet_(book),known=new Set(sheet.getLastRow()>1?sheet.getRange(2,1,sheet.getLastRow()-1,1).getValues().flat().map(String):[]),pending=[]; rows.forEach(r=>{const id=String(r.Nro_pedido||'');if(id&&!known.has(id)){known.add(id);pending.push([id,initialState||'Pendiente',new Date().toISOString(),'']);}}); if(pending.length){const start=sheet.getLastRow()+1;if(sheet.getMaxRows()'$ '+Number(n).toFixed(2).replace('.',',')+' ARS'; const total=rows.reduce((n,r)=>n+Math.round(Number(r.Venta_neta_ARS)*100),0)/100; const clean=v=>String(v||'').replace(/[\r\n]+/g,' ').trim(); const subject=(clean(first.Cliente)+' \u2014 '+clean(first.Nro_pedido)).slice(0,240); const body=['Nuevo pedido ANVOR', 'Pedido: '+first.Nro_pedido,'Cliente: '+first.Cliente,'Tel\u00e9fono / WhatsApp: '+first.Telefono,'Email: '+first.Email,'Domicilio: '+first.Domicilio,'','Detalle de la compra:',...rows.map(r=>r.Producto+' | Art\u00edculo: '+r.Articulo+' | Cantidad: '+r.Unidades+' | Precio unitario: '+money(r.Precio_unitario_ARS)+' | Descuento: '+money(r.Descuento_ARS)+' | Importe: '+money(r.Venta_neta_ARS)),'','Monto a pagar por productos: '+money(total),'Cup\u00f3n: '+(first.Cupon||'Sin cup\u00f3n'),'Env\u00edo: se coordina y cotiza por WhatsApp; no est\u00e1 incluido en este total.','Estado: '+first.Estado,'Contact\u00e1 al cliente por WhatsApp para cerrar la venta.'].join('\n'); return {to:'ramilatta18@gmail.com',subject,body,name:'ANVOR \u00b7 Pedidos'}; } function enviarAvisos_(book) { const sales=rows_(book,'venta');enqueueAvisos_(book,sales); const sheet=avisosSheet_(book);if(sheet.getLastRow()<2)return; const queue=sheet.getRange(2,1,sheet.getLastRow()-1,4).getValues();let sent=0; for(let i=0;iString(r.Nro_pedido)===String(id));if(!order.length)continue; if(order[0].Estado==='cancelado'){sheet.getRange(i+2,2,1,3).setValues([['Omitido',new Date().toISOString(),'Pedido cancelado antes del aviso']]);continue;} if(MailApp.getRemainingDailyQuota()<1)break; // A crash after sending cannot be distinguished from a successful delivery. // Persist Enviando first; never automatically resend ambiguous attempts. sheet.getRange(i+2,2,1,3).setValues([['Enviando',new Date().toISOString(),'Si este estado persiste, revis\u00e1 tu correo antes de cambiarlo a Pendiente.']]);SpreadsheetApp.flush(); try{MailApp.sendEmail(orderEmail_(order));sheet.getRange(i+2,2,1,3).setValues([['Enviado',new Date().toISOString(),'']]);sent++;} catch(error){sheet.getRange(i+2,2,1,3).setValues([['Revisar',new Date().toISOString(),String(error.message).slice(0,300)]]);} SpreadsheetApp.flush(); } } function notificarPedidosAnvor() { const lock=LockService.getScriptLock();if(!lock.tryLock(10000))return; try{const props=PropertiesService.getScriptProperties();if(props.getProperty('AVISOS_EMAIL_ACTIVOS')!=='1')return;const book=SpreadsheetApp.openById(props.getProperty('SPREADSHEET_ID'));enviarAvisos_(book);enviarConfirmaciones_(book);}finally{lock.releaseLock();} } function activarAvisosAnvor() { const props=PropertiesService.getScriptProperties(),book=SpreadsheetApp.getActiveSpreadsheet(); if(!book||book.getId()!==props.getProperty('SPREADSHEET_ID'))throw new Error('Abr\u00ed el script desde la planilla ANVOR vinculada.'); MailApp.getRemainingDailyQuota(); // Requests the Google mail permission, without sending a test email. const lock=LockService.getScriptLock();lock.waitLock(10000); try{ const sales=book.getSheetByName('venta'),old=ANVOR_HEADERS.venta.slice(0,15); if(old.some((h,i)=>sales.getRange(1,i+1).getValue()!==h))throw new Error('Los encabezados de venta cambiaron. No se modific\u00f3 la hoja.'); const header=sales.getRange('P1').getValue();if(header&&header!=='Telefono')throw new Error('La columna P est\u00e1 ocupada.'); if(!header){if(sales.getRange(2,16,sales.getMaxRows()-1,1).getValues().some(r=>r[0]!==''))throw new Error('La columna P contiene datos.');sales.getRange('P1').setValue('Telefono');} sales.getRange('O1').copyTo(sales.getRange('P1'),SpreadsheetApp.CopyPasteType.PASTE_FORMAT,false);sales.getRange('P2:P').setNumberFormat('@');sales.setColumnWidth(16,180); if(props.getProperty('AVISOS_EMAIL_ACTIVOS')!=='1')enqueueAvisos_(book,rows_(book,'venta'),'Hist\u00f3rico'); if(!ScriptApp.getProjectTriggers().some(t=>t.getHandlerFunction()==='notificarPedidosAnvor'))ScriptApp.newTrigger('notificarPedidosAnvor').timeBased().everyMinutes(5).create(); if(props.getProperty('CONFIRMACIONES_CLIENTES_ACTIVAS')!=='1')enqueueConfirmaciones_(book,rows_(book,'venta'),'Hist\u00f3rico'); props.setProperty('CONFIRMACIONES_CLIENTES_ACTIVAS','1'); props.setProperty('AVISOS_EMAIL_ACTIVOS','1'); }finally{lock.releaseLock();} console.log('Avisos preparados. Actualizar la implementacion a Nueva version.'); } function customerEmail_(rows){ const first=rows[0],escape=v=>String(v==null?'':v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const money=n=>'$ '+Number(n).toLocaleString('es-AR',{minimumFractionDigits:2,maximumFractionDigits:2}); const total=rows.reduce((sum,r)=>sum+Math.round(Number(r.Venta_neta_ARS)*100),0)/100; const id=String(first.Nro_pedido).replace(/[\r\n]/g,''); const subject='ANVOR - Pedido nro. '+id; const body=['\u00a1Pedido nro. '+id+' confirmado!','','Hola, '+first.Cliente+'. Tus productos quedaron reservados.','',...rows.map(r=>r.Producto+' | Cantidad: '+r.Unidades+' | Precio unitario: '+money(r.Precio_unitario_ARS)+' | Importe: '+money(r.Venta_neta_ARS)),'','Total a abonar por productos: '+money(total)+' ARS','El env\u00edo se coordina y cotiza por separado.','','Nos pondremos en contacto lo antes posible para coordinar el env\u00edo y el pago.','No realizamos ning\u00fan cobro al recibir tu pedido.','','\u00a1Muchas gracias!','ANVOR \u00b7 Elev\u00e1 lo cotidiano.'].join('\n'); const items=rows.map(r=>''+escape(r.Producto)+'
Cantidad: '+escape(r.Unidades)+' \u00b7 Precio unitario: '+escape(money(r.Precio_unitario_ARS))+''+(Number(r.Descuento_ARS)>0?'
Descuento: '+escape(money(r.Descuento_ARS))+'':'')+''+escape(money(r.Venta_neta_ARS))+'').join(''); const htmlBody='
ANVOR

GRACIAS POR ELEGIRNOS

\u00a1Pedido nro. '+escape(id)+' confirmado!

Hola, '+escape(first.Cliente)+'.
Tus productos quedaron reservados.

'+items+'
Total de productos'+escape(money(total))+'

Importes en pesos argentinos. Env\u00edo a coordinar y cotizar por separado.

Nos pondremos en contacto lo antes posible para coordinar el env\u00edo y el pago.

No realizamos ning\u00fan cobro al recibir tu pedido.

\u00a1Muchas gracias!

ANVOR \u00b7 ELEV\u00c1 LO COTIDIANO
'; return {to:String(first.Email).trim(),subject,body,htmlBody,name:'ANVOR',replyTo:'ramilatta18@gmail.com'}; } function confirmacionesSheet_(book){ let sheet=book.getSheetByName('confirmaciones_clientes'); if(!sheet){sheet=book.insertSheet('confirmaciones_clientes');sheet.getRange(1,1,1,4).setValues([['Nro_pedido','Estado_email','Actualizado','Detalle']]);sheet.setFrozenRows(1);sheet.getRange('A1:D1').setBackground('#171717').setFontColor('#ffffff').setFontWeight('bold');sheet.setColumnWidths(1,4,220);sheet.getRange('A:D').protect().setDescription('ANVOR: confirmaciones autom\u00e1ticas').setWarningOnly(true);} return sheet; } function enqueueConfirmaciones_(book,rows,initialState){ const sheet=confirmacionesSheet_(book),known=new Set(sheet.getLastRow()>1?sheet.getRange(2,1,sheet.getLastRow()-1,1).getValues().flat().map(String):[]),pending=[]; rows.forEach(r=>{const id=String(r.Nro_pedido||'');if(id&&!known.has(id)){known.add(id);pending.push([id,initialState||'Pendiente',new Date().toISOString(),'']);}}); if(pending.length){const start=sheet.getLastRow()+1;if(sheet.getMaxRows()String(r.Nro_pedido)===String(id));if(!order.length)continue; if(order[0].Estado==='cancelado'){sheet.getRange(i+2,2,1,3).setValues([['Omitido',new Date().toISOString(),'Pedido cancelado']]);continue;} if(MailApp.getRemainingDailyQuota()<1)break; sheet.getRange(i+2,2,1,3).setValues([['Enviando',new Date().toISOString(),'Verificar entrega antes de reintentar.']]);SpreadsheetApp.flush(); try{const message=customerEmail_(order);if(!/^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(message.to))throw new Error('Email del cliente inv\u00e1lido');MailApp.sendEmail(message);sheet.getRange(i+2,2,1,3).setValues([['Enviado',new Date().toISOString(),'']]);sent++;} catch(error){sheet.getRange(i+2,2,1,3).setValues([['Revisar',new Date().toISOString(),String(error.message).slice(0,300)]]);} SpreadsheetApp.flush(); } }