first commit
This commit is contained in:
@@ -0,0 +1,977 @@
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
Reference in New Issue
Block a user