ggs-cron/lib/common.js
2025-08-06 16:16:26 +07:00

1409 lines
40 KiB
JavaScript

// ===================================================================
// 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,
};