This commit is contained in:
seb
2026-10-14 20:50:07 +02:00
parent 9c54a8a1d3
commit d880273629
34 changed files with 4454 additions and 2695 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -135,6 +135,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/delivery/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` |
| [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` |
| [`routes/delivery/file.md`](routes/delivery/file.md) | `GET /delivery/{id}/file/{fileId}` |
| [`routes/delivery/_id/pdf.md`](routes/delivery/_id/pdf.md) | `POST /delivery/{id}/pdf` |
| [`routes/entity/list.md`](routes/entity/list.md) | `GET /entity` |
| [`routes/entity/create.md`](routes/entity/create.md) | `POST /entity` |
| [`routes/entity/next-number.md`](routes/entity/next-number.md) | `GET /entity/next-number` |
@@ -187,8 +188,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` |
| [`routes/order/get.md`](routes/order/get.md) | `GET /order/{id}` |
| [`routes/order/update.md`](routes/order/update.md) | `PATCH /order/{id}` |
| [`routes/order/_id/file/_fileId.md`](routes/order/_id/file/_fileId.md) | `GET /order/{id}/file/{fileId}` |
| [`routes/order/_id/payment.get.md`](routes/order/_id/payment.get.md) | `GET /order/{id}/payment` |
| [`routes/order/_id/payment.post.md`](routes/order/_id/payment.post.md) | `POST /order/{id}/payment` |
| [`routes/order/_id/pdf.md`](routes/order/_id/pdf.md) | `POST /order/{id}/pdf` |
| [`routes/order/_id/shipment.get.md`](routes/order/_id/shipment.get.md) | `GET /order/{id}/shipment` |
| [`routes/order/_id/shipment.post.md`](routes/order/_id/shipment.post.md) | `POST /order/{id}/shipment` |
| [`routes/order/_id/shipping.get.md`](routes/order/_id/shipping.get.md) | `GET /order/{id}/shipping` |
@@ -224,10 +227,14 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` |
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
| [`routes/quote/_id/file/_fileId.md`](routes/quote/_id/file/_fileId.md) | `GET /quote/{id}/file/{fileId}` |
| [`routes/quote/_id/pdf.md`](routes/quote/_id/pdf.md) | `POST /quote/{id}/pdf` |
| [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` |
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
| [`routes/shipment/_id.delete.md`](routes/shipment/_id.delete.md) | `DELETE /shipment/{id}` |
| [`routes/shipment/_id.get.md`](routes/shipment/_id.get.md) | `GET /shipment/{id}` |
| [`routes/shipment/_id/file/_fileId.md`](routes/shipment/_id/file/_fileId.md) | `GET /shipment/{id}/file/{fileId}` |
| [`routes/shipment/_id/pdf.md`](routes/shipment/_id/pdf.md) | `POST /shipment/{id}/pdf` |
| [`routes/shipment/_id/shipping.get.md`](routes/shipment/_id/shipping.get.md) | `GET /shipment/{id}/shipping` |
| [`routes/shipment/_id/shipping.post.md`](routes/shipment/_id/shipping.post.md) | `POST /shipment/{id}/shipping` |
| [`routes/shipping/_id.delete.md`](routes/shipping/_id.delete.md) | `DELETE /shipping/{id}` |
@@ -281,12 +288,16 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `delivery` | `POST /delivery/{id}/pdf` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `POST /stock/adjust` |
| `modelconversation` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `POST /modelconversation/{id}/message` |
| `order` | `POST /order/{id}/pdf` |
| `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` |
| `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` |
| `quote` | `POST /quote/{id}/pdf` |
| `shipment` | `POST /shipment/{id}/pdf` |
| `stock` | `POST /stock/adjust` |
| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` |
| `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 235 API endpoints across all modules.
Overview of all 242 API endpoints across all modules.
### Notation
@@ -10,60 +10,61 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str, shipping?: T36|null, shippings?: T36[], payment?: T42|null, payments?: T42[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], createdAt?: str, shipping?: T37|null, shippings?: T37[], payment?: T43|null, payments?: T43[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T5[], files?: T15[] }`
- `T3` = `{ article: T4 }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T39, brand: T37|null, categoryIds: int[], photos: T34[], eans: str[], prices: T46[], suppliers: T16[] }`
- `T5` = `{ delivery: T6 }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T28[], files: T14[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }`
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T28[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T8` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T9|null }`
- `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T17, totalSizeBytes: int, durationMs: int }`
- `T10` = `{ quote: T11 }`
- `T11` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null }`
- `T12` = `{ labeltemplate: T13 }`
- `T13` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }`
- `T14` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T15` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T16` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }`
- `T17` = `{ vfs: T51, storage: T51, database: T44 }`
- `T18` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T19` = `{ ledger: T20[] }`
- `T20` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T21` = `{ id: int, name: str, sort: int, exclusive: bool, values: T40[], articleCount?: int }`
- `T22` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
- `T23` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T24` = `{ objects: T29[] }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T40, brand: T38|null, categoryIds: int[], photos: T35[], eans: str[], prices: T47[], suppliers: T17[] }`
- `T5` = `{ id: int, number: str, type: str, date: str, lines: T29[], shippings: T37[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T15[] }`
- `T6` = `{ delivery: T7 }`
- `T7` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T29[], files: T15[], externalNumber?: str|null, createdAt?: str, ledger?: T51 }`
- `T8` = `{ quote: T9 }`
- `T9` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T15[] }`
- `T10` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T11|null }`
- `T11` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T18, totalSizeBytes: int, durationMs: int }`
- `T12` = `{ labeltemplate: T14 }`
- `T13` = `{ attachment: T15, fileUrl: str }`
- `T14` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T25, thumb?: str|null }`
- `T15` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T16` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T17` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T54|null }`
- `T18` = `{ vfs: T52, storage: T52, database: T45 }`
- `T19` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T20` = `{ ledger: T21[] }`
- `T21` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T22` = `{ id: int, name: str, sort: int, exclusive: bool, values: T41[], articleCount?: int }`
- `T23` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
- `T24` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T25` = `{ objects: T30[] }`
- `T26` = `{ printer: T32 }`
- `T27` = `{ entity: T35 }`
- `T28` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T29` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T26` = `{ objects: T31[] }`
- `T27` = `{ printer: T33 }`
- `T28` = `{ entity: T36 }`
- `T29` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T31` = `{ shipping: T36 }`
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T38` = `{ article: T43 }`
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T52` = `{ warehouse: T54 }`
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T54` = `{ id: int, name: str, sort: int }`
- `T55` = `{ id: int, categoryIds: int[] }`
- `T31` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T32` = `{ shipping: T37 }`
- `T33` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T34` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T35` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T36` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T37` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T38` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T39` = `{ article: T44 }`
- `T40` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T41` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T42` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T43` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T44` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T45` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T46` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T47` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T48` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T49` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T50` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T51` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T52` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T53` = `{ warehouse: T55 }`
- `T54` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T55` = `{ id: int, name: str, sort: int }`
- `T56` = `{ id: int, categoryIds: int[] }`
### Routes
@@ -93,7 +94,7 @@ _none_
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T38`
- Returns: `201 T39`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -129,10 +130,10 @@ _none_
- 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 T38`
- Returns: `201 T39`
#### 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: T43, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T44, 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 }`
@@ -159,7 +160,7 @@ _none_
- 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 T38`
- Returns: `200 T39`
#### 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 T3`
@@ -177,10 +178,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T16[] }`
- Returns: `200 { suppliers: T17[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T16 }`
- Returns: `201 { supplier: T17 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -189,7 +190,7 @@ _none_
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T16 }`
- Returns: `200 { supplier: T17 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T3`
@@ -201,19 +202,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T21[] }`
- Returns: `200 { attributenames: T22[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T21 }`
- Returns: `201 { attributename: T22 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T40[] }`
- Returns: `200 { values: T41[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T40 }`
- Returns: `201 { attributevalue: T41 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -222,7 +223,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T21 }`
- Returns: `200 { attributename: T22 }`
### Section: /attributevalue
@@ -231,34 +232,34 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T40 }`
- Returns: `200 { attributevalue: T41 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T48[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T48[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T45 }`
- Returns: `200 { message: str, user: T46 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T45, googleClientId: str }`
- Returns: `200 { user: T46, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T45 }`
- Returns: `200 { user: T46 }`
### Section: /backup
#### List backups and service status — `GET /backup`
_none_
- Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T8|null, latestDaily?: T8|null, latestMonthly?: T8|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T8[], daily: T8[], monthly: T8[] } }`
- Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T10|null, latestDaily?: T10|null, latestMonthly?: T10|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T10[], daily: T10[], monthly: T10[] } }`
#### List VFS files in a backup — `GET /backup/{id}/vfs`
- Params: `id: str, query: companyId?: int`
- Returns: `200 { backupId: str, companyId: int, files: { name: str, size: int, mime: str, mtime: str }[] }`
@@ -273,7 +274,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, 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: `201 { brand: { id: int, name: str, pics: T47[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T48[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -282,10 +283,10 @@ _none_
- 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: T47[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T48[] } }`
#### 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: T47[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T48[] } }`
### Section: /category
@@ -309,7 +310,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T37|null }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T38|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -336,10 +337,10 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T23[] }`
- Returns: `200 { companies: T24[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T23, user: T45 }`
- Returns: `201 { company: T24, user: T46 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str`
- Returns: `200 SSE`
@@ -369,7 +370,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: T23 }`
- Returns: `200 { company: T24 }`
### Section: /customergroup
@@ -378,7 +379,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T54 }`
- Returns: `201 { customergroup: T55 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -387,16 +388,16 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T54 }`
- Returns: `200 { customergroup: T55 }`
### Section: /delivery
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T50 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T51 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T33[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T5`
- Params: `body: { externalNumber: str, date: str, lines: T34[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T6`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
@@ -414,58 +415,61 @@ _none_
- Returns: `204 void`
#### Get delivery — `GET /delivery/{id}`
- Params: `id: int`
- Returns: `200 T5`
- Returns: `200 T6`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T33[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T5`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T34[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T6`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
### Section: /entity
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T35[] }`
- Returns: `200 { entities: T36[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T27`
- Returns: `201 T28`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T18[] }`
- Returns: `200 { addresses: T19[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T18 }`
- Returns: `201 { address: T19 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T18 }`
- Returns: `200 { address: T19 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T48[] }`
- Returns: `200 { contacts: T49[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T48 }`
- Returns: `201 { contact: T49 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T48 }`
- Returns: `200 { contact: T49 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T27`
- Returns: `200 T28`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T27`
- Returns: `200 T28`
### Section: /events
@@ -509,7 +513,7 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T25 }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T26 }`
- Returns: `201 T12`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
@@ -518,26 +522,26 @@ _none_
- Params: `id: int`
- Returns: `200 T12`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
- Returns: `200 T12`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
- Returns: `200 T12`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T19`
- Returns: `200 T20`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T20 }`
- Returns: `201 { ledger: T21 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T19`
- Returns: `200 T20`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T19`
- Returns: `200 T20`
### Section: /mcp
@@ -567,16 +571,16 @@ _none_
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T22[] }`
- Returns: `200 { conversations: T23[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T22 }`
- Returns: `201 { conversation: T23 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T22 }`
- Returns: `200 { conversation: T23 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -617,7 +621,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Params: `body: { number: str, date: str, lines: T34[], entityId?: int|null }`
- Returns: `201 T1`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -626,26 +630,32 @@ _none_
- Params: `id: int`
- Returns: `200 T1`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T34[] }`
- Returns: `200 T1`
#### Get order attachment file content — `GET /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T42[] }`
- Returns: `200 { payments: T43[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T42 }`
- Returns: `201 { payment: T43 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
- Returns: `200 { shipments: T5[] }`
#### Create shipment for an order — `POST /order/{id}/shipment`
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
- Returns: `201 { shipment: T7 }`
- Returns: `201 { shipment: T5 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T36[] }`
- Returns: `200 { shippings: T37[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 T31`
- Returns: `201 T32`
### Section: /package
@@ -663,37 +673,37 @@ _none_
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: T15[] }`
- Returns: `200 { posterminals: T16[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
- Returns: `200 { posterminal: T15 }`
- Returns: `200 { posterminal: T16 }`
#### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update POS terminal — `PATCH /posterminal/{id}`
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
- Returns: `200 { posterminal: T15 }`
- Returns: `200 { posterminal: T16 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T55`
- Returns: `200 T56`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T55`
- Returns: `200 T56`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T55`
- Returns: `200 T56`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T55`
- Returns: `200 T56`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T32[] }`
- Returns: `200 { printers: T33[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T26`
- Returns: `201 T27`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -702,22 +712,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T26`
- Returns: `200 T27`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T26`
- Returns: `200 T27`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T26`
- Returns: `200 T27`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T41[] }`
- Returns: `200 { propertynames: T42[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T41 }`
- Returns: `201 { propertyname: T42 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -726,7 +736,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T41 }`
- Returns: `200 { propertyname: T42 }`
### Section: /public
@@ -747,19 +757,25 @@ _none_
#### List quotes — `GET /quote`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T11[], total: int, unfilteredTotal: int, offset: int, limit: int }`
- Returns: `200 { quotes: T9[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Returns: `201 T10`
- Params: `body: { number: str, date: str, lines: T34[], entityId?: int|null }`
- Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T10`
- Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Returns: `200 T10`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T34[] }`
- Returns: `200 T8`
#### Get quote attachment file content — `GET /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
### Section: /serper
@@ -783,13 +799,19 @@ _none_
- Returns: `204 void`
#### Get shipment by ID — `GET /shipment/{id}`
- Params: `id: int`
- Returns: `200 { shipment: T7 }`
- Returns: `200 { shipment: T5 }`
#### Get shipment attachment file content — `GET /shipment/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T36[] }`
- Returns: `200 { shippings: T37[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T31`
- Returns: `201 T32`
### Section: /shipping
@@ -798,7 +820,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T31`
- Returns: `200 T32`
#### 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 }[] }`
@@ -816,28 +838,28 @@ _none_
#### 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: T20[] }`
- 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: T21[] }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T49[] }`
- Returns: `200 { suppliers: T50[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T49 }`
- Returns: `201 { supplier: T50 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T49 }`
- Returns: `200 { supplier: T50 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T54|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T54|null }[] }`
### Section: /thumb
@@ -852,10 +874,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T39[] }`
- Returns: `200 { units: T40[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T39 }`
- Returns: `200 { unit: T40 }`
### Section: /v1
@@ -909,10 +931,10 @@ _none_
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T54[] }`
- Returns: `200 { warehouses: T55[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T52`
- Returns: `201 T53`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -921,10 +943,10 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T52`
- Returns: `200 T53`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T52`
- Returns: `200 T53`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T52`
- Returns: `200 T53`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 164 API endpoints across all modules.
Overview of all 167 API endpoints across all modules.
### Notation
@@ -10,50 +10,51 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str, shipping?: T30|null, shippings?: T30[], payment?: T36|null, payments?: T36[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T23[], createdAt?: str, shipping?: T31|null, shippings?: T31[], payment?: T37|null, payments?: T37[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T5[], files?: T13[] }`
- `T3` = `{ article: T4 }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T33, brand: T31|null, categoryIds: int[], photos: T28[], eans: str[], prices: T38[], suppliers: T13[] }`
- `T5` = `{ delivery: T6 }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T22[], files: T12[], externalNumber?: str|null, createdAt?: str, ledger?: T42 }`
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T22[], shippings: T30[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T34, brand: T32|null, categoryIds: int[], photos: T29[], eans: str[], prices: T39[], suppliers: T14[] }`
- `T5` = `{ id: int, number: str, type: str, date: str, lines: T23[], shippings: T31[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T13[] }`
- `T6` = `{ delivery: T7 }`
- `T7` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T23[], files: T13[], externalNumber?: str|null, createdAt?: str, ledger?: T43 }`
- `T8` = `{ quote: T9 }`
- `T9` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null }`
- `T10` = `{ labeltemplate: T11 }`
- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T18, thumb?: str|null }`
- `T12` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T13` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }`
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T15` = `{ ledger: T16[] }`
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T34[], articleCount?: int }`
- `T18` = `{ objects: T23[] }`
- `T9` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T23[], createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T13[] }`
- `T10` = `{ labeltemplate: T12 }`
- `T11` = `{ attachment: T13, fileUrl: str }`
- `T12` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T19, thumb?: str|null }`
- `T13` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T14` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T45|null }`
- `T15` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T16` = `{ ledger: T17[] }`
- `T17` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T18` = `{ id: int, name: str, sort: int, exclusive: bool, values: T35[], articleCount?: int }`
- `T19` = `{ objects: T24[] }`
- `T20` = `{ printer: T26 }`
- `T21` = `{ entity: T29 }`
- `T22` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T20` = `{ objects: T25[] }`
- `T21` = `{ printer: T27 }`
- `T22` = `{ entity: T30 }`
- `T23` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T24` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T25` = `{ shipping: T30 }`
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T27` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T28` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T29` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T30` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T31` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T32` = `{ article: T37 }`
- `T33` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T34` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T35` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T36` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T37` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T38` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T39` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T40` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T41` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T42` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T43` = `{ warehouse: T45 }`
- `T44` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T45` = `{ id: int, name: str, sort: int }`
- `T25` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T26` = `{ shipping: T31 }`
- `T27` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T28` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T29` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T30` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T31` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T32` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T33` = `{ article: T38 }`
- `T34` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T35` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T36` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T37` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T38` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T39` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T40` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T41` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T42` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T43` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T44` = `{ warehouse: T46 }`
- `T45` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T46` = `{ id: int, name: str, sort: int }`
### Routes
@@ -62,7 +63,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T32`
- Returns: `201 T33`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
- Returns: `204 void`
@@ -95,10 +96,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 T32`
- Returns: `201 T33`
#### 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: T37, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T38, 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 }`
@@ -122,7 +123,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 T32`
- Returns: `200 T33`
#### 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 T3`
@@ -140,10 +141,10 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T13[] }`
- Returns: `200 { suppliers: T14[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T13 }`
- Returns: `201 { supplier: T14 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -152,7 +153,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T13 }`
- Returns: `200 { supplier: T14 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T3`
@@ -164,19 +165,19 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T17[] }`
- Returns: `200 { attributenames: T18[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T17 }`
- Returns: `201 { attributename: T18 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T34[] }`
- Returns: `200 { values: T35[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T34 }`
- Returns: `201 { attributevalue: T35 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -185,7 +186,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T17 }`
- Returns: `200 { attributename: T18 }`
### Section: /attributevalue
@@ -194,13 +195,13 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T34 }`
- Returns: `200 { attributevalue: T35 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T39[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T40[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T39[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T40[] } }`
### Section: /brand
@@ -209,7 +210,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, 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: `201 { brand: { id: int, name: str, pics: T39[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T40[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -218,10 +219,10 @@ _none_
- 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: T39[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T40[] } }`
#### 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: T39[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T40[] } }`
### Section: /category
@@ -242,7 +243,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T31|null }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T32|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -296,7 +297,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T45 }`
- Returns: `201 { customergroup: T46 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -305,73 +306,73 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T45 }`
- Returns: `200 { customergroup: T46 }`
### Section: /delivery
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T42 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T43 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T27[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T5`
- Params: `body: { externalNumber: str, date: str, lines: T28[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T6`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get delivery — `GET /delivery/{id}`
- Params: `id: int`
- Returns: `200 T5`
- Returns: `200 T6`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T27[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T5`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T28[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T6`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int`
- Returns: `201 T11`
### Section: /entity
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T29[] }`
- Returns: `200 { entities: T30[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T21`
- Returns: `201 T22`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T14[] }`
- Returns: `200 { addresses: T15[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T14 }`
- Returns: `201 { address: T15 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T14 }`
- Returns: `200 { address: T15 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T40[] }`
- Returns: `200 { contacts: T41[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T40 }`
- Returns: `201 { contact: T41 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T40 }`
- Returns: `200 { contact: T41 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T21`
- Returns: `200 T22`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T21`
- Returns: `200 T22`
### Section: /labeltemplate
@@ -379,7 +380,7 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T19 }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T20 }`
- Returns: `201 T10`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
@@ -388,26 +389,26 @@ _none_
- Params: `id: int`
- Returns: `200 T10`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T20 }`
- Returns: `200 T10`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T20 }`
- Returns: `200 T10`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T15`
- Returns: `200 T16`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T16 }`
- Returns: `201 { ledger: T17 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T15`
- Returns: `200 T16`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T15`
- Returns: `200 T16`
### Section: /number
@@ -424,7 +425,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
- Params: `body: { number: str, date: str, lines: T28[], entityId?: int|null }`
- Returns: `201 T1`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -433,26 +434,29 @@ _none_
- Params: `id: int`
- Returns: `200 T1`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T27[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T28[] }`
- Returns: `200 T1`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T36[] }`
- Returns: `200 { payments: T37[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T36 }`
- Returns: `201 { payment: T37 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 T11`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
- Returns: `200 { shipments: T5[] }`
#### Create shipment for an order — `POST /order/{id}/shipment`
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
- Returns: `201 { shipment: T7 }`
- Returns: `201 { shipment: T5 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T30[] }`
- Returns: `200 { shippings: T31[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 T25`
- Returns: `201 T26`
### Section: /package
@@ -470,10 +474,10 @@ _none_
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T26[] }`
- Returns: `200 { printers: T27[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T20`
- Returns: `201 T21`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -482,22 +486,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T20`
- Returns: `200 T21`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T20`
- Returns: `200 T21`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T20`
- Returns: `200 T21`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T35[] }`
- Returns: `200 { propertynames: T36[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T35 }`
- Returns: `201 { propertyname: T36 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -506,7 +510,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T35 }`
- Returns: `200 { propertyname: T36 }`
### Section: /quote
@@ -514,7 +518,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T9[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
- Params: `body: { number: str, date: str, lines: T28[], entityId?: int|null }`
- Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
@@ -523,8 +527,11 @@ _none_
- Params: `id: int`
- Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T27[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T28[] }`
- Returns: `200 T8`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
- Returns: `201 T11`
### Section: /shipment
@@ -533,13 +540,16 @@ _none_
- Returns: `204 void`
#### Get shipment by ID — `GET /shipment/{id}`
- Params: `id: int`
- Returns: `200 { shipment: T7 }`
- Returns: `200 { shipment: T5 }`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int`
- Returns: `201 T11`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T30[] }`
- Returns: `200 { shippings: T31[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T25`
- Returns: `201 T26`
### Section: /shipping
@@ -548,7 +558,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T25`
- Returns: `200 T26`
#### 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 }[] }`
@@ -566,28 +576,28 @@ _none_
#### 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: T16[] }`
- 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: T17[] }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T41[] }`
- Returns: `200 { suppliers: T42[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T41 }`
- Returns: `201 { supplier: T42 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T41 }`
- Returns: `200 { supplier: T42 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T45|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T45|null }[] }`
### Section: /unit
@@ -596,19 +606,19 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T33[] }`
- Returns: `200 { units: T34[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T33 }`
- Returns: `200 { unit: T34 }`
### Section: /warehouse
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T45[] }`
- Returns: `200 { warehouses: T46[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T43`
- Returns: `201 T44`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -617,13 +627,13 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T43`
- Returns: `200 T44`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T43`
- Returns: `200 T44`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T43`
- Returns: `200 T44`
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:

View File

@@ -0,0 +1,58 @@
# `POST /delivery/{id}/pdf`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Generate and attach delivery note PDF (goods receipt)
Generates an A4 PDF document for the goods receipt / delivery note with line items and quantities.
Attaches the PDF as a `manifestattachment` to the delivery.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Delivery ID |
## Signals
On success, emits SSE signal: `delivery` (see [`GET /events`](../events.md)).
## Response `201`
PDF generated and attached to delivery
```json
{
"attachment": {
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
},
"fileUrl": "/delivery/1/file/5"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Delivery not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,38 @@
# `GET /order/{id}/file/{fileId}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get order attachment file content
Returns the full binary content of an attached file (e.g. order PDF)
for the specified order. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Order ID |
| `fileId` | yes | integer | File ID or attachment ID |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | When true, sets Content-Disposition to attachment instead of inline |
## Response `200`
Binary file content
## Errors
| Status | Description |
|--------|-------------|
| 304 | Not modified |
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 404 | Order or file not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,58 @@
# `POST /order/{id}/pdf`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Generate and attach order PDF
Generates an A4 PDF document for the sales order with line items, prices, and tax breakdown.
Attaches the PDF as a `manifestattachment` to the order so it can be printed or archived.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Order ID |
## Signals
On success, emits SSE signal: `order` (see [`GET /events`](../events.md)).
## Response `201`
PDF generated and attached to order
```json
{
"attachment": {
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
},
"fileUrl": "/order/1/file/5"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Order not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -52,6 +52,24 @@ List of shipments
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
]

View File

@@ -89,6 +89,24 @@ Created shipment
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -140,8 +140,44 @@ Created order
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -115,8 +115,44 @@ Order
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -124,8 +124,44 @@ Order list
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
],

View File

@@ -134,8 +134,44 @@ Updated order
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -0,0 +1,38 @@
# `GET /quote/{id}/file/{fileId}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get quote attachment file content
Returns the full binary content of an attached file (e.g. quote PDF)
for the specified quote. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Quote ID |
| `fileId` | yes | integer | File ID or attachment ID |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | When true, sets Content-Disposition to attachment instead of inline |
## Response `200`
Binary file content
## Errors
| Status | Description |
|--------|-------------|
| 304 | Not modified |
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 404 | Quote or file not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,58 @@
# `POST /quote/{id}/pdf`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Generate and attach quote PDF
Generates an A4 PDF document for the quote with prices, net sum, and tax breakdown.
Stores the PDF as a `manifestattachment` for printing or downloading.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Quote ID |
## Signals
On success, emits SSE signal: `quote` (see [`GET /events`](../events.md)).
## Response `201`
PDF generated and attached to quote
```json
{
"attachment": {
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
},
"fileUrl": "/quote/1/file/5"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Quote not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -66,6 +66,24 @@ Created quote
"taxrate": 1,
"comment": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -41,6 +41,24 @@ Quote
"taxrate": 1,
"comment": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -47,6 +47,24 @@ Quote list
"taxrate": 1,
"comment": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
],

View File

@@ -60,6 +60,24 @@ Updated quote
"taxrate": 1,
"comment": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -51,6 +51,24 @@ Shipment details
"type": "…",
"state": "…"
}
],
"files": [
{
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
}
]
}
}

View File

@@ -0,0 +1,38 @@
# `GET /shipment/{id}/file/{fileId}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get shipment attachment file content
Returns the full binary content of an attached file (e.g. shipment delivery note PDF)
for the specified shipment. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Shipment ID |
| `fileId` | yes | integer | File ID or attachment ID |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | When true, sets Content-Disposition to attachment instead of inline |
## Response `200`
Binary file content
## Errors
| Status | Description |
|--------|-------------|
| 304 | Not modified |
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 404 | Shipment or file not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,59 @@
# `POST /shipment/{id}/pdf`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Generate and attach shipment PDF (delivery note)
Generates an A4 delivery note PDF document for the shipment with line items and package tracking.
Shipping slips contain no price information.
Attaches the PDF as a `manifestattachment` to the shipment so it can be printed or attached to packages.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Shipment ID |
## Signals
On success, emits SSE signal: `shipment` (see [`GET /events`](../events.md)).
## Response `201`
PDF generated and attached to shipment
```json
{
"attachment": {
"id": 1,
"manifestId": 1,
"fileId": 1,
"sort": 1,
"hash": "…",
"mimetype": "…",
"name": "…",
"size": 1,
"created": "2026-08-10T00:00:00.000Z",
"modified": "2026-08-10T00:00:00.000Z",
"thumbavif": "…",
"thumbx": 1,
"thumby": 1,
"meta": {}
},
"fileUrl": "/shipment/1/file/5"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Shipment not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -28,12 +28,16 @@ const OMITTED_ROUTES = new Set([
'GET /company/export',
'POST /company/export',
'POST /category/{id}/image',
'GET /delivery/{id}/file/{fileId}',
'POST /delivery/parse',
'GET /events',
'GET /health',
'GET /image/{fullHash}',
'GET /order/{id}/file/{fileId}',
'GET /quote/{id}/file/{fileId}',
'POST /serper/image',
'POST /serper/images',
'GET /shipment/{id}/file/{fileId}',
'GET /thumb/{fullHash}',
'GET /v1/category',
'GET /v1/client',

View File

@@ -987,6 +987,11 @@
* type: array
* items:
* $ref: '#/components/schemas/ManifestLine'
* files:
* type: array
* description: Attached documents (e.g. quote PDF) for the quote
* items:
* $ref: '#/components/schemas/DeliveryFile'
* OrderSummary:
* type: object
* required: [id, number, type, entityId, date]
@@ -1293,6 +1298,11 @@
* type: array
* items:
* $ref: '#/components/schemas/Shipping'
* files:
* type: array
* description: Attached documents (e.g. shipment label PDF) for the shipment
* items:
* $ref: '#/components/schemas/DeliveryFile'
* Package:
* type: object
* required: [id, shippingId, entityaddressId, number, type, state]

View File

@@ -1,5 +1,6 @@
import multer from 'multer';
import * as db from '../../db/delivery.js';
import { createAndAttachManifestPdf } from '../../services/manifestPdf.js';
import { writeErrorLog } from '../../util/errorLog.js';
// #region Helpers
@@ -902,4 +903,97 @@ export function register(app, basePath = '') {
app.get(`${basePath}/delivery/:id/files/:fileId`, handleGetDeliveryFile);
// #endregion
/**
* @openapi
* /delivery/{id}/pdf:
* post:
* tags: [Delivery]
* summary: Generate and attach delivery note PDF (goods receipt)
* description: |
* Generates an A4 PDF document for the goods receipt / delivery note with line items and quantities.
* Attaches the PDF as a `manifestattachment` to the delivery.
* security:
* - cookieAuth: []
* x-signals: [delivery]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Delivery ID
* responses:
* 201:
* description: PDF generated and attached to delivery
* content:
* application/json:
* schema:
* type: object
* required: [attachment, fileUrl]
* properties:
* attachment:
* $ref: '#/components/schemas/DeliveryFile'
* fileUrl:
* type: string
* example: /delivery/1/file/5
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Delivery not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /delivery/:id/pdf
app.post(`${basePath}/delivery/:id/pdf`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
const { attachment } = await createAndAttachManifestPdf(
req.user.id,
selected.companyId,
id,
'delivery'
);
res.status(201).json({
attachment,
fileUrl: `/delivery/${id}/file/${attachment.fileId}`,
});
} catch (error) {
const status = deliveryErrorStatus(error);
if (status === 500) console.error('Create delivery PDF failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -1,4 +1,5 @@
import * as db from '../../db/order.js';
import { createAndAttachManifestPdf } from '../../services/manifestPdf.js';
// #region Helpers
@@ -15,6 +16,8 @@ function orderErrorStatus(error) {
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Order not found') return 404;
if (error.message === 'Manifest not found') return 404;
if (error.message === 'Attachment not found') return 404;
if (error.message === 'Entity not found') return 404;
if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid entityId') return 400;
@@ -23,6 +26,9 @@ function orderErrorStatus(error) {
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Invalid seekMonths') return 400;
if (error.message === 'File name required') return 400;
if (error.message === 'Mimetype required') return 400;
if (error.message === 'Content required') return 400;
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
return 500;
}
@@ -567,4 +573,210 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /order/{id}/pdf:
* post:
* tags: [Order]
* summary: Generate and attach order PDF
* description: |
* Generates an A4 PDF document for the sales order with line items, prices, and tax breakdown.
* Attaches the PDF as a `manifestattachment` to the order so it can be printed or archived.
* security:
* - cookieAuth: []
* x-signals: [order]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Order ID
* responses:
* 201:
* description: PDF generated and attached to order
* content:
* application/json:
* schema:
* type: object
* required: [attachment, fileUrl]
* properties:
* attachment:
* $ref: '#/components/schemas/DeliveryFile'
* fileUrl:
* type: string
* example: /order/1/file/5
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Order not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /order/:id/pdf
app.post(`${basePath}/order/:id/pdf`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
const { attachment } = await createAndAttachManifestPdf(
req.user.id,
selected.companyId,
id,
'order'
);
res.status(201).json({
attachment,
fileUrl: `/order/${id}/file/${attachment.fileId}`,
});
} catch (error) {
const status = orderErrorStatus(error);
if (status === 500) console.error('Create order PDF failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /order/{id}/file/{fileId}:
* get:
* tags: [Order]
* summary: Get order attachment file content
* description: |
* Returns the full binary content of an attached file (e.g. order PDF)
* for the specified order. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Order ID
* - in: path
* name: fileId
* required: true
* schema:
* type: integer
* description: File ID or attachment ID
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: When true, sets Content-Disposition to attachment instead of inline
* responses:
* 200:
* description: Binary file content
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* application/octet-stream:
* schema:
* type: string
* format: binary
* 304:
* description: Not modified
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Order or file not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /order/:id/file/:fileId
app.get(`${basePath}/order/:id/file/:fileId`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const fileId = Number(req.params.fileId);
if (!Number.isInteger(fileId) || fileId <= 0) {
return res.status(400).json({ error: 'fileId is required' });
}
try {
const attachment = await db.getFile(req.user.id, id, fileId);
if (attachment.hash) {
const etag = `"${attachment.hash}"`;
res.setHeader('ETag', etag);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) {
return res.status(304).end();
}
}
const isDownload = req.query.download === '1' || req.query.download === 'true';
const dispositionType = isDownload ? 'attachment' : 'inline';
const filename = attachment.name || `file-${attachment.fileId}`;
const asciiName = filename.replace(/["\r\n\\]/g, '_');
const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape);
res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream');
res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
res.setHeader('Cache-Control', 'private, max-age=86400');
if (attachment.content && Buffer.isBuffer(attachment.content)) {
res.setHeader('Content-Length', String(attachment.content.length));
return res.status(200).send(attachment.content);
} else {
const buffer = Buffer.from(attachment.content || '');
res.setHeader('Content-Length', String(buffer.length));
return res.status(200).send(buffer);
}
} catch (error) {
const status = orderErrorStatus(error);
if (status === 500) console.error('Get order file failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -1,4 +1,5 @@
import * as db from '../../db/quote.js';
import { createAndAttachManifestPdf } from '../../services/manifestPdf.js';
// #region Helpers
@@ -15,11 +16,16 @@ function quoteErrorStatus(error) {
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Quote not found') return 404;
if (error.message === 'Manifest not found') return 404;
if (error.message === 'Attachment not found') return 404;
if (error.message === 'Entity not found') return 404;
if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid quote') return 400;
if (error.message === 'Invalid lines') return 400;
if (error.message === 'File name required') return 400;
if (error.message === 'Mimetype required') return 400;
if (error.message === 'Content required') return 400;
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
return 500;
}
@@ -546,4 +552,210 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /quote/{id}/pdf:
* post:
* tags: [Quote]
* summary: Generate and attach quote PDF
* description: |
* Generates an A4 PDF document for the quote with prices, net sum, and tax breakdown.
* Stores the PDF as a `manifestattachment` for printing or downloading.
* security:
* - cookieAuth: []
* x-signals: [quote]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Quote ID
* responses:
* 201:
* description: PDF generated and attached to quote
* content:
* application/json:
* schema:
* type: object
* required: [attachment, fileUrl]
* properties:
* attachment:
* $ref: '#/components/schemas/DeliveryFile'
* fileUrl:
* type: string
* example: /quote/1/file/5
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Quote not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /quote/:id/pdf
app.post(`${basePath}/quote/:id/pdf`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
const { attachment } = await createAndAttachManifestPdf(
req.user.id,
selected.companyId,
id,
'quote'
);
res.status(201).json({
attachment,
fileUrl: `/quote/${id}/file/${attachment.fileId}`,
});
} catch (error) {
const status = quoteErrorStatus(error);
if (status === 500) console.error('Create quote PDF failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /quote/{id}/file/{fileId}:
* get:
* tags: [Quote]
* summary: Get quote attachment file content
* description: |
* Returns the full binary content of an attached file (e.g. quote PDF)
* for the specified quote. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Quote ID
* - in: path
* name: fileId
* required: true
* schema:
* type: integer
* description: File ID or attachment ID
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: When true, sets Content-Disposition to attachment instead of inline
* responses:
* 200:
* description: Binary file content
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* application/octet-stream:
* schema:
* type: string
* format: binary
* 304:
* description: Not modified
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Quote or file not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /quote/:id/file/:fileId
app.get(`${basePath}/quote/:id/file/:fileId`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const fileId = Number(req.params.fileId);
if (!Number.isInteger(fileId) || fileId <= 0) {
return res.status(400).json({ error: 'fileId is required' });
}
try {
const attachment = await db.getFile(req.user.id, id, fileId);
if (attachment.hash) {
const etag = `"${attachment.hash}"`;
res.setHeader('ETag', etag);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) {
return res.status(304).end();
}
}
const isDownload = req.query.download === '1' || req.query.download === 'true';
const dispositionType = isDownload ? 'attachment' : 'inline';
const filename = attachment.name || `file-${attachment.fileId}`;
const asciiName = filename.replace(/["\r\n\\]/g, '_');
const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape);
res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream');
res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
res.setHeader('Cache-Control', 'private, max-age=86400');
if (attachment.content && Buffer.isBuffer(attachment.content)) {
res.setHeader('Content-Length', String(attachment.content.length));
return res.status(200).send(attachment.content);
} else {
const buffer = Buffer.from(attachment.content || '');
res.setHeader('Content-Length', String(buffer.length));
return res.status(200).send(buffer);
}
} catch (error) {
const status = quoteErrorStatus(error);
if (status === 500) console.error('Get quote file failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -1,4 +1,5 @@
import * as db from '../../db/shipment.js';
import { createAndAttachManifestPdf } from '../../services/manifestPdf.js';
import { emit } from '../sse.js';
// #region Helpers
@@ -383,4 +384,212 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /shipment/{id}/pdf:
* post:
* tags: [Shipment]
* summary: Generate and attach shipment PDF (delivery note)
* description: |
* Generates an A4 delivery note PDF document for the shipment with line items and package tracking.
* Shipping slips contain no price information.
* Attaches the PDF as a `manifestattachment` to the shipment so it can be printed or attached to packages.
* security:
* - cookieAuth: []
* x-signals: [shipment]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Shipment ID
* responses:
* 201:
* description: PDF generated and attached to shipment
* content:
* application/json:
* schema:
* type: object
* required: [attachment, fileUrl]
* properties:
* attachment:
* $ref: '#/components/schemas/DeliveryFile'
* fileUrl:
* type: string
* example: /shipment/1/file/5
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Shipment not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /shipment/:id/pdf
app.post(`${basePath}/shipment/:id/pdf`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const { attachment } = await createAndAttachManifestPdf(
req.user.id,
selected.companyId,
parsedId.id,
'shipment'
);
res.status(201).json({
attachment,
fileUrl: `/shipment/${parsedId.id}/file/${attachment.fileId}`,
});
} catch (error) {
const status = shipmentErrorStatus(error);
if (status === 500) console.error('Create shipment PDF failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /shipment/{id}/file/{fileId}:
* get:
* tags: [Shipment]
* summary: Get shipment attachment file content
* description: |
* Returns the full binary content of an attached file (e.g. shipment delivery note PDF)
* for the specified shipment. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Shipment ID
* - in: path
* name: fileId
* required: true
* schema:
* type: integer
* description: File ID or attachment ID
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: When true, sets Content-Disposition to attachment instead of inline
* responses:
* 200:
* description: Binary file content
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* application/octet-stream:
* schema:
* type: string
* format: binary
* 304:
* description: Not modified
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Shipment or file not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /shipment/:id/file/:fileId
app.get(`${basePath}/shipment/:id/file/:fileId`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
const parsedFileId = parsePositiveInt(req.params.fileId, 'fileId');
if (parsedFileId.error) {
return res.status(400).json({ error: parsedFileId.error });
}
try {
const attachment = await db.getFile(req.user.id, parsedId.id, parsedFileId.id);
if (attachment.hash) {
const etag = `"${attachment.hash}"`;
res.setHeader('ETag', etag);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) {
return res.status(304).end();
}
}
const isDownload = req.query.download === '1' || req.query.download === 'true';
const dispositionType = isDownload ? 'attachment' : 'inline';
const filename = attachment.name || `file-${attachment.fileId}`;
const asciiName = filename.replace(/["\r\n\\]/g, '_');
const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape);
res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream');
res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
res.setHeader('Cache-Control', 'private, max-age=86400');
if (attachment.content && Buffer.isBuffer(attachment.content)) {
res.setHeader('Content-Length', String(attachment.content.length));
return res.status(200).send(attachment.content);
} else {
const buffer = Buffer.from(attachment.content || '');
res.setHeader('Content-Length', String(buffer.length));
return res.status(200).send(buffer);
}
} catch (error) {
const status = shipmentErrorStatus(error);
if (status === 500) console.error('Get shipment file failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -29,3 +29,14 @@ export async function getFile(googleAuthId, deliveryId, fileId) {
const store = await getStore();
return store.manifestattachment.getById(googleAuthId, deliveryId, fileId);
}
export async function addAttachment(googleAuthId, deliveryId, data) {
const store = await getStore();
return store.manifestattachment.add(googleAuthId, deliveryId, data);
}
export async function listAttachments(googleAuthId, deliveryId) {
const store = await getStore();
return store.manifestattachment.listByManifestId(googleAuthId, deliveryId);
}

View File

@@ -26,7 +26,7 @@ function calculateTotalQuantity(lines = []) {
return Math.round(qty * 1000) / 1000;
}
function enrichQuote(quote) {
function enrichQuote(quote, files = []) {
if (!quote) return quote;
const lines = Array.isArray(quote.lines) ? quote.lines : [];
const positions = quote.positions !== undefined ? quote.positions : lines.length;
@@ -38,6 +38,7 @@ function enrichQuote(quote) {
positions,
grossTotal,
totalQuantity,
files: Array.isArray(files) ? files : (quote.files || []),
};
}
@@ -45,12 +46,12 @@ export async function list(googleAuthId, opts) {
const store = await getStore();
const result = await store.quote.list(googleAuthId, opts);
if (Array.isArray(result)) {
return result.map(enrichQuote);
return result.map((q) => enrichQuote(q));
}
if (result && Array.isArray(result.quotes)) {
return {
...result,
quotes: result.quotes.map(enrichQuote),
quotes: result.quotes.map((q) => enrichQuote(q)),
};
}
return result;
@@ -58,8 +59,11 @@ export async function list(googleAuthId, opts) {
export async function getById(googleAuthId, id) {
const store = await getStore();
const quote = await store.quote.getById(googleAuthId, id);
return enrichQuote(quote);
const [quote, files] = await Promise.all([
store.quote.getById(googleAuthId, id),
store.manifestattachment.listByManifestId(googleAuthId, id).catch(() => []),
]);
return enrichQuote(quote, files);
}
export async function create(googleAuthId, data) {
@@ -83,3 +87,19 @@ export async function rebuildSummaries(googleAuthId) {
const store = await getStore();
return store.quote.rebuildSummaries(googleAuthId);
}
export async function getFile(googleAuthId, quoteId, fileId) {
const store = await getStore();
return store.manifestattachment.getById(googleAuthId, quoteId, fileId);
}
export async function addAttachment(googleAuthId, quoteId, data) {
const store = await getStore();
return store.manifestattachment.add(googleAuthId, quoteId, data);
}
export async function listAttachments(googleAuthId, quoteId) {
const store = await getStore();
return store.manifestattachment.listByManifestId(googleAuthId, quoteId);
}

View File

@@ -7,7 +7,15 @@ export async function create(googleAuthId, data) {
export async function getById(googleAuthId, id) {
const store = await getStore();
return store.shipment.getById(googleAuthId, id);
const [shipment, files] = await Promise.all([
store.shipment.getById(googleAuthId, id),
store.manifestattachment.listByManifestId(googleAuthId, id).catch(() => []),
]);
if (!shipment) return shipment;
return {
...shipment,
files: Array.isArray(files) ? files : [],
};
}
export async function listByOrderId(googleAuthId, orderId) {
@@ -19,3 +27,19 @@ export async function remove(googleAuthId, id) {
const store = await getStore();
return store.shipment.delete(googleAuthId, id);
}
export async function getFile(googleAuthId, shipmentId, fileId) {
const store = await getStore();
return store.manifestattachment.getById(googleAuthId, shipmentId, fileId);
}
export async function addAttachment(googleAuthId, shipmentId, data) {
const store = await getStore();
return store.manifestattachment.add(googleAuthId, shipmentId, data);
}
export async function listAttachments(googleAuthId, shipmentId) {
const store = await getStore();
return store.manifestattachment.listByManifestId(googleAuthId, shipmentId);
}

766
src/services/manifestPdf.js Normal file
View File

@@ -0,0 +1,766 @@
import PDFDocument from 'pdfkit';
import { getStore } from '../db/store.js';
import * as dbOrder from '../db/order.js';
import { emit } from '../api/sse.js';
/**
* Formats a Date or date string to German format DD.MM.YYYY.
* @param {Date|string} dateVal
* @returns {string}
*/
export function formatDate(dateVal) {
if (!dateVal) return '';
const d = new Date(dateVal);
if (Number.isNaN(d.getTime())) return String(dateVal);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
/**
* Formats a number as German currency string (e.g. 1.234,50 €).
* @param {number} val
* @returns {string}
*/
export function formatCurrency(val) {
const n = Number(val) || 0;
return (
n.toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}) + ' €'
);
}
/**
* Formats a number with up to 3 decimal places (e.g. 1 or 2,5).
* @param {number} val
* @returns {string}
*/
export function formatQuantity(val) {
const n = Number(val) || 0;
return n.toLocaleString('de-DE', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
}
/**
* Configuration per manifest type.
*/
const TYPE_CONFIG = {
quote: {
docTitle: 'ANGEBOT',
numberPrefix: 'Angebots-Nr.',
showPrices: true,
filenamePrefix: 'angebot',
introText: 'Wir freuen uns über Ihr Interesse und bieten Ihnen freibleibend wie folgt an:',
closingText: 'Dieses Angebot ist freibleibend und 30 Tage ab Ausstellungsdatum gültig. Wir freuen uns auf Ihren Auftrag!',
defaultSubject: 'Angebot',
},
order: {
docTitle: 'AUFTRAG',
numberPrefix: 'Auftrags-Nr.',
showPrices: true,
filenamePrefix: 'auftrag',
introText: 'Wir bestätigen dankend die Annahme Ihres Auftrags zu folgenden Positionen:',
closingText: 'Vielen Dank für Ihren Auftrag!',
defaultSubject: 'Auftragsbestätigung',
},
shipment: {
docTitle: 'LIEFERSCHEIN',
numberPrefix: 'Lieferschein-Nr.',
showPrices: false,
filenamePrefix: 'lieferschein',
introText: 'Mit dieser Sendung liefern wir Ihnen folgende Artikel:',
closingText: 'Die Ware bleibt bis zur vollständigen Bezahlung unser Eigentum. Bitte prüfen Sie die Sendung sofort auf Vollständigkeit und eventuelle Transportschäden.',
defaultSubject: 'Lieferschein',
},
delivery: {
docTitle: 'WARENEINGANG',
numberPrefix: 'Beleg-Nr.',
showPrices: false,
filenamePrefix: 'wareneingang',
introText: 'Wareneingangsdokumentation zu folgenden Positionen:',
closingText: 'Wareneingang ordnungsgemäß im Lager erfasst.',
defaultSubject: 'Wareneingang',
},
};
/**
* Generates an A4 PDF document Buffer for any manifest document type.
*
* @param {object} params
* @param {'quote'|'order'|'shipment'|'delivery'} [params.type='order'] - Manifest document type.
* @param {object} params.manifest - Manifest document object (order, quote, shipment, or delivery).
* @param {object} [params.company] - Company details (name, etc.).
* @param {Array<object>} [params.settings] - Company settings list [{ key, value }].
* @param {object} [params.partner] - Entity (customer or supplier).
* @param {Array<object>} [params.addresses] - Entity addresses.
* @returns {Promise<Buffer>}
*/
export function generateManifestPdf({
type = 'order',
manifest,
company = {},
settings = [],
partner = null,
addresses = [],
}) {
const config = TYPE_CONFIG[type] || TYPE_CONFIG.order;
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 45,
bufferPages: true,
info: {
Title: `${config.docTitle} ${manifest.number || manifest.id}`,
Author: company.name || 'Dash',
Subject: `${config.defaultSubject} #${manifest.number || manifest.id}`,
CreationDate: new Date(),
},
});
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Palette
const primaryColor = '#0f172a'; // slate-900
const secondaryColor = '#2563eb'; // blue-600
const textColor = '#1e293b'; // slate-800
const mutedColor = '#64748b'; // slate-500
const lightBorder = '#e2e8f0'; // slate-200
const lightBg = '#f8fafc'; // slate-50
// Helper to lookup settings
const settingsMap = new Map();
if (Array.isArray(settings)) {
for (const s of settings) {
if (s?.key) settingsMap.set(s.key, s.value);
}
}
const companyName = company?.name || settingsMap.get('company.name') || 'Firma';
const companyStreet = settingsMap.get('company.street') || '';
const companyZipCity = [settingsMap.get('company.zip'), settingsMap.get('company.city')].filter(Boolean).join(' ');
const companyEmail = settingsMap.get('company.email') || '';
const companyPhone = settingsMap.get('company.phone') || '';
const companyTaxId = settingsMap.get('company.taxId') || settingsMap.get('company.vatId') || '';
// Selected Address (for shipment prefer shipping address, for others prefer billing, fallback to first)
let chosenAddress = null;
if (Array.isArray(addresses) && addresses.length > 0) {
if (type === 'shipment') {
chosenAddress = addresses.find((a) => a.addressType === 'shipping') || addresses[0];
} else {
chosenAddress = addresses.find((a) => a.addressType === 'billing') || addresses[0];
}
}
const partnerName = partner
? [partner.firstName, partner.name].filter(Boolean).join(' ')
: '';
// ----------------------------------------------------
// 1. Header (Top bar)
// ----------------------------------------------------
let y = 45;
// Company Title (Left)
doc.font('Helvetica-Bold')
.fontSize(16)
.fillColor(primaryColor)
.text(companyName, 45, y);
// Document Title (Right)
doc.font('Helvetica-Bold')
.fontSize(20)
.fillColor(secondaryColor)
.text(config.docTitle, 350, y, { align: 'right', width: 200 });
y += 24;
// Company subline
const companyAddressLine = [companyStreet, companyZipCity].filter(Boolean).join(' • ');
if (companyAddressLine) {
doc.font('Helvetica')
.fontSize(8)
.fillColor(mutedColor)
.text(companyAddressLine, 45, y);
}
// Document Number under Document Title
doc.font('Helvetica-Bold')
.fontSize(11)
.fillColor(primaryColor)
.text(`${config.numberPrefix} ${manifest.number || manifest.id}`, 350, y, { align: 'right', width: 200 });
y += 18;
// Separator rule
doc.moveTo(45, y)
.lineTo(550, y)
.strokeColor(lightBorder)
.lineWidth(1)
.stroke();
y += 20;
// ----------------------------------------------------
// 2. Partner Address & Meta Box
// ----------------------------------------------------
const addressBlockTop = y;
// Small return-address header line (DIN 5008 style)
if (companyName || companyZipCity) {
doc.font('Helvetica')
.fontSize(7)
.fillColor(mutedColor)
.text(`${companyName} ${companyAddressLine ? '• ' + companyAddressLine : ''}`, 45, y);
y += 14;
}
// Partner Address
const partnerLabel = type === 'delivery' ? 'Lieferant' : (type === 'shipment' ? 'Lieferadresse' : 'Rechnungsadresse');
if (partnerName || chosenAddress) {
doc.font('Helvetica-Bold')
.fontSize(10)
.fillColor(textColor);
const displayName = chosenAddress?.name
? [chosenAddress.firstName, chosenAddress.name].filter(Boolean).join(' ')
: partnerName;
if (displayName) {
doc.text(displayName, 45, y);
y += 14;
}
doc.font('Helvetica')
.fontSize(9)
.fillColor(textColor);
if (chosenAddress?.street) {
const streetLine = [chosenAddress.street, chosenAddress.streetNumber].filter(Boolean).join(' ');
doc.text(streetLine, 45, y);
y += 13;
}
if (chosenAddress?.zip || chosenAddress?.city) {
const zipCity = [chosenAddress.zip, chosenAddress.city].filter(Boolean).join(' ');
doc.text(zipCity, 45, y);
y += 13;
}
if (chosenAddress?.county) {
doc.text(chosenAddress.county, 45, y);
y += 13;
}
} else {
doc.font('Helvetica')
.fontSize(9)
.fillColor(mutedColor)
.text(`Keine ${partnerLabel} hinterlegt`, 45, y);
y += 14;
}
// Right side Meta Box
const metaBoxX = 330;
const metaBoxWidth = 220;
const metaBoxY = addressBlockTop;
const metaBoxHeight = 85;
doc.roundedRect(metaBoxX, metaBoxY, metaBoxWidth, metaBoxHeight, 4)
.fillColor(lightBg)
.fill()
.strokeColor(lightBorder)
.lineWidth(0.75)
.stroke();
let metaY = metaBoxY + 10;
const drawMetaRow = (label, val) => {
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text(label, metaBoxX + 10, metaY, { width: 95 });
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text(val || '–', metaBoxX + 105, metaY, { width: 105, align: 'right' });
metaY += 15;
};
drawMetaRow(`${config.docTitle.slice(0, 1) + config.docTitle.slice(1).toLowerCase()}-Nr.:`, String(manifest.number || manifest.id));
const dateLabel = type === 'shipment' ? 'Versanddatum:' : (type === 'delivery' ? 'Eingangsdatum:' : 'Belegdatum:');
drawMetaRow(dateLabel, formatDate(manifest.date || manifest.createdAt));
if (manifest.externalNumber) {
drawMetaRow('Externe Referenz:', String(manifest.externalNumber));
}
if (partner?.number) {
const numLabel = type === 'delivery' ? 'Lieferantennr.:' : 'Kundennummer:';
drawMetaRow(numLabel, String(partner.number));
}
// Ensure y is past the address & meta blocks
y = Math.max(y + 15, metaBoxY + metaBoxHeight + 15);
// If shipment has package tracking info, show a compact card
if (type === 'shipment' && Array.isArray(manifest.shippings) && manifest.shippings.length > 0) {
const pkgBoxHeight = 16 + manifest.shippings.length * 13;
doc.roundedRect(45, y, 505, pkgBoxHeight, 4)
.fillColor(lightBg)
.fill()
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(primaryColor).text('Pakete & Sendungsverfolgung:', 55, y + 6);
let pkgY = y + 20;
manifest.shippings.forEach((pkg, pIdx) => {
const pkgText = `Paket ${pIdx + 1}: ${pkg.carrier || 'Paketdienst'} ${pkg.number ? '#' + pkg.number : ''} ${pkg.trackingCode ? '• Tracking: ' + pkg.trackingCode : ''} (${pkg.state || 'gepackt'})`;
doc.font('Helvetica').fontSize(8).fillColor(textColor).text(pkgText, 55, pkgY, { width: 485, ellipsis: true });
pkgY += 13;
});
y += pkgBoxHeight + 15;
}
// Intro text
doc.font('Helvetica')
.fontSize(9)
.fillColor(textColor)
.text(config.introText, 45, y);
y += 18;
// ----------------------------------------------------
// 3. Line Items Table
// ----------------------------------------------------
const showPrices = config.showPrices;
// Column setups
const colXPrices = { pos: 45, sku: 75, name: 165, amount: 345, net: 405, tax: 465, total: 495 };
const colWidthPrices = { pos: 25, sku: 85, name: 175, amount: 55, net: 55, tax: 25, total: 55 };
const colXNoPrices = { pos: 45, sku: 80, name: 185, amount: 455 };
const colWidthNoPrices = { pos: 30, sku: 100, name: 265, amount: 95 };
const drawTableHeader = (atY) => {
doc.rect(45, atY, 505, 20)
.fillColor(primaryColor)
.fill();
doc.font('Helvetica-Bold')
.fontSize(8)
.fillColor('#ffffff');
if (showPrices) {
doc.text('Pos', colXPrices.pos + 3, atY + 6, { width: colWidthPrices.pos });
doc.text('Art.-Nr.', colXPrices.sku, atY + 6, { width: colWidthPrices.sku });
doc.text('Bezeichnung', colXPrices.name, atY + 6, { width: colWidthPrices.name });
doc.text('Menge', colXPrices.amount, atY + 6, { width: colWidthPrices.amount, align: 'right' });
doc.text('E-Preis Netto', colXPrices.net, atY + 6, { width: colWidthPrices.net, align: 'right' });
doc.text('MwSt', colXPrices.tax, atY + 6, { width: colWidthPrices.tax, align: 'right' });
doc.text('Gesamt Netto', colXPrices.total, atY + 6, { width: colWidthPrices.total, align: 'right' });
} else {
doc.text('Pos', colXNoPrices.pos + 3, atY + 6, { width: colWidthNoPrices.pos });
doc.text('Art.-Nr.', colXNoPrices.sku, atY + 6, { width: colWidthNoPrices.sku });
doc.text('Bezeichnung', colXNoPrices.name, atY + 6, { width: colWidthNoPrices.name });
doc.text('Liefermenge', colXNoPrices.amount, atY + 6, { width: colWidthNoPrices.amount, align: 'right' });
}
return atY + 22;
};
y = drawTableHeader(y);
const lines = Array.isArray(manifest.lines) ? manifest.lines : [];
let totalNet = 0;
let totalQty = 0;
const taxBreakdown = new Map();
lines.forEach((line, index) => {
const amount = Number(line.amount || 0);
const net = Number(line.net || 0);
const rebate = Number(line.rebate || 0);
const taxrate = Number(line.taxrate || 0);
totalQty += amount;
let lineNet = 0;
let unitNetAfterRebate = net;
if (showPrices) {
unitNetAfterRebate = net * (1 - rebate / 100);
lineNet = amount * unitNetAfterRebate;
const lineTax = lineNet * (taxrate / 100);
totalNet += lineNet;
const currTax = taxBreakdown.get(taxrate) || { net: 0, tax: 0 };
currTax.net += lineNet;
currTax.tax += lineTax;
taxBreakdown.set(taxrate, currTax);
}
// Row height estimation
const hasComment = Boolean(line.comment);
const hasRebate = showPrices && rebate > 0;
let extraLines = 0;
if (hasComment) extraLines += 1;
if (hasRebate) extraLines += 1;
const rowHeight = 18 + extraLines * 11;
// Check page overflow
if (y + rowHeight > 730) {
doc.addPage();
y = drawTableHeader(45);
}
// Zebra striping
if (index % 2 === 1) {
doc.rect(45, y - 2, 505, rowHeight)
.fillColor(lightBg)
.fill();
}
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor);
const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : '';
if (showPrices) {
// Pos
doc.text(String(index + 1), colXPrices.pos + 3, y, { width: colWidthPrices.pos });
// SKU
doc.text(line.sku || '–', colXPrices.sku, y, { width: colWidthPrices.sku, ellipsis: true });
// Name
doc.text((line.name || 'Position') + puSuffix, colXPrices.name, y, {
width: colWidthPrices.name,
ellipsis: true,
});
// Amount
doc.text(formatQuantity(amount), colXPrices.amount, y, {
width: colWidthPrices.amount,
align: 'right',
});
// Unit Price Net
doc.text(formatCurrency(net), colXPrices.net, y, {
width: colWidthPrices.net,
align: 'right',
});
// Tax Rate
doc.text(`${taxrate}%`, colXPrices.tax, y, {
width: colWidthPrices.tax,
align: 'right',
});
// Line Total Net
doc.font('Helvetica-Bold')
.text(formatCurrency(lineNet), colXPrices.total, y, {
width: colWidthPrices.total,
align: 'right',
});
let subY = y + 12;
if (hasRebate) {
doc.font('Helvetica')
.fontSize(7.5)
.fillColor(secondaryColor)
.text(`Rabatt: ${rebate}% (effektiver E-Preis: ${formatCurrency(unitNetAfterRebate)})`, colXPrices.name, subY, {
width: colWidthPrices.name,
});
subY += 10;
}
if (hasComment) {
doc.font('Helvetica-Oblique')
.fontSize(7.5)
.fillColor(mutedColor)
.text(line.comment, colXPrices.name, subY, {
width: colWidthPrices.name,
});
}
} else {
// Without Prices
doc.text(String(index + 1), colXNoPrices.pos + 3, y, { width: colWidthNoPrices.pos });
doc.text(line.sku || '–', colXNoPrices.sku, y, { width: colWidthNoPrices.sku, ellipsis: true });
doc.text((line.name || 'Position') + puSuffix, colXNoPrices.name, y, {
width: colWidthNoPrices.name,
ellipsis: true,
});
doc.font('Helvetica-Bold')
.text(formatQuantity(amount) + ' Stk', colXNoPrices.amount, y, {
width: colWidthNoPrices.amount,
align: 'right',
});
if (hasComment) {
doc.font('Helvetica-Oblique')
.fontSize(7.5)
.fillColor(mutedColor)
.text(line.comment, colXNoPrices.name, y + 12, {
width: colWidthNoPrices.name,
});
}
}
// Bottom row separator
doc.moveTo(45, y + rowHeight - 2)
.lineTo(550, y + rowHeight - 2)
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
y += rowHeight;
});
// ----------------------------------------------------
// 4. Summary & Totals Block
// ----------------------------------------------------
if (showPrices) {
let totalTax = 0;
for (const item of taxBreakdown.values()) {
totalTax += item.tax;
}
const grandTotal = totalNet + totalTax;
if (y + 110 > 750) {
doc.addPage();
y = 50;
} else {
y += 15;
}
const summaryX = 330;
const summaryWidth = 220;
// Subtotal Net
doc.font('Helvetica')
.fontSize(9)
.fillColor(mutedColor)
.text('Summe Netto:', summaryX, y, { width: 110 });
doc.font('Helvetica-Bold')
.fontSize(9)
.fillColor(textColor)
.text(formatCurrency(totalNet), summaryX + 110, y, { width: 110, align: 'right' });
y += 15;
// Tax Breakdown
const sortedRates = Array.from(taxBreakdown.entries()).sort((a, b) => a[0] - b[0]);
for (const [rate, val] of sortedRates) {
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text(`zzgl. ${rate}% MwSt:`, summaryX, y, { width: 110 });
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor)
.text(formatCurrency(val.tax), summaryX + 110, y, { width: 110, align: 'right' });
y += 14;
}
// Divider before Grand Total
doc.moveTo(summaryX, y + 2)
.lineTo(summaryX + summaryWidth, y + 2)
.strokeColor(lightBorder)
.lineWidth(1)
.stroke();
y += 8;
// Grand Total
doc.font('Helvetica-Bold')
.fontSize(12)
.fillColor(primaryColor)
.text('Gesamtsumme:', summaryX, y, { width: 110 });
doc.font('Helvetica-Bold')
.fontSize(12)
.fillColor(secondaryColor)
.text(formatCurrency(grandTotal), summaryX + 110, y, { width: 110, align: 'right' });
y += 24;
// Payments / open balance note (for orders)
if (manifest.paidTotal !== undefined && manifest.paidTotal > 0) {
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text('Bereits bezahlt:', summaryX, y, { width: 110 });
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor)
.text(formatCurrency(manifest.paidTotal), summaryX + 110, y, { width: 110, align: 'right' });
y += 14;
const openAmount = Math.max(0, grandTotal - Number(manifest.paidTotal || 0));
if (openAmount > 0.01) {
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text('Offener Betrag:', summaryX, y, { width: 110 });
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text(formatCurrency(openAmount), summaryX + 110, y, { width: 110, align: 'right' });
y += 16;
}
}
} else {
// Non-price summary (total quantity and positions count)
if (y + 50 > 750) {
doc.addPage();
y = 50;
} else {
y += 15;
}
doc.roundedRect(45, y, 505, 30, 4)
.fillColor(lightBg)
.fill()
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
doc.font('Helvetica')
.fontSize(9)
.fillColor(mutedColor)
.text('Gesamtübersicht:', 55, y + 9);
doc.font('Helvetica-Bold')
.fontSize(9)
.fillColor(textColor)
.text(`${lines.length} Positionen • Gesamtmenge: ${formatQuantity(totalQty)} Stück`, 150, y + 9, {
width: 385,
align: 'right',
});
y += 45;
}
// Closing note
if (config.closingText) {
if (y + 40 > 750) {
doc.addPage();
y = 50;
}
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text(config.closingText, 45, y, { width: 505 });
}
// ----------------------------------------------------
// 5. Footers on all pages
// ----------------------------------------------------
const range = doc.bufferedPageRange();
for (let i = 0; i < range.count; i++) {
doc.switchToPage(i);
// Footer rule
doc.moveTo(45, 785)
.lineTo(550, 785)
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
// Footer text left
const footerInfo = [
companyName,
companyTaxId ? `USt-IdNr.: ${companyTaxId}` : null,
companyEmail,
companyPhone,
].filter(Boolean).join(' • ');
doc.font('Helvetica')
.fontSize(7.5)
.fillColor(mutedColor)
.text(footerInfo || 'Vielen Dank für Ihre Zusammenarbeit!', 45, 793, {
width: 350,
ellipsis: true,
lineBreak: false,
});
// Footer text right: Page X of Y
doc.text(
`Seite ${i + 1} von ${range.count}`,
450,
793,
{ width: 100, align: 'right', lineBreak: false }
);
}
doc.end();
} catch (err) {
reject(err);
}
});
}
/**
* Creates and attaches a PDF to any manifest (quote, order, shipment, delivery)
* and emits SSE updates.
*
* @param {number} googleAuthId - Google auth user ID.
* @param {number} companyId - Active company ID.
* @param {number} manifestId - Manifest ID.
* @param {'quote'|'order'|'shipment'|'delivery'} type - Manifest type.
* @returns {Promise<{manifest: object, attachment: object, pdfBuffer: Buffer, filename: string}>}
*/
export async function createAndAttachManifestPdf(googleAuthId, companyId, manifestId, type = 'order') {
const store = await getStore();
let manifest;
if (type === 'quote') {
manifest = await store.quote.getById(googleAuthId, manifestId);
} else if (type === 'order') {
manifest = await dbOrder.getById(googleAuthId, manifestId);
} else if (type === 'shipment') {
manifest = await store.shipment.getById(googleAuthId, manifestId);
} else if (type === 'delivery') {
manifest = await store.delivery.getById(googleAuthId, manifestId);
} else {
throw new Error(`Unsupported manifest type: ${type}`);
}
if (!manifest) {
throw new Error('Manifest not found');
}
const partnerId = manifest.entityId ?? manifest.supplierId ?? null;
const [company, settings, partner, addresses] = await Promise.all([
store.company.getById(companyId).catch(() => null),
store.companysetting.list(googleAuthId, { frontendOnly: false, maskWriteonly: false }).catch(() => []),
partnerId ? store.entity.getById(googleAuthId, partnerId).catch(() => null) : null,
partnerId ? store.entityaddress.list(googleAuthId, partnerId).catch(() => []) : [],
]);
const pdfBuffer = await generateManifestPdf({
type,
manifest,
company: company || {},
settings: settings || [],
partner: partner || null,
addresses: addresses || [],
});
const config = TYPE_CONFIG[type] || TYPE_CONFIG.order;
const safeNumber = String(manifest.number || manifest.id).replace(/[^a-zA-Z0-9._-]/g, '_');
const filename = `${config.filenamePrefix}-${safeNumber}.pdf`;
const attachment = await store.manifestattachment.add(googleAuthId, manifest.id, {
mimetype: 'application/pdf',
name: filename,
content: pdfBuffer,
meta: { type: `${type}_pdf` },
});
emit(type, { companyId });
return { manifest, attachment, pdfBuffer, filename };
}
// Re-export alias for order backwards compatibility
export { generateManifestPdf as generateOrderPdf };

