diff --git a/.env.example b/.env.example index 5211468..0b6a8ea 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,18 @@ CORS_ORIGINS=http://localhost:5173 GOOGLE_CLIENT_ID= JWT_SECRET=fallback_secret_please_change_in_production -# Database +# Database — SQLite (auth allowlist) SQLITE_PATH=data/dashback.db + +# Database — MSSQL (JTL-Wawi article data) +MSSQL_SERVER= +MSSQL_DATABASE= +MSSQL_USER= +MSSQL_PASSWORD= +MSSQL_PORT=1433 +MSSQL_ENCRYPT=false +MSSQL_TRUST_SERVER_CERTIFICATE=false + +# Article search — category filters (comma-separated IDs) +KAT_SKIP= +KAT_LISTS=0 diff --git a/package-lock.json b/package-lock.json index 135281f..77c4f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "ink": "^7.1.0", "ink-text-input": "^6.0.0", "jsonwebtoken": "^9.0.3", - "mssql": "^12.5.5", + "mssql": "^12.6.0", "react": "^19.2.7", "sharp": "^0.35.2" } @@ -247,33 +247,33 @@ } }, "node_modules/@azure/msal-browser": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.14.0.tgz", - "integrity": "sha512-Dfl7hPZe9/JJwRhFFXHq2z1oHYBuGubmff3kWXOsd1AGgyXlqjNYAWuN/1JL/ZrcZBs8TKMjGSil6Rcc7E8VPQ==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.15.0.tgz", + "integrity": "sha512-2NYT6v+eeQn8kmNddr9LnbXSvXbVELpmFMmfFvtRxD7I/5+5GlkMlncApeuRFj+mY6C9syOwQip1a0Y+TIbyiA==", "license": "MIT", "dependencies": { - "@azure/msal-common": "16.9.0" + "@azure/msal-common": "16.10.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.9.0.tgz", - "integrity": "sha512-1MWGjqgUCRAYgLmVFZKp7fs3Rg1TFvIMgywY8ze2olNVvLlJoRThuoziWSDJuwwyJI5L4rnLb9Tyt5D9GvSLPw==", + "version": "16.10.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.10.0.tgz", + "integrity": "sha512-iYtjpanlv6963Jprs0MvzIap07V+QhultjQctfbEDQCflsDAEeO3R7XnVA5gk30fhoBFLdgJT7VqO0TGsEsN9w==", "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.5.tgz", - "integrity": "sha512-RUuewWk9JvWJS5Yiy8/74Lm1rQAWlrU/qg/Bgtk1jIauVRtnb9XKwS5Xg0J+Whwjesq9EVrBIFgQEP8vHxgezA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.3.0.tgz", + "integrity": "sha512-fXtJX811pX8y8QlrQqBSH6+plvWyKZDI0IxkheAcyAw9OtcpXyFivmTC7eGUqutLWaDlKXuQ3yOESD4zAmkjHg==", "license": "MIT", "dependencies": { - "@azure/msal-common": "16.9.0", + "@azure/msal-common": "16.10.0", "jsonwebtoken": "^9.0.0" }, "engines": { @@ -1026,15 +1026,14 @@ } }, "node_modules/bl": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", - "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", "dependencies": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", + "buffer": "^5.5.0", "inherits": "^2.0.4", - "readable-stream": "^4.2.0" + "readable-stream": "^3.4.0" } }, "node_modules/body-parser": { @@ -1075,9 +1074,9 @@ } }, "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -1095,7 +1094,7 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "ieee754": "^1.1.13" } }, "node_modules/buffer-equal-constant-time": { @@ -1203,9 +1202,9 @@ } }, "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.1.tgz", + "integrity": "sha512-2FPVnc3JxdRLONB/9edO1RwuUFFPJ3U2c6XvyccEhjqV5xw6mS22aH27OFdD1u4IYQOEUzXsT6ZU06d1VCSu+Q==", "license": "MIT", "dependencies": { "slice-ansi": "^9.0.0", @@ -2385,9 +2384,9 @@ "license": "MIT" }, "node_modules/mssql": { - "version": "12.5.5", - "resolved": "https://registry.npmjs.org/mssql/-/mssql-12.5.5.tgz", - "integrity": "sha512-kQYghNfnnx+jX0Wyl8g+MtP6Z3DtOajINNx6pEgaG8K/MGlB3LCtU8Bmoje4zeEwukI35yCfpJQa+5AEUcNf1g==", + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-12.6.0.tgz", + "integrity": "sha512-shOPombtihqeQ/URNsdkAebOqjeSSpFaTgZ4kO+XdMU8QvfAGzZ8lfB7AliakWhM8U+/AgOOPHV+jHW67UJlPQ==", "license": "MIT", "dependencies": { "@tediousjs/connection-string": "^1.0.0", @@ -2715,19 +2714,17 @@ } }, "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 6" } }, "node_modules/restore-cursor": { @@ -3189,55 +3186,6 @@ "node": ">=6" } }, - "node_modules/tar-stream/node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/tar-stream/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -3268,6 +3216,58 @@ "node": ">=18.17" } }, + "node_modules/tedious/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/tedious/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/tedious/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/terminal-size": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", diff --git a/package.json b/package.json index 643397e..c056b33 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "start": "node src/index.js", "manage-users": "node scripts/manageUsers.js", + "search-articles": "node scripts/searchArticles.js", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { @@ -23,7 +24,7 @@ "ink": "^7.1.0", "ink-text-input": "^6.0.0", "jsonwebtoken": "^9.0.3", - "mssql": "^12.5.5", + "mssql": "^12.6.0", "react": "^19.2.7", "sharp": "^0.35.2" } diff --git a/scripts/searchArticles.js b/scripts/searchArticles.js new file mode 100644 index 0000000..a06e125 --- /dev/null +++ b/scripts/searchArticles.js @@ -0,0 +1,401 @@ +import React, { useState } from 'react'; +import { render, Box, Text, useApp, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import chalk from 'chalk'; +import { getArticle } from '../src/services/article/getArticle.js'; +import { searchArticles } from '../src/services/article/searchArticles.js'; +import { closePool } from '../src/db/mssqlClient.js'; +import { getBrutto } from '../src/utils/tax.js'; + +import '../src/config/env.js'; + +const e = React.createElement; + +const MODES = { + SEARCH: 'search', + LOADING: 'loading', + LIST: 'list', + RESULT: 'result', + ERROR: 'error', +}; + +const VISIBLE_ROWS = 15; + +const eur = (value) => { + if (value == null || Number.isNaN(Number(value))) { + return '—'; + } + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value); +}; + +function formatPrice(row) { + if (row.fVKNEtto == null) { + return '—'; + } + return eur(getBrutto(row.fVKNEtto, row.kSteuerklasse)); +} + +function hasValue(value) { + if (value == null || value === '' || value === 0) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + return true; +} + +function DetailRow({ label, value, alwaysShow = false }) { + if (!alwaysShow && !hasValue(value) && value !== 0 && value !== '0') { + return null; + } + const text = value == null || value === '' ? '—' : String(value); + return e(Box, null, + e(Text, { dimColor: true }, `${label.padEnd(14)} `), + e(Text, null, text) + ); +} + +function Section({ title, children }) { + const items = React.Children.toArray(children).filter(Boolean); + if (!items.length) { + return null; + } + return e(Box, { flexDirection: 'column', marginTop: 1 }, + e(Text, null, chalk.cyan.bold(title)), + ...items + ); +} + +function ArticleDetail({ article }) { + const brutto = article.fVKNEtto != null + ? getBrutto(article.fVKNEtto, article.kSteuerklasse) + : null; + const aktiv = article.cAktiv === 'Y' + ? chalk.green('active') + : article.cAktiv === 'N' + ? chalk.red('inactive') + : article.cAktiv; + + return e(Box, { flexDirection: 'column' }, + e(Text, null, chalk.white.bold(article.cName ?? '—')), + e(Text, { dimColor: true }, article.cArtNr ?? '—'), + + e(Section, { title: 'Overview' }, + e(DetailRow, { label: 'Artikel-ID', value: article.kArtikel }), + e(DetailRow, { label: 'Status', value: aktiv }), + e(DetailRow, { label: 'Hersteller', value: article.cHersteller }), + e(DetailRow, { label: 'Barcode', value: article.cBarcode }), + article.EANs?.length + ? e(DetailRow, { label: 'EANs', value: article.EANs.map((row) => row.cEAN).join(', ') }) + : null + ), + + e(Section, { title: 'Pricing' }, + e(DetailRow, { label: 'VK brutto', value: brutto != null ? eur(brutto) : null }), + e(DetailRow, { label: 'VK netto', value: article.fVKNEtto != null ? eur(article.fVKNEtto) : null }), + e(DetailRow, { label: 'Steuerklasse', value: article.kSteuerklasse }), + hasValue(article.fUVP) ? e(DetailRow, { label: 'UVP', value: eur(article.fUVP) }) : null, + article.staffel?.length + ? e(Box, { flexDirection: 'column', marginTop: 0 }, + e(Text, { dimColor: true }, 'Staffel'), + ...article.staffel.map((row, index) => + e(Text, { key: index, dimColor: true }, ` ab ${row.menge} ${row.fVKBrutto} €`) + ) + ) + : null + ), + + e(Section, { title: 'Stock' }, + e(DetailRow, { label: 'Lager 1', value: article.fBestand, alwaysShow: true }), + hasValue(article.garage) ? e(DetailRow, { label: 'Garage', value: article.garage }) : null, + hasValue(article.keller) ? e(DetailRow, { label: 'Keller', value: article.keller }) : null, + article.fAndereLager?.length + ? e(DetailRow, { + label: 'Andere Lager', + value: article.fAndereLager.map((row) => row.fSumme).join(', '), + }) + : null, + article.incoming?.length + ? e(Text, { dimColor: true }, ` ${article.incoming.length} open supplier order(s)`) + : null + ), + + e(Section, { title: 'Product' }, + e(DetailRow, { label: 'Versandklasse', value: article.tVersandklassecName ?? article.kVersandklasse }), + hasValue(article.fGewicht) ? e(DetailRow, { label: 'Gewicht', value: `${article.fGewicht} kg` }) : null, + hasValue(article.shops) ? e(DetailRow, { label: 'Shops', value: article.shops }) : null, + article.KATs?.length + ? e(DetailRow, { label: 'Kategorien', value: article.KATs.map((row) => row.kKategorie).join(', ') }) + : null, + article.merkmale?.length + ? e(Box, { flexDirection: 'column' }, + e(Text, { dimColor: true }, 'Merkmale'), + ...article.merkmale.map((row, index) => + e(Text, { key: index, dimColor: true }, ` ${row.cName}: ${row.cWert}`) + ) + ) + : null + ), + + article.lief?.length + ? e(Section, { title: `Suppliers (${article.lief.length})` }, + ...article.lief.slice(0, 8).map((row, index) => + e(Text, { key: index, dimColor: true }, + ` ${row.nStandard ? chalk.yellow('* ') : ' '}${row.cFirma ?? '—'} ${row.cLiefArtNr ?? '—'} EK ${eur(row.fEKNetto)}` + ) + ), + article.lief.length > 8 + ? e(Text, { dimColor: true }, ` … +${article.lief.length - 8} more`) + : null + ) + : null, + + article.children?.length + ? e(Section, { title: `Variants (${article.children.length})` }, + ...article.children.slice(0, 6).map((row, index) => + e(Text, { key: index, dimColor: true }, + ` ${row.cArtNr} ${row.cName ?? '—'}` + ) + ) + ) + : null, + + article.liste?.length + ? e(Section, { title: `Stückliste (${article.liste.length})` }, + ...article.liste.map((row, index) => + e(Text, { key: index, dimColor: true }, + ` ${row.cArtNr} × ${row.fAnzahl} ${row.cName ?? '—'}` + ) + ) + ) + : null, + + article.orders?.length + ? e(Section, { title: 'Open orders' }, + ...article.orders.map((row, index) => + e(Text, { key: index, dimColor: true }, ` ${row.cType} ${row.cNote ?? ''}`) + ) + ) + : null, + + hasValue(article.cBeschreibung) + ? e(Section, { title: 'Description' }, + e(Text, null, truncateForDisplay(article.cBeschreibung, 240)) + ) + : null + ); +} + +function truncateForDisplay(value, max = 80) { + if (typeof value !== 'string' || value.length <= max) { + return value; + } + return `${value.slice(0, max)}… (${value.length} chars)`; +} + +function formatListRow(row) { + const stock = row.fBestand ?? '—'; + const price = formatPrice(row); + const name = row.cName ?? '—'; + return `${row.cArtNr ?? '—'} ${stock} ${price} ${name}`; +} + +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); + + useInput((_input, key) => { + if (key.escape) { + onBack(); + return; + } + if (key.upArrow) { + setSelected((s) => Math.max(0, s - 1)); + } + if (key.downArrow) { + setSelected((s) => Math.min(rows.length - 1, s + 1)); + } + if (key.return && rows[selected]) { + onSelect(rows[selected]); + } + }); + + return e(Box, { flexDirection: 'column' }, + e(Text, null, chalk.cyan.bold(`${rows.length} result(s) — ↑↓ select, Enter open, Esc back`)), + visible.map((row, index) => { + const absoluteIndex = windowStart + index; + const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' '; + const label = absoluteIndex === selected + ? chalk.cyan.bold(formatListRow(row)) + : formatListRow(row); + return e(Text, { key: row.kArtikel ?? absoluteIndex }, prefix, label); + }), + rows.length > VISIBLE_ROWS + ? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`)) + : null + ); +}; + +const App = () => { + const { exit } = useApp(); + const [mode, setMode] = useState(MODES.SEARCH); + const [target, setTarget] = useState(''); + const [rows, setRows] = useState([]); + const [article, setArticle] = useState(null); + const [error, setError] = useState(''); + const [loadingLabel, setLoadingLabel] = useState('Searching…'); + + const quit = async (code = 0) => { + try { + await closePool(); + } catch { + // ignore cleanup errors + } + exit(); + process.exit(code); + }; + + const loadArticle = async (cArtNr) => { + setLoadingLabel(`Loading ${cArtNr}…`); + setMode(MODES.LOADING); + setError(''); + + try { + const result = await getArticle(cArtNr); + if (!result) { + setError(`Article not found: ${cArtNr}`); + setMode(MODES.ERROR); + return; + } + setArticle(result); + setMode(MODES.RESULT); + } catch (err) { + setError(err.message); + setMode(MODES.ERROR); + } + }; + + const handleSearch = async () => { + const trimmed = target.trim(); + if (!trimmed) { + setError('Search terms are required'); + setMode(MODES.ERROR); + return; + } + + setLoadingLabel('Searching…'); + setMode(MODES.LOADING); + setError(''); + + try { + const { recordset } = await searchArticles(trimmed); + if (!recordset.length) { + setError(`No articles found for: ${trimmed}`); + setMode(MODES.ERROR); + return; + } + if (recordset.length === 1) { + await loadArticle(recordset[0].cArtNr); + return; + } + setRows(recordset); + setMode(MODES.LIST); + } catch (err) { + setError(err.message); + setMode(MODES.ERROR); + } + }; + + const backToSearch = () => { + setRows([]); + setArticle(null); + setError(''); + setMode(MODES.SEARCH); + }; + + const resetSearch = () => { + setTarget(''); + backToSearch(); + }; + + if (mode === MODES.LOADING) { + return e(Text, null, chalk.yellow(loadingLabel)); + } + + if (mode === MODES.ERROR) { + return e(Box, { flexDirection: 'column' }, + e(Text, null, chalk.red(`Error: ${error}`)), + e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to try again'))), + e(ExitOnKey, { onExit: backToSearch }) + ); + } + + if (mode === MODES.LIST) { + return e(ResultList, { + rows, + onSelect: (row) => loadArticle(row.cArtNr), + onBack: backToSearch, + }); + } + + const backToList = () => { + setArticle(null); + setError(''); + setMode(rows.length ? MODES.LIST : MODES.SEARCH); + }; + + if (mode === MODES.RESULT) { + const footer = rows.length + ? 'Esc = back to results, Enter = new search, q = quit' + : 'Enter = new search, q = quit'; + + return e(Box, { flexDirection: 'column' }, + e(ArticleDetail, { article }), + e(Box, { marginTop: 1 }, e(Text, null, chalk.gray(footer))), + e(ResultInput, { + onAgain: resetSearch, + onBack: backToList, + onQuit: () => quit(0), + canGoBack: rows.length > 0, + }) + ); + } + + return e(Box, { flexDirection: 'column' }, + e(Box, { marginBottom: 1 }, + e(Text, null, chalk.cyan.bold('=== Article Search ===')) + ), + e(Text, null, chalk.gray('Partial match on name, number, EAN, barcode, supplier number')), + e(Box, { marginTop: 1 }, + e(Text, null, 'Target: '), + e(TextInput, { value: target, onChange: setTarget, onSubmit: handleSearch }) + ), + e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Enter to search, Ctrl+C to quit'))) + ); +}; + +const ResultInput = ({ onAgain, onBack, onQuit, canGoBack }) => { + useInput((_input, key) => { + if (key.escape && canGoBack) { + onBack(); + return; + } + if (_input === 'q' || (key.ctrl && _input === 'c')) { + onQuit(); + return; + } + if (key.return) { + onAgain(); + } + }); + return null; +}; + +const ExitOnKey = ({ onExit }) => { + useInput(onExit); + return null; +}; + +render(e(App)); diff --git a/src/api/routes/article/get.js b/src/api/routes/article/get.js new file mode 100644 index 0000000..6f6c7cb --- /dev/null +++ b/src/api/routes/article/get.js @@ -0,0 +1,34 @@ +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 }); + } + }); +} diff --git a/src/api/routes/article/search.js b/src/api/routes/article/search.js new file mode 100644 index 0000000..9c56ffe --- /dev/null +++ b/src/api/routes/article/search.js @@ -0,0 +1,32 @@ +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) + * @returns {Object} 200 - `{ katagg, 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 result = await searchArticles(String(target), filter); + 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 a8f8f08..4ea63f6 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -3,7 +3,14 @@ import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: resolve(__dirname, '../../.env') }); +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, @@ -14,4 +21,17 @@ export default { 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, + user: process.env.MSSQL_USER, + password: process.env.MSSQL_PASSWORD, + port: Number(process.env.MSSQL_PORT) || 1433, + 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, + }, }; diff --git a/src/db/mssql/categories.js b/src/db/mssql/categories.js new file mode 100644 index 0000000..456d6a7 --- /dev/null +++ b/src/db/mssql/categories.js @@ -0,0 +1,4 @@ +export const LOAD_KAT_MAP = ` + SELECT kKategorie, kOberKategorie + FROM dbo.tkategorie +`; diff --git a/src/db/mssql/getArticle.js b/src/db/mssql/getArticle.js new file mode 100644 index 0000000..ac0dae7 --- /dev/null +++ b/src/db/mssql/getArticle.js @@ -0,0 +1,248 @@ +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 +`; diff --git a/src/db/mssql/searchArticles.js b/src/db/mssql/searchArticles.js new file mode 100644 index 0000000..1dc9b54 --- /dev/null +++ b/src/db/mssql/searchArticles.js @@ -0,0 +1,37 @@ +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}) + ) +`; diff --git a/src/db/mssqlClient.js b/src/db/mssqlClient.js new file mode 100644 index 0000000..33f8eba --- /dev/null +++ b/src/db/mssqlClient.js @@ -0,0 +1,64 @@ +import sql from 'mssql'; +import env from '../config/env.js'; + +let pool; +let connecting; + +function getMssqlConfig() { + const { server, database, user, password, port, encrypt, trustServerCertificate } = env.mssql; + + if (!server || !database || !user || !password) { + throw new Error('MSSQL is not configured (MSSQL_SERVER, MSSQL_DATABASE, MSSQL_USER, MSSQL_PASSWORD required)'); + } + + return { + server, + database, + user, + password, + port, + pool: { + max: 10, + min: 0, + idleTimeoutMillis: 30_000, + }, + options: { + encrypt, + trustServerCertificate, + }, + }; +} + +export async function getPool() { + if (pool) { + return pool; + } + + if (!connecting) { + connecting = new sql.ConnectionPool(getMssqlConfig()) + .connect() + .then((connectedPool) => { + pool = connectedPool; + return pool; + }) + .finally(() => { + connecting = undefined; + }); + } + + return connecting; +} + +export async function ensureConnection() { + const activePool = await getPool(); + await activePool.request().query('SELECT 1 AS ok'); +} + +export async function closePool() { + if (pool) { + await pool.close(); + pool = undefined; + } +} + +export { sql }; diff --git a/src/index.js b/src/index.js index 1f24164..0925126 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,13 @@ import './config/env.js'; import { startApi, stopApi } from './api/index.js'; import { closeDb } from './db/client.js'; +import { closePool } from './db/mssqlClient.js'; async function shutdown() { await stopApi(); try { closeDb(); + await closePool(); } catch (error) { console.error('Failed to close database:', error); process.exitCode = 1; diff --git a/src/services/article/enrichSupplier.js b/src/services/article/enrichSupplier.js new file mode 100644 index 0000000..1535acb --- /dev/null +++ b/src/services/article/enrichSupplier.js @@ -0,0 +1,15 @@ +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; +} diff --git a/src/services/article/getArticle.js b/src/services/article/getArticle.js new file mode 100644 index 0000000..0d3f190 --- /dev/null +++ b/src/services/article/getArticle.js @@ -0,0 +1,192 @@ +import env from '../../config/env.js'; +import { getPool } from '../../db/mssqlClient.js'; +import { getBrutto } from '../../utils/tax.js'; +import { enrichSupplierAnmerkungRow } from './enrichSupplier.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 }); + return rows.map((item) => ({ + menge: item.nAnzahlAb.toString(), + fVKBrutto: getBrutto(item.fNEttoPreis, kSteuerklasse).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); +} diff --git a/src/services/article/searchArticles.js b/src/services/article/searchArticles.js new file mode 100644 index 0000000..3e4dde6 --- /dev/null +++ b/src/services/article/searchArticles.js @@ -0,0 +1,146 @@ +import { getPool } from '../../db/mssqlClient.js'; +import { LOAD_KAT_MAP } from '../../db/mssql/categories.js'; +import * as Q from '../../db/mssql/searchArticles.js'; + +let katMapCache; + +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, katMap) { + const list = [kat]; + if (katMap[kat]) { + for (const item of getUpPath(katMap[kat], katMap)) { + list.push(item); + } + } + return list; +} + +async function loadKatMap(pool) { + if (katMapCache) { + return katMapCache; + } + + const result = await pool.request().query(LOAD_KAT_MAP); + katMapCache = {}; + for (const item of result.recordset) { + katMapCache[item.kKategorie] = item.kOberKategorie; + } + return katMapCache; +} + +function buildKatAgg(recordset, katMap) { + const katagg = {}; + + for (const item of recordset) { + if (!item.katList) { + continue; + } + for (const kat of item.katList.split(',')) { + for (const katpart of getUpPath(kat, katMap)) { + if (!katagg[katpart]) { + katagg[katpart] = 0; + } + katagg[katpart]++; + } + } + } + + return katagg; +} + +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 = ['']) { + const trimmed = target?.toString().trim(); + if (!trimmed) { + return { katagg: {}, recordset: [] }; + } + + 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 katMap = await loadKatMap(pool); + const katagg = buildKatAgg(result.recordset, katMap); + + return { katagg, recordset: result.recordset }; +} diff --git a/src/utils/tax.js b/src/utils/tax.js new file mode 100644 index 0000000..7d7e7a1 --- /dev/null +++ b/src/utils/tax.js @@ -0,0 +1,14 @@ +const STEUER_RATES = { + 1: 19, + 2: 7, + 3: 0, +}; + +export function getSteuer(kSteuerklasse) { + return STEUER_RATES[kSteuerklasse] ?? 19; +} + +export function getBrutto(netto, kSteuerklasse) { + const rate = getSteuer(kSteuerklasse); + return netto * (1 + rate / 100); +}