reshape search
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user