Compare commits

...

6 Commits

Author SHA1 Message Date
seb
c687553eea u 2026-10-18 03:15:20 +02:00
seb
fda895f75a u 2026-10-17 23:59:26 +02:00
seb
ce5da810a5 u 2026-10-17 23:34:29 +02:00
seb
3c2b933f93 u 2026-10-17 23:32:42 +02:00
seb
688fa4f3fd u 2026-10-17 23:31:37 +02:00
seb
ab42a3ffb4 u 2026-10-17 19:03:24 +02:00
42 changed files with 6481 additions and 3281 deletions

View File

@@ -320,6 +320,29 @@ const articles = await store.article.listExport(42, null);
// => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }]
```
## `article.listHistory`
*Source: `src/store/article/listHistory.js`*
Lists article history entries for the user's selected company, supporting pagination, date cutoff via seekMonths, and search.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').Provider` | |
**Returns**
`(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
**Example**
```js
const res = await store.article.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'hjg', seekMonths: -13 });
// => { history: [...], total, unfilteredTotal, offset, limit }
```
## `article.listSearchIndex`
*Source: `src/store/article/listSearchIndex.js`*

View File

@@ -0,0 +1,9 @@
# articlewarehousesummary
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `articlewarehousesummary.syncSummary`
*Source: `src/store/articlewarehousesummary/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `articlewarehousesummary` table. Keeps `articlewarehousesummary` in sync with ledger movements, track_stock status, and warehouses.

View File

@@ -158,7 +158,7 @@ Returns the articles belonging to a single category.
**Returns**
`Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>` — The articles in the category.
`Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>` — The articles in the category.
**Example**

View File

@@ -55,7 +55,7 @@ Deletes an entity by its id within the user's selected company.
**Throws**
`Error` — If the entity is not found.
`Error` — If the entity is not found or is in use.
**Example**

View File

