Initial catalog maker baseline
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
import { runCatalogMaker } from "./workflows/catalog-maker.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { parseArgs } from "./lib/args.js";
|
||||
import { fail } from "./lib/log.js";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const command = args._[0] ?? "catalog-maker";
|
||||
|
||||
try {
|
||||
const config = loadConfig({
|
||||
configPath: args.config ?? "config/local.json",
|
||||
dryRun: args["dry-run"] ?? undefined,
|
||||
});
|
||||
|
||||
if (command === "catalog-maker") {
|
||||
await runCatalogMaker({ config });
|
||||
} else {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function loadConfig({ configPath = "config/local.json", dryRun } = {}) {
|
||||
const localConfig = readJsonFile(configPath);
|
||||
const exampleConfig = readJsonFile("config/local.example.json");
|
||||
const config = mergeConfig(exampleConfig, localConfig);
|
||||
|
||||
const appMode = String(config.appMode || "dry-run").toLowerCase();
|
||||
const resolvedDryRun = dryRun === undefined ? appMode !== "live" : Boolean(dryRun);
|
||||
|
||||
return {
|
||||
appMode,
|
||||
dryRun: resolvedDryRun,
|
||||
sourceWorkbook: path.resolve(config.sourceWorkbook || "./xxx_endpoint_bo_2026-08-04_v1.xlsm"),
|
||||
outputDir: path.resolve(config.outputDir || "./outputs"),
|
||||
server: {
|
||||
host: config.server?.host || "127.0.0.1",
|
||||
port: Number(config.server?.port || 3400),
|
||||
},
|
||||
endpoint: {
|
||||
url: config.endpoint?.url || "https://www.9b-plus.com/apiv1/endpoint/query",
|
||||
loginUrl: config.endpoint?.loginUrl || "https://www.9b-plus.com/apiv1/auth/login",
|
||||
token: config.endpoint?.token || "",
|
||||
username: config.endpoint?.username || "",
|
||||
password: config.endpoint?.password || "",
|
||||
},
|
||||
database: {
|
||||
driver: String(config.database?.driver || "endpoint").toLowerCase(),
|
||||
mode: String(config.database?.mode || "local").toLowerCase(),
|
||||
allowWrites: Boolean(config.database?.allowWrites),
|
||||
host: config.database?.host || "127.0.0.1",
|
||||
port: Number(config.database?.port || 3306),
|
||||
name: config.database?.name || "catalog_maker_test",
|
||||
user: config.database?.user || "catalog_maker",
|
||||
password: config.database?.password || "",
|
||||
connectionLimit: Number(config.database?.connectionLimit || 5),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function mergeConfig(base, override) {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
server: {
|
||||
...base.server,
|
||||
...override.server,
|
||||
},
|
||||
endpoint: {
|
||||
...base.endpoint,
|
||||
...override.endpoint,
|
||||
},
|
||||
database: {
|
||||
...base.database,
|
||||
...override.database,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import mariadb from "mariadb";
|
||||
import { assertReadOnlySql } from "../lib/sql-readonly.js";
|
||||
|
||||
let pool;
|
||||
|
||||
export function isMariaDbConfigured(config) {
|
||||
return config.database?.driver === "mariadb";
|
||||
}
|
||||
|
||||
export async function testMariaDbConnection(database) {
|
||||
const connection = await mariadb.createConnection({
|
||||
host: database.host,
|
||||
port: Number(database.port),
|
||||
database: database.name,
|
||||
user: database.user,
|
||||
password: database.password,
|
||||
bigIntAsNumber: true,
|
||||
});
|
||||
try {
|
||||
const rows = await connection.query("SELECT VERSION() AS version");
|
||||
return { connected: true, version: rows[0]?.version || "MariaDB" };
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryMariaDb(config, sql) {
|
||||
assertReadOnlySql(sql);
|
||||
const connection = await getPool(config).getConnection();
|
||||
try {
|
||||
const rows = await connection.query(sql);
|
||||
return { configured: true, items: normalizeRows(rows) };
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeMariaDbWrite(config, sql) {
|
||||
if (config.database.mode !== "local") {
|
||||
throw new Error("MariaDB writes are blocked outside Local DB mode.");
|
||||
}
|
||||
if (!config.database.allowWrites) {
|
||||
throw new Error("MariaDB writes are disabled. Enable local database writes in settings first.");
|
||||
}
|
||||
|
||||
const connection = await getPool(config).getConnection();
|
||||
try {
|
||||
const result = await connection.query(sql);
|
||||
return {
|
||||
configured: true,
|
||||
items: [],
|
||||
affectedRows: result.affectedRows ?? 0,
|
||||
insertId: result.insertId ?? 0,
|
||||
};
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function getPool(config) {
|
||||
if (!pool) {
|
||||
pool = mariadb.createPool({
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
database: config.database.name,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
connectionLimit: config.database.connectionLimit,
|
||||
bigIntAsNumber: true,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
function normalizeRows(rows) {
|
||||
return Array.isArray(rows) ? rows.filter((row) => row && typeof row === "object" && !("meta" in row)) : [];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { assertReadOnlySql } from "../lib/sql-readonly.js";
|
||||
import { assertAllowedWriteSql } from "../lib/sql-write-allowlist.js";
|
||||
import { executeMariaDbWrite, isMariaDbConfigured, queryMariaDb } from "../db/mariadb-client.js";
|
||||
|
||||
export function isEndpointConfigured(config) {
|
||||
return Boolean(
|
||||
config.endpoint.url &&
|
||||
(config.endpoint.token || (config.endpoint.loginUrl && config.endpoint.username && config.endpoint.password)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function queryEndpoint(config, sql) {
|
||||
assertReadOnlySql(sql);
|
||||
|
||||
if (isMariaDbConfigured(config)) {
|
||||
return queryMariaDb(config, sql);
|
||||
}
|
||||
|
||||
if (!isEndpointConfigured(config)) {
|
||||
return {
|
||||
configured: false,
|
||||
items: [],
|
||||
message: "Endpoint token is not configured in config/local.json.",
|
||||
};
|
||||
}
|
||||
|
||||
let token = config.endpoint.token;
|
||||
let response = await sendQuery(config, sql, token);
|
||||
|
||||
if (response.status === 401 && config.endpoint.username && config.endpoint.password) {
|
||||
token = await loginEndpoint(config);
|
||||
response = await sendQuery(config, sql, token);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data?.message || data?.error || response.statusText;
|
||||
throw new Error(`Endpoint request failed (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
return normalizeEndpointResponse(data);
|
||||
}
|
||||
|
||||
export async function executeEndpointWrite(config, sql, { operation } = {}) {
|
||||
if (isMariaDbConfigured(config)) {
|
||||
return executeMariaDbWrite(config, sql);
|
||||
}
|
||||
|
||||
assertAllowedWriteSql(sql, { operation });
|
||||
|
||||
if (!isEndpointConfigured(config)) {
|
||||
return {
|
||||
configured: false,
|
||||
items: [],
|
||||
message: "Endpoint token is not configured in config/local.json.",
|
||||
};
|
||||
}
|
||||
|
||||
let token = config.endpoint.token;
|
||||
let response = await sendQuery(config, sql, token);
|
||||
|
||||
if (response.status === 401 && config.endpoint.username && config.endpoint.password) {
|
||||
token = await loginEndpoint(config);
|
||||
response = await sendQuery(config, sql, token);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data?.message || data?.error || response.statusText;
|
||||
throw new Error(`Endpoint write failed (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
return normalizeEndpointResponse(data);
|
||||
}
|
||||
|
||||
async function sendQuery(config, sql, token) {
|
||||
const form = new FormData();
|
||||
form.append("query", sql);
|
||||
|
||||
return fetch(config.endpoint.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
async function loginEndpoint(config) {
|
||||
const form = new FormData();
|
||||
form.append("username", config.endpoint.username);
|
||||
form.append("password", config.endpoint.password);
|
||||
|
||||
const response = await fetch(config.endpoint.loginUrl, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data?.message || data?.error || response.statusText;
|
||||
throw new Error(`Endpoint login failed (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
const token = data?.token || data?.access_token || data?.data?.token || data?.data?.access_token;
|
||||
if (!token) {
|
||||
throw new Error("Endpoint login did not return a token.");
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
function normalizeEndpointResponse(data) {
|
||||
if (Array.isArray(data)) {
|
||||
return { configured: true, items: data };
|
||||
}
|
||||
|
||||
if (Array.isArray(data.items)) {
|
||||
return { configured: true, items: data.items, meta: data };
|
||||
}
|
||||
|
||||
if (Array.isArray(data.data)) {
|
||||
return { configured: true, items: data.data, meta: data };
|
||||
}
|
||||
|
||||
if (Array.isArray(data.rows)) {
|
||||
return { configured: true, items: data.rows, meta: data };
|
||||
}
|
||||
|
||||
return { configured: true, items: [], meta: data };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function parseArgs(argv) {
|
||||
const args = { _: [] };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (!arg.startsWith("--")) {
|
||||
args._.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
|
||||
if (inlineValue !== undefined) {
|
||||
args[key] = coerceValue(inlineValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
args[key] = coerceValue(next);
|
||||
index += 1;
|
||||
} else {
|
||||
args[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function coerceValue(value) {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function info(message) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
export function warn(message) {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function fail(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const writeStatementPattern =
|
||||
/^(?:alter|analyze|call|create|delete|drop|grant|insert|load|lock|optimize|replace|revoke|set|truncate|update)\b/i;
|
||||
|
||||
export function assertReadOnlySql(sql) {
|
||||
const normalized = stripLeadingSqlNoise(sql);
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("SQL query is empty.");
|
||||
}
|
||||
|
||||
if (writeStatementPattern.test(normalized)) {
|
||||
throw new Error(`Blocked non-read-only SQL statement: ${normalized.split(/\s+/, 1)[0].toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stripLeadingSqlNoise(sql) {
|
||||
return String(sql)
|
||||
.replace(/^\s+/g, "")
|
||||
.replace(/^(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)+/g, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const allowedOperation = "catalog-maker-refresh-manufacturer";
|
||||
|
||||
export function assertAllowedWriteSql(sql, { operation } = {}) {
|
||||
if (operation !== allowedOperation) {
|
||||
throw new Error(`Write SQL blocked. Unsupported operation: ${operation || "none"}`);
|
||||
}
|
||||
|
||||
const compact = compactSql(sql);
|
||||
const allowed = [
|
||||
/^updateps_product_catalogpcsetpc\.skladovka=0,pc\.catalog_9b=0wherepc\.id_manufacturer=\d+$/,
|
||||
/^updateps_product_catalogpcjoinps_import_suppliers_dumpisdonisd\.ean=pc\.ean_13setpc\.skladovka=1wherepc\.id_manufacturer=\d+$/,
|
||||
/^updateps_product_catalogpcsetpc\.catalog_9b=1wherepc\.id_manufacturer=\d+andpc\.ean_13in\(selectpa\.ean13fromps_product_attributepajoinps_productponp\.id_product=pa\.id_productwherep\.id_manufacturer=\d+\)$/,
|
||||
];
|
||||
|
||||
if (!allowed.some((pattern) => pattern.test(compact))) {
|
||||
throw new Error("Write SQL blocked. Statement is not on the catalog-maker allowlist.");
|
||||
}
|
||||
}
|
||||
|
||||
function compactSql(sql) {
|
||||
return String(sql)
|
||||
.replace(/`/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "");
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { testMariaDbConnection } from "./db/mariadb-client.js";
|
||||
import { listManufacturers } from "./services/manufacturers.js";
|
||||
import { listLanguages } from "./services/languages.js";
|
||||
import {
|
||||
loadManufacturerCatalogState,
|
||||
findProductSources,
|
||||
listMappingSources,
|
||||
listSuggestedProducts,
|
||||
loadProductByEan,
|
||||
loadProductInfo,
|
||||
getProductPictures,
|
||||
} from "./services/catalog-products.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = path.resolve(__dirname, "../public");
|
||||
const config = loadConfig();
|
||||
|
||||
const mimeTypes = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
]);
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
|
||||
if (url.pathname === "/api/status") {
|
||||
await sendJson(response, getStatus());
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/database/test" && request.method === "POST") {
|
||||
const body = await readJsonBody(request);
|
||||
const database = {
|
||||
driver: "mariadb",
|
||||
host: body.host || "127.0.0.1",
|
||||
port: Number(body.port || 3306),
|
||||
name: body.name || "",
|
||||
user: body.user || "",
|
||||
password: body.password || "",
|
||||
};
|
||||
const result = await testMariaDbConnection(database);
|
||||
await sendJson(response, result);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestConfig = url.pathname.startsWith("/api/")
|
||||
? getRequestConfig(request)
|
||||
: null;
|
||||
|
||||
if (url.pathname === "/api/manufacturers") {
|
||||
await sendJson(response, await listManufacturers(requestConfig));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/languages") {
|
||||
await sendJson(response, await listLanguages(requestConfig));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/product") {
|
||||
await sendJson(
|
||||
response,
|
||||
await loadManufacturerCatalogState(requestConfig, {
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
position: url.searchParams.get("position") || 1,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/mapping-sources") {
|
||||
await sendJson(
|
||||
response,
|
||||
await listMappingSources(requestConfig, {
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
checkUrls: url.searchParams.get("check") === "1",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/find-sources") {
|
||||
await sendJson(
|
||||
response,
|
||||
await findProductSources(requestConfig, {
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
sourceKey: url.searchParams.get("sourceKey"),
|
||||
productName: url.searchParams.get("productName"),
|
||||
supplierReference: url.searchParams.get("supplierReference"),
|
||||
ean: url.searchParams.get("ean"),
|
||||
eanGroup: url.searchParams.get("eanGroup"),
|
||||
color: url.searchParams.get("color"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/get-pictures") {
|
||||
await sendJson(
|
||||
response,
|
||||
await getProductPictures(requestConfig, {
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
sourceKey: url.searchParams.get("sourceKey"),
|
||||
productName: url.searchParams.get("productName"),
|
||||
supplierReference: url.searchParams.get("supplierReference"),
|
||||
ean: url.searchParams.get("ean"),
|
||||
eanGroup: url.searchParams.get("eanGroup"),
|
||||
color: url.searchParams.get("color"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/product-info") {
|
||||
await sendJson(
|
||||
response,
|
||||
await loadProductInfo(requestConfig, {
|
||||
productId: url.searchParams.get("productId"),
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
supplierReference: url.searchParams.get("supplierReference"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/product-by-ean") {
|
||||
await sendJson(
|
||||
response,
|
||||
await loadProductByEan(requestConfig, {
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
ean: url.searchParams.get("ean"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/catalog-maker/suggested-products") {
|
||||
await sendJson(
|
||||
response,
|
||||
await listSuggestedProducts(requestConfig, {
|
||||
manufacturerId: url.searchParams.get("manufacturerId"),
|
||||
supplierReference: url.searchParams.get("supplierReference"),
|
||||
productName: url.searchParams.get("productName"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const safePath = path.normalize(requestedPath).replace(/^(\.\.[/\\])+/, "");
|
||||
const filePath = path.join(publicDir, safePath);
|
||||
|
||||
if (!filePath.startsWith(publicDir)) {
|
||||
response.writeHead(403);
|
||||
response.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
"content-type": mimeTypes.get(path.extname(filePath)) || "application/octet-stream",
|
||||
});
|
||||
response.end(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
response.writeHead(404);
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
await sendJson(response, {
|
||||
message: error.message || "Internal server error",
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.server.port, config.server.host, () => {
|
||||
console.log(`Catalog Maker preview: http://${config.server.host}:${config.server.port}`);
|
||||
});
|
||||
|
||||
function getStatus() {
|
||||
return {
|
||||
mode: config.dryRun ? "dry-run" : "live",
|
||||
databaseAccess: "read-only",
|
||||
database: {
|
||||
driver: config.database.driver,
|
||||
mode: config.database.mode,
|
||||
allowWrites: config.database.driver === "mariadb" && config.database.mode === "local" && config.database.allowWrites,
|
||||
},
|
||||
workbook: path.basename(config.sourceWorkbook),
|
||||
workbookExists: true,
|
||||
steps: [
|
||||
["load-context", "Ready", "Workbook and local config"],
|
||||
["load-manufacturer", "Mapping", "Manufacturer and product selection"],
|
||||
["check-stock", "Mapping", "Supplier stock and existing catalog state"],
|
||||
["build-combinations", "Mapping", "Product combinations"],
|
||||
["update-prices", "Mapping", "Price calculation preview"],
|
||||
["update-texts", "Mapping", "Product card text generation"],
|
||||
["handle-pictures", "Mapping", "Cover and detail pictures"],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestConfig(request) {
|
||||
const selectedMode = String(request.headers["x-database-mode"] || config.database.mode).toLowerCase();
|
||||
|
||||
if (selectedMode === "local" && config.database.driver !== "mariadb") {
|
||||
throw new Error("Local DB is selected, but MariaDB is not configured. Live DB was not used.");
|
||||
}
|
||||
|
||||
if (selectedMode === "live" && config.database.driver === "mariadb") {
|
||||
throw new Error("Live DB is selected, but this project is configured for local MariaDB.");
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
database: {
|
||||
...config.database,
|
||||
mode: selectedMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendJson(response, data, statusCode = 200) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
return body ? JSON.parse(body) : {};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { Builder } from "selenium-webdriver";
|
||||
import firefox from "selenium-webdriver/firefox.js";
|
||||
import { start } from "geckodriver";
|
||||
|
||||
const FIREFOX_PATHS = [
|
||||
"C:\\Program Files\\Mozilla Firefox\\firefox.exe",
|
||||
"C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
|
||||
];
|
||||
|
||||
export function openInFirefox(url) {
|
||||
const firefoxPath = FIREFOX_PATHS.find((candidate) => candidate);
|
||||
if (!firefoxPath) throw new Error("Firefox executable was not found.");
|
||||
|
||||
const process = spawn(firefoxPath, [url], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
process.unref();
|
||||
}
|
||||
|
||||
export async function searchInFirefox({ url, headless = false, waitAfterLoadMs = 3000 }) {
|
||||
const firefoxPath = FIREFOX_PATHS.find((candidate) => candidate);
|
||||
const port = 4444;
|
||||
const geckodriver = await start({ port, binary: firefoxPath, log: "error" });
|
||||
let driver;
|
||||
|
||||
try {
|
||||
await waitForWebDriver(port);
|
||||
const options = new firefox.Options();
|
||||
if (firefoxPath) options.setBinary(firefoxPath);
|
||||
if (headless) options.addArguments("-headless");
|
||||
|
||||
driver = await new Builder()
|
||||
.forBrowser("firefox")
|
||||
.setFirefoxOptions(options)
|
||||
.usingServer(`http://127.0.0.1:${port}`)
|
||||
.build();
|
||||
|
||||
await driver.get(url);
|
||||
await driver.sleep(Math.max(1000, Number(waitAfterLoadMs) || 3000));
|
||||
|
||||
const [currentUrl, title, pageSource] = await Promise.all([
|
||||
driver.getCurrentUrl(),
|
||||
driver.getTitle(),
|
||||
driver.getPageSource(),
|
||||
]);
|
||||
const cleanText = stripHtml(pageSource).slice(0, 1200);
|
||||
const failed = /something has gone wrong|reference id/i.test(cleanText);
|
||||
|
||||
return { ok: !failed, url: currentUrl, title, text: cleanText, pageSource };
|
||||
} finally {
|
||||
if (driver) await driver.quit().catch(() => {});
|
||||
geckodriver.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWebDriver(port) {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/status`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Geckodriver needs a short moment to open its local port.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error("Firefox WebDriver did not start.");
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { queryEndpoint } from "../endpoint/endpoint-client.js";
|
||||
import { catalogMakerSql } from "../sql/catalog-maker.js";
|
||||
|
||||
export async function listLanguages(config) {
|
||||
const result = await queryEndpoint(config, catalogMakerSql.languages);
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((row) => ({
|
||||
id: row.id_lang,
|
||||
name: row.name,
|
||||
isoCode: row.iso_code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { queryEndpoint } from "../endpoint/endpoint-client.js";
|
||||
import { catalogMakerSql } from "../sql/catalog-maker.js";
|
||||
|
||||
export async function listManufacturers(config) {
|
||||
const result = await queryEndpoint(config, catalogMakerSql.manufacturers);
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((row) => ({
|
||||
id: row.id_manufacturer,
|
||||
name: row.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
export const catalogMakerSql = {
|
||||
manufacturers: `
|
||||
SELECT id_manufacturer, name
|
||||
FROM ps_manufacturer
|
||||
ORDER BY name ASC
|
||||
`,
|
||||
manufacturerByName: `
|
||||
SELECT id_manufacturer
|
||||
FROM ps_manufacturer
|
||||
WHERE name = ?
|
||||
`,
|
||||
languages: `
|
||||
SELECT id_lang, name, iso_code
|
||||
FROM ps_lang
|
||||
ORDER BY name ASC
|
||||
`,
|
||||
};
|
||||
|
||||
export function prepareManufacturerCatalogSqls({ manufacturerId }) {
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
|
||||
return [
|
||||
`
|
||||
UPDATE ps_product_catalog pc
|
||||
SET pc.skladovka = 0,
|
||||
pc.catalog_9b = 0
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
`,
|
||||
`
|
||||
UPDATE ps_product_catalog pc
|
||||
JOIN ps_import_suppliers_dump isd ON isd.ean = pc.ean_13
|
||||
SET pc.skladovka = 1
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
`,
|
||||
`
|
||||
UPDATE ps_product_catalog pc
|
||||
SET pc.catalog_9b = 1
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.ean_13 IN (
|
||||
SELECT pa.ean13
|
||||
FROM ps_product_attribute pa
|
||||
JOIN ps_product p ON p.id_product = pa.id_product
|
||||
WHERE p.id_manufacturer = ${safeManufacturerId}
|
||||
)
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export function productToDoByManufacturerSql({ manufacturerId, position = 1 }) {
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
const safePosition = Number.parseInt(position, 10) || 1;
|
||||
|
||||
return `
|
||||
WITH todo_refs AS (
|
||||
SELECT
|
||||
supplier_reference,
|
||||
ROW_NUMBER() OVER (ORDER BY MIN(id_product_catalog) ASC) AS position,
|
||||
COUNT(*) OVER () AS total
|
||||
FROM ps_product_catalog
|
||||
WHERE id_manufacturer = ${safeManufacturerId}
|
||||
AND catalog_9b = 0
|
||||
AND skladovka = 1
|
||||
GROUP BY supplier_reference
|
||||
),
|
||||
target_ref AS (
|
||||
SELECT supplier_reference, position, total
|
||||
FROM todo_refs
|
||||
WHERE position = ${safePosition}
|
||||
),
|
||||
combination AS (
|
||||
SELECT pc.*
|
||||
FROM ps_product_catalog pc
|
||||
JOIN target_ref tr ON tr.supplier_reference = pc.supplier_reference
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.catalog_9b = 0
|
||||
AND pc.skladovka = 1
|
||||
ORDER BY pc.id_product_catalog ASC
|
||||
LIMIT 1
|
||||
),
|
||||
existing_product AS (
|
||||
SELECT pc.supplier_reference, pc.ean_13
|
||||
FROM ps_product_catalog pc
|
||||
JOIN target_ref tr ON tr.supplier_reference = pc.supplier_reference
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.catalog_9b = 1
|
||||
AND (pc.skladovka = 1 OR pc.skladovka = 0)
|
||||
ORDER BY pc.id_product_catalog ASC
|
||||
)
|
||||
SELECT
|
||||
co.*,
|
||||
tr.position,
|
||||
tr.total,
|
||||
p.id_product,
|
||||
p.price,
|
||||
p.id_supplier,
|
||||
(
|
||||
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg')
|
||||
FROM ps_image i
|
||||
JOIN ps_product_lang pl
|
||||
ON pl.id_product = i.id_product
|
||||
AND pl.id_lang = 2
|
||||
WHERE i.id_product = p.id_product
|
||||
ORDER BY i.cover DESC, i.position ASC
|
||||
LIMIT 1
|
||||
) AS image_url
|
||||
FROM combination co
|
||||
JOIN target_ref tr ON tr.supplier_reference = co.supplier_reference
|
||||
LEFT JOIN existing_product ep ON ep.supplier_reference = co.supplier_reference
|
||||
LEFT JOIN ps_product_attribute pa ON pa.ean13 = ep.ean_13
|
||||
LEFT JOIN ps_product p ON p.id_product = pa.id_product
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
|
||||
export function productByEanSql({ manufacturerId, ean }) {
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
const safeEan = escapeSqlString(normalizeEan(ean));
|
||||
|
||||
if (!safeEan) {
|
||||
throw new Error("ean is required.");
|
||||
}
|
||||
|
||||
return `
|
||||
WITH todo_refs AS (
|
||||
SELECT
|
||||
supplier_reference,
|
||||
ROW_NUMBER() OVER (ORDER BY MIN(id_product_catalog) ASC) AS position,
|
||||
COUNT(*) OVER () AS total
|
||||
FROM ps_product_catalog
|
||||
WHERE id_manufacturer = ${safeManufacturerId}
|
||||
AND catalog_9b = 0
|
||||
AND skladovka = 1
|
||||
GROUP BY supplier_reference
|
||||
),
|
||||
combination AS (
|
||||
SELECT pc.*
|
||||
FROM ps_product_catalog pc
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.ean_13 = '${safeEan}'
|
||||
ORDER BY pc.id_product_catalog ASC
|
||||
LIMIT 1
|
||||
),
|
||||
existing_product AS (
|
||||
SELECT pc.supplier_reference, pc.ean_13
|
||||
FROM ps_product_catalog pc
|
||||
JOIN combination co ON co.supplier_reference = pc.supplier_reference
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.catalog_9b = 1
|
||||
ORDER BY pc.id_product_catalog ASC
|
||||
)
|
||||
SELECT
|
||||
co.*,
|
||||
tr.position,
|
||||
tr.total,
|
||||
p.id_product,
|
||||
p.price,
|
||||
p.id_supplier,
|
||||
(
|
||||
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg')
|
||||
FROM ps_image i
|
||||
JOIN ps_product_lang pl
|
||||
ON pl.id_product = i.id_product
|
||||
AND pl.id_lang = 2
|
||||
WHERE i.id_product = p.id_product
|
||||
ORDER BY i.cover DESC, i.position ASC
|
||||
LIMIT 1
|
||||
) AS image_url
|
||||
FROM combination co
|
||||
LEFT JOIN todo_refs tr ON tr.supplier_reference = co.supplier_reference
|
||||
LEFT JOIN existing_product ep ON ep.supplier_reference = co.supplier_reference
|
||||
LEFT JOIN ps_product_attribute pa ON pa.ean13 = ep.ean_13
|
||||
LEFT JOIN ps_product p ON p.id_product = pa.id_product
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
|
||||
export function productCombinationsByReferenceSql({ manufacturerId, supplierReference }) {
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
const safeSupplierReference = escapeSqlString(supplierReference);
|
||||
|
||||
if (!safeSupplierReference) {
|
||||
throw new Error("supplierReference is required.");
|
||||
}
|
||||
|
||||
return `
|
||||
SELECT pc.*
|
||||
FROM ps_product_catalog pc
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.supplier_reference = '${safeSupplierReference}'
|
||||
ORDER BY pc.id_product_catalog ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export function productCombinationInfoSql({ productId, manufacturerId, supplierReference }) {
|
||||
const safeProductId = parsePositiveInteger(productId, "productId");
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
const safeSupplierReference = escapeSqlString(supplierReference);
|
||||
|
||||
if (!safeSupplierReference) {
|
||||
throw new Error("supplierReference is required.");
|
||||
}
|
||||
|
||||
return `
|
||||
WITH catalog_rows AS (
|
||||
SELECT pc.*
|
||||
FROM ps_product_catalog pc
|
||||
WHERE pc.id_manufacturer = ${safeManufacturerId}
|
||||
AND pc.supplier_reference = '${safeSupplierReference}'
|
||||
),
|
||||
attribute_group AS (
|
||||
SELECT a.id_attribute_group
|
||||
FROM ps_product_attribute pa
|
||||
JOIN ps_product_attribute_combination pac
|
||||
ON pac.id_product_attribute = pa.id_product_attribute
|
||||
JOIN ps_attribute a
|
||||
ON a.id_attribute = pac.id_attribute
|
||||
JOIN ps_attribute_group_lang agl
|
||||
ON agl.id_attribute_group = a.id_attribute_group
|
||||
WHERE a.id_attribute_group <> 1
|
||||
AND agl.id_lang = 1
|
||||
AND pa.id_product = ${safeProductId}
|
||||
GROUP BY a.id_attribute_group
|
||||
ORDER BY a.id_attribute_group ASC
|
||||
LIMIT 1
|
||||
),
|
||||
matched_attributes AS (
|
||||
SELECT
|
||||
cr.id_product_catalog,
|
||||
(
|
||||
SELECT a.id_attribute
|
||||
FROM ps_attribute_lang al
|
||||
JOIN ps_attribute a
|
||||
ON a.id_attribute = al.id_attribute
|
||||
WHERE al.name = TRIM(SUBSTRING_INDEX(cr.color, '/', 1))
|
||||
AND al.id_lang = 1
|
||||
AND a.id_attribute_group = 1
|
||||
ORDER BY a.id_attribute ASC
|
||||
LIMIT 1
|
||||
) AS color_attribute_id,
|
||||
(
|
||||
SELECT al.name
|
||||
FROM ps_attribute_lang al
|
||||
JOIN ps_attribute a
|
||||
ON a.id_attribute = al.id_attribute
|
||||
WHERE al.name = TRIM(SUBSTRING_INDEX(cr.color, '/', 1))
|
||||
AND al.id_lang = 1
|
||||
AND a.id_attribute_group = 1
|
||||
ORDER BY a.id_attribute ASC
|
||||
LIMIT 1
|
||||
) AS color_name,
|
||||
(
|
||||
SELECT a.id_attribute
|
||||
FROM ps_attribute_lang al
|
||||
JOIN ps_attribute a
|
||||
ON a.id_attribute = al.id_attribute
|
||||
JOIN attribute_group ag
|
||||
ON ag.id_attribute_group = a.id_attribute_group
|
||||
WHERE al.name = REPLACE(TRIM(cr.combination), ',', '.')
|
||||
AND al.id_lang = 1
|
||||
ORDER BY a.id_attribute ASC
|
||||
LIMIT 1
|
||||
) AS combi_attribute_id,
|
||||
(
|
||||
SELECT al.name
|
||||
FROM ps_attribute_lang al
|
||||
JOIN ps_attribute a
|
||||
ON a.id_attribute = al.id_attribute
|
||||
JOIN attribute_group ag
|
||||
ON ag.id_attribute_group = a.id_attribute_group
|
||||
WHERE al.name = REPLACE(TRIM(cr.combination), ',', '.')
|
||||
AND al.id_lang = 1
|
||||
ORDER BY a.id_attribute ASC
|
||||
LIMIT 1
|
||||
) AS combi_name
|
||||
FROM catalog_rows cr
|
||||
),
|
||||
existing_combinations AS (
|
||||
SELECT
|
||||
ma.id_product_catalog,
|
||||
pa.id_product_attribute,
|
||||
pa.ean13
|
||||
FROM matched_attributes ma
|
||||
JOIN ps_product_attribute_combination pac_color
|
||||
ON pac_color.id_attribute = ma.color_attribute_id
|
||||
JOIN ps_product_attribute_combination pac_combi
|
||||
ON pac_combi.id_product_attribute = pac_color.id_product_attribute
|
||||
AND pac_combi.id_attribute = ma.combi_attribute_id
|
||||
JOIN ps_product_attribute pa
|
||||
ON pa.id_product_attribute = pac_color.id_product_attribute
|
||||
AND pa.id_product = ${safeProductId}
|
||||
)
|
||||
SELECT
|
||||
cr.id_product_catalog,
|
||||
cr.ean_13 AS catalog_ean13,
|
||||
ma.color_attribute_id,
|
||||
ma.color_name,
|
||||
ma.combi_attribute_id,
|
||||
ma.combi_name,
|
||||
ec.id_product_attribute,
|
||||
ec.ean13 AS nine_b_ean13,
|
||||
ROUND(cr.wholesale_price * (1 - (CAST(cr.voc_discount AS DECIMAL(10, 4)) / 100)), 0)
|
||||
AS nine_b_voc,
|
||||
ROUND(cr.moc_price * (1 - (CAST(cr.max_moc_dicount AS DECIMAL(10, 4)) / 100)), 0)
|
||||
AS nine_b_moc,
|
||||
CASE
|
||||
WHEN cr.skladovka = 1
|
||||
AND (
|
||||
ma.color_attribute_id IS NULL
|
||||
OR ma.combi_attribute_id IS NULL
|
||||
OR ec.id_product_attribute IS NULL
|
||||
OR ec.ean13 <> cr.ean_13
|
||||
)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS has_problem
|
||||
FROM catalog_rows cr
|
||||
LEFT JOIN matched_attributes ma
|
||||
ON ma.id_product_catalog = cr.id_product_catalog
|
||||
LEFT JOIN existing_combinations ec
|
||||
ON ec.id_product_catalog = cr.id_product_catalog
|
||||
ORDER BY cr.id_product_catalog ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export function suggestedProductsSql({ manufacturerId, supplierReference, productName }) {
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
const safeSupplierReference = escapeSqlLike(supplierReference);
|
||||
const safeProductName = escapeSqlLike(productName);
|
||||
|
||||
if (!safeSupplierReference && !safeProductName) {
|
||||
throw new Error("supplierReference or productName is required.");
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
if (safeSupplierReference) {
|
||||
conditions.push(`p.supplier_reference LIKE '%${safeSupplierReference}%'`);
|
||||
}
|
||||
if (safeProductName) {
|
||||
conditions.push(`pl.name LIKE '%${safeProductName}%'`);
|
||||
}
|
||||
|
||||
return `
|
||||
SELECT
|
||||
p.id_product,
|
||||
pl.name,
|
||||
p.supplier_reference,
|
||||
p.active,
|
||||
(
|
||||
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-cart_default/', pli.link_rewrite, '.jpg')
|
||||
FROM ps_image i
|
||||
JOIN ps_product_lang pli
|
||||
ON pli.id_product = i.id_product
|
||||
AND pli.id_lang = 2
|
||||
WHERE i.id_product = p.id_product
|
||||
ORDER BY i.cover DESC, i.position ASC
|
||||
LIMIT 1
|
||||
) AS image_url
|
||||
FROM ps_product p
|
||||
JOIN ps_product_lang pl
|
||||
ON pl.id_product = p.id_product
|
||||
AND pl.id_lang = 1
|
||||
WHERE p.id_manufacturer = ${safeManufacturerId}
|
||||
AND (${conditions.join(" OR ")})
|
||||
ORDER BY p.active DESC, p.id_product DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
}
|
||||
|
||||
export function mappingSourceTablesSql() {
|
||||
return `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (
|
||||
'ps_product_catalog_source',
|
||||
'ps_product_catalog_manufacturer_source'
|
||||
)
|
||||
ORDER BY FIELD(
|
||||
table_name,
|
||||
'ps_product_catalog_manufacturer_source',
|
||||
'ps_product_catalog_source'
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
export function mappingSourcesSql({ manufacturerId }) {
|
||||
const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId");
|
||||
|
||||
return `
|
||||
SELECT
|
||||
ms.id_product_catalog_manufacturer_source AS mapping_id,
|
||||
ms.id_manufacturer,
|
||||
ms.id_product_catalog_source,
|
||||
ms.enabled AS mapping_enabled,
|
||||
ms.priority AS mapping_priority,
|
||||
s.source_key,
|
||||
s.source_name,
|
||||
s.source_type,
|
||||
s.base_url,
|
||||
s.enabled,
|
||||
s.priority AS source_priority,
|
||||
s.search_primary_key,
|
||||
s.search_fallback_keys,
|
||||
s.fallback_source_type,
|
||||
s.search_url_template,
|
||||
s.fallback_url_template,
|
||||
s.search_context_keys,
|
||||
s.access_mode,
|
||||
s.browser_engine,
|
||||
s.browser_headless,
|
||||
s.wait_after_load_ms,
|
||||
s.note
|
||||
FROM ps_product_catalog_manufacturer_source ms
|
||||
JOIN ps_product_catalog_source s
|
||||
ON s.id_product_catalog_source = ms.id_product_catalog_source
|
||||
WHERE ms.id_manufacturer = ${safeManufacturerId}
|
||||
ORDER BY
|
||||
ms.priority ASC,
|
||||
s.priority ASC,
|
||||
s.source_name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeSqlString(value) {
|
||||
return String(value ?? "").replaceAll("\\", "\\\\").replaceAll("'", "''").trim();
|
||||
}
|
||||
|
||||
function escapeSqlLike(value) {
|
||||
return escapeSqlString(value).replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, name) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeEan(value) {
|
||||
return String(value ?? "").replace(/\D/g, "");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs";
|
||||
import { info, warn } from "../lib/log.js";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: "load-context",
|
||||
title: "Load workbook and local configuration",
|
||||
status: "planned",
|
||||
},
|
||||
{
|
||||
id: "load-manufacturer",
|
||||
title: "Load selected manufacturer and products to process",
|
||||
status: "needs-mapping",
|
||||
},
|
||||
{
|
||||
id: "check-stock",
|
||||
title: "Check supplier stock and existing catalog combinations",
|
||||
status: "needs-mapping",
|
||||
},
|
||||
{
|
||||
id: "build-combinations",
|
||||
title: "Create or update product combinations",
|
||||
status: "needs-mapping",
|
||||
},
|
||||
{
|
||||
id: "update-prices",
|
||||
title: "Update product and combination prices",
|
||||
status: "needs-mapping",
|
||||
},
|
||||
{
|
||||
id: "update-texts",
|
||||
title: "Generate/update product card texts",
|
||||
status: "needs-mapping",
|
||||
},
|
||||
{
|
||||
id: "handle-pictures",
|
||||
title: "Resolve cover/detail product pictures",
|
||||
status: "needs-mapping",
|
||||
},
|
||||
];
|
||||
|
||||
export async function runCatalogMaker({ config }) {
|
||||
info("Catalog maker workflow");
|
||||
info(`Mode: ${config.dryRun ? "dry-run" : "live"}`);
|
||||
info(`Workbook: ${config.sourceWorkbook}`);
|
||||
|
||||
if (!fs.existsSync(config.sourceWorkbook)) {
|
||||
throw new Error(`Source workbook not found: ${config.sourceWorkbook}`);
|
||||
}
|
||||
|
||||
info("");
|
||||
info("Planned replacement for Excel button: Catalog maker - 20");
|
||||
|
||||
for (const step of steps) {
|
||||
const marker = step.status === "planned" ? "ready" : step.status;
|
||||
info(`- ${step.id}: ${marker} - ${step.title}`);
|
||||
}
|
||||
|
||||
warn("");
|
||||
warn("No database or catalog changes were made. Database access in this project is read-only by rule #1.");
|
||||
warn("Next step: map the original VBA/SQL behavior for CommandButton20_Click.");
|
||||
}
|
||||
Reference in New Issue
Block a user