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)
KAT_SKIP=
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)`;
}
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,
});
}

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.
* @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 });

View File

@@ -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',
};

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 = `
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
`;

View File

@@ -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));
return { katagg, recordset: result.recordset };
console.dir({ katagg, length, recordset }, { depth: null });
return { katagg, length, recordset };
}

View File

@@ -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<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 (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;
}