@@ -84,3 +84,26 @@ Lists ledger entries for a given article SKU, optionally filtered by transaction
const entries = await store.ledger.listBySku(googleAuthId, 'SKU-001', 'DELIVERY');
// => [{ id, manifestarticle_id, warehouse_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }]
```
## `ledger.listHistory`
*Source: `src/store/ledger/listHistory.js`*
Lists ledger entries for the user's selected company across all articles, supporting pagination, date cutoff via seekMonths, and search.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').Provider` | |
**Returns**
`(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
**Example**
```js
const res = await store.ledger.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'kjh', seekMonths: -1 });
// => { ledger: [...], total, unfilteredTotal, offset, limit }
```

8
API.md
View File

@@ -36,6 +36,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- **companysetting**: `id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)`
- **companyuser**: `id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)`
- **customergroup**: `id PK, name:s(255), sort:i=0, company_id?->company`
- **articlewarehousesummary**: `id PK, article_id->article, warehouse_id->warehouse, stock:f=0, UQ(article_id, warehouse_id)`
- **deliverysummary**: `id PK, company_id->company, manifest_id->manifest UQ, delivery_number:s(100), external_number?:s(100), delivery_date:dt, created_at:dt, supplier_id?->entity, supplier_name?:s(255), positions:i=0, gross_total:f=0, net_total:f=0, total_quantity:f=0, stock_positions:i=0, booked_positions:i=0, unbooked_positions:i=0, stock_quantity:f=0, booked_quantity:f=0, unbooked_quantity:f=0, is_booked:b=0`
- **entity**: `id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)`
- **entityaddress**: `id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0`
@@ -98,7 +99,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>`
- `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>`
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>`
- `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<{id: number, parentId: number|null, sort: number, name: string|null, slug: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, articleCount: number, posterminalIds: number[]}>, articleCount: number}>`
- `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>`
- `order(input: {googleAuthId: number, parentId: number, categoryIds: Array<number>}, tx?: object): Promise<{categoryIds: Array<number>}>`
@@ -115,6 +116,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `importRows(input: {googleAuthId: number, rows: Array<object>}, tx?: object): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>`
- `listChildren(googleAuthId: number, parentSku: string, tx?: object): Promise<Array<{id: number, sku: string, parentsort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>`
- `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise<Array<object>>`
- `listHistory(provider: import('../../providers/provider.js').Provider): (googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
- `listSearchIndex(googleAuthId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>`
- `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array<string>}, tx?: object): Promise<{fullHashes: Array<string>}>`
- `removeChild(googleAuthId: number, parentSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>`
@@ -237,6 +239,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `add(googleAuthId: number, input: {manifestarticle_id: number, warehouse_id?: number|null, transaction_type: string, amount: number, transaction_text: string}, tx?: object): Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>`
- `listByManifestarticleId(googleAuthId: number, manifestarticleId: number, optionsOrType?: string|object, tx?: object): Promise<Array<{id: number, manifestarticle_id: number, warehouse_id: number|null, sku: string|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>>`
- `listBySku(googleAuthId: number, sku: string, optionsOrType?: string|object, tx?: object): Promise<Array<{id: number, manifestarticle_id: number, warehouse_id: number|null, sku: string|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>>`
- `listHistory(provider: import('../../providers/provider.js').Provider): (googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
- [warehouse](API.warehouse.md)
- `create(googleAuthId: number, input: {name: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
@@ -247,6 +250,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- [stock](API.stock.md)
- `accept(provider: object): Function`
- `adjust(googleAuthId: number, input: {sku?: string, warehouseId?: number, amount?: number, text?: string, externalNumber?: string, date?: string|Date, lines?: Array<object>}, tx?: object): Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>`
- `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `ship(provider: object): Function`
- [posterminal](API.posterminal.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
@@ -335,6 +339,8 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- [articlelist](API.articlelist.md)
- `getItemsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`
- `setItemsByArticleSku(googleAuthId: number, sku: string, items?: Array<{articleId?: number, sku?: string, count?: number, sort?: number}>, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`
- [articlewarehousesummary](API.articlewarehousesummary.md)
- `syncSummary(): void`
- [carrier](API.carrier.md)
- `create(googleAuthId: number, input: {name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`

View File

@@ -43,6 +43,18 @@ Adjusts article stock manually by creating an implicit manifest of type "adjustm
`Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>`
## `stock.rebuildSummaries`
*Source: `src/store/stock/rebuildSummaries.js`*
Rebuilds the `articlewarehousesummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `stock.ship`
*Source: `src/store/stock/ship.js`*

View File

@@ -52,7 +52,7 @@ Deletes a supplier by its id within the user's selected company.
**Throws**
`Error` — If the supplier is not found.
`Error` — If the supplier is not found or is in use.
**Example**

View File

@@ -351,6 +351,7 @@ const SCHEMA_TABLES = [
['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'],
['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'],
['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'],
['articlewarehousesummary', 'id PK, article_id->article, warehouse_id->warehouse, stock:f=0, UQ(article_id, warehouse_id)'],
['deliverysummary', 'id PK, company_id->company, manifest_id->manifest UQ, delivery_number:s(100), external_number?:s(100), delivery_date:dt, created_at:dt, supplier_id?->entity, supplier_name?:s(255), positions:i=0, gross_total:f=0, net_total:f=0, total_quantity:f=0, stock_positions:i=0, booked_positions:i=0, unbooked_positions:i=0, stock_quantity:f=0, booked_quantity:f=0, unbooked_quantity:f=0, is_booked:b=0'],
['entity', 'id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)'],
['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'],

View File

@@ -374,6 +374,7 @@ const CLUSTERS = [
'manifest',
'manifestarticle',
'manifestattachment',
'articlewarehousesummary',
'deliverysummary',
'ordersummary',
'quotesummary',

View File

@@ -20,6 +20,7 @@
"init-schema": "node scripts/init-schema.js",
"purge-db": "node scripts/purge-db.js",
"reset-db": "node scripts/reset-db.js",
"test": "node --test \"test/*.test.js\"",
"test:api": "node scripts/test-api.js",
"test:benchmark": "node scripts/benchmark.js"
},

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: 396 KiB

After

Width:  |  Height:  |  Size: 407 KiB

View File

@@ -25,6 +25,7 @@ import articleAddImage from './store/article/addImage.js';
import articleCreateUnderParent from './store/article/createUnderParent.js';
import articleGetBySku from './store/article/getBySku.js';
import articleGetHistoryBySku from './store/article/getHistoryBySku.js';
import articleListHistory from './store/article/listHistory.js';
import articleListSearchIndex from './store/article/listSearchIndex.js';
import articleListExport from './store/article/listExport.js';
import articleUpdateBySku from './store/article/updateBySku.js';
@@ -159,6 +160,7 @@ import manifestattachmentOrder from './store/manifestattachment/order.js';
import ledgerAdd from './store/ledger/add.js';
import ledgerListByManifestarticleId from './store/ledger/listByManifestarticleId.js';
import ledgerListBySku from './store/ledger/listBySku.js';
import ledgerListHistory from './store/ledger/listHistory.js';
import warehouseCreate from './store/warehouse/create.js';
import warehouseGetById from './store/warehouse/getById.js';
import warehouseList from './store/warehouse/list.js';
@@ -223,6 +225,7 @@ import labeltemplateDelete from './store/labeltemplate/delete.js';
import stockAdjust from './store/stock/adjust.js';
import stockAccept from './store/stock/accept.js';
import stockShip from './store/stock/ship.js';
import stockRebuildSummaries from './store/stock/rebuildSummaries.js';
import propertynameList from './store/propertyname/list.js';
import propertynameCreate from './store/propertyname/create.js';
import propertynameUpdate from './store/propertyname/update.js';
@@ -319,6 +322,7 @@ export default class LibDashStore {
createUnderParent: articleCreateUnderParent(provider),
getBySku: articleGetBySku(provider),
getHistoryBySku: articleGetHistoryBySku(provider),
listHistory: articleListHistory(provider),
listSearchIndex: articleListSearchIndex(provider),
listExport: articleListExport(provider),
updateBySku: articleUpdateBySku(provider),
@@ -554,6 +558,7 @@ export default class LibDashStore {
add: ledgerAdd(provider),
listByManifestarticleId: ledgerListByManifestarticleId(provider),
listBySku: ledgerListBySku(provider),
listHistory: ledgerListHistory(provider),
};
this.warehouse = {
@@ -624,6 +629,7 @@ export default class LibDashStore {
adjust: stockAdjust(provider),
accept: stockAccept(provider),
ship: stockShip(provider),
rebuildSummaries: stockRebuildSummaries(provider),
};
this.adjustment = this.stock;

View File

@@ -1,6 +1,7 @@
/** Drop order = reverse of schema create dependency order. */
export const TABLES = [
'articlewarehousesummary',
'deliverysummary',
'shipmentsummary',
'quotesummary',

View File

@@ -68,6 +68,22 @@ WHERE ca.category_id = @categoryId
ORDER BY a.id
`;
export const GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES = `
SELECT
aws.id,
aws.article_id AS articleId,
aws.warehouse_id AS warehouseId,
w.name AS warehouseName,
aws.stock
FROM articlewarehousesummary aws
INNER JOIN warehouse w ON w.id = aws.warehouse_id
INNER JOIN categoryarticle ca ON ca.article_id = aws.article_id
INNER JOIN article a ON a.id = ca.article_id
WHERE ca.category_id = @categoryId
AND a.parentarticle_id IS NULL
ORDER BY w.sort ASC, w.name ASC, aws.id ASC
`;
export const CREATE = `
INSERT INTO category (parent_id, sort)
OUTPUT INSERTED.id, INSERTED.parent_id AS parentId, INSERTED.sort

View File

@@ -161,3 +161,14 @@ WHERE e.id = @id
AND cg.company_id = @companyId
AND cg.name = @groupName
`;
export const CHECK_SUPPLIER_IN_USE = `
SELECT TOP 1 1 AS inUse
FROM manifest m
WHERE m.entity_id = @id AND m.company_id = @companyId
UNION ALL
SELECT TOP 1 1 AS inUse
FROM articlesupplier asup
INNER JOIN article a ON a.id = asup.article_id
WHERE asup.entity_id = @id AND a.company_id = @companyId;
`;

View File

@@ -619,7 +619,7 @@ BEGIN
pu FLOAT NOT NULL CONSTRAINT DF_articlesupplier_pu DEFAULT 1,
sort INT NOT NULL CONSTRAINT DF_articlesupplier_sort DEFAULT 0,
CONSTRAINT FK_articlesupplier_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
CONSTRAINT FK_articlesupplier_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE,
CONSTRAINT FK_articlesupplier_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION,
CONSTRAINT FK_articlesupplier_tax FOREIGN KEY (tax_id) REFERENCES tax(id) ON DELETE NO ACTION,
CONSTRAINT UQ_articlesupplier_article_entity UNIQUE (article_id, entity_id),
CONSTRAINT UQ_articlesupplier_article_sort UNIQUE (article_id, sort)
@@ -643,7 +643,7 @@ BEGIN
[date] DATETIME2 NOT NULL CONSTRAINT DF_manifest_date DEFAULT SYSUTCDATETIME(),
created_at DATETIME2 NOT NULL CONSTRAINT DF_manifest_created_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_manifest_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
CONSTRAINT FK_manifest_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
CONSTRAINT FK_manifest_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION
);
END
`;
@@ -1338,6 +1338,24 @@ BEGIN
END
`;
export const INIT_ARTICLEWAREHOUSESUMMARY = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articlewarehousesummary')
BEGIN
CREATE TABLE articlewarehousesummary (
id INT IDENTITY(1,1) PRIMARY KEY,
article_id INT NOT NULL,
warehouse_id INT NOT NULL,
stock FLOAT NOT NULL CONSTRAINT DF_articlewarehousesummary_stock DEFAULT 0,
CONSTRAINT FK_articlewarehousesummary_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
CONSTRAINT FK_articlewarehousesummary_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouse(id) ON DELETE CASCADE,
CONSTRAINT UQ_articlewarehousesummary_article_warehouse UNIQUE (article_id, warehouse_id)
);
CREATE INDEX IX_articlewarehousesummary_warehouse
ON articlewarehousesummary (warehouse_id);
END
`;
export const INIT_MCP_CLIENT = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
BEGIN
@@ -1485,6 +1503,7 @@ export const SCHEMA_SCRIPTS = [
INIT_QUOTESUMMARY,
INIT_SHIPMENTSUMMARY,
INIT_DELIVERYSUMMARY,
INIT_ARTICLEWAREHOUSESUMMARY,
INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN,

View File

@@ -5,6 +5,7 @@ import { ARTICLE_LIST_COLUMNS, ARTICLE_LIST_JOINS } from './article.js';
const queries = translateQueries(source, 'category');
export const {
CREATE, DELETE, GET_BY_PARENT_AND_NAME,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES,
GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
MOVE, UPDATE_SORT,
} = queries;

View File

@@ -23,4 +23,16 @@ WHERE id = @id
AND customergroup_id IN (
SELECT id FROM customergroup WHERE company_id = @companyId AND name = @groupName
)
`;
export const CHECK_SUPPLIER_IN_USE = `
SELECT 1 AS inUse
FROM manifest m
WHERE m.entity_id = @id AND m.company_id = @companyId
UNION ALL
SELECT 1 AS inUse
FROM articlesupplier asup
INNER JOIN article a ON a.id = asup.article_id
WHERE asup.entity_id = @id AND a.company_id = @companyId
LIMIT 1;
`;

View File

@@ -412,7 +412,7 @@ CREATE TABLE IF NOT EXISTS articlesupplier (
UNIQUE (article_id, entity_id),
UNIQUE (article_id, sort),
FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION,
FOREIGN KEY (tax_id) REFERENCES tax(id) ON DELETE NO ACTION
);
CREATE INDEX IF NOT EXISTS IX_articlesupplier_article_entity
@@ -430,7 +430,7 @@ CREATE TABLE IF NOT EXISTS manifest (
"date" TEXT NOT NULL DEFAULT (${UTC_NOW}),
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION
);
`;
@@ -981,6 +981,20 @@ CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_supplier
ON deliverysummary (company_id, supplier_id);
`;
export const INIT_ARTICLEWAREHOUSESUMMARY = `
CREATE TABLE IF NOT EXISTS articlewarehousesummary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
stock REAL NOT NULL DEFAULT 0,
UNIQUE (article_id, warehouse_id),
FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
FOREIGN KEY (warehouse_id) REFERENCES warehouse(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_articlewarehousesummary_warehouse
ON articlewarehousesummary (warehouse_id);
`;
export const INIT_MCP_CLIENT = `
CREATE TABLE IF NOT EXISTS mcp_client (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1111,6 +1125,7 @@ export const SCHEMA_SCRIPTS = [
INIT_QUOTESUMMARY,
INIT_SHIPMENTSUMMARY,
INIT_DELIVERYSUMMARY,
INIT_ARTICLEWAREHOUSESUMMARY,
INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN,

View File

@@ -19,6 +19,7 @@ import {
recordChildAssignedToParentHistory,
} from '../articlehistory/record.js';
import attachChild from './attachChild.js';
import { initArticleWarehouseSummary } from '../articlewarehousesummary/syncSummary.js';
export default function addChild(provider) {
const resolve = resolveArticleBySku(provider);
@@ -266,6 +267,10 @@ export default function addChild(provider) {
childSku,
});
if (finalTrackStock === 1) {
await initArticleWarehouseSummary(runner, { companyId, articleId: childId });
}
return load(googleAuthId, childSku, runner);
};

View File

@@ -17,6 +17,7 @@ import nextSku from '../numbers/nextSku.js';
import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js';
import { recordArticleCreatedHistory } from '../articlehistory/record.js';
import { initArticleWarehouseSummary } from '../articlewarehousesummary/syncSummary.js';
export default function createUnderParent(provider) {
const access = resolveCategoryAccess(provider);
@@ -113,6 +114,10 @@ export default function createUnderParent(provider) {
name,
});
if (finalTrackStock === 1) {
await initArticleWarehouseSummary(runner, { companyId, articleId: id });
}
return {
id,
parentId,

View File

@@ -0,0 +1,170 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
/**
* Lists article history entries for the user's selected company,
* supporting pagination, date cutoff via seekMonths, and search.
*
* @param {import('../../providers/provider.js').Provider} provider
* @returns {(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>}
*
* @example
* const res = await store.article.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'hjg', seekMonths: -13 });
* // => { history: [...], total, unfilteredTotal, offset, limit }
*/
export default function listHistory(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
return async (googleAuthId, opts, tx) => {
let offset = 0;
let limit = 50;
let isPaged = true;
let resolvedTx = tx;
if (opts != null && typeof opts === 'object' && !opts._isTx) {
if (opts.offset !== undefined) {
const parsedOffset = Number(opts.offset);
if (!Number.isInteger(parsedOffset) || parsedOffset < 0) {
throw new Error('Invalid offset');
}
offset = parsedOffset;
}
if (opts.limit !== undefined) {
const parsedLimit = Number(opts.limit);
if (!Number.isInteger(parsedLimit) || parsedLimit < 1) {
throw new Error('Invalid limit');
}
limit = parsedLimit;
}
} else if (opts != null) {
resolvedTx = opts;
}
const runner = makeRunner(provider, resolvedTx);
const { companyId, languageId } = await access(googleAuthId, runner);
const conditions = ['a.company_id = @companyId'];
const params = {
companyId,
languageId,
};
// Date cutoff via seekMonths (strictly integer)
if (opts?.seekMonths !== undefined) {
if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) {
throw new Error('Invalid seekMonths');
}
const d = new Date();
d.setMonth(d.getMonth() - Math.abs(opts.seekMonths));
params.dateTo = d.toISOString();
conditions.push(`ah.${isSqlite ? '"date"' : '[date]'} <= @dateTo`);
}
// Free-text search
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
params.searchPattern = `%${opts.search.trim()}%`;
if (isSqlite) {
conditions.push(`(
a.sku LIKE @searchPattern
OR at.name LIKE @searchPattern
OR ah."text" LIKE @searchPattern
)`);
} else {
conditions.push(`(
a.sku LIKE @searchPattern
OR at.name LIKE @searchPattern
OR ah.[text] LIKE @searchPattern
)`);
}
}
const whereClause = conditions.join('\n AND ');
const sqlCount = `
SELECT COUNT(*) AS total
FROM articlehistory ah
INNER JOIN article a ON a.id = ah.article_id
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE ${whereClause}
`;
const sqlCountBase = `
SELECT COUNT(*) AS total
FROM articlehistory ah
INNER JOIN article a ON a.id = ah.article_id
WHERE a.company_id = @companyId
`;
let sqlList;
if (isSqlite) {
sqlList = `
SELECT
ah.id,
ah."date",
a.sku,
at.name,
ah."text" AS historytext
FROM articlehistory ah
INNER JOIN article a ON a.id = ah.article_id
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE ${whereClause}
ORDER BY ah."date" DESC, ah.id DESC
${isPaged ? 'LIMIT @limit OFFSET @offset' : ''}
`;
} else {
sqlList = `
SELECT
ah.id,
ah.[date],
a.sku,
at.name,
ah.[text] AS historytext
FROM articlehistory ah
INNER JOIN article a ON a.id = ah.article_id
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE ${whereClause}
ORDER BY ah.[date] DESC, ah.id DESC
${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''}
`;
}
const hasUserFilters = (
opts?.seekMonths !== undefined ||
(typeof opts?.search === 'string' && opts.search.trim() !== '')
);
params.offset = offset;
params.limit = limit;
const [countResult, listResult, unfilteredCountResult] = await Promise.all([
runner.query(sqlCount, params),
runner.query(sqlList, params),
hasUserFilters ? runner.query(sqlCountBase, { companyId }) : null,
]);
const total = Number(countResult.rows[0]?.total ?? 0);
const unfilteredTotal = hasUserFilters
? Number(unfilteredCountResult?.rows[0]?.total ?? 0)
: total;
return {
history: listResult.rows.map((row) => ({
id: row.id,
date:
row.date instanceof Date
? row.date.toISOString()
: row.date
? new Date(row.date).toISOString()
: null,
sku: row.sku,
name: row.name ?? null,
historytext: row.historytext,
})),
total,
unfilteredTotal,
offset,
limit,
};
};
}

View File

@@ -13,6 +13,10 @@ import resolveArticleBySku from './resolveArticleBySku.js';
import { recordTrackStockChangeHistory } from '../articlehistory/record.js';
import stockAdjust from '../stock/adjust.js';
import makeRunner from '../runner.js';
import {
initArticleWarehouseSummary,
removeArticleWarehouseSummary,
} from '../articlewarehousesummary/syncSummary.js';
export default function setTrackStockBySku(provider) {
const resolve = resolveArticleBySku(provider);
@@ -94,6 +98,12 @@ export default function setTrackStockBySku(provider) {
// Write history if track_stock changed
if (oldTrack !== newTrack) {
if (!newTrack) {
await removeArticleWarehouseSummary(runner, { articleId });
} else {
await initArticleWarehouseSummary(runner, { companyId, articleId });
}
await recordTrackStockChangeHistory(runner, {
articleId,
companyuserId,

View File

@@ -11,6 +11,7 @@ import {
recordParentDeactivatedHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
import { removeArticleWarehouseSummary } from '../articlewarehousesummary/syncSummary.js';
export default function toggleParent(provider) {
const resolve = resolveArticleBySku(provider);
@@ -96,6 +97,7 @@ export default function toggleParent(provider) {
await runner.query('DELETE FROM price WHERE article_id = @articleId', { articleId });
await runner.query('UPDATE article SET track_stock = 0 WHERE id = @articleId', { articleId });
await removeArticleWarehouseSummary(runner, { articleId });
// If article is a child, verify attribute is not used by parent or any parent up the chain
let currentParentId = currentArt?.parentarticle_id;

View File

@@ -0,0 +1,266 @@
/**
* Centralized sync and rebuild logic for the materialized `articlewarehousesummary` table.
*
* Keeps `articlewarehousesummary` in sync with ledger movements, track_stock status,
* and warehouses.
*/
const SQL_FIND_ARTICLES_BY_MANIFESTARTICLE = `
SELECT a.id AS articleId, a.company_id AS companyId, a.track_stock AS trackStock
FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
INNER JOIN article a ON a.company_id = m.company_id
AND (
a.sku = ma.sku
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku)
)
WHERE ma.id = @manifestarticleId AND m.company_id = @companyId;
`;
const SQL_GET_STOCK_FROM_LEDGER = `
SELECT COALESCE(SUM(l.amount), 0) AS stock
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE l.warehouse_id = @warehouseId
AND m.company_id = @companyId
AND (
ma.sku = (SELECT sku FROM article WHERE id = @articleId)
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
);
`;
const SQL_UPSERT_SUMMARY_SQLITE = `
INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock)
VALUES (@articleId, @warehouseId, @stock)
ON CONFLICT (article_id, warehouse_id) DO UPDATE SET stock = excluded.stock;
`;
const SQL_UPSERT_SUMMARY_MSSQL = `
MERGE INTO articlewarehousesummary AS target
USING (SELECT @articleId AS article_id, @warehouseId AS warehouse_id) AS source
ON (target.article_id = source.article_id AND target.warehouse_id = source.warehouse_id)
WHEN MATCHED THEN
UPDATE SET stock = @stock
WHEN NOT MATCHED THEN
INSERT (article_id, warehouse_id, stock)
VALUES (@articleId, @warehouseId, @stock);
`;
const SQL_INIT_FOR_ARTICLE_SQLITE = `
INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock)
SELECT @articleId, w.id, 0
FROM warehouse w
WHERE w.company_id = @companyId
ON CONFLICT (article_id, warehouse_id) DO UPDATE SET stock = 0;
`;
const SQL_INIT_FOR_ARTICLE_MSSQL = `
MERGE INTO articlewarehousesummary AS target
USING (
SELECT @articleId AS article_id, w.id AS warehouse_id
FROM warehouse w
WHERE w.company_id = @companyId
) AS source
ON (target.article_id = source.article_id AND target.warehouse_id = source.warehouse_id)
WHEN MATCHED THEN
UPDATE SET stock = 0
WHEN NOT MATCHED THEN
INSERT (article_id, warehouse_id, stock)
VALUES (source.article_id, source.warehouse_id, 0);
`;
const SQL_INIT_FOR_NEW_WAREHOUSE_SQLITE = `
INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock)
SELECT a.id, @warehouseId, 0
FROM article a
WHERE a.company_id = @companyId AND a.track_stock = 1
ON CONFLICT (article_id, warehouse_id) DO UPDATE SET stock = 0;
`;
const SQL_INIT_FOR_NEW_WAREHOUSE_MSSQL = `
MERGE INTO articlewarehousesummary AS target
USING (
SELECT a.id AS article_id, @warehouseId AS warehouse_id
FROM article a
WHERE a.company_id = @companyId AND a.track_stock = 1
) AS source
ON (target.article_id = source.article_id AND target.warehouse_id = source.warehouse_id)
WHEN MATCHED THEN
UPDATE SET stock = 0
WHEN NOT MATCHED THEN
INSERT (article_id, warehouse_id, stock)
VALUES (source.article_id, source.warehouse_id, 0);
`;
const SQL_DELETE_BY_ARTICLE = `
DELETE FROM articlewarehousesummary WHERE article_id = @articleId;
`;
/**
* Updates `articlewarehousesummary` after a ledger entry is appended with a warehouseId.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} opts.manifestarticleId
* @param {number|null} opts.warehouseId
*/
export async function syncArticleWarehouseSummaryForLedger(runner, { companyId, manifestarticleId, warehouseId }) {
if (warehouseId == null) {
return;
}
const isSqlite = runner.dialect === 'sqlite3';
const upsertSql = isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL;
const res = await runner.query(SQL_FIND_ARTICLES_BY_MANIFESTARTICLE, {
manifestarticleId,
companyId,
});
const rows = res.rows ?? [];
for (const row of rows) {
const articleId = row.articleId;
const isTracked = Boolean(row.trackStock ?? 0);
if (!isTracked) {
await runner.query(SQL_DELETE_BY_ARTICLE, { articleId });
continue;
}
const stockRes = await runner.query(SQL_GET_STOCK_FROM_LEDGER, {
articleId,
warehouseId,
companyId,
});
const rawStock = stockRes.rows[0]?.stock ?? 0;
const stock = Math.round(Number(rawStock) * 1000) / 1000;
await runner.query(upsertSql, {
articleId,
warehouseId,
stock,
});
}
}
/**
* Initializes `articlewarehousesummary` with stock = 0 for all warehouses of the company
* when track_stock is enabled for an article.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} opts.articleId
*/
export async function initArticleWarehouseSummary(runner, { companyId, articleId }) {
const isSqlite = runner.dialect === 'sqlite3';
const sql = isSqlite ? SQL_INIT_FOR_ARTICLE_SQLITE : SQL_INIT_FOR_ARTICLE_MSSQL;
await runner.query(sql, { companyId, articleId });
}
/**
* Removes all rows for an article from `articlewarehousesummary` when track_stock is disabled.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.articleId
*/
export async function removeArticleWarehouseSummary(runner, { articleId }) {
await runner.query(SQL_DELETE_BY_ARTICLE, { articleId });
}
/**
* Initializes entries in `articlewarehousesummary` for a newly created warehouse
* for all articles with stock tracking enabled.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} opts.warehouseId
*/
export async function initWarehouseSummaryForNewWarehouse(runner, { companyId, warehouseId }) {
const isSqlite = runner.dialect === 'sqlite3';
const sql = isSqlite ? SQL_INIT_FOR_NEW_WAREHOUSE_SQLITE : SQL_INIT_FOR_NEW_WAREHOUSE_MSSQL;
await runner.query(sql, { companyId, warehouseId });
}
/**
* Rebuilds the entire `articlewarehousesummary` for a given company.
*
* @param {object} runner - Database runner.
* @param {number} companyId - The company id.
*/
export async function rebuildArticleWarehouseSummaries(runner, companyId) {
const isSqlite = runner.dialect === 'sqlite3';
if (isSqlite) {
const sqlRebuildSqlite = `
DELETE FROM articlewarehousesummary
WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId);
INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock)
SELECT
a.id AS article_id,
w.id AS warehouse_id,
COALESCE(s.stock, 0) AS stock
FROM article a
CROSS JOIN warehouse w ON w.company_id = a.company_id
LEFT JOIN (
SELECT
ca.id AS article_id,
l.warehouse_id,
SUM(l.amount) AS stock
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
INNER JOIN article ca ON ca.company_id = m.company_id
AND (
ca.sku = ma.sku
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = ca.id AND asup.sku = ma.sku)
)
WHERE m.company_id = @companyId
AND l.warehouse_id IS NOT NULL
GROUP BY ca.id, l.warehouse_id
) s ON s.article_id = a.id AND s.warehouse_id = w.id
WHERE a.company_id = @companyId
AND a.track_stock = 1;
`;
await runner.query(sqlRebuildSqlite, { companyId });
} else {
const sqlRebuildMssql = `
DELETE FROM articlewarehousesummary
WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId);
INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock)
SELECT
a.id AS article_id,
w.id AS warehouse_id,
COALESCE(s.stock, 0) AS stock
FROM article a
CROSS JOIN warehouse w
LEFT JOIN (
SELECT
ca.id AS article_id,
l.warehouse_id,
SUM(l.amount) AS stock
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
INNER JOIN article ca ON ca.company_id = m.company_id
AND (
ca.sku = ma.sku
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = ca.id AND asup.sku = ma.sku)
)
WHERE m.company_id = @companyId
AND l.warehouse_id IS NOT NULL
GROUP BY ca.id, l.warehouse_id
) s ON s.article_id = a.id AND s.warehouse_id = w.id
WHERE a.company_id = @companyId
AND w.company_id = @companyId
AND a.track_stock = 1;
`;
await runner.query(sqlRebuildMssql, { companyId });
}
}

View File

@@ -11,7 +11,7 @@ export default function getCategoryArticles(provider) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>} The articles in the category.
* @returns {Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>} The articles in the category.
*
* @example
* const articles = await store.category.getCategoryArticles(42, 5);

View File

@@ -1,17 +1,48 @@
import { GET_CATEGORY_ARTICLES as MSSQL_GET } from '../../providers/mssql/queries/category.js';
import { GET_CATEGORY_ARTICLES as SQLITE_GET } from '../../providers/sqlite3/queries/category.js';
import {
GET_CATEGORY_ARTICLES as MSSQL_GET,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES as MSSQL_SUMMARIES,
} from '../../providers/mssql/queries/category.js';
import {
GET_CATEGORY_ARTICLES as SQLITE_GET,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES as SQLITE_SUMMARIES,
} from '../../providers/sqlite3/queries/category.js';
import makeRunner from '../runner.js';
export default function queryCategoryArticles(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
const sqlSummaries = provider?.dialect === 'sqlite3' ? SQLITE_SUMMARIES : MSSQL_SUMMARIES;
return async ({ categoryId, languageId, posCustomerGroupId }, tx) => {
const result = await makeRunner(provider, tx).query(sql, {
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, {
categoryId,
languageId,
posCustomerGroupId,
});
if (result.rows.length === 0) {
return [];
}
const summaryResult = await runner.query(sqlSummaries, { categoryId });
const summaryMap = new Map();
for (const s of summaryResult.rows) {
let list = summaryMap.get(s.articleId);
if (!list) {
list = [];
summaryMap.set(s.articleId, list);
}
list.push({
id: s.id,
articleId: s.articleId,
article_id: s.articleId,
warehouseId: s.warehouseId,
warehouse_id: s.warehouseId,
warehouseName: s.warehouseName,
stock: Number(s.stock ?? 0),
});
}
return result.rows.map((row) => {
const {
brandId,
@@ -22,6 +53,7 @@ export default function queryCategoryArticles(provider) {
brandThumbHash,
...rest
} = row;
const summaries = summaryMap.get(row.id) ?? [];
return {
...rest,
track_stock: Boolean(rest.track_stock ?? 0),
@@ -38,7 +70,14 @@ export default function queryCategoryArticles(provider) {
thumbHash: brandThumbHash ?? null,
}
: null,
articlewarehousesummary: summaries,
warehouses: summaries.map((s) => ({
id: s.warehouseId,
name: s.warehouseName,
stock: s.stock,
})),
};
});
};
}

View File

@@ -1,11 +1,22 @@
import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entity.js';
import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entity.js';
import {
DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE,
CHECK_SUPPLIER_IN_USE as MSSQL_CHECK_IN_USE,
GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID,
} from '../../providers/mssql/queries/entity.js';
import {
DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE,
CHECK_SUPPLIER_IN_USE as SQLITE_CHECK_IN_USE,
GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID,
} from '../../providers/sqlite3/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const checkInUseSql = isSqlite ? SQLITE_CHECK_IN_USE : MSSQL_CHECK_IN_USE;
const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID;
/**
* Deletes an entity by its id within the user's selected company.
@@ -14,20 +25,38 @@ export default function del(provider) {
* @param {number} id - The id of the entity to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the entity is not found.
* @throws {Error} If the entity is not found or is in use.
*
* @example
* await store.entity.delete(42, 5);
*/
return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Entity not found');
}
const { companyId } = await access(googleAuthId, tx);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await makeRunner(provider, tx).query(sql, {
id,
const existing = await runner.query(getByIdSql, {
id: numericId,
companyId,
});
if (!existing.rows || existing.rows.length === 0) {
throw new Error('Entity not found');
}
const usage = await runner.query(checkInUseSql, {
id: numericId,
companyId,
});
if (usage.rows && usage.rows.length > 0) {
throw new Error('Entity in use');
}
const result = await runner.query(sql, {
id: numericId,
companyId,
});
@@ -36,3 +65,4 @@ export default function del(provider) {
}
};
}

View File

@@ -1,5 +1,6 @@
import { syncDeliverySummary } from '../delivery/syncSummary.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js';
import { syncArticleWarehouseSummaryForLedger } from '../articlewarehousesummary/syncSummary.js';
import { createHash } from 'node:crypto';
import * as mssqlQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js';
@@ -239,6 +240,12 @@ export default function add(provider) {
memo: text,
});
await syncArticleWarehouseSummaryForLedger(runner, {
companyId,
manifestarticleId,
warehouseId: effectiveWhId,
});
for (const match of articleMatches) {
await recordWarehouseHistory(runner, {
articleId: match.id,
@@ -315,6 +322,12 @@ export default function add(provider) {
memo: text,
});
await syncArticleWarehouseSummaryForLedger(runner, {
companyId,
manifestarticleId,
warehouseId: effectiveWhId,
});
for (const match of articleMatches) {
await recordWarehouseHistory(runner, {
articleId: match.id,
@@ -357,6 +370,12 @@ export default function add(provider) {
memo: transactionText,
});
await syncArticleWarehouseSummaryForLedger(runner, {
companyId,
manifestarticleId,
warehouseId,
});
return shape(row);
};

View File

@@ -0,0 +1,180 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
/**
* Lists ledger entries for the user's selected company across all articles,
* supporting pagination, date cutoff via seekMonths, and search.
*
* @param {import('../../providers/provider.js').Provider} provider
* @returns {(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>}
*
* @example
* const res = await store.ledger.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'kjh', seekMonths: -1 });
* // => { ledger: [...], total, unfilteredTotal, offset, limit }
*/
export default function listHistory(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
return async (googleAuthId, opts, tx) => {
let offset = 0;
let limit = 50;
let isPaged = true;
let resolvedTx = tx;
if (opts != null && typeof opts === 'object' && !opts._isTx) {
if (opts.offset !== undefined) {
const parsedOffset = Number(opts.offset);
if (!Number.isInteger(parsedOffset) || parsedOffset < 0) {
throw new Error('Invalid offset');
}
offset = parsedOffset;
}
if (opts.limit !== undefined) {
const parsedLimit = Number(opts.limit);
if (!Number.isInteger(parsedLimit) || parsedLimit < 1) {
throw new Error('Invalid limit');
}
limit = parsedLimit;
}
} else if (opts != null) {
resolvedTx = opts;
}
const runner = makeRunner(provider, resolvedTx);
const { companyId, languageId } = await access(googleAuthId, runner);
const conditions = ['m.company_id = @companyId'];
const params = {
companyId,
languageId,
};
// Date cutoff via seekMonths (strictly integer)
if (opts?.seekMonths !== undefined) {
if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) {
throw new Error('Invalid seekMonths');
}
const d = new Date();
d.setMonth(d.getMonth() - Math.abs(opts.seekMonths));
params.dateTo = d.toISOString();
conditions.push(`l.created_at <= @dateTo`);
}
// Free-text search
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
params.searchPattern = `%${opts.search.trim()}%`;
const numCol = isSqlite ? 'm."number"' : 'm.[number]';
conditions.push(`(
ma.sku LIKE @searchPattern
OR ma.name LIKE @searchPattern
OR at.name LIKE @searchPattern
OR ${numCol} LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR l.transaction_type LIKE @searchPattern
OR l.transaction_text LIKE @searchPattern
)`);
}
const whereClause = conditions.join('\n AND ');
const sqlCount = `
SELECT COUNT(*) AS total
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.sku = ma.sku AND a.company_id = m.company_id
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE ${whereClause}
`;
const sqlCountBase = `
SELECT COUNT(*) AS total
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.company_id = @companyId
`;
let sqlList;
if (isSqlite) {
sqlList = `
SELECT
l.id,
l.created_at AS "date",
ma.sku,
COALESCE(at.name, ma.name) AS name,
l.transaction_type,
l.transaction_text,
l.amount
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.sku = ma.sku AND a.company_id = m.company_id
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE ${whereClause}
ORDER BY l.created_at DESC, l.id DESC
${isPaged ? 'LIMIT @limit OFFSET @offset' : ''}
`;
} else {
sqlList = `
SELECT
l.id,
l.created_at AS [date],
ma.sku,
COALESCE(at.name, ma.name) AS name,
l.transaction_type,
l.transaction_text,
l.amount
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
INNER JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.sku = ma.sku AND a.company_id = m.company_id
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE ${whereClause}
ORDER BY l.created_at DESC, l.id DESC
${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''}
`;
}
const hasUserFilters = (
opts?.seekMonths !== undefined ||
(typeof opts?.search === 'string' && opts.search.trim() !== '')
);
params.offset = offset;
params.limit = limit;
const [countResult, listResult, unfilteredCountResult] = await Promise.all([
runner.query(sqlCount, params),
runner.query(sqlList, params),
hasUserFilters ? runner.query(sqlCountBase, { companyId }) : null,
]);
const total = Number(countResult.rows[0]?.total ?? 0);
const unfilteredTotal = hasUserFilters
? Number(unfilteredCountResult?.rows[0]?.total ?? 0)
: total;
return {
ledger: listResult.rows.map((row) => ({
id: row.id,
date:
row.date instanceof Date
? row.date.toISOString()
: row.date
? new Date(row.date).toISOString()
: null,
sku: row.sku ?? null,
name: row.name ?? null,
transaction_type: row.transaction_type,
transaction_text: row.transaction_text,
amount: row.amount,
})),
total,
unfilteredTotal,
offset,
limit,
};
};
}

View File

@@ -10,6 +10,7 @@ import nextNumber from '../numbers/nextNumber.js';
import ledgerAdd from '../ledger/add.js';
import makeRunner from '../runner.js';
import { parseDate } from '../manifestLines.js';
import { syncArticleWarehouseSummaryForLedger } from '../articlewarehousesummary/syncSummary.js';
const ADJUSTMENT_TYPE = 'adjustment';
@@ -140,6 +141,12 @@ export default function adjust(provider) {
});
const ledgerRow = inserted.rows[0];
await syncArticleWarehouseSummaryForLedger(runner, {
companyId,
manifestarticleId,
warehouseId,
});
// Write warehouse history for tracked article
if (manifestArticle.manifest_type === 'delivery') {
await recordWarehouseHistory(runner, {

View File

@@ -0,0 +1,19 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { rebuildArticleWarehouseSummaries } from '../articlewarehousesummary/syncSummary.js';
/**
* Rebuilds the `articlewarehousesummary` table for the user's selected company.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function rebuildSummaries(provider) {
const access = resolveSelectedCompanyAccess(provider);
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
await rebuildArticleWarehouseSummaries(runner, companyId);
return { ok: true, companyId };
};
}

View File

@@ -1,14 +1,23 @@
import {
DELETE_BY_ID as MSSQL_DELETE_BY_ID,
CHECK_SUPPLIER_IN_USE as MSSQL_CHECK_IN_USE,
GET_SUPPLIER_BY_ID as MSSQL_GET_SUPPLIER,
SUPPLIER_GROUP_NAME,
} from '../../providers/mssql/queries/entity.js';
import { DELETE_BY_ID as SQLITE_DELETE_BY_ID } from '../../providers/sqlite3/queries/entity.js';
import {
DELETE_BY_ID as SQLITE_DELETE_BY_ID,
CHECK_SUPPLIER_IN_USE as SQLITE_CHECK_IN_USE,
GET_SUPPLIER_BY_ID as SQLITE_GET_SUPPLIER,
} from '../../providers/sqlite3/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE_BY_ID : MSSQL_DELETE_BY_ID;
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_DELETE_BY_ID : MSSQL_DELETE_BY_ID;
const checkInUseSql = isSqlite ? SQLITE_CHECK_IN_USE : MSSQL_CHECK_IN_USE;
const getSupplierSql = isSqlite ? SQLITE_GET_SUPPLIER : MSSQL_GET_SUPPLIER;
/**
* Deletes a supplier by its id within the user's selected company.
@@ -17,21 +26,39 @@ export default function del(provider) {
* @param {number} id - The id of the supplier to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the supplier is not found.
* @throws {Error} If the supplier is not found or is in use.
*
* @example
* await store.supplier.delete(42, 5);
*/
return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Supplier not found');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
const { companyId } = await access(googleAuthId, runner);
const existing = await runner.query(getSupplierSql, {
id: numericId,
companyId,
groupName: SUPPLIER_GROUP_NAME,
});
if (!existing.rows || existing.rows.length === 0) {
throw new Error('Supplier not found');
}
const usage = await runner.query(checkInUseSql, {
id: numericId,
companyId,
});
if (usage.rows && usage.rows.length > 0) {
throw new Error('Supplier in use');
}
const result = await runner.query(sql, {
id,
id: numericId,
companyId,
groupName: SUPPLIER_GROUP_NAME,
});
@@ -41,3 +68,4 @@ export default function del(provider) {
}
};
}

View File

@@ -9,6 +9,7 @@ import {
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { initWarehouseSummaryForNewWarehouse } from '../articlewarehousesummary/syncSummary.js';
function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
@@ -65,6 +66,9 @@ export default function create(provider) {
name: cleaned,
sort,
};
await initWarehouseSummaryForNewWarehouse(runner, { companyId, warehouseId: row.id });
return shape(row);
} catch (err) {
if (

648
test/article.test.js Normal file
View File

@@ -0,0 +1,648 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestStore, createTenant, createArticle, makePng } from './helpers.js';
describe('store.article', () => {
let ctx;
let store;
let tenant;
let other;
let gid;
before(async () => {
ctx = await createTestStore('dash-article-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Article Test GmbH' });
other = await createTenant(store, { name: 'Article Other GmbH' });
gid = tenant.googleAuthId;
});
after(async () => {
await ctx.cleanup();
});
const get = async (sku, who = gid) => (await store.article.getBySku(who, sku)).article;
const historyTexts = async (sku) =>
(await store.article.getHistoryBySku(gid, sku)).map((h) => h.text);
describe('createUnderParent', () => {
it('creates an article with auto-SKU, default unit and untracked stock', async () => {
const art = await createArticle(store, tenant, { name: 'Cola Zero', desc: '<p>Lecker</p>', seo: 'cola' });
assert.equal(typeof art.id, 'number');
assert.equal(art.parentId, tenant.rootCategoryId);
assert.equal(art.name, 'Cola Zero');
assert.equal(art.track_stock, false);
assert.ok(typeof art.sku === 'string' && art.sku.length > 0);
const detail = await get(art.sku);
assert.equal(detail.id, art.id);
assert.equal(detail.name, 'Cola Zero');
assert.equal(detail.slug, 'cola-zero');
assert.equal(detail.desc, '<p>Lecker</p>');
assert.equal(detail.seo, 'cola');
assert.equal(detail.unitamount, 1);
assert.equal(detail.weight, 0);
assert.equal(detail.track_stock, false);
assert.deepEqual(detail.categoryIds, [tenant.rootCategoryId]);
assert.deepEqual(detail.eans, []);
assert.deepEqual(detail.prices, []);
assert.deepEqual(detail.photos, []);
assert.equal(detail.brand, null);
assert.equal(detail.parentArticleId, null);
assert.ok(detail.unit.si, 'default unit is assigned');
});
it('allocates distinct SKUs and unique slugs for equal names', async () => {
const a = await createArticle(store, tenant, { name: 'Dupe Name' });
const b = await createArticle(store, tenant, { name: 'Dupe Name' });
assert.notEqual(a.sku, b.sku);
assert.equal((await get(a.sku)).slug, 'dupe-name');
assert.equal((await get(b.sku)).slug, 'dupe-name-2');
});
it('accepts an explicit SKU and rejects duplicates', async () => {
const art = await createArticle(store, tenant, { sku: 'EXPLICIT-1' });
assert.equal(art.sku, 'EXPLICIT-1');
await assert.rejects(
() => createArticle(store, tenant, { sku: 'EXPLICIT-1' }),
{ message: 'SKU "EXPLICIT-1" already exists' },
);
});
it('records a creation history entry', async () => {
const art = await createArticle(store, tenant, { name: 'History Born' });
const history = await store.article.getHistoryBySku(gid, art.sku);
assert.ok(history.length >= 1);
assert.ok(history.every((h) => typeof h.text === 'string' && h.date));
});
it('rejects a parent category of another company', async () => {
await assert.rejects(
() => createArticle(store, tenant, { parentId: other.rootCategoryId }),
{ message: 'Not authorized' },
);
});
});
describe('stock tracking', () => {
it('cannot enable tracking without a warehouse', async () => {
const fresh = await createTenant(store, { name: 'No Warehouse GmbH' });
const msg = { message: 'At least one warehouse is required to enable stock tracking' };
await assert.rejects(() => createArticle(store, fresh, { track_stock: true }), msg);
const art = await createArticle(store, fresh);
await assert.rejects(() => store.article.setTrackStockBySku(fresh.googleAuthId, art.sku, true), msg);
await assert.rejects(
() => store.article.updateBySku(fresh.googleAuthId, art.sku, { track_stock: true }),
msg,
);
});
it('setTrackStockBySku toggles the flag and validates input', async () => {
await store.warehouse.create(gid, { name: 'Article WH' });
const art = await createArticle(store, tenant);
const on = await store.article.setTrackStockBySku(gid, art.sku, true);
assert.equal(on.track_stock, true);
assert.equal((await get(art.sku)).track_stock, true);
const off = await store.article.setTrackStockBySku(gid, art.sku, 0);
assert.equal(off.track_stock, false);
await assert.rejects(
() => store.article.setTrackStockBySku(gid, art.sku, 'yes'),
{ message: 'Invalid track_stock' },
);
});
});
describe('updateBySku', () => {
it('updates name, desc, seo and unitamount and records history', async () => {
const art = await createArticle(store, tenant, { name: 'Before' });
await store.article.updateBySku(gid, art.sku, {
name: 'After',
desc: 'new desc',
seo: 'new seo',
unitamount: 0.5,
});
const detail = await get(art.sku);
assert.equal(detail.name, 'After');
assert.equal(detail.slug, 'after');
assert.equal(detail.desc, 'new desc');
assert.equal(detail.seo, 'new seo');
assert.equal(detail.unitamount, 0.5);
const texts = await historyTexts(art.sku);
assert.ok(texts.some((t) => t.includes('Name geändert von "Before" auf "After"')));
});
it('validates the patch', async () => {
const art = await createArticle(store, tenant);
const upd = (patch) => store.article.updateBySku(gid, art.sku, patch);
await assert.rejects(() => upd({}), { message: 'Name required' });
await assert.rejects(() => upd({ name: ' ' }), { message: 'Name required' });
await assert.rejects(() => upd({ unitamount: 0 }), { message: 'Invalid unitamount' });
await assert.rejects(() => upd({ unitamount: Number.NaN }), { message: 'Invalid unitamount' });
await assert.rejects(() => upd({ track_stock: 'x' }), { message: 'Invalid track_stock' });
await assert.rejects(() => upd({ unitSi: 'does-not-exist' }), { message: 'Unit not found' });
});
it('rejects unknown SKUs', async () => {
await assert.rejects(
() => store.article.updateBySku(gid, 'NOPE-404', { name: 'x' }),
{ message: 'Article not found' },
);
});
});
describe('setEansBySku', () => {
it('replaces the EAN list (trimmed)', async () => {
const art = await createArticle(store, tenant);
let res = await store.article.setEansBySku(gid, art.sku, [' 4001234567890 ', '4001234567891']);
assert.deepEqual(res.eans, ['4001234567890', '4001234567891']);
res = await store.article.setEansBySku(gid, art.sku, ['4001234567892']);
assert.deepEqual(res.eans, ['4001234567892']);
assert.deepEqual((await get(art.sku)).eans, ['4001234567892']);
res = await store.article.setEansBySku(gid, art.sku, []);
assert.deepEqual(res.eans, []);
});
it('rejects invalid input', async () => {
const art = await createArticle(store, tenant);
const err = { message: 'Invalid eans' };
await assert.rejects(() => store.article.setEansBySku(gid, art.sku, 'x'), err);
await assert.rejects(() => store.article.setEansBySku(gid, art.sku, ['']), err);
await assert.rejects(() => store.article.setEansBySku(gid, art.sku, [123]), err);
await assert.rejects(() => store.article.setEansBySku(gid, art.sku, ['1', ' 1 ']), err);
});
it('rejects an EAN used by another article of the company', async () => {
const a = await createArticle(store, tenant);
const b = await createArticle(store, tenant);
await store.article.setEansBySku(gid, a.sku, ['4009999999999']);
await assert.rejects(
() => store.article.setEansBySku(gid, b.sku, ['4009999999999']),
{ message: 'EAN already in use' },
);
assert.deepEqual((await get(b.sku)).eans, [], 'failed write persisted nothing');
});
});
describe('setPricesBySku', () => {
it('stores net prices with tax rate and minmultiplier', async () => {
const art = await createArticle(store, tenant);
const res = await store.article.setPricesBySku(gid, art.sku, [
{ net: 10, taxRate: 19 },
{ net: 9, tax: 19, minmultiplier: 10 },
]);
assert.equal(res.prices.length, 2);
assert.deepEqual(
res.prices.map((p) => [p.minmultiplier, p.net, p.taxRate]),
[[1, 10, 19], [10, 9, 19]],
);
});
it('converts gross to net', async () => {
const art = await createArticle(store, tenant);
const res = await store.article.setPricesBySku(gid, art.sku, [{ gross: 11.9, taxRate: 19 }]);
assert.equal(res.prices.length, 1);
assert.equal(res.prices[0].net, 10);
});
it('replaces previous prices', async () => {
const art = await createArticle(store, tenant);
await store.article.setPricesBySku(gid, art.sku, [{ net: 1, taxRate: 7 }]);
const res = await store.article.setPricesBySku(gid, art.sku, [{ net: 2, taxRate: 7 }]);
assert.deepEqual(res.prices.map((p) => p.net), [2]);
});
it('rejects invalid prices', async () => {
const art = await createArticle(store, tenant);
const err = { message: 'Invalid prices' };
const set = (prices) => store.article.setPricesBySku(gid, art.sku, prices);
await assert.rejects(() => set('x'), err);
await assert.rejects(() => set([{ net: 1 }]), err);
await assert.rejects(() => set([{ net: 1, taxRate: -1 }]), err);
await assert.rejects(() => set([{ taxRate: 19 }]), err);
await assert.rejects(() => set([{ net: -1, taxRate: 19 }]), err);
await assert.rejects(() => set([{ net: 1, taxRate: 19, minmultiplier: 0 }]), err);
await assert.rejects(() => set([{ net: 1, taxRate: 19 }, { net: 2, taxRate: 19 }]), err);
});
});
describe('setCategoryIdsBySku', () => {
it('replaces category membership', async () => {
const art = await createArticle(store, tenant);
const cat = await store.category.createUnderParent({ googleAuthId: gid, parentId: tenant.rootCategoryId, name: 'Art Cat' });
const res = await store.article.setCategoryIdsBySku(gid, art.sku, [cat.id, tenant.rootCategoryId]);
assert.deepEqual([...res.categoryIds].sort(), [cat.id, tenant.rootCategoryId].sort());
await store.article.setCategoryIdsBySku(gid, art.sku, [cat.id]);
assert.deepEqual((await get(art.sku)).categoryIds, [cat.id]);
});
it('validates the category ids', async () => {
const art = await createArticle(store, tenant);
const set = (ids) => store.article.setCategoryIdsBySku(gid, art.sku, ids);
await assert.rejects(() => set('x'), { message: 'Invalid categoryIds' });
await assert.rejects(() => set([]), { message: 'Article must have at least one category' });
await assert.rejects(() => set([tenant.rootCategoryId, tenant.rootCategoryId]), { message: 'Invalid categoryIds' });
await assert.rejects(() => set([other.rootCategoryId]), /Invalid categoryIds|Not authorized/);
assert.deepEqual((await get(art.sku)).categoryIds, [tenant.rootCategoryId]);
});
});
describe('setWeightBySku', () => {
it('sets the weight and validates it', async () => {
const art = await createArticle(store, tenant);
const res = await store.article.setWeightBySku(gid, art.sku, 1.5);
assert.equal(res.weight, 1.5);
assert.equal((await get(art.sku)).weight, 1.5);
await assert.rejects(() => store.article.setWeightBySku(gid, art.sku, -1), { message: 'Invalid weight' });
await assert.rejects(() => store.article.setWeightBySku(gid, art.sku, 'heavy'), { message: 'Invalid weight' });
});
});
describe('setBrandIdBySku', () => {
it('sets and clears the brand', async () => {
const art = await createArticle(store, tenant);
const brand = await store.brand.create(gid, { name: 'Acme' });
await store.article.setBrandIdBySku(gid, art.sku, brand.id);
let detail = await get(art.sku);
assert.equal(detail.brand.id, brand.id);
assert.equal(detail.brand.name, 'Acme');
await store.article.setBrandIdBySku(gid, art.sku, null);
detail = await get(art.sku);
assert.equal(detail.brand, null);
});
it('rejects invalid and foreign brands', async () => {
const art = await createArticle(store, tenant);
const foreignBrand = await store.brand.create(other.googleAuthId, { name: 'Foreign Brand' });
await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, 'x'), { message: 'Invalid brandId' });
await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, 0), { message: 'Invalid brandId' });
await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, 999999), { message: 'Brand not found' });
await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, foreignBrand.id), { message: 'Brand not found' });
});
});
describe('getHistoryBySku', () => {
it('honours the limit option', async () => {
const art = await createArticle(store, tenant, { name: 'H0' });
await store.article.updateBySku(gid, art.sku, { name: 'H1' });
await store.article.updateBySku(gid, art.sku, { name: 'H2' });
const all = await store.article.getHistoryBySku(gid, art.sku);
assert.ok(all.length >= 3);
const limited = await store.article.getHistoryBySku(gid, art.sku, { limit: 1 });
assert.equal(limited.length, 1);
});
});
describe('listSearchIndex / listExport', () => {
it('includes sku, name, eans and categories', async () => {
const art = await createArticle(store, tenant, { name: 'Indexed' });
await store.article.setEansBySku(gid, art.sku, ['4000000000017']);
const index = await store.article.listSearchIndex(gid);
const entry = index.find((e) => e.sku === art.sku);
assert.ok(entry);
assert.equal(entry.name, 'Indexed');
assert.deepEqual(entry.eans, ['4000000000017']);
assert.deepEqual(entry.categoryIds, [tenant.rootCategoryId]);
const exported = await store.article.listExport(gid, null);
assert.ok(exported.some((e) => e.sku === art.sku));
});
it('scopes export to a category', async () => {
const cat = await store.category.createUnderParent({ googleAuthId: gid, parentId: tenant.rootCategoryId, name: 'Export Cat' });
const inside = await createArticle(store, tenant, { parentId: cat.id });
const outside = await createArticle(store, tenant);
const exported = await store.article.listExport(gid, cat.id);
const skus = exported.map((e) => e.sku);
assert.ok(skus.includes(inside.sku));
assert.ok(!skus.includes(outside.sku));
});
it('does not leak articles of other companies', async () => {
const foreign = await createArticle(store, other, { name: 'Foreign Indexed', sku: 'FOREIGN-INDEXED' });
const index = await store.article.listSearchIndex(gid);
assert.ok(!index.some((e) => e.sku === foreign.sku));
await assert.rejects(() => store.article.listExport(gid, other.rootCategoryId), /Not authorized|not found/);
});
});
describe('delete', () => {
it('deletes an article', async () => {
const art = await createArticle(store, tenant);
await store.article.delete(gid, art.sku);
await assert.rejects(() => get(art.sku), { message: 'Article not found' });
});
it('rejects unknown SKUs', async () => {
await assert.rejects(() => store.article.delete(gid, 'NOPE-404'), { message: 'Article not found' });
});
it('cannot delete articles of another company', async () => {
const foreign = await createArticle(store, other, { sku: 'FOREIGN-DELETE' });
await assert.rejects(() => store.article.delete(gid, foreign.sku), /Article not found|Not authorized/);
assert.ok(await get(foreign.sku, other.googleAuthId));
});
});
describe('variants (parent / child)', () => {
let attr;
before(async () => {
attr = await store.attributename.create(gid, { name: 'Farbe' });
});
const makeParent = async (name = 'T-Shirt') => {
const parent = await createArticle(store, tenant, { name });
await store.article.toggleParent(gid, parent.sku, { isParent: true, attributeNameId: attr.id });
return parent;
};
it('toggleParent requires an attribute name', async () => {
const art = await createArticle(store, tenant);
await assert.rejects(
() => store.article.toggleParent(gid, art.sku, { isParent: true }),
{ message: 'attributeNameId is required to become a parent article' },
);
});
it('addChild creates sequential child SKUs and listChildren lists them', async () => {
const parent = await makeParent();
const red = await store.article.addChild(gid, parent.sku, { value: 'Rot' });
const blue = await store.article.addChild(gid, parent.sku, { value: 'Blau' });
assert.equal(red.article.sku, `${parent.sku}-1`);
assert.equal(blue.article.sku, `${parent.sku}-2`);
const children = await store.article.listChildren(gid, parent.sku);
assert.deepEqual(children.map((c) => c.sku), [red.article.sku, blue.article.sku]);
assert.deepEqual(children.map((c) => c.parentsort), [0, 1]);
assert.deepEqual(children.map((c) => c.attributeValue), ['Rot', 'Blau']);
const detail = await get(parent.sku);
assert.equal(detail.isParent, true);
assert.equal(detail.childCount, 2);
assert.equal((await get(red.article.sku)).parentSku, parent.sku);
});
it('addChild rejects duplicates, explicit SKUs and non-parents', async () => {
const parent = await makeParent('Hoodie');
await store.article.addChild(gid, parent.sku, { value: 'Rot' });
await assert.rejects(
() => store.article.addChild(gid, parent.sku, { value: 'Rot' }),
{ message: 'Variant with this attribute value already exists' },
);
await assert.rejects(
() => store.article.addChild(gid, parent.sku, { value: 'Lila', sku: 'CUSTOM' }),
/sku is not allowed when creating a child article/,
);
await assert.rejects(
() => store.article.addChild(gid, parent.sku, {}),
{ message: 'Attribute value is required for child article' },
);
const plain = await createArticle(store, tenant);
await assert.rejects(
() => store.article.addChild(gid, plain.sku, { value: 'Rot' }),
{ message: 'Article is not a parent article' },
);
await assert.rejects(
() => store.article.addChild(gid, 'NOPE-404', { value: 'Rot' }),
{ message: 'Article not found' },
);
});
it('parent articles cannot have prices', async () => {
const parent = await makeParent('Priceless');
await assert.rejects(
() => store.article.setPricesBySku(gid, parent.sku, [{ net: 1, taxRate: 19 }]),
{ message: 'A parent article cannot have prices' },
);
});
it('removeChild unlinks (re-compacting parentsort) or deletes', async () => {
const parent = await makeParent('Cap');
const c1 = await store.article.addChild(gid, parent.sku, { value: 'A' });
const c2 = await store.article.addChild(gid, parent.sku, { value: 'B' });
const c3 = await store.article.addChild(gid, parent.sku, { value: 'C' });
const unlinked = await store.article.removeChild(gid, parent.sku, c2.article.sku);
assert.equal(unlinked.success, true);
assert.equal(unlinked.deleted, false);
assert.equal((await get(c2.article.sku)).parentArticleId, null);
let children = await store.article.listChildren(gid, parent.sku);
assert.deepEqual(children.map((c) => c.sku), [c1.article.sku, c3.article.sku]);
assert.deepEqual(children.map((c) => c.parentsort), [0, 1]);
const deleted = await store.article.removeChild(gid, parent.sku, c3.article.sku, { deleteArticle: true });
assert.equal(deleted.deleted, true);
await assert.rejects(() => get(c3.article.sku), { message: 'Article not found' });
await assert.rejects(
() => store.article.removeChild(gid, parent.sku, c2.article.sku),
{ message: 'Child article does not belong to this parent' },
);
children = await store.article.listChildren(gid, parent.sku);
assert.equal(children.length, 1);
});
it('attachChild attaches an existing article and guards against misuse', async () => {
const parent = await makeParent('Sock');
const loose = await createArticle(store, tenant, { name: 'Loose Sock' });
const res = await store.article.attachChild(gid, parent.sku, { sku: loose.sku, value: 'Grün' });
assert.equal(res.article.sku, loose.sku);
assert.equal(res.article.parentArticleId, parent.id);
assert.ok(res.children.some((c) => c.sku === loose.sku));
// idempotent on same parent
const again = await store.article.attachChild(gid, parent.sku, { sku: loose.sku, value: 'Grün' });
assert.equal(again.article.sku, loose.sku);
await assert.rejects(
() => store.article.attachChild(gid, parent.sku, {}),
{ message: 'sku is required to attach an existing article' },
);
await assert.rejects(
() => store.article.attachChild(gid, parent.sku, { sku: parent.sku, value: 'X' }),
{ message: 'An article cannot be a child of itself' },
);
const otherParent = await makeParent('Other Sock');
await assert.rejects(
() => store.article.attachChild(gid, otherParent.sku, { sku: loose.sku, value: 'Gelb' }),
{ message: 'Article already belongs to another parent' },
);
});
it('cannot disable parent mode while children exist', async () => {
const parent = await makeParent('Scarf');
await store.article.addChild(gid, parent.sku, { value: 'Rot' });
await assert.rejects(
() => store.article.toggleParent(gid, parent.sku, { isParent: false }),
{ message: 'Cannot disable parent mode on an article that still has children' },
);
});
it('delete refuses parents with children unless cascade', async () => {
const parent = await makeParent('Glove');
const child = await store.article.addChild(gid, parent.sku, { value: 'Rot' });
await assert.rejects(() => store.article.delete(gid, parent.sku), { message: 'Article has children' });
await store.article.delete(gid, parent.sku, { cascade: true });
await assert.rejects(() => get(parent.sku), { message: 'Article not found' });
await assert.rejects(() => get(child.article.sku), { message: 'Article not found' });
});
it('setParentBySku links and validates the parent', async () => {
const parent = await createArticle(store, tenant, { name: 'Compound V' });
const child = await createArticle(store, tenant, { name: 'Compound K' });
const linked = await store.article.setParentBySku(gid, child.sku, parent.sku);
assert.equal(linked.parentArticleId, parent.id);
assert.equal(linked.parentSku, parent.sku);
assert.equal((await get(parent.sku)).isParent, true);
await assert.rejects(() => store.article.setParentBySku(gid, child.sku, 'NOPE-404'), { message: 'Parent not found' });
await assert.rejects(() => store.article.setParentBySku(gid, child.sku, child.sku), { message: 'Invalid parent' });
});
it('setParentBySku unlinks with null', {
todo: 'BUG src/store/article/setParentBySku.js:66 — insertHistorySql is not defined (ReferenceError when unlinking a child that has a parent)',
}, async () => {
const parent = await createArticle(store, tenant, { name: 'Unlink V' });
const child = await createArticle(store, tenant, { name: 'Unlink K' });
await store.article.setParentBySku(gid, child.sku, parent.sku);
const unlinked = await store.article.setParentBySku(gid, child.sku, null);
assert.equal(unlinked.parentArticleId, null);
});
});
describe('importRows', () => {
it('imports rows, resolves categories/brands/prices/eans and links parents in a 2nd pass', async () => {
const result = await store.article.importRows({
googleAuthId: gid,
rows: [
// child appears before its parent
{ sku: 'IMP-1-1', name: 'Imported 1g', vk: 'K', parentCategory: 'Import | Flowers', brand: 'ImportBrand', net: '11,00', tax: 19, eans: ['4262557690455'], parentSku: 'IMP-1' },
{ sku: 'IMP-1', name: 'Imported', vk: 'V', parentCategory: 'Import | Flowers' },
{ sku: '', name: 'No SKU' },
],
});
assert.deepEqual(result.imported.map((r) => r.sku).sort(), ['IMP-1', 'IMP-1-1']);
assert.equal(result.errors.length, 1);
assert.equal(result.errors[0].error, 'Invalid row');
const child = await get('IMP-1-1');
assert.equal(child.name, 'Imported 1g');
assert.equal(child.brand.name, 'ImportBrand');
assert.equal(child.prices[0].net, 11);
assert.equal(child.prices[0].taxRate, 19);
assert.deepEqual(child.eans, ['4262557690455']);
assert.equal(child.parentSku, 'IMP-1');
const { tree } = await store.category.getTreeForCompany(gid);
const leaf = tree.find((c) => c.id === child.categoryIds[0]);
assert.equal(leaf.name, 'Flowers');
});
it('skips existing SKUs and requires a parent category', async () => {
const existing = await createArticle(store, tenant);
const result = await store.article.importRows({
googleAuthId: gid,
rows: [
{ sku: existing.sku, name: 'Again', parentId: tenant.rootCategoryId },
{ sku: 'IMP-NOCAT', name: 'No Category' },
],
});
assert.deepEqual(result.skipped.map((s) => s.sku), [existing.sku]);
assert.equal(result.errors.length, 1);
assert.equal(result.errors[0].error, 'Parent category required');
assert.equal(result.imported.length, 0);
});
it('rejects non-array rows', async () => {
await assert.rejects(
() => store.article.importRows({ googleAuthId: gid, rows: 'nope' }),
{ message: 'Invalid rows' },
);
});
});
describe('images', () => {
it('adds, orders and deletes images', async () => {
const art = await createArticle(store, tenant);
const img1 = await store.article.addImage(gid, art.sku, await makePng({ r: 10 }));
const img2 = await store.article.addImage(gid, art.sku, await makePng({ r: 250, width: 80 }));
assert.notEqual(img1.fullHash, img2.fullHash);
assert.equal(img1.full.w, 64);
assert.equal(img1.full.h, 48);
assert.ok(img1.thumb.w > 0 && img1.smallThumb.w > 0);
let photos = (await get(art.sku)).photos;
assert.deepEqual(photos.map((p) => p.fullHash), [img1.fullHash, img2.fullHash]);
const ordered = await store.article.orderImages(gid, art.sku, { fullHashes: [img2.fullHash, img1.fullHash] });
assert.deepEqual(ordered.fullHashes, [img2.fullHash, img1.fullHash]);
photos = (await get(art.sku)).photos;
assert.deepEqual(photos.map((p) => p.fullHash), [img2.fullHash, img1.fullHash]);
await assert.rejects(
() => store.article.orderImages(gid, art.sku, { fullHashes: [img1.fullHash] }),
{ message: 'Invalid image order' },
);
await assert.rejects(
() => store.article.orderImages(gid, art.sku, { fullHashes: [img1.fullHash, 'bogus'] }),
{ message: 'Invalid image order' },
);
await store.article.deleteImage(gid, art.sku, img2.fullHash);
photos = (await get(art.sku)).photos;
assert.deepEqual(photos.map((p) => p.fullHash), [img1.fullHash]);
await assert.rejects(
() => store.article.deleteImage(gid, art.sku, img2.fullHash),
{ message: 'Image not found' },
);
});
});
describe('tenant isolation', () => {
it('getBySku / setters do not work across companies', async () => {
const foreign = await createArticle(store, other, { name: 'Foreign', sku: 'FOREIGN-ISO' });
const denied = /Article not found|Not authorized/;
await assert.rejects(() => store.article.getBySku(gid, foreign.sku), denied);
await assert.rejects(() => store.article.updateBySku(gid, foreign.sku, { name: 'Hijack' }), denied);
await assert.rejects(() => store.article.setEansBySku(gid, foreign.sku, ['4011111111111']), denied);
await assert.rejects(() => store.article.setPricesBySku(gid, foreign.sku, [{ net: 1, taxRate: 19 }]), denied);
await assert.rejects(() => store.article.setWeightBySku(gid, foreign.sku, 2), denied);
const untouched = await get(foreign.sku, other.googleAuthId);
assert.equal(untouched.name, 'Foreign');
assert.deepEqual(untouched.eans, []);
});
});
});

337
test/category.test.js Normal file
View File

@@ -0,0 +1,337 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestStore, createTenant, createArticle, makePng } from './helpers.js';
describe('store.category', () => {
let ctx;
let store;
let tenant;
let other;
before(async () => {
ctx = await createTestStore('dash-category-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Category Test GmbH' });
other = await createTenant(store, { name: 'Category Other GmbH' });
});
after(async () => {
await ctx.cleanup();
});
const create = (name, parentId = tenant.rootCategoryId, who = tenant) =>
store.category.createUnderParent({ googleAuthId: who.googleAuthId, parentId, name });
const findInTree = async (id, who = tenant) => {
const { tree } = await store.category.getTreeForCompany(who.googleAuthId);
return tree.find((c) => c.id === id);
};
describe('createUnderParent', () => {
it('creates a child with name, slug and incrementing sort', async () => {
const parent = await create('Create Parent');
const a = await create('Getränke & Säfte', parent.id);
const b = await create('Snacks', parent.id);
assert.equal(a.parentId, parent.id);
assert.equal(a.name, 'Getränke & Säfte');
assert.equal(a.slug, 'getraenke-saefte');
assert.equal(typeof a.id, 'number');
assert.ok(b.sort > a.sort, 'second sibling gets a higher sort');
});
it('rejects an unknown parent', async () => {
await assert.rejects(() => create('Orphan', 999999), { message: 'Parent not found' });
});
it('rejects a parent of another company', async () => {
await assert.rejects(() => create('Intruder', other.rootCategoryId), {
message: 'Not authorized',
});
});
});
describe('getTreeForCompany', () => {
it('returns root + created categories with article counts', async () => {
const cat = await create('Tree Cat');
await createArticle(store, tenant, { parentId: cat.id, name: 'Tree Article' });
const result = await store.category.getTreeForCompany(tenant.googleAuthId);
assert.equal(typeof result.languageId, 'number');
assert.ok(Array.isArray(result.tree));
const root = result.tree.find((c) => c.id === tenant.rootCategoryId);
assert.ok(root, 'root category is part of the tree');
assert.equal(root.parentId, null);
const node = result.tree.find((c) => c.id === cat.id);
assert.equal(node.parentId, tenant.rootCategoryId);
assert.equal(node.name, 'Tree Cat');
assert.equal(node.articleCount, 1);
assert.deepEqual(node.posterminalIds, []);
assert.ok(result.articleCount >= 1);
});
it('does not leak categories of other companies', async () => {
const foreign = await create('Foreign Only', other.rootCategoryId, other);
assert.equal(await findInTree(foreign.id), undefined);
assert.ok(await findInTree(foreign.id, other));
});
});
describe('getCategoryArticles', () => {
it('lists only the articles of that category', async () => {
const cat = await create('Articles Cat');
const art = await createArticle(store, tenant, { parentId: cat.id, name: 'Listed' });
await createArticle(store, tenant, { name: 'Not Listed' });
const list = await store.category.getCategoryArticles(tenant.googleAuthId, cat.id);
assert.equal(list.length, 1);
assert.equal(list[0].sku, art.sku);
assert.equal(list[0].name, 'Listed');
assert.equal(list[0].parentArticleId, null);
assert(Array.isArray(list[0].articlewarehousesummary));
assert(Array.isArray(list[0].warehouses));
});
it('rejects a category of another company', async () => {
await assert.rejects(
() => store.category.getCategoryArticles(tenant.googleAuthId, other.rootCategoryId),
/Not authorized|not found/,
);
});
});
describe('ensureByPath', () => {
it('creates missing path segments and is idempotent', async () => {
const leaf = await store.category.ensureByPath({
googleAuthId: tenant.googleAuthId,
parentId: tenant.rootCategoryId,
path: 'Flowers | CBN | Indoor',
});
const again = await store.category.ensureByPath({
googleAuthId: tenant.googleAuthId,
parentId: tenant.rootCategoryId,
path: ' Flowers|CBN |Indoor ',
});
assert.equal(again, leaf);
const node = await findInTree(leaf);
assert.equal(node.name, 'Indoor');
const mid = await findInTree(node.parentId);
assert.equal(mid.name, 'CBN');
const top = await findInTree(mid.parentId);
assert.equal(top.name, 'Flowers');
assert.equal(top.parentId, tenant.rootCategoryId);
});
it('reuses existing prefix segments', async () => {
const cbn = await store.category.ensureByPath({
googleAuthId: tenant.googleAuthId,
parentId: tenant.rootCategoryId,
path: 'Flowers | CBN',
});
const outdoor = await store.category.ensureByPath({
googleAuthId: tenant.googleAuthId,
parentId: tenant.rootCategoryId,
path: 'Flowers | CBN | Outdoor',
});
assert.equal((await findInTree(outdoor)).parentId, cbn);
});
it('rejects an empty path', async () => {
await assert.rejects(
() => store.category.ensureByPath({
googleAuthId: tenant.googleAuthId,
parentId: tenant.rootCategoryId,
path: ' | | ',
}),
{ message: 'Invalid category path' },
);
});
});
describe('rename', () => {
it('renames and re-slugs', async () => {
const cat = await create('Old Name');
const res = await store.category.rename({
googleAuthId: tenant.googleAuthId,
id: cat.id,
name: 'Soft Drinks',
});
assert.deepEqual(res, { id: cat.id, name: 'Soft Drinks', slug: 'soft-drinks' });
assert.equal((await findInTree(cat.id)).name, 'Soft Drinks');
});
it('rejects an empty name', async () => {
const cat = await create('Keep Name');
await assert.rejects(
() => store.category.rename({ googleAuthId: tenant.googleAuthId, id: cat.id, name: ' ' }),
{ message: 'Name required' },
);
});
it('rejects an unknown category', async () => {
await assert.rejects(
() => store.category.rename({ googleAuthId: tenant.googleAuthId, id: 999999, name: 'X' }),
/not found/,
);
});
});
describe('move', () => {
it('moves a category under a new parent', async () => {
const a = await create('Move A');
const b = await create('Move B');
const res = await store.category.move({ googleAuthId: tenant.googleAuthId, id: b.id, parentId: a.id });
assert.equal(res.id, b.id);
assert.equal(res.parentId, a.id);
assert.equal((await findInTree(b.id)).parentId, a.id);
});
it('rejects moving a category into itself', async () => {
const a = await create('Self Move');
await assert.rejects(
() => store.category.move({ googleAuthId: tenant.googleAuthId, id: a.id, parentId: a.id }),
{ message: 'Invalid parent' },
);
});
it('rejects cycles (moving a parent under its descendant)', async () => {
const top = await create('Cycle Top');
const mid = await create('Cycle Mid', top.id);
const low = await create('Cycle Low', mid.id);
await assert.rejects(
() => store.category.move({ googleAuthId: tenant.googleAuthId, id: top.id, parentId: low.id }),
{ message: 'Invalid parent' },
);
});
it('rejects cross-company moves', async () => {
const a = await create('Cross Move');
await assert.rejects(
() => store.category.move({ googleAuthId: tenant.googleAuthId, id: a.id, parentId: other.rootCategoryId }),
/Not authorized|Parent not found/,
);
});
});
describe('order', () => {
it('applies the given sibling order', async () => {
const parent = await create('Order Parent');
const c1 = await create('O1', parent.id);
const c2 = await create('O2', parent.id);
const c3 = await create('O3', parent.id);
const res = await store.category.order({
googleAuthId: tenant.googleAuthId,
parentId: parent.id,
categoryIds: [c3.id, c1.id, c2.id],
});
assert.deepEqual(res.categoryIds, [c3.id, c1.id, c2.id]);
assert.equal((await findInTree(c3.id)).sort, 0);
assert.equal((await findInTree(c1.id)).sort, 1);
assert.equal((await findInTree(c2.id)).sort, 2);
});
it('rejects incomplete, duplicate or foreign ids', async () => {
const parent = await create('Order Invalid');
const c1 = await create('I1', parent.id);
const c2 = await create('I2', parent.id);
const stranger = await create('Stranger');
const call = (categoryIds) => store.category.order({
googleAuthId: tenant.googleAuthId,
parentId: parent.id,
categoryIds,
});
const err = { message: 'Invalid category order' };
await assert.rejects(() => call('nope'), err);
await assert.rejects(() => call([c1.id]), err);
await assert.rejects(() => call([c1.id, c1.id]), err);
await assert.rejects(() => call([c1.id, stranger.id]), err);
// valid order still works afterwards
await call([c2.id, c1.id]);
});
});
describe('delete', () => {
it('deletes an empty category', async () => {
const cat = await create('Delete Me');
await store.category.delete({ googleAuthId: tenant.googleAuthId, id: cat.id });
assert.equal(await findInTree(cat.id), undefined);
});
it('refuses to delete the root category', async () => {
await assert.rejects(
() => store.category.delete({ googleAuthId: tenant.googleAuthId, id: tenant.rootCategoryId }),
{ message: 'Cannot delete root category' },
);
});
it('refuses non-recursive delete with subcategories or articles', async () => {
const withSub = await create('Has Sub');
await create('Sub', withSub.id);
await assert.rejects(
() => store.category.delete({ googleAuthId: tenant.googleAuthId, id: withSub.id }),
{ message: 'Category has subcategories' },
);
const withArt = await create('Has Article');
await createArticle(store, tenant, { parentId: withArt.id });
await assert.rejects(
() => store.category.delete({ googleAuthId: tenant.googleAuthId, id: withArt.id }),
{ message: 'Category has articles' },
);
});
it('recursive delete removes subtree and articles only living in it', async () => {
const top = await create('Recursive Top');
const sub = await create('Recursive Sub', top.id);
const keepCat = await create('Recursive Keep');
const onlyInSubtree = await createArticle(store, tenant, { parentId: sub.id, name: 'Doomed' });
const shared = await createArticle(store, tenant, { parentId: sub.id, name: 'Survivor' });
await store.article.setCategoryIdsBySku(tenant.googleAuthId, shared.sku, [sub.id, keepCat.id]);
await store.category.delete({ googleAuthId: tenant.googleAuthId, id: top.id, recursive: true });
assert.equal(await findInTree(top.id), undefined);
assert.equal(await findInTree(sub.id), undefined);
await assert.rejects(
() => store.article.getBySku(tenant.googleAuthId, onlyInSubtree.sku),
{ message: 'Article not found' },
);
const survivor = await store.article.getBySku(tenant.googleAuthId, shared.sku);
assert.deepEqual(survivor.article.categoryIds, [keepCat.id]);
});
});
describe('addImage / deleteImage', () => {
it('adds an image, exposes it in the tree and deletes it again', async () => {
const cat = await create('Image Cat');
const img = await store.category.addImage(tenant.googleAuthId, cat.id, await makePng());
assert.equal(img.id, cat.id);
assert.equal(typeof img.fullHash, 'string');
assert.ok(img.fullHash.length > 0);
assert.ok(img.thumbW > 0 && img.thumbH > 0);
assert.ok(img.smallThumbW > 0 && img.smallThumbH > 0);
assert.equal((await findInTree(cat.id)).fullHash, img.fullHash);
const cleared = await store.category.deleteImage(tenant.googleAuthId, cat.id);
assert.deepEqual(cleared, { id: cat.id, thumbW: null, thumbH: null, thumbHash: null });
assert.equal((await findInTree(cat.id)).fullHash, null);
});
it('deleteImage without image throws', async () => {
const cat = await create('No Image Cat');
await assert.rejects(
() => store.category.deleteImage(tenant.googleAuthId, cat.id),
{ message: 'Image not found' },
);
});
});
});

303
test/delivery.test.js Normal file
View File

@@ -0,0 +1,303 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestStore, createTenant, createArticle, isoNow } from './helpers.js';
describe('store.delivery', () => {
let ctx;
let store;
let tenant;
let other;
let gid;
let supplier;
let warehouse;
let tracked;
const line = (sku, amount = 5, extra = {}) => ({ sku, name: `Line ${sku}`, pu: 1, amount, net: 10, taxrate: 19, ...extra });
const create = (body = {}) =>
store.delivery.create(gid, { supplierId: supplier.id, date: isoNow(), lines: [], ...body });
before(async () => {
ctx = await createTestStore('dash-delivery-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Delivery Test GmbH' });
other = await createTenant(store, { name: 'Delivery Other GmbH' });
gid = tenant.googleAuthId;
supplier = await store.supplier.create(gid, { name: 'Supplier One' });
warehouse = await store.warehouse.create(gid, { name: 'Hauptlager' });
tracked = await createArticle(store, tenant, { name: 'Tracked', sku: 'DEL-TRACKED', track_stock: true });
});
after(async () => {
await ctx.cleanup();
});
describe('create / getById', () => {
it('creates a delivery with number, lines and an unbooked summary', async () => {
const d = await create({
externalNumber: ' EXT-1 ',
lines: [line('DEL-TRACKED', 5), { name: 'Free text', amount: 2, net: 3.5, taxrate: 7 }],
});
assert.equal(typeof d.id, 'number');
assert.equal(d.type, 'delivery');
assert.ok(d.number);
assert.equal(d.externalNumber, 'EXT-1');
assert.equal(d.supplierId, supplier.id);
assert.equal(d.lines.length, 2);
assert.equal(d.lines[0].sku, 'DEL-TRACKED');
assert.equal(d.lines[0].amount, 5);
assert.equal(d.lines[1].sku, null);
assert.equal(d.lines[1].name, 'Free text');
assert.equal(d.lines[1].pu, 1);
assert.equal(d.positions, 2);
assert.equal(d.isBooked, false);
assert.deepEqual(d.files, []);
const fetched = await store.delivery.getById(gid, d.id);
assert.equal(fetched.id, d.id);
assert.equal(fetched.number, d.number);
assert.equal(fetched.lines.length, 2);
});
it('allocates distinct sequential numbers', async () => {
const a = await create();
const b = await create();
assert.notEqual(a.number, b.number);
});
it('allows a delivery without supplier', async () => {
const d = await store.delivery.create(gid, { date: isoNow(), lines: [] });
assert.equal(d.supplierId, null);
});
it('validates input', async () => {
await assert.rejects(() => create({ date: 'garbage' }), { message: 'Invalid delivery' });
await assert.rejects(() => create({ createdAt: 'garbage' }), { message: 'Invalid delivery' });
await assert.rejects(() => create({ supplierId: '1' }), { message: 'Invalid supplierId' });
await assert.rejects(() => create({ supplierId: 999999 }), { message: 'Supplier not found' });
await assert.rejects(() => create({ lines: 'x' }), { message: 'Invalid lines' });
await assert.rejects(() => create({ lines: [{ amount: 1, net: 1, taxrate: 19 }] }), { message: 'Invalid lines' });
await assert.rejects(() => create({ lines: [line('DEL-TRACKED', 'many')] }), { message: 'Invalid lines' });
await assert.rejects(() => create({ lines: [line('DEL-TRACKED', 1, { taxrate: -1 })] }), { message: 'Invalid lines' });
});
it('rejects a supplier of another company', async () => {
const foreign = await store.supplier.create(other.googleAuthId, { name: 'Foreign Supplier' });
await assert.rejects(() => create({ supplierId: foreign.id }), { message: 'Supplier not found' });
});
it('rejects booking a parent article on a document', async () => {
const attr = await store.attributename.create(gid, { name: 'Delivery Attr' });
const parent = await createArticle(store, tenant, { name: 'Parent', sku: 'DEL-PARENT' });
await store.article.toggleParent(gid, parent.sku, { isParent: true, attributeNameId: attr.id });
await assert.rejects(
() => create({ lines: [line('DEL-PARENT')] }),
/A parent article cannot be booked on documents \(DEL-PARENT\)/,
);
});
it('getById rejects unknown and foreign deliveries', async () => {
const d = await create();
await assert.rejects(() => store.delivery.getById(gid, 999999), { message: 'Delivery not found' });
await assert.rejects(() => store.delivery.getById(other.googleAuthId, d.id), { message: 'Delivery not found' });
});
});
describe('update', () => {
it('updates header fields', async () => {
const d = await create({ externalNumber: 'OLD' });
const upd = await store.delivery.update(gid, d.id, { externalNumber: 'NEW', number: 'MY-NUMBER' });
assert.equal(upd.externalNumber, 'NEW');
assert.equal(upd.number, 'MY-NUMBER');
const cleared = await store.delivery.update(gid, d.id, { externalNumber: ' ' });
assert.equal(cleared.externalNumber, null);
const noSupplier = await store.delivery.update(gid, d.id, { supplierId: null });
assert.equal(noSupplier.supplierId, null);
});
it('replaces lines of an unbooked delivery', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
const upd = await store.delivery.update(gid, d.id, { lines: [line('DEL-TRACKED', 7), line('DEL-TRACKED', 3)] });
assert.deepEqual(upd.lines.map((l) => l.amount), [7, 3]);
});
it('refuses to replace lines that are already booked', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 4)] });
await store.delivery.book(gid, { id: d.id, warehouseId: warehouse.id });
await assert.rejects(
() => store.delivery.update(gid, d.id, { lines: [line('DEL-TRACKED', 9)] }),
{ message: 'Delivery lines already booked on ledger' },
);
assert.equal((await store.delivery.getById(gid, d.id)).lines[0].amount, 4);
});
it('validates input', async () => {
const d = await create();
await assert.rejects(() => store.delivery.update(gid, d.id, {}), { message: 'Invalid delivery' });
await assert.rejects(() => store.delivery.update(gid, d.id, { number: ' ' }), { message: 'Invalid delivery' });
await assert.rejects(() => store.delivery.update(gid, d.id, { date: 'x' }), { message: 'Invalid delivery' });
await assert.rejects(() => store.delivery.update(gid, d.id, { createdAt: 'x' }), { message: 'Invalid delivery' });
await assert.rejects(() => store.delivery.update(gid, d.id, { supplierId: 999999 }), { message: 'Supplier not found' });
await assert.rejects(() => store.delivery.update(gid, 999999, { number: 'X' }), { message: 'Delivery not found' });
await assert.rejects(() => store.delivery.update(gid, Number.NaN, { number: 'X' }), { message: 'Delivery not found' });
});
it('cannot update deliveries of another company', async () => {
const d = await create();
await assert.rejects(
() => store.delivery.update(other.googleAuthId, d.id, { number: 'HIJACK' }),
{ message: 'Delivery not found' },
);
});
});
describe('book', () => {
it('books all lines and marks the delivery as booked', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 6), line('DEL-TRACKED', 2)] });
const booked = await store.delivery.book(gid, { id: d.id, warehouseId: warehouse.id });
assert.equal(booked.isBooked, true);
assert.equal(booked.ledger.isBooked, true);
assert.equal(booked.ledger.completelyOnLedgerPositions, 2);
for (const l of booked.lines) {
const entries = await store.ledger.listByManifestarticleId(gid, l.id);
assert.deepEqual(entries.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE']);
assert.equal(entries[1].warehouse_id, warehouse.id);
}
});
it('accepts a plain id and rejects double booking', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
await store.delivery.book(gid, d.id);
await assert.rejects(
() => store.delivery.book(gid, d.id),
/Delivery line(s)? already (accepted|booked) on ledger/,
);
});
it('rejects invalid, unknown and empty deliveries', async () => {
await assert.rejects(() => store.delivery.book(gid, {}), { message: 'Delivery not found' });
await assert.rejects(() => store.delivery.book(gid, 999999), { message: 'Delivery not found' });
const empty = await create();
await assert.rejects(() => store.delivery.book(gid, empty.id), { message: 'Delivery has no lines' });
});
it('cannot book a delivery of another company', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
await assert.rejects(() => store.delivery.book(other.googleAuthId, d.id), { message: 'Delivery not found' });
});
});
describe('delete', () => {
it('deletes an unbooked delivery', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
await store.delivery.delete(gid, d.id);
await assert.rejects(() => store.delivery.getById(gid, d.id), { message: 'Delivery not found' });
});
it('refuses to delete a booked delivery', async () => {
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
await store.delivery.book(gid, d.id);
await assert.rejects(() => store.delivery.delete(gid, d.id), { message: 'Delivery in use in ledger' });
assert.ok(await store.delivery.getById(gid, d.id));
});
it('rejects unknown ids and foreign deliveries', async () => {
const d = await create();
await assert.rejects(() => store.delivery.delete(gid, 999999), { message: 'Delivery not found' });
await assert.rejects(() => store.delivery.delete(gid, Number.NaN), { message: 'Delivery not found' });
await assert.rejects(() => store.delivery.delete(other.googleAuthId, d.id), { message: 'Delivery not found' });
assert.ok(await store.delivery.getById(gid, d.id));
});
});
describe('list', () => {
let lt; // isolated tenant so counts are exact
let ls1;
let ls2;
let d1;
let d2;
let d3;
before(async () => {
lt = await createTenant(store, { name: 'Delivery List GmbH' });
await store.warehouse.create(lt.googleAuthId, { name: 'List WH' });
ls1 = await store.supplier.create(lt.googleAuthId, { name: 'Alpha Trading' });
ls2 = await store.supplier.create(lt.googleAuthId, { name: 'Beta Supplies' });
await createArticle(store, lt, { sku: 'LST-1', track_stock: true });
const mk = (body) => store.delivery.create(lt.googleAuthId, { lines: [line('LST-1', 2)], ...body });
d1 = await mk({ supplierId: ls1.id, externalNumber: 'EXT-ALPHA', date: '2026-01-10T00:00:00Z' });
d2 = await mk({ supplierId: ls2.id, externalNumber: 'EXT-BETA', date: '2026-02-10T00:00:00Z' });
d3 = await mk({ supplierId: ls2.id, externalNumber: 'EXT-BETA-2', date: '2026-03-10T00:00:00Z' });
await store.delivery.book(lt.googleAuthId, d1.id);
});
it('returns a plain array without options, newest date first', async () => {
const all = await store.delivery.list(lt.googleAuthId);
assert.ok(Array.isArray(all));
assert.deepEqual(all.map((d) => d.id), [d3.id, d2.id, d1.id]);
assert.equal(all[2].isBooked, true);
assert.equal(all[0].isBooked, false);
});
it('paginates', async () => {
const page = await store.delivery.list(lt.googleAuthId, { offset: 1, limit: 1 });
assert.equal(page.total, 3);
assert.equal(page.unfilteredTotal, 3);
assert.equal(page.offset, 1);
assert.equal(page.limit, 1);
assert.deepEqual(page.deliveries.map((d) => d.id), [d2.id]);
});
it('filters by search, supplier and unbooked state', async () => {
const bySearch = await store.delivery.list(lt.googleAuthId, { search: 'EXT-BETA' });
assert.equal(bySearch.total, 2);
assert.equal(bySearch.unfilteredTotal, 3);
const bySupplierName = await store.delivery.list(lt.googleAuthId, { search: 'Alpha' });
assert.deepEqual(bySupplierName.deliveries.map((d) => d.id), [d1.id]);
const bySupplier = await store.delivery.list(lt.googleAuthId, { supplierId: ls1.id });
assert.deepEqual(bySupplier.deliveries.map((d) => d.id), [d1.id]);
const unbooked = await store.delivery.list(lt.googleAuthId, { onlyUnbooked: true });
assert.deepEqual(unbooked.deliveries.map((d) => d.id).sort(), [d2.id, d3.id].sort());
});
it('validates options', async () => {
const l = (o) => store.delivery.list(lt.googleAuthId, o);
await assert.rejects(() => l({ offset: -1 }), { message: 'Invalid offset' });
await assert.rejects(() => l({ limit: 0 }), { message: 'Invalid limit' });
await assert.rejects(() => l({ seekMonths: '1' }), { message: 'Invalid seekMonths' });
await assert.rejects(() => l({ supplierId: 0 }), { message: 'Invalid supplierId' });
});
it('does not leak deliveries of other companies', async () => {
const mine = await store.delivery.list(gid);
assert.ok(!mine.some((d) => [d1.id, d2.id, d3.id].includes(d.id)));
});
});
describe('rebuildSummaries', () => {
it('keeps list results stable after a rebuild', async () => {
const rt = await createTenant(store, { name: 'Delivery Rebuild GmbH' });
await store.warehouse.create(rt.googleAuthId, { name: 'Rebuild WH' });
await createArticle(store, rt, { sku: 'RB-1', track_stock: true });
const d = await store.delivery.create(rt.googleAuthId, { date: isoNow(), lines: [line('RB-1', 3)] });
await store.delivery.book(rt.googleAuthId, d.id);
const before = await store.delivery.list(rt.googleAuthId);
await store.delivery.rebuildSummaries(rt.googleAuthId);
const after = await store.delivery.list(rt.googleAuthId);
assert.equal(after.length, before.length);
assert.equal(after[0].isBooked, true);
assert.equal(after[0].positions, before[0].positions);
assert.equal(after[0].bookedQuantity, before[0].bookedQuantity);
});
});
});

