Compare commits

...

9 Commits

Author SHA1 Message Date
seb
a01b42b24b u 2026-10-18 03:25:03 +02:00
seb
04943fb7a6 u 2026-10-18 03:15:23 +02:00
seb
e085e064ee u 2026-10-17 23:59:28 +02:00
seb
df846f4f06 u 2026-10-17 23:31:40 +02:00
seb
4bd977ed12 u 2026-10-17 22:26:02 +02:00
seb
1778815a1e u 2026-10-17 22:03:24 +02:00
seb
856692b66c u 2026-10-17 21:37:36 +02:00
seb
5e9b168e0d u 2026-10-17 20:53:56 +02:00
seb
bee21d2c20 u 2026-10-17 19:51:34 +02:00
23 changed files with 1954 additions and 881 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: 322 KiB

After

Width:  |  Height:  |  Size: 330 KiB

View File

@@ -39,6 +39,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` | | [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` |
| [`routes/article/create.md`](routes/article/create.md) | `POST /article` | | [`routes/article/create.md`](routes/article/create.md) | `POST /article` |
| [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` | | [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` |
| [`routes/article/history-list.md`](routes/article/history-list.md) | `GET /article/history` |
| [`routes/article/delete.md`](routes/article/delete.md) | `DELETE /article/{sku}` | | [`routes/article/delete.md`](routes/article/delete.md) | `DELETE /article/{sku}` |
| [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` | | [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` |
| [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` | | [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` |
@@ -166,6 +167,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PATCH /labeltemplate/{id}` | | [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PATCH /labeltemplate/{id}` |
| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PUT /labeltemplate/{id}` | | [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PUT /labeltemplate/{id}` |
| [`routes/ledger.md`](routes/ledger.md) | `GET /ledger` | | [`routes/ledger.md`](routes/ledger.md) | `GET /ledger` |
| [`routes/ledger/history.md`](routes/ledger/history.md) | `GET /ledger/history` |
| [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` | | [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` |
| [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` | | [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` |
| [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` | | [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 261 API endpoints across all modules. Overview of all 263 API endpoints across all modules.
### Notation ### Notation
@@ -83,6 +83,9 @@ _none_
#### Export articles as ZIP — `GET /article/export.zip` #### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int` - Params: `query: categoryId?: int`
- Returns: `200 zip` - Returns: `200 zip`
#### Query company-wide article history — `GET /article/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { history: { id: int, date: str, sku: str, historytext: str, name?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Delete article by SKU — `DELETE /article/{sku}` #### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool` - Params: `sku: str, query: cascade?: bool`
- Returns: `204 void` - Returns: `204 void`
@@ -316,7 +319,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - 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?: T26|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?: T26|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }` - Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -528,6 +531,9 @@ _none_
#### Query ledger by manifest article ID or SKU — `GET /ledger` #### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T15[] }` - Returns: `200 { ledger: T15[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str` - Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T15[] }` - Returns: `200 { ledger: T15[] }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 186 API endpoints across all modules. Overview of all 188 API endpoints across all modules.
### Notation ### Notation
@@ -50,6 +50,9 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### Create article in category — `POST /article` #### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }` - Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 { article: T26 }` - Returns: `201 { article: T26 }`
#### Query company-wide article history — `GET /article/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { history: { id: int, date: str, sku: str, historytext: str, name?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Delete article by SKU — `DELETE /article/{sku}` #### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool` - Params: `sku: str, query: cascade?: bool`
- Returns: `204 void` - Returns: `204 void`
@@ -250,7 +253,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - 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?: T20|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?: T20|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }` - Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -410,6 +413,9 @@ _none_
#### Query ledger by manifest article ID or SKU — `GET /ledger` #### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T11[] }` - Returns: `200 { ledger: T11[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str` - Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T11[] }` - Returns: `200 { ledger: T11[] }`

View File

@@ -0,0 +1,52 @@
# `GET /article/history`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Query company-wide article history
Queries article change history across all articles for the authenticated user's selected company.
Supports pagination, search filter, and date cutoff via `seekMonths`.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `offset` | no | integer | Paging offset |
| `limit` | no | integer | Page size |
| `search` | no | string | Optional search filter across SKU, article name, and history text |
| `seekMonths` | no | integer | Negative integer cutoff months ago (e.g. -13) |
## Response `200`
Paged article history list
```json
{
"history": [
{
"id": 1,
"date": "2026-08-10T00:00:00.000Z",
"sku": "…",
"name": "…",
"historytext": "…"
}
],
"total": 1,
"unfilteredTotal": 1,
"offset": 1,
"limit": 1
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -43,7 +43,25 @@ Articles list
"thumbW": 1, "thumbW": 1,
"thumbH": 1, "thumbH": 1,
"thumbHash": "…" "thumbHash": "…"
},
"articlewarehousesummary": [
{
"id": 1,
"articleId": 1,
"article_id": 1,
"warehouseId": 1,
"warehouse_id": 1,
"warehouseName": "…",
"stock": 1
} }
],
"warehouses": [
{
"id": 1,
"name": "…",
"stock": 1
}
]
} }
] ]
} }

View File

@@ -0,0 +1,54 @@
# `GET /ledger/history`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Query company-wide ledger history
Queries ledger entries across all articles for the authenticated user's selected company.
Supports pagination, search filter, and date cutoff via `seekMonths`.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `offset` | no | integer | Paging offset |
| `limit` | no | integer | Page size |
| `search` | no | string | Optional search filter across SKU, article name, manifest number, external number, transaction type, and transaction text |
| `seekMonths` | no | integer | Negative integer cutoff months ago (e.g. -1) |
## Response `200`
Paged ledger history list
```json
{
"ledger": [
{
"id": 1,
"date": "2026-08-10T00:00:00.000Z",
"sku": "…",
"name": "…",
"transaction_type": "…",
"transaction_text": "…",
"amount": 1
}
],
"total": 1,
"unfilteredTotal": 1,
"offset": 1,
"limit": 1
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -28,6 +28,7 @@ _No content_
| 401 | Unauthorized — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` |
| 404 | Supplier not found — `{"error":"…"}` | | 404 | Supplier not found — `{"error":"…"}` |
| 409 | Supplier in use — `{"error":"…"}` |
--- ---

View File

