services
This commit is contained in:
@@ -34,8 +34,15 @@ Mirrors [`src/api/routes/`](../../src/api/routes/).
|
|||||||
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
|
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
|
||||||
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
|
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
|
||||||
| [`routes/health.md`](routes/health.md) | `GET /health` |
|
| [`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
|
- for every route we have a file in /src/api/routes
|
||||||
- [ ] HTTPS in dev (or proxy) so `Secure` cookies work
|
- 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/
|
||||||
121
docs/api/routes/article/get.md
Normal file
121
docs/api/routes/article/get.md
Normal file
@@ -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();
|
||||||
|
```
|
||||||
100
docs/api/routes/article/search.md
Normal file
100
docs/api/routes/article/search.md
Normal file
@@ -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();
|
||||||
|
```
|
||||||
78
docs/api/routes/misc/getCategories.md
Normal file
78
docs/api/routes/misc/getCategories.md
Normal file
@@ -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])
|
||||||
|
);
|
||||||
|
```
|
||||||
63
docs/api/routes/misc/getSteuer.md
Normal file
63
docs/api/routes/misc/getSteuer.md
Normal file
@@ -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();
|
||||||
|
```
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"manage-users": "node scripts/manageUsers.js",
|
"manage-users": "node scripts/manageUsers.js",
|
||||||
"search-articles": "node scripts/searchArticles.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"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
90
scripts/getCategories.js
Normal file
90
scripts/getCategories.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
52
scripts/getSteuer.js
Normal file
52
scripts/getSteuer.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import chalk from 'chalk';
|
|||||||
import { getArticle } from '../src/services/article/getArticle.js';
|
import { getArticle } from '../src/services/article/getArticle.js';
|
||||||
import { searchArticles } from '../src/services/article/searchArticles.js';
|
import { searchArticles } from '../src/services/article/searchArticles.js';
|
||||||
import { closePool } from '../src/db/mssqlClient.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';
|
import '../src/config/env.js';
|
||||||
|
|
||||||
@@ -28,11 +28,12 @@ const eur = (value) => {
|
|||||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
|
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatPrice(row) {
|
function formatPrice(row, steuerklassen) {
|
||||||
if (row.fVKNEtto == null) {
|
if (row.fVKNEtto == null) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
return eur(getBrutto(row.fVKNEtto, row.kSteuerklasse));
|
const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuerklassen);
|
||||||
|
return brutto == null ? '—' : eur(brutto);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasValue(value) {
|
function hasValue(value) {
|
||||||
@@ -67,10 +68,8 @@ function Section({ title, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArticleDetail({ article }) {
|
function ArticleDetail({ article, steuerklassen }) {
|
||||||
const brutto = article.fVKNEtto != null
|
const brutto = bruttoFromNetto(article.fVKNEtto, article.kSteuerklasse, steuerklassen);
|
||||||
? getBrutto(article.fVKNEtto, article.kSteuerklasse)
|
|
||||||
: null;
|
|
||||||
const aktiv = article.cAktiv === 'Y'
|
const aktiv = article.cAktiv === 'Y'
|
||||||
? chalk.green('active')
|
? chalk.green('active')
|
||||||
: article.cAktiv === 'N'
|
: article.cAktiv === 'N'
|
||||||
@@ -194,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) {
|
function formatListRow(row, steuerklassen) {
|
||||||
const stock = row.fBestand ?? '—';
|
const stock = row.fBestand ?? '—';
|
||||||
const price = formatPrice(row);
|
const price = formatPrice(row, steuerklassen);
|
||||||
const name = row.cName ?? '—';
|
const name = row.cName ?? '—';
|
||||||
return `${row.cArtNr ?? '—'} ${stock} ${price} ${name}`;
|
return `${row.cArtNr ?? '—'} ${stock} ${price} ${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResultList = ({ rows, onSelect, onBack }) => {
|
const ResultList = ({ rows, onSelect, onBack, steuerklassen }) => {
|
||||||
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);
|
||||||
@@ -228,8 +227,8 @@ const ResultList = ({ rows, onSelect, onBack }) => {
|
|||||||
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))
|
? chalk.cyan.bold(formatListRow(row, steuerklassen))
|
||||||
: formatListRow(row);
|
: formatListRow(row, steuerklassen);
|
||||||
return e(Text, { key: row.kArtikel ?? absoluteIndex }, prefix, label);
|
return e(Text, { key: row.kArtikel ?? absoluteIndex }, prefix, label);
|
||||||
}),
|
}),
|
||||||
rows.length > VISIBLE_ROWS
|
rows.length > VISIBLE_ROWS
|
||||||
@@ -238,7 +237,7 @@ const ResultList = ({ rows, onSelect, onBack }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = ({ steuerklassen }) => {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [mode, setMode] = useState(MODES.SEARCH);
|
const [mode, setMode] = useState(MODES.SEARCH);
|
||||||
const [target, setTarget] = useState('');
|
const [target, setTarget] = useState('');
|
||||||
@@ -337,6 +336,7 @@ const App = () => {
|
|||||||
rows,
|
rows,
|
||||||
onSelect: (row) => loadArticle(row.cArtNr),
|
onSelect: (row) => loadArticle(row.cArtNr),
|
||||||
onBack: backToSearch,
|
onBack: backToSearch,
|
||||||
|
steuerklassen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ const App = () => {
|
|||||||
: 'Enter = new search, q = quit';
|
: 'Enter = new search, q = quit';
|
||||||
|
|
||||||
return e(Box, { flexDirection: 'column' },
|
return e(Box, { flexDirection: 'column' },
|
||||||
e(ArticleDetail, { article }),
|
e(ArticleDetail, { article, steuerklassen }),
|
||||||
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray(footer))),
|
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray(footer))),
|
||||||
e(ResultInput, {
|
e(ResultInput, {
|
||||||
onAgain: resetSearch,
|
onAgain: resetSearch,
|
||||||
@@ -398,4 +398,5 @@ const ExitOnKey = ({ onExit }) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
render(e(App));
|
const steuerklassen = await loadSteuerklassen();
|
||||||
|
render(e(App, { steuerklassen }));
|
||||||
|
|||||||
20
src/api/routes/misc/getCategories.js
Normal file
20
src/api/routes/misc/getCategories.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/api/routes/misc/getSteuer.js
Normal file
19
src/api/routes/misc/getSteuer.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,3 +2,27 @@ export const LOAD_KAT_MAP = `
|
|||||||
SELECT kKategorie, kOberKategorie
|
SELECT kKategorie, kOberKategorie
|
||||||
FROM dbo.tkategorie
|
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
|
||||||
|
`;
|
||||||
|
|||||||
11
src/db/mssql/steuer.js
Normal file
11
src/db/mssql/steuer.js
Normal file
@@ -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
|
||||||
|
`;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import env from '../../config/env.js';
|
import env from '../../config/env.js';
|
||||||
import { getPool } from '../../db/mssqlClient.js';
|
import { getPool } from '../../db/mssqlClient.js';
|
||||||
import { getBrutto } from '../../utils/tax.js';
|
|
||||||
import { enrichSupplierAnmerkungRow } from './enrichSupplier.js';
|
import { enrichSupplierAnmerkungRow } from './enrichSupplier.js';
|
||||||
|
import { bruttoFromNetto, loadSteuerklassen } from '../misc/steuerklassen.js';
|
||||||
import * as Q from '../../db/mssql/getArticle.js';
|
import * as Q from '../../db/mssql/getArticle.js';
|
||||||
|
|
||||||
async function query(pool, sql, inputs = {}) {
|
async function query(pool, sql, inputs = {}) {
|
||||||
@@ -71,9 +71,10 @@ async function loadAttributes(pool, kArtikel) {
|
|||||||
|
|
||||||
async function loadStaffel(pool, kArtikel, kSteuerklasse) {
|
async function loadStaffel(pool, kArtikel, kSteuerklasse) {
|
||||||
const rows = await query(pool, Q.STAFFEL, { kArtikel });
|
const rows = await query(pool, Q.STAFFEL, { kArtikel });
|
||||||
|
const steuerklassen = await loadSteuerklassen();
|
||||||
return rows.map((item) => ({
|
return rows.map((item) => ({
|
||||||
menge: item.nAnzahlAb.toString(),
|
menge: item.nAnzahlAb.toString(),
|
||||||
fVKBrutto: getBrutto(item.fNEttoPreis, kSteuerklasse).toFixed(2),
|
fVKBrutto: bruttoFromNetto(item.fNEttoPreis, kSteuerklasse, steuerklassen)?.toFixed(2),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
src/services/misc/getCategories.js
Normal file
73
src/services/misc/getCategories.js
Normal file
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
6
src/services/misc/getSteuer.js
Normal file
6
src/services/misc/getSteuer.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { loadSteuerklassen } from './steuerklassen.js';
|
||||||
|
|
||||||
|
export async function getSteuer() {
|
||||||
|
const steuerklassen = await loadSteuerklassen();
|
||||||
|
return { steuerklassen };
|
||||||
|
}
|
||||||
28
src/services/misc/steuerklassen.js
Normal file
28
src/services/misc/steuerklassen.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user