This commit is contained in:
seb
2026-10-01 10:19:16 +02:00
parent 956fadcc8d
commit 8a7500cb5a
11 changed files with 59 additions and 19 deletions

View File

@@ -23,11 +23,11 @@ Overview of all 136 API endpoints across all modules.
| `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Set or clear article brand by SKU | | `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Set or clear article brand by SKU |
| `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article categories by SKU | | `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article categories by SKU |
| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article | | `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article |
| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article | | `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article |
| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article | | `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article |
| `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article EANs by SKU | | `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article EANs by SKU |
| `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article | | `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article |
| `GET` | `/article/{sku}/history` | `sku:string` | `200 { history: { id, date, text }[] }` | Get article history by SKU | | `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU |
| `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image | | `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image |
| `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images | | `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images |
| `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article | | `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article |

View File

@@ -16,7 +16,7 @@ Replaces all attribute values for an article. Enforces exclusive constraint (max
| Field | Required | Type | Description | | Field | Required | Type | Description |
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `attributevalues` | yes | any[] | | | `attributevalues` | yes | any[] | Array of attribute value IDs (numbers) or objects with id or attributeValueId |
```json ```json
{ {

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Add a child article to a father article Add a child article to a father article
Automatically generates SKU as fatherSku-attributeValueId and assigns the variant attribute. Automatically generates SKU as fatherSku-nextFreeIndex (or accepts custom sku) and assigns the variant attribute.
## Path parameters ## Path parameters
@@ -19,12 +19,14 @@ Automatically generates SKU as fatherSku-attributeValueId and assigns the varian
| `attributeValueId` | no | integer | ID of existing attribute value for the father's attribute | | `attributeValueId` | no | integer | ID of existing attribute value for the father's attribute |
| `value` | no | string | String value to find or create under the father's attribute | | `value` | no | string | String value to find or create under the father's attribute |
| `name` | no | string | Optional custom variant name | | `name` | no | string | Optional custom variant name |
| `sku` | no | string | Optional custom SKU for the child article |
```json ```json
{ {
"attributeValueId": 1, "attributeValueId": 1,
"value": "…", "value": "…",
"name": "…" "name": "…",
"sku": "…"
} }
``` ```
@@ -48,8 +50,7 @@ Created child article
| Status | Description | | Status | Description |
|--------|-------------| |--------|-------------|
| 400 | Father not configured as father or missing value | | 400 | Father not configured as father, missing value, duplicate variant, or child SKU already exists |
| 409 | Child SKU already exists |
--- ---

View File

@@ -14,6 +14,12 @@ Auth like GET by SKU.
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `sku` | yes | string | Article SKU | | `sku` | yes | string | Article SKU |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `limit` | no | integer | Maximum number of entries to return (newest first). Omit for all entries. |
## Response `200` ## Response `200`
Article history (newest first) Article history (newest first)

View File

@@ -88,6 +88,9 @@ function articleErrorStatus(error) {
if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400; if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400;
if (error.message === 'Attribute value not found or does not match father attribute') return 400; if (error.message === 'Attribute value not found or does not match father attribute') return 400;
if (error.message === 'Child article does not belong to this father') return 400; if (error.message === 'Child article does not belong to this father') return 400;
if (error.message === 'Variant with this attribute value already exists') return 400;
if (error.message?.startsWith('Child article') && error.message?.includes('already exists')) return 400;
if (error.message?.toLowerCase().includes('duplicate variant')) return 400;
if (error.message === 'Father article not found') return 404; if (error.message === 'Father article not found') return 404;
if ( if (
error.message?.includes('Stückliste') || error.message?.includes('Stückliste') ||
@@ -553,6 +556,14 @@ export function register(app, basePath = '') {
* schema: * schema:
* type: string * type: string
* description: Article SKU * description: Article SKU
* - in: query
* name: limit
* required: false
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* description: Maximum number of entries to return (newest first). Omit for all entries.
* responses: * responses:
* 200: * 200:
* description: Article history (newest first) * description: Article history (newest first)
@@ -560,12 +571,15 @@ export function register(app, basePath = '') {
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* required: [history] * required: [history, truncated]
* properties: * properties:
* history: * history:
* type: array * type: array
* items: * items:
* $ref: '#/components/schemas/ArticleHistoryEntry' * $ref: '#/components/schemas/ArticleHistoryEntry'
* truncated:
* type: boolean
* description: True when a limit was applied and the result set was exactly that size (more entries may exist).
* example: * example:
* history: * history:
* - id: 42 * - id: 42
@@ -605,9 +619,19 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'sku is required' }); return res.status(400).json({ error: 'sku is required' });
} }
const rawLimit = req.query.limit;
let limit = null;
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' });
}
}
try { try {
const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku)); const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku), limit != null ? { limit } : undefined);
res.status(200).json({ history }); const truncated = limit != null && history.length === limit;
res.status(200).json({ history, truncated });
} catch (error) { } catch (error) {
const status = articleErrorStatus(error); const status = articleErrorStatus(error);
if (status === 500) console.error('Get article history by sku failed:', error); if (status === 500) console.error('Get article history by sku failed:', error);
@@ -1868,7 +1892,7 @@ export function register(app, basePath = '') {
* /article/{sku}/children: * /article/{sku}/children:
* post: * post:
* summary: Add a child article to a father article * summary: Add a child article to a father article
* description: Automatically generates SKU as fatherSku-attributeValueId and assigns the variant attribute. * description: Automatically generates SKU as fatherSku-nextFreeIndex (or accepts custom sku) and assigns the variant attribute.
* tags: * tags:
* - Article * - Article
* security: * security:
@@ -1896,6 +1920,9 @@ export function register(app, basePath = '') {
* name: * name:
* type: string * type: string
* description: Optional custom variant name * description: Optional custom variant name
* sku:
* type: string
* description: Optional custom SKU for the child article
* responses: * responses:
* 201: * 201:
* description: Created child article * description: Created child article
@@ -1907,9 +1934,7 @@ export function register(app, basePath = '') {
* article: * article:
* $ref: '#/components/schemas/Article' * $ref: '#/components/schemas/Article'
* 400: * 400:
* description: Father not configured as father or missing value * description: Father not configured as father, missing value, duplicate variant, or child SKU already exists
* 409:
* description: Child SKU already exists
*/ */
app.post(`${basePath}/article/:sku/children`, async (req, res) => { app.post(`${basePath}/article/:sku/children`, async (req, res) => {
const parsed = parseSku(req.params.sku); const parsed = parseSku(req.params.sku);

View File

@@ -89,14 +89,19 @@ export function register(app, basePath = '') {
* properties: * properties:
* attributevalues: * attributevalues:
* type: array * type: array
* description: Array of attribute value IDs (numbers) or objects with id or attributeValueId
* items: * items:
* oneOf: * oneOf:
* - type: integer * - type: integer
* description: Attribute value ID
* - type: object * - type: object
* required: [attributeValueId]
* properties: * properties:
* id:
* type: integer
* description: Attribute value ID
* attributeValueId: * attributeValueId:
* type: integer * type: integer
* description: Attribute value ID
* sort: * sort:
* type: integer * type: integer
* responses: * responses:
@@ -142,7 +147,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: skuParsed.error }); return res.status(400).json({ error: skuParsed.error });
} }
const valuesInput = req.body?.attributevalues ?? req.body; const valuesInput = req.body?.attributevalues ?? req.body?.attributeValues ?? req.body?.attribute_values ?? req.body?.values ?? req.body;
if (!Array.isArray(valuesInput)) { if (!Array.isArray(valuesInput)) {
return res.status(400).json({ error: 'attributevalues array required' }); return res.status(400).json({ error: 'attributevalues array required' });
} }

View File

@@ -171,6 +171,7 @@ function deliveryErrorStatus(error) {
if (error.message === 'Invalid file') return 400; if (error.message === 'Invalid file') return 400;
if (error.message === 'Invalid JSON in "data" field') return 400; if (error.message === 'Invalid JSON in "data" field') return 400;
if (error.message === 'Only PDF or text files are accepted as attachments') return 400; if (error.message === 'Only PDF or text files are accepted as attachments') return 400;
if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400;
return 500; return 500;
} }

View File

@@ -18,6 +18,7 @@ function orderErrorStatus(error) {
if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid order') return 400; if (error.message === 'Invalid order') return 400;
if (error.message === 'Invalid lines') return 400; if (error.message === 'Invalid lines') return 400;
if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400;
return 500; return 500;
} }

View File

@@ -18,6 +18,7 @@ function quoteErrorStatus(error) {
if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid quote') return 400; if (error.message === 'Invalid quote') return 400;
if (error.message === 'Invalid lines') return 400; if (error.message === 'Invalid lines') return 400;
if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400;
return 500; return 500;
} }

View File

@@ -20,7 +20,7 @@ function stockErrorStatus(error) {
if (error.message?.includes('Amount must be')) return 400; if (error.message?.includes('Amount must be')) return 400;
if (error.message?.includes('Invalid amount')) return 400; if (error.message?.includes('Invalid amount')) return 400;
if (error.message === 'No items to adjust') return 400; if (error.message === 'No items to adjust') return 400;
if (error.message?.includes('cannot have stock') || error.message?.includes('A father article cannot have') || error.message?.includes('father article')) return 400; if (error.message?.includes('cannot have stock') || error.message?.includes('A father article cannot have') || error.message?.includes('father article') || error.message?.includes('Stückliste')) return 400;
return 500; return 500;
} }

View File

@@ -10,9 +10,9 @@ export async function getBySku(googleAuthId, sku) {
return store.article.getBySku(googleAuthId, sku); return store.article.getBySku(googleAuthId, sku);
} }
export async function getHistoryBySku(googleAuthId, sku) { export async function getHistoryBySku(googleAuthId, sku, opts) {
const store = await getStore(); const store = await getStore();
return store.article.getHistoryBySku(googleAuthId, sku); return store.article.getHistoryBySku(googleAuthId, sku, opts);
} }
export async function updateBySku(googleAuthId, sku, data) { export async function updateBySku(googleAuthId, sku, data) {