Initial catalog maker baseline

This commit is contained in:
2026-08-05 12:25:23 +02:00
commit 2e7d83b679
30 changed files with 5437 additions and 0 deletions
+24
View File
@@ -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;
}
+67
View File
@@ -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,
},
};
}
+77
View File
@@ -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)) : [];
}
+152
View File
@@ -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 };
}
+36
View File
@@ -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;
}
+12
View File
@@ -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}`);
}
+21
View File
@@ -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();
}
+27
View File
@@ -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
View File
@@ -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
+82
View File
@@ -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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/\s+/g, " ")
.trim();
}
+14
View File
@@ -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,
})),
};
}
+13
View File
@@ -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,
})),
};
}
+442
View File
@@ -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, "");
}
+62
View File
@@ -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.");
}