This commit is contained in:
seb
2026-07-06 01:31:11 +02:00
commit 917930d0fa
34 changed files with 2912 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const CATEGORY_COUNT_SQL = `
${CATEGORY_TREE_CTE}
SELECT COUNT(*) AS CategoryCount
FROM dbo.tKategorie k
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND CONVERT(BIGINT, k.bRowversion) > @cursor;
`;
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(CATEGORY_COUNT_SQL);
return result.recordset[0]?.CategoryCount ?? 0;
}

View File

@@ -0,0 +1,48 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const CATEGORY_LIST_SQL = `
${CATEGORY_TREE_CTE}
SELECT TOP (@limit)
k.kKategorie AS id,
k.kOberKategorie AS pid,
k.nSort AS sort,
ks.cName AS name,
b.cHash AS imgHash,
CONVERT(BIGINT, k.bRowversion) AS lastChanged
FROM dbo.tKategorie k
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
LEFT JOIN dbo.tKategoriebildPlattform kbp
ON kbp.kKategorie = k.kKategorie AND kbp.kPlattform = @imagePlatformId AND kbp.kShop = @imageShopId
LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
AND k.cAktiv = 'Y'
AND CONVERT(BIGINT, k.bRowversion) > @cursor
ORDER BY lastChanged ASC;
`;
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(CATEGORY_LIST_SQL);
return result.recordset.map((row) => ({
_id: String(row.id),
imghash: row.imgHash ?? null,
imgsrc: row.imgHash ?? null,
name: row.name,
pid: String(row.pid),
discounts: [],
sort: String(row.sort),
lastChanged: String(row.lastChanged),
}));
}

View File

@@ -0,0 +1,18 @@
import sql from 'mssql';
export const CATEGORY_TREE_CTE = `
WITH CategoryTree AS (
SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = @rootCategoryId
UNION ALL
SELECT t.kKategorie
FROM dbo.tKategorie t
INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie
)`;
export function getRootCategoryId() {
return Number(process.env.ROOT_CATEGORY_ID) || 1;
}
export function categoryTreeRequest(pool, rootCategoryId = getRootCategoryId()) {
return pool.request().input('rootCategoryId', sql.Int, rootCategoryId);
}

View File

@@ -0,0 +1,48 @@
import sql from 'mssql';
import { getPool } from '../db.js';
const CUSTOMER_GROUP_IDS_SQL = `
SELECT kKundenGruppe
FROM dbo.tKundenGruppe
ORDER BY kKundenGruppe;
`;
const CUSTOMER_GROUP_LIST_SQL = `
SELECT
kKundenGruppe AS id,
cName AS name,
nStandard AS standard,
fRabatt AS discountPercent,
CONVERT(BIGINT, bRowversion) AS lastChanged
FROM dbo.tKundenGruppe
WHERE CONVERT(BIGINT, bRowversion) > @cursor
ORDER BY lastChanged ASC;
`;
const CUSTOMER_GROUP_COUNT_SQL = `
SELECT COUNT(*) AS CustomerGroupCount
FROM dbo.tKundenGruppe
WHERE CONVERT(BIGINT, bRowversion) > @cursor;
`;
export async function getCustomerGroupIds() {
const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL);
return result.recordset.map((row) => row.kKundenGruppe);
}
export async function getCustomerGroupList({ cursor = 0 } = {}) {
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_LIST_SQL);
return result.recordset.map((row) => ({
customerGroupId: String(row.id),
name: row.name,
standard: String(row.standard),
discountPercent: Number(row.discountPercent).toFixed(2),
lastChanged: String(row.lastChanged),
}));
}
export async function getCustomerGroupCount({ cursor = 0 } = {}) {
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_COUNT_SQL);
return result.recordset[0]?.CustomerGroupCount ?? 0;
}

43
src/queries/image.js Normal file
View File

