diff --git a/.env.example b/.env.example index 0b6a8ea..3e4c023 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,6 @@ MSSQL_TRUST_SERVER_CERTIFICATE=false # Article search — category filters (comma-separated IDs) KAT_SKIP= KAT_LISTS=0 + +# Tax zone name for VAT rates (tSteuerzone.cName) +STEUER_ZONE=Zone-EU diff --git a/scripts/searchArticles.js b/scripts/searchArticles.js index 040ebbf..81bec90 100644 --- a/scripts/searchArticles.js +++ b/scripts/searchArticles.js @@ -193,14 +193,14 @@ function truncateForDisplay(value, max = 80) { return `${value.slice(0, max)}… (${value.length} chars)`; } -function formatListRow(row, steuerklassen) { - const stock = row.fBestand ?? '—'; - const price = formatPrice(row, steuerklassen); - const name = row.cName ?? '—'; - return `${row.cArtNr ?? '—'} ${stock} ${price} ${name}`; +function formatListRow(row) { + const stock = row.stock ?? '—'; + const price = row.price == null ? '—' : eur(row.price); + const name = row.name ?? '—'; + return `${row.sku ?? '—'} ${stock} ${price} ${name}`; } -const ResultList = ({ rows, onSelect, onBack, steuerklassen }) => { +const ResultList = ({ rows, onSelect, onBack }) => { const [selected, setSelected] = useState(0); const windowStart = Math.max(0, Math.min(selected - Math.floor(VISIBLE_ROWS / 2), rows.length - VISIBLE_ROWS)); const visible = rows.slice(windowStart, windowStart + VISIBLE_ROWS); @@ -227,9 +227,9 @@ const ResultList = ({ rows, onSelect, onBack, steuerklassen }) => { const absoluteIndex = windowStart + index; const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' '; const label = absoluteIndex === selected - ? chalk.cyan.bold(formatListRow(row, steuerklassen)) - : formatListRow(row, steuerklassen); - return e(Text, { key: row.kArtikel ?? absoluteIndex }, prefix, label); + ? chalk.cyan.bold(formatListRow(row)) + : formatListRow(row); + return e(Text, { key: row.id ?? absoluteIndex }, prefix, label); }), rows.length > VISIBLE_ROWS ? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`)) @@ -296,7 +296,7 @@ const App = ({ steuerklassen }) => { return; } if (recordset.length === 1) { - await loadArticle(recordset[0].cArtNr); + await loadArticle(recordset[0].sku); return; } setRows(recordset); @@ -334,9 +334,8 @@ const App = ({ steuerklassen }) => { if (mode === MODES.LIST) { return e(ResultList, { rows, - onSelect: (row) => loadArticle(row.cArtNr), + onSelect: (row) => loadArticle(row.sku), onBack: backToSearch, - steuerklassen, }); } diff --git a/src/api/routes/article/search.js b/src/api/routes/article/search.js index 9c56ffe..1a6f218 100644 --- a/src/api/routes/article/search.js +++ b/src/api/routes/article/search.js @@ -11,7 +11,9 @@ export function register(app, basePath = '') { * @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) - * @returns {Object} 200 - `{ katagg, recordset }` + * @query {number} [offset=0] - Number of results to skip + * @query {number} [maxCount=10] - Maximum number of results to return + * @returns {Object} 200 - `{ katagg, length, recordset }` * @returns {Object} 400 - `{ error }` when target is missing */ app.get(`${basePath}/article/search`, async (req, res) => { @@ -23,7 +25,9 @@ export function register(app, basePath = '') { try { const filter = parseFilter(req.query.filter); - const result = await searchArticles(String(target), 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 }); diff --git a/src/config/env.js b/src/config/env.js index 4ea63f6..eba94cf 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -34,4 +34,5 @@ export default { katSkip: parseIntList(process.env.KAT_SKIP), katLists: Number(process.env.KAT_LISTS) || 0, }, + steuerZone: process.env.STEUER_ZONE || 'Zone-EU', }; diff --git a/src/db/mssql/steuer.js b/src/db/mssql/steuer.js index c95de0b..594392b 100644 --- a/src/db/mssql/steuer.js +++ b/src/db/mssql/steuer.js @@ -1,3 +1,14 @@ +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, @@ -6,6 +17,8 @@ export const LOAD_STEUERKLASSEN = ` sz.fSteuersatz FROM dbo.tSteuerklasse sk INNER JOIN dbo.tSteuersatz sz ON sz.kSteuerklasse = sk.kSteuerklasse - WHERE sz.kSteuerzone = 1 + WHERE sz.kSteuerzone IN ( + SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @steuerZone + ) ORDER BY sk.kSteuerklasse `; diff --git a/src/services/article/searchArticles.js b/src/services/article/searchArticles.js index 3e4dde6..238cf55 100644 --- a/src/services/article/searchArticles.js +++ b/src/services/article/searchArticles.js @@ -1,6 +1,7 @@ import { getPool } from '../../db/mssqlClient.js'; import { LOAD_KAT_MAP } from '../../db/mssql/categories.js'; import * as Q from '../../db/mssql/searchArticles.js'; +import { bruttoFromNetto, loadSteuersatz } from '../misc/getSteuer.js'; let katMapCache; @@ -55,6 +56,22 @@ function buildKatAgg(recordset, katMap) { return katagg; } +function shapeArticle(row, steuersaetze) { + const taxRow = steuersaetze.find((item) => item.kSteuerklasse === row.kSteuerklasse); + const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuersaetze); + + return { + katList: 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; @@ -120,12 +137,15 @@ function buildQuery(filter, categoryFilter) { WHERE `; } -export async function searchArticles(target, filter = ['']) { +export async function searchArticles(target, filter = [''], offset = 0, maxCount = 10) { const trimmed = target?.toString().trim(); if (!trimmed) { - return { katagg: {}, recordset: [] }; + return { katagg: {}, 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); @@ -139,8 +159,17 @@ export async function searchArticles(target, filter = ['']) { } const result = await request.query(sql); - const katMap = await loadKatMap(pool); + const [katMap, steuersaetze] = await Promise.all([ + loadKatMap(pool), + loadSteuersatz(), + ]); const katagg = buildKatAgg(result.recordset, katMap); + const length = result.recordset.length; + const recordset = result.recordset + .slice(start, start + count) + .map((row) => shapeArticle(row, steuersaetze)); + + console.dir({ katagg, length, recordset }, { depth: null }); - return { katagg, recordset: result.recordset }; + return { katagg, length, recordset }; } diff --git a/src/services/misc/getSteuer.js b/src/services/misc/getSteuer.js index f86d199..08dc033 100644 --- a/src/services/misc/getSteuer.js +++ b/src/services/misc/getSteuer.js @@ -1,31 +1,56 @@ import { getPool } from '../../db/mssqlClient.js'; -import { LOAD_STEUERKLASSEN } from '../../db/mssql/steuer.js'; +import { LOAD_STEUERSATZ, LOAD_STEUERKLASSEN } from '../../db/mssql/steuer.js'; +import env from '../../config/env.js'; -let cache; +let steuersatzCache; +let steuerklassenCache; /** - * Loads JTL tax classes with domestic VAT rates (`tSteuerklasse` / `tSteuersatz`). + * Loads VAT rates per tax class for the configured steuer zone (`tSteuersatz` only). + * Results are cached for the process lifetime. + * @returns {Promise>} + */ +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>} */ export async function loadSteuerklassen() { - if (cache) { - return cache; + if (steuerklassenCache) { + return steuerklassenCache; } const pool = await getPool(); - const result = await pool.request().query(LOAD_STEUERKLASSEN); - cache = result.recordset.map((row) => ({ + 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 cache; + return steuerklassenCache; } -export function bruttoFromNetto(netto, kSteuerklasse, steuerklassen) { - const row = steuerklassen.find((item) => item.kSteuerklasse === kSteuerklasse); +export function bruttoFromNetto(netto, kSteuerklasse, steuersaetze) { + const row = steuersaetze.find((item) => item.kSteuerklasse === kSteuerklasse); if (row == null || netto == null) { return null; }