diff --git a/docs/api/README.md b/docs/api/README.md index 07ede59..265876f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -34,8 +34,15 @@ Mirrors [`src/api/routes/`](../../src/api/routes/). | [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` | | [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` | | [`routes/health.md`](routes/health.md) | `GET /health` | +| [`routes/article/search.md`](routes/article/search.md) | `GET /article/search` | +| [`routes/article/get.md`](routes/article/get.md) | `GET /article/get` | +| [`routes/misc/getSteuer.md`](routes/misc/getSteuer.md) | `GET /misc/getSteuer` | +| [`routes/misc/getCategories.md`](routes/misc/getCategories.md) | `GET /misc/getCategories` | -## Frontend checklist +## DESIGN -- [ ] `credentials: 'include'` on every API call -- [ ] HTTPS in dev (or proxy) so `Secure` cookies work +- for every route we have a file in /src/api/routes +- that should loosly bound to services in /src/services +- for every service we have a CLI tester in /scripts/ +- all DB SQL code shall be in /src/db +- with mssql staements in /src/db/mssql/ \ No newline at end of file diff --git a/docs/api/routes/article/get.md b/docs/api/routes/article/get.md new file mode 100644 index 0000000..d7895e5 --- /dev/null +++ b/docs/api/routes/article/get.md @@ -0,0 +1,121 @@ +# `GET /article/get` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Full article detail by **exact** match. Use after the user picks a row from [`GET /article/search`](search.md). + +## Query parameters + +| Param | Required | Description | +|-------|----------|-------------| +| `target` | yes | Exact article number (`cArtNr`), packaging EAN (`tGebinde.cEAN`), or barcode (`cBarcode`). | + +## Response `200` + +```json +{ + "article": { + "kArtikel": 49, + "cArtNr": "42000044", + "cName": "BioBizz AllMix Peat Free 20L", + "cAktiv": "Y", + "fVKNEtto": 11.3445378151261, + "kSteuerklasse": 1, + "fBestand": null, + "cHersteller": null, + "cBarcode": null, + "KATs": [{ "kKategorie": 16 }, { "kKategorie": 39 }], + "lief": [], + "staffel": [], + "EANs": [], + "merkmale": [], + "attribute": [] + } +} +``` + +The object is enriched with related data in parallel. Fields below are always present on success; arrays may be empty. + +### Core fields + +| Field | Type | Notes | +|-------|------|-------| +| `kArtikel` | number | Internal ID | +| `cArtNr` | string | Article number | +| `cName` | string | Name | +| `cBeschreibung` | string | Long description (HTML) | +| `cKurzBeschreibung` | string | Short description | +| `cAktiv` | string | `"Y"` or `"N"` | +| `fVKNEtto` | number | Net sales price | +| `kSteuerklasse` | number | `1` / `2` / `3` | +| `fUVP` | number | RRP | +| `fBestand` | number \| null | Stock warehouse 1 | +| `fGewicht` | number | Weight | +| `cBarcode` | string \| null | | +| `cHersteller` | string \| null | Manufacturer name | +| `cAnmerkung` | string | Internal note | +| `kVersandklasse` | number | Shipping class ID | +| `tVersandklassecName` | string | Shipping class name | +| `nIstVater` | number | `1` if parent article with variants | +| `kVaterArtikel` | number | Parent article ID (variants) | +| `kStueckliste` | number | Bill-of-materials ID; `0` if none | +| `kEigenschaftKombi` | number | Variant combination | +| `cEigenschaftName` | string \| null | Variant property name | +| `cEigenschaftWertName` | string \| null | Variant value name | +| `cLiefArtNr96` | string \| null | Supplier 96 article number | +| `nVPE` | number | Sales unit flag | +| `kMassEinheit` | number \| null | Unit of measure | +| `fMassMenge` | number | Unit quantity | +| `shops` | number \| null | Shop bitmask sum | +| `garage` | number \| undefined | Extra storage slot 0 qty | +| `keller` | number \| undefined | Extra storage slot 1 qty | +| `cHerstellerGPSR` | string \| null | Manufacturer GPSR text | +| `herstellerBild` | string \| null | Manufacturer logo, base64 PNG | + +### Related arrays / objects + +| Field | Description | +|-------|-------------| +| `children` | Variant rows (when `nIstVater === 1`) | +| `vater` | Parent `{ cArtNr }` (when variant) | +| `liste` | Stückliste / BOM components | +| `fAndereLager` | Stock in other warehouses `[{ fSumme }]` | +| `EANs` | `[{ cEAN }]` | +| `KATs` | `[{ kKategorie }]` | +| `lief` | Suppliers; `cAnmerkung` may be parsed into `anmerkung` | +| `incoming` | Open supplier purchase orders | +| `orders` | Open incidents (`order_count`, `order_label`, `order_free`) | +| `merkmale` | Features `[{ kMerkmal, cName, cWert, … }]` | +| `attribute` | Custom attributes | +| `staffel` | Tier prices `[{ menge, fVKBrutto }]` — brutto pre-formatted string | + +### Supplier row (`lief[]`) + +| Field | Notes | +|-------|-------| +| `kLieferant`, `cFirma` | Supplier | +| `cLiefArtNr` | Supplier article number | +| `nStandard` | Preferred supplier flag | +| `fEKNetto`, `fMwSt` | Purchase price / VAT | +| `fLagerbestand` | Supplier stock | +| `cEmail`, `cAnmerkung` | Contact / note | + +## Errors + +| Status | Body | +|--------|------| +| 400 | `{ "error": "Query parameter \"target\" is required" }` | +| 401 | `{ "error": "Unauthorized" }` | +| 404 | `{ "error": "Article not found" }` | +| 500 | `{ "error": "..." }` | + +## Example + +```js +// User selected cArtNr from search results +const res = await fetch( + 'http://localhost:4208/api/article/get?target=42000044', + { credentials: 'include' } +); +const { article } = await res.json(); +``` diff --git a/docs/api/routes/article/search.md b/docs/api/routes/article/search.md new file mode 100644 index 0000000..eb00840 --- /dev/null +++ b/docs/api/routes/article/search.md @@ -0,0 +1,100 @@ +# `GET /article/search` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Fuzzy article search against JTL-Wawi (MSSQL). Use this to build a search box / result list. + +## Query parameters + +| Param | Required | Description | +|-------|----------|-------------| +| `target` | yes | Search string. Split on spaces; each word must match (AND). Each word is matched with `LIKE %word%` against EAN, supplier article number, name, article number, or barcode. | +| `filter` | no | Comma-separated category IDs. Omit or leave empty to search all categories. See [Category filter](#category-filter). | + +## Search behaviour + +- Multiple spaces are collapsed to one. +- Example: `target=bizz allmix` → both `bizz` **and** `allmix` must match somewhere on the article. +- By default only **active** articles (`cAktiv = 'Y'`) are returned. +- Without a category filter, at most **2000** rows are returned. +- Results are ordered by product name ascending. + +## Category filter + +| `filter` value | Effect | +|----------------|--------| +| *(omitted or empty)* | All categories; `TOP 2000` | +| `751` | Include inactive articles (no `cAktiv = 'Y'` restriction). Still uses category branch logic if other IDs are present. | +| `16` or `16,39` | Restrict to articles in those categories (OR). No row limit. | + +Pass category IDs as a comma-separated list: `filter=16,39`. + +## Response `200` + +```json +{ + "katagg": { + "16": 12, + "39": 8 + }, + "recordset": [ + { + "katList": "16,39", + "fVerfuegbar": 0, + "kArtikel": 49, + "fBestand": null, + "cArtNr": "42000044", + "cName": "BioBizz AllMix Peat Free 20L", + "fVKNEtto": 11.3445378151261, + "kSteuerklasse": 1 + } + ] +} +``` + +### `recordset[]` fields + +| Field | Type | Notes | +|-------|------|-------| +| `kArtikel` | number | Internal article ID | +| `cArtNr` | string | Article number — pass to [`GET /article/get`](get.md) | +| `cName` | string | Display name | +| `fBestand` | number \| null | Stock in warehouse 1 | +| `fVerfuegbar` | number \| null | Available stock | +| `fVKNEtto` | number | Net sales price | +| `kSteuerklasse` | number | Tax class: `1` = 19%, `2` = 7%, `3` = 0% | +| `katList` | string \| null | Comma-separated category IDs on the article | + +### `katagg` + +Map of category ID → count of matching articles in that category (including parent categories in the tree). Use for category sidebar / facet counts. + +### Display price (frontend) + +Brutto VK is not returned; compute client-side using [`GET /misc/getSteuer`](../misc/getSteuer.md): + +```js +function getBrutto(netto, kSteuerklasse, steuerklassen) { + const row = steuerklassen.find((s) => s.kSteuerklasse === kSteuerklasse); + if (!row) return null; + return netto * (1 + row.fSteuersatz / 100); +} +``` + +## Errors + +| Status | Body | +|--------|------| +| 400 | `{ "error": "Query parameter \"target\" is required" }` | +| 401 | `{ "error": "Unauthorized" }` | +| 500 | `{ "error": "..." }` | + +## Example + +```js +const res = await fetch( + 'http://localhost:4208/api/article/search?target=bizz%20allmix', + { credentials: 'include' } +); +const { katagg, recordset } = await res.json(); +``` diff --git a/docs/api/routes/misc/getCategories.md b/docs/api/routes/misc/getCategories.md new file mode 100644 index 0000000..b494191 --- /dev/null +++ b/docs/api/routes/misc/getCategories.md @@ -0,0 +1,78 @@ +# `GET /misc/getCategories` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Returns the full JTL category list and a nested tree. Same data source as the legacy `getKat` socket handler in dashApp. + +## Response `200` + +```json +{ + "categories": [ + { + "kKategorie": 16, + "kOberKategorie": 0, + "nSort": 10, + "cName": "Dünger", + "count": 142 + } + ], + "tree": [ + { + "kKategorie": 16, + "kOberKategorie": 0, + "nSort": 10, + "cName": "Dünger", + "count": 142, + "children": [ + { + "kKategorie": 39, + "kOberKategorie": 16, + "nSort": 0, + "cName": "Bio", + "count": 47, + "children": [] + } + ] + } + ] +} +``` + +### `categories[]` + +Flat list — useful for lookups by ID (`katNames`-style maps). + +| Field | Type | Description | +|-------|------|-------------| +| `kKategorie` | number | Category ID | +| `kOberKategorie` | number | Parent category ID (`0` = root) | +| `nSort` | number | Sort order among siblings | +| `cName` | string \| null | German display name (Sprache 1, Plattform 1, Shop 0) | +| `count` | number | Active articles directly in this category | + +### `tree[]` + +Nested hierarchy rooted at `kOberKategorie === 0` (or orphaned nodes). Each node has the same fields plus `children[]`, sorted by `nSort` then `cName`. + +Use `tree` for sidebar navigation; use `categories` for ID → name maps or [`GET /article/search`](../article/search.md) `filter` param. + +## Errors + +| Status | Body | +|--------|------| +| 401 | `{ "error": "Unauthorized" }` | +| 500 | `{ "error": "..." }` | + +## Example + +```js +const res = await fetch('http://localhost:4208/api/misc/getCategories', { + credentials: 'include', +}); +const { categories, tree } = await res.json(); + +const katNames = Object.fromEntries( + categories.map((kat) => [kat.kKategorie, kat.cName]) +); +``` diff --git a/docs/api/routes/misc/getSteuer.md b/docs/api/routes/misc/getSteuer.md new file mode 100644 index 0000000..80ae702 --- /dev/null +++ b/docs/api/routes/misc/getSteuer.md @@ -0,0 +1,63 @@ +# `GET /misc/getSteuer` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Returns tax classes and their rates from JTL-Wawi (`tSteuerklasse` + `tSteuersatz`, domestic zone `kSteuerzone = 1`). + +## Response `200` + +```json +{ + "steuerklassen": [ + { + "kSteuerklasse": 1, + "cName": "Normaler Steuersatz", + "nStandard": 1, + "fSteuersatz": 19 + }, + { + "kSteuerklasse": 2, + "cName": "Ermäßigter Steuersatz", + "nStandard": 0, + "fSteuersatz": 7 + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `kSteuerklasse` | Tax class ID (used on articles as `kSteuerklasse`) | +| `cName` | Display name | +| `nStandard` | `1` if this is the default tax class | +| `fSteuersatz` | VAT rate in percent | + +### Brutto calculation + +Look up the article's `kSteuerklasse` in `steuerklassen`, then: + +```js +function getBrutto(netto, kSteuerklasse, steuerklassen) { + const row = steuerklassen.find((s) => s.kSteuerklasse === kSteuerklasse); + if (!row) return null; + return netto * (1 + row.fSteuersatz / 100); +} +``` + +Load once on app init and reuse for search results and article detail. + +## Errors + +| Status | Body | +|--------|------| +| 401 | `{ "error": "Unauthorized" }` | +| 500 | `{ "error": "..." }` | + +## Example + +```js +const res = await fetch('http://localhost:4208/api/misc/getSteuer', { + credentials: 'include', +}); +const { steuerklassen } = await res.json(); +``` diff --git a/package.json b/package.json index c056b33..d57fd2e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start": "node src/index.js", "manage-users": "node scripts/manageUsers.js", "search-articles": "node scripts/searchArticles.js", + "get-steuer": "node scripts/getSteuer.js", + "get-categories": "node scripts/getCategories.js", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { diff --git a/scripts/getCategories.js b/scripts/getCategories.js new file mode 100644 index 0000000..d6dd7df --- /dev/null +++ b/scripts/getCategories.js @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import { render, Box, Text, useApp, useInput } from 'ink'; +import chalk from 'chalk'; +import { getCategories } from '../src/services/misc/getCategories.js'; +import { closePool } from '../src/db/mssqlClient.js'; + +import '../src/config/env.js'; + +const e = React.createElement; + +const VISIBLE_ROWS = 20; + +function flattenTree(nodes, depth = 0, out = []) { + for (const node of nodes) { + out.push({ ...node, depth }); + flattenTree(node.children, depth + 1, out); + } + return out; +} + +function formatRow(row) { + const indent = ' '.repeat(row.depth); + const count = chalk.gray(` (${row.count})`); + return `${indent}${row.cName ?? '—'}${count} ${chalk.dim(String(row.kKategorie))}`; +} + +const CategoryBrowser = ({ rows }) => { + const { exit } = useApp(); + 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); + const current = rows[selected]; + + const quit = () => { + closePool() + .catch(() => {}) + .finally(() => { + exit(); + process.exit(0); + }); + }; + + useInput((input, key) => { + if (input === 'q' || (key.ctrl && input === 'c')) { + quit(); + return; + } + if (key.upArrow) { + setSelected((s) => Math.max(0, s - 1)); + } + if (key.downArrow) { + setSelected((s) => Math.min(rows.length - 1, s + 1)); + } + }); + + return e(Box, { flexDirection: 'column' }, + e(Text, null, chalk.cyan.bold(`=== Categories (${rows.length}) ===`)), + e(Box, { marginTop: 1, flexDirection: 'column' }, + visible.map((row, index) => { + const absoluteIndex = windowStart + index; + const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' '; + const label = absoluteIndex === selected + ? chalk.cyan.bold(formatRow(row)) + : formatRow(row); + return e(Text, { key: row.kKategorie }, prefix, label); + }) + ), + rows.length > VISIBLE_ROWS + ? e(Text, { dimColor: true }, `Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`) + : null, + current + ? e(Box, { marginTop: 1, flexDirection: 'column' }, + e(Text, { dimColor: true }, + `kKategorie: ${current.kKategorie} parent: ${current.kOberKategorie} sort: ${current.nSort ?? '—'} articles: ${current.count}` + ) + ) + : null, + e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('↑↓ navigate, q exit'))) + ); +}; + +try { + const { tree } = await getCategories(); + const rows = flattenTree(tree); + render(e(CategoryBrowser, { rows })); +} catch (error) { + console.error(chalk.red(`Error: ${error.message}`)); + await closePool().catch(() => {}); + process.exit(1); +} diff --git a/scripts/getSteuer.js b/scripts/getSteuer.js new file mode 100644 index 0000000..642bc0d --- /dev/null +++ b/scripts/getSteuer.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, Box, Text, useApp, useInput } from 'ink'; +import chalk from 'chalk'; +import { getSteuer } from '../src/services/misc/getSteuer.js'; +import { closePool } from '../src/db/mssqlClient.js'; + +import '../src/config/env.js'; + +const e = React.createElement; + +function SteuerTable({ steuerklassen }) { + return e(Box, { flexDirection: 'column' }, + e(Text, null, chalk.cyan.bold('=== Steuerklassen ===')), + e(Box, { marginTop: 1, flexDirection: 'column' }, + e(Text, { dimColor: true }, ' ID Rate Name'), + ...steuerklassen.map((row) => + e(Text, { key: row.kSteuerklasse }, + ' ', + chalk.white(String(row.kSteuerklasse).padEnd(4)), + chalk.yellow(`${String(row.fSteuersatz).padStart(3)}%`.padEnd(8)), + row.cName ?? '—', + row.nStandard === 1 ? chalk.gray(' (standard)') : '' + ) + ) + ), + e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to exit'))) + ); +} + +const App = ({ steuerklassen }) => { + const { exit } = useApp(); + + useInput(() => { + closePool() + .catch(() => {}) + .finally(() => { + exit(); + process.exit(0); + }); + }); + + return e(SteuerTable, { steuerklassen }); +}; + +try { + const { steuerklassen } = await getSteuer(); + render(e(App, { steuerklassen })); +} catch (error) { + console.error(chalk.red(`Error: ${error.message}`)); + await closePool().catch(() => {}); + process.exit(1); +} diff --git a/scripts/searchArticles.js b/scripts/searchArticles.js index a06e125..b4b2dbe 100644 --- a/scripts/searchArticles.js +++ b/scripts/searchArticles.js @@ -5,7 +5,7 @@ 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 { bruttoFromNetto, loadSteuerklassen } from '../src/services/misc/steuerklassen.js'; import '../src/config/env.js'; @@ -28,11 +28,12 @@ const eur = (value) => { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value); }; -function formatPrice(row) { +function formatPrice(row, steuerklassen) { if (row.fVKNEtto == null) { return '—'; } - return eur(getBrutto(row.fVKNEtto, row.kSteuerklasse)); + const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuerklassen); + return brutto == null ? '—' : eur(brutto); } function hasValue(value) { @@ -67,10 +68,8 @@ function Section({ title, children }) { ); } -function ArticleDetail({ article }) { - const brutto = article.fVKNEtto != null - ? getBrutto(article.fVKNEtto, article.kSteuerklasse) - : null; +function ArticleDetail({ article, steuerklassen }) { + const brutto = bruttoFromNetto(article.fVKNEtto, article.kSteuerklasse, steuerklassen); const aktiv = article.cAktiv === 'Y' ? chalk.green('active') : article.cAktiv === 'N' @@ -194,14 +193,14 @@ function truncateForDisplay(value, max = 80) { return `${value.slice(0, max)}… (${value.length} chars)`; } -function formatListRow(row) { +function formatListRow(row, steuerklassen) { const stock = row.fBestand ?? '—'; - const price = formatPrice(row); + const price = formatPrice(row, steuerklassen); const name = row.cName ?? '—'; return `${row.cArtNr ?? '—'} ${stock} ${price} ${name}`; } -const ResultList = ({ rows, onSelect, onBack }) => { +const ResultList = ({ rows, onSelect, onBack, steuerklassen }) => { 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); @@ -228,8 +227,8 @@ const ResultList = ({ rows, onSelect, onBack }) => { const absoluteIndex = windowStart + index; const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' '; const label = absoluteIndex === selected - ? chalk.cyan.bold(formatListRow(row)) - : formatListRow(row); + ? chalk.cyan.bold(formatListRow(row, steuerklassen)) + : formatListRow(row, steuerklassen); return e(Text, { key: row.kArtikel ?? absoluteIndex }, prefix, label); }), rows.length > VISIBLE_ROWS @@ -238,7 +237,7 @@ const ResultList = ({ rows, onSelect, onBack }) => { ); }; -const App = () => { +const App = ({ steuerklassen }) => { const { exit } = useApp(); const [mode, setMode] = useState(MODES.SEARCH); const [target, setTarget] = useState(''); @@ -337,6 +336,7 @@ const App = () => { rows, onSelect: (row) => loadArticle(row.cArtNr), onBack: backToSearch, + steuerklassen, }); } @@ -352,7 +352,7 @@ const App = () => { : 'Enter = new search, q = quit'; return e(Box, { flexDirection: 'column' }, - e(ArticleDetail, { article }), + e(ArticleDetail, { article, steuerklassen }), e(Box, { marginTop: 1 }, e(Text, null, chalk.gray(footer))), e(ResultInput, { onAgain: resetSearch, @@ -398,4 +398,5 @@ const ExitOnKey = ({ onExit }) => { return null; }; -render(e(App)); +const steuerklassen = await loadSteuerklassen(); +render(e(App, { steuerklassen })); diff --git a/src/api/routes/misc/getCategories.js b/src/api/routes/misc/getCategories.js new file mode 100644 index 0000000..caeddd9 --- /dev/null +++ b/src/api/routes/misc/getCategories.js @@ -0,0 +1,20 @@ +import { getCategories } from '../../../services/misc/getCategories.js'; + +/** + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @route GET /misc/getCategories + * @returns {Object} 200 - `{ categories, tree }` + */ + app.get(`${basePath}/misc/getCategories`, async (_req, res) => { + try { + const result = await getCategories(); + res.status(200).json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); +} diff --git a/src/api/routes/misc/getSteuer.js b/src/api/routes/misc/getSteuer.js new file mode 100644 index 0000000..a37d90d --- /dev/null +++ b/src/api/routes/misc/getSteuer.js @@ -0,0 +1,19 @@ +import { getSteuer } from '../../../services/misc/getSteuer.js'; + +/** + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @route GET /misc/getSteuer + * @returns {Object} 200 - `{ steuerklassen }` + */ + app.get(`${basePath}/misc/getSteuer`, async (_req, res) => { + try { + res.status(200).json(await getSteuer()); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); +} diff --git a/src/db/mssql/categories.js b/src/db/mssql/categories.js index 456d6a7..4df122e 100644 --- a/src/db/mssql/categories.js +++ b/src/db/mssql/categories.js @@ -2,3 +2,27 @@ export const LOAD_KAT_MAP = ` SELECT kKategorie, kOberKategorie FROM dbo.tkategorie `; + +export const LOAD_CATEGORIES = ` + SELECT + kKategorie, + kOberKategorie, + nSort, + ( + SELECT cName + FROM dbo.tKategorieSprache + WHERE kSprache = 1 + AND kPlattform = 1 + AND kShop = 0 + AND dbo.tkategorie.kKategorie = dbo.tKategorieSprache.kKategorie + ) AS cName + FROM dbo.tkategorie + ORDER BY nSort, cName +`; + +export const CATEGORY_ARTICLE_COUNTS = ` + SELECT ka.kKategorie, COUNT(*) AS count + FROM tkategorieartikel ka + INNER JOIN tArtikel a ON a.kArtikel = ka.kArtikel AND a.cAktiv = 'Y' + GROUP BY ka.kKategorie +`; diff --git a/src/db/mssql/steuer.js b/src/db/mssql/steuer.js new file mode 100644 index 0000000..c95de0b --- /dev/null +++ b/src/db/mssql/steuer.js @@ -0,0 +1,11 @@ +export const LOAD_STEUERKLASSEN = ` + SELECT + sk.kSteuerklasse, + sk.cName, + sk.nStandard, + sz.fSteuersatz + FROM dbo.tSteuerklasse sk + INNER JOIN dbo.tSteuersatz sz ON sz.kSteuerklasse = sk.kSteuerklasse + WHERE sz.kSteuerzone = 1 + ORDER BY sk.kSteuerklasse +`; diff --git a/src/services/article/getArticle.js b/src/services/article/getArticle.js index 0d3f190..262290c 100644 --- a/src/services/article/getArticle.js +++ b/src/services/article/getArticle.js @@ -1,7 +1,7 @@ import env from '../../config/env.js'; import { getPool } from '../../db/mssqlClient.js'; -import { getBrutto } from '../../utils/tax.js'; import { enrichSupplierAnmerkungRow } from './enrichSupplier.js'; +import { bruttoFromNetto, loadSteuerklassen } from '../misc/steuerklassen.js'; import * as Q from '../../db/mssql/getArticle.js'; async function query(pool, sql, inputs = {}) { @@ -71,9 +71,10 @@ async function loadAttributes(pool, kArtikel) { async function loadStaffel(pool, kArtikel, kSteuerklasse) { const rows = await query(pool, Q.STAFFEL, { kArtikel }); + const steuerklassen = await loadSteuerklassen(); return rows.map((item) => ({ menge: item.nAnzahlAb.toString(), - fVKBrutto: getBrutto(item.fNEttoPreis, kSteuerklasse).toFixed(2), + fVKBrutto: bruttoFromNetto(item.fNEttoPreis, kSteuerklasse, steuerklassen)?.toFixed(2), })); } diff --git a/src/services/misc/getCategories.js b/src/services/misc/getCategories.js new file mode 100644 index 0000000..8954a8f --- /dev/null +++ b/src/services/misc/getCategories.js @@ -0,0 +1,73 @@ +import { getPool } from '../../db/mssqlClient.js'; +import { + CATEGORY_ARTICLE_COUNTS, + LOAD_CATEGORIES, +} from '../../db/mssql/categories.js'; + +function sortCategories(list) { + list.sort((a, b) => { + const sortDiff = (a.nSort ?? 0) - (b.nSort ?? 0); + if (sortDiff !== 0) { + return sortDiff; + } + return String(a.cName ?? '').localeCompare(String(b.cName ?? ''), 'de'); + }); + for (const node of list) { + sortCategories(node.children); + } +} + +function buildCategoryTree(rows) { + const nodes = new Map(); + + for (const row of rows) { + nodes.set(row.kKategorie, { + kKategorie: row.kKategorie, + kOberKategorie: row.kOberKategorie, + nSort: row.nSort, + cName: row.cName ?? null, + count: row.count ?? 0, + children: [], + }); + } + + const roots = []; + + for (const node of nodes.values()) { + const parent = nodes.get(node.kOberKategorie); + if (parent && node.kOberKategorie !== node.kKategorie) { + parent.children.push(node); + } else { + roots.push(node); + } + } + + sortCategories(roots); + return roots; +} + +export async function getCategories() { + const pool = await getPool(); + + const [categoriesResult, countsResult] = await Promise.all([ + pool.request().query(LOAD_CATEGORIES), + pool.request().query(CATEGORY_ARTICLE_COUNTS), + ]); + + const countByCategory = new Map( + countsResult.recordset.map((row) => [row.kKategorie, row.count]) + ); + + const categories = categoriesResult.recordset.map((row) => ({ + kKategorie: row.kKategorie, + kOberKategorie: row.kOberKategorie, + nSort: row.nSort, + cName: row.cName ?? null, + count: countByCategory.get(row.kKategorie) ?? 0, + })); + + return { + categories, + tree: buildCategoryTree(categories), + }; +} diff --git a/src/services/misc/getSteuer.js b/src/services/misc/getSteuer.js new file mode 100644 index 0000000..e10f23b --- /dev/null +++ b/src/services/misc/getSteuer.js @@ -0,0 +1,6 @@ +import { loadSteuerklassen } from './steuerklassen.js'; + +export async function getSteuer() { + const steuerklassen = await loadSteuerklassen(); + return { steuerklassen }; +} diff --git a/src/services/misc/steuerklassen.js b/src/services/misc/steuerklassen.js new file mode 100644 index 0000000..263c6a3 --- /dev/null +++ b/src/services/misc/steuerklassen.js @@ -0,0 +1,28 @@ +import { getPool } from '../../db/mssqlClient.js'; +import { LOAD_STEUERKLASSEN } from '../../db/mssql/steuer.js'; + +let cache; + +export async function loadSteuerklassen() { + if (cache) { + return cache; + } + + const pool = await getPool(); + const result = await pool.request().query(LOAD_STEUERKLASSEN); + cache = result.recordset.map((row) => ({ + kSteuerklasse: row.kSteuerklasse, + cName: row.cName, + nStandard: row.nStandard, + fSteuersatz: row.fSteuersatz, + })); + return cache; +} + +export function bruttoFromNetto(netto, kSteuerklasse, steuerklassen) { + const row = steuerklassen.find((item) => item.kSteuerklasse === kSteuerklasse); + if (row == null || netto == null) { + return null; + } + return netto * (1 + row.fSteuersatz / 100); +} diff --git a/src/utils/tax.js b/src/utils/tax.js deleted file mode 100644 index 7d7e7a1..0000000 --- a/src/utils/tax.js +++ /dev/null @@ -1,14 +0,0 @@ -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); -}