@@ -0,0 +1,43 @@
import sql from 'mssql';
import { getPool } from '../db.js';
const IMAGE_BY_HASH_SQL = `
SELECT bBild, bVorschauBild, nBreite, nHoehe, nVorschauBreite, nVorschauHoehe, cQuelle
FROM dbo.tBild
WHERE cHash = @hash;
`;
function contentTypeFor(cQuelle) {
const extension = (cQuelle || '').split('.').pop()?.toLowerCase();
switch (extension) {
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
case 'jpg':
case 'jpeg':
default:
return 'image/jpeg';
}
}
export async function getImageByHash(hash, size) {
const result = await getPool().request().input('hash', sql.NVarChar, hash).query(IMAGE_BY_HASH_SQL);
const row = result.recordset[0];
if (!row) {
return null;
}
const previewMaxDimension = Math.max(row.nVorschauBreite || 0, row.nVorschauHoehe || 0);
const useFull = !previewMaxDimension || Number(size) > previewMaxDimension;
const buffer = useFull ? row.bBild : row.bVorschauBild;
if (!buffer) {
return null;
}
return { buffer, contentType: contentTypeFor(row.cQuelle) };
}

View File

@@ -0,0 +1,149 @@
const PFAND_ATTR_IDS = [
'Pfandartikel',
'Pfandart (Bezeichnung auf Ausdruck)',
'Pfandbetrag',
];
let pfandMetaCache = null;
let activeShopsCache = null;
async function getPfandMetadata(pool) {
if (!pfandMetaCache) {
const result = await pool.request().query(`
SELECT
a.kAttribut,
a.cAttributId,
a.nSortierung,
a.kFeldTyp,
a.cGruppeName,
s.cName
FROM dbo.tAttribut a
INNER JOIN dbo.tAttributSprache s ON s.kAttribut = a.kAttribut AND s.kSprache = 0
WHERE a.cGruppeName = 'JTL-POS'
AND a.cAttributId IN ('Pfandartikel', 'Pfandart (Bezeichnung auf Ausdruck)', 'Pfandbetrag')
`);
pfandMetaCache = result.recordset;
}
return pfandMetaCache;
}
async function getActiveShops(pool) {
if (!activeShopsCache) {
const result = await pool.request().query(`
WITH ActiveShops AS (
SELECT ss.kShop, s.kKategorie AS rootKategorie
FROM dbo.tShopSubshop ss
INNER JOIN dbo.tShop s ON s.kShop = ss.kShop
WHERE ss.nGesperrt = 0
)
SELECT DISTINCT ash.kShop FROM ActiveShops ash
`);
activeShopsCache = result.recordset.map((row) => row.kShop);
}
return activeShopsCache;
}
function attributesSql(articleIds, activeShops, pfandKAttribute) {
const shopList = activeShops.length ? activeShops.join(',') : 'NULL';
const pfandList = pfandKAttribute.length ? pfandKAttribute.join(',') : 'NULL';
return `
SELECT
aa.kArtikel AS articleId,
aa.kAttribut,
aa.kShop,
at.nSortierung,
at.kFeldTyp,
at.cGruppeName,
at.cAttributId,
ats.cName,
aas.cWertVarchar,
aas.nWertInt,
aas.fWertDecimal
FROM dbo.tArtikelAttribut aa
INNER JOIN dbo.tArtikelAttributSprache aas
ON aas.kArtikelAttribut = aa.kArtikelAttribut AND aas.kSprache = 0
INNER JOIN dbo.tAttribut at ON at.kAttribut = aa.kAttribut
INNER JOIN dbo.tAttributSprache ats ON ats.kAttribut = at.kAttribut AND ats.kSprache = 0
WHERE aa.kArtikel IN (${articleIds.join(',')})
AND (
aa.kShop = 0
OR (
aa.kShop IN (${shopList})
AND aa.kAttribut IN (${pfandList})
)
)
ORDER BY aa.kArtikel, at.nSortierung, aa.kShop;
`;
}
function mergeArticleAttributes(rows) {
const byAttribute = new Map();
for (const row of rows) {
byAttribute.set(row.kAttribut, row);
}
return [...byAttribute.values()].sort((a, b) => a.nSortierung - b.nSortierung);
}
function toPosAttribute(row) {
return {
aname: row.cName,
aprice: '0.0',
asort: String(row.nSortierung),
atype: String(row.kFeldTyp),
agroup: row.cGruppeName ?? '',
};
}
function deriveDepositFields(rows) {
const byId = new Map(rows.map((row) => [row.cAttributId, row]));
const pfandArtikel = byId.get('Pfandartikel');
const pfandArt = byId.get('Pfandart (Bezeichnung auf Ausdruck)');
const pfandBetrag = byId.get('Pfandbetrag');
if (!pfandArtikel && !pfandArt && !pfandBetrag) {
return null;
}
return {
deposit: pfandArtikel?.nWertInt === 1 ? '1' : '0',
deposit_name: pfandArt?.cWertVarchar ?? '',
d_price:
pfandBetrag?.fWertDecimal != null
? Number(pfandBetrag.fWertDecimal).toFixed(2)
: '0.0',
};
}
export async function getProductAttributes(pool, articleIds) {
if (!articleIds.length) {
return new Map();
}
const [pfandMeta, activeShops] = await Promise.all([
getPfandMetadata(pool),
getActiveShops(pool),
]);
const pfandKAttribute = pfandMeta.map((row) => row.kAttribut);
const result = await pool.request().query(attributesSql(articleIds, activeShops, pfandKAttribute));
const rowsByArticle = new Map();
for (const row of result.recordset) {
if (!rowsByArticle.has(row.articleId)) {
rowsByArticle.set(row.articleId, []);
}
rowsByArticle.get(row.articleId).push(row);
}
const attributesByArticle = new Map();
for (const [articleId, rows] of rowsByArticle) {
const merged = mergeArticleAttributes(rows);
attributesByArticle.set(articleId, {
attributes: merged.map(toPosAttribute),
deposit: deriveDepositFields(merged),
});
}
return attributesByArticle;
}

