reshape search

This commit is contained in:
seb
2026-06-29 10:23:32 +02:00
parent b278d3cf92
commit 3bff749b49
7 changed files with 103 additions and 29 deletions

View File

@@ -23,3 +23,6 @@ MSSQL_TRUST_SERVER_CERTIFICATE=false
# Article search — category filters (comma-separated IDs) # Article search — category filters (comma-separated IDs)
KAT_SKIP= KAT_SKIP=
KAT_LISTS=0 KAT_LISTS=0
# Tax zone name for VAT rates (tSteuerzone.cName)
STEUER_ZONE=Zone-EU

View File

@@ -193,14 +193,14 @@ function truncateForDisplay(value, max = 80) {
return `${value.slice(0, max)}… (${value.length} chars)`; return `${value.slice(0, max)}… (${value.length} chars)`;
} }
function formatListRow(row, steuerklassen) { function formatListRow(row) {
const stock = row.fBestand ?? '—'; const stock = row.stock ?? '—';
const price = formatPrice(row, steuerklassen); const price = row.price == null ? '—' : eur(row.price);
const name = row.cName ?? '—'; const name = row.name ?? '—';
return `${row.cArtNr ?? '—'} ${stock} ${price} ${name}`; return `${row.sku ?? '—'} ${stock} ${price} ${name}`;
} }
const ResultList = ({ rows, onSelect, onBack, steuerklassen }) => { const ResultList = ({ rows, onSelect, onBack }) => {
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
const windowStart = Math.max(0, Math.min(selected - Math.floor(VISIBLE_ROWS / 2), rows.length - VISIBLE_ROWS)); 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); const visible = rows.slice(windowStart, windowStart + VISIBLE_ROWS);
@@ -227,9 +227,9 @@ const ResultList = ({ rows, onSelect, onBack, steuerklassen }) => {
const absoluteIndex = windowStart + index; const absoluteIndex = windowStart + index;
const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' '; const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' ';
const label = absoluteIndex === selected const label = absoluteIndex === selected
? chalk.cyan.bold(formatListRow(row, steuerklassen)) ? chalk.cyan.bold(formatListRow(row))
: formatListRow(row, steuerklassen); : formatListRow(row);
return e(Text, { key: row.kArtikel ?? absoluteIndex }, prefix, label); return e(Text, { key: row.id ?? absoluteIndex }, prefix, label);
}), }),
rows.length > VISIBLE_ROWS rows.length > VISIBLE_ROWS
? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`)) ? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`))
@@ -296,7 +296,7 @@ const App = ({ steuerklassen }) => {
return; return;
} }
if (recordset.length === 1) { if (recordset.length === 1) {
await loadArticle(recordset[0].cArtNr); await loadArticle(recordset[0].sku);
return; return;
} }
setRows(recordset); setRows(recordset);
@@ -334,9 +334,8 @@ const App = ({ steuerklassen }) => {
if (mode === MODES.LIST) { if (mode === MODES.LIST) {
return e(ResultList, { return e(ResultList, {
rows, rows,
onSelect: (row) => loadArticle(row.cArtNr), onSelect: (row) => loadArticle(row.sku),
onBack: backToSearch, onBack: backToSearch,
steuerklassen,
}); });
} }

View File

@@ -11,7 +11,9 @@ export function register(app, basePath = '') {
* @description Search articles by partial match on name, number, EAN, barcode, or supplier number. * @description Search articles by partial match on name, number, EAN, barcode, or supplier number.
* @query {string} target - Space-separated search terms * @query {string} target - Space-separated search terms
* @query {string} [filter] - Optional comma-separated category filter IDs (empty = all) * @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 * @returns {Object} 400 - `{ error }` when target is missing
*/ */
app.get(`${basePath}/article/search`, async (req, res) => { app.get(`${basePath}/article/search`, async (req, res) => {
@@ -23,7 +25,9 @@ export function register(app, basePath = '') {
try { try {
const filter = parseFilter(req.query.filter); 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); return res.status(200).json(result);
} catch (error) { } catch (error) {
return res.status(500).json({ error: error.message }); return res.status(500).json({ error: error.message });

View File

@@ -34,4 +34,5 @@ export default {
katSkip: parseIntList(process.env.KAT_SKIP), katSkip: parseIntList(process.env.KAT_SKIP),
katLists: Number(process.env.KAT_LISTS) || 0, katLists: Number(process.env.KAT_LISTS) || 0,
}, },
steuerZone: process.env.STEUER_ZONE || 'Zone-EU',
}; };

View File

@@ -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 = ` export const LOAD_STEUERKLASSEN = `
SELECT SELECT
sk.kSteuerklasse, sk.kSteuerklasse,
@@ -6,6 +17,8 @@ export const LOAD_STEUERKLASSEN = `
sz.fSteuersatz sz.fSteuersatz
FROM dbo.tSteuerklasse sk FROM dbo.tSteuerklasse sk
INNER JOIN dbo.tSteuersatz sz ON sz.kSteuerklasse = sk.kSteuerklasse 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 ORDER BY sk.kSteuerklasse
`; `;

View File

@@ -1,6 +1,7 @@
import { getPool } from '../../db/mssqlClient.js'; import { getPool } from '../../db/mssqlClient.js';
import { LOAD_KAT_MAP } from '../../db/mssql/categories.js'; import { LOAD_KAT_MAP } from '../../db/mssql/categories.js';
import * as Q from '../../db/mssql/searchArticles.js'; import * as Q from '../../db/mssql/searchArticles.js';
import { bruttoFromNetto, loadSteuersatz } from '../misc/getSteuer.js';
let katMapCache; let katMapCache;
@@ -55,6 +56,22 @@ function buildKatAgg(recordset, katMap) {
return katagg; 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) { function buildTargetClause(request, target) {
const parts = []; const parts = [];
let i = 0; let i = 0;
@@ -120,12 +137,15 @@ function buildQuery(filter, categoryFilter) {
WHERE `; WHERE `;
} }
export async function searchArticles(target, filter = ['']) { export async function searchArticles(target, filter = [''], offset = 0, maxCount = 10) {
const trimmed = target?.toString().trim(); const trimmed = target?.toString().trim();
if (!trimmed) { 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 pool = await getPool();
const request = pool.request(); const request = pool.request();
const { parts } = buildTargetClause(request, trimmed); const { parts } = buildTargetClause(request, trimmed);
@@ -139,8 +159,17 @@ export async function searchArticles(target, filter = ['']) {
} }
const result = await request.query(sql); 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 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 };
} }

View File

@@ -1,31 +1,56 @@
import { getPool } from '../../db/mssqlClient.js'; 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<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. * Results are cached for the process lifetime.
* @returns {Promise<Array<{ kSteuerklasse: number, cName: string, nStandard: number, fSteuersatz: number }>>} * @returns {Promise<Array<{ kSteuerklasse: number, cName: string, nStandard: number, fSteuersatz: number }>>}
*/ */
export async function loadSteuerklassen() { export async function loadSteuerklassen() {
if (cache) { if (steuerklassenCache) {
return cache; return steuerklassenCache;
} }
const pool = await getPool(); const pool = await getPool();
const result = await pool.request().query(LOAD_STEUERKLASSEN); const result = await pool.request()
cache = result.recordset.map((row) => ({ .input('steuerZone', env.steuerZone)
.query(LOAD_STEUERKLASSEN);
steuerklassenCache = result.recordset.map((row) => ({
kSteuerklasse: row.kSteuerklasse, kSteuerklasse: row.kSteuerklasse,
cName: row.cName, cName: row.cName,
nStandard: row.nStandard, nStandard: row.nStandard,
fSteuersatz: row.fSteuersatz, fSteuersatz: row.fSteuersatz,
})); }));
return cache; return steuerklassenCache;
} }
export function bruttoFromNetto(netto, kSteuerklasse, steuerklassen) { export function bruttoFromNetto(netto, kSteuerklasse, steuersaetze) {
const row = steuerklassen.find((item) => item.kSteuerklasse === kSteuerklasse); const row = steuersaetze.find((item) => item.kSteuerklasse === kSteuerklasse);
if (row == null || netto == null) { if (row == null || netto == null) {
return null; return null;
} }