// =================================================================== // CONSTANTS // =================================================================== const MAXIMUM_SLOTS = 20; const MAXIMUM_CRON_JOB = 10; // =================================================================== // GENERAL // =================================================================== /** * * Extract parameter mapping from string * * @param {String} pstr parameters in string expect format like `var1=1,var2=2,debug` * @returns Map */ function getParamsMapFromString(pstr) { var pmap = new Map(); let psplt = pstr.split(","); for (ps of psplt) { let comp = ps.split("="); if (comp.length > 1) { pmap.set(comp[0], comp[1]); } else if (pmap.has("others")) { pmap["others"].append(comp); } else { pmap["others"] = [comp]; } } return pmap; } // =================================================================== // PROFILE PRICE // =================================================================== class RecipePriceProfile { constructor(productCode, name, nameTH, price) { this.productCode = productCode; this.name = name; this.nameTH = nameTH; this.price = price; } /** * * @param {Function} fn callback function for price * @returns int */ test(fn) { return fn(this.price); } /** * Fill empty into array so that its size is matched the expected length * @param {Array} arr incompleted array * @param {int} expect_len expected length of array * @returns Array */ fill_until_length(arr, expect_len) { // Log.debug(`arr dbg: ${JSON.stringify(arr)}`); if (arr.length < expect_len) { for (let m = arr.length; m < expect_len; m++) { arr.push(" "); } } return arr; } /** * Build data into expected format * @param {int} index index of callback, this does modify the price. If expected index did not exist, the price is not modified. * @param {Function} then_fn modify function to price after created template. * @returns Array */ build_slot(index, then_fn) { let debug = index == 1 && this.productCode.includes("12-02-03-0039"); let ret = [ this.productCode, this.name, this.nameTH, " ", this.test(getSlotFunctionByIndex(index)), ]; if (debug) Log.debug(`build_slot${index}: ${JSON.stringify(ret)}`); if (then_fn != undefined) { ret = then_fn(ret); if (debug) Log.debug(`build_slot${index}.then: ${JSON.stringify(ret)}`); } ret = this.fill_until_length(ret, 12); if (debug) Log.debug(`build_slot${index}.fill: ${JSON.stringify(ret)}`); return ret; } /** * Describe the values mapping * @returns Object */ describe() { return { productCode: this.productCode, name: this.name, nameTH: this.nameTH, price: this.price, }; } } /** * Generate array of price slot sheets up to `MAXIMUM_SLOTS` * @returns Array */ function getPriceSlotNames() { let prefix_slot = "PriceSlot"; let price_names = []; for (let i = 1; i <= MAXIMUM_SLOTS; i++) { let curr_name = prefix_slot + i.toString(); price_names.push(curr_name); } return price_names; } /** * Get price modifying functions by index. * * `-1` = roundup * * `1-20` = price slot callback if expected slot function is implemented * * If a function by index is not implemented or unsupported, this will return its unmodified price * * `Updated: 8/4/25` * * @param {int} index index of callback * @returns Function(int) */ function getSlotFunctionByIndex(index) { switch (index) { case -1: return (pack) => { if (typeof pack[4] == "string") return pack; pack[4] = Math.round(pack[4]); let test10 = pack[4] % 10; if (test10 == 5 || test10 == 0) { return pack; } else { if (test10 < 5) { let diff = 5 - test10; pack[4] = pack[4] + diff; return pack; } else if (test10 > 5) { let diff = 10 - test10; pack[4] = pack[4] + diff; return pack; } } }; // ------------------------------------- // SLOT FUNCTIONS // ------------------------------------- // increase 15% case 1: return (price) => price * 1.15; // increase 25% case 2: return (price) => price * 1.25; // increase 30% case 3: return (price) => price * 1.3; // increase 35% case 4: return (price) => price * 1.35; // increase 45% case 5: return (price) => price * 1.45; case 6: return (price) => price - 5; case 8: return (price) => "HIDE"; case 9: return (price) => price * 0.60; default: return (price) => price; } } // ===================================================================== // EXPORT PROFILE PRICE // ===================================================================== const ProfilePrice = { RecipePriceProfile: RecipePriceProfile, getPriceSlotNames: getPriceSlotNames, getSlotFunctionByIndex: getSlotFunctionByIndex, // infos version: 3, version_name: "ObiShou-PP", changelogs: [ "2/4/25 initialized version 2, fix timeout on trigger", "8/4/25 initialized version 3, express server", ], maximum_slots: MAXIMUM_SLOTS, }; // ===================================================== // LOGGER // ===================================================== const { createLogger, format, transports } = require("winston"); require("winston-daily-rotate-file"); const file_rotate_transport = new transports.DailyRotateFile({ filename: "log-%DATE%.log", datePattern: "YYYY-MM-DD", zippedArchive: true, maxSize: "20m", maxFiles: "14d", dirname: "logs", format: format.combine( format.colorize({ all: false, }), format.timestamp({ format: "DD-MM-YYYY HH:mm:ss" }), format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`), ), }); /** * common logger instance */ const logger = createLogger({ level: "debug", format: format.combine( format.colorize(), format.timestamp({ format: "DD-MM-YYYY HH:mm:ss" }), format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`), ), transports: [file_rotate_transport, new transports.Console()], }); /** * `Log` reference to created instance, * use this for logging */ const Log = { info: (msg) => logger.info(msg), debug: (msg) => logger.debug(msg), warn: (msg) => logger.warn(msg), err: (msg) => logger.error(msg), }; // ====================================================================== // CONCURRENT // ====================================================================== async function runTasksConcurrent(concurrent_tasks, ok, error) { return await Promise.all(concurrent_tasks) .then( (result) => { return ok(result); }, (reject) => { return error(reject); }, ) .catch((err) => { return error(err); }); } const Concurrent = { runTasks: runTasksConcurrent, }; // ====================================================================== // SAVE TO FILE // ====================================================================== const fs = require("fs"); function saveJsonToFile(filename, content) { let formatted = JSON.stringify(content, null, 2); fs.writeFile(filename, formatted, "utf8", (err) => { if (err) { Log.err(`save json error: ${err.stack}`); } else { Log.info(`save ${filename}:${formatted.length}`); } }); } // ====================================================================== // GOOGLE // ====================================================================== const { google, sheets_v4 } = require("googleapis"); function authorize() { const oauthClient = new google.auth.GoogleAuth({ credentials: { client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/gm, "\n"), }, scopes: ["https://www.googleapis.com/auth/spreadsheets"], }); return oauthClient; } function getCountrySpreadSheetId(cnt) { switch (cnt) { case "tha": return process.env.TB_THA_SHEET; case "mys": return process.env.TB_MYS_SHEET; case "sgp": return process.env.TB_SGP_SHEET; case "uae": return process.env.TB_UAE_SHEET; default: return process.env.TEST_SHEET_ID; } } const GoogleFunctions = { auth: authorize, SpreadSheets: (auth) => google.sheets({ version: "v4", auth }), GetCountrySpreadSheet: getCountrySpreadSheetById, getAllSheetNamesByCountry: getAllSheetNamesByCountry, getCountrySheetByName: getCountrySheetByName, getPriceSlotValues: getPriceSlotValues, syncProfilePrice: syncProfilePrice, }; async function getTestSpreadSheet(sheet) { const test = await sheet.spreadsheets.get({ spreadsheetId: process.env.TEST_SHEET_ID, }); Log.debug(`test spreadsheet: ${test.status}`); } async function getCountrySpreadSheetById(sheet, country, callback) { const result = await sheet.spreadsheets.get({ spreadsheetId: getCountrySpreadSheetId(country), }); Log.debug(`try get ${country} --> ${result.status}`); if (callback != undefined) { return await callback(result); } return result; } async function getAllSheetNamesByCountry(sheet, country_short, callback) { const sheets_mapping = await GoogleFunctions.GetCountrySpreadSheet( sheet, country_short, (sheetDetail) => callback(sheetDetail), ).then((ok) => { Log.debug(`ok! ${JSON.stringify(ok)}`); return ok; }); return sheets_mapping; } async function getCountrySheetByName( sheet, country_short, sheet_name, callback, ) { const values = await sheet.spreadsheets.values .get({ spreadsheetId: getCountrySpreadSheetId(country_short), range: sheet_name, }) .then((res) => { Log.debug(`length of each line: ${res.data.values[0].length}`); return res.data.values; }); if (callback != undefined) { return callback(values); } return values; } async function getPriceSlotValues(sheet, country_short) { // Warning: large operation let expected_sheets = getPriceSlotNames(); let result = {}; // add source let price_sheet_value = await getCountrySheetByName( sheet, country_short, "price", ); let price_aot_sheet_value = await getCountrySheetByName( sheet, country_short, "TaobinAOTPrice2", ); if (price_sheet_value != undefined) { result["price"] = price_sheet_value; } if (price_aot_sheet_value != undefined) { result["TaobinAOTPrice2"] = price_aot_sheet_value; } for (let _sheet of expected_sheets) { try { let sheet_value = await getCountrySheetByName( sheet, country_short, _sheet, ); if (sheet_value != undefined) { result[_sheet] = sheet_value; Log.debug(`${_sheet} ok!`); } } catch (err) { Log.err(`error while fetching price slots: ${err}`); } } Log.debug(`price slots: ${Object.keys(result)}`); return result; } // REFERENCE // // const {google} = require('googleapis'); // const sheets = google.sheets({version: 'v4'}); // const auth = new google.auth.JWT (credentials.client_email, null, credentials.private_key, ['https://www.googleapis.com/auth/spreadsheets']); // function updateGoogleSheet (spreadsheetId) { // /* Written by Amit Agarwal */ // /* Web: ctrlq.org Email: amit@labnol.org */ // var data = [ // { // range: "Sheet1!A1", // Update single cell // values: [ // ["A1"] // ] // }, // { // range: "Sheet1!B1:B3", // Update a column // values: [ // ["B1"],["B2"],["B3"] // ] // }, // { // range: "Sheet1!C1:E1", // Update a row // values: [ // ["C1","D1","E1"] // ] // }, // { // range: "Sheet1!F1:H2", // Update a 2d range // values: [ // ["F1", "F2"], // ["H1", "H2"] // ] // }]; // var resource = { // spreadsheetId: spreadsheetId, // auth: auth, // valueInputOption: "USER_ENTERED", // data: data // }; // sheets.spreadsheets.values.batchUpdate (resource); // } function numberToColumn(n) { let result = ""; while (n > 0) { // Adjust by subtracting 1 to account for Excel's 1-based indexing n--; // Get remainder that maps directly to a letter (0 -> A, 1 -> B, ..., 25 -> Z) let remainder = n % 26; // Convert remainder to letter (65 is the char code for 'A') result = String.fromCharCode(65 + remainder) + result; // Move to the next digit in the base-26 system n = Math.floor(n / 26); } return result; } class PriceSlotStatus { constructor(name, changed, added) { this.name = name; this.changed = changed; this.added = added; this.gen_changed = {}; this.gen_added = {}; } createUpdateDataFormat(source_last_row_index) { let data_for_update = []; let data_for_append = []; let change_fn = (c) => { let range_name = `${this.name}!`; let first = c.column[0]; let updated = []; if (c.column.length == 1) { range_name += `${first}${c.index}`; updated.push(c.row[c.change_col[0]]); } else if (c.column.length > 1) { let last = c.column[c.column.length - 1]; range_name += `${first}${c.index}:${last}${c.index}`; let change_idx_first = c.change_col[0]; let change_idx_last = c.change_col[c.change_col.length - 1]; for (let ci = change_idx_first; ci <= change_idx_last; ci++) { updated.push(c.row[ci]); } } return { range: range_name, values: [updated], }; }; this.changed.forEach((cl) => { let change_value = change_fn(cl); data_for_update.push(change_value); }); this.added.forEach((al) => data_for_append.push(al.row)); this.gen_changed = { result: data_for_update, }; this.gen_added = { last_idx: source_last_row_index, result: data_for_append, }; // Log.debug(`gen_changed: ${JSON.stringify(this.gen_changed)}`); // Log.debug(`gen_added: ${JSON.stringify(this.gen_added)}`); } } function from_price_process_result(price_slot_map) { return new PriceSlotStatus( price_slot_map.name, price_slot_map.status.changed, price_slot_map.status.added, ); } /** * Find differences between two 2D arrays * @param {Array} array1 * @param {Array} array2 * @param {Function} isCategoryRowFn * @returns */ function diff2DArraysCustom(array1, array2, isCategoryRowFn) { const differences = []; // Use provided function or default category detection const categoryCheck = isCategoryRowFn || function (row) { if (!row || row.length !== 2) return false; return (!row[0] || row[0] === "") && row[1] && row[1] !== ""; }; // Helper function to find similar row in array function findSimilarRow(targetRow, searchArray, startIndex = 0) { for (let i = startIndex; i < searchArray.length; i++) { if (categoryCheck(searchArray[i])) continue; // First element must match exactly if (targetRow[0] !== searchArray[i][0]) continue; // Calculate similarity for remaining columns const maxCols = Math.max(targetRow.length, searchArray[i].length); let matches = 1; // First column already matches for (let j = 1; j < maxCols; j++) { if (targetRow[j] === searchArray[i][j]) { matches++; } } // Consider it similar if more than 50% of columns match (including the required first column) if (matches / maxCols > 0.5) { return i; } } return -1; } // Helper function to check if a row is empty function isEmptyRow(row) { if (!row || row.length === 0) return true; // Check if all elements are empty, null, undefined, or whitespace return row.every( (cell) => !cell || (typeof cell === "string" && cell.trim() === ""), ); } let i = 0, j = 0; while (i < array1.length || j < array2.length) { const row1 = array1[i] || null; const row2 = array2[j] || null; Log.debug(`Comparing rows ${i} and ${j} --- \n${row1} vs ${row2}`); // Skip category rows if (row1 && (categoryCheck(row1) || isEmptyRow(row1))) { i++; continue; } if (row2 && (categoryCheck(row2) || isEmptyRow(row2))) { j++; continue; } // End of first array - remaining rows in second array are added if (i >= array1.length) { differences.push({ type: "added", rowIndex: j, originalIndex: i, row: row2, description: `Row added at position ${j}`, }); j++; continue; } // End of second array - remaining rows in first array are removed if (j >= array2.length) { // differences.push({ // type: "removed", // rowIndex: i, // originalIndex: i, // row: row1, // description: `Row removed from position ${i}`, // }); i++; continue; } // Compare current rows const maxCols = Math.max(row1.length, row2.length); const rowDifferences = []; for (let k = 0; k < maxCols; k++) { const val1 = row1[k]; const val2 = row2[k]; if (val1 !== val2) { rowDifferences.push({ columnIndex: k, oldValue: val1, newValue: val2, }); } } // If rows are identical, move both pointers if (rowDifferences.length === 0) { i++; j++; continue; } // Check if this might be an insert/delete scenario // Look ahead to see if row1 appears later in array2 const row1FoundLater = findSimilarRow(row1, array2, j + 1); // Look ahead to see if row2 appears later in array1 const row2FoundLater = findSimilarRow(row2, array1, i + 1); if (row2FoundLater !== -1 && row1FoundLater === -1) { // row2 is inserted - row1 appears later in array1 differences.push({ type: "inserted", rowIndex: j, originalIndex: i, row: row2, description: `Row inserted at position ${j}`, }); j++; } else if (row1FoundLater !== -1 && row2FoundLater === -1) { // row1 is deleted - row2 appears later in array2 // differences.push({ // type: "removed", // rowIndex: i, // originalIndex: i, // row: row1, // description: `Row removed from position ${i}`, // }); i++; } else { // Rows are different - this is a modification // differences.push({ // type: "modified", // rowIndex: i, // originalIndex: i, // changes: rowDifferences, // description: `Row ${i} has ${rowDifferences.length} difference(s)`, // }); i++; j++; } } return differences; } /** * Update price task, compare new updated values modified from function with current value * * 17/4/25 - update for additional parameters * * @param {String} _sheet sheet name * @param {Object} curr_sheet_from_price_slot_map current values of sheet `_sheet` * @param {Object} price_slot_mapping_split_by_modified initialized map to stored result of sheet `_sheet` with * @param {Object} all_price_sheets all sheet new prices, where values are updated * @param {CallableFunction} searchProductCodeRow search function (to find product code on another sheet) * @returns `{ changed: [], added: [] }` */ function createPriceUpdateProcessTask( _sheet, curr_sheet_from_price_slot_map, price_slot_mapping_split_by_modified, all_price_sheets, searchProductCodeRow, ) { // check source and update const getSourceAndUpdate = (current_from_all_price) => { let params = current_from_all_price[0][8]; Log.debug( `checking if param config: ${JSON.stringify(current_from_all_price[0])}`, ); if (params) { let pmap = getParamsMapFromString(params); return pmap; } return undefined; }; return new Promise((resolve, reject) => { var current_price_slot_sheet = all_price_sheets[_sheet]; var pconfig = getSourceAndUpdate(current_price_slot_sheet); price_slot_mapping_split_by_modified[_sheet] = { changed: [], added: [], }; let source = _sheet == "PriceSlot8" ? current_price_slot_sheet : curr_sheet_from_price_slot_map; // let source = curr_sheet_from_price_slot_map; let update, notExistInSource; let indexOfUpdate = 0; let indexOfSource = 0; // for any sheet with "source=" and "update=" if (pconfig) { let skipByParam = false; // check reject by skip param if (pconfig.has("skip")) { let skip_val = pconfig.get("skip"); Log.debug(`Skip by param --> ${skip_val}`); if (skip_val == "true") { notExistInSource = undefined; source = []; Log.warn(`Skip at sheet ${_sheet}`); skipByParam = true; } } if (!skipByParam) { source = all_price_sheets[pconfig.get("source")]; update = all_price_sheets[pconfig.get("update")]; indexOfSource = pconfig.get("targetSource"); indexOfUpdate = pconfig.get("targetUpdate"); Log.debug( `Found config from sheet, source: [${indexOfSource}], update: [${indexOfUpdate}], `, ); // add new product code into source const sourcePdOnly = source .filter((x) => x[indexOfSource] && x[indexOfSource].includes("-")) .map((x) => x[indexOfSource]); const updatePdOnly = update .filter((x) => x[indexOfUpdate] && x[indexOfUpdate].includes("-")) .map((x) => x[indexOfUpdate]); const currentPdOnly = current_price_slot_sheet .filter((x) => x[0] && x[0].includes("-")) .map((x) => x[0]); // notExistInSource = updatePdOnly.filter( (x) => !sourcePdOnly.includes(x) && !currentPdOnly.includes(x), ); Log.debug( `Need append ${_sheet} : ${JSON.stringify(notExistInSource)}`, ); } } // =========================================================== // NEED REWORK NOW, V2 // =========================================================== for (let [index, row] of Object(source).entries()) { if (row[indexOfSource] == undefined) { continue; } let search_result = searchProductCodeRow(row[0], _sheet, pconfig); // if (_sheet == "PriceSlot8") { // Log.debug( // `searchResult ${row[indexOfSource]}: ${search_result} ${JSON.stringify(pconfig)} ${search_result !== undefined} must_true: ${row[indexOfSource].includes("-")}`, // ); // } if (row[indexOfSource].includes("-") && search_result == undefined) { let payload = { index: index + 2, row: row, }; if (_sheet != "PriceSlot8") { Log.debug( `${_sheet} -> Payload Appended for ${JSON.stringify(payload)}`, ); price_slot_mapping_split_by_modified[_sheet].added.push(payload); } else { payload.row[payload.row.length - 1] = "HIDE"; price_slot_mapping_split_by_modified[_sheet].added.push(payload); // Log.debug(`not found ${JSON.stringify(payload)}`); // p8hide += 1; } } } // Update loop // clone first let current_price_slot_in_google_sheet = current_price_slot_sheet; let updated_from_generated = source.toReversed(); // NOTE: PriceSlot8 needs extra processes while (updated_from_generated.length > 0 && _sheet != "PriceSlot8") { let current_updated = updated_from_generated.pop(); if (current_updated == undefined) { continue; } let saved_index = -1; let saved_current_shown; // check by current for ( let i = 0; i < current_price_slot_in_google_sheet.length && current_updated; i++ ) { // same pd first then name similarity let has_same_pd = current_updated[0] == current_price_slot_in_google_sheet[i][0]; let has_same_name = current_updated[1] == current_price_slot_in_google_sheet[i][1]; if (has_same_pd && has_same_name) { // current_price_slot_in_google_sheet[i] = current_updated; // updated_from_generated.push(current_updated); saved_index = i; saved_current_shown = current_price_slot_in_google_sheet[i]; break; } else if (has_same_pd) { // current_price_slot_in_google_sheet[i] = current_updated; // updated_from_generated.push(current_updated); saved_index = i; saved_current_shown = current_price_slot_in_google_sheet[i]; break; } } if (saved_index > -1 && saved_current_shown && current_updated) { let need_update_column = []; let change_col = []; // diff now for ( let i = 0; i < saved_current_shown.length && current_updated; i++ ) { if ( saved_current_shown[i] == undefined || current_updated[i] == undefined ) { continue; } if ( saved_current_shown[i].toString() !== current_updated[i].toString() && _sheet != "PriceSlot8" ) { need_update_column.push(numberToColumn(i + 1)); change_col.push(i); } else if ( _sheet == "PriceSlot8" && i == 4 && saved_current_shown[i].toString() !== current_updated[i].toString() ) { need_update_column.push(numberToColumn(i + 1)); change_col.push(i); } } // Build an update payload if (need_update_column.length > 0) { let payload = { index: saved_index + 1, row: current_updated, old_row: saved_current_shown, column: need_update_column, change_col: change_col, }; if (_sheet == "PriceSlot8") { payload.row[4] = payload.old_row[4]; } Log.debug( `${_sheet} -> Payload Updated for ${_sheet}: ${JSON.stringify(payload)}`, ); price_slot_mapping_split_by_modified[_sheet].changed.push(payload); } } } Log.debug( `check length source.${_sheet} ${source.length} by_all ${all_price_sheets[_sheet].length}`, ); // =========================================================== // WIP DO NOT TOUCH // =========================================================== if (update && notExistInSource && notExistInSource.length > 0) { Log.debug( `Has new update by config params, new pd = ${notExistInSource.length}`, ); for (let pd of notExistInSource) { let latest = price_slot_mapping_split_by_modified[_sheet].added[ price_slot_mapping_split_by_modified[_sheet].added.length - 1 ]; // Log.debug(JSON.stringify(price_slot_mapping_split_by_modified[_sheet])); Log.debug( `get latest for ${pd} ${_sheet}--> ${JSON.stringify(latest)}`, ); let latest_idx = latest.index; let latest_row = update.find( (x) => x[indexOfUpdate] && x[indexOfUpdate].includes(pd), ); if (_sheet == "PriceSlot8") { latest_row[latest_row.length - 1] = "HIDE"; } let payload = { index: latest_idx + 2, row: latest_row, }; Log.debug( `${_sheet} -> Payload Appended for ${JSON.stringify(payload)}`, ); price_slot_mapping_split_by_modified[_sheet].added.push(payload); } } resolve({ name: _sheet, status: price_slot_mapping_split_by_modified[_sheet], }); }); } /** * * Sync price from sheet `price` to other `PriceSlot` sheets. * * For debugging, set `is_test_mode` to `undefined` (for checking value process) * or `true` for setting values to test sheet. Otherwise, set to `false`. * * @param {sheets_v4.Sheets} sheet sheet instance that already authed * @param {String} country_short country short string * @param {Boolean} is_test_mode toggle output to test sheet */ async function syncProfilePrice(sheet, country_short, is_test_mode) { var all_price_sheets = await getPriceSlotValues(sheet, country_short); var source_price_sheet = all_price_sheets["price"]; var price_slot_map = new Map(); const sheetKeys = Object.keys(all_price_sheets); sheetKeys.forEach((name) => { price_slot_map[name] = []; }); let unique_pd = new Set(); for (let row of source_price_sheet) { let isHeaderRow = (rw) => rw[1].includes("Name") && rw[5].includes("Price"); if (row[0] != undefined && row[0].toString().includes("-")) { let curr = new RecipePriceProfile(row[0], row[1], row[2], row[4]); // Log.debug(`Processing recipe: ${JSON.stringify(curr.describe())}`); if (!unique_pd.has(row[0])) { unique_pd.add(row[0]); } for (let i = 1; i < MAXIMUM_SLOTS + 1; i++) { let current_price_slot = `PriceSlot${i}`; let built_slot_result = curr.build_slot(i, getSlotFunctionByIndex(-1)); if (!sheetKeys.includes(current_price_slot)) { continue; } // add header if length is 0 if ( isHeaderRow(all_price_sheets[current_price_slot][0]) && price_slot_map[current_price_slot].size == 0 ) { price_slot_map[current_price_slot].push( all_price_sheets[current_price_slot][0], ); } else { price_slot_map[current_price_slot].push(built_slot_result); } } } } const getSourceSheet = (sheetName, config) => { if (config) return all_price_sheets[config.source]; return sheetName === "PriceSlot8" ? all_price_sheets["TaobinAOTPrice2"] : all_price_sheets[sheetName]; }; const isValidRow = (row, sheetName) => { if (!row?.length || (sheetName === "PriceSlot8" && !row[1])) return false; return true; }; const matchProductCode = (row, pd, sheetName) => { const index = sheetName === "PriceSlot8" ? 1 : 0; try { return row[index]?.includes(pd); } catch (err) { Log.err(`${sheetName}.search(${pd}): ${row} -> ${err.stack}`); return false; } }; const searchProductCodeRow = (pd, sheetName, config) => { const source = getSourceSheet(sheetName, config); if (!source) { return undefined; } return source.findLast( (row) => isValidRow(row, sheetName) && matchProductCode(row, pd, sheetName), ); }; // if exist --> price change? // if not exist --> append & need highlight let concurrent_tasks = []; let price_slot_mapping_split_by_modified = {}; // TODO: needs optimization Log.debug(`price length: ${unique_pd.size}`); for (let _sheet of sheetKeys) { var curr_sheet_from_price_slot_map = price_slot_map[_sheet]; if ( _sheet == "price" || all_price_sheets[_sheet][0].includes("ServiceType") || _sheet == "TaobinAOTPrice2" ) { Log.debug("skip by unsupported price slot format!"); continue; } // fn concurrent_tasks.push( createPriceUpdateProcessTask( _sheet, curr_sheet_from_price_slot_map, price_slot_mapping_split_by_modified, all_price_sheets, searchProductCodeRow, ), ); } let result_from_prom = await Concurrent.runTasks( concurrent_tasks, (ok) => { saveJsonToFile("./priceslot.updated.json", { timestamp: Date.now(), data: ok, }); return ok; }, (err) => { Log.err(`Promise error! ${err.stack}`); return []; }, ); // TODO: update value to sheet // result_from_prom.forEach(async (price_slot_change) => { // let profileSlotStatus = from_price_process_result(price_slot_change); // let last_row_idx = all_price_sheets[profileSlotStatus.name].length; // Log.debug(`${profileSlotStatus.name}.lastIdx: ${last_row_idx}`); // profileSlotStatus.createUpdateDataFormat(last_row_idx); // // NOTE: range must contain sheet name // let testWithinFirst2 = // profileSlotStatus.name == "PriceSlot1" || // profileSlotStatus.name == "PriceSlot2"; // if (is_test_mode && testWithinFirst2) { // // last PriceSlot1 idx = 1004 // // last PriceSlot2 idx = 1004 // const append_result = await sheet.spreadsheets.values.append({ // range: `${profileSlotStatus.name}!A${profileSlotStatus.gen_added.last_idx}`, // spreadsheetId: getCountrySpreadSheetId( // is_test_mode ? "test" : country_short // ), // valueInputOption: "USER_ENTERED", // requestBody: { // majorDimension: "ROWS", // values: profileSlotStatus.gen_added.result, // }, // }); // Log.debug( // `append to ${profileSlotStatus.name}: ${JSON.stringify( // append_result.status // )}` // ); // const update_result = await sheet.spreadsheets.values.batchUpdate({ // spreadsheetId: getCountrySpreadSheetId( // is_test_mode ? "test" : country_short // ), // requestBody: { // valueInputOption: "USER_ENTERED", // data: profileSlotStatus.gen_changed.result, // }, // }); // Log.debug( // `update to ${profileSlotStatus.name}: ${JSON.stringify( // update_result.status // )}` // ); // } // }); await _finalizeSyncProfilePrice( sheet, all_price_sheets, result_from_prom, country_short, is_test_mode, ); // save // ref: https://github.com/googleapis/google-api-nodejs-client // update // https://cloud.google.com/workflows/docs/reference/googleapis/sheets/v4/spreadsheets.values/update // batch update // https://cloud.google.com/workflows/docs/reference/googleapis/sheets/v4/spreadsheets.values/batchUpdate // append // https://cloud.google.com/workflows/docs/reference/googleapis/sheets/v4/spreadsheets.values/append } /** * * @param {sheets_v4.Sheets} sheet sheet instance * @param {Map} all_price_sheets all fetched price sheets * @param {Array} result_from_prom result of processed promise, expect format of `{ changed: [], added: [] }[]` * @param {String} country_short country short string * @param {Boolean} is_test_mode toggle output test mode */ async function _finalizeSyncProfilePrice( sheet, all_price_sheets, result_from_prom, country_short, is_test_mode, ) { const batchOps = result_from_prom.reduce( (acc, price_slot_change) => { let profileSlotStatus = from_price_process_result(price_slot_change); let last_row_idx = all_price_sheets[profileSlotStatus.name].length; Log.debug(`${profileSlotStatus.name}.lastIdx: ${last_row_idx}`); profileSlotStatus.createUpdateDataFormat(last_row_idx); // let testWithinFirst2 = profileSlotStatus.name == "PriceSlot1"; acc.append.push({ range: `${profileSlotStatus.name}!A${profileSlotStatus.gen_added.last_idx}`, values: profileSlotStatus.gen_added.result, }); acc.update.push(...profileSlotStatus.gen_changed.result); return acc; }, { append: [], update: [] }, ); if (is_test_mode) { Log.debug(`test mode: ${is_test_mode}`); // save result saveJsonToFile("./priceslot.test.json", batchOps); return; } // Log.debug(`debug batchops: ${JSON.stringify(batchOps)}`); if (!is_test_mode && batchOps.append.length) { let result_append = await Promise.all( batchOps.append.map((ops) => sheet.spreadsheets.values.append({ range: ops.range, spreadsheetId: getCountrySpreadSheetId( is_test_mode ? "test" : country_short, ), valueInputOption: "USER_ENTERED", requestBody: { majorDimension: "ROWS", values: ops.values, }, }), ), ); // Log.debug(`debug batchops append: ${JSON.stringify(result_append)}`); saveJsonToFile("./priceslot.append.final.json", result_append); } else { Log.warn("No append"); } if (!is_test_mode && batchOps.update.length) { let result_sheet_update = await sheet.spreadsheets.values.batchUpdate({ spreadsheetId: getCountrySpreadSheetId( is_test_mode ? "test" : country_short, ), requestBody: { valueInputOption: "USER_ENTERED", data: batchOps.update, }, }); // Log.debug(`debug batchops update: ${JSON.stringify(result_sheet_update)}`); saveJsonToFile("./priceslot.update.final.json", result_sheet_update); } else { Log.warn("No update"); } } // special // ====================================================================== // PLUGINS // ====================================================================== class PluginsManager { source = "./plugins"; // store file contents and eval later /** * Script Mappings, contained filename and script part * * @type {Map} */ scripts = {}; constructor(cronTask, CronJobs) { this.cronTask = cronTask; this.CronJobs = CronJobs; } load() { try { const files = fs.readdirSync(this.source); files.forEach((file, index) => { const data = fs.readFileSync(`${this.source}/${file}`, "utf8"); this.scripts[file] = data; }); } catch (loadError) { Log.err(`[pm] load error: ${loadError.stack}`); } } update(new_file_only) { try { const files = fs.readdirSync(this.source); for (let file of files) { if (new_file_only && this.scripts[file] != undefined) { // detect new file while iterate list of file continue; } const data = fs.readFileSync(`${this.source}/${file}`, "utf8"); this.scripts[file] = data; } } catch (loadError) { Log.err(`[pm] load error: ${loadError.stack}`); } } checkScript() { Log.info(`[pm] ${Object.keys(this.scripts)}`); } listRunnableScripts() { const scriptNames = Object.keys(this.scripts); let result = []; for (let sn of scriptNames) { if (this.scripts[sn] != undefined) { result.push(sn); } } return result; } /** * * @param {Express} app an express app instance * @param {Express} express another express app instance for non-cyclic * @param {String} pluginName existed plugin name * @param {Map} endpointMap endpoint mapping */ createEndpoint(app, express, pluginName, endpointMap) { var router = express.Router(); Log.debug(`[pm] Creating endpoint for ${pluginName} ... `); if (endpointMap == undefined) { Log.debug(`[pm] undefined map detected! ${pluginName} ... `); // do create default let purePluginName = pluginName.toString().split(".")[0]; Log.debug(`[pm] endpoint name = ${purePluginName} ... `); app.use( `/${purePluginName}`, router.get("/", function (req, res, next) { res.send(`${purePluginName} does not have functions yet!`); }), ); Log.warn( `[pm] Created default endpoint for ${purePluginName} --> /${purePluginName}`, ); } else { let purePluginName = pluginName.toString().split(".")[0]; const endpointNames = Object.keys(endpointMap); for (let en of endpointNames) { router.get(`/${en}`, endpointMap[en]); Log.debug(`[pm] ... created ${purePluginName}/${en}`); } app.use(`/${purePluginName}`, router); } } // runScript(name){ // try { // return eval(this.scripts[name]); // } catch(runScriptErr) { // Log.err(`[pm] run script error: ${runScriptErr.stack}`); // } // } } module.exports = { ProfilePrice, logger, Log, GoogleFunctions, Concurrent, getTestSpreadSheet, PluginsManager, getCountrySheetByName, diff2DArraysCustom, saveJsonToFile, };