89
test/helpers.js Normal file
View File

@@ -0,0 +1,89 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
/**
* Creates an isolated sqlite-backed store in a temp directory.
* Call `cleanup()` in an `after()` hook.
*/
export async function createTestStore(prefix = 'dash-unit-') {
const tempDir = mkdtempSync(join(tmpdir(), prefix));
const provider = new BetterSqlite3Provider({
filename: join(tempDir, 'test.db'),
storageDir: join(tempDir, 'storage'),
});
await provider.initialize();
const store = await new LibDashStore(provider);
const cleanup = async () => {
try {
provider.db?.close?.();
} catch {
// ignore
}
rmSync(tempDir, { recursive: true, force: true });
};
return { store, provider, tempDir, cleanup };
}
let tenantSeq = 0;
/**
* Creates a google_auth user + company and selects it.
* Returns everything a test typically needs.
*/
export async function createTenant(store, { name, email } = {}) {
tenantSeq += 1;
const googleAuthId = await store.google_auth.add(email ?? `tenant-${tenantSeq}@example.com`);
const company = await store.company.create({
name: name ?? `Tenant ${tenantSeq} GmbH`,
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
return { googleAuthId, company, rootCategoryId: company.rootCategoryId };
}
let articleSeq = 0;
/** Creates an article under the tenant's root category (or `parentId`). */
export async function createArticle(store, tenant, overrides = {}) {
articleSeq += 1;
return store.article.createUnderParent({
googleAuthId: tenant.googleAuthId,
parentId: overrides.parentId ?? tenant.rootCategoryId,
name: overrides.name ?? `Article ${articleSeq}`,
...overrides,
});
}
/** Returns the first customer group id of the tenant (needed for entity.create). */
export async function firstCustomerGroupId(store, tenant) {
const groups = await store.customergroup.list(tenant.googleAuthId);
return groups[0].id;
}
/** Creates a customer entity. */
export async function createCustomer(store, tenant, name = 'Customer') {
return store.entity.create(tenant.googleAuthId, {
customergroupId: await firstCustomerGroupId(store, tenant),
name,
});
}
/** Generates a small solid-color PNG buffer for image tests. */
export async function makePng({ width = 64, height = 48, r = 200, g = 30, b = 60 } = {}) {
return sharp({
create: { width, height, channels: 3, background: { r, g, b } },
})
.png()
.toBuffer();
}
export const isoNow = () => new Date().toISOString();

446
test/ledger.test.js Normal file
View File

@@ -0,0 +1,446 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { createTestStore, createTenant, createArticle, createCustomer, isoNow } from './helpers.js';
import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js';
describe('store.ledger', () => {
let ctx;
let store;
let tenant;
let other;
let gid;
let supplier;
let warehouse;
let warehouse2;
let customer;
let seq = 0;
const uniqueSku = (p) => `${p}-${++seq}`;
const makeDelivery = async (sku, amount = 5, extra = {}) => {
const d = await store.delivery.create(gid, {
supplierId: supplier.id,
date: isoNow(),
lines: [{ sku, name: `Line ${sku}`, pu: 1, amount, net: 10, taxrate: 19, ...extra }],
});
return { delivery: d, lineId: d.lines[0].id };
};
const makeShipmentLine = async (sku, orderAmount, shipAmount) => {
seq += 1;
const order = await store.order.create(gid, {
number: `LG-O-${seq}`,
date: '2026-10-12',
entityId: customer.id,
lines: [{ sku, name: `Order ${sku}`, pu: 1, amount: orderAmount, net: 25, taxrate: 19 }],
});
const shipment = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku, amount: shipAmount }] });
return { order, shipment, lineId: shipment.lines[0].id };
};
const stockOf = async (sku, warehouseId = warehouse.id) => {
const art = (await store.article.getBySku(gid, sku)).article;
return art.warehouses.find((w) => w.id === warehouseId)?.stock;
};
before(async () => {
ctx = await createTestStore('dash-ledger-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Ledger Test GmbH' });
other = await createTenant(store, { name: 'Ledger Other GmbH' });
gid = tenant.googleAuthId;
supplier = await store.supplier.create(gid, { name: 'Ledger Supplier' });
warehouse = await store.warehouse.create(gid, { name: 'Hauptlager' });
warehouse2 = await store.warehouse.create(gid, { name: 'Nebenlager' });
customer = await createCustomer(store, tenant, 'Ledger Customer');
});
after(async () => {
await ctx.cleanup();
});
describe('add — delivery lines', () => {
it('untracked article: single ACCEPTANCE row without warehouse', async () => {
const sku = uniqueSku('UNTRACKED');
await createArticle(store, tenant, { sku });
const { delivery, lineId } = await makeDelivery(sku, 5);
const row = await store.ledger.add(gid, {
manifestarticle_id: lineId,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(typeof row.id, 'number');
assert.equal(row.manifestarticle_id, lineId);
assert.equal(row.warehouse_id, null);
assert.equal(row.transaction_type, 'ACCEPTANCE');
assert.equal(row.amount, 5);
assert.equal(row.transaction_text, `Lieferung ${delivery.number}`);
assert.equal(row.hash_version, 1);
assert.match(row.prev_hash, /^[0-9a-f]{128}$|^TODO_TSE_BASED_INITHASH$/);
assert.ok(!Number.isNaN(Date.parse(row.created_at)));
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.equal(entries.length, 1);
});
it('tracked article: ACCEPTANCE + WAREHOUSE rows and stock increase', async () => {
const sku = uniqueSku('TRACKED');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 10);
const row = await store.ledger.add(gid, {
manifestarticle_id: lineId,
warehouse_id: warehouse.id,
transaction_type: 'ACCEPTANCE',
amount: 10,
transaction_text: 'Eingang',
});
assert.equal(row.warehouse_id, null);
assert.equal(row.transaction_type, 'ACCEPTANCE');
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.deepEqual(entries.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE']);
assert.deepEqual(entries.map((e) => e.warehouse_id), [null, warehouse.id]);
assert.deepEqual(entries.map((e) => e.amount), [10, 10]);
assert.equal(entries[1].id, entries[0].id + 1);
assert.equal(await stockOf(sku), 10);
});
it('uses the default warehouse when none is given', async () => {
const sku = uniqueSku('DEFWH');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 3);
await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 3, transaction_text: 'x' });
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.equal(entries.length, 2);
assert.ok(entries[1].warehouse_id > 0);
});
it('stores acceptance amounts as positive numbers', async () => {
const sku = uniqueSku('ABS');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 4);
const row = await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: -4, transaction_text: 'x' });
assert.equal(row.amount, 4);
});
it('rejects double booking of a delivery line', async () => {
const sku = uniqueSku('DOUBLE');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 2);
const input = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'x' };
await store.ledger.add(gid, input);
await assert.rejects(() => store.ledger.add(gid, input), { message: 'Delivery line already booked on ledger' });
assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 1);
});
it('rejects an unknown warehouse and rolls back', async () => {
const sku = uniqueSku('BADWH');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 2);
await assert.rejects(
() => store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: 999999, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'x' }),
{ message: 'Warehouse not found' },
);
assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 0, 'transaction rolled back');
});
it('accepts camelCase aliases', async () => {
const sku = uniqueSku('CAMEL');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
const row = await store.ledger.add(gid, { manifestarticleId: lineId, transactionType: 'ACCEPTANCE', amount: 1, transactionText: 'alias' });
assert.equal(row.manifestarticle_id, lineId);
assert.equal(row.transaction_text, 'alias');
});
});
describe('add — shipment lines', () => {
it('books SHIPMENT (negative) + WAREHOUSE rows and reduces stock', async () => {
const sku = uniqueSku('SHIP');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId: inLine } = await makeDelivery(sku, 10);
await store.ledger.add(gid, { manifestarticle_id: inLine, warehouse_id: warehouse.id, transaction_type: 'ACCEPTANCE', amount: 10, transaction_text: 'in' });
const { lineId } = await makeShipmentLine(sku, 5, 3);
const row = await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse.id, transaction_type: 'SHIPMENT', amount: 3, transaction_text: 'out' });
assert.equal(row.transaction_type, 'SHIPMENT');
assert.equal(row.warehouse_id, null);
assert.equal(row.amount, -3);
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.deepEqual(entries.map((e) => [e.transaction_type, e.warehouse_id, e.amount]), [
['SHIPMENT', null, -3],
['WAREHOUSE', warehouse.id, -3],
]);
assert.equal(await stockOf(sku), 7);
});
it('rejects double booking of a shipment line', async () => {
const sku = uniqueSku('SHIPDBL');
await createArticle(store, tenant, { sku });
const { lineId } = await makeShipmentLine(sku, 2, 2);
const input = { manifestarticle_id: lineId, transaction_type: 'SHIPMENT', amount: 2, transaction_text: 'x' };
await store.ledger.add(gid, input);
await assert.rejects(() => store.ledger.add(gid, input), { message: 'Shipment line already booked on ledger' });
});
});
describe('add — guards', () => {
it('refuses to book quote and order lines', async () => {
const sku = uniqueSku('DOC');
await createArticle(store, tenant, { sku });
const line = { sku, name: 'x', pu: 1, amount: 1, net: 1, taxrate: 19 };
const quote = await store.quote.create(gid, { number: `LG-Q-${sku}`, date: isoNow(), entityId: customer.id, lines: [line] });
const order = await store.order.create(gid, { number: `LG-O-${sku}`, date: isoNow(), entityId: customer.id, lines: [line] });
const base = { transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' };
await assert.rejects(() => store.ledger.add(gid, { ...base, manifestarticle_id: quote.lines[0].id }), { message: 'Cannot book ledger entries for quote' });
await assert.rejects(() => store.ledger.add(gid, { ...base, manifestarticle_id: order.lines[0].id }), { message: 'Cannot book ledger entries for order' });
});
it('validates the payload', async () => {
const sku = uniqueSku('VALID');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
const ok = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' };
const bad = (patch) => store.ledger.add(gid, { ...ok, ...patch });
const err = { message: 'Invalid ledger entry' };
await assert.rejects(() => bad({ manifestarticle_id: 0 }), err);
await assert.rejects(() => bad({ manifestarticle_id: 1.5 }), err);
await assert.rejects(() => bad({ manifestarticle_id: 'x' }), err);
await assert.rejects(() => bad({ warehouse_id: -1 }), err);
await assert.rejects(() => bad({ warehouse_id: 'x' }), err);
await assert.rejects(() => bad({ transaction_type: ' ' }), err);
await assert.rejects(() => bad({ transaction_type: 'T'.repeat(101) }), err);
await assert.rejects(() => bad({ transaction_type: 5 }), err);
await assert.rejects(() => bad({ amount: 'abc' }), err);
await assert.rejects(() => bad({ amount: Number.POSITIVE_INFINITY }), err);
await assert.rejects(() => bad({ transaction_text: undefined }), err);
await assert.rejects(() => bad({ transaction_text: null }), err);
});
it('rejects unknown manifest articles and ones of other companies', async () => {
const sku = uniqueSku('FOREIGN');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
const input = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' };
await assert.rejects(() => store.ledger.add(gid, { ...input, manifestarticle_id: 999999 }), { message: 'Manifest article not found' });
await assert.rejects(() => store.ledger.add(other.googleAuthId, input), { message: 'Manifest article not found' });
assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 0);
});
});
describe('hash chain', () => {
it('chains every row to the SHA-512 hash of the previous row', async () => {
const t = await createTenant(store, { name: 'Chain GmbH' });
await store.warehouse.create(t.googleAuthId, { name: 'Chain WH' });
await createArticle(store, t, { sku: 'CHAIN-1', track_stock: true });
const rows = [];
for (let i = 1; i <= 3; i += 1) {
const d = await store.delivery.create(t.googleAuthId, {
date: isoNow(),
lines: [{ sku: 'CHAIN-1', name: 'c', pu: 1, amount: i, net: 1, taxrate: 19 }],
});
await store.ledger.add(t.googleAuthId, { manifestarticle_id: d.lines[0].id, transaction_type: 'ACCEPTANCE', amount: i, transaction_text: `c${i}` });
rows.push(...(await store.ledger.listByManifestarticleId(t.googleAuthId, d.lines[0].id)));
}
assert.equal(rows.length, 6);
for (let i = 1; i < rows.length; i += 1) {
assert.equal(rows[i].prev_hash, computePrevHash(rows[i - 1]), `row ${rows[i].id} links to row ${rows[i - 1].id}`);
assert.ok(rows[i].id > rows[i - 1].id);
assert.equal(rows[i].hash_version, 1);
}
});
it('computePrevHash returns INIT_HASH for no row and a deterministic sha512 otherwise', () => {
assert.equal(computePrevHash(null), INIT_HASH);
assert.equal(computePrevHash(undefined), INIT_HASH);
const row = {
id: 7, manifestarticle_id: 3, warehouse_id: undefined, transaction_type: 'ACCEPTANCE',
amount: 2, transaction_text: 'x', hash_version: 1, prev_hash: INIT_HASH,
created_at: new Date('2026-01-01T00:00:00.000Z'),
};
const expected = createHash('sha512').update(JSON.stringify({
id: 7, manifestarticle_id: 3, warehouse_id: null, transaction_type: 'ACCEPTANCE',
amount: 2, transaction_text: 'x', hash_version: 1, prev_hash: INIT_HASH,
created_at: '2026-01-01T00:00:00.000Z',
}), 'utf8').digest('hex');
assert.equal(computePrevHash(row), expected);
assert.equal(computePrevHash({ ...row, created_at: '2026-01-01T00:00:00.000Z' }), expected);
assert.notEqual(computePrevHash({ ...row, amount: 3 }), expected);
});
});
describe('listByManifestarticleId', () => {
it('filters by transaction type and warehouse', async () => {
const sku = uniqueSku('FILTER');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 4);
await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse2.id, transaction_type: 'ACCEPTANCE', amount: 4, transaction_text: 'x' });
const byType = await store.ledger.listByManifestarticleId(gid, lineId, 'WAREHOUSE');
assert.deepEqual(byType.map((e) => e.transaction_type), ['WAREHOUSE']);
const byObjType = await store.ledger.listByManifestarticleId(gid, lineId, { transactionType: 'ACCEPTANCE' });
assert.deepEqual(byObjType.map((e) => e.transaction_type), ['ACCEPTANCE']);
const byWh = await store.ledger.listByManifestarticleId(gid, lineId, { warehouse_id: warehouse2.id });
assert.deepEqual(byWh.map((e) => e.warehouse_id), [warehouse2.id]);
const none = await store.ledger.listByManifestarticleId(gid, lineId, { warehouseId: warehouse.id });
assert.deepEqual(none, []);
assert.ok(byType[0].sku === sku);
});
it('rejects invalid, unknown and foreign lines', async () => {
const sku = uniqueSku('LISTERR');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 0), { message: 'Invalid manifest article id' });
await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 'x'), { message: 'Invalid manifest article id' });
await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 999999), { message: 'Manifest article not found' });
await assert.rejects(() => store.ledger.listByManifestarticleId(other.googleAuthId, lineId), { message: 'Manifest article not found' });
});
});
describe('listBySku', () => {
it('lists all movements of an article in booking order', async () => {
const sku = uniqueSku('BYSKU');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 8);
await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse.id, transaction_type: 'ACCEPTANCE', amount: 8, transaction_text: 'in' });
const ship = await makeShipmentLine(sku, 5, 2);
await store.ledger.add(gid, { manifestarticle_id: ship.lineId, warehouse_id: warehouse.id, transaction_type: 'SHIPMENT', amount: 2, transaction_text: 'out' });
const all = await store.ledger.listBySku(gid, sku);
assert.deepEqual(all.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE', 'SHIPMENT', 'WAREHOUSE']);
assert.ok(all.every((e) => e.sku === sku));
assert.equal(all.reduce((sum, e) => sum + (e.warehouse_id ? e.amount : 0), 0), 6, 'warehouse rows sum to stock');
const shipments = await store.ledger.listBySku(gid, sku, 'SHIPMENT');
assert.equal(shipments.length, 1);
assert.equal(shipments[0].amount, -2);
const whRows = await store.ledger.listBySku(gid, sku, { warehouseId: warehouse.id });
assert.equal(whRows.length, 2);
});
it('trims the SKU, rejects blanks and does not leak across companies', async () => {
const sku = uniqueSku('BYSKU2');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' });
assert.equal((await store.ledger.listBySku(gid, ` ${sku} `)).length, 1);
assert.deepEqual(await store.ledger.listBySku(other.googleAuthId, sku), []);
await assert.rejects(() => store.ledger.listBySku(gid, ' '), { message: 'Invalid SKU' });
await assert.rejects(() => store.ledger.listBySku(gid, null), { message: 'Invalid SKU' });
assert.deepEqual(await store.ledger.listBySku(gid, 'NO-SUCH-SKU'), []);
});
});
describe('stock.adjust (adjustment manifests)', () => {
it('books a warehouse movement via the ledger and updates stock', async () => {
const sku = uniqueSku('ADJ');
await createArticle(store, tenant, { sku, track_stock: true });
const res = await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 12, text: 'Inventur' });
assert.equal(res.manifest.type, 'adjustment');
assert.equal(res.ledger.length, 1);
assert.equal(res.ledger[0].warehouse_id, warehouse.id);
assert.equal(res.ledger[0].amount, 12);
assert.equal(await stockOf(sku), 12);
await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: -5 });
assert.equal(await stockOf(sku), 7);
assert.equal((await store.ledger.listBySku(gid, sku)).length, 2);
});
it('rejects adjustments for untracked articles and bad amounts', async () => {
const sku = uniqueSku('ADJBAD');
await createArticle(store, tenant, { sku });
await assert.rejects(
() => store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 1 }),
{ message: `Stock tracking is disabled for article (${sku})` },
);
const tracked = uniqueSku('ADJBAD2');
await createArticle(store, tenant, { sku: tracked, track_stock: true });
await assert.rejects(() => store.stock.adjust(gid, { sku: tracked, warehouseId: warehouse.id, amount: 0 }), /non-zero/);
});
it('disabling tracking writes a zeroing ledger entry', async () => {
const sku = uniqueSku('ADJOFF');
await createArticle(store, tenant, { sku, track_stock: true });
await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 9 });
assert.equal(await stockOf(sku), 9);
await store.article.setTrackStockBySku(gid, sku, false);
assert.equal(await stockOf(sku), 0);
});
});
describe('stock.accept / stock.ship', () => {
it('accept books positive lines as ACCEPTANCE and negative lines as RETURN', async () => {
const sku = uniqueSku('ACCEPT');
await createArticle(store, tenant, { sku, track_stock: true });
const d = await store.delivery.create(gid, {
supplierId: supplier.id,
date: isoNow(),
lines: [
{ sku, name: 'in', pu: 1, amount: 6, net: 10, taxrate: 19 },
{ sku, name: 'ret', pu: 1, amount: -2, net: 10, taxrate: 19 },
],
});
const pos = await store.stock.accept(gid, { manifestarticleId: d.lines[0].id, warehouseId: warehouse.id });
assert.equal(pos.document_ledger.transaction_type, 'ACCEPTANCE');
assert.equal(pos.document_ledger.amount, 6);
assert.equal(pos.adjust_ledger.amount, 6);
assert.equal(pos.adjust_ledger.warehouse_id, warehouse.id);
const neg = await store.stock.accept(gid, { manifestarticleId: d.lines[1].id, warehouseId: warehouse.id });
assert.equal(neg.document_ledger.transaction_type, 'RETURN');
assert.equal(neg.document_ledger.amount, -2);
assert.equal(await stockOf(sku), 4);
});
it('ship books positive lines as SHIPMENT and negative lines as RETOURE', async () => {
const sku = uniqueSku('SHIPSTK');
await createArticle(store, tenant, { sku, track_stock: true });
await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 20 });
seq += 1;
const order = await store.order.create(gid, {
number: `LG-SO-${seq}`, date: '2026-10-12', entityId: customer.id,
lines: [{ sku, name: 'o', pu: 1, amount: 10, net: 1, taxrate: 19 }],
});
const shipment = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku, amount: 4 }, { sku, amount: -1 }] });
const pos = await store.stock.ship(gid, { manifestarticleId: shipment.lines[0].id, warehouseId: warehouse.id });
assert.equal(pos.document_ledger.transaction_type, 'SHIPMENT');
assert.equal(pos.document_ledger.amount, -4);
const neg = await store.stock.ship(gid, { manifestarticleId: shipment.lines[1].id, warehouseId: warehouse.id });
assert.equal(neg.document_ledger.transaction_type, 'RETOURE');
assert.equal(neg.document_ledger.amount, 1);
assert.equal(await stockOf(sku), 17);
});
});
describe('append-only', () => {
it('exposes no mutating API besides add', () => {
assert.deepEqual(Object.keys(store.ledger).sort(), ['add', 'listByManifestarticleId', 'listBySku']);
});
});
});