View File

@@ -1,555 +1,8 @@
import PDFDocument from 'pdfkit';
/**
* Formats a Date or date string to German format DD.MM.YYYY.
* @param {Date|string} dateVal
* @returns {string}
*/
export function formatDate(dateVal) {
if (!dateVal) return '';
const d = new Date(dateVal);
if (Number.isNaN(d.getTime())) return String(dateVal);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
/**
* Formats a number as German currency string (e.g. 1.234,50 €).
* @param {number} val
* @returns {string}
*/
export function formatCurrency(val) {
const n = Number(val) || 0;
return (
n.toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}) + ' €'
);
}
/**
* Formats a number with up to 3 decimal places (e.g. 1 or 2,5).
* @param {number} val
* @returns {string}
*/
export function formatQuantity(val) {
const n = Number(val) || 0;
return n.toLocaleString('de-DE', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
}
/**
* Generates an A4 PDF document Buffer for an order.
*
* @param {object} params
* @param {object} params.order - Order object with lines, orderTotal, etc.
* @param {object} [params.company] - Company details (name, etc.).
* @param {Array<object>} [params.settings] - Company settings list [{ key, value }].
* @param {object} [params.customer] - Customer entity (firstName, name, number).
* @param {Array<object>} [params.addresses] - Customer addresses.
* @returns {Promise<Buffer>}
*/
export function generateOrderPdf({ order, company = {}, settings = [], customer = null, addresses = [] }) {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 45,
bufferPages: true,
info: {
Title: `Auftrag ${order.number || order.id}`,
Author: company.name || 'Dash',
Subject: `Auftragsbestätigung #${order.number || order.id}`,
CreationDate: new Date(),
},
});
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Palette
const primaryColor = '#0f172a'; // slate-900
const secondaryColor = '#2563eb'; // blue-600
const textColor = '#1e293b'; // slate-800
const mutedColor = '#64748b'; // slate-500
const lightBorder = '#e2e8f0'; // slate-200
const lightBg = '#f8fafc'; // slate-50
// Helper to lookup settings
const settingsMap = new Map();
if (Array.isArray(settings)) {
for (const s of settings) {
if (s?.key) settingsMap.set(s.key, s.value);
}
}
const companyName = company?.name || settingsMap.get('company.name') || 'Firma';
const companyStreet = settingsMap.get('company.street') || '';
const companyZipCity = [settingsMap.get('company.zip'), settingsMap.get('company.city')].filter(Boolean).join(' ');
const companyEmail = settingsMap.get('company.email') || '';
const companyPhone = settingsMap.get('company.phone') || '';
const companyTaxId = settingsMap.get('company.taxId') || settingsMap.get('company.vatId') || '';
// Selected Address (prefer billing, then first)
let chosenAddress = null;
if (Array.isArray(addresses) && addresses.length > 0) {
chosenAddress = addresses.find((a) => a.addressType === 'billing') || addresses[0];
}
const customerName = customer
? [customer.firstName, customer.name].filter(Boolean).join(' ')
: '';
// ----------------------------------------------------
// 1. Header (Top bar)
// ----------------------------------------------------
let y = 45;
// Company Title (Left)
doc.font('Helvetica-Bold')
.fontSize(16)
.fillColor(primaryColor)
.text(companyName, 45, y);
// Document Title & Number (Right)
doc.font('Helvetica-Bold')
.fontSize(20)
.fillColor(secondaryColor)
.text('AUFTRAG', 350, y, { align: 'right', width: 200 });
y += 24;
// Company subline
const companyAddressLine = [companyStreet, companyZipCity].filter(Boolean).join(' • ');
if (companyAddressLine) {
doc.font('Helvetica')
.fontSize(8)
.fillColor(mutedColor)
.text(companyAddressLine, 45, y);
}
// Order Number under Document Title
doc.font('Helvetica-Bold')
.fontSize(11)
.fillColor(primaryColor)
.text(`Auftrags-Nr. ${order.number || order.id}`, 350, y, { align: 'right', width: 200 });
y += 18;
// Separator rule
doc.moveTo(45, y)
.lineTo(550, y)
.strokeColor(lightBorder)
.lineWidth(1)
.stroke();
y += 20;
// ----------------------------------------------------
// 2. Customer Address & Order Meta Box
// ----------------------------------------------------
const addressBlockTop = y;
// Small return-address header line (standard DIN 5008 style)
if (companyName || companyZipCity) {
doc.font('Helvetica')
.fontSize(7)
.fillColor(mutedColor)
.text(`${companyName} ${companyAddressLine ? '• ' + companyAddressLine : ''}`, 45, y);
y += 14;
}
// Customer Address
if (customerName || chosenAddress) {
doc.font('Helvetica-Bold')
.fontSize(10)
.fillColor(textColor);
const displayName = chosenAddress?.name
? [chosenAddress.firstName, chosenAddress.name].filter(Boolean).join(' ')
: customerName;
if (displayName) {
doc.text(displayName, 45, y);
y += 14;
}
doc.font('Helvetica')
.fontSize(9)
.fillColor(textColor);
if (chosenAddress?.street) {
const streetLine = [chosenAddress.street, chosenAddress.streetNumber].filter(Boolean).join(' ');
doc.text(streetLine, 45, y);
y += 13;
}
if (chosenAddress?.zip || chosenAddress?.city) {
const zipCity = [chosenAddress.zip, chosenAddress.city].filter(Boolean).join(' ');
doc.text(zipCity, 45, y);
y += 13;
}
if (chosenAddress?.county) {
doc.text(chosenAddress.county, 45, y);
y += 13;
}
} else {
doc.font('Helvetica')
.fontSize(9)
.fillColor(mutedColor)
.text('Keine Kundenadresse hinterlegt', 45, y);
y += 14;
}
// Right side Meta Box (Cards)
const metaBoxX = 330;
const metaBoxWidth = 220;
const metaBoxY = addressBlockTop;
const metaBoxHeight = 85;
doc.roundedRect(metaBoxX, metaBoxY, metaBoxWidth, metaBoxHeight, 4)
.fillColor(lightBg)
.fill()
.strokeColor(lightBorder)
.lineWidth(0.75)
.stroke();
let metaY = metaBoxY + 10;
const drawMetaRow = (label, val) => {
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text(label, metaBoxX + 10, metaY, { width: 90 });
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text(val || '–', metaBoxX + 105, metaY, { width: 105, align: 'right' });
metaY += 15;
};
drawMetaRow('Auftragsnummer:', String(order.number || order.id));
drawMetaRow('Auftragsdatum:', formatDate(order.date || order.createdAt));
if (order.externalNumber) {
drawMetaRow('Externe Referenz:', String(order.externalNumber));
}
if (customer?.number) {
drawMetaRow('Kundennummer:', String(customer.number));
}
// Ensure y is past the address & meta blocks
y = Math.max(y + 15, metaBoxY + metaBoxHeight + 20);
// Intro text
doc.font('Helvetica')
.fontSize(9)
.fillColor(textColor)
.text('Wir bestätigen dankend die Annahme Ihres Auftrags zu folgenden Positionen:', 45, y);
y += 20;
// ----------------------------------------------------
// 3. Line Items Table
// ----------------------------------------------------
const colX = {
pos: 45,
sku: 75,
name: 165,
amount: 345,
net: 405,
tax: 465,
total: 495,
};
const colWidth = {
pos: 25,
sku: 85,
name: 175,
amount: 55,
net: 55,
tax: 25,
total: 55,
};
const drawTableHeader = (atY) => {
doc.rect(45, atY, 505, 20)
.fillColor(primaryColor)
.fill();
doc.font('Helvetica-Bold')
.fontSize(8)
.fillColor('#ffffff');
doc.text('Pos', colX.pos + 3, atY + 6, { width: colWidth.pos });
doc.text('Art.-Nr.', colX.sku, atY + 6, { width: colWidth.sku });
doc.text('Bezeichnung', colX.name, atY + 6, { width: colWidth.name });
doc.text('Menge', colX.amount, atY + 6, { width: colWidth.amount, align: 'right' });
doc.text('E-Preis Netto', colX.net, atY + 6, { width: colWidth.net, align: 'right' });
doc.text('MwSt', colX.tax, atY + 6, { width: colWidth.tax, align: 'right' });
doc.text('Gesamt Netto', colX.total, atY + 6, { width: colWidth.total, align: 'right' });
return atY + 22;
};
y = drawTableHeader(y);
const lines = Array.isArray(order.lines) ? order.lines : [];
let totalNet = 0;
const taxBreakdown = new Map(); // rate -> { net, tax }
lines.forEach((line, index) => {
const amount = Number(line.amount || 0);
const net = Number(line.net || 0);
const rebate = Number(line.rebate || 0);
const taxrate = Number(line.taxrate || 0);
const unitNetAfterRebate = net * (1 - rebate / 100);
const lineNet = amount * unitNetAfterRebate;
const lineTax = lineNet * (taxrate / 100);
totalNet += lineNet;
const currTax = taxBreakdown.get(taxrate) || { net: 0, tax: 0 };
currTax.net += lineNet;
currTax.tax += lineTax;
taxBreakdown.set(taxrate, currTax);
// Row height estimation
const hasComment = Boolean(line.comment);
const hasRebate = rebate > 0;
let extraLines = 0;
if (hasComment) extraLines += 1;
if (hasRebate) extraLines += 1;
const rowHeight = 18 + extraLines * 11;
// Check page overflow
if (y + rowHeight > 730) {
doc.addPage();
y = drawTableHeader(45);
}
// Zebra striping
if (index % 2 === 1) {
doc.rect(45, y - 2, 505, rowHeight)
.fillColor(lightBg)
.fill();
}
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor);
// Pos
doc.text(String(index + 1), colX.pos + 3, y, { width: colWidth.pos });
// SKU
doc.text(line.sku || '–', colX.sku, y, { width: colWidth.sku, ellipsis: true });
// Name
const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : '';
doc.text((line.name || 'Position') + puSuffix, colX.name, y, {
width: colWidth.name,
ellipsis: true,
});
// Amount
doc.text(formatQuantity(amount), colX.amount, y, {
width: colWidth.amount,
align: 'right',
});
// Unit Price Net
doc.text(formatCurrency(net), colX.net, y, {
width: colWidth.net,
align: 'right',
});
// Tax Rate
doc.text(`${taxrate}%`, colX.tax, y, {
width: colWidth.tax,
align: 'right',
});
// Line Total Net
doc.font('Helvetica-Bold')
.text(formatCurrency(lineNet), colX.total, y, {
width: colWidth.total,
align: 'right',
});
let subY = y + 12;
// Optional rebate notice
if (hasRebate) {
doc.font('Helvetica')
.fontSize(7.5)
.fillColor(secondaryColor)
.text(`Rabatt: ${rebate}% (effektiver E-Preis: ${formatCurrency(unitNetAfterRebate)})`, colX.name, subY, {
width: colWidth.name,
});
subY += 10;
}
// Optional comment
if (hasComment) {
doc.font('Helvetica-Oblique')
.fontSize(7.5)
.fillColor(mutedColor)
.text(line.comment, colX.name, subY, {
width: colWidth.name,
});
}
// Bottom row separator
doc.moveTo(45, y + rowHeight - 2)
.lineTo(550, y + rowHeight - 2)
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
y += rowHeight;
});
// ----------------------------------------------------
// 4. Summary & Totals Block
// ----------------------------------------------------
let totalTax = 0;
for (const item of taxBreakdown.values()) {
totalTax += item.tax;
}
const grandTotal = totalNet + totalTax;
// Summary block fits ~90pt
if (y + 110 > 750) {
doc.addPage();
y = 50;
} else {
y += 15;
}
const summaryX = 330;
const summaryWidth = 220;
// Subtotal Net
doc.font('Helvetica')
.fontSize(9)
.fillColor(mutedColor)
.text('Summe Netto:', summaryX, y, { width: 110 });
doc.font('Helvetica-Bold')
.fontSize(9)
.fillColor(textColor)
.text(formatCurrency(totalNet), summaryX + 110, y, { width: 110, align: 'right' });
y += 15;
// Tax Breakdown
const sortedRates = Array.from(taxBreakdown.entries()).sort((a, b) => a[0] - b[0]);
for (const [rate, val] of sortedRates) {
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text(`zzgl. ${rate}% MwSt:`, summaryX, y, { width: 110 });
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor)
.text(formatCurrency(val.tax), summaryX + 110, y, { width: 110, align: 'right' });
y += 14;
}
// Divider before Grand Total
doc.moveTo(summaryX, y + 2)
.lineTo(summaryX + summaryWidth, y + 2)
.strokeColor(lightBorder)
.lineWidth(1)
.stroke();
y += 8;
// Grand Total (Brutto)
doc.font('Helvetica-Bold')
.fontSize(12)
.fillColor(primaryColor)
.text('Gesamtsumme:', summaryX, y, { width: 110 });
doc.font('Helvetica-Bold')
.fontSize(12)
.fillColor(secondaryColor)
.text(formatCurrency(grandTotal), summaryX + 110, y, { width: 110, align: 'right' });
y += 24;
// Payment / status note
if (order.paidTotal !== undefined && order.paidTotal > 0) {
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text('Bereits bezahlt:', summaryX, y, { width: 110 });
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor)
.text(formatCurrency(order.paidTotal), summaryX + 110, y, { width: 110, align: 'right' });
y += 14;
const openAmount = Math.max(0, grandTotal - Number(order.paidTotal || 0));
if (openAmount > 0.01) {
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text('Offener Betrag:', summaryX, y, { width: 110 });
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text(formatCurrency(openAmount), summaryX + 110, y, { width: 110, align: 'right' });
y += 16;
}
}
// ----------------------------------------------------
// 5. Footers on all pages
// ----------------------------------------------------
const range = doc.bufferedPageRange();
for (let i = 0; i < range.count; i++) {
doc.switchToPage(i);
// Footer rule
doc.moveTo(45, 785)
.lineTo(550, 785)
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
// Footer text left: Company info / Thank you
const footerInfo = [
companyName,
companyTaxId ? `USt-IdNr.: ${companyTaxId}` : null,
companyEmail,
companyPhone,
].filter(Boolean).join(' • ');
doc.font('Helvetica')
.fontSize(7.5)
.fillColor(mutedColor)
.text(footerInfo || 'Vielen Dank für Ihren Auftrag!', 45, 793, {
width: 350,
ellipsis: true,
lineBreak: false,
});
// Footer text right: Page X of Y
doc.text(
`Seite ${i + 1} von ${range.count}`,
450,
793,
{ width: 100, align: 'right', lineBreak: false }
);
}
doc.end();
} catch (err) {
reject(err);
}
});
}
export {
formatDate,
formatCurrency,
formatQuantity,
generateManifestPdf,
generateOrderPdf,
createAndAttachManifestPdf,
} from './manifestPdf.js';