Compare commits
6 Commits
f8cc01baf0
...
c687553eea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c687553eea | ||
|
|
fda895f75a | ||
|
|
ce5da810a5 | ||
|
|
3c2b933f93 | ||
|
|
688fa4f3fd | ||
|
|
ab42a3ffb4 |
@@ -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`*
|
||||
|
||||
9
API.articlewarehousesummary.md
Normal file
9
API.articlewarehousesummary.md
Normal 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.
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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
8
API.md
@@ -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>`
|
||||
|
||||
12
API.stock.md
12
API.stock.md
@@ -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`*
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -374,6 +374,7 @@ const CLUSTERS = [
|
||||
'manifest',
|
||||
'manifestarticle',
|
||||
'manifestattachment',
|
||||
'articlewarehousesummary',
|
||||
'deliverysummary',
|
||||
'ordersummary',
|
||||
'quotesummary',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
240
schema-graph.dot
240
schema-graph.dot
File diff suppressed because one or more lines are too long
6376
schema-graph.svg
6376
schema-graph.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 407 KiB |
@@ -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;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Drop order = reverse of schema create dependency order. */
|
||||
export const TABLES = [
|
||||
|
||||
'articlewarehousesummary',
|
||||
'deliverysummary',
|
||||
'shipmentsummary',
|
||||
'quotesummary',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
170
src/store/article/listHistory.js
Normal file
170
src/store/article/listHistory.js
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
266
src/store/articlewarehousesummary/syncSummary.js
Normal file
266
src/store/articlewarehousesummary/syncSummary.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
180
src/store/ledger/listHistory.js
Normal file
180
src/store/ledger/listHistory.js
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
19
src/store/stock/rebuildSummaries.js
Normal file
19
src/store/stock/rebuildSummaries.js
Normal 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 };
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
648
test/article.test.js
Normal 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
337
test/category.test.js
Normal 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
303
test/delivery.test.js
Normal 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
89
test/helpers.js
Normal 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
446
test/ledger.test.js
Normal 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
330
test/shipment.test.js
Normal 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)));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user