330
test/shipment.test.js Normal file
View File

@@ -0,0 +1,330 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestStore, createTenant, createArticle, createCustomer } from './helpers.js';
describe('store.shipment', () => {
let ctx;
let store;
let tenant;
let other;
let gid;
let customer;
let seq = 0;
const makeOrder = async (lines = [
{ sku: 'SH-A', amount: 10, net: 100, taxrate: 19 },
{ sku: 'SH-B', amount: 5, net: 50, taxrate: 19 },
], date = '2026-10-12') => {
seq += 1;
return store.order.create(gid, {
number: `AU-${2000 + seq}`,
date,
entityId: customer.id,
lines,
});
};
before(async () => {
ctx = await createTestStore('dash-shipment-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Shipment Test GmbH' });
other = await createTenant(store, { name: 'Shipment Other GmbH' });
gid = tenant.googleAuthId;
customer = await createCustomer(store, tenant, 'Max Mustermann');
// untracked articles -> no stock prerequisites for shipments
await createArticle(store, tenant, { name: 'Item A', sku: 'SH-A' });
await createArticle(store, tenant, { name: 'Item B', sku: 'SH-B' });
});
after(async () => {
await ctx.cleanup();
});
describe('create', () => {
it('creates numbered shipments from an order', async () => {
const order = await makeOrder();
const s1 = await store.shipment.create(gid, {
orderId: order.id,
lines: [{ sku: 'SH-A', amount: 6 }, { sku: 'SH-B', amount: 3 }],
});
const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
assert.equal(s1.number, `${order.number}-L-1`);
assert.equal(s2.number, `${order.number}-L-2`);
assert.equal(s1.type, 'shipment');
assert.equal(s1.state, 'new');
assert.equal(s1.lineCount, 2);
assert.equal(s1.packageCount, 0);
assert.equal(s1.entityId, customer.id);
assert.deepEqual(s1.lines.map((l) => [l.sku, l.amount]), [['SH-A', 6], ['SH-B', 3]]);
// line data defaults from the order line
assert.equal(s1.lines[0].net, 100);
assert.equal(s1.lines[0].taxrate, 19);
});
it('defaults amount to the remaining quantity', async () => {
const order = await makeOrder();
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 3 }] });
const rest = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] });
assert.equal(rest.lines[0].amount, 7);
});
it('ships everything remaining when lines are omitted', async () => {
const order = await makeOrder();
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
const rest = await store.shipment.create(gid, { orderId: order.id });
assert.deepEqual(
rest.lines.map((l) => [l.sku, l.amount]).sort(),
[['SH-A', 6], ['SH-B', 5]],
);
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'All items already shipped' });
});
it('updates the order summary', async () => {
const order = await makeOrder();
const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id);
assert.equal(summary().shipped_quantity, 0);
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
assert.equal(summary().shipped_quantity, 10);
assert.equal(summary().is_shipped, 0);
await store.shipment.create(gid, { orderId: order.id });
assert.equal(summary().shipped_quantity, 15);
assert.equal(summary().is_shipped, 1);
});
it('enforces the ordered quantity ceiling', async () => {
const order = await makeOrder();
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 6 }] });
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 5 }] }),
{ message: 'Shipment amount exceeds ordered amount for article (SH-A)' },
);
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] }),
{ message: 'Shipment amount exceeds ordered amount for article (SH-A)' },
);
});
it('rejects foreign articles and invalid input', async () => {
const order = await makeOrder();
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-UNKNOWN', amount: 1 }] }),
{ message: `Article (SH-UNKNOWN) is not part of order (${order.number})` },
);
await assert.rejects(() => store.shipment.create(gid, {}), { message: 'Order is required' });
await assert.rejects(() => store.shipment.create(gid, { orderId: 0 }), { message: 'Order is required' });
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id, lines: 'x' }), { message: 'Invalid lines' });
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 0 }] }),
{ message: 'Invalid lines' },
);
await assert.rejects(() => store.shipment.create(gid, { orderId: 999999 }), { message: 'Order not found' });
});
it('rejects orders without lines', async () => {
const empty = await makeOrder([]);
await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id }), { message: 'Order has no lines' });
});
it('cannot ship orders of another company', async () => {
const order = await makeOrder();
await assert.rejects(
() => store.shipment.create(other.googleAuthId, { orderId: order.id }),
{ message: 'Order not found' },
);
});
it('honours custom state and external number', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, {
orderId: order.id,
lines: [{ sku: 'SH-A', amount: 1 }],
state: 'delivered',
externalNumber: ' DHL-77 ',
});
assert.equal(s.state, 'delivered');
assert.equal(s.externalNumber, 'DHL-77');
});
});
describe('order.createShipment', () => {
it('is equivalent to shipment.create with the order id', async () => {
const order = await makeOrder();
const s = await store.order.createShipment(gid, order.id, { lines: [{ sku: 'SH-B', amount: 2 }] });
assert.equal(s.number, `${order.number}-L-1`);
assert.deepEqual(s.lines.map((l) => [l.sku, l.amount]), [['SH-B', 2]]);
});
});
describe('getById / listByOrderId', () => {
it('returns shipments with lines and packages', async () => {
const order = await makeOrder();
const s1 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-B', amount: 1 }] });
const carrier = await store.carrier.create(gid, { name: `DHL ${order.number}`, hasPackages: true });
await store.shipping.create(gid, { manifestId: s1.id, carrierId: carrier.id, type: 'parcel', state: 'draft', trackingCode: 'T-1' });
const fetched = await store.shipment.getById(gid, s1.id);
assert.equal(fetched.id, s1.id);
assert.equal(fetched.lines.length, 1);
assert.equal(fetched.packageCount, 1);
assert.equal(fetched.shippings[0].trackingCode, 'T-1');
assert.equal(fetched.shippings[0].carrier, carrier.name);
const byOrder = await store.shipment.listByOrderId(gid, order.id);
assert.deepEqual(byOrder.map((s) => s.id), [s1.id, s2.id]);
assert.equal(byOrder[0].shippings.length, 1);
assert.equal(byOrder[1].shippings.length, 0);
});
it('rejects unknown ids, foreign access and invalid ids', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
await assert.rejects(() => store.shipment.getById(gid, 999999), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.getById(other.googleAuthId, s.id), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.getById(gid, 'x'), { message: 'Shipment is required' });
await assert.rejects(() => store.shipment.listByOrderId(gid, 999999), { message: 'Order not found' });
await assert.rejects(() => store.shipment.listByOrderId(other.googleAuthId, order.id), { message: 'Order not found' });
});
});
describe('update', () => {
it('changes the state and validates it', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
assert.equal((await store.shipment.update(gid, s.id, { state: 'delivered' })).state, 'delivered');
assert.equal((await store.shipment.update(gid, s.id, { state: ' RETURNED ' })).state, 'returned');
assert.equal((await store.shipment.update(gid, s.id, {})).state, 'returned', 'empty patch keeps the state');
await assert.rejects(() => store.shipment.update(gid, s.id, { state: 'lost' }), { message: 'Invalid state' });
await assert.rejects(() => store.shipment.update(gid, 999999, { state: 'new' }), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.update(other.googleAuthId, s.id, { state: 'new' }), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.update(gid, 0, { state: 'new' }), { message: 'Shipment is required' });
});
});
describe('delete', () => {
it('deletes an unshipped shipment and reopens the order quantity', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id);
assert.equal(summary().shipped_quantity, 10);
await store.shipment.delete(gid, s.id);
await assert.rejects(() => store.shipment.getById(gid, s.id), { message: 'Shipment not found' });
assert.equal(summary().shipped_quantity, 0);
const again = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
assert.ok(again.id);
});
it('refuses to delete a shipment with shipped packages', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 2 }] });
const carrier = await store.carrier.create(gid, { name: `Carrier ${order.number}`, hasPackages: true });
const pkg = await store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft' });
await store.shipping.update(gid, pkg.id, { state: 'shipped' });
await assert.rejects(() => store.shipment.delete(gid, s.id), { message: 'Shipped shipment cannot be deleted' });
assert.ok(await store.shipment.getById(gid, s.id));
});
it('rejects unknown, foreign and invalid ids', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
await assert.rejects(() => store.shipment.delete(gid, 999999), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.delete(other.googleAuthId, s.id), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.delete(gid, null), { message: 'Shipment is required' });
assert.ok(await store.shipment.getById(gid, s.id));
});
});
describe('list / rebuildSummaries', () => {
let lt;
let o1;
let o2;
let s1;
let s2;
let s3;
before(async () => {
lt = await createTenant(store, { name: 'Shipment List GmbH' });
const cust = await createCustomer(store, lt, 'Listy McList');
await createArticle(store, lt, { sku: 'LS-A' });
const mk = (number) => store.order.create(lt.googleAuthId, {
number, date: '2026-10-12', entityId: cust.id,
lines: [{ sku: 'LS-A', amount: 10, net: 1, taxrate: 19 }],
});
o1 = await mk('LIST-1');
o2 = await mk('LIST-2');
s1 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 3 }] });
s2 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 2 }] });
s3 = await store.shipment.create(lt.googleAuthId, { orderId: o2.id, lines: [{ sku: 'LS-A', amount: 4 }] });
});
it('lists with summary data and pagination', async () => {
const res = await store.shipment.list(lt.googleAuthId, { offset: 0, limit: 10 });
assert.equal(res.total, 3);
assert.equal(res.unfilteredTotal, 3);
assert.equal(res.limit, 10);
assert.equal(res.offset, 0);
const row = res.shipments.find((s) => s.id === s1.id);
assert.equal(row.orderNumber, 'LIST-1');
assert.equal(row.orderId, o1.id);
assert.equal(row.state, 'new');
assert.equal(row.positions, 1);
assert.equal(row.totalQuantity, 3);
const page = await store.shipment.list(lt.googleAuthId, { offset: 2, limit: 1 });
assert.equal(page.shipments.length, 1);
assert.equal(page.total, 3);
});
it('returns a plain array without options', async () => {
const all = await store.shipment.list(lt.googleAuthId);
assert.ok(Array.isArray(all));
assert.equal(all.length, 3);
});
it('filters by order, search and state', async () => {
const byOrder = await store.shipment.list(lt.googleAuthId, { orderId: o1.id });
assert.deepEqual(byOrder.shipments.map((s) => s.id).sort(), [s1.id, s2.id].sort());
const bySearch = await store.shipment.list(lt.googleAuthId, { search: 'LIST-2' });
assert.deepEqual(bySearch.shipments.map((s) => s.id), [s3.id]);
assert.equal(bySearch.unfilteredTotal, 3);
await store.shipment.update(lt.googleAuthId, s1.id, { state: 'delivered' });
const delivered = await store.shipment.list(lt.googleAuthId, { state: 'delivered' });
assert.deepEqual(delivered.shipments.map((s) => s.id), [s1.id]);
});
it('validates options', async () => {
const l = (o) => store.shipment.list(lt.googleAuthId, o);
await assert.rejects(() => l({ offset: -1 }), { message: 'Invalid offset' });
await assert.rejects(() => l({ limit: 0 }), { message: 'Invalid limit' });
await assert.rejects(() => l({ seekMonths: 1.5 }), { message: 'Invalid seekMonths' });
});
it('rebuildSummaries preserves state and quantities', async () => {
await store.shipment.update(lt.googleAuthId, s2.id, { state: 'returned' });
await store.shipment.rebuildSummaries(lt.googleAuthId);
const returned = await store.shipment.list(lt.googleAuthId, { state: 'returned' });
assert.deepEqual(returned.shipments.map((s) => s.id), [s2.id]);
assert.equal(returned.shipments[0].totalQuantity, 2);
assert.equal(returned.shipments[0].orderNumber, 'LIST-1');
});
it('does not leak shipments of other companies', async () => {
const mine = await store.shipment.list(gid, { limit: 1000, offset: 0 });
assert.ok(!mine.shipments.some((s) => [s1.id, s2.id, s3.id].includes(s.id)));
});
});
});