978 lines
32 KiB
JavaScript
978 lines
32 KiB
JavaScript
/**
|
|
* Cotizador Walmart Connect · Google Workspace Worker
|
|
* GomezLee Marketing · 2026
|
|
*
|
|
* Versión corregida para Google Apps Script V8.
|
|
* - NO usa variables de entorno.
|
|
* - NO usa Script Properties.
|
|
* - Gemini NO se llama desde Apps Script.
|
|
* - Las imágenes llegan desde los nodos nativos de Google Gemini en n8n.
|
|
*/
|
|
|
|
const WMC_CONFIG = {
|
|
GOOGLE_AUTOMATION_SECRET: 'mwI_n-1r4_-QTLp1-zIt-zQKh_HqaoGoVhyapyLN2T2-r7xf8iP5AC3v9wJt5Av6',
|
|
OUTPUT_FOLDER_ID: '19EmZsJ7PzWCaFzeij9AZ3-mkPctNyK0G',
|
|
WALMART_TEMPLATE_ID: '17f1MB1uXQiQk4jn4_MPQixpBRwCpwDMZTsRmWA9UMOA',
|
|
GLM_MONTHLY_TEMPLATE_ID: '1RL9BckErp1QCLdxlM6riBRh9iO4f14ezf6fUgizBZJw',
|
|
|
|
// Confirmado en los ejemplos GLM enviados (Guatemala y Costa Rica).
|
|
GLM_VENDOR_NUMBER: '1603060802',
|
|
GLM_IVA_RATE: 0.12
|
|
};
|
|
|
|
const WMC = {
|
|
NAVY: '#041E42',
|
|
GREEN: '#6CC24A',
|
|
BLUE: '#4F758B',
|
|
TEXT: '#4A4A4A',
|
|
LIGHT: '#F5F5F5'
|
|
};
|
|
|
|
/**
|
|
* Health check sencillo: al abrir la URL /exec en el navegador debe devolver JSON.
|
|
*/
|
|
function doGet() {
|
|
return json_({
|
|
ok: true,
|
|
service: 'Cotizador WMC · Google Workspace Worker',
|
|
version: '2026-08-23.3'
|
|
});
|
|
}
|
|
|
|
function doPost(e) {
|
|
try {
|
|
const body = JSON.parse((e && e.postData && e.postData.contents) || '{}');
|
|
requireSecret_(body.secret);
|
|
|
|
const action = String(body.action || '');
|
|
let result;
|
|
|
|
if (action === 'generate_quote') {
|
|
result = generateWalmartQuote_(body);
|
|
} else if (action === 'generate_monthly_glm') {
|
|
result = generateMonthlyGlm_(body);
|
|
} else if (action === 'generate_proposal') {
|
|
result = generateProposal_(body);
|
|
} else if (action === 'generate_image') {
|
|
result = generateImageArtifact_(body);
|
|
} else {
|
|
result = { ok: true, skipped: true, message: 'Acción sin artefacto.' };
|
|
}
|
|
|
|
return json_(result);
|
|
} catch (err) {
|
|
console.error(err && err.stack ? err.stack : err);
|
|
return json_({
|
|
ok: false,
|
|
error: String(err && err.message ? err.message : err)
|
|
});
|
|
}
|
|
}
|
|
|
|
function requireSecret_(value) {
|
|
if (!WMC_CONFIG.GOOGLE_AUTOMATION_SECRET || String(value || '') !== WMC_CONFIG.GOOGLE_AUTOMATION_SECRET) {
|
|
throw new Error('Solicitud no autorizada.');
|
|
}
|
|
}
|
|
|
|
function json_(obj) {
|
|
return ContentService
|
|
.createTextOutput(JSON.stringify(obj))
|
|
.setMimeType(ContentService.MimeType.JSON);
|
|
}
|
|
|
|
function safeName_(value, fallback) {
|
|
const s = String(value || fallback || 'WMC')
|
|
.replace(/[\\/:*?"<>|#%{}\[\]]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return s.slice(0, 160) || String(fallback || 'WMC');
|
|
}
|
|
|
|
function outputFolder_() {
|
|
return DriveApp.getFolderById(WMC_CONFIG.OUTPUT_FOLDER_ID);
|
|
}
|
|
|
|
function copyTemplate_(templateId, name) {
|
|
const file = DriveApp.getFileById(templateId);
|
|
return file.makeCopy(safeName_(name, 'WMC'), outputFolder_());
|
|
}
|
|
|
|
function norm_(v) {
|
|
return String(v || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function findCell_(sheet, variants) {
|
|
const values = sheet.getDataRange().getDisplayValues();
|
|
const wanted = variants.map(function(v) { return norm_(v); });
|
|
|
|
for (let r = 0; r < values.length; r++) {
|
|
for (let c = 0; c < values[r].length; c++) {
|
|
const n = norm_(values[r][c]);
|
|
for (let i = 0; i < wanted.length; i++) {
|
|
const w = wanted[i];
|
|
if (n === w || n.indexOf(w + ':') === 0 || n.indexOf(w) >= 0) {
|
|
return { row: r + 1, col: c + 1, text: values[r][c] };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findRow_(sheet, variants) {
|
|
const c = findCell_(sheet, variants);
|
|
return c ? c.row : null;
|
|
}
|
|
|
|
function setRightOfLabel_(sheet, variants, value) {
|
|
if (value === undefined || value === null || value === '') return false;
|
|
const cell = findCell_(sheet, variants);
|
|
if (!cell) return false;
|
|
sheet.getRange(cell.row, Math.min(cell.col + 1, sheet.getMaxColumns())).setValue(value);
|
|
return true;
|
|
}
|
|
|
|
function visibleFirstSheet_(ss) {
|
|
const sheets = ss.getSheets();
|
|
for (let i = 0; i < sheets.length; i++) {
|
|
if (!sheets[i].isSheetHidden()) return sheets[i];
|
|
}
|
|
return sheets[0];
|
|
}
|
|
|
|
function findExactTextRowInColumnAfter_(sheet, col, text, afterRow, beforeRow) {
|
|
const start = Math.max(1, Number(afterRow || 1));
|
|
const end = Math.min(sheet.getMaxRows(), Number(beforeRow || sheet.getMaxRows()));
|
|
if (end < start) return null;
|
|
|
|
const values = sheet.getRange(start, col, end - start + 1, 1).getDisplayValues();
|
|
const wanted = norm_(text);
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
if (norm_(values[i][0]) === wanted) return start + i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function firstNumber_() {
|
|
for (let i = 0; i < arguments.length; i++) {
|
|
const v = arguments[i];
|
|
if (v === '' || v === null || v === undefined) continue;
|
|
const n = Number(v);
|
|
if (Number.isFinite(n)) return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function numOrBlank_(v) {
|
|
if (v === '' || v === null || v === undefined) return '';
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : '';
|
|
}
|
|
|
|
function countryCurrencyCode_(code) {
|
|
const map = { GT: 'GTQ', CR: 'CRC', SV: 'USD', HN: 'HNL', NI: 'NIO' };
|
|
return map[String(code || '').toUpperCase()] || '';
|
|
}
|
|
|
|
function countryCurrencySymbol_(code) {
|
|
const map = { GT: 'Q', CR: '₡', SV: '$', HN: 'L', NI: 'C$' };
|
|
return map[String(code || '').toUpperCase()] || '';
|
|
}
|
|
|
|
// ============================================================================
|
|
// NORMALIZACIÓN FINANCIERA DEFENSIVA
|
|
// ============================================================================
|
|
|
|
function money_(value) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return 0;
|
|
return Math.round((n + Number.EPSILON) * 100) / 100;
|
|
}
|
|
|
|
function isFeeSection_(section) {
|
|
return norm_(section).indexOf('fee') >= 0;
|
|
}
|
|
|
|
function normalizeWalmartQuoteFinancials_(input) {
|
|
const q = JSON.parse(JSON.stringify(input || {}));
|
|
const rateRaw = Number(q.exchangeRate !== undefined ? q.exchangeRate : q.exchange_rate);
|
|
const rate = Number.isFinite(rateRaw) && rateRaw > 0 ? rateRaw : 1;
|
|
const items = Array.isArray(q.items) ? q.items : [];
|
|
|
|
let subtotalUsd = 0;
|
|
let explicitFeeUsd = 0;
|
|
|
|
q.items = items.map(function(raw) {
|
|
const it = raw && typeof raw === 'object' ? raw : {};
|
|
const units = firstNumber_(it.units, it.quantity, 0);
|
|
const days = (it.days === '' || it.days === null || it.days === undefined)
|
|
? 1
|
|
: firstNumber_(it.days, 1);
|
|
const unitUsd = firstNumber_(it.unitCostUsd, it.unit_cost_usd, 0);
|
|
const suppliedTotal = firstNumber_(it.totalUsd, it.total_usd, 0);
|
|
const computedTotal = money_(Number(units || 0) * Number(days || 0) * Number(unitUsd || 0));
|
|
const totalUsd = Number(unitUsd || 0) > 0 && Number(units || 0) > 0
|
|
? computedTotal
|
|
: money_(suppliedTotal || 0);
|
|
|
|
const normalized = Object.assign({}, it, {
|
|
units: Number(units || 0),
|
|
days: Number(days || 0),
|
|
unitCostUsd: money_(unitUsd || 0),
|
|
totalUsd: totalUsd,
|
|
unitCostLocal: money_(Number(unitUsd || 0) * rate),
|
|
totalLocal: money_(totalUsd * rate)
|
|
});
|
|
|
|
if (isFeeSection_(it.section)) explicitFeeUsd += totalUsd;
|
|
else subtotalUsd += totalUsd;
|
|
|
|
return normalized;
|
|
});
|
|
|
|
subtotalUsd = money_(subtotalUsd);
|
|
let feeRate = firstNumber_(q.agencyFeeRate, q.feeRate);
|
|
if (feeRate === null && explicitFeeUsd > 0 && subtotalUsd > 0) feeRate = explicitFeeUsd / subtotalUsd;
|
|
if (feeRate === null || !Number.isFinite(feeRate) || feeRate < 0) feeRate = 0.06;
|
|
if (feeRate > 1) feeRate = feeRate / 100;
|
|
|
|
const feeUsd = money_(subtotalUsd * feeRate);
|
|
q.exchangeRate = rate;
|
|
q.agencyFeeRate = feeRate;
|
|
q.subtotalUsd = subtotalUsd;
|
|
q.subtotalLocal = money_(subtotalUsd * rate);
|
|
q.agencyFeeUsd = feeUsd;
|
|
q.agencyFeeLocal = money_(feeUsd * rate);
|
|
q.totalUsd = money_(subtotalUsd + feeUsd);
|
|
q.totalLocal = money_(q.totalUsd * rate);
|
|
|
|
return q;
|
|
}
|
|
|
|
function readWalmartGrandTotals_(sheet, q) {
|
|
const totalCell = findCell_(sheet, ['Total con todos los rubros', 'Total general']);
|
|
if (!totalCell) {
|
|
return {
|
|
totalUsd: money_(q.totalUsd || 0),
|
|
totalLocal: money_(q.totalLocal || 0)
|
|
};
|
|
}
|
|
|
|
const row = totalCell.row;
|
|
const usd = Number(sheet.getRange(row, 5).getValue());
|
|
const local = Number(sheet.getRange(row, 7).getValue());
|
|
return {
|
|
totalUsd: Number.isFinite(usd) ? money_(usd) : money_(q.totalUsd || 0),
|
|
totalLocal: Number.isFinite(local) ? money_(local) : money_(q.totalLocal || 0)
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// 1) COTIZACIÓN OFICIAL WALMART CONNECT
|
|
// ============================================================================
|
|
|
|
function generateWalmartQuote_(body) {
|
|
const q = normalizeWalmartQuoteFinancials_(body.quote || {});
|
|
if (!q.countryCode || !Array.isArray(q.items) || !q.items.length) {
|
|
throw new Error('La cotización no contiene país/items suficientes.');
|
|
}
|
|
|
|
const stamp = Utilities.formatDate(
|
|
new Date(),
|
|
Session.getScriptTimeZone() || 'America/Guatemala',
|
|
'yyyyMMdd-HHmm'
|
|
);
|
|
|
|
const name = safeName_(
|
|
'WMC_' + (q.project || q.advertiser || 'Cotizacion') + '_' + q.countryCode + '_' + stamp,
|
|
'WMC_Cotizacion'
|
|
);
|
|
|
|
const file = copyTemplate_(WMC_CONFIG.WALMART_TEMPLATE_ID, name);
|
|
const ss = SpreadsheetApp.openById(file.getId());
|
|
const sheet = ss.getSheetByName('Solicitud Cotizaciones') || visibleFirstSheet_(ss);
|
|
|
|
setRightOfLabel_(sheet, ['Brand Solution'], q.brandSolution || 'WMC');
|
|
setRightOfLabel_(sheet, ['País', 'Pais'], q.country || q.countryCode);
|
|
setRightOfLabel_(sheet, ['Agencia'], 'GomezLee Marketing S.A.');
|
|
setRightOfLabel_(sheet, ['Proyecto'], q.project || '');
|
|
setRightOfLabel_(sheet, ['Anunciante'], q.advertiser || '');
|
|
setRightOfLabel_(sheet, ['Formato'], q.format || '');
|
|
setRightOfLabel_(sheet, ['Tipo de cambio'], Number(q.exchangeRate || 1));
|
|
|
|
const sectionDefs = [
|
|
{ key: 'Personal', aliases: ['Personal'] },
|
|
{ key: 'Mobiliario - Equipo', aliases: ['Mobiliario - Equipo', 'Mobiliario y Equipo', 'Mobiliario'] },
|
|
{ key: 'Insumos de degustación y/o preparación', aliases: ['Insumos de degustación y/o preparación', 'Insumos de degustación', 'Insumos'] },
|
|
{ key: 'Logística e Instalación', aliases: ['Logística e Instalación', 'Logistica e Instalación', 'Logistica e Instalacion', 'Logística', 'Logistica'] }
|
|
];
|
|
|
|
const grouped = {};
|
|
for (let i = 0; i < q.items.length; i++) {
|
|
const it = q.items[i] || {};
|
|
const section = String(it.section || 'Otros');
|
|
if (!grouped[section]) grouped[section] = [];
|
|
grouped[section].push(it);
|
|
}
|
|
|
|
// De abajo hacia arriba para que las inserciones no desplacen secciones todavía no procesadas.
|
|
const results = [];
|
|
for (let i = sectionDefs.length - 1; i >= 0; i--) {
|
|
const def = sectionDefs[i];
|
|
const items = getItemsForSection_(q.items, def.aliases);
|
|
results.push(fillWalmartSection_(sheet, def.aliases, items));
|
|
}
|
|
|
|
// Reordenamos para cálculos posteriores.
|
|
const sectionTotals = [];
|
|
for (let i = 0; i < sectionDefs.length; i++) {
|
|
const headerRow = findRow_(sheet, sectionDefs[i].aliases);
|
|
if (!headerRow) continue;
|
|
const totalRow = findExactTextRowInColumnAfter_(sheet, 1, 'Total', headerRow + 1);
|
|
if (totalRow) sectionTotals.push(totalRow);
|
|
}
|
|
|
|
configureWalmartFee_(sheet, q, sectionTotals);
|
|
configureWalmartGrandTotal_(sheet, sectionTotals);
|
|
|
|
setRightOfLabel_(sheet, ['Vigencia de la cotización', 'Vigencia'], q.validity || '30 días');
|
|
setRightOfLabel_(
|
|
sheet,
|
|
['Observaciones o exclusiones', 'Observaciones'],
|
|
Array.isArray(q.observations) ? q.observations.join('\n') : (q.observations || '')
|
|
);
|
|
|
|
insertVisualReferences_(sheet, q.visualReferences || []);
|
|
|
|
// Forzamos el recálculo del machote y guardamos en la trazabilidad los mismos
|
|
// totales finales que ve el usuario en la hoja (incluyendo Fee de agencia).
|
|
SpreadsheetApp.flush();
|
|
const finalTotals = readWalmartGrandTotals_(sheet, q);
|
|
q.totalUsd = finalTotals.totalUsd;
|
|
q.totalLocal = finalTotals.totalLocal;
|
|
|
|
let data = ss.getSheetByName('_WMC_DATA');
|
|
if (!data) data = ss.insertSheet('_WMC_DATA');
|
|
data.clear();
|
|
data.getRange('A1:B8').setValues([
|
|
['generatedAt', new Date()],
|
|
['countryCode', q.countryCode],
|
|
['currencyCode', q.currencyCode || countryCurrencyCode_(q.countryCode)],
|
|
['exchangeRate', Number(q.exchangeRate || 1)],
|
|
['totalUsd', Number(q.totalUsd || 0)],
|
|
['totalLocal', Number(q.totalLocal || 0)],
|
|
['conversationId', body.conversationId || ''],
|
|
['quoteJson', JSON.stringify(q)]
|
|
]);
|
|
data.hideSheet();
|
|
|
|
try {
|
|
sheet.setName(safeName_('Cotización ' + (q.format || '') + ' ' + q.countryCode, 'Cotización').slice(0, 99));
|
|
} catch (ignore) {}
|
|
|
|
SpreadsheetApp.flush();
|
|
|
|
return {
|
|
ok: true,
|
|
artifactType: 'quote',
|
|
artifactId: file.getId(),
|
|
artifactUrl: ss.getUrl(),
|
|
title: name,
|
|
provider: 'Google Sheets'
|
|
};
|
|
}
|
|
|
|
function getItemsForSection_(items, aliases) {
|
|
const wanted = aliases.map(function(v) { return norm_(v); });
|
|
const out = [];
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const it = items[i] || {};
|
|
const section = norm_(it.section || '');
|
|
let match = false;
|
|
|
|
for (let j = 0; j < wanted.length; j++) {
|
|
if (section === wanted[j] || section.indexOf(wanted[j]) >= 0 || wanted[j].indexOf(section) >= 0) {
|
|
match = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (match) out.push(it);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function fillWalmartSection_(sheet, aliases, items) {
|
|
const headerRow = findRow_(sheet, aliases);
|
|
if (!headerRow) return { headerRow: null, totalRow: null };
|
|
|
|
let totalRow = findExactTextRowInColumnAfter_(sheet, 1, 'Total', headerRow + 1);
|
|
if (!totalRow) throw new Error('No se encontró la fila Total para la sección ' + aliases[0] + '.');
|
|
|
|
const start = headerRow + 1;
|
|
let slots = totalRow - start;
|
|
const needed = Math.max(1, items.length);
|
|
|
|
if (needed > slots) {
|
|
const extra = needed - slots;
|
|
sheet.insertRowsBefore(totalRow, extra);
|
|
|
|
if (slots > 0) {
|
|
sheet.getRange(start, 1, 1, 7).copyTo(
|
|
sheet.getRange(totalRow, 1, extra, 7),
|
|
SpreadsheetApp.CopyPasteType.PASTE_FORMAT,
|
|
false
|
|
);
|
|
}
|
|
|
|
totalRow += extra;
|
|
slots += extra;
|
|
}
|
|
|
|
// Limpia únicamente las filas de detalle; nunca toca la fila Total.
|
|
if (slots > 0) sheet.getRange(start, 1, slots, 7).clearContent();
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const row = start + i;
|
|
const it = items[i] || {};
|
|
|
|
sheet.getRange(row, 1, 1, 4).setValues([[
|
|
it.description || 'Item',
|
|
numOrBlank_(it.units),
|
|
numOrBlank_(it.days),
|
|
numOrBlank_(it.unitCostUsd)
|
|
]]);
|
|
|
|
sheet.getRange(row, 5).setFormula('=B' + row + '*C' + row + '*D' + row);
|
|
sheet.getRange(row, 6).setFormula('=D' + row + '*$B$11');
|
|
sheet.getRange(row, 7).setFormula('=E' + row + '*$B$11');
|
|
}
|
|
|
|
// Mantiene fórmulas neutras en espacios libres para que el subtotal siga siendo confiable.
|
|
for (let r = start + items.length; r < totalRow; r++) {
|
|
sheet.getRange(r, 5).setFormula('=B' + r + '*C' + r + '*D' + r);
|
|
sheet.getRange(r, 6).setFormula('=D' + r + '*$B$11');
|
|
sheet.getRange(r, 7).setFormula('=E' + r + '*$B$11');
|
|
}
|
|
|
|
const end = totalRow - 1;
|
|
sheet.getRange(totalRow, 4).setFormula('=SUM(D' + start + ':D' + end + ')');
|
|
sheet.getRange(totalRow, 5).setFormula('=SUM(E' + start + ':E' + end + ')');
|
|
sheet.getRange(totalRow, 6).setFormula('=SUM(F' + start + ':F' + end + ')');
|
|
sheet.getRange(totalRow, 7).setFormula('=SUM(G' + start + ':G' + end + ')');
|
|
|
|
sheet.getRange(start, 4, Math.max(1, slots), 2).setNumberFormat('$#,##0.00;[Red]-$#,##0.00');
|
|
sheet.getRange(start, 6, Math.max(1, slots), 2).setNumberFormat('#,##0.00;[Red]-#,##0.00');
|
|
|
|
return { headerRow: headerRow, totalRow: totalRow };
|
|
}
|
|
|
|
function configureWalmartFee_(sheet, q, sectionTotalRows) {
|
|
const feeHeader = findRow_(sheet, ['Fee de agencia', 'Fee Agencia', 'Fee']);
|
|
if (!feeHeader) return;
|
|
|
|
const feeCalcRow = feeHeader + 1;
|
|
let feeRate = firstNumber_(q.agencyFeeRate, q.feeRate);
|
|
|
|
const feeItems = getItemsForSection_(q.items || [], ['Fee de agencia', 'Fee Agencia', 'Fee']);
|
|
if (feeRate === null && feeItems.length) {
|
|
const baseUsd = sumItemTotals_(q.items || [], false);
|
|
const feeUsd = sumItemTotals_(feeItems, true);
|
|
if (baseUsd > 0 && feeUsd >= 0) feeRate = feeUsd / baseUsd;
|
|
}
|
|
|
|
// Si Gemini no suministra un fee explícito, conserva el 6% del machote oficial.
|
|
if (feeRate === null || !Number.isFinite(feeRate) || feeRate < 0) {
|
|
const current = Number(sheet.getRange(feeHeader, 4).getValue());
|
|
feeRate = Number.isFinite(current) && current >= 0 ? current : 0.06;
|
|
}
|
|
|
|
if (feeRate > 1) feeRate = feeRate / 100;
|
|
|
|
sheet.getRange(feeHeader, 4).setValue(feeRate).setNumberFormat('0.00%');
|
|
sheet.getRange(feeHeader, 6).setValue(feeRate).setNumberFormat('0.00%');
|
|
|
|
const dRefs = sectionTotalRows.map(function(r) { return 'D' + r; });
|
|
const eRefs = sectionTotalRows.map(function(r) { return 'E' + r; });
|
|
const fRefs = sectionTotalRows.map(function(r) { return 'F' + r; });
|
|
const gRefs = sectionTotalRows.map(function(r) { return 'G' + r; });
|
|
|
|
sheet.getRange(feeCalcRow, 4).setFormula('=(' + dRefs.join('+') + ')*D' + feeHeader);
|
|
sheet.getRange(feeCalcRow, 5).setFormula('=(' + eRefs.join('+') + ')*D' + feeHeader);
|
|
sheet.getRange(feeCalcRow, 6).setFormula('=(' + fRefs.join('+') + ')*F' + feeHeader);
|
|
sheet.getRange(feeCalcRow, 7).setFormula('=(' + gRefs.join('+') + ')*F' + feeHeader);
|
|
}
|
|
|
|
function configureWalmartGrandTotal_(sheet, sectionTotalRows) {
|
|
const totalCell = findCell_(sheet, ['Total con todos los rubros', 'Total general']);
|
|
if (!totalCell) return;
|
|
|
|
const grandRow = totalCell.row;
|
|
const feeHeader = findRow_(sheet, ['Fee de agencia', 'Fee Agencia', 'Fee']);
|
|
const feeCalcRow = feeHeader ? feeHeader + 1 : null;
|
|
|
|
const cols = [4, 5, 6, 7];
|
|
for (let i = 0; i < cols.length; i++) {
|
|
const col = cols[i];
|
|
const letter = columnLetter_(col);
|
|
const refs = sectionTotalRows.map(function(r) { return letter + r; });
|
|
if (feeCalcRow) refs.push(letter + feeCalcRow);
|
|
sheet.getRange(grandRow, col).setFormula('=' + refs.join('+'));
|
|
}
|
|
}
|
|
|
|
function sumItemTotals_(items, includeAll) {
|
|
let total = 0;
|
|
for (let i = 0; i < items.length; i++) {
|
|
const it = items[i] || {};
|
|
if (!includeAll && norm_(it.section || '').indexOf('fee') >= 0) continue;
|
|
const n = firstNumber_(it.totalUsd, it.total_usd);
|
|
if (n !== null) total += n;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function columnLetter_(col) {
|
|
let n = Number(col);
|
|
let s = '';
|
|
while (n > 0) {
|
|
const rem = (n - 1) % 26;
|
|
s = String.fromCharCode(65 + rem) + s;
|
|
n = Math.floor((n - 1) / 26);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 2) REFERENCIAS VISUALES
|
|
// ============================================================================
|
|
|
|
function insertVisualReferences_(sheet, refs) {
|
|
if (!Array.isArray(refs) || !refs.length) return;
|
|
|
|
const row = findRow_(sheet, ['Referencias visuales', 'Referencia visual']);
|
|
if (!row) return;
|
|
|
|
let col = 1;
|
|
refs.slice(0, 6).forEach(function(ref) {
|
|
try {
|
|
const blob = visualBlob_(ref);
|
|
if (!blob) return;
|
|
|
|
const img = sheet.insertImage(blob, col, row + 1);
|
|
const maxW = 360;
|
|
const maxH = 260;
|
|
const scale = Math.min(1, maxW / img.getWidth(), maxH / img.getHeight());
|
|
|
|
img.setWidth(Math.round(img.getWidth() * scale));
|
|
img.setHeight(Math.round(img.getHeight() * scale));
|
|
col = Math.min(col + 4, Math.max(1, sheet.getMaxColumns() - 1));
|
|
} catch (err) {
|
|
console.warn('No se pudo insertar referencia visual: ' + err);
|
|
}
|
|
});
|
|
}
|
|
|
|
function driveIdFromUrl_(url) {
|
|
const text = String(url || '');
|
|
let m = text.match(/\/d\/([a-zA-Z0-9_-]{15,})/);
|
|
if (m) return m[1];
|
|
m = text.match(/[?&]id=([a-zA-Z0-9_-]{15,})/);
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
function visualBlob_(ref) {
|
|
const obj = ref && typeof ref === 'object' ? ref : {};
|
|
const directId = String(obj.imageDriveId || obj.driveId || obj.fileId || '').trim();
|
|
if (directId) return DriveApp.getFileById(directId).getBlob();
|
|
|
|
const url = typeof ref === 'string' ? ref : String(obj.url || obj.imageUrl || '').trim();
|
|
if (!url) return null;
|
|
|
|
const driveId = driveIdFromUrl_(url);
|
|
if (driveId) return DriveApp.getFileById(driveId).getBlob();
|
|
|
|
const response = UrlFetchApp.fetch(url, {
|
|
muteHttpExceptions: true,
|
|
followRedirects: true
|
|
});
|
|
|
|
if (response.getResponseCode() < 200 || response.getResponseCode() >= 300) {
|
|
throw new Error('No se pudo descargar la referencia visual. HTTP ' + response.getResponseCode());
|
|
}
|
|
return response.getBlob();
|
|
}
|
|
|
|
// ============================================================================
|
|
// 3) MENSUALIZACIÓN INTERNA GLM
|
|
// ============================================================================
|
|
|
|
function generateMonthlyGlm_(body) {
|
|
const q = body.quote || {};
|
|
const a = body.artifact || {};
|
|
const metadata = a.metadata && typeof a.metadata === 'object' ? a.metadata : {};
|
|
|
|
const stamp = Utilities.formatDate(
|
|
new Date(),
|
|
Session.getScriptTimeZone() || 'America/Guatemala',
|
|
'yyyyMMdd-HHmm'
|
|
);
|
|
|
|
const name = safeName_(
|
|
'GLM-' + (q.project || a.title || 'WMC') + '_' + (q.countryCode || '') + '_' + stamp,
|
|
'GLM-WMC'
|
|
);
|
|
|
|
const file = copyTemplate_(WMC_CONFIG.GLM_MONTHLY_TEMPLATE_ID, name);
|
|
const ss = SpreadsheetApp.openById(file.getId());
|
|
const sheet = ss.getSheetByName('Cotización') || visibleFirstSheet_(ss);
|
|
|
|
const country = q.country || q.countryName || q.countryCode || '';
|
|
const countryCode = String(q.countryCode || '').toUpperCase();
|
|
const advertiser = q.advertiser || a.client || '';
|
|
const project = q.project || a.project || q.format || 'Cotización Walmart Connect';
|
|
const contact = a.attention || metadata.attention || q.contactName || q.contact_name || '';
|
|
const email = a.email || metadata.email || q.contactEmail || q.contact_email || (body.user ? body.user.email : '') || '';
|
|
const currencySymbol = a.currencySymbol || metadata.currencySymbol || q.currencySymbol || countryCurrencySymbol_(countryCode);
|
|
const vendor = a.vendorNumber || metadata.vendorNumber || WMC_CONFIG.GLM_VENDOR_NUMBER;
|
|
const quoteCode = a.quoteCode || metadata.quoteCode || q.quoteCode || q.quote_code || '';
|
|
|
|
// El formato GLM real guarda estos valores dentro de la misma celda A6:A12.
|
|
sheet.getRange('A6').setValue('País: ' + country);
|
|
sheet.getRange('E6').setValue(quoteCode);
|
|
sheet.getRange('A7').setValue('Empresa: Walmart Connect');
|
|
sheet.getRange('A8').setValue('Asunto: ' + project);
|
|
sheet.getRange('A9').setValue('Atención:' + contact);
|
|
sheet.getRange('A10').setValue('Correo:' + email);
|
|
sheet.getRange('A11').setValue('Moneda: ' + currencySymbol);
|
|
sheet.getRange('A12').setValue('Vendor #: ' + vendor);
|
|
|
|
const summaryTitle = a.summaryTitle || metadata.summaryTitle || ('Walmart Connect-' + (advertiser || project));
|
|
sheet.getRange('A14').setValue(summaryTitle);
|
|
|
|
const monthlyDate = parseDateOrNow_(a.monthlyDate || metadata.monthlyDate || a.date || metadata.date);
|
|
sheet.getRange('A15').setValue(monthlyDate).setNumberFormat('mmmm yyyy');
|
|
sheet.getRange('B17').setValue(country);
|
|
|
|
const lines = Array.isArray(a.monthlyLines) && a.monthlyLines.length
|
|
? a.monthlyLines
|
|
: (Array.isArray(q.items) ? q.items : []);
|
|
|
|
const activity = fillGlmActivityLines_(sheet, lines, q, a, metadata);
|
|
refreshGlmBudgetSummary_(sheet, activity);
|
|
|
|
let data = ss.getSheetByName('_WMC_MONTHLY_DATA');
|
|
if (!data) data = ss.insertSheet('_WMC_MONTHLY_DATA');
|
|
data.clear();
|
|
data.getRange('A1:B7').setValues([
|
|
['generatedAt', new Date()],
|
|
['countryCode', countryCode],
|
|
['currency', currencySymbol],
|
|
['sourceQuoteId', a.sourceQuoteId || ''],
|
|
['conversationId', body.conversationId || ''],
|
|
['quoteJson', JSON.stringify(q)],
|
|
['artifactJson', JSON.stringify(a)]
|
|
]);
|
|
data.hideSheet();
|
|
|
|
SpreadsheetApp.flush();
|
|
|
|
return {
|
|
ok: true,
|
|
artifactType: 'monthly_glm',
|
|
artifactId: file.getId(),
|
|
artifactUrl: ss.getUrl(),
|
|
title: name,
|
|
provider: 'Google Sheets'
|
|
};
|
|
}
|
|
|
|
function fillGlmActivityLines_(sheet, lines, quote, artifact, metadata) {
|
|
const header = findCell_(sheet, ['DESCRIPCIÓN', 'Descripcion', 'Descripción']);
|
|
if (!header) throw new Error('No se encontró el encabezado DESCRIPCIÓN en la plantilla GLM.');
|
|
|
|
const start = header.row + 1;
|
|
let totalRow = findExactTextRowInColumnAfter_(sheet, 4, 'Total', start);
|
|
if (!totalRow) throw new Error('No se encontró la primera fila Total del bloque de actividades GLM.');
|
|
|
|
let slots = totalRow - start;
|
|
const needed = Math.max(1, lines.length);
|
|
|
|
if (needed > slots) {
|
|
const extra = needed - slots;
|
|
sheet.insertRowsBefore(totalRow, extra);
|
|
|
|
if (slots > 0) {
|
|
sheet.getRange(start, 1, 1, 5).copyTo(
|
|
sheet.getRange(totalRow, 1, extra, 5),
|
|
SpreadsheetApp.CopyPasteType.PASTE_FORMAT,
|
|
false
|
|
);
|
|
}
|
|
|
|
totalRow += extra;
|
|
slots += extra;
|
|
}
|
|
|
|
if (slots > 0) sheet.getRange(start, 1, slots, 5).clearContent();
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const row = start + i;
|
|
const it = lines[i] || {};
|
|
const qty = firstNumber_(it.units, it.quantity, 1);
|
|
const days = firstNumber_(it.days, 1);
|
|
const localUnit = firstNumber_(
|
|
it.unitCostLocal,
|
|
it.unit_cost_local,
|
|
firstNumber_(it.unitCostUsd, it.unit_cost_usd, 0) * firstNumber_(quote.exchangeRate, quote.exchange_rate, 1)
|
|
);
|
|
|
|
sheet.getRange(row, 1, 1, 4).setValues([[
|
|
it.description || it.concept || it.item_name || 'Item',
|
|
qty,
|
|
days,
|
|
localUnit
|
|
]]);
|
|
sheet.getRange(row, 5).setFormula('=D' + row + '*B' + row + '*C' + row);
|
|
}
|
|
|
|
const end = totalRow - 1;
|
|
sheet.getRange(totalRow, 4).setValue('Total ');
|
|
sheet.getRange(totalRow, 5).setFormula('=SUM(E' + start + ':E' + end + ')');
|
|
|
|
const taxRow = totalRow + 1;
|
|
const grandRow = totalRow + 2;
|
|
let taxRate = firstNumber_(artifact.taxRate, metadata.taxRate, WMC_CONFIG.GLM_IVA_RATE);
|
|
if (taxRate > 1) taxRate = taxRate / 100;
|
|
|
|
sheet.getRange(taxRow, 4).setValue('IVA ');
|
|
sheet.getRange(taxRow, 5).setFormula('=E' + totalRow + '*' + taxRate);
|
|
sheet.getRange(grandRow, 4).setValue('Total ');
|
|
sheet.getRange(grandRow, 5).setFormula('=E' + totalRow + '+E' + taxRow);
|
|
|
|
sheet.getRange(start, 4, Math.max(1, slots), 2).setNumberFormat('#,##0.00;[Red]-#,##0.00');
|
|
sheet.getRange(totalRow, 5, 3, 1).setNumberFormat('#,##0.00;[Red]-#,##0.00');
|
|
|
|
return {
|
|
startRow: start,
|
|
endRow: end,
|
|
totalRow: totalRow,
|
|
taxRow: taxRow,
|
|
grandRow: grandRow
|
|
};
|
|
}
|
|
|
|
function refreshGlmBudgetSummary_(sheet, activity) {
|
|
const pmCell = findCell_(sheet, ['Project Management']);
|
|
let pmTotalRow = null;
|
|
|
|
if (pmCell) {
|
|
// En el machote GLM real, el total de Project Management es el último Total antes de Presupuesto.
|
|
const presupuestoCell = findCell_(sheet, ['Presupuesto']);
|
|
const before = presupuestoCell ? presupuestoCell.row - 1 : sheet.getMaxRows();
|
|
|
|
for (let r = before; r > pmCell.row; r--) {
|
|
const d = norm_(sheet.getRange(r, 4).getDisplayValue());
|
|
if (d === 'total') {
|
|
pmTotalRow = r;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sinFee = findCell_(sheet, ['Sub-Total-1 Presupuesto actividades SIN FEE']);
|
|
const conFee = findCell_(sheet, ['Sub-Total-1 Presupuesto actividades CON FEE']);
|
|
const totalActividad = findCell_(sheet, ['Total De Actividad', 'Total de Actividad']);
|
|
|
|
if (sinFee) {
|
|
if (pmTotalRow) sheet.getRange(sinFee.row, 2).setFormula('=E' + pmTotalRow);
|
|
else sheet.getRange(sinFee.row, 2).setValue(0);
|
|
}
|
|
|
|
if (conFee) sheet.getRange(conFee.row, 2).setFormula('=E' + activity.grandRow);
|
|
|
|
if (totalActividad) {
|
|
const refs = [];
|
|
if (sinFee) refs.push('B' + sinFee.row);
|
|
if (conFee) refs.push('B' + conFee.row);
|
|
if (refs.length) sheet.getRange(totalActividad.row, 2).setFormula('=' + refs.join('+'));
|
|
}
|
|
}
|
|
|
|
function parseDateOrNow_(value) {
|
|
if (value instanceof Date && !isNaN(value.getTime())) return value;
|
|
if (value) {
|
|
const d = new Date(value);
|
|
if (!isNaN(d.getTime())) return d;
|
|
}
|
|
return new Date();
|
|
}
|
|
|
|
// ============================================================================
|
|
// 4) GOOGLE SLIDES
|
|
// ============================================================================
|
|
|
|
function generateProposal_(body) {
|
|
const a = body.artifact || {};
|
|
const q = body.quote || {};
|
|
const title = safeName_(a.title || q.project || 'Propuesta Walmart Connect', 'Propuesta Walmart Connect');
|
|
|
|
const pres = SlidesApp.create(title);
|
|
const file = DriveApp.getFileById(pres.getId());
|
|
file.moveTo(outputFolder_());
|
|
|
|
const slides = Array.isArray(a.slides) && a.slides.length
|
|
? a.slides
|
|
: [
|
|
{ type: 'cover', title: title, subtitle: 'Walmart Connect' },
|
|
{ type: 'thankyou', title: 'Thank you!' }
|
|
];
|
|
|
|
// Primero agrega las nuevas diapositivas y al final elimina la inicial automática.
|
|
const initialSlide = pres.getSlides().length ? pres.getSlides()[0] : null;
|
|
for (let i = 0; i < slides.length; i++) {
|
|
addProposalSlide_(pres, slides[i], body, i);
|
|
}
|
|
if (initialSlide && pres.getSlides().length > 1) initialSlide.remove();
|
|
|
|
return {
|
|
ok: true,
|
|
artifactType: 'proposal',
|
|
artifactId: pres.getId(),
|
|
artifactUrl: pres.getUrl(),
|
|
title: title,
|
|
provider: 'Google Slides'
|
|
};
|
|
}
|
|
|
|
function addProposalSlide_(pres, spec, body, index) {
|
|
const type = String(spec.type || 'content');
|
|
const slide = pres.appendSlide(SlidesApp.PredefinedLayout.BLANK);
|
|
const pageW = pres.getPageWidth();
|
|
const pageH = pres.getPageHeight();
|
|
const dark = type === 'cover' || type === 'section' || type === 'thankyou';
|
|
|
|
slide.getBackground().setSolidFill(dark ? WMC.NAVY : '#FFFFFF');
|
|
|
|
const titleText = String(spec.title || (index === 0 ? (body.title || 'Walmart Connect') : ''));
|
|
const titleBox = slide.insertTextBox(
|
|
titleText,
|
|
dark ? 48 : 44,
|
|
dark ? pageH * 0.38 : 28,
|
|
pageW - 96,
|
|
dark ? 90 : 48
|
|
);
|
|
|
|
titleBox.getText().getTextStyle()
|
|
.setFontFamily('Arial')
|
|
.setFontSize(dark ? 28 : 22)
|
|
.setBold(true)
|
|
.setForegroundColor(dark ? '#FFFFFF' : WMC.NAVY);
|
|
|
|
titleBox.getText().getParagraphStyle().setParagraphAlignment(
|
|
dark ? SlidesApp.ParagraphAlignment.CENTER : SlidesApp.ParagraphAlignment.START
|
|
);
|
|
|
|
const sub = String(spec.subtitle || spec.body || '');
|
|
if (sub) {
|
|
const subBox = slide.insertTextBox(
|
|
sub,
|
|
52,
|
|
dark ? pageH * 0.57 : 82,
|
|
pageW - 104,
|
|
70
|
|
);
|
|
|
|
subBox.getText().getTextStyle()
|
|
.setFontFamily('Arial')
|
|
.setFontSize(13)
|
|
.setForegroundColor(dark ? '#FFFFFF' : WMC.TEXT);
|
|
|
|
if (dark) {
|
|
subBox.getText().getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.CENTER);
|
|
}
|
|
}
|
|
|
|
if (!dark) {
|
|
try {
|
|
const blob = visualBlob_({
|
|
imageDriveId: spec.imageDriveId || '',
|
|
imageUrl: spec.imageUrl || '',
|
|
url: spec.imageUrl || ''
|
|
});
|
|
|
|
if (blob) {
|
|
const img = slide.insertImage(blob);
|
|
const maxW = pageW - 96;
|
|
const maxH = pageH - 185;
|
|
const scale = Math.min(maxW / img.getWidth(), maxH / img.getHeight());
|
|
const finalW = img.getWidth() * scale;
|
|
const finalH = img.getHeight() * scale;
|
|
|
|
img.setWidth(finalW)
|
|
.setHeight(finalH)
|
|
.setLeft((pageW - finalW) / 2)
|
|
.setTop(150);
|
|
}
|
|
} catch (err) {
|
|
console.warn('No se pudo insertar imagen en Slides: ' + err);
|
|
}
|
|
}
|
|
|
|
const footer = slide.insertTextBox(
|
|
'PROPRIETARY & CONFIDENTIAL · Walmart Connect / GomezLee Marketing',
|
|
30,
|
|
pageH - 26,
|
|
pageW - 60,
|
|
14
|
|
);
|
|
|
|
footer.getText().getTextStyle()
|
|
.setFontFamily('Arial')
|
|
.setFontSize(7)
|
|
.setForegroundColor(dark ? '#D9E2F3' : '#777777');
|
|
}
|
|
|
|
// ============================================================================
|
|
// 5) IMAGEN YA GENERADA POR GEMINI EN N8N
|
|
// ============================================================================
|
|
|
|
function generateImageArtifact_(body) {
|
|
const a = body.artifact || {};
|
|
const driveId = String(a.imageDriveId || a.driveId || a.fileId || '').trim();
|
|
|
|
if (driveId) {
|
|
const file = DriveApp.getFileById(driveId);
|
|
return {
|
|
ok: true,
|
|
artifactType: 'image',
|
|
artifactId: file.getId(),
|
|
artifactUrl: file.getUrl(),
|
|
title: a.title || file.getName() || 'Visual WMC',
|
|
provider: 'Google Gemini (n8n) + Google Drive'
|
|
};
|
|
}
|
|
|
|
const url = String(a.imageUrl || a.url || '').trim();
|
|
if (url) {
|
|
return {
|
|
ok: true,
|
|
artifactType: 'image',
|
|
artifactId: null,
|
|
artifactUrl: url,
|
|
title: a.title || 'Visual WMC',
|
|
provider: 'Google Gemini (n8n)'
|
|
};
|
|
}
|
|
|
|
throw new Error('Las imágenes se generan en n8n con el nodo nativo Google Gemini; no se recibió imageDriveId/imageUrl.');
|
|
}
|