u
This commit is contained in:
@@ -7,7 +7,7 @@ import cookieParser from 'cookie-parser';
|
||||
import { loggerMiddleware } from './middleware/logger.js';
|
||||
import env from '../config/env.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { ensureConnection } from '../db/client.js';
|
||||
import { ensureConnection } from '../db/store.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -6,17 +6,17 @@ import { meJson } from '../auth/meJson.js';
|
||||
const userCache = new Map();
|
||||
const USER_CACHE_TTL = 10 * 60 * 1000;
|
||||
|
||||
export function getCachedUser(id) {
|
||||
const cached = userCache.get(id);
|
||||
export function getCachedUser(email) {
|
||||
const cached = userCache.get(email);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
userCache.delete(id);
|
||||
userCache.delete(email);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setCachedUser(id, user) {
|
||||
userCache.set(id, { user, expiresAt: Date.now() + USER_CACHE_TTL });
|
||||
export function setCachedUser(email, user) {
|
||||
userCache.set(email, { user, expiresAt: Date.now() + USER_CACHE_TTL });
|
||||
if (userCache.size > 1000) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of userCache) {
|
||||
@@ -25,8 +25,8 @@ export function setCachedUser(id, user) {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedUser(id) {
|
||||
userCache.delete(id);
|
||||
export function clearCachedUser(email) {
|
||||
userCache.delete(email);
|
||||
}
|
||||
|
||||
function authMeJson(res, req, status, body) {
|
||||
@@ -51,10 +51,10 @@ export const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, env.jwtSecret);
|
||||
|
||||
let user = getCachedUser(decoded.id);
|
||||
let user = getCachedUser(decoded.email);
|
||||
if (!user) {
|
||||
user = db.getAllowedEmailById(decoded.id);
|
||||
if (user) setCachedUser(decoded.id, user);
|
||||
user = await db.getAllowedEmail(decoded.email);
|
||||
if (user) setCachedUser(decoded.email, user);
|
||||
}
|
||||
if (!user) {
|
||||
return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
|
||||
@@ -63,6 +63,7 @@ export const authMiddleware = async (req, res, next) => {
|
||||
req.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
selectedCompanyId: user.selectedCompanyId,
|
||||
...(decoded.picture && { picture: decoded.picture }),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getArticle } from '../../../services/article/getArticle.js';
|
||||
|
||||
/**
|
||||
* Registers the full article lookup route backed by MSSQL.
|
||||
* @param {import('express').Application} app - Express app
|
||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /article/get
|
||||
* @description Look up a single article by exact article number, EAN, or barcode.
|
||||
* @query {string} target - Article number, packaging EAN, or barcode
|
||||
* @returns {Object} 200 - `{ article }` with enriched article data
|
||||
* @returns {Object} 400 - `{ error }` when target is missing
|
||||
* @returns {Object} 404 - `{ error }` when no article matches
|
||||
*/
|
||||
app.get(`${basePath}/article/get`, async (req, res) => {
|
||||
const target = req.query.target;
|
||||
|
||||
if (!target || !String(target).trim()) {
|
||||
return res.status(400).json({ error: 'Query parameter "target" is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const article = await getArticle(String(target));
|
||||
if (!article) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
return res.status(200).json({ article });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { parseFilter, searchArticles } from '../../../services/article/searchArticles.js';
|
||||
|
||||
/**
|
||||
* Registers the article search route backed by MSSQL.
|
||||
* @param {import('express').Application} app - Express app
|
||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /article/search
|
||||
* @description Search articles by partial match on name, number, EAN, barcode, or supplier number.
|
||||
* @query {string} target - Space-separated search terms
|
||||
* @query {string} [filter] - Optional comma-separated category filter IDs (empty = all)
|
||||
* @query {number} [offset=0] - Number of results to skip
|
||||
* @query {number} [maxCount=10] - Maximum number of results to return
|
||||
* @returns {Object} 200 - `{ categories, length, recordset }`
|
||||
* @returns {Object} 400 - `{ error }` when target is missing
|
||||
*/
|
||||
app.get(`${basePath}/article/search`, async (req, res) => {
|
||||
const target = req.query.target;
|
||||
|
||||
if (!target || !String(target).trim()) {
|
||||
return res.status(400).json({ error: 'Query parameter "target" is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = parseFilter(req.query.filter);
|
||||
const offset = req.query.offset !== undefined ? Number(req.query.offset) : 0;
|
||||
const maxCount = req.query.maxCount !== undefined ? Number(req.query.maxCount) : 10;
|
||||
const result = await searchArticles(String(target), filter, offset, maxCount);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function register(app, basePath = '') {
|
||||
* @route POST /auth/login
|
||||
* @description Verifies a Google ID token, checks allowlist, and sets an HTTP-only `token` cookie.
|
||||
* @body {string} idToken - Google ID token from Google Identity Services
|
||||
* @returns {Object} 200 - `{ message, user }` with `{ id, email, picture? }` in `user`
|
||||
* @returns {Object} 200 - `{ message, user }` with `{ id, email, selectedCompanyId, picture? }` in `user`
|
||||
* @returns {Object} 400 - Missing token or Google client not configured
|
||||
* @returns {Object} 401 - Invalid Google token
|
||||
* @returns {Object} 403 - Email not on allowlist
|
||||
@@ -43,7 +43,7 @@ export function register(app, basePath = '') {
|
||||
return res.status(401).json({ error: 'Invalid Google account' });
|
||||
}
|
||||
|
||||
const allowed = db.getAllowedEmail(email);
|
||||
const allowed = await db.getAllowedEmail(email);
|
||||
if (!allowed) {
|
||||
console.warn(`Login rejected: email not authorized (${email})`);
|
||||
return res.status(403).json({ error: 'Email is not authorized' });
|
||||
@@ -52,6 +52,7 @@ export function register(app, basePath = '') {
|
||||
const user = {
|
||||
id: allowed.id,
|
||||
email: allowed.email,
|
||||
selectedCompanyId: allowed.selectedCompanyId,
|
||||
...(payload.picture && { picture: payload.picture }),
|
||||
};
|
||||
const token = jwt.sign(user, env.jwtSecret, { expiresIn: '1d' });
|
||||
|
||||
@@ -18,8 +18,8 @@ export function register(app, basePath = '') {
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, env.jwtSecret, { ignoreExpiration: true });
|
||||
if (decoded?.id) {
|
||||
clearCachedUser(decoded.id);
|
||||
if (decoded?.email) {
|
||||
clearCachedUser(decoded.email);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cache clearing on logout
|
||||
|
||||
@@ -9,11 +9,11 @@ export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /auth/me
|
||||
* @description Returns the authenticated user from `req.user` and the Google client ID.
|
||||
* @returns {Object} 200 - `{ user: { id, email, picture? }, googleClientId }`
|
||||
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? }, googleClientId }`
|
||||
*/
|
||||
app.get(`${basePath}/auth/me`, (req, res) => {
|
||||
const { id, email, picture } = req.user;
|
||||
const user = { id, email, ...(picture && { picture }) };
|
||||
const { id, email, selectedCompanyId, picture } = req.user;
|
||||
const user = { id, email, selectedCompanyId, ...(picture && { picture }) };
|
||||
meJson(res, 200, { user });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ensureConnection } from '../../db/client.js';
|
||||
import { ensureConnection } from '../../db/store.js';
|
||||
|
||||
/**
|
||||
* Registers a database connectivity health check route.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { getCategories } from '../../../services/misc/getCategories.js';
|
||||
|
||||
/**
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /misc/getCategories
|
||||
* @returns {Object} 200 - `{ categories, tree }`
|
||||
*/
|
||||
app.get(`${basePath}/misc/getCategories`, async (_req, res) => {
|
||||
try {
|
||||
const result = await getCategories();
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { getSteuer } from '../../../services/misc/getSteuer.js';
|
||||
|
||||
/**
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /misc/getSteuer
|
||||
* @returns {Object} 200 - `{ steuerklassen }`
|
||||
*/
|
||||
app.get(`${basePath}/misc/getSteuer`, async (_req, res) => {
|
||||
try {
|
||||
res.status(200).json(await getSteuer());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,13 +5,6 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true });
|
||||
|
||||
function parseIntList(value) {
|
||||
return (value || '')
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
export default {
|
||||
port: Number(process.env.PORT) || 4208,
|
||||
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
|
||||
@@ -20,7 +13,6 @@ export default {
|
||||
.filter(Boolean),
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
|
||||
sqlitePath: process.env.SQLITE_PATH,
|
||||
mssql: {
|
||||
server: process.env.MSSQL_SERVER,
|
||||
database: process.env.MSSQL_DATABASE,
|
||||
@@ -30,9 +22,4 @@ export default {
|
||||
encrypt: process.env.MSSQL_ENCRYPT === 'true',
|
||||
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE === 'true',
|
||||
},
|
||||
article: {
|
||||
katSkip: parseIntList(process.env.KAT_SKIP),
|
||||
katLists: Number(process.env.KAT_LISTS) || 0,
|
||||
},
|
||||
steuerZone: process.env.STEUER_ZONE || 'Zone-EU',
|
||||
};
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import env from '../config/env.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const defaultPath = resolve(__dirname, '../../data/dashback.db');
|
||||
|
||||
let db;
|
||||
|
||||
function getDbPath() {
|
||||
return env.sqlitePath || defaultPath;
|
||||
}
|
||||
|
||||
function initSchema(database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS google_auth (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
const dbPath = getDbPath();
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
initSchema(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function ensureConnection() {
|
||||
getDb().prepare('SELECT 1').get();
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,27 @@
|
||||
import { getDb } from './client.js';
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export function listAllowedEmails() {
|
||||
return getDb()
|
||||
.prepare('SELECT id, email, created_at FROM google_auth ORDER BY email COLLATE NOCASE')
|
||||
.all();
|
||||
export async function listAllowedEmails() {
|
||||
const store = await getStore();
|
||||
return store.google_auth.list();
|
||||
}
|
||||
|
||||
export function getAllowedEmail(email) {
|
||||
return getDb()
|
||||
.prepare('SELECT id, email, created_at FROM google_auth WHERE email = ? COLLATE NOCASE')
|
||||
.get(email);
|
||||
}
|
||||
|
||||
export function getAllowedEmailById(id) {
|
||||
return getDb()
|
||||
.prepare('SELECT id, email, created_at FROM google_auth WHERE id = ?')
|
||||
.get(id);
|
||||
}
|
||||
|
||||
export function addAllowedEmail(email) {
|
||||
export async function getAllowedEmail(email) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const result = getDb()
|
||||
.prepare('INSERT INTO google_auth (email) VALUES (?)')
|
||||
.run(normalized);
|
||||
return getAllowedEmailById(result.lastInsertRowid);
|
||||
const store = await getStore();
|
||||
return (await store.google_auth.getByEmail(normalized)) ?? null;
|
||||
}
|
||||
|
||||
export function removeAllowedEmail(email) {
|
||||
export async function addAllowedEmail(email) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return getDb()
|
||||
.prepare('DELETE FROM google_auth WHERE email = ? COLLATE NOCASE')
|
||||
.run(normalized).changes > 0;
|
||||
const store = await getStore();
|
||||
const id = await store.google_auth.add(normalized);
|
||||
return { id, email: normalized };
|
||||
}
|
||||
|
||||
export async function removeAllowedEmail(email) {
|
||||
const row = await getAllowedEmail(email);
|
||||
if (!row) return false;
|
||||
const store = await getStore();
|
||||
await store.google_auth.del(row.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export const LOAD_KAT_MAP = `
|
||||
SELECT kKategorie, kOberKategorie
|
||||
FROM dbo.tkategorie
|
||||
`;
|
||||
|
||||
export const LOAD_CATEGORIES = `
|
||||
SELECT
|
||||
kKategorie,
|
||||
kOberKategorie,
|
||||
nSort,
|
||||
(
|
||||
SELECT cName
|
||||
FROM dbo.tKategorieSprache
|
||||
WHERE kSprache = 1
|
||||
AND kPlattform = 1
|
||||
AND kShop = 0
|
||||
AND dbo.tkategorie.kKategorie = dbo.tKategorieSprache.kKategorie
|
||||
) AS cName
|
||||
FROM dbo.tkategorie
|
||||
ORDER BY nSort, cName
|
||||
`;
|
||||
|
||||
export const CATEGORY_ARTICLE_COUNTS = `
|
||||
SELECT ka.kKategorie, COUNT(*) AS count
|
||||
FROM tkategorieartikel ka
|
||||
INNER JOIN tArtikel a ON a.kArtikel = ka.kArtikel AND a.cAktiv = 'Y'
|
||||
GROUP BY ka.kKategorie
|
||||
`;
|
||||
@@ -1,248 +0,0 @@
|
||||
export const FIND_ARTICLE = `
|
||||
SELECT TOP 1
|
||||
tArtikelBeschreibung.cBeschreibung,
|
||||
tArtikelBeschreibung.cKurzBeschreibung,
|
||||
b.kVersandklasse,
|
||||
(SELECT cName FROM dbo.tVersandklasse WHERE tVersandklasse.kVersandklasse = b.kVersandklasse) AS tVersandklassecName,
|
||||
nIstVater,
|
||||
kStueckliste,
|
||||
kVaterArtikel,
|
||||
b.kEigenschaftKombi,
|
||||
b.kArtikel,
|
||||
fUVP,
|
||||
fGewicht,
|
||||
tHersteller.cName AS cHersteller,
|
||||
cBarcode,
|
||||
fBestand,
|
||||
b.cArtNr,
|
||||
b.cAktiv,
|
||||
tArtikelBeschreibung.cName,
|
||||
b.fVKNEtto,
|
||||
b.kSteuerklasse,
|
||||
nVPE,
|
||||
kMassEinheit,
|
||||
fMassMenge,
|
||||
kGrundPreisEinheit,
|
||||
cAnmerkung,
|
||||
tEigenschaftSprache.kEigenschaft,
|
||||
tEigenschaftSprache.cName AS cEigenschaftName,
|
||||
tEigenschaftWertSprache.kEigenschaftWert,
|
||||
tEigenschaftWertSprache.cName AS cEigenschaftWertName,
|
||||
(
|
||||
SELECT c.cArtNr
|
||||
FROM tArtikel c
|
||||
WHERE c.kArtikel = (
|
||||
SELECT tArtikel_kArtikel
|
||||
FROM tliefartikel
|
||||
WHERE cLiefArtNr = b.cArtNr AND tlieferant_klieferant = 96
|
||||
)
|
||||
) AS cLiefArtNr96
|
||||
FROM tArtikelBeschreibung
|
||||
INNER JOIN tArtikel b ON b.kArtikel = tArtikelBeschreibung.kArtikel
|
||||
LEFT JOIN dbo.vLagerbestandProLager ON (
|
||||
dbo.vLagerbestandProLager.kArtikel = b.kArtikel
|
||||
AND vLagerbestandProLager.kWarenlager = 1
|
||||
)
|
||||
LEFT JOIN tGebinde ON b.kArtikel = tGebinde.kArtikel
|
||||
LEFT JOIN tHersteller ON b.kHersteller = tHersteller.kHersteller
|
||||
LEFT JOIN tEigenschaftKombiWert ON b.kEigenschaftKombi = tEigenschaftKombiWert.kEigenschaftKombi
|
||||
LEFT JOIN tEigenschaftSprache ON (
|
||||
tEigenschaftKombiWert.kEigenschaft = tEigenschaftSprache.kEigenschaft
|
||||
AND tEigenschaftSprache.kSprache = 1
|
||||
)
|
||||
LEFT JOIN tEigenschaftWertSprache ON (
|
||||
tEigenschaftWertSprache.kEigenschaftWert = tEigenschaftKombiWert.kEigenschaftWert
|
||||
AND tEigenschaftWertSprache.kSprache = 1
|
||||
)
|
||||
WHERE b.cArtNr = @target OR tGebinde.cEAN = @target OR b.cBarcode = @target
|
||||
`;
|
||||
|
||||
export const CHILDREN_BY_VATER = `
|
||||
SELECT
|
||||
tArtikel.cArtNr,
|
||||
tArtikelBeschreibung.cName,
|
||||
tArtikel.kEigenschaftKombi,
|
||||
tEigenschaftSprache.kEigenschaft,
|
||||
tEigenschaftSprache.cName AS cEigenschaftName,
|
||||
tEigenschaftWertSprache.kEigenschaftWert,
|
||||
tEigenschaftWertSprache.cName AS cEigenschaftWertName
|
||||
FROM tArtikelBeschreibung
|
||||
INNER JOIN tArtikel ON tArtikel.kArtikel = tArtikelBeschreibung.kArtikel
|
||||
LEFT JOIN tEigenschaftKombiWert ON tArtikel.kEigenschaftKombi = tEigenschaftKombiWert.kEigenschaftKombi
|
||||
LEFT JOIN tEigenschaftSprache ON (
|
||||
tEigenschaftKombiWert.kEigenschaft = tEigenschaftSprache.kEigenschaft
|
||||
AND tEigenschaftSprache.kSprache = 1
|
||||
)
|
||||
LEFT JOIN tEigenschaftWertSprache ON (
|
||||
tEigenschaftWertSprache.kEigenschaftWert = tEigenschaftKombiWert.kEigenschaftWert
|
||||
AND tEigenschaftWertSprache.kSprache = 1
|
||||
)
|
||||
WHERE tArtikel.kVaterArtikel = @kArtikel
|
||||
`;
|
||||
|
||||
export const VATER_BY_ID = `
|
||||
SELECT tArtikel.cArtNr
|
||||
FROM tArtikelBeschreibung
|
||||
INNER JOIN tArtikel ON tArtikel.kArtikel = tArtikelBeschreibung.kArtikel
|
||||
WHERE tArtikel.kArtikel = @kArtikel
|
||||
`;
|
||||
|
||||
export const STUECKLISTE = `
|
||||
SELECT
|
||||
tArtikelBeschreibung.cName,
|
||||
tArtikel.cArtNr,
|
||||
tStueckliste.fAnzahl,
|
||||
tArtikel.fVKNEtto,
|
||||
tArtikel.kSteuerklasse,
|
||||
vLagerbestandProLager.fBestand
|
||||
FROM dbo.tStueckliste
|
||||
INNER JOIN dbo.tArtikelBeschreibung ON tArtikelBeschreibung.kArtikel = tStueckliste.kArtikel
|
||||
LEFT JOIN dbo.vLagerbestandProLager ON (
|
||||
vLagerbestandProLager.kArtikel = tStueckliste.kArtikel
|
||||
AND vLagerbestandProLager.kWarenlager = 1
|
||||
)
|
||||
INNER JOIN dbo.tArtikel ON tArtikel.kArtikel = tStueckliste.kArtikel
|
||||
WHERE tStueckliste.kStueckliste = @kStueckliste
|
||||
ORDER BY tStueckliste.nSort
|
||||
`;
|
||||
|
||||
export const ANDERE_LAGER = `
|
||||
SELECT SUM(fBestand) AS fSumme
|
||||
FROM dbo.vLagerbestandProLager
|
||||
WHERE dbo.vLagerbestandProLager.kArtikel = @kArtikel
|
||||
AND vLagerbestandProLager.kWarenlager != 1
|
||||
GROUP BY dbo.vLagerbestandProLager.kArtikel
|
||||
`;
|
||||
|
||||
export const EANS = `
|
||||
SELECT cEAN
|
||||
FROM tGebinde
|
||||
WHERE kArtikel = @kArtikel
|
||||
`;
|
||||
|
||||
export function categoriesQuery(katSkipClause, katLists) {
|
||||
return `
|
||||
SELECT kKategorie
|
||||
FROM dbo.tkategorieartikel
|
||||
WHERE kArtikel = @kArtikel
|
||||
AND ${katSkipClause}kKategorie NOT IN (
|
||||
SELECT kKategorie
|
||||
FROM tKategorie
|
||||
WHERE kOberKategorie = ${katLists}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
export const SUPPLIERS = `
|
||||
SELECT
|
||||
tlieferant.kLieferant,
|
||||
cFirma,
|
||||
tliefartikel.cLiefArtNr,
|
||||
nStandard,
|
||||
fLagerbestand,
|
||||
cVPEEinheit,
|
||||
nVPEMenge,
|
||||
fMwSt,
|
||||
fEKNetto,
|
||||
tShopKorb.nMenge,
|
||||
tlieferant.cEmail,
|
||||
tlieferant.cAnmerkung
|
||||
FROM tliefartikel
|
||||
INNER JOIN tlieferant ON tlieferant.kLieferant = tliefartikel.tLieferant_kLieferant
|
||||
LEFT JOIN tShopKorb ON (
|
||||
tShopKorb.kLieferant = tlieferant.kLieferant
|
||||
AND tliefartikel.cLiefArtNr = tShopKorb.cLiefArtNr
|
||||
)
|
||||
WHERE tArtikel_kArtikel = @kArtikel
|
||||
ORDER BY nStandard DESC, tlieferant.kLieferant
|
||||
`;
|
||||
|
||||
export const INCOMING_ORDERS = `
|
||||
SELECT
|
||||
tLieferantenBestellung.kLieferant,
|
||||
tLieferantenBestellungPos.kLieferantenBestellungPos,
|
||||
tLieferantenBestellungPos.kLieferantenBestellung,
|
||||
cEigeneBestellnummer,
|
||||
tLieferantenBestellungPos.kLieferantenBestellung + 1 AS kLieferantenBestellung2,
|
||||
cFirma,
|
||||
tLieferantenBestellungPos.dLieferdatum,
|
||||
cFremdbelegnummer,
|
||||
cInternerKommentar,
|
||||
fAnzahlOffen
|
||||
FROM tLieferantenBestellung
|
||||
INNER JOIN tlieferant ON tlieferant.kLieferant = tLieferantenBestellung.kLieferant
|
||||
INNER JOIN tLieferantenBestellungPos ON (
|
||||
tLieferantenBestellungPos.kLieferantenBestellung = tLieferantenBestellung.kLieferantenBestellung
|
||||
)
|
||||
WHERE fAnzahlOffen > 0 AND kArtikel = @kArtikel
|
||||
ORDER BY tLieferantenBestellungPos.kLieferantenBestellung
|
||||
`;
|
||||
|
||||
export const OPEN_INCIDENTS = `
|
||||
SELECT cType, cNote, kIncident, cEmployeeIssuer, cEmployeeMap
|
||||
FROM dbo.tIncident
|
||||
WHERE kRef1 = @kArtikel
|
||||
AND cState = 'new'
|
||||
AND cType IN ('order_count', 'order_label', 'order_free')
|
||||
`;
|
||||
|
||||
export const MERKMALE = `
|
||||
SELECT
|
||||
tArtikelMerkmal.kMerkmal,
|
||||
tArtikelMerkmal.kMerkmalWert,
|
||||
tMerkmalSprache.cName,
|
||||
tMerkmalWertSprache.cWert
|
||||
FROM dbo.tArtikelMerkmal
|
||||
INNER JOIN dbo.tMerkmalSprache ON tMerkmalSprache.kMerkmal = tArtikelMerkmal.kMerkmal
|
||||
INNER JOIN dbo.tMerkmalWertSprache ON tMerkmalWertSprache.kMerkmalWert = tArtikelMerkmal.kMerkmalWert
|
||||
WHERE tArtikelMerkmal.kArtikel = @kArtikel
|
||||
ORDER BY tArtikelMerkmal.kMerkmal
|
||||
`;
|
||||
|
||||
export const ATTRIBUTES = `
|
||||
SELECT
|
||||
tArtikelAttribut.kAttribut,
|
||||
tArtikelAttributSprache.cWertVarchar,
|
||||
tArtikelAttributSprache.nWertInt,
|
||||
tArtikelAttributSprache.fWertDecimal
|
||||
FROM dbo.tArtikelAttribut
|
||||
INNER JOIN dbo.tArtikelAttributSprache ON (
|
||||
tArtikelAttributSprache.kArtikelAttribut = tArtikelAttribut.kArtikelAttribut
|
||||
AND kSprache = 0
|
||||
)
|
||||
WHERE kShop = 0 AND tArtikelAttribut.kArtikel = @kArtikel
|
||||
`;
|
||||
|
||||
export const STAFFEL = `
|
||||
SELECT nAnzahlAb, fNEttoPreis
|
||||
FROM tPreis
|
||||
INNER JOIN tPreisDetail ON tPreisDetail.kPreis = tPreis.kPreis
|
||||
WHERE kArtikel = @kArtikel AND kKundenGruppe = 2 AND kShop = 0
|
||||
`;
|
||||
|
||||
export const EXTRA_LAGER = `
|
||||
SELECT nMenge
|
||||
FROM tExtraLager
|
||||
WHERE kArtikel = @kArtikel AND nLager = @nLager
|
||||
`;
|
||||
|
||||
export const SHOP_SUM = `
|
||||
SELECT SUM(kShop) AS kShop
|
||||
FROM tArtikelShop
|
||||
WHERE kArtikel = @kArtikel
|
||||
`;
|
||||
|
||||
export const HERSTELLER_GPSR = `
|
||||
SELECT
|
||||
tHersteller.cBeschreibung AS cHerstellerGPSR,
|
||||
b.bVorschauBild AS herstellerBild
|
||||
FROM tArtikel
|
||||
INNER JOIN tHersteller ON tArtikel.kHersteller = tHersteller.kHersteller
|
||||
LEFT JOIN tHerstellerBildPlattform hbp ON (
|
||||
hbp.kHersteller = tHersteller.kHersteller
|
||||
AND hbp.kPlattform = 1
|
||||
AND hbp.kShop = 0
|
||||
)
|
||||
LEFT JOIN tBild b ON b.kBild = hbp.kBild
|
||||
WHERE tArtikel.kArtikel = @kArtikel
|
||||
`;
|
||||
@@ -1,37 +0,0 @@
|
||||
export const SELECT_COLUMNS = `
|
||||
(SELECT STRING_AGG(tk1.kKategorie, ',')
|
||||
FROM dbo.tkategorieartikel AS tk1
|
||||
WHERE tk1.kArtikel = tArtikel.kArtikel
|
||||
GROUP BY tk1.kArtikel) AS katList,
|
||||
MIN(fVerfuegbar) AS fVerfuegbar,
|
||||
tArtikel.kArtikel,
|
||||
MIN(fBestand) AS fBestand,
|
||||
MIN(tArtikel.cArtNr) AS cArtNr,
|
||||
MIN(tArtikelBeschreibung.cName) AS cName,
|
||||
MIN(tArtikel.fVKNEtto) AS fVKNEtto,
|
||||
MIN(tArtikel.kSteuerklasse) AS kSteuerklasse
|
||||
`;
|
||||
|
||||
export const FROM_JOINS = `
|
||||
FROM tArtikelBeschreibung
|
||||
INNER JOIN tArtikel ON tArtikel.kArtikel = tArtikelBeschreibung.kArtikel
|
||||
LEFT JOIN tLiefArtikel ON tArtikel.kArtikel = tLiefArtikel.tArtikel_kArtikel
|
||||
LEFT JOIN tGebinde ON tArtikel.kArtikel = tGebinde.kArtikel
|
||||
LEFT JOIN dbo.tlagerbestand ON dbo.tlagerbestand.kArtikel = tArtikel.kArtikel
|
||||
LEFT JOIN dbo.vLagerbestandProLager ON (
|
||||
dbo.vLagerbestandProLager.kArtikel = tArtikel.kArtikel
|
||||
AND vLagerbestandProLager.kWarenlager = 1
|
||||
)
|
||||
`;
|
||||
|
||||
export const GROUP_ORDER = `
|
||||
GROUP BY tArtikel.kArtikel
|
||||
ORDER BY MIN(tArtikelBeschreibung.cName)
|
||||
`;
|
||||
|
||||
export const CATEGORY_JOIN = `
|
||||
INNER JOIN dbo.tkategorieartikel ON (
|
||||
tkategorieartikel.kArtikel = tArtikel.kArtikel
|
||||
AND ({categoryFilter})
|
||||
)
|
||||
`;
|
||||
@@ -1,24 +0,0 @@
|
||||
export const LOAD_STEUERSATZ = `
|
||||
SELECT
|
||||
kSteuerklasse,
|
||||
fSteuersatz
|
||||
FROM dbo.tSteuersatz
|
||||
WHERE kSteuerzone IN (
|
||||
SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @steuerZone
|
||||
)
|
||||
ORDER BY kSteuerklasse
|
||||
`;
|
||||
|
||||
export const LOAD_STEUERKLASSEN = `
|
||||
SELECT
|
||||
sk.kSteuerklasse,
|
||||
sk.cName,
|
||||
sk.nStandard,
|
||||
sz.fSteuersatz
|
||||
FROM dbo.tSteuerklasse sk
|
||||
INNER JOIN dbo.tSteuersatz sz ON sz.kSteuerklasse = sk.kSteuerklasse
|
||||
WHERE sz.kSteuerzone IN (
|
||||
SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @steuerZone
|
||||
)
|
||||
ORDER BY sk.kSteuerklasse
|
||||
`;
|
||||
@@ -1,7 +1,9 @@
|
||||
import sql from 'mssql';
|
||||
import LibDashStore from 'libdashstore';
|
||||
import env from '../config/env.js';
|
||||
|
||||
let pool;
|
||||
let store;
|
||||
let connecting;
|
||||
|
||||
function getMssqlConfig() {
|
||||
@@ -29,36 +31,36 @@ function getMssqlConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPool() {
|
||||
if (pool) {
|
||||
return pool;
|
||||
/**
|
||||
* Returns the initialized LibDashStore singleton.
|
||||
* @returns {Promise<InstanceType<typeof LibDashStore>>}
|
||||
*/
|
||||
export async function getStore() {
|
||||
if (store) {
|
||||
return store;
|
||||
}
|
||||
|
||||
if (!connecting) {
|
||||
connecting = new sql.ConnectionPool(getMssqlConfig())
|
||||
.connect()
|
||||
.then((connectedPool) => {
|
||||
pool = connectedPool;
|
||||
return pool;
|
||||
})
|
||||
.finally(() => {
|
||||
connecting = undefined;
|
||||
});
|
||||
connecting = (async () => {
|
||||
pool = await new sql.ConnectionPool(getMssqlConfig()).connect();
|
||||
store = await new LibDashStore(pool);
|
||||
return store;
|
||||
})().finally(() => {
|
||||
connecting = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return connecting;
|
||||
}
|
||||
|
||||
export async function ensureConnection() {
|
||||
const activePool = await getPool();
|
||||
await activePool.request().query('SELECT 1 AS ok');
|
||||
await getStore();
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
export async function closeStore() {
|
||||
if (pool) {
|
||||
await pool.close();
|
||||
pool = undefined;
|
||||
store = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export { sql };
|
||||
@@ -1,13 +1,11 @@
|
||||
import './config/env.js';
|
||||
import { startApi, stopApi } from './api/index.js';
|
||||
import { closeDb } from './db/client.js';
|
||||
import { closePool } from './db/mssqlClient.js';
|
||||
import { closeStore } from './db/store.js';
|
||||
|
||||
async function shutdown() {
|
||||
await stopApi();
|
||||
try {
|
||||
closeDb();
|
||||
await closePool();
|
||||
await closeStore();
|
||||
} catch (error) {
|
||||
console.error('Failed to close database:', error);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export function enrichSupplierAnmerkungRow(row) {
|
||||
const enriched = { ...row };
|
||||
|
||||
if (typeof row.cAnmerkung === 'string' && row.cAnmerkung.trim().startsWith('{')) {
|
||||
try {
|
||||
enriched.anmerkung = JSON.parse(row.cAnmerkung);
|
||||
} catch {
|
||||
enriched.anmerkung = row.cAnmerkung;
|
||||
}
|
||||
} else if (row.cAnmerkung != null) {
|
||||
enriched.anmerkung = row.cAnmerkung;
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import env from '../../config/env.js';
|
||||
import { getPool } from '../../db/mssqlClient.js';
|
||||
import { enrichSupplierAnmerkungRow } from './enrichSupplier.js';
|
||||
import { bruttoFromNetto, loadSteuerklassen } from '../misc/getSteuer.js';
|
||||
import * as Q from '../../db/mssql/getArticle.js';
|
||||
|
||||
async function query(pool, sql, inputs = {}) {
|
||||
const request = pool.request();
|
||||
for (const [name, value] of Object.entries(inputs)) {
|
||||
request.input(name, value);
|
||||
}
|
||||
const result = await request.query(sql);
|
||||
return result.recordset;
|
||||
}
|
||||
|
||||
function buildKatSkipClause() {
|
||||
if (!env.article.katSkip.length) {
|
||||
return '';
|
||||
}
|
||||
return env.article.katSkip.map((id) => `kKategorie != ${id}`).join(' AND ') + ' AND ';
|
||||
}
|
||||
|
||||
async function findArticle(pool, target) {
|
||||
const rows = await query(pool, Q.FIND_ARTICLE, { target });
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function loadChildren(pool, kArtikel) {
|
||||
return query(pool, Q.CHILDREN_BY_VATER, { kArtikel });
|
||||
}
|
||||
|
||||
async function loadVater(pool, kVaterArtikel) {
|
||||
const rows = await query(pool, Q.VATER_BY_ID, { kArtikel: kVaterArtikel });
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function loadStueckliste(pool, kStueckliste) {
|
||||
return query(pool, Q.STUECKLISTE, { kStueckliste });
|
||||
}
|
||||
|
||||
async function loadWarehouseExtras(pool, kArtikel) {
|
||||
const [fAndereLager, EANs, KATs] = await Promise.all([
|
||||
query(pool, Q.ANDERE_LAGER, { kArtikel }),
|
||||
query(pool, Q.EANS, { kArtikel }),
|
||||
query(pool, Q.categoriesQuery(buildKatSkipClause(), env.article.katLists), { kArtikel }),
|
||||
]);
|
||||
|
||||
return { fAndereLager, EANs, KATs };
|
||||
}
|
||||
|
||||
async function loadSuppliers(pool, kArtikel) {
|
||||
const rows = await query(pool, Q.SUPPLIERS, { kArtikel });
|
||||
return rows.map(enrichSupplierAnmerkungRow);
|
||||
}
|
||||
|
||||
async function loadIncomingOrders(pool, kArtikel) {
|
||||
return query(pool, Q.INCOMING_ORDERS, { kArtikel });
|
||||
}
|
||||
|
||||
async function loadOpenIncidents(pool, kArtikel) {
|
||||
return query(pool, Q.OPEN_INCIDENTS, { kArtikel });
|
||||
}
|
||||
|
||||
async function loadMerkmale(pool, kArtikel) {
|
||||
return query(pool, Q.MERKMALE, { kArtikel });
|
||||
}
|
||||
|
||||
async function loadAttributes(pool, kArtikel) {
|
||||
return query(pool, Q.ATTRIBUTES, { kArtikel });
|
||||
}
|
||||
|
||||
async function loadStaffel(pool, kArtikel, kSteuerklasse) {
|
||||
const rows = await query(pool, Q.STAFFEL, { kArtikel });
|
||||
const steuerklassen = await loadSteuerklassen();
|
||||
return rows.map((item) => ({
|
||||
menge: item.nAnzahlAb.toString(),
|
||||
fVKBrutto: bruttoFromNetto(item.fNEttoPreis, kSteuerklasse, steuerklassen)?.toFixed(2),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadExtraLager(pool, kArtikel, nLager) {
|
||||
const rows = await query(pool, Q.EXTRA_LAGER, { kArtikel, nLager });
|
||||
return rows[0]?.nMenge;
|
||||
}
|
||||
|
||||
async function loadShopSum(pool, kArtikel) {
|
||||
const rows = await query(pool, Q.SHOP_SUM, { kArtikel });
|
||||
return rows[0]?.kShop;
|
||||
}
|
||||
|
||||
async function loadHerstellerGpsr(pool, kArtikel) {
|
||||
const rows = await query(pool, Q.HERSTELLER_GPSR, { kArtikel });
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return { cHerstellerGPSR: null, herstellerBild: null };
|
||||
}
|
||||
|
||||
const herstellerBild = row.herstellerBild
|
||||
? Buffer.from(row.herstellerBild).toString('base64')
|
||||
: null;
|
||||
|
||||
return {
|
||||
cHerstellerGPSR: row.cHerstellerGPSR ?? null,
|
||||
herstellerBild,
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichArticle(pool, article) {
|
||||
const { kArtikel, kSteuerklasse, nIstVater, kVaterArtikel, kStueckliste } = article;
|
||||
|
||||
const parallel = [];
|
||||
|
||||
if (nIstVater === 1) {
|
||||
parallel.push(
|
||||
loadChildren(pool, kArtikel).then((children) => {
|
||||
article.children = children;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (kVaterArtikel) {
|
||||
parallel.push(
|
||||
loadVater(pool, kVaterArtikel).then((vater) => {
|
||||
article.vater = vater;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (kStueckliste !== 0) {
|
||||
parallel.push(
|
||||
loadStueckliste(pool, kStueckliste).then((liste) => {
|
||||
article.liste = liste;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
parallel.push(
|
||||
loadWarehouseExtras(pool, kArtikel).then(({ fAndereLager, EANs, KATs }) => {
|
||||
article.fAndereLager = fAndereLager;
|
||||
article.EANs = EANs;
|
||||
article.KATs = KATs;
|
||||
}),
|
||||
loadSuppliers(pool, kArtikel).then((lief) => {
|
||||
article.lief = lief;
|
||||
}),
|
||||
loadIncomingOrders(pool, kArtikel).then((incoming) => {
|
||||
article.incoming = incoming;
|
||||
}),
|
||||
loadOpenIncidents(pool, kArtikel).then((orders) => {
|
||||
article.orders = orders;
|
||||
}),
|
||||
loadMerkmale(pool, kArtikel).then((merkmale) => {
|
||||
article.merkmale = merkmale;
|
||||
}),
|
||||
loadAttributes(pool, kArtikel).then((attribute) => {
|
||||
article.attribute = attribute;
|
||||
}),
|
||||
loadStaffel(pool, kArtikel, kSteuerklasse).then((staffel) => {
|
||||
article.staffel = staffel;
|
||||
}),
|
||||
loadExtraLager(pool, kArtikel, 0).then((garage) => {
|
||||
article.garage = garage;
|
||||
}),
|
||||
loadExtraLager(pool, kArtikel, 1).then((keller) => {
|
||||
article.keller = keller;
|
||||
}),
|
||||
loadShopSum(pool, kArtikel).then((shops) => {
|
||||
article.shops = shops;
|
||||
}),
|
||||
loadHerstellerGpsr(pool, kArtikel).then(({ cHerstellerGPSR, herstellerBild }) => {
|
||||
article.cHerstellerGPSR = cHerstellerGPSR;
|
||||
article.herstellerBild = herstellerBild;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(parallel);
|
||||
return article;
|
||||
}
|
||||
|
||||
export async function getArticle(target) {
|
||||
const trimmed = target?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pool = await getPool();
|
||||
const article = await findArticle(pool, trimmed);
|
||||
if (!article) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enrichArticle(pool, article);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { getPool } from '../../db/mssqlClient.js';
|
||||
import { LOAD_CATEGORIES } from '../../db/mssql/categories.js';
|
||||
import * as Q from '../../db/mssql/searchArticles.js';
|
||||
import { bruttoFromNetto, loadSteuersatz } from '../misc/getSteuer.js';
|
||||
|
||||
let categoryCache;
|
||||
|
||||
export function parseFilter(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return [''];
|
||||
}
|
||||
const parts = String(raw).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return parts.length ? parts : [''];
|
||||
}
|
||||
|
||||
function getUpPath(kat, parentMap) {
|
||||
const list = [kat];
|
||||
if (parentMap[kat]) {
|
||||
for (const item of getUpPath(parentMap[kat], parentMap)) {
|
||||
list.push(item);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async function loadCategoryCache(pool) {
|
||||
if (categoryCache) {
|
||||
return categoryCache;
|
||||
}
|
||||
|
||||
const result = await pool.request().query(LOAD_CATEGORIES);
|
||||
const byId = {};
|
||||
const parentMap = {};
|
||||
for (const item of result.recordset) {
|
||||
byId[item.kKategorie] = {
|
||||
name: item.cName ?? null,
|
||||
parent: item.kOberKategorie,
|
||||
};
|
||||
parentMap[item.kKategorie] = item.kOberKategorie;
|
||||
}
|
||||
categoryCache = { byId, parentMap };
|
||||
return categoryCache;
|
||||
}
|
||||
|
||||
function buildKatAgg(recordset, parentMap) {
|
||||
const counts = {};
|
||||
|
||||
for (const item of recordset) {
|
||||
if (!item.katList) {
|
||||
continue;
|
||||
}
|
||||
for (const kat of item.katList.split(',')) {
|
||||
for (const katpart of getUpPath(kat, parentMap)) {
|
||||
if (!counts[katpart]) {
|
||||
counts[katpart] = 0;
|
||||
}
|
||||
counts[katpart]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
function shapeCategories(counts, byId) {
|
||||
const categories = {};
|
||||
|
||||
for (const [id, count] of Object.entries(counts)) {
|
||||
const meta = byId[id] ?? {};
|
||||
categories[id] = {
|
||||
count,
|
||||
name: meta.name ?? null,
|
||||
parent: meta.parent ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every applied filter id and its ancestors appear in categories
|
||||
* (count 0 if no hits), so the frontend can render hierarchy via parent links.
|
||||
*/
|
||||
function ensureFilterCategories(categories, filter, byId, parentMap) {
|
||||
for (const id of filter) {
|
||||
if (id === '' || id === null) {
|
||||
continue;
|
||||
}
|
||||
for (const ancestorId of getUpPath(id, parentMap)) {
|
||||
const key = String(ancestorId);
|
||||
if (categories[key]) {
|
||||
continue;
|
||||
}
|
||||
const meta = byId[ancestorId] ?? byId[key] ?? {};
|
||||
categories[key] = {
|
||||
count: 0,
|
||||
name: meta.name ?? null,
|
||||
parent: meta.parent ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
function shapeArticle(row, steuersaetze) {
|
||||
const taxRow = steuersaetze.find((item) => item.kSteuerklasse === row.kSteuerklasse);
|
||||
const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuersaetze);
|
||||
|
||||
return {
|
||||
categories: row.katList,
|
||||
available: row.fVerfuegbar,
|
||||
id: row.kArtikel,
|
||||
stock: row.fBestand,
|
||||
sku: row.cArtNr,
|
||||
name: row.cName,
|
||||
price: brutto == null ? null : Math.round(brutto * 10) / 10,
|
||||
tax: taxRow?.fSteuersatz ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTargetClause(request, target) {
|
||||
const parts = [];
|
||||
let i = 0;
|
||||
|
||||
const terms = target
|
||||
.toString()
|
||||
.trim()
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.split(' ')
|
||||
.filter(Boolean);
|
||||
|
||||
for (const part of terms) {
|
||||
request.input(`target_${i}`, `%${part}%`);
|
||||
parts.push(
|
||||
'tGebinde.cEAN LIKE @target_' + i +
|
||||
' OR tLiefArtikel.cLiefArtNr LIKE @target_' + i +
|
||||
' OR tArtikelBeschreibung.cName LIKE @target_' + i +
|
||||
' OR tArtikel.cArtNr LIKE @target_' + i +
|
||||
' OR tArtikel.cBarcode LIKE @target_' + i
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return { parts, nextIndex: i };
|
||||
}
|
||||
|
||||
function buildCategoryClause(request, filter, startIndex) {
|
||||
const parts = [];
|
||||
let i = startIndex;
|
||||
|
||||
for (const part of filter) {
|
||||
request.input(`target_${i}`, part);
|
||||
parts.push(`tkategorieartikel.kKategorie = @target_${i}`);
|
||||
i++;
|
||||
}
|
||||
|
||||
return {
|
||||
clause: '(' + parts.join(') OR (') + ')',
|
||||
nextIndex: i,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWhereClause(parts, filter) {
|
||||
const aktivClause = filter?.[0] == '751' ? '' : " AND (tArtikel.cAktiv = 'Y')";
|
||||
if (!parts.length) {
|
||||
return `1=1${aktivClause}`;
|
||||
}
|
||||
return '(' + parts.join(') AND (') + ')' + aktivClause;
|
||||
}
|
||||
|
||||
function buildQuery(filter, categoryFilter) {
|
||||
const select = filter[0] === '' || filter[0] === null
|
||||
? `SELECT TOP (2000) ${Q.SELECT_COLUMNS}`
|
||||
: `SELECT ${Q.SELECT_COLUMNS}`;
|
||||
|
||||
const categoryJoin = categoryFilter
|
||||
? Q.CATEGORY_JOIN.replace('{categoryFilter}', categoryFilter)
|
||||
: '';
|
||||
|
||||
return `${select}
|
||||
${Q.FROM_JOINS}
|
||||
${categoryJoin}
|
||||
WHERE `;
|
||||
}
|
||||
|
||||
export async function searchArticles(target, filter = [''], offset = 0, maxCount = 10) {
|
||||
const trimmed = target?.toString().trim();
|
||||
if (!trimmed) {
|
||||
return { categories: {}, length: 0, recordset: [] };
|
||||
}
|
||||
|
||||
const start = Math.max(0, Number(offset) || 0);
|
||||
const count = Math.max(0, Number(maxCount) || 10);
|
||||
|
||||
const pool = await getPool();
|
||||
const request = pool.request();
|
||||
const { parts } = buildTargetClause(request, trimmed);
|
||||
const whereClause = buildWhereClause(parts, filter);
|
||||
|
||||
let sql = buildQuery(filter, null) + whereClause + Q.GROUP_ORDER;
|
||||
|
||||
if (filter[0] !== '' && filter[0] !== null) {
|
||||
const { clause: categoryFilter } = buildCategoryClause(request, filter, parts.length);
|
||||
sql = buildQuery(filter, categoryFilter) + whereClause + Q.GROUP_ORDER;
|
||||
}
|
||||
|
||||
const result = await request.query(sql);
|
||||
const [categoryData, steuersaetze] = await Promise.all([
|
||||
loadCategoryCache(pool),
|
||||
loadSteuersatz(),
|
||||
]);
|
||||
const categories = ensureFilterCategories(
|
||||
shapeCategories(
|
||||
buildKatAgg(result.recordset, categoryData.parentMap),
|
||||
categoryData.byId
|
||||
),
|
||||
filter,
|
||||
categoryData.byId,
|
||||
categoryData.parentMap
|
||||
);
|
||||
const length = result.recordset.length;
|
||||
const recordset = result.recordset
|
||||
.slice(start, start + count)
|
||||
.map((row) => shapeArticle(row, steuersaetze));
|
||||
|
||||
console.dir({ categories, length, recordset }, { depth: null });
|
||||
|
||||
return { categories, length, recordset };
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { getPool } from '../../db/mssqlClient.js';
|
||||
import {
|
||||
CATEGORY_ARTICLE_COUNTS,
|
||||
LOAD_CATEGORIES,
|
||||
} from '../../db/mssql/categories.js';
|
||||
|
||||
function sortCategories(list) {
|
||||
list.sort((a, b) => {
|
||||
const sortDiff = (a.nSort ?? 0) - (b.nSort ?? 0);
|
||||
if (sortDiff !== 0) {
|
||||
return sortDiff;
|
||||
}
|
||||
return String(a.cName ?? '').localeCompare(String(b.cName ?? ''), 'de');
|
||||
});
|
||||
for (const node of list) {
|
||||
sortCategories(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCategoryTree(rows) {
|
||||
const nodes = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
nodes.set(row.kKategorie, {
|
||||
kKategorie: row.kKategorie,
|
||||
kOberKategorie: row.kOberKategorie,
|
||||
nSort: row.nSort,
|
||||
cName: row.cName ?? null,
|
||||
count: row.count ?? 0,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
const roots = [];
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const parent = nodes.get(node.kOberKategorie);
|
||||
if (parent && node.kOberKategorie !== node.kKategorie) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
sortCategories(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full JTL category list with per-category article counts and a nested tree.
|
||||
* @returns {Promise<{ categories: Array<{ kKategorie: number, kOberKategorie: number, nSort: number, cName: string|null, count: number }>, tree: object[] }>}
|
||||
*/
|
||||
export async function getCategories() {
|
||||
const pool = await getPool();
|
||||
|
||||
const [categoriesResult, countsResult] = await Promise.all([
|
||||
pool.request().query(LOAD_CATEGORIES),
|
||||
pool.request().query(CATEGORY_ARTICLE_COUNTS),
|
||||
]);
|
||||
|
||||
const countByCategory = new Map(
|
||||
countsResult.recordset.map((row) => [row.kKategorie, row.count])
|
||||
);
|
||||
|
||||
const categories = categoriesResult.recordset.map((row) => ({
|
||||
kKategorie: row.kKategorie,
|
||||
kOberKategorie: row.kOberKategorie,
|
||||
nSort: row.nSort,
|
||||
cName: row.cName ?? null,
|
||||
count: countByCategory.get(row.kKategorie) ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
categories,
|
||||
tree: buildCategoryTree(categories),
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { getPool } from '../../db/mssqlClient.js';
|
||||
import { LOAD_STEUERSATZ, LOAD_STEUERKLASSEN } from '../../db/mssql/steuer.js';
|
||||
import env from '../../config/env.js';
|
||||
|
||||
let steuersatzCache;
|
||||
let steuerklassenCache;
|
||||
|
||||
/**
|
||||
* Loads VAT rates per tax class for the configured steuer zone (`tSteuersatz` only).
|
||||
* Results are cached for the process lifetime.
|
||||
* @returns {Promise<Array<{ kSteuerklasse: number, fSteuersatz: number }>>}
|
||||
*/
|
||||
export async function loadSteuersatz() {
|
||||
if (steuersatzCache) {
|
||||
return steuersatzCache;
|
||||
}
|
||||
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('steuerZone', env.steuerZone)
|
||||
.query(LOAD_STEUERSATZ);
|
||||
steuersatzCache = result.recordset.map((row) => ({
|
||||
kSteuerklasse: row.kSteuerklasse,
|
||||
fSteuersatz: row.fSteuersatz,
|
||||
}));
|
||||
return steuersatzCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads tax classes with display metadata for the configured steuer zone.
|
||||
* Results are cached for the process lifetime.
|
||||
* @returns {Promise<Array<{ kSteuerklasse: number, cName: string, nStandard: number, fSteuersatz: number }>>}
|
||||
*/
|
||||
export async function loadSteuerklassen() {
|
||||
if (steuerklassenCache) {
|
||||
return steuerklassenCache;
|
||||
}
|
||||
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('steuerZone', env.steuerZone)
|
||||
.query(LOAD_STEUERKLASSEN);
|
||||
steuerklassenCache = result.recordset.map((row) => ({
|
||||
kSteuerklasse: row.kSteuerklasse,
|
||||
cName: row.cName,
|
||||
nStandard: row.nStandard,
|
||||
fSteuersatz: row.fSteuersatz,
|
||||
}));
|
||||
return steuerklassenCache;
|
||||
}
|
||||
|
||||
export function bruttoFromNetto(netto, kSteuerklasse, steuersaetze) {
|
||||
const row = steuersaetze.find((item) => item.kSteuerklasse === kSteuerklasse);
|
||||
if (row == null || netto == null) {
|
||||
return null;
|
||||
}
|
||||
return netto * (1 + row.fSteuersatz / 100);
|
||||
}
|
||||
|
||||
export async function getSteuer() {
|
||||
const steuerklassen = await loadSteuerklassen();
|
||||
return { steuerklassen };
|
||||
}
|
||||
Reference in New Issue
Block a user