Compare commits
9 Commits
7197a6ddb7
...
a01b42b24b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a01b42b24b | ||
|
|
04943fb7a6 | ||
|
|
e085e064ee | ||
|
|
df846f4f06 | ||
|
|
4bd977ed12 | ||
|
|
1778815a1e | ||
|
|
856692b66c | ||
|
|
5e9b168e0d | ||
|
|
bee21d2c20 |
File diff suppressed because one or more lines are too long
1780
api-graph.svg
1780
api-graph.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 330 KiB |
@@ -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/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/history-list.md`](routes/article/history-list.md) | `GET /article/history` |
|
||||
| [`routes/article/delete.md`](routes/article/delete.md) | `DELETE /article/{sku}` |
|
||||
| [`routes/article/get.md`](routes/article/get.md) | `GET /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) | `PUT /labeltemplate/{id}` |
|
||||
| [`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/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` |
|
||||
| [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 261 API endpoints across all modules.
|
||||
Overview of all 263 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -83,6 +83,9 @@ _none_
|
||||
#### Export articles as ZIP — `GET /article/export.zip`
|
||||
- Params: `query: categoryId?: int`
|
||||
- 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}`
|
||||
- Params: `sku: str, query: cascade?: bool`
|
||||
- Returns: `204 void`
|
||||
@@ -316,7 +319,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?: 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`
|
||||
- Params: `id: int`
|
||||
- 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`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- 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}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 { ledger: T15[] }`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview
|
||||
|
||||
Overview of all 186 API endpoints across all modules.
|
||||
Overview of all 188 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -50,6 +50,9 @@ 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 { 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}`
|
||||
- Params: `sku: str, query: cascade?: bool`
|
||||
- Returns: `204 void`
|
||||
@@ -250,7 +253,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?: 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`
|
||||
- Params: `id: int`
|
||||
- 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`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- 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}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 { ledger: T11[] }`
|
||||
|
||||
52
docs/api/routes/article/history-list.md
Normal file
52
docs/api/routes/article/history-list.md
Normal 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`._
|
||||
@@ -43,7 +43,25 @@ Articles list
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…"
|
||||
},
|
||||
"articlewarehousesummary": [
|
||||
{
|
||||
"id": 1,
|
||||
"articleId": 1,
|
||||
"article_id": 1,
|
||||
"warehouseId": 1,
|
||||
"warehouse_id": 1,
|
||||
"warehouseName": "…",
|
||||
"stock": 1
|
||||
}
|
||||
],
|
||||
"warehouses": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "…",
|
||||
"stock": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
54
docs/api/routes/ledger/history.md
Normal file
54
docs/api/routes/ledger/history.md
Normal 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`._
|
||||
@@ -28,6 +28,7 @@ _No content_
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Supplier not found — `{"error":"…"}` |
|
||||
| 409 | Supplier in use — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -576,6 +576,42 @@
|
||||
* brand:
|
||||
* $ref: '#/components/schemas/ArticleBrand'
|
||||
* 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:
|
||||
* type: object
|
||||
* required: [id, type, prefix, padding, next]
|
||||
|
||||
@@ -257,7 +257,155 @@ export function register(app, basePath = '') {
|
||||
|
||||
/**
|
||||
* @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
|
||||
* /article/{sku}:
|
||||
* get:
|
||||
|
||||
@@ -43,6 +43,9 @@ export function parseFullHash(value) {
|
||||
|
||||
export function articleErrorStatus(error) {
|
||||
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 === 'Not authorized') return 403;
|
||||
if (error.message === 'Parent not found') return 404;
|
||||
|
||||
@@ -19,6 +19,8 @@ function entityErrorStatus(error) {
|
||||
if (error.message === 'Name required') return 400;
|
||||
if (error.message === 'Number required') return 400;
|
||||
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 role') return 400;
|
||||
return 500;
|
||||
|
||||
@@ -12,6 +12,9 @@ function requireSelectedCompany(req) {
|
||||
|
||||
function ledgerErrorStatus(error) {
|
||||
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 === 'Company not found') return 404;
|
||||
if (error.message === 'Manifest article not found') return 404;
|
||||
@@ -281,4 +284,160 @@ export function register(app, basePath = '') {
|
||||
});
|
||||
|
||||
// #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
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ function supplierErrorStatus(error) {
|
||||
if (error.message === 'Supplier not found') return 404;
|
||||
if (error.message === 'Supplier required') return 400;
|
||||
if (error.message === 'Name required') return 400;
|
||||
if (error.message === 'Supplier in use') return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
@@ -307,6 +308,12 @@ export function register(app, basePath = '') {
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Supplier in use
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region PATCH /supplier/:id
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ export async function 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) {
|
||||
const store = await getStore();
|
||||
return store.article.updateBySku(googleAuthId, sku, data);
|
||||
|
||||
@@ -26,3 +26,16 @@ export async function listBySku(googleAuthId, sku, transactionType) {
|
||||
const store = await getStore();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ if the supplier entity does not exist, confirm with the user to create it.
|
||||
finally call draft_delivery_manifest
|
||||
|
||||
- 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).
|
||||
- Match supplier against . For unknown suppliers, extract lieferantName and set lieferant_id to null.
|
||||
- Write notes strictly in German.
|
||||
|
||||
@@ -358,6 +358,7 @@ export async function executeCompanyExport({
|
||||
'"attributes"',
|
||||
'"brandPictureFiles"',
|
||||
'"pictureFiles"',
|
||||
'"supplieer"',
|
||||
'"desc"',
|
||||
];
|
||||
const articleCsvRows = [articleCsvHeaders.join(';')];
|
||||
@@ -415,6 +416,21 @@ export async function executeCompanyExport({
|
||||
// Categories
|
||||
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 = [
|
||||
formatCsvField(a.id),
|
||||
formatCsvStringFormula(a.sku),
|
||||
@@ -436,6 +452,7 @@ export async function executeCompanyExport({
|
||||
formatCsvField(pfandAttr),
|
||||
formatCsvField(brandPicRel),
|
||||
formatCsvField(picPaths.join(';')),
|
||||
formatCsvField(supplierStr),
|
||||
formatCsvField(a.desc || ''),
|
||||
];
|
||||
articleCsvRows.push(row.join(';'));
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'path';
|
||||
import { getStore } from '../db/store.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 { normalizeAttributeName, extractVariantValue } from './companyImport/variantHelpers.js';
|
||||
import { createProgressTracker } from './companyImport/progressTracker.js';
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
cleanValue,
|
||||
parseGermanFloat,
|
||||
parseCsvRecords,
|
||||
parseSuppliers,
|
||||
computeSkeleHash,
|
||||
normalizeAttributeName,
|
||||
extractVariantValue,
|
||||
@@ -208,6 +209,7 @@ export async function executeCompanyImport({
|
||||
pfandArticlesCreated: articlesResult.pfandArticlesCreated,
|
||||
pfandArticlesUpdated: articlesResult.pfandArticlesUpdated,
|
||||
pfandOptionsLinked: articlesResult.pfandOptionsLinked,
|
||||
suppliersLinked: articlesResult.suppliersLinked || 0,
|
||||
articlePictures: articlesResult.totalImagesImported,
|
||||
ordersTotal: totalReceiptsCount,
|
||||
ordersImported: ordersResult.ordersImported,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import fs from 'fs';
|
||||
import { cleanValue, parseGermanFloat } from './csvParser.js';
|
||||
import { cleanValue, parseGermanFloat, parseSuppliers } from './csvParser.js';
|
||||
import { setupVariantAttributes } from './variantImporter.js';
|
||||
import { setupPfandArticles, linkArticlePfand, extractPfandValue } from './pfandImporter.js';
|
||||
import { computeSkeleHash } from './imageHasher.js';
|
||||
@@ -70,6 +70,34 @@ export async function importArticles({
|
||||
// 3. Setup variant attributes resolver
|
||||
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
|
||||
const parentJtlIds = new Set();
|
||||
const parentPfandMap = new Map();
|
||||
@@ -124,6 +152,7 @@ export async function importArticles({
|
||||
let articlesUpdated = 0;
|
||||
let variantsLinked = 0;
|
||||
let pfandOptionsLinked = 0;
|
||||
let suppliersLinked = 0;
|
||||
let articlePicturesImported = 0;
|
||||
|
||||
// 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 ---
|
||||
const imagesForThisArt = articleImagesBySku.get(sku);
|
||||
if (imagesForThisArt && imagesForThisArt.length > 0) {
|
||||
@@ -369,7 +474,7 @@ export async function importArticles({
|
||||
|
||||
progressTracker.flushSkeleHashes();
|
||||
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 {
|
||||
jtlArtIdToSku,
|
||||
@@ -381,6 +486,7 @@ export async function importArticles({
|
||||
pfandArticlesCreated: pfandSetup.pfandArticlesCreated,
|
||||
pfandArticlesUpdated: pfandSetup.pfandArticlesUpdated,
|
||||
pfandOptionsLinked,
|
||||
suppliersLinked,
|
||||
totalImagesImported: articlePicturesImported,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,4 +125,31 @@ export function parseCsvRecords(text) {
|
||||
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
|
||||
|
||||
|
||||
@@ -1,9 +1,74 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { createCanvas } from 'canvas';
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import sharp from 'sharp';
|
||||
import { getStore } from '../db/store.js';
|
||||
import * as dbOrder from '../db/order.js';
|
||||
import { emit } from '../api/sse.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.
|
||||
* @param {Date|string} dateVal
|
||||
@@ -65,7 +130,7 @@ const TYPE_CONFIG = {
|
||||
numberPrefix: 'Auftrags-Nr.',
|
||||
showPrices: true,
|
||||
filenamePrefix: 'auftrag',
|
||||
introText: 'Wir bestätigen dankend die Annahme Ihres Auftrags zu folgenden Positionen:',
|
||||
introText: '',
|
||||
closingText: 'Vielen Dank für Ihren Auftrag!',
|
||||
defaultSubject: 'Auftragsbestätigung',
|
||||
},
|
||||
@@ -75,7 +140,7 @@ const TYPE_CONFIG = {
|
||||
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.',
|
||||
closingText: '',
|
||||
defaultSubject: 'Lieferschein',
|
||||
},
|
||||
delivery: {
|
||||
@@ -83,8 +148,8 @@ const TYPE_CONFIG = {
|
||||
numberPrefix: 'Beleg-Nr.',
|
||||
showPrices: false,
|
||||
filenamePrefix: 'wareneingang',
|
||||
introText: 'Wareneingangsdokumentation zu folgenden Positionen:',
|
||||
closingText: 'Wareneingang ordnungsgemäß im Lager erfasst.',
|
||||
introText: '',
|
||||
closingText: '',
|
||||
defaultSubject: 'Wareneingang',
|
||||
},
|
||||
};
|
||||
@@ -183,6 +248,12 @@ export function generateManifestPdf({
|
||||
.fillColor(secondaryColor)
|
||||
.text(config.docTitle, 350, y, { align: 'right', width: 200 });
|
||||
|
||||
if (type === 'delivery') {
|
||||
try {
|
||||
doc.initForm();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
y += 24;
|
||||
|
||||
// Company subline
|
||||
@@ -337,6 +408,8 @@ export function generateManifestPdf({
|
||||
// ----------------------------------------------------
|
||||
const showPrices = config.showPrices;
|
||||
|
||||
const isDelivery = type === 'delivery';
|
||||
|
||||
// Column setups
|
||||
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 };
|
||||
@@ -344,16 +417,41 @@ export function generateManifestPdf({
|
||||
const colXNoPrices = { pos: 45, sku: 80, name: 185, amount: 455 };
|
||||
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) => {
|
||||
doc.rect(45, atY, 505, 20)
|
||||
.fillColor(primaryColor)
|
||||
.fill();
|
||||
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(8)
|
||||
.fontSize(7.5)
|
||||
.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('Art.-Nr.', colXPrices.sku, atY + 6, { width: colWidthPrices.sku });
|
||||
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('Gesamt Brutto', colXPrices.total, atY + 6, { width: colWidthPrices.total, align: 'right' });
|
||||
} else {
|
||||
doc.fontSize(8);
|
||||
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 });
|
||||
@@ -409,7 +508,131 @@ export function generateManifestPdf({
|
||||
|
||||
const nameWidth = showPrices ? colWidthPrices.name : colWidthNoPrices.name;
|
||||
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
|
||||
doc.font('Helvetica').fontSize(8.5);
|
||||
@@ -623,6 +846,33 @@ export function generateManifestPdf({
|
||||
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 {
|
||||
// Non-price summary (total quantity and positions count)
|
||||
if (y + 50 > 750) {
|
||||
@@ -747,6 +997,108 @@ export async function createAndAttachManifestPdf(googleAuthId, companyId, manife
|
||||
|
||||
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([
|
||||
store.company.getById(companyId).catch(() => null),
|
||||
store.companysetting.list(googleAuthId, { frontendOnly: false, maskWriteonly: false }).catch(() => []),
|
||||
|
||||
Reference in New Issue
Block a user