From 645a9153a09660a7fc50aa28606d704dd8401c18 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 9 Oct 2026 19:13:57 +0200 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 128 ++++++++++++++++++++++++-------- src/automats/brandAutomat.js | 79 +++++++++++++++++--- 2 files changed, 169 insertions(+), 38 deletions(-) diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 6e4e698..01d29bc 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -7,7 +7,7 @@ description: >- # Dash API Routes Overview (All) -Overview of all 192 API endpoints across all modules. +Overview of all 205 API endpoints across all modules. ### Notation @@ -17,7 +17,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### Reusable types - `T1` = `{ article: T2 }` -- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T32, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T36[], suppliers: T12[] }` +- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T31, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T35[], suppliers: T12[] }` - `T3` = `{ delivery: T4 }` - `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T20[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }` - `T5` = `{ labeltemplate: T6 }` @@ -45,13 +45,13 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }` - `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }` - `T29` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }` -- `T30` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` -- `T31` = `{ article: T34 }` -- `T32` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }` -- `T33` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }` -- `T34` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` -- `T35` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }` -- `T36` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` +- `T30` = `{ article: T33 }` +- `T31` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }` +- `T32` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }` +- `T33` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` +- `T34` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }` +- `T35` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` +- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` - `T37` = `{ id: int, entityId: int, contactType: str, contact: str }` - `T38` = `{ id: int, attributeId: int, value: str, sort: int }` - `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }` @@ -64,9 +64,18 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### Routes +#### List accounts — `GET /account` +_none_ +- Returns: `200 { accounts: { id: int, companyId: int, type: str, name: str }[] }` +#### Create account — `POST /account` +- Params: `body: { type: str, name: str }` +- Returns: `201 { account: { id: int, companyId: int, type: str, name: str } }` +#### Delete account — `DELETE /account/{id}` +- Params: `id: int` +- Returns: `204 void` #### Create article in category — `POST /article` - Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }` -- Returns: `201 T31` +- Returns: `201 T30` #### Export articles as ZIP — `GET /article/export.zip` - Params: `query: categoryId?: int` - Returns: `200 zip` @@ -102,10 +111,10 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }` #### Create and attach a new child article to a parent article — `POST /article/{sku}/children` - Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }` -- Returns: `201 T31` +- Returns: `201 T30` #### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children` - Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }` -- Returns: `200 { article: T34, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }` +- Returns: `200 { article: T33, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }` #### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}` - Params: `sku: str, childSku: str, query: deleteArticle?: bool` - Returns: `200 { success: bool, deleted: bool }` @@ -132,7 +141,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }` #### Toggle parent mode for an article — `POST /article/{sku}/parent` - Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` -- Returns: `200 T31` +- Returns: `200 T30` #### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices` - Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }` - Returns: `200 T1` @@ -201,31 +210,34 @@ _none_ - Returns: `200 { attributevalue: T38 }` #### Login with Google ID token — `POST /auth/login` - Params: `body: { idToken: str }` -- Returns: `200 { message: str, user: T35 }` +- Returns: `200 { message: str, user: T34 }` #### Logout — `POST /auth/logout` _none_ - Returns: `200 { message: str }` #### Current user — `GET /auth/me` _none_ -- Returns: `200 { user: T35, googleClientId: str }` +- Returns: `200 { user: T34, googleClientId: str }` #### Set selected company — `PUT /auth/selected-company` - Params: `body: { companyId: int }` -- Returns: `200 { user: T35 }` +- Returns: `200 { user: T34 }` #### List brands — `GET /brand` -_none_ -- Returns: `200 { brands: { id: int, name: str, pics: T30[], gsprpinfo?: str|null }[] }` -#### Upsert brand — `PUT /brand` +- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool` +- Returns: `200 { brands: { id: int, name: str, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` +#### Create brand — `POST /brand` - Params: `body: { name: str, gsprpinfo?: str }` -- Returns: `200 { brand: { id: int, name: str, pics: T30[], gsprpinfo?: str|null } }` +- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }` #### Delete brand — `DELETE /brand/{id}` - Params: `id: int` - Returns: `204 void` +#### Rename brand — `PATCH /brand/{id}` +- Params: `id: int, body: { name?: str, gsprpinfo?: str }` +- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }` #### Delete brand image — `DELETE /brand/{id}/image` - Params: `id: int` -- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T30[] } }` +- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }` #### Add or replace brand image — `POST /brand/{id}/image` - Params: `id: int, image/avif` -- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T30[] } }` +- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }` #### Create category — `POST /category` - Params: `body: { parentId: int, name: str }` - Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }` @@ -270,10 +282,10 @@ _none_ - Returns: `200 { ok: bool }` #### List companies — `GET /company` _none_ -- Returns: `200 { companies: T33[] }` +- Returns: `200 { companies: T32[] }` #### Create company — `POST /company` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` -- Returns: `201 { company: T33, user: T35 }` +- Returns: `201 { company: T32, user: T34 }` #### Get company history — `GET /company/history` - Params: `query: limit?: int` - Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }` @@ -297,7 +309,7 @@ _none_ - Returns: `204 void` #### Update company — `PUT /company/{id}` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` -- Returns: `200 { company: T33 }` +- Returns: `200 { company: T32 }` #### List customer groups — `GET /customergroup` _none_ - Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }` @@ -322,6 +334,15 @@ _none_ #### Parse document (SSE) — `POST /delivery/parse` - Params: `form: { file: str }` - Returns: `200 SSE` + +- Events: + - `status`: `{ "message": "…" }` + - `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }` + - `tool_call`: `{ "name": "output_manifest" }` + - `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }` + - `progress`: `ManifestResult` + - `result`: `ManifestResult` + - `error`: `{ "message": "…" }` #### Delete delivery — `DELETE /delivery/{id}` - Params: `id: int` - Returns: `204 void` @@ -379,6 +400,22 @@ _none_ #### Subscribe to update signals (SSE) — `GET /events` _none_ - Returns: `200 SSE` + +- Events: + - `category`: `{}` + - `articles`: `{}` + - `article-`: `{}` + - `stock`: `{}` + - `ledger`: `{}` + - `attributename`: `{}` + - `propertyname`: `{}` + - `labeltemplate`: `{}` + - `printer`: `{}` + - `warehouse`: `{}` + - `vfs`: `{}` + - `modelconversation`: `{}` + - `modelconversation-`: `{}` + - `brand`: `{ sender, brandId }` #### Database health check — `GET /health` _none_ - Returns: `200 { ok: bool }` @@ -443,8 +480,8 @@ _none_ - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` #### List orders — `GET /order` -_none_ -- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }` +- Params: `query: offset?: int, limit?: int` +- Returns: `200 { orders: T11[], total: int, offset: int, limit: int }` #### Create order — `POST /order` - Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }` - Returns: `201 T7` @@ -457,6 +494,24 @@ _none_ #### Update order — `PATCH /order/{id}` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }` - Returns: `200 T7` +#### List payments of an order — `GET /order/{id}/payment` +- Params: `id: int` +- Returns: `200 { payments: { id: int, accountId: int, manifestId: int, type: str, amount: num }[] }` +#### Add payment to an order — `POST /order/{id}/payment` +- Params: `id: int, body: { accountId: int, type: str, amount: num }` +- Returns: `201 { payment: { id: int, accountId: int, manifestId: int, type: str, amount: num } }` +#### List shippings of an order — `GET /order/{id}/shipping` +- Params: `id: int` +- Returns: `200 { shippings: { id: int, manifestId: int, number: str, type: str, state: str }[] }` +#### Create shipping for an order — `POST /order/{id}/shipping` +- Params: `id: int, body: { type: str, state: str }` +- Returns: `201 { shipping: { id: int, manifestId: int, number: str, type: str, state: str } }` +#### Delete package — `DELETE /package/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Delete payment — `DELETE /payment/{id}` +- Params: `id: int` +- Returns: `204 void` #### List POS terminals — `GET /posterminal` _none_ - Returns: `200 { posterminals: T10[] }` @@ -550,6 +605,21 @@ _none_ #### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images` - Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }` - Returns: `200 SSE` + +- Events: + - `images`: `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }` + - `thumb`: `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }` + - `done`: `{ "total": 100 }` + - `error`: `{ "error": "…" }` +#### Delete shipping — `DELETE /shipping/{id}` +- Params: `id: int` +- Returns: `204 void` +#### List packages of a shipping — `GET /shipping/{id}/package` +- Params: `id: int` +- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }` +#### Create package for a shipping — `POST /shipping/{id}/package` +- Params: `id: int, body: { entityaddressId: int, type: str, state: str, details?: str }` +- Returns: `201 { package: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null } }` #### Adjust stock manually — `POST /stock/adjust` - Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }` - Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T15[] }` @@ -579,10 +649,10 @@ _none_ - Returns: `204 void` #### List units — `GET /unit` _none_ -- Returns: `200 { units: T32[] }` +- Returns: `200 { units: T31[] }` #### Upsert unit — `PUT /unit` - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` -- Returns: `200 { unit: T32 }` +- Returns: `200 { unit: T31 }` #### JTL-POS category synchronization — `GET /v1/category` - Params: `query: lastChangedCategory?: str, limit?: int` - Returns: `200 {}[]` diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js index 4510fbf..88e2724 100644 --- a/src/automats/brandAutomat.js +++ b/src/automats/brandAutomat.js @@ -5,12 +5,19 @@ * Upstream Triggers: * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. + * - `sseAutomat`: Subscribes to the single `'brand'` event carrying + * `{ sender, brandId }` — any create/rename/delete/picture change triggers + * `actions.reload()`, which silently re-fetches the list. Subscribed views + * (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then + * re-render from the updated state. This is the reload pattern. Events sent + * by this tab (matching `sender`) are ignored. * - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`). */ import { Automat } from 'react-automat'; import { brand as brandApi } from '../api'; import authAutomat from './authAutomat'; +import sseAutomat, { getSseClientId } from './sseAutomat'; /** * @typedef {Object} BrandState @@ -34,19 +41,56 @@ function sortBrands(brands) { let brandAutomat; +/** In-flight `load()`/`reload()` promise so concurrent callers share one request. */ +let inflightLoad = null; + +async function fetchBrands() { + const res = await brandApi.list(); + return sortBrands(Array.isArray(res.brands) ? res.brands : []); +} + const actions = { /** Load all brands for the selected company. */ async load() { + if (inflightLoad) return inflightLoad; brandAutomat.setState({ status: 'loading', error: null }); - try { - const res = await brandApi.list(); - const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []); - brandAutomat.setState({ brands, status: 'succeeded' }); - return brands; - } catch (err) { - brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); - throw err; - } + inflightLoad = (async () => { + try { + const brands = await fetchBrands(); + brandAutomat.setState({ brands, status: 'succeeded' }); + return brands; + } catch (err) { + brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } finally { + inflightLoad = null; + } + })(); + return inflightLoad; + }, + + /** + * Silently re-fetches the brand list while keeping the current state + * visible (no loading flash). Subscribed views re-render from the updated + * state — this is the reload pattern for SSE-driven refreshes. + * + * @returns {Promise>} Fresh brands. + */ + async reload() { + if (inflightLoad) return inflightLoad; + inflightLoad = (async () => { + try { + const brands = await fetchBrands(); + brandAutomat.setState({ brands, status: 'succeeded', error: null }); + return brands; + } catch (err) { + brandAutomat.setState({ error: err?.message ?? String(err) }); + throw err; + } finally { + inflightLoad = null; + } + })(); + return inflightLoad; }, /** @@ -134,4 +178,21 @@ authAutomat.subscribe((auth) => { } }); +// SSE: single `brand` event with `{ sender, brandId }` payload for every +// create/rename/delete/picture change. Silently reloads the list; subscribed +// views re-render from state. Own events are skipped. +sseAutomat.actions.on('brand', (event) => { + try { + const payload = JSON.parse(event?.data ?? '{}'); + if (payload && typeof payload.sender === 'string' && payload.sender) { + try { + if (payload.sender === getSseClientId()) return; + } catch {} + } + } catch { + // Unparseable payload: fall through to reload. + } + void brandAutomat.actions.reload().catch(() => {}); +}); + export default brandAutomat;