From eea8e993589c4855aede01876bbd9f4167bfc4e2 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 19 Jul 2026 17:09:14 +0200 Subject: [PATCH] search update --- docs/api/routes/article/search.md | 96 ++++++++++++++++---------- src/services/article/searchArticles.js | 24 ++++++- 2 files changed, 80 insertions(+), 40 deletions(-) diff --git a/docs/api/routes/article/search.md b/docs/api/routes/article/search.md index eb00840..5441f5c 100644 --- a/docs/api/routes/article/search.md +++ b/docs/api/routes/article/search.md @@ -2,7 +2,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). -Fuzzy article search against JTL-Wawi (MSSQL). Use this to build a search box / result list. +Fuzzy article search. Use this to build a search box / result list with category facets. ## Query parameters @@ -10,14 +10,17 @@ Fuzzy article search against JTL-Wawi (MSSQL). Use this to build a search box / |-------|----------|-------------| | `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). | +| `offset` | no | Pagination offset (default `0`). | +| `maxCount` | no | Page size (default `10`). | ## 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. +- Without a category filter, at most **2000** rows are returned (before pagination). - Results are ordered by product name ascending. +- `length` is the total match count (before `offset` / `maxCount`); `recordset` is the current page. ## Category filter @@ -29,57 +32,74 @@ Fuzzy article search against JTL-Wawi (MSSQL). Use this to build a search box / Pass category IDs as a comma-separated list: `filter=16,39`. +Whenever `filter` is present, **`categories` always includes every id from `filter`**, even when `length` is `0` or a filtered id has no hits. Those entries use master-data `name` / `parent` and may have `count: 0`. Other facet categories with hits are still returned as usual. + ## Response `200` ```json { - "katagg": { - "16": 12, - "39": 8 - }, + "length": 42, "recordset": [ { - "katList": "16,39", - "fVerfuegbar": 0, - "kArtikel": 49, - "fBestand": null, - "cArtNr": "42000044", - "cName": "BioBizz AllMix Peat Free 20L", - "fVKNEtto": 11.3445378151261, - "kSteuerklasse": 1 + "categories": "16,39", + "available": 0, + "id": 49, + "stock": null, + "sku": "42000044", + "name": "BioBizz AllMix Peat Free 20L", + "price": 13.5, + "tax": 19 } - ] + ], + "categories": { + "16": { "name": "Dünger", "count": 42, "parent": 0 }, + "39": { "name": "Erde", "count": 18, "parent": 16 } + } } ``` +Empty result with an active filter still returns the applied category: + +```json +{ + "length": 0, + "recordset": [], + "categories": { + "16": { "name": "Dünger", "count": 0, "parent": 0 } + } +} +``` + +### Top-level fields + +| Field | Type | Notes | +|-------|------|-------| +| `length` | number | Total matching articles (unpaginated). | +| `recordset` | array | Current page of shaped article rows. | +| `categories` | object | Map keyed by category id (string). Facet counts for the current search (+ filters). | + ### `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 | +| `id` | number | Internal article ID (`kArtikel`) | +| `sku` | string | Article number — pass to [`GET /article/get`](get.md) | +| `name` | string | Display name | +| `stock` | number \| null | Stock in warehouse 1 | +| `available` | number \| null | Available stock | +| `price` | number \| null | Brutto VK, rounded to 1 decimal | +| `tax` | number \| null | Tax rate percent for the article’s Steuerklasse | +| `categories` | string \| null | Comma-separated category IDs on the article | -### `katagg` +### `categories` map entries -Map of category ID → count of matching articles in that category (including parent categories in the tree). Use for category sidebar / facet counts. +| Field | Type | Notes | +|-------|------|-------| +| `name` | string \| null | Display label from category master data | +| `count` | number | Hit count under the current search (+ other filters); may be `0` | +| `parent` | number \| null | Parent category id, or `0` if root | -### 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); -} -``` +Counts include parent categories rolled up from each article’s category path. Use for category sidebar / facet UI. Resolve labels via `categories[id].name` (skip ids without a name). ## Errors @@ -93,8 +113,8 @@ function getBrutto(netto, kSteuerklasse, steuerklassen) { ```js const res = await fetch( - 'http://localhost:4208/api/article/search?target=bizz%20allmix', + 'http://localhost:4208/api/article/search?target=bizz%20allmix&filter=16&maxCount=10', { credentials: 'include' } ); -const { katagg, recordset } = await res.json(); +const { categories, length, recordset } = await res.json(); ``` diff --git a/src/services/article/searchArticles.js b/src/services/article/searchArticles.js index 12f4387..71352e8 100644 --- a/src/services/article/searchArticles.js +++ b/src/services/article/searchArticles.js @@ -77,6 +77,22 @@ function shapeCategories(counts, byId) { return categories; } +/** Ensure every applied filter id appears in categories (count 0 if no hits). */ +function ensureFilterCategories(categories, filter, byId) { + for (const id of filter) { + if (id === '' || id === null || categories[id]) { + continue; + } + const meta = byId[id] ?? {}; + categories[id] = { + count: 0, + name: meta.name ?? null, + parent: meta.parent ?? null, + }; + } + return categories; +} + function shapeArticle(row, steuersaetze) { const taxRow = steuersaetze.find((item) => item.kSteuerklasse === row.kSteuerklasse); const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuersaetze); @@ -184,8 +200,12 @@ export async function searchArticles(target, filter = [''], offset = 0, maxCount loadCategoryCache(pool), loadSteuersatz(), ]); - const categories = shapeCategories( - buildKatAgg(result.recordset, categoryData.parentMap), + const categories = ensureFilterCategories( + shapeCategories( + buildKatAgg(result.recordset, categoryData.parentMap), + categoryData.byId + ), + filter, categoryData.byId ); const length = result.recordset.length;