View File

@@ -0,0 +1,20 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const PRODUCT_COUNT_SQL = `
${CATEGORY_TREE_CTE}
SELECT COUNT(DISTINCT a.kArtikel) AS ProductCount
FROM dbo.tArtikel a
INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel
WHERE a.cAktiv = 'Y'
AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND CONVERT(BIGINT, a.bRowversion) > @cursor;
`;
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(PRODUCT_COUNT_SQL);
return result.recordset[0]?.ProductCount ?? 0;
}

151
src/queries/product-list.js Normal file
View File

@@ -0,0 +1,151 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { getCustomerGroupIds } from './customer-groups.js';
import { getProductAttributes } from './product-attributes.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const TAX_ZONE_NAME = process.env.TAX_ZONE_NAME || 'Zone-EU';
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const PRODUCT_LIST_SQL = `
WITH TaxRates AS (
SELECT kSteuerklasse, fSteuersatz
FROM dbo.tSteuersatz
WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @taxZoneName)
)
SELECT TOP (@limit)
a.kArtikel AS id,
a.cArtNr AS sku,
ab.cName AS name,
a.fVKNetto AS netPrice,
tr.fSteuersatz AS taxRate,
a.dErstelldatum AS createdAt,
CONVERT(BIGINT, a.bRowversion) AS lastChanged,
(
SELECT TOP 1 img.cHash
FROM dbo.tArtikelbildPlattform abp
INNER JOIN dbo.tBild img ON img.kBild = abp.kBild
WHERE abp.kArtikel = a.kArtikel
AND abp.kPlattform = @imagePlatformId
AND abp.kShop = @imageShopId
ORDER BY abp.nNr
) AS imgHash,
(
SELECT STRING_AGG(CAST(ka.kKategorie AS varchar(20)), ',')
FROM dbo.tkategorieartikel ka
WHERE ka.kArtikel = a.kArtikel
) AS categoryIds,
a.nIstVater AS isParent,
a.kVaterArtikel AS parentArticleId,
(
SELECT TOP 1 pv.cVariantName
FROM Pos.vProductVariant pv
WHERE pv.kProduct = a.kArtikel
) AS variantName
FROM dbo.tArtikel a
INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse
WHERE a.cAktiv = 'Y'
AND CONVERT(BIGINT, a.bRowversion) > @cursor
ORDER BY lastChanged ASC;
`;
function priceOverridesSql(articleIds) {
const idList = articleIds.join(',');
return `
SELECT p.kArtikel AS articleId, p.kKundenGruppe AS customerGroupId, MIN(pd.fNettoPreis) AS netPrice
FROM dbo.tPreis p
INNER JOIN dbo.tPreisDetail pd ON pd.kPreis = p.kPreis
WHERE p.kArtikel IN (${idList}) AND p.kShop = 0 AND pd.nAnzahlAb = 0
GROUP BY p.kArtikel, p.kKundenGruppe;
`;
}
function formatDateTime(date) {
if (!date) {
return '0001-01-01 00:00:00';
}
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function grossPrice(netPrice, taxRate) {
return (Number(netPrice) * (1 + Number(taxRate || 0) / 100)).toFixed(2);
}
export async function getProductList({ cursor = 0, limit = 20 } = {}) {
const pool = getPool();
const [productResult, customerGroupIds] = await Promise.all([
pool
.request()
.input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID)
.input('taxZoneName', sql.NVarChar, TAX_ZONE_NAME)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(PRODUCT_LIST_SQL),
getCustomerGroupIds(),
]);
const products = productResult.recordset;
const articleIds = products.map((p) => p.id);
const overridesByArticle = new Map();
let attributesByArticle = new Map();
if (articleIds.length > 0) {
const [overrideResult, attributeMap] = await Promise.all([
pool.request().query(priceOverridesSql(articleIds)),
getProductAttributes(pool, articleIds),
]);
attributesByArticle = attributeMap;
for (const row of overrideResult.recordset) {
if (!overridesByArticle.has(row.articleId)) {
overridesByArticle.set(row.articleId, new Map());
}
overridesByArticle.get(row.articleId).set(row.customerGroupId, row.netPrice);
}
}
return products.map((product) => {
const overrides = overridesByArticle.get(product.id);
const basePrice = grossPrice(product.netPrice, product.taxRate);
const prices = customerGroupIds.map((customerGroupId) => {
const overrideNetPrice = overrides?.get(customerGroupId);
const price =
overrideNetPrice !== undefined ? grossPrice(overrideNetPrice, product.taxRate) : basePrice;
return {
customerGroupId: String(customerGroupId),
customerId: '0',
price,
quantity: '0',
};
});
const categoryIds = product.categoryIds ? product.categoryIds.split(',') : [];
const articleAttributes = attributesByArticle.get(product.id);
return {
_id: String(product.id),
imghash: product.imgHash ?? null,
imgsrc: product.imgHash ?? null,
sku: product.sku,
name: product.name,
tax_rate: String(Math.round(Number(product.taxRate || 0))),
price: basePrice,
created_at: formatDateTime(product.createdAt),
lastChanged: String(product.lastChanged),
categories_id: categoryIds[0] ?? '0',
categories: categoryIds.map((categoryId) => ({ categoryId })),
prices,
is_parent: product.isParent ? '1' : '0',
parent: product.parentArticleId > 0 ? String(product.parentArticleId) : '0',
variants: product.variantName ?? '',
attributes: articleAttributes?.attributes ?? [],
...(articleAttributes?.deposit ?? {}),
};
});
}