@@ -576,6 +576,42 @@
* brand: * brand:
* $ref: '#/components/schemas/ArticleBrand' * $ref: '#/components/schemas/ArticleBrand'
* nullable: true * nullable: true
* articlewarehousesummary:
* type: array
* description: Warehouse stock summary entries for this article
* items:
* $ref: '#/components/schemas/CategoryArticleWarehouseSummary'
* warehouses:
* type: array
* description: Warehouse stock list for this article
* items:
* type: object
* properties:
* id:
* type: integer
* name:
* type: string
* stock:
* type: number
* CategoryArticleWarehouseSummary:
* type: object
* required: [id, articleId, warehouseId, stock]
* properties:
* id:
* type: integer
* articleId:
* type: integer
* article_id:
* type: integer
* warehouseId:
* type: integer
* warehouse_id:
* type: integer
* warehouseName:
* type: string
* nullable: true
* stock:
* type: number
* NumberSequence: * NumberSequence:
* type: object * type: object
* required: [id, type, prefix, padding, next] * required: [id, type, prefix, padding, next]

View File

@@ -257,7 +257,155 @@ export function register(app, basePath = '') {
/** /**
* @openapi * @openapi
* /article/{sku}: * /article/history:
* get:
* tags: [Article]
* summary: Query company-wide article history
* description: |
* Queries article change history across all articles for the authenticated user's selected company.
* Supports pagination, search filter, and date cutoff via `seekMonths`.
* security:
* - cookieAuth: []
* x-markdown: article/history-list.md
* parameters:
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Paging offset
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* default: 50
* description: Page size
* - in: query
* name: search
* schema:
* type: string
* description: Optional search filter across SKU, article name, and history text
* - in: query
* name: seekMonths
* schema:
* type: integer
* description: Negative integer cutoff months ago (e.g. -13)
* responses:
* 200:
* description: Paged article history list
* content:
* application/json:
* schema:
* type: object
* required: [history, total, unfilteredTotal, offset, limit]
* properties:
* history:
* type: array
* items:
* type: object
* required: [id, date, sku, historytext]
* properties:
* id:
* type: integer
* date:
* type: string
* format: date-time
* sku:
* type: string
* name:
* type: string
* nullable: true
* historytext:
* type: string
* total:
* type: integer
* unfilteredTotal:
* type: integer
* offset:
* type: integer
* limit:
* type: integer
* 400:
* description: Validation error 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'
*/
// #region GET /article/history
app.get(`${basePath}/article/history`, async (req, res) => {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' });
}
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
let offset = 0;
let limit = 50;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
if (!Number.isInteger(offset) || offset < 0) {
return res.status(400).json({ error: 'offset must be an integer >= 0' });
}
}
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' });
}
}
const search = typeof req.query.search === 'string' && req.query.search.trim() !== ''
? req.query.search.trim()
: undefined;
let seekMonths = undefined;
if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') {
const parsedSeek = parseInt(req.query.seekMonths, 10);
if (!Number.isInteger(parsedSeek)) {
return res.status(400).json({ error: 'seekMonths must be an integer' });
}
seekMonths = parsedSeek;
}
try {
const result = await db.listHistory(req.user.id, {
offset,
limit,
search,
seekMonths,
});
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('List article history failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi * @openapi
* /article/{sku}: * /article/{sku}:
* get: * get:

View File

@@ -43,6 +43,9 @@ export function parseFullHash(value) {
export function articleErrorStatus(error) { export function articleErrorStatus(error) {
if (error.message === 'No company selected') return 400; if (error.message === 'No company selected') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Invalid seekMonths') return 400;
if (error.message === 'Unsupported image format' || error.message?.includes('unsupported image format')) return 400; if (error.message === 'Unsupported image format' || error.message?.includes('unsupported image format')) return 400;
if (error.message === 'Not authorized') return 403; if (error.message === 'Not authorized') return 403;
if (error.message === 'Parent not found') return 404; if (error.message === 'Parent not found') return 404;

View File

@@ -19,6 +19,8 @@ function entityErrorStatus(error) {
if (error.message === 'Name required') return 400; if (error.message === 'Name required') return 400;
if (error.message === 'Number required') return 400; if (error.message === 'Number required') return 400;
if (error.message === 'Number already in use') return 409; if (error.message === 'Number already in use') return 409;
if (error.message === 'Entity in use') return 409;
if (error.message === 'Supplier in use') return 409;
if (error.message === 'Invalid customergroupId') return 400; if (error.message === 'Invalid customergroupId') return 400;
if (error.message === 'Invalid role') return 400; if (error.message === 'Invalid role') return 400;
return 500; return 500;

View File

@@ -12,6 +12,9 @@ function requireSelectedCompany(req) {
function ledgerErrorStatus(error) { function ledgerErrorStatus(error) {
if (error.message === 'No company selected') return 400; if (error.message === 'No company selected') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Invalid seekMonths') return 400;
if (error.message === 'Not authorized') return 403; if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404; if (error.message === 'Company not found') return 404;
if (error.message === 'Manifest article not found') return 404; if (error.message === 'Manifest article not found') return 404;
@@ -281,4 +284,160 @@ export function register(app, basePath = '') {
}); });
// #endregion // #endregion
/**
* @openapi
* /ledger/history:
* get:
* tags: [Ledger]
* summary: Query company-wide ledger history
* description: |
* Queries ledger entries across all articles for the authenticated user's selected company.
* Supports pagination, search filter, and date cutoff via `seekMonths`.
* security:
* - cookieAuth: []
* x-markdown: ledger/history.md
* parameters:
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Paging offset
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* default: 50
* description: Page size
* - in: query
* name: search
* schema:
* type: string
* description: Optional search filter across SKU, article name, manifest number, external number, transaction type, and transaction text
* - in: query
* name: seekMonths
* schema:
* type: integer
* description: Negative integer cutoff months ago (e.g. -1)
* responses:
* 200:
* description: Paged ledger history list
* content:
* application/json:
* schema:
* type: object
* required: [ledger, total, unfilteredTotal, offset, limit]
* properties:
* ledger:
* type: array
* items:
* type: object
* required: [id, date, transaction_type, transaction_text]
* properties:
* id:
* type: integer
* date:
* type: string
* format: date-time
* sku:
* type: string
* nullable: true
* name:
* type: string
* nullable: true
* transaction_type:
* type: string
* transaction_text:
* type: string
* amount:
* type: number
* total:
* type: integer
* unfilteredTotal:
* type: integer
* offset:
* type: integer
* limit:
* type: integer
* 400:
* description: Validation error 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'
*/
// #region GET /ledger/history
app.get(`${basePath}/ledger/history`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
} }
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
let offset = 0;
let limit = 50;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
if (!Number.isInteger(offset) || offset < 0) {
return res.status(400).json({ error: 'offset must be an integer >= 0' });
}
}
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' });
}
}
const search = typeof req.query.search === 'string' && req.query.search.trim() !== ''
? req.query.search.trim()
: undefined;
let seekMonths = undefined;
if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') {
const parsedSeek = parseInt(req.query.seekMonths, 10);
if (!Number.isInteger(parsedSeek)) {
return res.status(400).json({ error: 'seekMonths must be an integer' });
}
seekMonths = parsedSeek;
}
try {
const result = await db.listHistory(req.user.id, {
offset,
limit,
search,
seekMonths,
});
res.status(200).json(result);
} catch (error) {
const status = ledgerErrorStatus(error);
if (status === 500) console.error('List ledger history failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -17,6 +17,7 @@ function supplierErrorStatus(error) {
if (error.message === 'Supplier not found') return 404; if (error.message === 'Supplier not found') return 404;
if (error.message === 'Supplier required') return 400; if (error.message === 'Supplier required') return 400;
if (error.message === 'Name required') return 400; if (error.message === 'Name required') return 400;
if (error.message === 'Supplier in use') return 409;
return 500; return 500;
} }
@@ -307,6 +308,12 @@ export function register(app, basePath = '') {
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
* 409:
* description: Supplier in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
// #region PATCH /supplier/:id // #region PATCH /supplier/:id

View File

@@ -15,6 +15,11 @@ export async function getHistoryBySku(googleAuthId, sku, opts) {
return store.article.getHistoryBySku(googleAuthId, sku, opts); return store.article.getHistoryBySku(googleAuthId, sku, opts);
} }
export async function listHistory(googleAuthId, opts) {
const store = await getStore();
return store.article.listHistory(googleAuthId, opts);
}
export async function updateBySku(googleAuthId, sku, data) { export async function updateBySku(googleAuthId, sku, data) {
const store = await getStore(); const store = await getStore();
return store.article.updateBySku(googleAuthId, sku, data); return store.article.updateBySku(googleAuthId, sku, data);

View File

@@ -26,3 +26,16 @@ export async function listBySku(googleAuthId, sku, transactionType) {
const store = await getStore(); const store = await getStore();
return store.ledger.listBySku(googleAuthId, sku, transactionType); return store.ledger.listBySku(googleAuthId, sku, transactionType);
} }
/**
* Lists ledger entries across all articles with pagination, seekMonths, and search.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [opts] - Query options.
* @returns {Promise<object>} Paged ledger history.
*/
export async function listHistory(googleAuthId, opts) {
const store = await getStore();
return store.ledger.listHistory(googleAuthId, opts);
}

View File

@@ -12,7 +12,7 @@ if the supplier entity does not exist, confirm with the user to create it.
finally call draft_delivery_manifest finally call draft_delivery_manifest
- manifestType: "rechnung" or "lieferschein". For "lieferschein", prices and taxes must be null. - manifestType: "rechnung" or "lieferschein". For "lieferschein", prices and taxes must be null.
- Exclude non-product lines (shipping, handling, etc.). - Exclude non-product lines (shipping, handling, packacking, etc.).
- Line items: Count, vpe (default: 1), liefArtNr, mwst (7 or 19), and eknetto per Item(euro cents). - Line items: Count, vpe (default: 1), liefArtNr, mwst (7 or 19), and eknetto per Item(euro cents).
- Match supplier against . For unknown suppliers, extract lieferantName and set lieferant_id to null. - Match supplier against . For unknown suppliers, extract lieferantName and set lieferant_id to null.
- Write notes strictly in German. - Write notes strictly in German.

View File

@@ -358,6 +358,7 @@ export async function executeCompanyExport({
'"attributes"', '"attributes"',
'"brandPictureFiles"', '"brandPictureFiles"',
'"pictureFiles"', '"pictureFiles"',
'"supplieer"',
'"desc"', '"desc"',
]; ];
const articleCsvRows = [articleCsvHeaders.join(';')]; const articleCsvRows = [articleCsvHeaders.join(';')];
@@ -415,6 +416,21 @@ export async function executeCompanyExport({
// Categories // Categories
const catStr = Array.isArray(a.categoryIds) ? a.categoryIds.join(';') : ''; const catStr = Array.isArray(a.categoryIds) ? a.categoryIds.join(';') : '';
// Suppliers: <supplier_name>:<supplier_sku>:<vpe>:<tax_rate>:<net_purchase_price> separated by |
let supplierStr = '';
if (Array.isArray(a.suppliers) && a.suppliers.length > 0) {
supplierStr = a.suppliers
.map((s) => {
const sName = String(s.supplierName || '').replace(/[:|]/g, ' ').trim();
const sSku = String(s.sku || '').replace(/[:|]/g, ' ').trim();
const vpe = (s.pu != null && Number(s.pu) > 1) ? Number(s.pu) : 0;
const sTaxRate = s.price?.taxRate != null ? Number(s.price.taxRate) : 0;
const netPrice = Number(s.price?.net || 0).toFixed(2);
return `${sName}:${sSku}:${vpe}:${sTaxRate}:${netPrice}`;
})
.join('|');
}
const row = [ const row = [
formatCsvField(a.id), formatCsvField(a.id),
formatCsvStringFormula(a.sku), formatCsvStringFormula(a.sku),
@@ -436,6 +452,7 @@ export async function executeCompanyExport({
formatCsvField(pfandAttr), formatCsvField(pfandAttr),
formatCsvField(brandPicRel), formatCsvField(brandPicRel),
formatCsvField(picPaths.join(';')), formatCsvField(picPaths.join(';')),
formatCsvField(supplierStr),
formatCsvField(a.desc || ''), formatCsvField(a.desc || ''),
]; ];
articleCsvRows.push(row.join(';')); articleCsvRows.push(row.join(';'));

View File

@@ -5,7 +5,7 @@ import path from 'path';
import { getStore } from '../db/store.js'; import { getStore } from '../db/store.js';
import { createLogger } from '../util/logger.js'; import { createLogger } from '../util/logger.js';
import { cleanValue, parseGermanFloat, parseCsvRecords } from './companyImport/csvParser.js'; import { cleanValue, parseGermanFloat, parseCsvRecords, parseSuppliers } from './companyImport/csvParser.js';
import { computeSkeleHash } from './companyImport/imageHasher.js'; import { computeSkeleHash } from './companyImport/imageHasher.js';
import { normalizeAttributeName, extractVariantValue } from './companyImport/variantHelpers.js'; import { normalizeAttributeName, extractVariantValue } from './companyImport/variantHelpers.js';
import { createProgressTracker } from './companyImport/progressTracker.js'; import { createProgressTracker } from './companyImport/progressTracker.js';
@@ -25,6 +25,7 @@ export {
cleanValue, cleanValue,
parseGermanFloat, parseGermanFloat,
parseCsvRecords, parseCsvRecords,
parseSuppliers,
computeSkeleHash, computeSkeleHash,
normalizeAttributeName, normalizeAttributeName,
extractVariantValue, extractVariantValue,
@@ -208,6 +209,7 @@ export async function executeCompanyImport({
pfandArticlesCreated: articlesResult.pfandArticlesCreated, pfandArticlesCreated: articlesResult.pfandArticlesCreated,
pfandArticlesUpdated: articlesResult.pfandArticlesUpdated, pfandArticlesUpdated: articlesResult.pfandArticlesUpdated,
pfandOptionsLinked: articlesResult.pfandOptionsLinked, pfandOptionsLinked: articlesResult.pfandOptionsLinked,
suppliersLinked: articlesResult.suppliersLinked || 0,
articlePictures: articlesResult.totalImagesImported, articlePictures: articlesResult.totalImagesImported,
ordersTotal: totalReceiptsCount, ordersTotal: totalReceiptsCount,
ordersImported: ordersResult.ordersImported, ordersImported: ordersResult.ordersImported,

View File

@@ -1,7 +1,7 @@
// MARK: - Imports // MARK: - Imports
// #region Imports // #region Imports
import fs from 'fs'; import fs from 'fs';
import { cleanValue, parseGermanFloat } from './csvParser.js'; import { cleanValue, parseGermanFloat, parseSuppliers } from './csvParser.js';
import { setupVariantAttributes } from './variantImporter.js'; import { setupVariantAttributes } from './variantImporter.js';
import { setupPfandArticles, linkArticlePfand, extractPfandValue } from './pfandImporter.js'; import { setupPfandArticles, linkArticlePfand, extractPfandValue } from './pfandImporter.js';
import { computeSkeleHash } from './imageHasher.js'; import { computeSkeleHash } from './imageHasher.js';
@@ -70,6 +70,34 @@ export async function importArticles({
// 3. Setup variant attributes resolver // 3. Setup variant attributes resolver
const variantSetup = await setupVariantAttributes({ store, googleAuthId }); const variantSetup = await setupVariantAttributes({ store, googleAuthId });
// 3b. Setup suppliers lookup and helper
const supplierByName = new Map();
try {
const existingSuppliers = await store.supplier.list(googleAuthId);
for (const s of existingSuppliers || []) {
if (s.name) supplierByName.set(s.name.trim().toLowerCase(), s);
}
} catch (sErr) {
log.warn('Could not list suppliers upfront:', sErr.message);
}
async function getOrCreateSupplier(name) {
const cleaned = (name || '').trim();
if (!cleaned) return null;
const key = cleaned.toLowerCase();
if (supplierByName.has(key)) {
return supplierByName.get(key);
}
try {
const created = await store.supplier.create(googleAuthId, { name: cleaned });
supplierByName.set(key, created);
return created;
} catch (err) {
log.warn(`Could not create supplier "${cleaned}":`, err.message);
return null;
}
}
// 4. Pre-scan articles to identify parents and map parent Pfand / parent rows // 4. Pre-scan articles to identify parents and map parent Pfand / parent rows
const parentJtlIds = new Set(); const parentJtlIds = new Set();
const parentPfandMap = new Map(); const parentPfandMap = new Map();
@@ -124,6 +152,7 @@ export async function importArticles({
let articlesUpdated = 0; let articlesUpdated = 0;
let variantsLinked = 0; let variantsLinked = 0;
let pfandOptionsLinked = 0; let pfandOptionsLinked = 0;
let suppliersLinked = 0;
let articlePicturesImported = 0; let articlePicturesImported = 0;
// 6. Unified single loop over all articles // 6. Unified single loop over all articles
@@ -344,6 +373,82 @@ export async function importArticles({
} }
} }
// --- Suppliers handling (only for non-parents) ---
if (!isParent) {
const rawSuppliers = cleanValue(a.supplieer ?? a.supplier);
const parsedSuppliers = parseSuppliers(rawSuppliers);
if (parsedSuppliers.length > 0) {
const existingByEntityId = new Map();
for (const asup of (existingArticle?.suppliers || [])) {
if (asup.entityId != null) {
existingByEntityId.set(asup.entityId, asup);
}
}
const assignedIds = [];
for (let i = 0; i < parsedSuppliers.length; i++) {
const sup = parsedSuppliers[i];
try {
const supplierEntity = await getOrCreateSupplier(sup.supplierName);
if (!supplierEntity?.id) continue;
const pu = sup.vpe > 0 ? sup.vpe : 1;
const netPrice = sup.netPurchasePrice;
const taxRate = sup.taxRate;
const sSku = sup.supplierSku || null;
const existingAssoc = existingByEntityId.get(supplierEntity.id);
if (existingAssoc) {
await store.articlesupplier.update(googleAuthId, sku, existingAssoc.id, {
sku: sSku,
pu,
netPrice,
taxRate,
});
assignedIds.push(existingAssoc.id);
} else {
const created = await store.articlesupplier.create(googleAuthId, sku, {
entityId: supplierEntity.id,
sku: sSku,
pu,
netPrice,
taxRate,
});
assignedIds.push(created.id);
}
} catch (supErr) {
log.warn(`Failed to process supplier "${sup.supplierName}" for ${sku}:`, supErr.message);
}
}
// Preserve primary / secondary ordering
if (assignedIds.length > 0) {
try {
const allExisting = new Map();
for (const asup of (existingArticle?.suppliers || [])) {
allExisting.set(asup.id, asup);
}
for (const id of assignedIds) {
allExisting.set(id, true);
}
const orderIds = [...assignedIds];
for (const id of allExisting.keys()) {
if (!orderIds.includes(id)) {
orderIds.push(id);
}
}
if (orderIds.length > 1) {
await store.articlesupplier.order(googleAuthId, sku, { ids: orderIds });
}
suppliersLinked += assignedIds.length;
} catch (orderErr) {
log.warn(`Failed to order suppliers for ${sku}:`, orderErr.message);
}
}
}
}
// --- Pictures handling for this article --- // --- Pictures handling for this article ---
const imagesForThisArt = articleImagesBySku.get(sku); const imagesForThisArt = articleImagesBySku.get(sku);
if (imagesForThisArt && imagesForThisArt.length > 0) { if (imagesForThisArt && imagesForThisArt.length > 0) {
@@ -369,7 +474,7 @@ export async function importArticles({
progressTracker.flushSkeleHashes(); progressTracker.flushSkeleHashes();
progressTracker.emitProgress(true); progressTracker.emitProgress(true);
log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}, Variants: ${variantsLinked}, Pfand: ${pfandOptionsLinked}, Pictures: ${articlePicturesImported}`); log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}, Variants: ${variantsLinked}, Pfand: ${pfandOptionsLinked}, Suppliers: ${suppliersLinked}, Pictures: ${articlePicturesImported}`);
return { return {
jtlArtIdToSku, jtlArtIdToSku,
@@ -381,6 +486,7 @@ export async function importArticles({
pfandArticlesCreated: pfandSetup.pfandArticlesCreated, pfandArticlesCreated: pfandSetup.pfandArticlesCreated,
pfandArticlesUpdated: pfandSetup.pfandArticlesUpdated, pfandArticlesUpdated: pfandSetup.pfandArticlesUpdated,
pfandOptionsLinked, pfandOptionsLinked,
suppliersLinked,
totalImagesImported: articlePicturesImported, totalImagesImported: articlePicturesImported,
}; };
} }

View File

@@ -125,4 +125,31 @@ export function parseCsvRecords(text) {
return records; return records;
} }
/**
* Parses the pipe/colon delimited supplieer column into structured supplier objects.
* Format: <supplier_name>:<supplier_sku>:<vpe>:<tax_rate>:<net_purchase_price>
* Delimited by '|' between suppliers and ':' between fields.
* The first entry is always the primary / standard supplier.
*
* @param {string} cellValue
* @returns {Array<{ isPrimary: boolean, supplierName: string, supplierSku: string, vpe: number, taxRate: number, netPurchasePrice: number }>}
*/
export function parseSuppliers(cellValue) {
if (!cellValue || typeof cellValue !== 'string' || cellValue.trim() === '') return [];
return cellValue.split('|').map((entry, index) => {
const [supplierName, supplierSku, vpe, taxRate, netPrice] = entry.split(':');
return {
isPrimary: index === 0, // First entry is always the standard supplier
supplierName: (supplierName || '').trim(),
supplierSku: (supplierSku || '').trim(),
vpe: Number(vpe) || 0,
taxRate: Number(taxRate) || 0,
netPurchasePrice: Number(netPrice) || 0,
};
}).filter((s) => s.supplierName);
}
// #endregion // #endregion

View File

@@ -1,9 +1,74 @@
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import { createCanvas } from 'canvas';
import JsBarcode from 'jsbarcode';
import sharp from 'sharp';
import { getStore } from '../db/store.js'; import { getStore } from '../db/store.js';
import * as dbOrder from '../db/order.js'; import * as dbOrder from '../db/order.js';
import { emit } from '../api/sse.js'; import { emit } from '../api/sse.js';
import { formatAttachment } from '../util/manifestAttachment.js'; import { formatAttachment } from '../util/manifestAttachment.js';
/**
* Generates a PNG barcode buffer for embedding in PDFDocument.
*
* @param {string} text
* @param {'CODE128'|'EAN13'|'EAN8'} [preferredFormat='CODE128']
* @param {object} [options]
* @returns {Buffer|null}
*/
export function generateBarcodePng(text, preferredFormat = 'CODE128', options = {}) {
if (!text) return null;
const str = String(text).trim();
if (!str) return null;
const width = options.width || 120;
const height = options.height || 36;
try {
const canvas = createCanvas(width, height);
JsBarcode(canvas, str, {
format: preferredFormat,
displayValue: options.displayValue ?? true,
fontSize: options.fontSize || 9,
textMargin: options.textMargin || 1,
margin: options.margin || 2,
});
return canvas.toBuffer('image/png');
} catch (e1) {
if (preferredFormat !== 'CODE128') {
try {
const canvas = createCanvas(width, height);
JsBarcode(canvas, str, {
format: 'CODE128',
displayValue: options.displayValue ?? true,
fontSize: options.fontSize || 9,
textMargin: options.textMargin || 1,
margin: options.margin || 2,
});
return canvas.toBuffer('image/png');
} catch (e2) {
return null;
}
}
return null;
}
}
async function resolveThumbPng(store, googleAuthId, picHash, smallthumbbinary) {
if (smallthumbbinary) {
try {
return await sharp(smallthumbbinary).png().toBuffer();
} catch (_) { }
}
if (picHash) {
try {
const thumb = await store.pic.getThumbByHash(googleAuthId, picHash).catch(() => null);
if (thumb?.buffer) {
return await sharp(thumb.buffer).png().toBuffer();
}
} catch (_) { }
}
return null;
}
/** /**
* Formats a Date or date string to German format DD.MM.YYYY. * Formats a Date or date string to German format DD.MM.YYYY.
* @param {Date|string} dateVal * @param {Date|string} dateVal
@@ -65,7 +130,7 @@ const TYPE_CONFIG = {
numberPrefix: 'Auftrags-Nr.', numberPrefix: 'Auftrags-Nr.',
showPrices: true, showPrices: true,
filenamePrefix: 'auftrag', filenamePrefix: 'auftrag',
introText: 'Wir bestätigen dankend die Annahme Ihres Auftrags zu folgenden Positionen:', introText: '',
closingText: 'Vielen Dank für Ihren Auftrag!', closingText: 'Vielen Dank für Ihren Auftrag!',
defaultSubject: 'Auftragsbestätigung', defaultSubject: 'Auftragsbestätigung',
}, },
@@ -75,7 +140,7 @@ const TYPE_CONFIG = {
showPrices: false, showPrices: false,
filenamePrefix: 'lieferschein', filenamePrefix: 'lieferschein',
introText: 'Mit dieser Sendung liefern wir Ihnen folgende Artikel:', 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.', closingText: '',
defaultSubject: 'Lieferschein', defaultSubject: 'Lieferschein',
}, },
delivery: { delivery: {
@@ -83,8 +148,8 @@ const TYPE_CONFIG = {
numberPrefix: 'Beleg-Nr.', numberPrefix: 'Beleg-Nr.',
showPrices: false, showPrices: false,
filenamePrefix: 'wareneingang', filenamePrefix: 'wareneingang',
introText: 'Wareneingangsdokumentation zu folgenden Positionen:', introText: '',
closingText: 'Wareneingang ordnungsgemäß im Lager erfasst.', closingText: '',
defaultSubject: 'Wareneingang', defaultSubject: 'Wareneingang',
}, },
}; };
@@ -183,6 +248,12 @@ export function generateManifestPdf({
.fillColor(secondaryColor) .fillColor(secondaryColor)
.text(config.docTitle, 350, y, { align: 'right', width: 200 }); .text(config.docTitle, 350, y, { align: 'right', width: 200 });
if (type === 'delivery') {
try {
doc.initForm();
} catch (_) { }
}
y += 24; y += 24;
// Company subline // Company subline
@@ -337,6 +408,8 @@ export function generateManifestPdf({
// ---------------------------------------------------- // ----------------------------------------------------
const showPrices = config.showPrices; const showPrices = config.showPrices;
const isDelivery = type === 'delivery';
// Column setups // Column setups
const colXPrices = { pos: 45, sku: 70, name: 150, amount: 335, gross: 385, tax: 450, total: 482 }; const colXPrices = { pos: 45, sku: 70, name: 150, amount: 335, gross: 385, tax: 450, total: 482 };
const colWidthPrices = { pos: 22, sku: 75, name: 180, amount: 45, gross: 60, tax: 30, total: 65 }; const colWidthPrices = { pos: 22, sku: 75, name: 180, amount: 45, gross: 60, tax: 30, total: 65 };
@@ -344,16 +417,41 @@ export function generateManifestPdf({
const colXNoPrices = { pos: 45, sku: 80, name: 185, amount: 455 }; const colXNoPrices = { pos: 45, sku: 80, name: 185, amount: 455 };
const colWidthNoPrices = { pos: 30, sku: 100, name: 265, amount: 92 }; const colWidthNoPrices = { pos: 30, sku: 100, name: 265, amount: 92 };
// Delivery columns (Wareneingang)
const colDeliv = {
pos: 45, posW: 20,
thumb: 67, thumbW: 30,
suppSku: 99, suppSkuW: 52,
ownSku: 153, ownSkuW: 92,
ean: 247, eanW: 78,
name: 327, nameW: 81,
check: 410, checkW: 18,
deliv: 428, delivW: 40,
added: 468, addedW: 42,
stock: 510, stockW: 38,
};
const drawTableHeader = (atY) => { const drawTableHeader = (atY) => {
doc.rect(45, atY, 505, 20) doc.rect(45, atY, 505, 20)
.fillColor(primaryColor) .fillColor(primaryColor)
.fill(); .fill();
doc.font('Helvetica-Bold') doc.font('Helvetica-Bold')
.fontSize(8) .fontSize(7.5)
.fillColor('#ffffff'); .fillColor('#ffffff');
if (showPrices) { if (isDelivery) {
doc.text('Pos', colDeliv.pos, atY + 6, { width: colDeliv.posW, align: 'center' });
doc.text('Bild', colDeliv.thumb, atY + 6, { width: colDeliv.thumbW, align: 'center' });
doc.text('Lief.-SKU', colDeliv.suppSku, atY + 6, { width: colDeliv.suppSkuW });
doc.text('Eigene SKU', colDeliv.ownSku, atY + 6, { width: colDeliv.ownSkuW });
doc.text('EAN', colDeliv.ean, atY + 6, { width: colDeliv.eanW });
doc.text('Bezeichnung', colDeliv.name, atY + 6, { width: colDeliv.nameW });
doc.text('Geliefert', colDeliv.deliv, atY + 6, { width: colDeliv.delivW, align: 'right' });
doc.text('Gebucht', colDeliv.added, atY + 6, { width: colDeliv.addedW, align: 'right' });
doc.text('Bestand', colDeliv.stock, atY + 6, { width: colDeliv.stockW, align: 'right' });
} else if (showPrices) {
doc.fontSize(8);
doc.text('Pos', colXPrices.pos + 3, atY + 6, { width: colWidthPrices.pos }); doc.text('Pos', colXPrices.pos + 3, atY + 6, { width: colWidthPrices.pos });
doc.text('Art.-Nr.', colXPrices.sku, atY + 6, { width: colWidthPrices.sku }); doc.text('Art.-Nr.', colXPrices.sku, atY + 6, { width: colWidthPrices.sku });
doc.text('Bezeichnung', colXPrices.name, atY + 6, { width: colWidthPrices.name }); doc.text('Bezeichnung', colXPrices.name, atY + 6, { width: colWidthPrices.name });
@@ -362,6 +460,7 @@ export function generateManifestPdf({
doc.text('MwSt', colXPrices.tax, atY + 6, { width: colWidthPrices.tax, align: 'right' }); doc.text('MwSt', colXPrices.tax, atY + 6, { width: colWidthPrices.tax, align: 'right' });
doc.text('Gesamt Brutto', colXPrices.total, atY + 6, { width: colWidthPrices.total, align: 'right' }); doc.text('Gesamt Brutto', colXPrices.total, atY + 6, { width: colWidthPrices.total, align: 'right' });
} else { } else {
doc.fontSize(8);
doc.text('Pos', colXNoPrices.pos + 3, atY + 6, { width: colWidthNoPrices.pos }); doc.text('Pos', colXNoPrices.pos + 3, atY + 6, { width: colWidthNoPrices.pos });
doc.text('Art.-Nr.', colXNoPrices.sku, atY + 6, { width: colWidthNoPrices.sku }); doc.text('Art.-Nr.', colXNoPrices.sku, atY + 6, { width: colWidthNoPrices.sku });
doc.text('Bezeichnung', colXNoPrices.name, atY + 6, { width: colWidthNoPrices.name }); doc.text('Bezeichnung', colXNoPrices.name, atY + 6, { width: colWidthNoPrices.name });
@@ -409,7 +508,131 @@ export function generateManifestPdf({
const nameWidth = showPrices ? colWidthPrices.name : colWidthNoPrices.name; const nameWidth = showPrices ? colWidthPrices.name : colWidthNoPrices.name;
const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : ''; const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : '';
const nameText = (line.name || 'Position') + puSuffix; const ownName = (line.ownArticleName || line.articleName || '').trim();
const baseName = (isDelivery && line.ownSku && ownName)
? ownName
: (line.name || 'Position');
const nameText = baseName + puSuffix;
if (isDelivery) {
const rowHeight = 38;
if (y + rowHeight > 730) {
doc.addPage();
y = drawTableHeader(45);
}
if (index % 2 === 1) {
doc.rect(45, y - 2, 505, rowHeight)
.fillColor(lightBg)
.fill();
}
const centerY = y + 12;
// 1. Pos
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(textColor)
.text(String(index + 1), colDeliv.pos, centerY, { width: colDeliv.posW, align: 'center' });
// 2. Thumbpic
if (line.thumbPng) {
try {
doc.image(line.thumbPng, colDeliv.thumb + 2, y + 4, {
fit: [26, 26],
align: 'center',
valign: 'center',
});
} catch (_) {
doc.roundedRect(colDeliv.thumb + 2, y + 4, 26, 26, 2)
.fillColor('#f1f5f9').fill()
.strokeColor('#cbd5e1').lineWidth(0.5).stroke();
}
} else {
doc.roundedRect(colDeliv.thumb + 2, y + 4, 26, 26, 2)
.fillColor('#f1f5f9').fill()
.strokeColor('#cbd5e1').lineWidth(0.5).stroke();
doc.font('Helvetica').fontSize(6).fillColor(mutedColor).text('Kein Bild', colDeliv.thumb + 2, y + 14, { width: 26, align: 'center' });
}
// 3. Supplier SKU
doc.font('Helvetica').fontSize(7.5).fillColor(textColor)
.text(line.supplierSku || '–', colDeliv.suppSku, y + 12, { width: colDeliv.suppSkuW, ellipsis: true });
// 4. Eigene SKU as text + Code128 Barcode
const ownSkuText = line.ownSku || '–';
doc.font('Helvetica-Bold').fontSize(7).fillColor(primaryColor)
.text(ownSkuText, colDeliv.ownSku, y + 2, { width: colDeliv.ownSkuW, ellipsis: true });
if (line.ownSkuBarcode) {
try {
doc.image(line.ownSkuBarcode, colDeliv.ownSku, y + 12, { width: 88, height: 20 });
} catch (_) { }
}
// 5. EAN as barcode
if (line.ean) {
doc.font('Helvetica').fontSize(6.5).fillColor(mutedColor)
.text(line.ean, colDeliv.ean, y + 2, { width: colDeliv.eanW, ellipsis: true });
if (line.eanBarcode) {
try {
doc.image(line.eanBarcode, colDeliv.ean, y + 12, { width: 76, height: 20 });
} catch (_) { }
}
} else {
doc.font('Helvetica').fontSize(8).fillColor(mutedColor)
.text('–', colDeliv.ean, y + 12, { width: colDeliv.eanW, align: 'center' });
}
// 6. Name (own article name if own SKU is connected)
doc.font('Helvetica').fontSize(7.5).fillColor(textColor)
.text(nameText, colDeliv.name, y + 6, { width: colDeliv.nameW });
// 7. Checkbox (interactive PDF form field, checkable in PDF Reader)
doc.rect(colDeliv.check + 3, centerY - 1, 12, 12)
.strokeColor('#64748b')
.lineWidth(0.8)
.stroke();
try {
const checkFieldName = `check_${line.id ? line.id : index + 1}`;
doc.formCheckbox(checkFieldName, colDeliv.check + 3, centerY - 1, 12, 12, {
borderColor: '#64748b',
backgroundColor: '#ffffff',
});
const lastAnnot = doc.page.annotations[doc.page.annotations.length - 1];
if (lastAnnot?.data) {
lastAnnot.data.AS = 'Off';
lastAnnot.data.V = 'Off';
}
} catch (_) { }
// 8. Menge geliefert
doc.font('Helvetica-Bold').fontSize(8).fillColor(textColor)
.text(formatQuantity(amount) + ' Stk', colDeliv.deliv, y + 12, { width: colDeliv.delivW, align: 'right' });
// 9. Menge added / gebucht (swapped before bestand)
const hasBooked = line.trackStock && line.addedQuantity != null && line.addedQuantity > 0;
const bookedText = line.trackStock
? (line.addedQuantity != null && line.addedQuantity > 0 ? `+${formatQuantity(line.addedQuantity)} Stk` : (line.addedQuantity === 0 ? '0 Stk' : '–'))
: '–';
doc.font('Helvetica-Bold').fontSize(8).fillColor(hasBooked ? '#16a34a' : mutedColor)
.text(bookedText, colDeliv.added, y + 12, { width: colDeliv.addedW, align: 'right' });
// 10. Menge already in warehouse (bestand)
const stockText = line.trackStock ? (formatQuantity(line.currentStock ?? 0) + ' Stk') : '–';
doc.font('Helvetica').fontSize(7.5).fillColor(line.trackStock ? textColor : mutedColor)
.text(stockText, colDeliv.stock, y + 12, { width: colDeliv.stockW, align: 'right' });
// Row separator
doc.moveTo(45, y + rowHeight - 2)
.lineTo(550, y + rowHeight - 2)
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
y += rowHeight;
return;
}
// Measure actual rendered text heights to prevent overlaps // Measure actual rendered text heights to prevent overlaps
doc.font('Helvetica').fontSize(8.5); doc.font('Helvetica').fontSize(8.5);
@@ -623,6 +846,33 @@ export function generateManifestPdf({
y += 16; y += 16;
} }
} }
} else if (isDelivery) {
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();
const totalBooked = lines.reduce((sum, l) => sum + Number(l.addedQuantity || 0), 0);
const summaryText = `${lines.length} Positionen • Geliefert: ${formatQuantity(totalQty)} Stk • Ins Lager gebucht: ${formatQuantity(totalBooked)} Stk`;
doc.font('Helvetica-Bold')
.fontSize(9)
.fillColor(textColor)
.text(summaryText, 55, y + 9, {
width: 485,
align: 'right',
});
y += 45;
} else { } else {
// Non-price summary (total quantity and positions count) // Non-price summary (total quantity and positions count)
if (y + 50 > 750) { if (y + 50 > 750) {
@@ -747,6 +997,108 @@ export async function createAndAttachManifestPdf(googleAuthId, companyId, manife
const partnerId = manifest.entityId ?? manifest.supplierId ?? null; const partnerId = manifest.entityId ?? manifest.supplierId ?? null;
if (type === 'delivery' && Array.isArray(manifest.lines)) {
const isSqlite = store.provider?.dialect === 'sqlite3';
manifest.lines = await Promise.all(
manifest.lines.map(async (line) => {
const rawSku = line.sku ? String(line.sku).trim() : '';
let ownSku = '';
let ownArticleName = '';
let supplierSku = '';
let ean = '';
let trackStock = false;
let currentStock = null;
let addedQuantity = null;
let thumbPng = null;
let articleId = null;
if (rawSku) {
const findSql = isSqlite
? `SELECT a.id, a.sku, a.track_stock,
(SELECT at.name FROM articletext at WHERE at.article_id = a.id ORDER BY CASE WHEN at.language_id = (SELECT default_language_id FROM company WHERE id = @companyId) THEN 0 ELSE 1 END, at.id ASC LIMIT 1) AS article_name,
(SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = a.id AND (asup.entity_id = @partnerId OR @partnerId IS NULL) LIMIT 1) AS supplier_sku,
(SELECT ae.ean FROM articleean ae WHERE ae.article_id = a.id ORDER BY ae.id ASC LIMIT 1) AS ean,
(SELECT p.hash FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC LIMIT 1) AS pic_hash,
(SELECT p.smallthumbbinary FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC LIMIT 1) AS smallthumbbinary
FROM article a
WHERE a.company_id = @companyId
AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))
LIMIT 1`
: `SELECT TOP 1 a.id, a.sku, a.track_stock,
(SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id ORDER BY CASE WHEN at.language_id = (SELECT default_language_id FROM company WHERE id = @companyId) THEN 0 ELSE 1 END, at.id ASC) AS article_name,
(SELECT TOP 1 asup.sku FROM articlesupplier asup WHERE asup.article_id = a.id AND (asup.entity_id = @partnerId OR @partnerId IS NULL)) AS supplier_sku,
(SELECT TOP 1 ae.ean FROM articleean ae WHERE ae.article_id = a.id ORDER BY ae.id ASC) AS ean,
(SELECT TOP 1 p.hash FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC) AS pic_hash,
(SELECT TOP 1 p.smallthumbbinary FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC) AS smallthumbbinary
FROM article a
WHERE a.company_id = @companyId
AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))`;
const artRes = await store.provider.query(findSql, { companyId, sku: rawSku, partnerId }).catch(() => ({ rows: [] }));
const art = artRes.rows?.[0];
if (art) {
articleId = art.id;
ownSku = art.sku || rawSku;
ownArticleName = (art.article_name || art.name || '').trim();
supplierSku = art.supplier_sku || (rawSku !== art.sku ? rawSku : '');
ean = art.ean || '';
trackStock = Number(art.track_stock ?? 0) === 1;
thumbPng = await resolveThumbPng(store, googleAuthId, art.pic_hash, art.smallthumbbinary);
if (trackStock) {
const stockSql = `SELECT COALESCE(SUM(l.amount), 0) AS stock
FROM ledger l
WHERE l.warehouse_id IS NOT NULL
AND l.manifestarticle_id IN (
SELECT ma.id FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.company_id = @companyId
AND (ma.sku = @ownSku OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId))
)`;
const stockRes = await store.provider.query(stockSql, { companyId, ownSku, articleId }).catch(() => ({ rows: [] }));
currentStock = Number(stockRes.rows?.[0]?.stock ?? 0);
}
} else {
supplierSku = rawSku;
}
}
if (line.id) {
const ledgerSql = `SELECT COALESCE(SUM(l.amount), 0) AS added_amount,
COUNT(CASE WHEN l.warehouse_id IS NOT NULL THEN 1 END) AS has_warehouse
FROM ledger l
WHERE l.manifestarticle_id = @lineId
AND l.transaction_type IN ('WAREHOUSE', 'ADJUSTMENT')`;
const ledgerRes = await store.provider.query(ledgerSql, { lineId: line.id }).catch(() => ({ rows: [] }));
const lRow = ledgerRes.rows?.[0];
if (lRow && Number(lRow.has_warehouse || 0) > 0) {
addedQuantity = Number(lRow.added_amount || 0);
}
}
const ownSkuBarcode = ownSku ? generateBarcodePng(ownSku, 'CODE128', { width: 110, height: 26, displayValue: false }) : null;
const eanBarcode = ean ? generateBarcodePng(ean, 'EAN13', { width: 100, height: 26, displayValue: false }) : null;
return {
...line,
ownSku,
ownArticleName,
articleName: ownArticleName,
supplierSku,
ean,
trackStock,
currentStock,
addedQuantity,
thumbPng,
ownSkuBarcode,
eanBarcode,
};
})
);
}
const [company, settings, partner, addresses] = await Promise.all([ const [company, settings, partner, addresses] = await Promise.all([
store.company.getById(companyId).catch(() => null), store.company.getById(companyId).catch(() => null),
store.companysetting.list(googleAuthId, { frontendOnly: false, maskWriteonly: false }).catch(() => []), store.companysetting.list(googleAuthId, { frontendOnly: false, maskWriteonly: false }).catch(() => []),