Compare commits

..

10 Commits

Author SHA1 Message Date
seb
8a626b17fc u 2026-10-13 13:21:11 +02:00
seb
a5886b7c92 u 2026-10-13 12:19:35 +02:00
seb
2ef2c0edbf u 2026-10-13 01:30:11 +02:00
seb
4b2ca7ce0e u 2026-10-13 00:36:36 +02:00
seb
8a8aeaedd7 u 2026-10-12 22:32:21 +02:00
seb
28e62093b3 u 2026-10-12 19:48:07 +02:00
seb
64d1772842 u 2026-10-12 18:36:57 +02:00
seb
10b1d53f9d u 2026-10-12 18:09:54 +02:00
seb
88ff9e08a9 u 2026-10-12 18:01:25 +02:00
seb
2e961252f7 u 2026-10-12 17:29:29 +02:00
106 changed files with 4172 additions and 623 deletions

View File

@@ -217,13 +217,13 @@ Returns the change history of an article by its SKU.
**Returns**
`Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>` — The article history entries.
`Promise<Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>>` — The article history entries.
**Example**
```js
const history = await store.article.getHistoryBySku(42, 'SKU-1');
// => [{ id, companyuserId, date, text }]
// => [{ id, companyuserId, date, text, meta }]
const recent = await store.article.getHistoryBySku(42, 'SKU-1', { limit: 5 });
// => up to 5 most recent entries

19
API.articlehistory.md Normal file
View File

@@ -0,0 +1,19 @@
# articlehistory
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `articlehistory.record`
*Source: `src/store/articlehistory/record.js`*
Formats a numeric currency amount in German language style: "€ 1.000,99"
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `amount` | `number|string` | The currency amount. |
**Returns**
`string` — The formatted currency string.

View File

@@ -171,7 +171,7 @@ const articles = await store.category.getCategoryArticles(42, 5);
*Source: `src/store/category/getTreeForCompany.js`*
Returns the full category tree for the user's selected company together with its articles and the posterminal ids assigned to each category.
Returns the full category tree for the user's selected company together with the article count and the posterminal ids assigned to each category.
**Parameters**
@@ -182,13 +182,13 @@ Returns the full category tree for the user's selected company together with its
**Returns**
`Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>` — The category tree, articles and language.
`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}>` — The category tree and article count.
**Example**
```js
const tree = await store.category.getTreeForCompany(42);
// => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] }
// => { languageId, tree: [{ id, articleCount, posterminalIds, ... }], articleCount: 25 }
```
## `category.move`

View File

@@ -186,13 +186,13 @@ Lists the companies accessible to the given Google auth user.
**Returns**
`Promise<Array<object>>` — The accessible company rows.
`Promise<Array<{id: number, name: string, rootCategoryId: number, languageId: number, iso639: string|null, taxrate: number, reducedtaxrate: number, taxfreerate: number}>>` — The accessible company rows.
**Example**
```js
const companies = await store.company.list(42);
// => [{ id, name, ... }]
// => [{ id, name, rootCategoryId, languageId, iso639, taxrate, reducedtaxrate, taxfreerate }]
```
## `company.update`

210
API.mcp.md Normal file
View File

@@ -0,0 +1,210 @@
# mcp
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `mcp.consumeAuthCode`
*Source: `src/store/mcp/consumeAuthCode.js`*
Marks an authorization code as used.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `code` | `string` | The authorization code to mark used. |
**Returns**
`Promise<boolean>` — True once marked.
## `mcp.createAuthCode`
*Source: `src/store/mcp/createAuthCode.js`*
Stores an OAuth 2.1 authorization code with PKCE parameters.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.code` | `string` | The generated authorization code. |
| `input.clientId` | `string` | The client ID. |
| `input.googleAuthId` | `number` | The ID of the authenticated user. |
| `input.redirectUri` | `string` | The redirect URI used in the authorize request. |
| `input.codeChallenge` | `string` | The PKCE code challenge. |
| `input.codeChallengeMethod` | `string` | The PKCE method (e.g. S256). |
| `[input.scope]` | `string|null` | Granted scope. |
| `input.expiresAt` | `string|Date` | Expiration ISO string or Date. |
**Returns**
`Promise<string>` — The authorization code.
## `mcp.createRefreshToken`
*Source: `src/store/mcp/createRefreshToken.js`*
Stores an OAuth 2.1 refresh token.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.refreshToken` | `string` | The refresh token string. |
| `input.clientId` | `string` | The client ID. |
| `input.googleAuthId` | `number` | The user ID. |
| `[input.scope]` | `string|null` | Scopes granted. |
| `input.expiresAt` | `string|Date` | Expiration Date or ISO string. |
**Returns**
`Promise<object>` — Created token record or id.
## `mcp.deleteClient`
*Source: `src/store/mcp/deleteClient.js`*
Deletes a registered MCP client by client ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `clientId` | `string` | The client identifier to delete. |
**Returns**
`Promise<boolean>` — True if deleted.
## `mcp.getAuthCode`
*Source: `src/store/mcp/getAuthCode.js`*
Retrieves an authorization code record.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `code` | `string` | The authorization code. |
**Returns**
`Promise<object|null>` — The auth code record or null.
## `mcp.getClient`
*Source: `src/store/mcp/getClient.js`*
Retrieves an MCP client by client ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `clientId` | `string` | The client identifier. |
**Returns**
`Promise<object|null>` — The client record or null if not found.
## `mcp.getRefreshToken`
*Source: `src/store/mcp/getRefreshToken.js`*
Retrieves an OAuth 2.1 refresh token.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `refreshToken` | `string` | The refresh token. |
**Returns**
`Promise<object|null>` — The refresh token record or null.
## `mcp.listAuthorizations`
*Source: `src/store/mcp/listAuthorizations.js`*
Lists active authorizations for a Google Auth user.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The user ID. |
**Returns**
`Promise<Array<object>>` — List of authorized clients and token counts.
## `mcp.listClients`
*Source: `src/store/mcp/listClients.js`*
Lists all registered MCP clients.
**Returns**
`Promise<Array<object>>` — List of client records.
## `mcp.registerClient`
*Source: `src/store/mcp/registerClient.js`*
Registers a new OAuth MCP client.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.clientId` | `string` | Unique client identifier. |
| `[input.clientSecret]` | `string|null` | Optional client secret for confidential clients. |
| `input.clientName` | `string` | Human-readable client name. |
| `input.redirectUris` | `string|Array<string>` | JSON string or array of redirect URIs. |
| `[input.grantTypes]` | `string|Array<string>` | Supported grant types. |
| `[input.responseTypes]` | `string|Array<string>` | Supported response types. |
| `[input.scope]` | `string|null` | Allowed scope. |
**Returns**
`Promise<object>` — The created client record.
## `mcp.revokeAuthorizations`
*Source: `src/store/mcp/revokeAuthorizations.js`*
Revokes all active authorizations (tokens) for a user and MCP client.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The user ID. |
| `clientId` | `string` | The MCP client ID. |
**Returns**
`Promise<boolean>` — True once revoked.
## `mcp.revokeRefreshToken`
*Source: `src/store/mcp/revokeRefreshToken.js`*
Revokes an OAuth 2.1 refresh token.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `refreshToken` | `string` | The refresh token to revoke. |
**Returns**
`Promise<boolean>` — True once revoked.

50
API.md
View File

@@ -11,7 +11,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- **article**: `id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_parent:b=0, parentarticle_id?->article, parentattributename_id?->attributename, parentsort:i=0, created_at:dt, updated_at:dt, UQ(parentarticle_id, parentsort)`
- **articleattributevalue**: `id PK, article_id->article, attributevalue_id->attributevalue, sort:i=0, UQ(article_id, attributevalue_id)`
- **articleean**: `id PK, article_id->article, ean:s(50) UQ`
- **articlehistory**: `id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt`
- **articlehistory**: `id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt, meta?:txt`
- **articlelist**: `id PK`
- **articlelistarticle**: `id PK, articlelist_id->articlelist, article_id->article, count:i=1, sort:i=0, UQ(articlelist_id, article_id), UQ(articlelist_id, sort)`
- **articlepic**: `id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)`
@@ -72,7 +72,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `getById(id: number, tx?: object): Promise<object|undefined>`
- `getByName(name: string, tx?: object): Promise<object|undefined>`
- `getHistory(googleAuthId: number, opts?: {limit?: number}, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<object>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, rootCategoryId: number, languageId: number, iso639: string|null, taxrate: number, reducedtaxrate: number, taxfreerate: number}>>`
- `update(id: number, input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number}, tx?: object): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number}>`
- [category](API.category.md)
- `addImage(googleAuthId: number, categoryId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number}>`
@@ -81,7 +81,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `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}>>`
- `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>`
- `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>}>`
- `rename(input: {googleAuthId: number, id: number, name: string}, tx?: object): Promise<{id: number, name: string, slug: string}>`
@@ -93,7 +93,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `delete(googleAuthId: number, sku: string, optionsOrTx?: object|*, maybeTx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise<void>`
- `getBySku(googleAuthId: number, sku: string, tx?: object): Promise<{article: {id: number, sku: string, name: string, slug: string, desc: string|null, seo: string|null, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null}}>`
- `getHistoryBySku(googleAuthId: number, sku: string, opts?: {limit?: number}, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `getHistoryBySku(googleAuthId: number, sku: string, opts?: {limit?: number}, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>>`
- `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>>`
@@ -184,8 +184,20 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `create(googleAuthId: number, body: {number: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number}, tx?: object): Promise<{orders: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>, total: number, offset: number, limit: number}|Array<object>>`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number}, tx?: object): Promise<{orders: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>, total: number, offset: number, limit: number}|Array<object>>`
- `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `syncSummary(): void`
- `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- [shipment](API.shipment.md)
- `create(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `delete(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `getById(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `listByOrderId(provider: import('../../providers/provider.js').DatabaseProvider): void`
- [shipping](API.shipping.md)
- `create(googleAuthId: number, input: {manifestId: number, type: string, state: string, trackingCode?: string, carrier?: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, manifestId: number, number: string, type: string, state: string}>>`
- `update(provider: import('../../providers/provider.js').DatabaseProvider): void`
- [manifestattachment](API.manifestattachment.md)
- `getById(googleAuthId: number, manifestId: number, id: number, tx?: object): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>`
- `listByManifestId(): void`
@@ -285,6 +297,8 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `create(googleAuthId: number, input: {type: string, name: string}, tx?: object): Promise<{id: number, companyId: number, type: string, name: string}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyId: number, type: string, name: string}>>`
- [articlehistory](API.articlehistory.md)
- `record(amount: number|string): string`
- [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}>>`
@@ -295,12 +309,25 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `delete(googleAuthId: number, categoryId: number, propertyId: number, tx?: object): Promise<void>`
- `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
- `setByCategoryId(googleAuthId: number, categoryId: number, properties: Array<{propertyId: number, value: string, sort?: number}>, tx?: object): Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
- [mcp](API.mcp.md)
- `consumeAuthCode(code: string): Promise<boolean>`
- `createAuthCode(input: {code: string, clientId: string, googleAuthId: number, redirectUri: string, codeChallenge: string, codeChallengeMethod: string, scope?: string|null, expiresAt: string|Date}): Promise<string>`
- `createRefreshToken(input: {refreshToken: string, clientId: string, googleAuthId: number, scope?: string|null, expiresAt: string|Date}): Promise<object>`
- `deleteClient(clientId: string): Promise<boolean>`
- `getAuthCode(code: string): Promise<object|null>`
- `getClient(clientId: string): Promise<object|null>`
- `getRefreshToken(refreshToken: string): Promise<object|null>`
- `listAuthorizations(googleAuthId: number): Promise<Array<object>>`
- `listClients(): Promise<Array<object>>`
- `registerClient(input: {clientId: string, clientSecret?: string|null, clientName: string, redirectUris: string|Array<string>, grantTypes?: string|Array<string>, responseTypes?: string|Array<string>, scope?: string|null}): Promise<object>`
- `revokeAuthorizations(googleAuthId: number, clientId: string): Promise<boolean>`
- `revokeRefreshToken(refreshToken: string): Promise<boolean>`
- [modelconversation](API.modelconversation.md)
- `create(googleAuthId: number, input: {provider: string, model: string}, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
- `create(googleAuthId: number, input: {provider: string, model: string, started_by?: number|null}, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>`
- `delete(googleAuthId: number, conversationId: number, tx?: object): Promise<void>`
- `get(googleAuthId: number, conversationId: number, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>>`
- `setState(googleAuthId: number, conversationId: number, state: string, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
- `get(googleAuthId: number, conversationId: number, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null, first_user_message: string|null}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null, first_user_message: string|null}>>`
- `setState(googleAuthId: number, conversationId: number, state: string, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>`
- [modelmessage](API.modelmessage.md)
- `add(googleAuthId: number, conversationId: number, input: {message: string, role?: 'user'|'assistant'|'system'|'tool'|'reasoning'|'usage'}, tx?: object): Promise<{id: number, conversation_id: number, created_at: string, role: string, message: string}>`
- `list(googleAuthId: number, conversationId: number, tx?: object): Promise<Array<{id: number, conversation_id: number, created_at: string, role: string, message: string}>>`
@@ -323,8 +350,3 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, accountId: number, manifestId: number, type: string, amount: number}>>`
- [runner.js](API.runner.js.md)
- `runner(provider: import('../providers/provider.js').DatabaseProvider, tx?: object): ((sql: string, params?: object) => Promise<import('../providers/provider.js').QueryResult>) & { query: Function, dialect?: string }`
- [shipping](API.shipping.md)
- `create(googleAuthId: number, input: {manifestId: number, type: string, state: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, type: string, state: string}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, manifestId: number, number: string, type: string, state: string}>>`
- `update(provider: import('../../providers/provider.js').DatabaseProvider): void`

View File

@@ -16,15 +16,16 @@ Creates a new (idle) model conversation for the user in the selected company.
| `input` | `object` | Conversation input. |
| `input.provider` | `string` | LLM provider name. |
| `input.model` | `string` | Model identifier. |
| `[input.started_by]` | `number|null` | Id of the conversation that starts this one as a subagent (unenforced reference; omit/null for a top-level conversation). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>`
**Throws**
`Error` — 'Provider required' or 'Model required'.
`Error` — 'Provider required', 'Model required' or 'Invalid started_by'.
**Example**
@@ -36,7 +37,7 @@ const c = await store.modelconversation.create(42, { provider: 'openrouter', mod
*Source: `src/store/modelconversation/delete.js`*
Deletes a model conversation by ID for the user in the selected company.
Deletes a model conversation by ID for the user in the selected company, together with every subagent conversation below it (`started_by` chain, recursively) and their messages.
**Parameters**
@@ -70,7 +71,7 @@ Gets a single model conversation by ID for the user in the selected company.
**Returns**
`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null, first_user_message: string|null}>` — `first_user_message` is the stored `message` payload of the first `user` message (null if none yet).
**Throws**
@@ -91,7 +92,7 @@ Lists the user's own model conversations in the selected company, newest activit
**Returns**
`Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>>`
`Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null, first_user_message: string|null}>>` — `started_by` is the originating conversation id for subagent conversations (null otherwise), so clients can build a tree. `first_user_message` is the stored `message` payload of the conversation's first `user` message (null if none yet).
**Example**
@@ -116,7 +117,7 @@ Sets the state of a model conversation ('idle' | 'busy').
**Returns**
`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>`
**Throws**

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/numbers/list.js`*
Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery') exist.
Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
**Parameters**

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/order/create.js`*
Creates a new order for the user's selected company with its lines.
Creates a new order for the user's selected company with its lines and records article history.
**Parameters**
@@ -104,9 +104,13 @@ Lists the orders of the user's selected company.
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination options. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of orders to skip. |
| `[opts.limit=20]` | `number` | Number of orders to return. |
| `[opts.limit=50]` | `number` | Number of orders to return. |
| `[opts.search]` | `string` | Free-text search matching order number, external number, or customer name. |
| `[opts.onlyUnpaid=false]` | `boolean` | When true, returns only unpaid orders. |
| `[opts.onlyUndelivered=false]` | `boolean` | When true, returns only undelivered orders. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
@@ -116,13 +120,31 @@ Lists the orders of the user's selected company.
**Example**
```js
const result = await store.order.list(42, { offset: 0, limit: 20 });
// => { orders: [...], total: 100, offset: 0, limit: 20 }
const result = await store.order.list(42, { offset: 0, limit: 50, search: '1002', onlyUnpaid: true, onlyUndelivered: true, seekMonths: -1 });
// => { orders: [...], total: 10, offset: 0, limit: 50 }
const orders = await store.order.list(42);
// => [{ id, number, type, entityId, externalNumber, date, createdAt, lines: [...] }]
```
## `order.rebuildSummaries`
*Source: `src/store/order/rebuildSummaries.js`*
Rebuilds the `ordersummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `order.syncSummary`
*Source: `src/store/order/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `ordersummary` table.
## `order.update`
*Source: `src/store/order/update.js`*

51
API.shipment.md Normal file
View File

@@ -0,0 +1,51 @@
# shipment
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `shipment.create`
*Source: `src/store/shipment/create.js`*
Creates a new shipment manifest for an order. Validates that all article positions belong to the order and do not exceed remaining ordered quantities.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.delete`
*Source: `src/store/shipment/delete.js`*
Deletes a shipment manifest of the selected company. Shipped shipments (with packages in 'shipped' state) cannot be deleted.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.getById`
*Source: `src/store/shipment/getById.js`*
Retrieves a shipment manifest by ID for the selected company, including lines and packages.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.listByOrderId`
*Source: `src/store/shipment/listByOrderId.js`*
Lists all shipment manifests belonging to an order.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/shipping/create.js`*
Creates a shipping for a manifest (order). The number is allocated from the `shipping` numbers sequence (own prefix + counter). If state is 'shipped', adjusts stock by appending negative ledger movements for tracked order lines, writes article history, and ensures the order was not already shipped.
Creates a shipping (package) for a shipment manifest. The number is allocated from the `shipping` numbers sequence (own prefix + counter). If state is 'shipped', adjusts stock by appending negative ledger movements for tracked shipment lines, writes article history for all items, and ensures stock is deducted once.
**Parameters**
@@ -14,24 +14,26 @@ Creates a shipping for a manifest (order). The number is allocated from the `shi
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Shipping input. |
| `input.manifestId` | `number` | The manifest (order) id. |
| `input.manifestId` | `number` | The shipment manifest id. |
| `input.type` | `string` | Shipping type. |
| `input.state` | `string` | Shipping state ('shipped' or other). |
| `[input.trackingCode]` | `string` | Carrier tracking code. |
| `[input.carrier]` | `string` | Carrier name. |
| `[input.warehouseId]` | `number` | Optional warehouse ID for stock deduction (defaults to company's first warehouse). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, manifestId: number, number: string, type: string, state: string}>`
`Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>`
**Throws**
`Error` — If manifest is not found, order already shipped, or input is invalid.
`Error` — If manifest is not found, manifest is not a shipment, or input is invalid.
**Example**
```js
await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped' });
await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped', trackingCode: '1Z999', carrier: 'UPS' });
```
## `shipping.delete`

View File

@@ -27,6 +27,8 @@ const MODULE_ORDER = [
'delivery',
'quote',
'order',
'shipment',
'shipping',
'manifestattachment',
'ledger',
'warehouse',
@@ -322,7 +324,7 @@ const SCHEMA_TABLES = [
['article', 'id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_parent:b=0, parentarticle_id?->article, parentattributename_id?->attributename, parentsort:i=0, created_at:dt, updated_at:dt, UQ(parentarticle_id, parentsort)'],
['articleattributevalue', 'id PK, article_id->article, attributevalue_id->attributevalue, sort:i=0, UQ(article_id, attributevalue_id)'],
['articleean', 'id PK, article_id->article, ean:s(50) UQ'],
['articlehistory', 'id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt'],
['articlehistory', 'id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt, meta?:txt'],
['articlelist', 'id PK'],
['articlelistarticle', 'id PK, articlelist_id->articlelist, article_id->article, count:i=1, sort:i=0, UQ(articlelist_id, article_id), UQ(articlelist_id, sort)'],
['articlepic', 'id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)'],

File diff suppressed because one or more lines are too long

View File

@@ -1246,49 +1246,53 @@
<!-- articlehistory -->
<g id="node23" class="node">
<title>articlehistory</title>
<polygon fill="#33415c" stroke="none" points="1648.85,-1073.6 1648.85,-1098 1814.89,-1098 1814.89,-1073.6 1648.85,-1073.6"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1073.6 1648.85,-1098 1814.89,-1098 1814.89,-1073.6 1648.85,-1073.6"/>
<text xml:space="preserve" text-anchor="start" x="1694.19" y="-1083.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#e2e8f0">articlehistory</text>
<polygon fill="#2b3a52" stroke="none" points="1648.85,-1046.8 1648.85,-1073.6 1814.89,-1073.6 1814.89,-1046.8 1648.85,-1046.8"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1046.8 1648.85,-1073.6 1814.89,-1073.6 1814.89,-1046.8 1648.85,-1046.8"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-1057" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#d8dee9">id</text>
<text xml:space="preserve" text-anchor="start" x="1780.39" y="-1058.5" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">INT </text>
<text xml:space="preserve" text-anchor="start" x="1797.39" y="-1058.5" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#8b95a7">PK</text>
<polygon fill="#2b3a52" stroke="none" points="1648.85,-1020 1648.85,-1046.8 1814.89,-1046.8 1814.89,-1020 1648.85,-1020"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1020 1648.85,-1046.8 1814.89,-1046.8 1814.89,-1020 1648.85,-1020"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-1030.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#d8dee9">article_id</text>
<text xml:space="preserve" text-anchor="start" x="1780.9" y="-1031.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">INT </text>
<text xml:space="preserve" text-anchor="start" x="1797.9" y="-1031.7" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#8b95a7">FK</text>
<polygon fill="#2b3a52" stroke="none" points="1648.85,-993.2 1648.85,-1020 1814.89,-1020 1814.89,-993.2 1648.85,-993.2"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-993.2 1648.85,-1020 1814.89,-1020 1814.89,-993.2 1648.85,-993.2"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-1003.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#d8dee9">companyuser_id</text>
<text xml:space="preserve" text-anchor="start" x="1765.11" y="-1003.4" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">?</text>
<text xml:space="preserve" text-anchor="start" x="1780.9" y="-1004.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">INT </text>
<text xml:space="preserve" text-anchor="start" x="1797.9" y="-1004.9" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#8b95a7">FK</text>
<polygon fill="#272e3d" stroke="none" points="1648.85,-966.4 1648.85,-993.2 1814.89,-993.2 1814.89,-966.4 1648.85,-966.4"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-966.4 1648.85,-993.2 1814.89,-993.2 1814.89,-966.4 1648.85,-966.4"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-974.2" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">date</text>
<text xml:space="preserve" text-anchor="start" x="1759.39" y="-976.2" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">DATETIME2</text>
<polygon fill="#2a3242" stroke="none" points="1648.85,-939.6 1648.85,-966.4 1814.89,-966.4 1814.89,-939.6 1648.85,-939.6"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-939.6 1648.85,-966.4 1814.89,-966.4 1814.89,-939.6 1648.85,-939.6"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-947.4" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">text</text>
<text xml:space="preserve" text-anchor="start" x="1733.89" y="-949.4" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">NVARCHAR(MAX)</text>
<polygon fill="#33415c" stroke="none" points="1648.85,-1087 1648.85,-1111.4 1814.89,-1111.4 1814.89,-1087 1648.85,-1087"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1087 1648.85,-1111.4 1814.89,-1111.4 1814.89,-1087 1648.85,-1087"/>
<text xml:space="preserve" text-anchor="start" x="1694.19" y="-1096.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#e2e8f0">articlehistory</text>
<polygon fill="#2b3a52" stroke="none" points="1648.85,-1060.2 1648.85,-1087 1814.89,-1087 1814.89,-1060.2 1648.85,-1060.2"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1060.2 1648.85,-1087 1814.89,-1087 1814.89,-1060.2 1648.85,-1060.2"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-1070.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#d8dee9">id</text>
<text xml:space="preserve" text-anchor="start" x="1780.39" y="-1071.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">INT </text>
<text xml:space="preserve" text-anchor="start" x="1797.39" y="-1071.9" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#8b95a7">PK</text>
<polygon fill="#2b3a52" stroke="none" points="1648.85,-1033.4 1648.85,-1060.2 1814.89,-1060.2 1814.89,-1033.4 1648.85,-1033.4"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1033.4 1648.85,-1060.2 1814.89,-1060.2 1814.89,-1033.4 1648.85,-1033.4"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-1043.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#d8dee9">article_id</text>
<text xml:space="preserve" text-anchor="start" x="1780.9" y="-1045.1" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">INT </text>
<text xml:space="preserve" text-anchor="start" x="1797.9" y="-1045.1" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#8b95a7">FK</text>
<polygon fill="#2b3a52" stroke="none" points="1648.85,-1006.6 1648.85,-1033.4 1814.89,-1033.4 1814.89,-1006.6 1648.85,-1006.6"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-1006.6 1648.85,-1033.4 1814.89,-1033.4 1814.89,-1006.6 1648.85,-1006.6"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-1016.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#d8dee9">companyuser_id</text>
<text xml:space="preserve" text-anchor="start" x="1765.11" y="-1016.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">?</text>
<text xml:space="preserve" text-anchor="start" x="1780.9" y="-1018.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">INT </text>
<text xml:space="preserve" text-anchor="start" x="1797.9" y="-1018.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#8b95a7">FK</text>
<polygon fill="#272e3d" stroke="none" points="1648.85,-979.8 1648.85,-1006.6 1814.89,-1006.6 1814.89,-979.8 1648.85,-979.8"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-979.8 1648.85,-1006.6 1814.89,-1006.6 1814.89,-979.8 1648.85,-979.8"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-987.6" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">date</text>
<text xml:space="preserve" text-anchor="start" x="1759.39" y="-989.6" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">DATETIME2</text>
<polygon fill="#2a3242" stroke="none" points="1648.85,-953 1648.85,-979.8 1814.89,-979.8 1814.89,-953 1648.85,-953"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-953 1648.85,-979.8 1814.89,-979.8 1814.89,-953 1648.85,-953"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-960.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">text</text>
<text xml:space="preserve" text-anchor="start" x="1733.89" y="-962.8" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">NVARCHAR(MAX)</text>
<polygon fill="#272e3d" stroke="none" points="1648.85,-926.2 1648.85,-953 1814.89,-953 1814.89,-926.2 1648.85,-926.2"/>
<polygon fill="none" stroke="#3f4c5f" points="1648.85,-926.2 1648.85,-953 1814.89,-953 1814.89,-926.2 1648.85,-926.2"/>
<text xml:space="preserve" text-anchor="start" x="1653.85" y="-934" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d8dee9">meta?</text>
<text xml:space="preserve" text-anchor="start" x="1733.89" y="-936" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">NVARCHAR(MAX)</text>
</g>
<!-- articlehistory&#45;&gt;companyuser -->
<g id="edge28" class="edge">
<title>articlehistory:e&#45;&gt;companyuser:n</title>
<g id="a_edge28"><a xlink:title="FK_articlehistory_companyuser">
<path fill="none" stroke="#5aa7ff" stroke-width="1.4" d="M1815.89,-1006.6C1875.29,-1006.6 1809.1,-914.69 1856.87,-879.4 2089.17,-707.8 6820.72,-979.05 7054.87,-810 7120.81,-762.4 7117.3,-718.3 7116.98,-639.55"/>
<path fill="none" stroke="#5aa7ff" stroke-width="1.4" d="M1815.89,-1020C1880.98,-1020 1805.07,-918.81 1856.87,-879.4 2086.74,-704.56 6820.72,-979.05 7054.87,-810 7120.81,-762.4 7117.3,-718.3 7116.98,-639.55"/>
<polygon fill="#5aa7ff" stroke="#5aa7ff" stroke-width="1.4" points="7119.43,-639.91 7116.96,-632.92 7114.53,-639.92 7119.43,-639.91"/>
</a>
</g>
</g>
<!-- articlehistory&#45;&gt;article -->
<g id="edge27" class="edge">
<title>articlehistory:sw&#45;&gt;article:n</title>
<title>articlehistory:se&#45;&gt;article:n</title>
<g id="a_edge27"><a xlink:title="FK_articlehistory_article">
<path fill="none" stroke="#6bdf9a" stroke-width="1.4" d="M1647.85,-1020C1621.33,-993.47 1627.31,-966.31 1648.85,-935.6 1694.51,-870.52 1974.73,-886.23 1995.47,-813.78"/>
<polygon fill="#6bdf9a" stroke="#6bdf9a" stroke-width="1.4" points="1997.86,-814.37 1996.39,-807.1 1993.01,-813.7 1997.86,-814.37"/>
<path fill="none" stroke="#6bdf9a" stroke-width="1.4" d="M1815.89,-1033.4C1865.98,-983.32 1812.84,-934.87 1856.87,-879.4 1898.67,-826.74 1988.16,-871.01 1996.11,-813.77"/>
<polygon fill="#6bdf9a" stroke="#6bdf9a" stroke-width="1.4" points="1998.54,-814.26 1996.55,-807.11 1993.65,-813.94 1998.54,-814.26"/>
</a>
</g>
</g>

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 321 KiB

94
scripts/test-mcp.js Normal file
View File

@@ -0,0 +1,94 @@
import assert from 'node:assert';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function main() {
console.log('Testing MCP store methods on in-memory SQLite...');
const provider = new BetterSqlite3Provider({ path: ':memory:' });
await provider.initialize();
const store = await new LibDashStore(provider);
// 1. Create a google_auth user
const googleAuthId = await store.google_auth.add('mcp-tester@example.com');
assert(googleAuthId > 0, 'googleAuthId should be positive integer');
// 2. Register client
const client = await store.mcp.registerClient({
clientId: 'test-client-123',
clientSecret: 'secret-456',
clientName: 'Test Client',
redirectUris: ['http://localhost:3000/callback'],
grantTypes: ['authorization_code', 'refresh_token'],
responseTypes: ['code'],
scope: 'mcp:tools',
});
console.log('Registered client:', client);
assert.strictEqual(client.clientId, 'test-client-123');
assert.strictEqual(client.clientName, 'Test Client');
// 3. Get client
const fetched = await store.mcp.getClient('test-client-123');
assert(fetched, 'Should find client');
assert.strictEqual(fetched.clientId, 'test-client-123');
// 4. List clients
const clients = await store.mcp.listClients();
assert.strictEqual(clients.length, 1);
// 5. Create auth code
const code = 'auth-code-xyz';
await store.mcp.createAuthCode({
code,
clientId: 'test-client-123',
googleAuthId,
redirectUri: 'http://localhost:3000/callback',
codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
codeChallengeMethod: 'S256',
scope: 'mcp:tools',
expiresAt: new Date(Date.now() + 300_000),
});
// 6. Get auth code
const codeRow = await store.mcp.getAuthCode(code);
assert(codeRow, 'Should find auth code');
assert.strictEqual(codeRow.clientId, 'test-client-123');
assert.strictEqual(codeRow.used, 0);
// 7. Consume auth code
await store.mcp.consumeAuthCode(code);
const consumedRow = await store.mcp.getAuthCode(code);
assert.strictEqual(consumedRow.used, 1);
// 8. Create refresh token
const refreshToken = 'refresh-token-abc';
await store.mcp.createRefreshToken({
refreshToken,
clientId: 'test-client-123',
googleAuthId,
scope: 'mcp:tools',
expiresAt: new Date(Date.now() + 86400_000),
});
// 9. Get refresh token
const tokenRow = await store.mcp.getRefreshToken(refreshToken);
assert(tokenRow, 'Should find refresh token');
assert.strictEqual(tokenRow.revoked, 0);
// 10. Revoke refresh token
await store.mcp.revokeRefreshToken(refreshToken);
const revokedRow = await store.mcp.getRefreshToken(refreshToken);
assert.strictEqual(revokedRow.revoked, 1);
// 11. Delete client
await store.mcp.deleteClient('test-client-123');
const deleted = await store.mcp.getClient('test-client-123');
assert.strictEqual(deleted, null);
await provider.close();
console.log('✓ All MCP store tests passed successfully!');
}
main().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -33,6 +33,7 @@ async function run() {
const list = await store.modelconversation.list(userA);
assert.equal(list.length, 1);
assert.equal(list[0].first_user_message, null);
assert.deepEqual(await store.modelmessage.list(userA, conv.id), []);
@@ -65,6 +66,8 @@ async function run() {
const msgs = await store.modelmessage.list(userA, conv.id);
assert.deepEqual(msgs.map((m) => m.message), ['hello', 'echo: hello', 'second']);
assert.deepEqual(msgs.map((m) => m.role), ['user', 'assistant', 'user']);
assert.equal((await store.modelconversation.list(userA)).find((c) => c.id === conv.id).first_user_message, 'hello');
assert.equal((await store.modelconversation.get(userA, conv.id)).first_user_message, 'hello');
// Other user cannot see or write
const userB = await store.google_auth.add('mc-b@example.com');
@@ -74,6 +77,33 @@ async function run() {
await assert.rejects(store.modelmessage.list(userB, conv.id), /Conversation not found/);
await assert.rejects(store.modelmessage.add(userB, conv.id, { message: 'x' }), /Conversation not found/);
// started_by: top-level is null, subagents link to their originator, list exposes it
assert.equal(conv.started_by, null);
const sub1 = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: conv.id });
assert.equal(sub1.started_by, conv.id);
const sub2 = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: sub1.id });
const sibling = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm' });
await assert.rejects(
store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: 0 }),
/Invalid started_by/,
);
const listed = await store.modelconversation.list(userA);
assert.equal(listed.find((c) => c.id === sub2.id).started_by, sub1.id);
assert.equal((await store.modelconversation.get(userA, sub1.id)).started_by, conv.id);
await store.modelmessage.add(userA, sub2.id, { message: 'deep' });
// Deleting a child removes only its own chain; deleting the root removes the whole chain
await store.modelconversation.delete(userA, sub2.id);
await assert.rejects(store.modelconversation.get(userA, sub2.id), /Conversation not found/);
await store.modelconversation.get(userA, sub1.id);
const sub3 = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: sub1.id });
await store.modelconversation.delete(userA, conv.id);
for (const gone of [conv.id, sub1.id, sub3.id]) {
await assert.rejects(store.modelconversation.get(userA, gone), /Conversation not found/);
}
await store.modelconversation.get(userA, sibling.id);
await assert.rejects(store.modelconversation.delete(userA, conv.id), /Conversation not found/);
console.log('PASS');
} finally {
await provider.close?.();

View File

@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Order Filtering & Paging in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-order-filters-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('filter-test@example.com');
const company = await store.company.create({
name: 'Filter Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const warehouse = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
await store.unit.upsert(googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
});
const groups = await store.customergroup.list(googleAuthId);
const customerAlice = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
firstName: 'Alice',
name: 'Wonderland',
});
const customerBob = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
firstName: 'Bob',
name: 'Builder',
});
const art = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Widget',
track_stock: 1,
});
await store.stock.adjust(googleAuthId, {
sku: art.sku,
warehouseId: warehouse.id,
amount: 100,
text: 'Stock in',
});
// Account for payments
const account = await store.account.create(googleAuthId, {
name: 'Bank',
type: 'bank',
});
// Create 3 orders
// Order 1: Alice, O-1001, €119 (1 * 100 + 19% tax), Date: 2 months ago (2026-08-01)
// Paid in full, Unshipped
const order1 = await store.order.create(googleAuthId, {
number: 'O-1001',
date: '2026-08-01T10:00:00Z',
entityId: customerAlice.id,
lines: [{ sku: art.sku, name: 'Widget', amount: 1, net: 100, taxrate: 19 }],
});
await store.payment.create(googleAuthId, {
accountId: account.id,
manifestId: order1.id,
type: 'bank_transfer',
amount: 119,
});
// Order 2: Bob, O-1002, €238 (2 * 100 + 19% tax), Date: 10 days ago (2026-10-03)
// Unpaid, Shipped in full with shipping 'shipped'
const order2 = await store.order.create(googleAuthId, {
number: 'O-1002',
date: '2026-10-03T10:00:00Z',
entityId: customerBob.id,
lines: [{ sku: art.sku, name: 'Widget', amount: 2, net: 100, taxrate: 19 }],
});
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order2.id,
lines: [{ sku: art.sku, amount: 2 }],
});
await store.shipping.create(googleAuthId, {
manifestId: shipment2.id,
type: 'parcel',
state: 'shipped',
});
// Order 3: Alice, O-1003, €59.50, Date: today (2026-10-13)
// Unpaid, Undelivered
const order3 = await store.order.create(googleAuthId, {
number: 'O-1003',
date: '2026-10-13T10:00:00Z',
entityId: customerAlice.id,
lines: [{ sku: art.sku, name: 'Widget', amount: 1, net: 50, taxrate: 19 }],
});
// Order 4: Bob, O-1004, €119 (1 * 100 + 19% tax), Date: today (2026-10-13)
// Overpaid (paid €150), Delivered
const order4 = await store.order.create(googleAuthId, {
number: 'O-1004',
date: '2026-10-13T10:00:00Z',
entityId: customerBob.id,
lines: [{ sku: art.sku, name: 'Widget', amount: 1, net: 100, taxrate: 19 }],
});
await store.payment.create(googleAuthId, {
accountId: account.id,
manifestId: order4.id,
type: 'bank_transfer',
amount: 150, // Overpaid!
});
const shipment4 = await store.shipment.create(googleAuthId, {
orderId: order4.id,
lines: [{ sku: art.sku, amount: 1 }],
});
await store.shipping.create(googleAuthId, {
manifestId: shipment4.id,
type: 'parcel',
state: 'shipped',
});
console.log('Orders created successfully.');
// 1. Search Alice
const resAlice = await store.order.list(googleAuthId, { search: 'Alice' });
assert.equal(resAlice.total, 2, 'Should find 2 orders for Alice');
assert.deepEqual(resAlice.orders.map((o) => o.number), ['O-1003', 'O-1001']);
// 2. Search Bob
const resBob = await store.order.list(googleAuthId, { search: 'Bob' });
assert.equal(resBob.total, 2, 'Should find 2 orders for Bob');
assert.deepEqual(resBob.orders.map((o) => o.number), ['O-1004', 'O-1002']);
// 3. Search 1002
const res1002 = await store.order.list(googleAuthId, { search: '1002' });
assert.equal(res1002.total, 1, 'Should find 1 order for 1002');
assert.equal(res1002.orders[0].number, 'O-1002');
// 4. onlyUnpaid (O-1004 is overpaid, so it also counts as unpaid!)
const resUnpaid = await store.order.list(googleAuthId, { onlyUnpaid: true });
assert.equal(resUnpaid.total, 3, 'O-1004 (overpaid), O-1003 (unpaid), O-1002 (unpaid) are unpaid');
assert.deepEqual(resUnpaid.orders.map((o) => o.number), ['O-1004', 'O-1003', 'O-1002']);
// 5. onlyUndelivered
const resUndelivered = await store.order.list(googleAuthId, { onlyUndelivered: true });
assert.equal(resUndelivered.total, 2, 'O-1001 and O-1003 are undelivered');
assert.deepEqual(resUndelivered.orders.map((o) => o.number), ['O-1003', 'O-1001']);
// 6. onlyUnpaid AND onlyUndelivered
const resBoth = await store.order.list(googleAuthId, { onlyUnpaid: true, onlyUndelivered: true });
assert.equal(resBoth.total, 1, 'Only O-1003 is both unpaid and undelivered');
assert.equal(resBoth.orders[0].number, 'O-1003');
// 7. seekMonths -1 (orders older than 1 month)
const resSeek = await store.order.list(googleAuthId, { seekMonths: -1 });
assert.equal(resSeek.total, 1, 'Only O-1001 is older than 1 month');
assert.equal(resSeek.orders[0].number, 'O-1001');
// 8. Paging
const resPaged = await store.order.list(googleAuthId, { offset: 0, limit: 1 });
assert.equal(resPaged.total, 4);
assert.equal(resPaged.orders.length, 1);
assert.equal(resPaged.orders[0].number, 'O-1004');
const resPaged2 = await store.order.list(googleAuthId, { offset: 1, limit: 1 });
assert.equal(resPaged2.total, 4);
assert.equal(resPaged2.orders.length, 1);
assert.equal(resPaged2.orders[0].number, 'O-1003');
// 9. Unpaged backwards-compatibility
const resAll = await store.order.list(googleAuthId);
assert(Array.isArray(resAll));
assert.equal(resAll.length, 4);
console.log('✓ All filter and pagination tests passed via store.order.list!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -106,10 +106,17 @@ async function run() {
});
assert.equal(order.lines.length, 2);
// 4. Create shipping with state 'open' (not shipped yet)
console.log('4. Creating shipping with state "open"...');
// 4. Create shipment and shipping with state 'open' (not shipped yet)
console.log('4. Creating shipment and shipping with state "open"...');
const shipment = await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [
{ sku: trackedArt.sku, amount: 3 },
{ sku: untrackedArt.sku, amount: 2 },
],
});
const shippingOpen = await store.shipping.create(googleAuthId, {
manifestId: order.id,
manifestId: shipment.id,
type: 'parcel',
state: 'open',
});
@@ -145,8 +152,13 @@ async function run() {
// Assert article history: order goes into history, shipment does NOT
const history = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku);
console.log('Tracked article history:', history.map((h) => h.text));
const orderHistory = history.find((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 3)');
assert(orderHistory, 'Article history should contain order creation entry');
const orderHistory = history.find((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 3, € 59,50)');
assert(orderHistory, 'Article history should contain order creation entry with formatted gross price');
assert(orderHistory.meta, 'Article history entry should have meta');
const parsedMeta = JSON.parse(orderHistory.meta);
assert.equal(parsedMeta.type, 'order');
assert.equal(parsedMeta.orderId, order.id);
assert.equal(parsedMeta.gross, 59.5);
const shippingHistory = history.find((h) => h.text.includes('Versand Auftrag'));
assert(!shippingHistory, 'Article history should NOT report the shipment');
@@ -154,14 +166,29 @@ async function run() {
const untrackedHistory = await store.article.getHistoryBySku(googleAuthId, untrackedArt.sku);
console.log('Untracked article history:', untrackedHistory.map((h) => h.text));
assert(
untrackedHistory.some((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 2)'),
'Untracked article history should contain order creation entry',
untrackedHistory.some((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 2, € 23,80)'),
'Untracked article history should contain order creation entry with formatted gross price',
);
assert(
!untrackedHistory.some((h) => h.text.includes('Versand Auftrag')),
'Untracked article history should NOT report the shipment',
);
// Test order import with historical date preserves date in article history
const historicalDate = '2023-04-15T10:30:00.000Z';
await store.order.create(googleAuthId, {
number: 'O-HIST-001',
date: historicalDate,
createdAt: historicalDate,
lines: [{ sku: trackedArt.sku, amount: 1, net: 10, taxrate: 19 }],
});
const histWithOld = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku);
const oldEntry = histWithOld.find((h) => h.text.startsWith('Auftrag O-HIST-001'));
assert(oldEntry, 'Historical order entry should exist in article history');
assert.equal(new Date(oldEntry.date).toISOString(), historicalDate, 'History entry should preserve historical order date');
const oldMeta = JSON.parse(oldEntry.meta);
assert.equal(oldMeta.orderNumber, 'O-HIST-001');
// 6. Test immutability: once shipped, cannot be deleted
console.log('6. Testing cannot delete shipped shipping...');
await assert.rejects(
@@ -180,8 +207,8 @@ async function run() {
{ message: 'Shipped shipping cannot be undone' },
);
// 8. Test order cannot be shipped again
console.log('8. Testing order cannot be shipped twice...');
// 8. Test order manifest cannot be used directly for shipping
console.log('8. Testing order manifest cannot be used directly for shipping...');
await assert.rejects(
async () => {
await store.shipping.create(googleAuthId, {
@@ -190,10 +217,10 @@ async function run() {
state: 'shipped',
});
},
{ message: 'Order already shipped' },
{ message: 'Manifest is not a shipment' },
);
// 9. Test direct creation with state 'shipped'
// 9. Test direct creation with state 'shipped' on shipment manifest
console.log('9. Testing direct creation with state "shipped"...');
const order2 = await store.order.create(googleAuthId, {
number: 'O-2026-002',
@@ -211,8 +238,13 @@ async function run() {
],
});
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order2.id,
lines: [{ sku: trackedArt.sku, amount: 2 }],
});
const directShipped = await store.shipping.create(googleAuthId, {
manifestId: order2.id,
manifestId: shipment2.id,
type: 'parcel',
state: 'shipped',
});

240
scripts/test-shipment.js Normal file
View File

@@ -0,0 +1,240 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Shipment Manifest & Multi-Package Shipping ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-shipment-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('shipment-test@example.com');
const company = await store.company.create({
name: 'Shipment Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Create a warehouse
const wh = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
// Create customer entity
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
name: 'Max Mustermann',
});
// Create Item A and Item B
const itemA = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Item A',
sku: 'ITEM-A',
});
const itemB = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Item B',
sku: 'ITEM-B',
});
// Stock up Item A (20) and Item B (20)
await store.stock.adjust(googleAuthId, {
sku: 'ITEM-A',
warehouseId: wh.id,
amount: 20,
comment: 'Initial Stock',
});
await store.stock.adjust(googleAuthId, {
sku: 'ITEM-B',
warehouseId: wh.id,
amount: 20,
comment: 'Initial Stock',
});
// Create Order with 10x Item A, 5x Item B
const order = await store.order.create(googleAuthId, {
number: 'AU-1001',
date: '2026-10-12',
entityId: customer.id,
lines: [
{ sku: 'ITEM-A', amount: 10, net: 100, taxrate: 19 },
{ sku: 'ITEM-B', amount: 5, net: 50, taxrate: 19 },
],
});
assert.equal(order.number, 'AU-1001');
console.log('Order created:', order.number);
// 1. Create Shipment 1: 6x Item A, 3x Item B -> AU-1001-L-1
const shipment1 = await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [
{ sku: 'ITEM-A', amount: 6 },
{ sku: 'ITEM-B', amount: 3 },
],
});
assert.equal(shipment1.number, 'AU-1001-L-1');
assert.equal(shipment1.lines.length, 2);
console.log('PASS: Shipment 1 created with number:', shipment1.number);
// 2. Create Shipment 2: 5x Item A -> Fails with ceiling violation (6 + 5 > 10)
let ceilingErrorThrown = false;
try {
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'ITEM-A', amount: 5 }],
});
} catch (err) {
ceilingErrorThrown = true;
assert.match(err.message, /Shipment amount exceeds ordered amount for article \(ITEM-A\)/);
console.log('PASS: Ceiling violation caught:', err.message);
}
assert.ok(ceilingErrorThrown, 'Expected ceiling violation error to be thrown');
// Also test foreign article rejection
let foreignArticleErrorThrown = false;
try {
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'ITEM-UNKNOWN', amount: 1 }],
});
} catch (err) {
foreignArticleErrorThrown = true;
assert.match(err.message, /Article \(ITEM-UNKNOWN\) is not part of order/);
console.log('PASS: Foreign article rejected:', err.message);
}
assert.ok(foreignArticleErrorThrown, 'Expected foreign article error to be thrown');
// 3. Create Shipment 2: 4x Item A, 2x Item B -> AU-1001-L-2
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [
{ sku: 'ITEM-A', amount: 4 },
{ sku: 'ITEM-B', amount: 2 },
],
});
assert.equal(shipment2.number, 'AU-1001-L-2');
assert.equal(shipment2.lines.length, 2);
console.log('PASS: Shipment 2 created with number:', shipment2.number);
// 4. Try creating another shipment (now 10/10 of Item A and 5/5 of Item B are shipped)
let exhaustedErrorThrown = false;
try {
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'ITEM-A', amount: 1 }],
});
} catch (err) {
exhaustedErrorThrown = true;
assert.match(err.message, /Shipment amount exceeds ordered amount for article \(ITEM-A\)/);
console.log('PASS: Exhausted quantity violation caught:', err.message);
}
assert.ok(exhaustedErrorThrown, 'Expected exhausted error to be thrown');
// 5. Add packages to Shipment 1 with tracking codes
const pkg1 = await store.shipping.create(googleAuthId, {
manifestId: shipment1.id,
type: 'parcel',
state: 'draft',
trackingCode: 'TRACK-111',
carrier: 'DHL',
});
assert.equal(pkg1.trackingCode, 'TRACK-111');
assert.equal(pkg1.carrier, 'DHL');
assert.equal(pkg1.state, 'draft');
console.log('PASS: Package 1 created for Shipment 1:', pkg1.number, pkg1.trackingCode);
const pkg2 = await store.shipping.create(googleAuthId, {
manifestId: shipment1.id,
type: 'parcel',
state: 'draft',
trackingCode: 'TRACK-222',
carrier: 'DHL',
});
assert.equal(pkg2.trackingCode, 'TRACK-222');
console.log('PASS: Package 2 created for Shipment 1:', pkg2.number, pkg2.trackingCode);
// Check listByOrderId
const orderShipments = await store.shipment.listByOrderId(googleAuthId, order.id);
assert.equal(orderShipments.length, 2);
assert.equal(orderShipments[0].shippings.length, 2);
console.log('PASS: store.shipment.listByOrderId returned both shipments');
// Check getById
const fetchedShipment1 = await store.shipment.getById(googleAuthId, shipment1.id);
assert.equal(fetchedShipment1.id, shipment1.id);
assert.equal(fetchedShipment1.shippings.length, 2);
console.log('PASS: store.shipment.getById returned shipment with shippings');
// 6. Transition Package 1 to shipped (should deduct ledger stock for Shipment 1 lines: 6 of Item A, 3 of Item B)
const updatedPkg1 = await store.shipping.update(googleAuthId, pkg1.id, {
state: 'shipped',
});
assert.equal(updatedPkg1.state, 'shipped');
console.log('PASS: Package 1 transitioned to shipped');
// Transition Package 2 to shipped (stock already booked for shipment1, should not double-deduct)
const updatedPkg2 = await store.shipping.update(googleAuthId, pkg2.id, {
state: 'shipped',
});
assert.equal(updatedPkg2.state, 'shipped');
console.log('PASS: Package 2 transitioned to shipped');
// 7. Verify cannot delete shipped shipment
let deleteErrorThrown = false;
try {
await store.shipment.delete(googleAuthId, shipment1.id);
} catch (err) {
deleteErrorThrown = true;
assert.match(err.message, /Shipped shipment cannot be deleted/);
console.log('PASS: Shipped shipment delete rejected:', err.message);
}
assert.ok(deleteErrorThrown, 'Expected shipped shipment delete error');
// 8. Delete unshipped shipment 2
await store.shipment.delete(googleAuthId, shipment2.id);
const shipmentsAfterDel = await store.shipment.listByOrderId(googleAuthId, order.id);
assert.equal(shipmentsAfterDel.length, 1);
console.log('PASS: Unshipped shipment 2 successfully deleted');
// 9. Verify peek next number works for entity and other types without error
const nextEntity = await store.entity.peekNextNumber(googleAuthId);
assert.ok(typeof nextEntity === 'string' && nextEntity.length > 0);
console.log('PASS: store.entity.peekNextNumber succeeded:', nextEntity);
const numList = await store.numbers.list(googleAuthId);
assert.ok(Array.isArray(numList) && numList.length > 0);
const shipmentSeq = numList.find((n) => n.type === 'shipment');
assert.ok(shipmentSeq, 'Expected shipment sequence in numbers.list');
assert.equal(shipmentSeq.prefix, '-L-');
console.log('PASS: store.numbers.list includes shipment with prefix -L-');
console.log('--- ALL TESTS PASSED SUCCESSFULLY! ---');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('TEST FAILED:', err);
process.exit(1);
});

View File

@@ -98,6 +98,7 @@ import orderGetById from './store/order/getById.js';
import orderCreate from './store/order/create.js';
import orderUpdate from './store/order/update.js';
import orderDelete from './store/order/delete.js';
import orderRebuildSummaries from './store/order/rebuildSummaries.js';
import posterminalList from './store/posterminal/list.js';
import posterminalUpsert from './store/posterminal/upsert.js';
import posterminalDelete from './store/posterminal/delete.js';
@@ -185,6 +186,10 @@ import shippingCreate from './store/shipping/create.js';
import shippingUpdate from './store/shipping/update.js';
import shippingListByManifestId from './store/shipping/listByManifestId.js';
import shippingDelete from './store/shipping/delete.js';
import shipmentCreate from './store/shipment/create.js';
import shipmentGetById from './store/shipment/getById.js';
import shipmentListByOrderId from './store/shipment/listByOrderId.js';
import shipmentDelete from './store/shipment/delete.js';
import packageCreate from './store/package/create.js';
import packageListByShippingId from './store/package/listByShippingId.js';
import packageDelete from './store/package/delete.js';
@@ -226,6 +231,18 @@ import articlelistGetItems from './store/articlelist/getItemsByArticleSku.js';
import articlelistSetItems from './store/articlelist/setItemsByArticleSku.js';
import optionslistGetOptions from './store/optionslist/getOptionsByArticleSku.js';
import optionslistSetOptions from './store/optionslist/setOptionsByArticleSku.js';
import mcpRegisterClient from './store/mcp/registerClient.js';
import mcpGetClient from './store/mcp/getClient.js';
import mcpListClients from './store/mcp/listClients.js';
import mcpDeleteClient from './store/mcp/deleteClient.js';
import mcpCreateAuthCode from './store/mcp/createAuthCode.js';
import mcpGetAuthCode from './store/mcp/getAuthCode.js';
import mcpConsumeAuthCode from './store/mcp/consumeAuthCode.js';
import mcpCreateRefreshToken from './store/mcp/createRefreshToken.js';
import mcpGetRefreshToken from './store/mcp/getRefreshToken.js';
import mcpRevokeRefreshToken from './store/mcp/revokeRefreshToken.js';
import mcpListAuthorizations from './store/mcp/listAuthorizations.js';
import mcpRevokeAuthorizations from './store/mcp/revokeAuthorizations.js';
export default class LibDashStore {
/**
@@ -411,6 +428,7 @@ export default class LibDashStore {
create: orderCreate(provider),
update: orderUpdate(provider),
delete: orderDelete(provider),
rebuildSummaries: orderRebuildSummaries(provider),
};
this.posterminal = {
@@ -457,6 +475,13 @@ export default class LibDashStore {
delete: shippingDelete(provider),
};
this.shipment = {
create: shipmentCreate(provider),
getById: shipmentGetById(provider),
listByOrderId: shipmentListByOrderId(provider),
delete: shipmentDelete(provider),
};
this.package = {
create: packageCreate(provider),
listByShippingId: packageListByShippingId(provider),
@@ -608,6 +633,21 @@ export default class LibDashStore {
setOptionsByArticleSku: optionslistSetOptions(provider),
};
this.mcp = {
registerClient: mcpRegisterClient(provider),
getClient: mcpGetClient(provider),
listClients: mcpListClients(provider),
deleteClient: mcpDeleteClient(provider),
createAuthCode: mcpCreateAuthCode(provider),
getAuthCode: mcpGetAuthCode(provider),
consumeAuthCode: mcpConsumeAuthCode(provider),
createRefreshToken: mcpCreateRefreshToken(provider),
getRefreshToken: mcpGetRefreshToken(provider),
revokeRefreshToken: mcpRevokeRefreshToken(provider),
listAuthorizations: mcpListAuthorizations(provider),
revokeAuthorizations: mcpRevokeAuthorizations(provider),
};
this.purge = storePurge(provider);
this.initialize = storeInitialize(provider);

View File

@@ -5,6 +5,7 @@
*/
import sql from 'mssql';
import { logSlowQuery } from '../provider.js';
function normalizeRows(result) {
const recordset = result?.recordset ?? [];
@@ -24,11 +25,16 @@ export class MssqlExecutor {
/** @returns {Promise<import('../provider.js').QueryResult>} */
async query(sql, params = {}) {
const req = this.#requestFactory();
for (const [key, value] of Object.entries(params)) {
req.input(key, value);
const start = performance.now();
try {
const req = this.#requestFactory();
for (const [key, value] of Object.entries(params)) {
req.input(key, value);
}
return normalizeRows(await req.query(sql));
} finally {
logSlowQuery(sql, performance.now() - start, params);
}
return normalizeRows(await req.query(sql));
}
/**
@@ -37,22 +43,27 @@ export class MssqlExecutor {
* (`getnextnumberwithprefixandpadding`).
*/
async exec(procName, { inputs = {}, outputs = [] } = {}) {
const req = this.#requestFactory();
for (const [key, value] of Object.entries(inputs)) {
req.input(key, value);
const start = performance.now();
try {
const req = this.#requestFactory();
for (const [key, value] of Object.entries(inputs)) {
req.input(key, value);
}
for (const out of outputs) {
req.output(out);
}
const result = await req.execute(procName);
return {
rows: result.recordset ?? [],
rowsAffected: Array.isArray(result.rowsAffected)
? result.rowsAffected.reduce((a, b) => a + b, 0)
: 0,
insertId: null,
outputs: result.output ?? {},
};
} finally {
logSlowQuery(`EXEC ${procName}`, performance.now() - start, inputs);
}
for (const out of outputs) {
req.output(out);
}
const result = await req.execute(procName);
return {
rows: result.recordset ?? [],
rowsAffected: Array.isArray(result.rowsAffected)
? result.rowsAffected.reduce((a, b) => a + b, 0)
: 0,
insertId: null,
outputs: result.output ?? {},
};
}
}

View File

@@ -1,5 +1,6 @@
/** Drop order = reverse of schema create dependency order. */
export const TABLES = [
'ordersummary',
'package',
'shipping',
'payment',

View File

@@ -1,17 +1,17 @@
export const INSERT = `
INSERT INTO articlehistory (article_id, companyuser_id, [text])
VALUES (@articleId, @companyuserId, @text)
INSERT INTO articlehistory (article_id, companyuser_id, [date], [text], meta)
VALUES (@articleId, @companyuserId, COALESCE(@date, SYSUTCDATETIME()), @text, @meta)
`;
export const LIST_BY_ARTICLE_ID = `
SELECT id, companyuser_id AS companyuserId, [date], [text]
SELECT id, companyuser_id AS companyuserId, [date], [text], meta
FROM articlehistory
WHERE article_id = @articleId
ORDER BY [date] DESC, id DESC
`;
export const LIST_BY_ARTICLE_ID_LIMITED = `
SELECT TOP (@limit) id, companyuser_id AS companyuserId, [date], [text]
SELECT TOP (@limit) id, companyuser_id AS companyuserId, [date], [text], meta
FROM articlehistory
WHERE article_id = @articleId
ORDER BY [date] DESC, id DESC

View File

@@ -19,7 +19,13 @@ SELECT
p.hash AS fullHash,
p.thumbw AS thumbW,
p.thumbh AS thumbH,
p.thumbhash AS thumbHash
p.thumbhash AS thumbHash,
(
SELECT COUNT(*)
FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
WHERE ca.category_id = t.id AND a.parentarticle_id IS NULL
) AS articleCount
FROM tree t
LEFT JOIN categorytext ct
ON ct.category_id = t.id AND ct.language_id = @languageId
@@ -33,6 +39,19 @@ LEFT JOIN pic p ON p.id = first_pic.pic_id
ORDER BY t.sort, t.id
`;
export const COUNT_ARTICLES_IN_TREE = `
WITH tree AS (
SELECT id FROM category WHERE id = @rootId
UNION ALL
SELECT c.id FROM category c INNER JOIN tree t ON c.parent_id = t.id
)
SELECT COUNT(DISTINCT ca.article_id) AS total
FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
WHERE ca.category_id IN (SELECT id FROM tree)
AND a.parentarticle_id IS NULL
`;
import {
ARTICLE_LIST_COLUMNS,
ARTICLE_LIST_JOINS,

View File

@@ -52,11 +52,13 @@ SELECT
c.name,
c.root_category_id AS rootCategoryId,
c.default_language_id AS languageId,
l.iso639,
tr.rate AS taxrate,
rtr.rate AS reducedtaxrate,
tfr.rate AS taxfreerate
FROM company c
INNER JOIN companyuser cu ON cu.company_id = c.id
LEFT JOIN language l ON l.id = c.default_language_id
LEFT JOIN tax tr ON tr.id = c.taxrate
LEFT JOIN tax rtr ON rtr.id = c.reducedtaxrate
LEFT JOIN tax tfr ON tfr.id = c.taxfreerate

View File

@@ -38,8 +38,8 @@ WHERE sku = @sku
`;
export const INSERT_ARTICLE_HISTORY = `
INSERT INTO articlehistory (article_id, companyuser_id, [text])
VALUES (@articleId, @companyuserId, @text);
INSERT INTO articlehistory (article_id, companyuser_id, [date], [text], meta)
VALUES (@articleId, @companyuserId, COALESCE(@date, SYSUTCDATETIME()), @text, @meta);
`;
export const GET_LATEST_ROW = `

View File

@@ -57,13 +57,13 @@ WHERE id = @id
`;
export const SHIPPING_CREATE = `
INSERT INTO shipping (manifest_id, [number], [type], [state])
OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.[type], INSERTED.[state]
VALUES (@manifestId, @number, @type, @state)
INSERT INTO shipping (manifest_id, [number], tracking_code, carrier, [type], [state])
OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier, INSERTED.[type], INSERTED.[state]
VALUES (@manifestId, @number, @trackingCode, @carrier, @type, @state)
`;
export const SHIPPING_LIST_BY_MANIFEST = `
SELECT id, manifest_id, [number], [type], [state]
SELECT id, manifest_id, [number], tracking_code, carrier, [type], [state]
FROM shipping
WHERE manifest_id = @manifestId
ORDER BY id ASC
@@ -76,7 +76,7 @@ WHERE id = @id
`;
export const GET_SHIPPING_BY_ID = `
SELECT s.id, s.manifest_id AS manifestId, s.[number], s.[type], s.[state]
SELECT s.id, s.manifest_id AS manifestId, s.[number], s.tracking_code, s.carrier, s.[type], s.[state]
FROM shipping s
INNER JOIN manifest m ON m.id = s.manifest_id
WHERE s.id = @id AND m.company_id = @companyId;
@@ -84,8 +84,8 @@ WHERE s.id = @id AND m.company_id = @companyId;
export const SHIPPING_UPDATE = `
UPDATE shipping
SET [type] = @type, [state] = @state
OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.[type], INSERTED.[state]
SET [type] = @type, [state] = @state, tracking_code = @trackingCode, carrier = @carrier
OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier, INSERTED.[type], INSERTED.[state]
WHERE id = @id;
`;

View File

@@ -0,0 +1,113 @@
export const INSERT_CLIENT = `
INSERT INTO mcp_client (client_id, client_secret, client_name, redirect_uris, grant_types, response_types, scope)
OUTPUT INSERTED.id, INSERTED.client_id, INSERTED.client_secret, INSERTED.client_name, INSERTED.redirect_uris, INSERTED.grant_types, INSERTED.response_types, INSERTED.scope, INSERTED.created_at
VALUES (@clientId, @clientSecret, @clientName, @redirectUris, @grantTypes, @responseTypes, @scope)
`;
export const GET_CLIENT = `
SELECT
id,
client_id AS clientId,
client_secret AS clientSecret,
client_name AS clientName,
redirect_uris AS redirectUris,
grant_types AS grantTypes,
response_types AS responseTypes,
scope,
created_at AS createdAt
FROM mcp_client
WHERE client_id = @clientId
`;
export const LIST_CLIENTS = `
SELECT
id,
client_id AS clientId,
client_name AS clientName,
redirect_uris AS redirectUris,
grant_types AS grantTypes,
response_types AS responseTypes,
scope,
created_at AS createdAt
FROM mcp_client
ORDER BY created_at DESC
`;
export const DELETE_CLIENT = `
DELETE FROM mcp_client
WHERE client_id = @clientId
`;
export const INSERT_AUTH_CODE = `
INSERT INTO mcp_auth_code (code, client_id, google_auth_id, redirect_uri, code_challenge, code_challenge_method, scope, expires_at)
VALUES (@code, @clientId, @googleAuthId, @redirectUri, @codeChallenge, @codeChallengeMethod, @scope, @expiresAt)
`;
export const GET_AUTH_CODE = `
SELECT
code,
client_id AS clientId,
google_auth_id AS googleAuthId,
redirect_uri AS redirectUri,
code_challenge AS codeChallenge,
code_challenge_method AS codeChallengeMethod,
scope,
expires_at AS expiresAt,
used
FROM mcp_auth_code
WHERE code = @code
`;
export const CONSUME_AUTH_CODE = `
UPDATE mcp_auth_code
SET used = 1
WHERE code = @code
`;
export const INSERT_REFRESH_TOKEN = `
INSERT INTO mcp_token (refresh_token, client_id, google_auth_id, scope, expires_at)
OUTPUT INSERTED.id
VALUES (@refreshToken, @clientId, @googleAuthId, @scope, @expiresAt)
`;
export const GET_REFRESH_TOKEN = `
SELECT
id,
refresh_token AS refreshToken,
client_id AS clientId,
google_auth_id AS googleAuthId,
scope,
expires_at AS expiresAt,
revoked
FROM mcp_token
WHERE refresh_token = @refreshToken
`;
export const REVOKE_REFRESH_TOKEN = `
UPDATE mcp_token
SET revoked = 1
WHERE refresh_token = @refreshToken
`;
export const LIST_AUTHORIZATIONS_BY_USER = `
SELECT
c.id AS clientDbId,
c.client_id AS clientId,
c.client_name AS clientName,
c.scope,
c.created_at AS clientCreatedAt,
MAX(t.created_at) AS authorizedAt,
COUNT(t.id) AS tokenCount
FROM mcp_token t
JOIN mcp_client c ON t.client_id = c.client_id
WHERE t.google_auth_id = @googleAuthId AND t.revoked = 0
GROUP BY c.id, c.client_id, c.client_name, c.scope, c.created_at
ORDER BY MAX(t.created_at) DESC
`;
export const REVOKE_USER_AUTHORIZATIONS = `
UPDATE mcp_token
SET revoked = 1
WHERE google_auth_id = @googleAuthId AND client_id = @clientId
`;

View File

@@ -5,29 +5,46 @@ export const MODELCONVERSATION_COLUMNS = `
model,
created_at,
updated_at,
state
state,
started_by
`;
/** Raw stored payload of the conversation's first `user` message; `c` is the conversation alias. */
const FIRST_USER_MESSAGE_COLUMN = `
(
SELECT m.message
FROM modelmessage m
WHERE m.id = (
SELECT MIN(f.id)
FROM modelmessage f
WHERE f.conversation_id = c.id AND f.role = 'user'
)
) AS first_user_message
`;
export const LIST_BY_COMPANYUSER = `
SELECT
${MODELCONVERSATION_COLUMNS}
FROM modelconversation
WHERE companyuser_id = @companyuserId
ORDER BY updated_at DESC, id DESC
${MODELCONVERSATION_COLUMNS},
${FIRST_USER_MESSAGE_COLUMN}
FROM modelconversation c
WHERE c.companyuser_id = @companyuserId
ORDER BY c.updated_at DESC, c.id DESC
`;
export const GET_BY_COMPANYUSER_AND_ID = `
SELECT
${MODELCONVERSATION_COLUMNS}
FROM modelconversation
WHERE id = @id AND companyuser_id = @companyuserId
${MODELCONVERSATION_COLUMNS},
${FIRST_USER_MESSAGE_COLUMN}
FROM modelconversation c
WHERE c.id = @id AND c.companyuser_id = @companyuserId
`;
export const CREATE = `
INSERT INTO modelconversation (
companyuser_id,
provider,
model
model,
started_by
)
OUTPUT
INSERTED.id,
@@ -36,11 +53,13 @@ OUTPUT
INSERTED.model,
INSERTED.created_at,
INSERTED.updated_at,
INSERTED.state
INSERTED.state,
INSERTED.started_by
VALUES (
@companyuserId,
@provider,
@model
@model,
@startedBy
)
`;
@@ -69,11 +88,30 @@ OUTPUT
INSERTED.model,
INSERTED.created_at,
INSERTED.updated_at,
INSERTED.state
INSERTED.state,
INSERTED.started_by
WHERE id = @id AND companyuser_id = @companyuserId
`;
/**
* Deletes a conversation and, because started_by is not a foreign key, the whole chain of
* subagent conversations below it (started_by -> id, recursively) in a single statement.
* Messages go with their conversations via ON DELETE CASCADE. rowsAffected is 0 only when
* the root conversation does not exist for this user (a root always yields >= 1 row).
*/
export const DELETE_BY_COMPANYUSER_AND_ID = `
WITH subtree AS (
SELECT id
FROM modelconversation
WHERE id = @id AND companyuser_id = @companyuserId
UNION ALL
SELECT c.id
FROM modelconversation c
INNER JOIN subtree s ON c.started_by = s.id
WHERE c.companyuser_id = @companyuserId
)
DELETE FROM modelconversation
WHERE id = @id AND companyuser_id = @companyuserId
WHERE id IN (SELECT id FROM subtree)
`;

View File

@@ -5,8 +5,8 @@ IF NOT EXISTS (
WHERE company_id = @companyId AND [type] = @type
)
BEGIN
INSERT INTO numbers (company_id, [type])
VALUES (@companyId, @type);
INSERT INTO numbers (company_id, [type], prefix)
VALUES (@companyId, @type, ISNULL(@prefix, ''));
END
`;

View File

@@ -0,0 +1,57 @@
export const SHIPMENT_TYPE = 'shipment';
export const GET_ORDER_HEADER = `
SELECT id, company_id AS companyId, [number], [type], entity_id AS entityId, external_number AS externalNumber, [date]
FROM manifest
WHERE id = @orderId AND company_id = @companyId AND [type] = 'order';
`;
export const GET_ORDER_LINES = `
SELECT id, sku, name, pu, amount, net, rebate, taxrate, comment
FROM manifestarticle
WHERE manifest_id = @orderId
ORDER BY id ASC;
`;
export const GET_SHIPMENT_PREFIX = `
SELECT prefix
FROM numbers
WHERE company_id = @companyId AND [type] = 'shipment';
`;
export const LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN = `
SELECT m.id, m.[number], m.[type], m.entity_id AS entityId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt
FROM manifest m
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND m.[number] LIKE @pattern
ORDER BY m.id ASC;
`;
export const GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN = `
SELECT ma.sku, SUM(ma.amount) AS shippedAmount
FROM manifest m
INNER JOIN manifestarticle ma ON ma.manifest_id = m.id
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND m.[number] LIKE @pattern
AND (@excludeShipmentId IS NULL OR m.id != @excludeShipmentId)
GROUP BY ma.sku;
`;
export const GET_SHIPMENT_HEADER = `
SELECT m.id, m.[number], m.[type], m.entity_id AS entityId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt
FROM manifest m
WHERE m.id = @id AND m.company_id = @companyId AND m.[type] = 'shipment';
`;
export const CHECK_SHIPMENT_PACKAGES_SHIPPED = `
SELECT TOP 1 id
FROM shipping
WHERE manifest_id = @id AND LOWER([state]) = 'shipped';
`;
export const DELETE_SHIPMENT = `
DELETE FROM manifest
WHERE id = @id AND company_id = @companyId AND [type] = 'shipment';
`;

View File

@@ -372,6 +372,7 @@ BEGIN
companyuser_id INT NULL,
[date] DATETIME2 NOT NULL CONSTRAINT DF_articlehistory_date DEFAULT SYSUTCDATETIME(),
[text] NVARCHAR(MAX) NOT NULL,
meta NVARCHAR(MAX) NULL,
CONSTRAINT FK_articlehistory_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
CONSTRAINT FK_articlehistory_companyuser FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE SET NULL
);
@@ -818,10 +819,12 @@ BEGIN
created_at DATETIME2 NOT NULL CONSTRAINT DF_modelconversation_created_at DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2 NOT NULL CONSTRAINT DF_modelconversation_updated_at DEFAULT SYSUTCDATETIME(),
state NVARCHAR(20) NOT NULL CONSTRAINT DF_modelconversation_state DEFAULT 'idle',
started_by INT NULL, -- id of the conversation that started this one as a subagent; deliberately NOT a foreign key
CONSTRAINT FK_modelconversation_companyuser FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE CASCADE,
CONSTRAINT CK_modelconversation_state CHECK (state IN ('idle', 'busy'))
);
CREATE INDEX IX_modelconversation_companyuser ON modelconversation (companyuser_id, updated_at);
CREATE INDEX IX_modelconversation_started_by ON modelconversation (started_by);
END
`;
@@ -1148,6 +1151,8 @@ BEGIN
id INT IDENTITY(1,1) PRIMARY KEY,
manifest_id INT NOT NULL,
[number] NVARCHAR(100) NOT NULL,
tracking_code NVARCHAR(255) NULL,
carrier NVARCHAR(100) NULL,
[type] NVARCHAR(100) NOT NULL,
[state] NVARCHAR(100) NOT NULL,
CONSTRAINT FK_shipping_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE
@@ -1172,6 +1177,99 @@ BEGIN
END
`;
export const INIT_ORDERSUMMARY = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ordersummary')
BEGIN
CREATE TABLE ordersummary (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
manifest_id INT NOT NULL CONSTRAINT UQ_ordersummary_manifest UNIQUE,
order_number NVARCHAR(100) NOT NULL,
order_date DATETIME2 NOT NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_ordersummary_created_at DEFAULT SYSUTCDATETIME(),
entity_id INT NULL,
customer_name NVARCHAR(255) NULL,
positions INT NOT NULL CONSTRAINT DF_ordersummary_positions DEFAULT 0,
gross_total FLOAT NOT NULL CONSTRAINT DF_ordersummary_gross_total DEFAULT 0,
paid_total FLOAT NOT NULL CONSTRAINT DF_ordersummary_paid_total DEFAULT 0,
ordered_quantity FLOAT NOT NULL CONSTRAINT DF_ordersummary_ordered_quantity DEFAULT 0,
shipped_quantity FLOAT NOT NULL CONSTRAINT DF_ordersummary_shipped_quantity DEFAULT 0,
is_paid TINYINT NOT NULL CONSTRAINT DF_ordersummary_is_paid DEFAULT 0,
is_shipped TINYINT NOT NULL CONSTRAINT DF_ordersummary_is_shipped DEFAULT 0,
CONSTRAINT FK_ordersummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
CONSTRAINT FK_ordersummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
CONSTRAINT FK_ordersummary_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
);
CREATE INDEX IX_ordersummary_company_date
ON ordersummary (company_id, order_date DESC, manifest_id DESC);
CREATE INDEX IX_ordersummary_company_shipped
ON ordersummary (company_id, is_shipped);
CREATE INDEX IX_ordersummary_company_paid
ON ordersummary (company_id, is_paid);
CREATE INDEX IX_ordersummary_company_number
ON ordersummary (company_id, order_number);
END
`;
export const INIT_MCP_CLIENT = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
BEGIN
CREATE TABLE mcp_client (
id INT IDENTITY(1,1) PRIMARY KEY,
client_id NVARCHAR(100) NOT NULL,
client_secret NVARCHAR(255) NULL,
client_name NVARCHAR(255) NOT NULL,
redirect_uris NVARCHAR(MAX) NOT NULL,
grant_types NVARCHAR(255) NULL,
response_types NVARCHAR(255) NULL,
scope NVARCHAR(255) NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_mcp_client_created_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_mcp_client_client_id UNIQUE (client_id)
);
END
`;
export const INIT_MCP_AUTH_CODE = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_auth_code')
BEGIN
CREATE TABLE mcp_auth_code (
code NVARCHAR(128) PRIMARY KEY,
client_id NVARCHAR(100) NOT NULL,
google_auth_id INT NOT NULL,
redirect_uri NVARCHAR(2048) NOT NULL,
code_challenge NVARCHAR(255) NOT NULL,
code_challenge_method NVARCHAR(32) NOT NULL,
scope NVARCHAR(255) NULL,
expires_at DATETIME2 NOT NULL,
used TINYINT NOT NULL CONSTRAINT DF_mcp_auth_code_used DEFAULT 0,
created_at DATETIME2 NOT NULL CONSTRAINT DF_mcp_auth_code_created_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_mcp_auth_code_google_auth FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE
);
END
`;
export const INIT_MCP_TOKEN = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_token')
BEGIN
CREATE TABLE mcp_token (
id INT IDENTITY(1,1) PRIMARY KEY,
refresh_token NVARCHAR(128) NOT NULL,
client_id NVARCHAR(100) NOT NULL,
google_auth_id INT NOT NULL,
scope NVARCHAR(255) NULL,
expires_at DATETIME2 NOT NULL,
revoked TINYINT NOT NULL CONSTRAINT DF_mcp_token_revoked DEFAULT 0,
created_at DATETIME2 NOT NULL CONSTRAINT DF_mcp_token_created_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_mcp_token_refresh UNIQUE (refresh_token),
CONSTRAINT FK_mcp_token_google_auth FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE
);
END
`;
/** DDL scripts in dependency order for schema init. */
export const SCHEMA_SCRIPTS = [
INIT_META,
@@ -1239,4 +1337,8 @@ export const SCHEMA_SCRIPTS = [
INIT_PAYMENT,
INIT_SHIPPING,
INIT_PACKAGE,
INIT_ORDERSUMMARY,
INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN,
];

View File

@@ -46,3 +46,22 @@ export function isForeignKeyConstraintError(err) {
err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY'
);
}
export const SLOW_QUERY_THRESHOLD_MS = 20;
/**
* Logs a SQL statement to the console if execution time exceeded SLOW_QUERY_THRESHOLD_MS (20ms).
*
* @param {string} sql
* @param {number} durationMs
* @param {object} [params]
*/
export function logSlowQuery(sql, durationMs, params = null) {
if (durationMs > SLOW_QUERY_THRESHOLD_MS) {
const timeStr = `${durationMs.toFixed(1)}ms`;
console.log(`[SQL >20ms] (${timeStr})\n${typeof sql === 'string' ? sql.trim() : sql}`);
if (params && Object.keys(params).length > 0) {
console.log('Params:', params);
}
}
}

View File

@@ -6,6 +6,7 @@
*/
import Database from 'better-sqlite3';
import { logSlowQuery } from '../provider.js';
import { SCHEMA_SCRIPTS } from './schema.js';
function normalizeParamValue(value) {
@@ -19,13 +20,23 @@ function normalizeParamValue(value) {
return value;
}
function bindParams(params) {
return Object.fromEntries(
function bindParams(params, sql = null) {
const bound = Object.fromEntries(
Object.entries(params).map(([key, value]) => [
key.replace(/^[@:$]/, ''),
normalizeParamValue(value),
]),
);
if (typeof sql === 'string') {
const matches = sql.matchAll(/[@:$]([a-zA-Z0-9_]+)/g);
for (const match of matches) {
const name = match[1];
if (!(name in bound)) {
bound[name] = null;
}
}
}
return bound;
}
function normalizeInsertId(value) {
@@ -226,24 +237,29 @@ export class BetterSqlite3Executor {
/** @returns {Promise<import('../provider.js').QueryResult>} */
async query(sql, params = {}) {
const statement = this.db.prepare(sql);
const bindings = bindParams(params);
const start = performance.now();
try {
const statement = this.db.prepare(sql);
const bindings = bindParams(params, sql);
if (statement.reader) {
const rows = statement.all(bindings);
if (statement.reader) {
const rows = statement.all(bindings);
return {
rows,
rowsAffected: rows.length,
insertId: normalizeInsertId(rows[0]?.id),
};
}
const result = statement.run(bindings);
return {
rows,
rowsAffected: rows.length,
insertId: normalizeInsertId(rows[0]?.id),
rows: [],
rowsAffected: result.changes,
insertId: normalizeInsertId(result.lastInsertRowid),
};
} finally {
logSlowQuery(sql, performance.now() - start, params);
}
const result = statement.run(bindings);
return {
rows: [],
rowsAffected: result.changes,
insertId: normalizeInsertId(result.lastInsertRowid),
};
}
}

View File

@@ -5,7 +5,7 @@ const queries = translateQueries(source, 'articlehistory');
export const { INSERT, LIST_BY_ARTICLE_ID } = queries;
export const LIST_BY_ARTICLE_ID_LIMITED = `
SELECT id, companyuser_id AS companyuserId, date, text
SELECT id, companyuser_id AS companyuserId, date, text, meta
FROM articlehistory
WHERE article_id = @articleId
ORDER BY date DESC, id DESC

View File

@@ -79,7 +79,13 @@ WITH RECURSIVE tree AS (
SELECT
t.id, t.parent_id AS parentId, t.sort, ct.name, ct.slug,
p.hash AS fullHash, p.thumbw AS thumbW, p.thumbh AS thumbH,
p.thumbhash AS thumbHash
p.thumbhash AS thumbHash,
(
SELECT COUNT(*)
FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
WHERE ca.category_id = t.id AND a.parentarticle_id IS NULL
) AS articleCount
FROM tree t
LEFT JOIN categorytext ct
ON ct.category_id = t.id AND ct.language_id = @languageId
@@ -89,4 +95,17 @@ LEFT JOIN pic p ON p.id = (
ORDER BY cp.sort, cp.id LIMIT 1
)
ORDER BY t.sort, t.id
`;
export const COUNT_ARTICLES_IN_TREE = `
WITH RECURSIVE tree AS (
SELECT id FROM category WHERE id = @rootId
UNION ALL
SELECT c.id FROM category c INNER JOIN tree t ON c.parent_id = t.id
)
SELECT COUNT(DISTINCT ca.article_id) AS total
FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
WHERE ca.category_id IN (SELECT id FROM tree)
AND a.parentarticle_id IS NULL
`;

View File

@@ -38,8 +38,8 @@ WHERE sku = @sku
`;
export const INSERT_ARTICLE_HISTORY = `
INSERT INTO articlehistory (article_id, companyuser_id, text)
VALUES (@articleId, @companyuserId, @text);
INSERT INTO articlehistory (article_id, companyuser_id, date, text, meta)
VALUES (@articleId, @companyuserId, COALESCE(@date, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), @text, @meta);
`;
export const GET_LATEST_ROW = `

View File

@@ -0,0 +1,18 @@
import * as source from '../../mssql/queries/mcp.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'mcp');
export const {
INSERT_CLIENT,
GET_CLIENT,
LIST_CLIENTS,
DELETE_CLIENT,
INSERT_AUTH_CODE,
GET_AUTH_CODE,
CONSUME_AUTH_CODE,
INSERT_REFRESH_TOKEN,
GET_REFRESH_TOKEN,
REVOKE_REFRESH_TOKEN,
LIST_AUTHORIZATIONS_BY_USER,
REVOKE_USER_AUTHORIZATIONS,
} = queries;

View File

@@ -5,8 +5,8 @@ const queries = translateQueries(source, 'numbers');
export const { GET_BY_COMPANY_AND_ID, LIST_BY_COMPANY, UPDATE_BY_COMPANY_AND_ID } = queries;
export const ENSURE_TYPE = `
INSERT INTO numbers (company_id, "type")
VALUES (@companyId, @type)
INSERT INTO numbers (company_id, "type", prefix)
VALUES (@companyId, @type, COALESCE(@prefix, ''))
ON CONFLICT (company_id, "type") DO NOTHING
`;

View File

@@ -0,0 +1,15 @@
import * as source from '../../mssql/queries/shipment.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'shipment');
export const {
SHIPMENT_TYPE,
GET_ORDER_HEADER,
GET_ORDER_LINES,
GET_SHIPMENT_PREFIX,
LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN,
GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN,
GET_SHIPMENT_HEADER,
CHECK_SHIPMENT_PACKAGES_SHIPPED,
DELETE_SHIPMENT,
} = queries;

View File

@@ -511,9 +511,11 @@ CREATE TABLE IF NOT EXISTS modelconversation (
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
state TEXT NOT NULL DEFAULT 'idle' CHECK (state IN ('idle', 'busy')),
started_by INTEGER, -- id of the conversation that started this one as a subagent; deliberately NOT a foreign key
FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_modelconversation_companyuser ON modelconversation (companyuser_id, updated_at);
CREATE INDEX IF NOT EXISTS IX_modelconversation_started_by ON modelconversation (started_by);
`;
@@ -551,6 +553,7 @@ CREATE TABLE IF NOT EXISTS articlehistory (
companyuser_id INTEGER,
"date" TEXT NOT NULL DEFAULT (${UTC_NOW}),
"text" TEXT NOT NULL,
meta TEXT,
FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE SET NULL
);
@@ -820,6 +823,8 @@ CREATE TABLE IF NOT EXISTS shipping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
manifest_id INTEGER NOT NULL,
"number" TEXT NOT NULL,
tracking_code TEXT NULL,
carrier TEXT NULL,
"type" TEXT NOT NULL,
"state" TEXT NOT NULL,
FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE
@@ -840,6 +845,81 @@ CREATE TABLE IF NOT EXISTS package (
);
`;
export const INIT_ORDERSUMMARY = `
CREATE TABLE IF NOT EXISTS ordersummary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
manifest_id INTEGER NOT NULL UNIQUE,
order_number TEXT NOT NULL,
order_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
entity_id INTEGER NULL,
customer_name TEXT NULL,
positions INTEGER NOT NULL DEFAULT 0,
gross_total REAL NOT NULL DEFAULT 0,
paid_total REAL NOT NULL DEFAULT 0,
ordered_quantity REAL NOT NULL DEFAULT 0,
shipped_quantity REAL NOT NULL DEFAULT 0,
is_paid INTEGER NOT NULL DEFAULT 0,
is_shipped INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS IX_ordersummary_company_date
ON ordersummary (company_id, order_date DESC, manifest_id DESC);
CREATE INDEX IF NOT EXISTS IX_ordersummary_company_shipped
ON ordersummary (company_id, is_shipped);
CREATE INDEX IF NOT EXISTS IX_ordersummary_company_paid
ON ordersummary (company_id, is_paid);
CREATE INDEX IF NOT EXISTS IX_ordersummary_company_number
ON ordersummary (company_id, order_number);
`;
export const INIT_MCP_CLIENT = `
CREATE TABLE IF NOT EXISTS mcp_client (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT,
client_name TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
grant_types TEXT,
response_types TEXT,
scope TEXT,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW})
);
`;
export const INIT_MCP_AUTH_CODE = `
CREATE TABLE IF NOT EXISTS mcp_auth_code (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
google_auth_id INTEGER NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL,
scope TEXT,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE
);
`;
export const INIT_MCP_TOKEN = `
CREATE TABLE IF NOT EXISTS mcp_token (
id INTEGER PRIMARY KEY AUTOINCREMENT,
refresh_token TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL,
google_auth_id INTEGER NOT NULL,
scope TEXT,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE
);
`;
/** DDL scripts in dependency order for SQLite schema initialization. */
export const SCHEMA_SCRIPTS = [
INIT_META,
@@ -904,4 +984,8 @@ export const SCHEMA_SCRIPTS = [
INIT_PAYMENT,
INIT_SHIPPING,
INIT_PACKAGE,
INIT_ORDERSUMMARY,
INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN,
];

View File

@@ -10,12 +10,14 @@ import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import getBySku from './getBySku.js';
import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js';
import {
recordVariantAddedOnParentHistory,
recordChildAssignedToParentHistory,
} from '../articlehistory/record.js';
import attachChild from './attachChild.js';
export default function addChild(provider) {
@@ -31,7 +33,6 @@ export default function addChild(provider) {
const articleAttrValQueries = isSqlite ? sqliteArticleAttrVal : mssqlArticleAttrVal;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Adds a child article to a parent article (creates and attaches with auto-generated SKU).
@@ -242,17 +243,21 @@ export default function addChild(provider) {
});
// History on child
await runner.query(insertHistorySql, {
articleId: childId,
await recordChildAssignedToParentHistory(runner, {
childId,
companyuserId,
text: `Artikel "${childName}" (${childSku}) als Variante von "${parent.sku}" angelegt`,
childName,
childSku,
parentSku: parent.sku,
created: true,
});
// History on parent
await runner.query(insertHistorySql, {
articleId: parentId,
await recordVariantAddedOnParentHistory(runner, {
parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) hinzugefügt`,
childName,
childSku,
});
return load(googleAuthId, childSku, runner);

View File

@@ -6,11 +6,13 @@ import * as mssqlArticleAttrVal from '../../providers/mssql/queries/articleattri
import * as sqliteArticleAttrVal from '../../providers/sqlite3/queries/articleattributevalue.js';
import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import getBySku from './getBySku.js';
import listChildren from './listChildren.js';
import {
recordChildAssignedToParentHistory,
recordVariantAddedOnParentHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function attachChild(provider) {
@@ -23,7 +25,6 @@ export default function attachChild(provider) {
const attrValQueries = isSqlite ? sqliteAttrVal : mssqlAttrVal;
const articleAttrValQueries = isSqlite ? sqliteArticleAttrVal : mssqlArticleAttrVal;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Attaches an existing article as a child to a parent article.
@@ -288,16 +289,19 @@ export default function attachChild(provider) {
// History only if not already child
if (!isAlreadyChildOfThisParent) {
await runner.query(insertHistorySql, {
articleId: child.id,
await recordChildAssignedToParentHistory(runner, {
childId: child.id,
companyuserId,
text: `Artikel "${childName}" (${childSku}) als Variante von "${parent.sku}" zugewiesen`,
childName,
childSku,
parentSku: parent.sku,
});
await runner.query(insertHistorySql, {
articleId: parentId,
await recordVariantAddedOnParentHistory(runner, {
parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) hinzugefügt`,
childName,
childSku,
});
}

View File

@@ -4,14 +4,13 @@ import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import ensureDefaultUnit from '../unit/ensureDefault.js';
import nextSku from '../numbers/nextSku.js';
import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js';
import { recordArticleCreatedHistory } from '../articlehistory/record.js';
export default function createUnderParent(provider) {
const access = resolveCategoryAccess(provider);
@@ -23,7 +22,6 @@ export default function createUnderParent(provider) {
const createArticleSql = isSqlite ? SQLITE_CREATE_ARTICLE : MSSQL_CREATE_ARTICLE;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Creates a new article under a given parent category, allocating a SKU and
@@ -95,10 +93,10 @@ export default function createUnderParent(provider) {
seo: finalSeo,
});
await runner.query(insertHistorySql, {
await recordArticleCreatedHistory(runner, {
articleId: id,
companyuserId,
text: `Artikel "${name}" angelegt`,
name,
});
return {

View File

@@ -17,11 +17,11 @@ export default function getHistoryBySku(provider) {
* @param {object} [opts] - Optional options.
* @param {number} [opts.limit] - Maximum number of entries to return (newest first).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>} The article history entries.
* @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>>} The article history entries.
*
* @example
* const history = await store.article.getHistoryBySku(42, 'SKU-1');
* // => [{ id, companyuserId, date, text }]
* // => [{ id, companyuserId, date, text, meta }]
*
* const recent = await store.article.getHistoryBySku(42, 'SKU-1', { limit: 5 });
* // => up to 5 most recent entries
@@ -51,6 +51,7 @@ export default function getHistoryBySku(provider) {
companyuserId: row.companyuserId ?? null,
date: row.date,
text: row.text,
meta: row.meta ?? null,
}));
};
}

View File

@@ -4,10 +4,14 @@ import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js';
import {
recordParentConvertedHistory,
recordVariantAddedOnParentHistory,
recordChildAssignedToParentHistory,
recordArticleImportedHistory,
} from '../articlehistory/record.js';
import {
CREATE as MSSQL_CREATE_BRAND,
GET_BY_COMPANY_AND_NAME as MSSQL_GET_BRAND_BY_NAME,
@@ -55,7 +59,6 @@ export default function importRows(provider) {
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT;
const getBrandByNameSql = isSqlite ? SQLITE_GET_BRAND_BY_NAME : MSSQL_GET_BRAND_BY_NAME;
const createBrandSql = isSqlite ? SQLITE_CREATE_BRAND : MSSQL_CREATE_BRAND;
@@ -210,10 +213,10 @@ export default function importRows(provider) {
seo: null,
});
await runner.query(insertHistorySql, {
await recordArticleImportedHistory(runner, {
articleId,
companyuserId,
text: `Artikel "${name}" importiert`,
name,
});
// Brand (upsert by name).
@@ -318,22 +321,24 @@ export default function importRows(provider) {
});
if (!wasParent) {
await runner.query(insertHistorySql, {
await recordParentConvertedHistory(runner, {
articleId: parentId,
companyuserId,
text: 'Artikel zu Vaterartikel konvertiert',
});
}
const childName = String(row?.name ?? sku).trim();
await runner.query(insertHistorySql, {
articleId: parentId,
await recordVariantAddedOnParentHistory(runner, {
parentId,
companyuserId,
text: `Variante "${childName}" (${sku}) hinzugefügt`,
childName,
childSku: sku,
});
await runner.query(insertHistorySql, {
articleId: childId,
await recordChildAssignedToParentHistory(runner, {
childId,
companyuserId,
text: `Artikel "${childName}" (${sku}) als Variante von "${parentSku}" zugewiesen`,
childName,
childSku: sku,
parentSku,
});
}
}

View File

@@ -1,9 +1,11 @@
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import articleDelete from './delete.js';
import {
recordChildUnlinkedFromParentHistory,
recordChildDeletedFromParentHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function removeChild(provider) {
@@ -12,7 +14,6 @@ export default function removeChild(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteArticle : mssqlArticle;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Removes a child article from a parent article, either by unlinking or deleting it.
@@ -78,10 +79,11 @@ export default function removeChild(provider) {
await recompactParentSort();
// History on parent (child article is gone so we can only log on parent)
await runner.query(insertHistorySql, {
articleId: parentId,
await recordChildDeletedFromParentHistory(runner, {
parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) gelöscht`,
childName,
childSku,
});
return {
@@ -119,18 +121,14 @@ export default function removeChild(provider) {
await recompactParentSort();
// Add history on child
await runner.query(insertHistorySql, {
articleId: childId,
// Add history on child and parent
await recordChildUnlinkedFromParentHistory(runner, {
childId,
parentId,
companyuserId,
text: `Artikel "${childSku}" von Vaterartikel "${parentSku}" getrennt`,
});
// Add history on parent
await runner.query(insertHistorySql, {
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) getrennt`,
childSku,
childName,
parentSku,
});
return {

View File

@@ -6,10 +6,9 @@ import {
GET_BRAND_BY_COMPANY_AND_ID as SQLITE_GET_BRAND,
UPDATE_BRAND as SQLITE_UPDATE_BRAND,
} from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordBrandChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setBrandIdBySku(provider) {
@@ -19,7 +18,6 @@ export default function setBrandIdBySku(provider) {
const getBrandSql = isSqlite ? SQLITE_GET_BRAND : MSSQL_GET_BRAND;
const updateBrandSql = isSqlite ? SQLITE_UPDATE_BRAND : MSSQL_UPDATE_BRAND;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Sets (or clears) the brand of an article by its SKU.
@@ -82,13 +80,12 @@ export default function setBrandIdBySku(provider) {
}
// null = no brand; treat as distinct sentinel so first assignment is logged
if (oldBrandName !== newBrandName) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Marke ge\u00e4ndert von "${oldBrandName ?? ''}" auf "${newBrandName ?? ''}"`,
});
}
await recordBrandChangeHistory(runner, {
articleId,
companyuserId,
oldBrandName,
newBrandName,
});
const article = await load(articleId, languageId, runner);
if (!article) {

View File

@@ -6,11 +6,10 @@ import {
ADD_CATEGORY as SQLITE_ADD_CATEGORY,
DELETE_CATEGORIES_BY_ARTICLE as SQLITE_DELETE_CATEGORIES,
} from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordCategoryChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setCategoryIdsBySku(provider) {
@@ -21,7 +20,6 @@ export default function setCategoryIdsBySku(provider) {
const addCategorySql = isSqlite ? SQLITE_ADD_CATEGORY : MSSQL_ADD_CATEGORY;
const deleteCategoriesSql = isSqlite ? SQLITE_DELETE_CATEGORIES : MSSQL_DELETE_CATEGORIES;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Replaces the category membership of an article by its SKU.
@@ -86,15 +84,12 @@ export default function setCategoryIdsBySku(provider) {
}
// Write history if categories changed
const oldStr = oldCategoryIds.join(', ');
const newStr = [...normalized].sort((a, b) => a - b).join(', ');
if (oldStr !== newStr) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Kategorien geändert von [${oldStr}] auf [${newStr}]`,
});
}
await recordCategoryChangeHistory(runner, {
articleId,
companyuserId,
oldCategoryIds,
newCategoryIds: normalized,
});
const article = await load(articleId, languageId, runner);
if (!article) {

View File

@@ -8,11 +8,10 @@ import {
DELETE_EANS_BY_ARTICLE as SQLITE_DELETE_EANS,
EAN_EXISTS_FOR_OTHER_ARTICLE as SQLITE_CHECK_CONFLICT,
} from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordEanChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setEansBySku(provider) {
@@ -22,7 +21,6 @@ export default function setEansBySku(provider) {
const addEanSql = isSqlite ? SQLITE_ADD_EAN : MSSQL_ADD_EAN;
const deleteEansSql = isSqlite ? SQLITE_DELETE_EANS : MSSQL_DELETE_EANS;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
const checkConflictSql = isSqlite ? SQLITE_CHECK_CONFLICT : MSSQL_CHECK_CONFLICT;
/**
@@ -93,15 +91,12 @@ export default function setEansBySku(provider) {
}
// Write history if EANs changed
const oldStr = oldEans.join(', ');
const newStr = [...normalized].sort().join(', ');
if (oldStr !== newStr) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `EANs geändert von "${oldStr}" auf "${newStr}"`,
});
}
await recordEanChangeHistory(runner, {
articleId,
companyuserId,
oldEans,
newEans: normalized,
});
const article = await load(articleId, languageId, runner);
if (!article) {

View File

@@ -2,10 +2,13 @@ import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import {
recordParentConvertedHistory,
recordVariantAddedOnParentHistory,
recordChildAssignedToParentHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setParentBySku(provider) {
@@ -15,7 +18,6 @@ export default function setParentBySku(provider) {
const queries = isSqlite ? sqliteArticle : mssqlArticle;
const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Links an article to a parent article (V/K compound) by their SKUs.
@@ -118,23 +120,25 @@ export default function setParentBySku(provider) {
});
if (!wasParent) {
await runner.query(insertHistorySql, {
await recordParentConvertedHistory(runner, {
articleId: parentId,
companyuserId,
text: 'Artikel zu Vaterartikel konvertiert',
});
}
await runner.query(insertHistorySql, {
articleId: parentId,
await recordVariantAddedOnParentHistory(runner, {
parentId,
companyuserId,
text: `Variante "${childName}" (${sku}) hinzugefügt`,
childName,
childSku: sku,
});
await runner.query(insertHistorySql, {
articleId,
await recordChildAssignedToParentHistory(runner, {
childId: articleId,
companyuserId,
text: `Artikel "${sku}" als Variante von "${parentSku}" zugewiesen`,
childName: null,
childSku: sku,
parentSku,
});
const article = await load(articleId, languageId, runner);

View File

@@ -6,11 +6,10 @@ import {
CREATE as SQLITE_CREATE_PRICE,
DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as SQLITE_DELETE_PRICE,
} from '../../providers/sqlite3/queries/price.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import taxEnsure from '../tax/ensure.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordPriceChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
function normalizePrices(prices) {
@@ -66,7 +65,6 @@ export default function setPricesBySku(provider) {
const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE;
const deletePriceSql = isSqlite ? SQLITE_DELETE_PRICE : MSSQL_DELETE_PRICE;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Replaces the price list of an article by its SKU.
@@ -136,16 +134,12 @@ export default function setPricesBySku(provider) {
}
// Write history if prices changed
const fmtPrice = (p) => `${p.net}@${p.taxRate ?? 0}%`;
const oldStr = oldPrices.map(fmtPrice).join(', ');
const newStr = normalized.map((e) => `${e.price}@${e.tax}%`).join(', ');
if (oldStr !== newStr) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Preise geändert von [${oldStr}] auf [${newStr}]`,
});
}
await recordPriceChangeHistory(runner, {
articleId,
companyuserId,
oldPrices,
newPrices: normalized,
});
const article = await load(articleId, languageId, runner);
if (!article) {

View File

@@ -1,9 +1,8 @@
import { UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK } from '../../providers/mssql/queries/article.js';
import { UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK } from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordTrackStockChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setTrackStockBySku(provider) {
@@ -13,7 +12,6 @@ export default function setTrackStockBySku(provider) {
provider?.dialect === 'sqlite3'
? SQLITE_UPDATE_TRACK_STOCK
: MSSQL_UPDATE_TRACK_STOCK;
const insertHistorySql = provider?.dialect === 'sqlite3' ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Sets the track_stock flag of an article by its SKU.
@@ -55,13 +53,12 @@ export default function setTrackStockBySku(provider) {
}
// Write history if track_stock changed
if (oldTrack !== newTrack) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Bestandsführung geändert von "${oldTrack ? 'Ja' : 'Nein'}" auf "${newTrack ? 'Ja' : 'Nein'}"`,
});
}
await recordTrackStockChangeHistory(runner, {
articleId,
companyuserId,
oldTrack,
newTrack,
});
const article = await load(articleId, languageId, runner);
if (!article) {

View File

@@ -1,16 +1,14 @@
import { UPDATE_WEIGHT as MSSQL_UPDATE_WEIGHT } from '../../providers/mssql/queries/article.js';
import { UPDATE_WEIGHT as SQLITE_UPDATE_WEIGHT } from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordWeightChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setWeightBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE_WEIGHT : MSSQL_UPDATE_WEIGHT;
const insertHistorySql = provider?.dialect === 'sqlite3' ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Sets the weight of an article by its SKU.
@@ -51,13 +49,12 @@ export default function setWeightBySku(provider) {
}
// Write history if weight changed
if (Number(oldWeight) !== weight) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Gewicht geändert von "${oldWeight}" auf "${weight}"`,
});
}
await recordWeightChangeHistory(runner, {
articleId,
companyuserId,
oldWeight,
newWeight: weight,
});
const article = await load(articleId, languageId, runner);
if (!article) {

View File

@@ -4,10 +4,12 @@ import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/
import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js';
import * as mssqlAttr from '../../providers/mssql/queries/attributename.js';
import * as sqliteAttr from '../../providers/sqlite3/queries/attributename.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import getBySku from './getBySku.js';
import {
recordParentConvertedHistory,
recordParentDeactivatedHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function toggleParent(provider) {
@@ -18,7 +20,6 @@ export default function toggleParent(provider) {
const queries = isSqlite ? sqliteArticle : mssqlArticle;
const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT;
const attrQueries = isSqlite ? sqliteAttr : mssqlAttr;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Toggles an article into or out of parent mode.
@@ -142,32 +143,11 @@ export default function toggleParent(provider) {
});
// Add history entry recording parent conversion and deleted prices
const historyLines = [
`Artikel zu Vaterartikel konvertiert (Varianten-Attribut: "${attributeName}")`,
];
if (existingPrices.length > 0) {
const priceParts = existingPrices.map((p) => {
let str = `${p.net} €`;
if (p.taxRate != null) {
str += ` (${p.taxRate}% MwSt.)`;
}
if (p.minmultiplier > 1) {
str += ` ab ${p.minmultiplier} Stk.`;
}
if (p.customerGroupName) {
str += ` [${p.customerGroupName}]`;
}
return str;
});
const label = existingPrices.length === 1 ? 'Bisheriger Preis gelöscht' : 'Bisherige Preise gelöscht';
historyLines.push(`${label}: ${priceParts.join(', ')}`);
}
await runner.query(insertHistorySql, {
await recordParentConvertedHistory(runner, {
articleId,
companyuserId,
text: historyLines.join('\n'),
attributeName,
existingPrices,
});
} else {
// Disabling parent: verify zero children
@@ -183,10 +163,9 @@ export default function toggleParent(provider) {
parentAttributeNameId: null,
});
await runner.query(insertHistorySql, {
await recordParentDeactivatedHistory(runner, {
articleId,
companyuserId,
text: 'Vaterartikel-Modus deaktiviert',
});
}

View File

@@ -1,7 +1,5 @@
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import { UPDATE_BY_ARTICLE_AND_LANGUAGE as MSSQL_UPDATE_TEXT } from '../../providers/mssql/queries/articletext.js';
import { UPDATE_BY_ARTICLE_AND_LANGUAGE as SQLITE_UPDATE_TEXT } from '../../providers/sqlite3/queries/articletext.js';
import { GET_DETAIL_BY_ARTICLE_ID as MSSQL_GET_DETAIL } from '../../providers/mssql/queries/articleDetail.js';
@@ -12,6 +10,7 @@ import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js';
import { recordArticleUpdateHistory } from '../articlehistory/record.js';
function formatUnitLabel(amount, symbol) {
const sym = symbol ?? '';
@@ -28,7 +27,6 @@ export default function updateBySku(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
const updateTextSql = isSqlite ? SQLITE_UPDATE_TEXT : MSSQL_UPDATE_TEXT;
const getDetailSql = isSqlite ? SQLITE_GET_DETAIL : MSSQL_GET_DETAIL;
const getUnitSql = isSqlite ? SQLITE_GET_UNIT : MSSQL_GET_UNIT;
@@ -221,10 +219,10 @@ export default function updateBySku(provider) {
}
if (historyLines.length) {
await runner.query(insertHistorySql, {
await recordArticleUpdateHistory(runner, {
articleId,
companyuserId,
text: historyLines.join('\n'),
historyLines,
});
}

View File

@@ -8,9 +8,8 @@ import {
INSERT_ROW as SQLITE_INSERT,
GET_VALUE_DETAIL as SQLITE_GET_DETAIL,
} from '../../providers/sqlite3/queries/articleattributevalue.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { recordAttributeChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setByArticleSku(provider) {
@@ -19,7 +18,6 @@ export default function setByArticleSku(provider) {
const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const insertSql = isSqlite ? SQLITE_INSERT : MSSQL_INSERT;
const getDetailSql = isSqlite ? SQLITE_GET_DETAIL : MSSQL_GET_DETAIL;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Replaces all attribute values for an article by its SKU.
@@ -182,16 +180,12 @@ export default function setByArticleSku(provider) {
}
// Write history if attribute values changed
const fmtAttr = (a) => `${a.attributeName}: ${a.value}`;
const oldStr = oldAttrs.map(fmtAttr).join(', ');
const newStr = validated.map(fmtAttr).join(', ');
if (oldStr !== newStr) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Attributwerte geändert von [${oldStr}] auf [${newStr}]`,
});
}
await recordAttributeChangeHistory(runner, {
articleId,
companyuserId,
oldAttrs,
newAttrs: validated,
});
return inserted;
};

View File

@@ -0,0 +1,855 @@
import { INSERT as MSSQL_INSERT } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT } from '../../providers/sqlite3/queries/articlehistory.js';
/**
* Formats a numeric currency amount in German language style: "€ 1.000,99"
*
* @param {number|string} amount - The currency amount.
* @returns {string} The formatted currency string.
*/
export function formatCurrencyDe(amount) {
if (amount == null || !Number.isFinite(Number(amount))) return '';
const num = Number(amount);
const formatted = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
return `€ ${formatted}`;
}
/**
* Calculates unit gross price given net, rebate, and taxrate.
*
* @param {number|string} net - Unit net price.
* @param {number|string} [rebate=0] - Rebate percentage.
* @param {number|string} [taxrate=0] - Tax rate percentage.
* @returns {number} The calculated gross price.
*/
export function calculateGross(net, rebate = 0, taxrate = 0) {
const n = Number(net || 0);
const r = Number(rebate || 0);
const t = Number(taxrate || 0);
return n * (1 - r / 100) * (1 + t / 100);
}
/**
* Core function for recording an article history entry.
* Serializes meta into a JSON string if provided as an object.
*
* @param {object} runner - The database runner.
* @param {object} params - Insertion parameters.
* @param {number} params.articleId - The ID of the article.
* @param {number|null} [params.companyuserId] - The ID of the acting company user.
* @param {string} params.text - The human-readable history text.
* @param {object|string|null} [params.meta] - Structured meta object or JSON string.
* @param {string} [params.type] - Optional fallback type if meta is not an object.
* @returns {Promise<object>} Query result.
*/
export async function recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
type,
}) {
if (!articleId) {
throw new Error('articleId required for article history');
}
const cleanText = typeof text === 'string' ? text.trim() : '';
if (!cleanText) {
throw new Error('text required for article history');
}
const isSqlite = runner?.dialect === 'sqlite3';
const insertSql = isSqlite ? SQLITE_INSERT : MSSQL_INSERT;
let metaObj = meta;
if (metaObj === undefined || metaObj === null) {
metaObj = type ? { type } : null;
} else if (typeof metaObj === 'object' && type && !metaObj.type) {
metaObj = { type, ...metaObj };
}
let serializedMeta = null;
if (metaObj !== undefined && metaObj !== null) {
serializedMeta = typeof metaObj === 'string' ? metaObj : JSON.stringify(metaObj);
}
let parsedDate = null;
if (date != null) {
parsedDate = date instanceof Date ? date : new Date(date);
if (Number.isNaN(parsedDate.getTime())) {
parsedDate = null;
}
}
return runner.query(insertSql, {
articleId,
companyuserId: companyuserId ?? null,
date: parsedDate,
text: cleanText,
meta: serializedMeta,
});
}
/**
* Records an order entry in article history, including German currency formatted gross price.
* Format: "Auftrag {number} (Kunde: "{customerName}", Menge: {amount}, € 1.000,99)"
*/
export async function recordOrderHistory(runner, {
articleId,
companyuserId,
date,
orderId,
number,
customerName,
amount,
net,
rebate,
taxrate,
}) {
const details = [];
if (customerName) {
details.push(`Kunde: "${customerName}"`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
let gross = null;
if (net != null && Number.isFinite(Number(net))) {
gross = calculateGross(net, rebate, taxrate);
const formattedGross = formatCurrencyDe(gross);
if (formattedGross) {
details.push(formattedGross);
}
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const text = `Auftrag ${number}${suffix}`;
const meta = {
type: 'order',
...(orderId != null ? { orderId } : {}),
orderNumber: number,
customerName: customerName ?? null,
amount: amount != null ? Number(amount) : null,
net: net != null ? Number(net) : null,
rebate: rebate != null ? Number(rebate) : 0,
taxrate: taxrate != null ? Number(taxrate) : 0,
gross: gross != null ? Math.round(gross * 100) / 100 : null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records a delivery / warehouse ledger entry in article history.
*/
export async function recordDeliveryHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
supplierName,
warehouseName,
net,
rebate,
taxrate,
amount,
transactionText,
isDelivery = true,
}) {
let historyText = transactionText;
let ekBrutto = null;
if (isDelivery) {
const details = [];
if (supplierName) {
details.push(`Lieferant: "${supplierName}"`);
}
if (warehouseName) {
details.push(`Lager: "${warehouseName}"`);
}
if (net != null && Number.isFinite(Number(net))) {
const netNum = Number(net);
const rebateNum = Number(rebate ?? 0);
const taxrateNum = Number(taxrate ?? 0);
const netAfterRebate = netNum * (1 - rebateNum / 100);
const brutto = netAfterRebate * (1 + taxrateNum / 100);
ekBrutto = (Math.round(brutto * 100) / 100).toFixed(2);
details.push(`EK brutto: ${ekBrutto}`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base =
transactionText?.trim() ||
(manifestNumber ? `Lieferung ${manifestNumber}` : 'Lieferung');
historyText = `${base}${suffix}`;
} else if (warehouseName && transactionText && !transactionText.includes(warehouseName)) {
historyText = `${transactionText} (Lager: "${warehouseName}")`;
}
const meta = {
type: isDelivery ? 'delivery' : 'movement',
manifestNumber: manifestNumber ?? null,
supplierName: supplierName ?? null,
warehouseName: warehouseName ?? null,
amount: amount != null ? Number(amount) : null,
net: net != null ? Number(net) : null,
rebate: rebate != null ? Number(rebate) : 0,
taxrate: taxrate != null ? Number(taxrate) : 0,
ekBrutto: ekBrutto != null ? Number(ekBrutto) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Records a stock adjustment in article history.
*/
export async function recordStockAdjustmentHistory(runner, {
articleId,
companyuserId,
date,
amount,
reason,
}) {
const sign = amount > 0 ? '+' : '';
const text = `Bestandskorrektur ${sign}${amount} (${reason})`;
const meta = {
type: 'stock_adjustment',
amount,
reason,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records price changes in article history.
*/
export async function recordPriceChangeHistory(runner, {
articleId,
companyuserId,
date,
oldPrices,
newPrices,
}) {
const fmtPrice = (p) => `${p.net ?? p.price}@${p.taxRate ?? p.tax ?? 0}%`;
const oldStr = (oldPrices || []).map(fmtPrice).join(', ');
const newStr = (newPrices || []).map(fmtPrice).join(', ');
if (oldStr === newStr) return;
const text = `Preise geändert von [${oldStr}] auf [${newStr}]`;
const meta = {
type: 'price_change',
oldPrices,
newPrices,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records category assignment changes in article history.
*/
export async function recordCategoryChangeHistory(runner, {
articleId,
companyuserId,
date,
oldCategoryIds,
newCategoryIds,
}) {
const oldStr = [...(oldCategoryIds || [])].sort((a, b) => a - b).join(', ');
const newStr = [...(newCategoryIds || [])].sort((a, b) => a - b).join(', ');
if (oldStr === newStr) return;
const text = `Kategorien geändert von [${oldStr}] auf [${newStr}]`;
const meta = {
type: 'category_change',
oldCategoryIds,
newCategoryIds,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records brand changes in article history.
*/
export async function recordBrandChangeHistory(runner, {
articleId,
companyuserId,
date,
oldBrandName,
newBrandName,
}) {
if (oldBrandName === newBrandName) return;
const text = `Marke geändert von "${oldBrandName ?? ''}" auf "${newBrandName ?? ''}"`;
const meta = {
type: 'brand_change',
oldBrandName: oldBrandName ?? null,
newBrandName: newBrandName ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records EAN changes in article history.
*/
export async function recordEanChangeHistory(runner, {
articleId,
companyuserId,
date,
oldEans,
newEans,
}) {
const oldStr = (oldEans || []).join(', ');
const newStr = [...(newEans || [])].sort().join(', ');
if (oldStr === newStr) return;
const text = `EANs geändert von "${oldStr}" auf "${newStr}"`;
const meta = {
type: 'ean_change',
oldEans,
newEans,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records weight changes in article history.
*/
export async function recordWeightChangeHistory(runner, {
articleId,
companyuserId,
date,
oldWeight,
newWeight,
}) {
if (Number(oldWeight) === Number(newWeight)) return;
const text = `Gewicht geändert von "${oldWeight}" auf "${newWeight}"`;
const meta = {
type: 'weight_change',
oldWeight: Number(oldWeight),
newWeight: Number(newWeight),
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records track_stock changes in article history.
*/
export async function recordTrackStockChangeHistory(runner, {
articleId,
companyuserId,
date,
oldTrack,
newTrack,
}) {
if (Boolean(oldTrack) === Boolean(newTrack)) return;
const text = `Bestandsführung geändert von "${oldTrack ? 'Ja' : 'Nein'}" auf "${newTrack ? 'Ja' : 'Nein'}"`;
const meta = {
type: 'track_stock_change',
oldTrackStock: Boolean(oldTrack),
newTrackStock: Boolean(newTrack),
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records general article field updates (name, unit, description, track_stock).
*/
export async function recordArticleUpdateHistory(runner, {
articleId,
companyuserId,
date,
historyLines,
changes,
}) {
if (!historyLines || !historyLines.length) return;
const text = historyLines.join('\n');
const meta = {
type: 'article_update',
changes: changes ?? historyLines,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records initial article creation.
*/
export async function recordArticleCreatedHistory(runner, {
articleId,
companyuserId,
date,
name,
}) {
const text = `Artikel "${name}" angelegt`;
const meta = {
type: 'article_created',
name,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records article import.
*/
export async function recordArticleImportedHistory(runner, {
articleId,
companyuserId,
date,
name,
}) {
const text = `Artikel "${name}" importiert`;
const meta = {
type: 'article_imported',
name,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text,
meta,
});
}
/**
* Records conversion of an article to parent article.
*/
export async function recordParentConvertedHistory(runner, {
articleId,
companyuserId,
attributeName,
existingPrices,
}) {
const historyLines = [
attributeName
? `Artikel zu Vaterartikel konvertiert (Varianten-Attribut: "${attributeName}")`
: 'Artikel zu Vaterartikel konvertiert',
];
if (existingPrices && existingPrices.length > 0) {
const priceParts = existingPrices.map((p) => {
let str = `${p.net} €`;
if (p.taxRate != null) str += ` (${p.taxRate}% MwSt.)`;
if (p.minmultiplier > 1) str += ` ab ${p.minmultiplier} Stk.`;
if (p.customerGroupName) str += ` [${p.customerGroupName}]`;
return str;
});
const label = existingPrices.length === 1 ? 'Bisheriger Preis gelöscht' : 'Bisherige Preise gelöscht';
historyLines.push(`${label}: ${priceParts.join(', ')}`);
}
const text = historyLines.join('\n');
const meta = {
type: 'parent_converted',
attributeName: attributeName ?? null,
deletedPricesCount: existingPrices ? existingPrices.length : 0,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records deactivation of parent article mode.
*/
export async function recordParentDeactivatedHistory(runner, {
articleId,
companyuserId,
}) {
const text = 'Vaterartikel-Modus deaktiviert';
const meta = {
type: 'parent_deactivated',
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records adding a variant onto a parent article.
*/
export async function recordVariantAddedOnParentHistory(runner, {
parentId,
companyuserId,
childName,
childSku,
}) {
const text = `Variante "${childName}" (${childSku}) hinzugefügt`;
const meta = {
type: 'variant_added_to_parent',
childName,
childSku,
};
return recordArticleHistory(runner, {
articleId: parentId,
companyuserId,
text,
meta,
});
}
/**
* Records assigning a child article to a parent article.
*/
export async function recordChildAssignedToParentHistory(runner, {
childId,
companyuserId,
childName,
childSku,
parentSku,
created = false,
}) {
const text = created
? `Artikel "${childName}" (${childSku}) als Variante von "${parentSku}" angelegt`
: (childName
? `Artikel "${childName}" (${childSku}) als Variante von "${parentSku}" zugewiesen`
: `Artikel "${childSku}" als Variante von "${parentSku}" zugewiesen`);
const meta = {
type: created ? 'child_created_under_parent' : 'child_assigned_to_parent',
childName: childName ?? null,
childSku,
parentSku,
};
return recordArticleHistory(runner, {
articleId: childId,
companyuserId,
text,
meta,
});
}
/**
* Records unlinking a child article from a parent article on both child and parent.
*/
export async function recordChildUnlinkedFromParentHistory(runner, {
childId,
parentId,
companyuserId,
childSku,
childName,
parentSku,
}) {
await recordArticleHistory(runner, {
articleId: childId,
companyuserId,
text: `Artikel "${childSku}" von Vaterartikel "${parentSku}" getrennt`,
meta: {
type: 'child_unlinked',
childSku,
parentSku,
},
});
await recordArticleHistory(runner, {
articleId: parentId,
companyuserId,
text: `Variante "${childName || childSku}" (${childSku}) getrennt`,
meta: {
type: 'variant_separated',
childSku,
childName: childName ?? null,
},
});
}
/**
* Records deleting a variant child article from a parent.
*/
export async function recordChildDeletedFromParentHistory(runner, {
parentId,
companyuserId,
childName,
childSku,
}) {
const text = `Variante "${childName || childSku}" (${childSku}) gelöscht`;
const meta = {
type: 'variant_deleted',
childName: childName ?? null,
childSku,
};
return recordArticleHistory(runner, {
articleId: parentId,
companyuserId,
text,
meta,
});
}
/**
* Records adding a supplier to an article.
*/
export async function recordSupplierAddHistory(runner, {
articleId,
companyuserId,
supplierName,
supplierSku,
netPrice,
}) {
const details = [];
if (supplierSku) details.push(`Artikelnr: "${supplierSku}"`);
if (netPrice !== null && netPrice !== undefined) details.push(`EK: ${netPrice}`);
const suffix = details.length ? ` (${details.join(', ')})` : '';
const text = `Lieferant "${supplierName}" hinzugefügt${suffix}`;
const meta = {
type: 'supplier_add',
supplierName,
supplierSku: supplierSku ?? null,
netPrice: netPrice ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records updating supplier details on an article.
*/
export async function recordSupplierUpdateHistory(runner, {
articleId,
companyuserId,
historyLines,
supplierName,
}) {
if (!historyLines || !historyLines.length) return;
const text = historyLines.join('\n');
const meta = {
type: 'supplier_update',
supplierName,
changes: historyLines,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records deleting a supplier from an article.
*/
export async function recordSupplierDeleteHistory(runner, {
articleId,
companyuserId,
supplierName,
supplierSku,
}) {
const suffix = supplierSku ? ` (Artikelnr: "${supplierSku}")` : '';
const text = `Lieferant "${supplierName}" entfernt${suffix}`;
const meta = {
type: 'supplier_delete',
supplierName,
supplierSku: supplierSku ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records property changes on an article.
*/
export async function recordPropertyChangeHistory(runner, {
articleId,
companyuserId,
oldProps,
newProps,
}) {
const fmtProp = (p) => `${p.name}=${p.value}`;
const oldStr = (oldProps || []).map(fmtProp).join(', ');
const newStr = (newProps || []).map(fmtProp).join(', ');
if (oldStr === newStr) return;
const text = `Eigenschaften geändert von [${oldStr}] auf [${newStr}]`;
const meta = {
type: 'property_change',
oldProps,
newProps,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records property deletion from an article.
*/
export async function recordPropertyDeleteHistory(runner, {
articleId,
companyuserId,
name,
value,
}) {
const text = `Eigenschaft "${name}" (${value}) gelöscht`;
const meta = {
type: 'property_delete',
name,
value,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records attribute value changes on an article.
*/
export async function recordAttributeChangeHistory(runner, {
articleId,
companyuserId,
oldAttrs,
newAttrs,
}) {
const fmtAttr = (a) => `${a.attributeName}: ${a.value}`;
const oldStr = (oldAttrs || []).map(fmtAttr).join(', ');
const newStr = (newAttrs || []).map(fmtAttr).join(', ');
if (oldStr === newStr) return;
const text = `Attributwerte geändert von [${oldStr}] auf [${newStr}]`;
const meta = {
type: 'attribute_change',
oldAttrs,
newAttrs,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records Stückliste (component items) assignment on an article.
*/
export async function recordArticleListChangeHistory(runner, {
articleId,
companyuserId,
items,
}) {
const newNames = (items || []).map((r) => `${r.count}x ${r.name || r.sku}`);
const text = `Stückliste gesetzt: [${newNames.join(', ')}]`;
const meta = {
type: 'articlelist_change',
items,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}
/**
* Records Optionsliste assignment on an article.
*/
export async function recordOptionsListChangeHistory(runner, {
articleId,
companyuserId,
options,
}) {
const newNames = (options || []).map((r) => r.name || r.sku);
const text = `Optionsliste gesetzt: [${newNames.join(', ')}]`;
const meta = {
type: 'optionslist_change',
options,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
text,
meta,
});
}

View File

@@ -1,9 +1,11 @@
import * as mssqlQueries from '../../providers/mssql/queries/articlelist.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlelist.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import getItemsByArticleSku from './getItemsByArticleSku.js';
import {
recordArticleListChangeHistory,
recordArticleHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
async function hasArticlelistCycle(runner, rootArticleId, candidateComponentId) {
@@ -44,7 +46,6 @@ export default function setItemsByArticleSku(provider) {
const getItems = getItemsByArticleSku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Sets or clears the Stückliste (bundle items) for an article.
@@ -100,10 +101,11 @@ export default function setItemsByArticleSku(provider) {
});
if (oldItemNames.length) {
await runner.query(insertHistorySql, {
await recordArticleHistory(runner, {
articleId,
companyuserId,
text: `Stückliste geleert (war: [${oldItemNames.join(', ')}])`,
type: 'articlelist_cleared',
});
}
}
@@ -221,11 +223,10 @@ export default function setItemsByArticleSku(provider) {
const result = await getItems(googleAuthId, sku, runner);
// Write history for Stückliste change
const newNames = result.map((r) => `${r.count}x ${r.name || r.sku}`);
await runner.query(insertHistorySql, {
await recordArticleListChangeHistory(runner, {
articleId,
companyuserId,
text: `Stückliste gesetzt: [${newNames.join(', ')}]`,
items: result,
});
return result;

View File

@@ -4,16 +4,14 @@ import {
import {
DELETE_BY_ARTICLE_AND_PROPERTY_ID as SQLITE_DELETE,
} from '../../providers/sqlite3/queries/articleproperty.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { recordPropertyDeleteHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function deleteArticleProperty(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Deletes a property from an article by SKU and propertyId.
@@ -41,10 +39,11 @@ export default function deleteArticleProperty(provider) {
await runner.query(deleteSql, { articleId, propertyId });
if (prop) {
await runner.query(insertHistorySql, {
await recordPropertyDeleteHistory(runner, {
articleId,
companyuserId,
text: `Eigenschaft "${prop.name}" (${prop.value}) gelöscht`,
name: prop.name,
value: prop.value,
});
}
};

View File

@@ -12,9 +12,8 @@ import {
import {
GET_BY_COMPANY_AND_ID as SQLITE_GET_PROP,
} from '../../providers/sqlite3/queries/propertyname.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { recordPropertyChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setByArticleSku(provider) {
@@ -23,7 +22,6 @@ export default function setByArticleSku(provider) {
const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const insertSql = isSqlite ? SQLITE_INSERT : MSSQL_INSERT;
const getPropSql = isSqlite ? SQLITE_GET_PROP : MSSQL_GET_PROP;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Replaces all properties for an article by its SKU.
@@ -119,16 +117,12 @@ export default function setByArticleSku(provider) {
}
// Write history if properties changed
const fmtProp = (p) => `${p.name}=${p.value}`;
const oldStr = oldProps.map(fmtProp).join(', ');
const newStr = validated.map(fmtProp).join(', ');
if (oldStr !== newStr) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Eigenschaften geändert von [${oldStr}] auf [${newStr}]`,
});
}
await recordPropertyChangeHistory(runner, {
articleId,
companyuserId,
oldProps,
newProps: validated,
});
return inserted;
};

View File

@@ -1,7 +1,5 @@
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import taxEnsure from '../tax/ensure.js';
import {
@@ -10,6 +8,7 @@ import {
resolveCompanyDefaultTax,
shapeArticleSupplier,
} from './helpers.js';
import { recordSupplierAddHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function create(provider) {
@@ -18,7 +17,6 @@ export default function create(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Assigns a supplier to an article, optionally creating a purchase price.
@@ -150,21 +148,15 @@ export default function create(provider) {
const supplierName =
supplierRow?.supplierName ?? (entityId ? `Lieferant #${entityId}` : 'Lieferant');
const details = [];
const actualSku = supplierRow?.sku ?? supplierSku;
if (actualSku) {
details.push(`Artikelnr: "${actualSku}"`);
}
const actualPrice = supplierRow?.priceNet ?? netPrice;
if (actualPrice !== null && actualPrice !== undefined) {
details.push(`EK: ${actualPrice}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
await runner.query(insertHistorySql, {
await recordSupplierAddHistory(runner, {
articleId,
companyuserId,
text: `Lieferant "${supplierName}" hinzugefügt${suffix}`,
supplierName,
supplierSku: actualSku,
netPrice: actualPrice,
});
return shapeArticleSupplier(supplierRow);

View File

@@ -1,15 +1,13 @@
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { recordSupplierDeleteHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Deletes a supplier assignment and cleans up its associated purchase price.
@@ -55,16 +53,11 @@ export default function del(provider) {
const supplierName =
existing.supplierName ?? (existing.entityId ? `Lieferant #${existing.entityId}` : 'Lieferant');
const details = [];
if (existing.sku) {
details.push(`Artikelnr: "${existing.sku}"`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
await runner.query(insertHistorySql, {
await recordSupplierDeleteHistory(runner, {
articleId,
companyuserId,
text: `Lieferant "${supplierName}" entfernt${suffix}`,
supplierName,
supplierSku: existing.sku,
});
};

View File

@@ -1,7 +1,5 @@
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import taxEnsure from '../tax/ensure.js';
import {
@@ -9,6 +7,7 @@ import {
resolveCompanyDefaultTax,
shapeArticleSupplier,
} from './helpers.js';
import { recordSupplierUpdateHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function update(provider) {
@@ -17,7 +16,6 @@ export default function update(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Updates an existing supplier assignment and its associated purchase price.
@@ -213,13 +211,12 @@ export default function update(provider) {
}
}
if (historyLines.length) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: historyLines.join('\n'),
});
}
await recordSupplierUpdateHistory(runner, {
articleId,
companyuserId,
historyLines,
supplierName,
});
const loadRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
id,

View File

@@ -1,52 +1,60 @@
import { GET_TREE_FOR_COMPANY as MSSQL_GET_TREE } from '../../providers/mssql/queries/category.js';
import { GET_TREE_FOR_COMPANY as SQLITE_GET_TREE } from '../../providers/sqlite3/queries/category.js';
import {
GET_TREE_FOR_COMPANY as MSSQL_GET_TREE,
COUNT_ARTICLES_IN_TREE as MSSQL_COUNT_ARTICLES,
} from '../../providers/mssql/queries/category.js';
import {
GET_TREE_FOR_COMPANY as SQLITE_GET_TREE,
COUNT_ARTICLES_IN_TREE as SQLITE_COUNT_ARTICLES,
} from '../../providers/sqlite3/queries/category.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import queryCategoryArticles from './queryCategoryArticles.js';
import getCategoryPosterminalIds from '../posterminal/getCategoryPosterminalIds.js';
import makeRunner from '../runner.js';
export default function getTreeForCompany(provider) {
const access = resolveSelectedCompanyAccess(provider);
const articles = queryCategoryArticles(provider);
const posterminalIds = getCategoryPosterminalIds(provider);
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_GET_TREE : MSSQL_GET_TREE;
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_GET_TREE : MSSQL_GET_TREE;
const countSql = isSqlite ? SQLITE_COUNT_ARTICLES : MSSQL_COUNT_ARTICLES;
/**
* Returns the full category tree for the user's selected company together
* with its articles and the posterminal ids assigned to each category.
* with the article count and the posterminal ids assigned to each category.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>} The category tree, articles and language.
* @returns {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}>} The category tree and article count.
*
* @example
* const tree = await store.category.getTreeForCompany(42);
* // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] }
* // => { languageId, tree: [{ id, articleCount, posterminalIds, ... }], articleCount: 25 }
*/
return async (googleAuthId, tx) => {
const { companyId, rootCategoryId, languageId, posCustomerGroupId } =
const { companyId, rootCategoryId, languageId } =
await access(googleAuthId, tx);
const runner = makeRunner(provider, tx);
const [treeResult, articleRows, ptMap] = await Promise.all([
const [treeResult, countResult, ptMap] = await Promise.all([
runner.query(sql, {
rootId: rootCategoryId,
languageId,
}),
articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }, tx),
runner.query(countSql, {
rootId: rootCategoryId,
}),
posterminalIds(companyId, tx),
]);
const tree = treeResult.rows.map((row) => ({
...row,
articleCount: Number(row.articleCount ?? 0),
posterminalIds: ptMap[row.id] ?? [],
}));
return {
languageId,
tree,
articles: articleRows,
articleCount: Number(countResult.rows[0]?.total ?? 0),
};
};
}

View File

@@ -10,11 +10,11 @@ export default function list(provider) {
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<object>>} The accessible company rows.
* @returns {Promise<Array<{id: number, name: string, rootCategoryId: number, languageId: number, iso639: string|null, taxrate: number, reducedtaxrate: number, taxfreerate: number}>>} The accessible company rows.
*
* @example
* const companies = await store.company.list(42);
* // => [{ id, name, ... }]
* // => [{ id, name, rootCategoryId, languageId, iso639, taxrate, reducedtaxrate, taxfreerate }]
*/
return async (googleAuthId, tx) => {
const result = await makeRunner(provider, tx).query(sql, { googleAuthId });

View File

@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import * as mssqlQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { recordDeliveryHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export const INIT_HASH = 'TODO_TSE_BASED_INITHASH';
@@ -205,42 +206,20 @@ export default function add(provider) {
!upperType.includes('LOSS') &&
!upperType.includes('WRITE_OFF'));
let historyText = transactionText;
if (isDelivery) {
const details = [];
if (manifestArticle.supplierName) {
details.push(`Lieferant: "${manifestArticle.supplierName}"`);
}
if (warehouseName) {
details.push(`Lager: "${warehouseName}"`);
}
if (manifestArticle.net != null && Number.isFinite(Number(manifestArticle.net))) {
const net = Number(manifestArticle.net);
const rebate = Number(manifestArticle.rebate ?? 0);
const taxrate = Number(manifestArticle.taxrate ?? 0);
const netAfterRebate = net * (1 - rebate / 100);
const brutto = netAfterRebate * (1 + taxrate / 100);
const ekBrutto = (Math.round(brutto * 100) / 100).toFixed(2);
details.push(`EK brutto: ${ekBrutto}`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base =
transactionText?.trim() ||
(manifestArticle.manifestNumber ? `Lieferung ${manifestArticle.manifestNumber}` : 'Lieferung');
historyText = `${base}${suffix}`;
} else if (warehouseName && !transactionText.includes(warehouseName)) {
historyText = `${transactionText} (Lager: "${warehouseName}")`;
}
if (!isShipping) {
for (const match of articleMatches.rows) {
await runner.query(queries.INSERT_ARTICLE_HISTORY, {
await recordDeliveryHistory(runner, {
articleId: match.articleId,
companyuserId,
text: historyText,
manifestNumber: manifestArticle.manifestNumber,
supplierName: manifestArticle.supplierName,
warehouseName,
net: manifestArticle.net,
rebate: manifestArticle.rebate,
taxrate: manifestArticle.taxrate,
amount,
transactionText,
isDelivery,
});
}
}

View File

@@ -0,0 +1,16 @@
import { CONSUME_AUTH_CODE as MSSQL_CONSUME } from '../../providers/mssql/queries/mcp.js';
import { CONSUME_AUTH_CODE as SQLITE_CONSUME } from '../../providers/sqlite3/queries/mcp.js';
export default function consumeAuthCode(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_CONSUME : MSSQL_CONSUME;
/**
* Marks an authorization code as used.
*
* @param {string} code - The authorization code to mark used.
* @returns {Promise<boolean>} True once marked.
*/
return async (code) => {
await provider.query(sql, { code });
return true;
};
}

View File

@@ -0,0 +1,43 @@
import { INSERT_AUTH_CODE as MSSQL_INSERT } from '../../providers/mssql/queries/mcp.js';
import { INSERT_AUTH_CODE as SQLITE_INSERT } from '../../providers/sqlite3/queries/mcp.js';
export default function createAuthCode(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_INSERT : MSSQL_INSERT;
/**
* Stores an OAuth 2.1 authorization code with PKCE parameters.
*
* @param {object} input
* @param {string} input.code - The generated authorization code.
* @param {string} input.clientId - The client ID.
* @param {number} input.googleAuthId - The ID of the authenticated user.
* @param {string} input.redirectUri - The redirect URI used in the authorize request.
* @param {string} input.codeChallenge - The PKCE code challenge.
* @param {string} input.codeChallengeMethod - The PKCE method (e.g. S256).
* @param {string|null} [input.scope] - Granted scope.
* @param {string|Date} input.expiresAt - Expiration ISO string or Date.
* @returns {Promise<string>} The authorization code.
*/
return async ({
code,
clientId,
googleAuthId,
redirectUri,
codeChallenge,
codeChallengeMethod = 'S256',
scope = 'mcp:tools',
expiresAt,
}) => {
const exp = expiresAt instanceof Date ? expiresAt.toISOString() : expiresAt;
await provider.query(sql, {
code,
clientId,
googleAuthId,
redirectUri,
codeChallenge,
codeChallengeMethod,
scope,
expiresAt: exp,
});
return code;
};
}

View File

@@ -0,0 +1,34 @@
import { INSERT_REFRESH_TOKEN as MSSQL_INSERT } from '../../providers/mssql/queries/mcp.js';
import { INSERT_REFRESH_TOKEN as SQLITE_INSERT } from '../../providers/sqlite3/queries/mcp.js';
export default function createRefreshToken(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_INSERT : MSSQL_INSERT;
/**
* Stores an OAuth 2.1 refresh token.
*
* @param {object} input
* @param {string} input.refreshToken - The refresh token string.
* @param {string} input.clientId - The client ID.
* @param {number} input.googleAuthId - The user ID.
* @param {string|null} [input.scope] - Scopes granted.
* @param {string|Date} input.expiresAt - Expiration Date or ISO string.
* @returns {Promise<object>} Created token record or id.
*/
return async ({
refreshToken,
clientId,
googleAuthId,
scope = 'mcp:tools',
expiresAt,
}) => {
const exp = expiresAt instanceof Date ? expiresAt.toISOString() : expiresAt;
const result = await provider.query(sql, {
refreshToken,
clientId,
googleAuthId,
scope,
expiresAt: exp,
});
return result.rows[0]?.id ?? result.insertId;
};
}

View File

@@ -0,0 +1,16 @@
import { DELETE_CLIENT as MSSQL_DELETE } from '../../providers/mssql/queries/mcp.js';
import { DELETE_CLIENT as SQLITE_DELETE } from '../../providers/sqlite3/queries/mcp.js';
export default function deleteClient(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a registered MCP client by client ID.
*
* @param {string} clientId - The client identifier to delete.
* @returns {Promise<boolean>} True if deleted.
*/
return async (clientId) => {
await provider.query(sql, { clientId });
return true;
};
}

View File

@@ -0,0 +1,16 @@
import { GET_AUTH_CODE as MSSQL_GET } from '../../providers/mssql/queries/mcp.js';
import { GET_AUTH_CODE as SQLITE_GET } from '../../providers/sqlite3/queries/mcp.js';
export default function getAuthCode(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Retrieves an authorization code record.
*
* @param {string} code - The authorization code.
* @returns {Promise<object|null>} The auth code record or null.
*/
return async (code) => {
const result = await provider.query(sql, { code });
return result.rows[0] ?? null;
};
}

View File

@@ -0,0 +1,16 @@
import { GET_CLIENT as MSSQL_GET } from '../../providers/mssql/queries/mcp.js';
import { GET_CLIENT as SQLITE_GET } from '../../providers/sqlite3/queries/mcp.js';
export default function getClient(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Retrieves an MCP client by client ID.
*
* @param {string} clientId - The client identifier.
* @returns {Promise<object|null>} The client record or null if not found.
*/
return async (clientId) => {
const result = await provider.query(sql, { clientId });
return result.rows[0] ?? null;
};
}

View File

@@ -0,0 +1,16 @@
import { GET_REFRESH_TOKEN as MSSQL_GET } from '../../providers/mssql/queries/mcp.js';
import { GET_REFRESH_TOKEN as SQLITE_GET } from '../../providers/sqlite3/queries/mcp.js';
export default function getRefreshToken(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Retrieves an OAuth 2.1 refresh token.
*
* @param {string} refreshToken - The refresh token.
* @returns {Promise<object|null>} The refresh token record or null.
*/
return async (refreshToken) => {
const result = await provider.query(sql, { refreshToken });
return result.rows[0] ?? null;
};
}

View File

@@ -0,0 +1,16 @@
import { LIST_AUTHORIZATIONS_BY_USER as MSSQL_LIST } from '../../providers/mssql/queries/mcp.js';
import { LIST_AUTHORIZATIONS_BY_USER as SQLITE_LIST } from '../../providers/sqlite3/queries/mcp.js';
export default function listAuthorizations(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists active authorizations for a Google Auth user.
*
* @param {number} googleAuthId - The user ID.
* @returns {Promise<Array<object>>} List of authorized clients and token counts.
*/
return async (googleAuthId) => {
const result = await provider.query(sql, { googleAuthId });
return result.rows ?? [];
};
}

View File

@@ -0,0 +1,15 @@
import { LIST_CLIENTS as MSSQL_LIST } from '../../providers/mssql/queries/mcp.js';
import { LIST_CLIENTS as SQLITE_LIST } from '../../providers/sqlite3/queries/mcp.js';
export default function listClients(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists all registered MCP clients.
*
* @returns {Promise<Array<object>>} List of client records.
*/
return async () => {
const result = await provider.query(sql);
return result.rows ?? [];
};
}

View File

@@ -0,0 +1,55 @@
import { INSERT_CLIENT as MSSQL_INSERT } from '../../providers/mssql/queries/mcp.js';
import { INSERT_CLIENT as SQLITE_INSERT } from '../../providers/sqlite3/queries/mcp.js';
export default function registerClient(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_INSERT : MSSQL_INSERT;
/**
* Registers a new OAuth MCP client.
*
* @param {object} input
* @param {string} input.clientId - Unique client identifier.
* @param {string|null} [input.clientSecret] - Optional client secret for confidential clients.
* @param {string} input.clientName - Human-readable client name.
* @param {string|Array<string>} input.redirectUris - JSON string or array of redirect URIs.
* @param {string|Array<string>} [input.grantTypes] - Supported grant types.
* @param {string|Array<string>} [input.responseTypes] - Supported response types.
* @param {string|null} [input.scope] - Allowed scope.
* @returns {Promise<object>} The created client record.
*/
return async ({
clientId,
clientSecret = null,
clientName,
redirectUris,
grantTypes = 'authorization_code,refresh_token',
responseTypes = 'code',
scope = 'mcp:tools',
}) => {
const urisStr = Array.isArray(redirectUris) ? JSON.stringify(redirectUris) : redirectUris;
const grantsStr = Array.isArray(grantTypes) ? grantTypes.join(',') : grantTypes;
const resTypesStr = Array.isArray(responseTypes) ? responseTypes.join(',') : responseTypes;
const result = await provider.query(sql, {
clientId,
clientSecret,
clientName,
redirectUris: urisStr,
grantTypes: grantsStr,
responseTypes: resTypesStr,
scope,
});
const row = result.rows[0];
return {
id: row?.id ?? result.insertId,
clientId: row?.clientId ?? row?.client_id ?? clientId,
clientSecret: row?.clientSecret ?? row?.client_secret ?? clientSecret,
clientName: row?.clientName ?? row?.client_name ?? clientName,
redirectUris: row?.redirectUris ?? row?.redirect_uris ?? urisStr,
grantTypes: row?.grantTypes ?? row?.grant_types ?? grantsStr,
responseTypes: row?.responseTypes ?? row?.response_types ?? resTypesStr,
scope: row?.scope ?? scope,
createdAt: row?.createdAt ?? row?.created_at,
};
};
}

View File

@@ -0,0 +1,17 @@
import { REVOKE_USER_AUTHORIZATIONS as MSSQL_REVOKE } from '../../providers/mssql/queries/mcp.js';
import { REVOKE_USER_AUTHORIZATIONS as SQLITE_REVOKE } from '../../providers/sqlite3/queries/mcp.js';
export default function revokeAuthorizations(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_REVOKE : MSSQL_REVOKE;
/**
* Revokes all active authorizations (tokens) for a user and MCP client.
*
* @param {number} googleAuthId - The user ID.
* @param {string} clientId - The MCP client ID.
* @returns {Promise<boolean>} True once revoked.
*/
return async (googleAuthId, clientId) => {
await provider.query(sql, { googleAuthId, clientId });
return true;
};
}

View File

@@ -0,0 +1,16 @@
import { REVOKE_REFRESH_TOKEN as MSSQL_REVOKE } from '../../providers/mssql/queries/mcp.js';
import { REVOKE_REFRESH_TOKEN as SQLITE_REVOKE } from '../../providers/sqlite3/queries/mcp.js';
export default function revokeRefreshToken(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_REVOKE : MSSQL_REVOKE;
/**
* Revokes an OAuth 2.1 refresh token.
*
* @param {string} refreshToken - The refresh token to revoke.
* @returns {Promise<boolean>} True once revoked.
*/
return async (refreshToken) => {
await provider.query(sql, { refreshToken });
return true;
};
}

View File

@@ -15,9 +15,10 @@ export default function create(provider) {
* @param {object} input - Conversation input.
* @param {string} input.provider - LLM provider name.
* @param {string} input.model - Model identifier.
* @param {number|null} [input.started_by] - Id of the conversation that starts this one as a subagent (unenforced reference; omit/null for a top-level conversation).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>}
* @throws {Error} 'Provider required' or 'Model required'.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>}
* @throws {Error} 'Provider required', 'Model required' or 'Invalid started_by'.
*
* @example
* const c = await store.modelconversation.create(42, { provider: 'openrouter', model: 'openai/gpt-5' });
@@ -28,6 +29,10 @@ export default function create(provider) {
if (!providerName) throw new Error('Provider required');
const model = typeof input.model === 'string' ? input.model.trim() : '';
if (!model) throw new Error('Model required');
const startedBy = input.started_by ?? null;
if (startedBy !== null && (!Number.isInteger(startedBy) || startedBy <= 0)) {
throw new Error('Invalid started_by');
}
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
@@ -36,6 +41,7 @@ export default function create(provider) {
companyuserId,
provider: providerName,
model,
startedBy,
});
return shape(result.rows[0]);
};

View File

@@ -9,7 +9,8 @@ export default function del(provider) {
provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a model conversation by ID for the user in the selected company.
* Deletes a model conversation by ID for the user in the selected company, together with
* every subagent conversation below it (`started_by` chain, recursively) and their messages.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.

View File

@@ -14,7 +14,7 @@ export default function get(provider) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>}
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null, first_user_message: string|null}>} `first_user_message` is the stored `message` payload of the first `user` message (null if none yet).
* @throws {Error} 'Conversation not found'.
*/
return async (googleAuthId, conversationId, tx) => {
@@ -28,6 +28,6 @@ export default function get(provider) {
const result = await runner.query(sql, { id, companyuserId });
if (!result.rows[0]) throw new Error('Conversation not found');
return shape(result.rows[0]);
return { ...shape(result.rows[0]), first_user_message: result.rows[0].first_user_message ?? null };
};
}

View File

@@ -13,7 +13,7 @@ export default function list(provider) {
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>>}
* @returns {Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null, first_user_message: string|null}>>} `started_by` is the originating conversation id for subagent conversations (null otherwise), so clients can build a tree. `first_user_message` is the stored `message` payload of the conversation's first `user` message (null if none yet).
*
* @example
* const conversations = await store.modelconversation.list(42);
@@ -22,6 +22,9 @@ export default function list(provider) {
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyuserId });
return result.rows.map(shape);
return result.rows.map((row) => ({
...shape(row),
first_user_message: row.first_user_message ?? null,
}));
};
}

View File

@@ -15,7 +15,7 @@ export default function setState(provider) {
* @param {number} conversationId - The conversation id.
* @param {string} state - The new state ('idle' | 'busy').
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>}
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>}
* @throws {Error} 'Invalid state' or 'Conversation not found'.
*/
return async (googleAuthId, conversationId, state, tx) => {

View File

@@ -7,5 +7,6 @@ export default function shape(row) {
created_at: row.created_at ?? row.createdAt,
updated_at: row.updated_at ?? row.updatedAt,
state: row.state,
started_by: row.started_by ?? row.startedBy ?? null,
};
}

View File

@@ -9,7 +9,13 @@ import {
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
const DEFAULT_TYPES = ['sku', 'entity', 'delivery', 'adjustment'];
const DEFAULT_TYPES = [
{ type: 'sku', prefix: '' },
{ type: 'entity', prefix: '' },
{ type: 'delivery', prefix: '' },
{ type: 'adjustment', prefix: '' },
{ type: 'shipment', prefix: '-L-' },
];
function shape(row) {
return {
@@ -30,7 +36,7 @@ export default function list(provider) {
/**
* Lists the number sequences of the user's selected company.
* Ensures that default sequences ('sku', 'entity', 'delivery') exist.
* Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
@@ -44,8 +50,10 @@ export default function list(provider) {
const { companyId } = await access(googleAuthId, tx);
const runner = makeRunner(provider, tx);
for (const type of DEFAULT_TYPES) {
await runner.query(ensureSql, { companyId, type });
for (const item of DEFAULT_TYPES) {
const type = typeof item === 'string' ? item : item.type;
const prefix = typeof item === 'string' ? (type === 'shipment' ? '-L-' : '') : item.prefix;
await runner.query(ensureSql, { companyId, type, prefix });
}
const result = await runner.query(sql, { companyId });

View File

@@ -37,7 +37,8 @@ export default function nextNumber(provider) {
*/
return async (companyId, type, tx) => {
const doAlloc = async (runner) => {
await runner.query(ensureSql, { companyId, type });
const prefix = type === 'shipment' ? '-L-' : '';
await runner.query(ensureSql, { companyId, type, prefix });
const res = await runner.query(getSql, { companyId, type });
const row = res.rows[0];
if (!row) {

View File

@@ -34,7 +34,8 @@ export default function peekNumber(provider) {
*/
return async (companyId, type, tx) => {
const doPeek = async (runner) => {
await runner.query(ensureSql, { companyId, type });
const prefix = type === 'shipment' ? '-L-' : '';
await runner.query(ensureSql, { companyId, type, prefix });
const res = await runner.query(getSql, { companyId, type });
const row = res.rows[0];
if (!row) {

View File

@@ -1,9 +1,11 @@
import * as mssqlQueries from '../../providers/mssql/queries/optionslist.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/optionslist.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import getOptionsByArticleSku from './getOptionsByArticleSku.js';
import {
recordOptionsListChangeHistory,
recordArticleHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
async function hasOptionslistCycle(runner, rootArticleId, candidateOptionId) {
@@ -44,7 +46,6 @@ export default function setOptionsByArticleSku(provider) {
const getOptions = getOptionsByArticleSku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Sets or clears the options list for an article.
@@ -100,10 +101,11 @@ export default function setOptionsByArticleSku(provider) {
});
if (oldOptNames.length) {
await runner.query(insertHistorySql, {
await recordArticleHistory(runner, {
articleId,
companyuserId,
text: `Optionsliste geleert (war: [${oldOptNames.join(', ')}])`,
type: 'optionslist_cleared',
});
}
}
@@ -228,11 +230,10 @@ export default function setOptionsByArticleSku(provider) {
const result = await getOptions(googleAuthId, sku, runner);
// Write history for options list change
const newNames = result.map((r) => r.name || r.sku);
await runner.query(insertHistorySql, {
await recordOptionsListChangeHistory(runner, {
articleId,
companyuserId,
text: `Optionsliste gesetzt: [${newNames.join(', ')}]`,
options: result,
});
return result;

View File

@@ -8,16 +8,16 @@ import {
} from '../../providers/sqlite3/queries/manifestDoc.js';
import {
FIND_ARTICLES_FOR_HISTORY as MSSQL_FIND_ARTICLES,
INSERT_ARTICLE_HISTORY as MSSQL_INSERT_HISTORY,
} from '../../providers/mssql/queries/ledger.js';
import {
FIND_ARTICLES_FOR_HISTORY as SQLITE_FIND_ARTICLES,
INSERT_ARTICLE_HISTORY as SQLITE_INSERT_HISTORY,
} from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js';
import { recordOrderHistory } from '../articlehistory/record.js';
import { syncOrderSummary } from './syncSummary.js';
import makeRunner from '../runner.js';
export default function create(provider) {
@@ -28,7 +28,6 @@ export default function create(provider) {
const createManifestSql = isSqlite ? SQLITE_CREATE_MANIFEST : MSSQL_CREATE_MANIFEST;
const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE;
const findArticlesSql = isSqlite ? SQLITE_FIND_ARTICLES : MSSQL_FIND_ARTICLES;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Creates a new order for the user's selected company with its lines and records article history.
@@ -98,6 +97,8 @@ export default function create(provider) {
customerName = entRes.rows[0]?.name ?? null;
}
const historyDate = body.createdAt ? createdAt : date;
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
if (!sku) continue;
@@ -107,21 +108,18 @@ export default function create(provider) {
companyId,
});
const details = [];
if (customerName) {
details.push(`Kunde: "${customerName}"`);
}
if (line.amount != null && Number.isFinite(Number(line.amount))) {
details.push(`Menge: ${line.amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const historyText = `Auftrag ${number}${suffix}`;
for (const match of articleMatches.rows) {
await runner.query(insertHistorySql, {
await recordOrderHistory(runner, {
articleId: match.articleId,
companyuserId,
text: historyText,
date: historyDate,
orderId: manifestId,
number,
customerName,
amount: line.amount,
net: line.net,
rebate: line.rebate,
taxrate: line.taxrate,
});
}
}
@@ -132,6 +130,8 @@ export default function create(provider) {
id: manifestId,
});
await syncOrderSummary(runner, { companyId, manifestId });
return doc;
};

View File

@@ -1,17 +1,13 @@
import {
COUNT_MANIFESTS as MSSQL_COUNT,
GET_MANIFEST_LINES_BY_MANIFEST_IDS as MSSQL_LINES_BY_MANIFEST_IDS,
LIST_MANIFESTS as MSSQL_LIST,
LIST_MANIFESTS_PAGED as MSSQL_LIST_PAGED,
ORDER_TYPE as MSSQL_ORDER_TYPE,
} from '../../providers/mssql/queries/manifestDoc.js';
import {
COUNT_MANIFESTS as SQLITE_COUNT,
GET_MANIFEST_LINES_BY_MANIFEST_IDS as SQLITE_LINES_BY_MANIFEST_IDS,
LIST_MANIFESTS as SQLITE_LIST,
LIST_MANIFESTS_PAGED as SQLITE_LIST_PAGED,
ORDER_TYPE as SQLITE_ORDER_TYPE,
} from '../../providers/sqlite3/queries/manifestDoc.js';
import { GET_SHIPMENT_PREFIX as MSSQL_GET_SHIPMENT_PREFIX } from '../../providers/mssql/queries/shipment.js';
import { GET_SHIPMENT_PREFIX as SQLITE_GET_SHIPMENT_PREFIX } from '../../providers/sqlite3/queries/shipment.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from '../manifestDoc/helpers.js';
import { shapeLine } from '../manifestLines.js';
@@ -20,27 +16,31 @@ import makeRunner from '../runner.js';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_LIST : MSSQL_LIST;
const sqlCount = isSqlite ? SQLITE_COUNT : MSSQL_COUNT;
const sqlPaged = isSqlite ? SQLITE_LIST_PAGED : MSSQL_LIST_PAGED;
const sqlLinesByManifestIds = isSqlite
? SQLITE_LINES_BY_MANIFEST_IDS
: MSSQL_LINES_BY_MANIFEST_IDS;
const sqlShipmentPrefix = isSqlite
? SQLITE_GET_SHIPMENT_PREFIX
: MSSQL_GET_SHIPMENT_PREFIX;
const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE;
/**
* Lists the orders of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [opts] - Optional pagination options.
* @param {object} [opts] - Optional pagination and filter options.
* @param {number} [opts.offset=0] - Number of orders to skip.
* @param {number} [opts.limit=20] - Number of orders to return.
* @param {number} [opts.limit=50] - Number of orders to return.
* @param {string} [opts.search] - Free-text search matching order number, external number, or customer name.
* @param {boolean} [opts.onlyUnpaid=false] - When true, returns only unpaid orders.
* @param {boolean} [opts.onlyUndelivered=false] - When true, returns only undelivered orders.
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{orders: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>, total: number, offset: number, limit: number}|Array<object>>} The orders.
*
* @example
* const result = await store.order.list(42, { offset: 0, limit: 20 });
* // => { orders: [...], total: 100, offset: 0, limit: 20 }
* const result = await store.order.list(42, { offset: 0, limit: 50, search: '1002', onlyUnpaid: true, onlyUndelivered: true, seekMonths: -1 });
* // => { orders: [...], total: 10, offset: 0, limit: 50 }
*
* const orders = await store.order.list(42);
* // => [{ id, number, type, entityId, externalNumber, date, createdAt, lines: [...] }]
@@ -51,11 +51,20 @@ export default function list(provider) {
let isPaged = false;
let resolvedTx = tx;
const hasFilter = opts != null && typeof opts === 'object' && !opts._isTx && (
opts.offset !== undefined ||
opts.limit !== undefined ||
(typeof opts.search === 'string' && opts.search.trim() !== '') ||
opts.onlyUnpaid === true ||
opts.onlyUndelivered === true ||
opts.seekMonths !== undefined
);
if (opts != null && typeof opts === 'object' && !opts._isTx) {
if (opts.offset !== undefined || opts.limit !== undefined) {
if (hasFilter || opts.offset !== undefined || opts.limit !== undefined) {
isPaged = true;
const parsedOffset = Number(opts.offset ?? 0);
const parsedLimit = Number(opts.limit ?? 20);
const parsedLimit = Number(opts.limit ?? 50);
if (!Number.isInteger(parsedOffset) || parsedOffset < 0) {
throw new Error('Invalid offset');
}
@@ -72,18 +81,133 @@ export default function list(provider) {
const runner = makeRunner(provider, resolvedTx);
const { companyId } = await access(googleAuthId, runner);
const conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`];
const params = {
companyId,
type: orderType,
};
// 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(`m.${isSqlite ? '"date"' : '[date]'} <= @dateTo`);
}
// Free-text search (strictly string)
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
params.searchPattern = `%${opts.search.trim()}%`;
if (isSqlite) {
conditions.push(`(
m."number" LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR e.name LIKE @searchPattern
OR e.first_name LIKE @searchPattern
OR (IFNULL(e.first_name, '') || ' ' || IFNULL(e.name, '')) LIKE @searchPattern
OR (IFNULL(e.name, '') || ' ' || IFNULL(e.first_name, '')) LIKE @searchPattern
)`);
} else {
conditions.push(`(
m.[number] LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR e.name LIKE @searchPattern
OR e.first_name LIKE @searchPattern
OR (ISNULL(e.first_name, '') + ' ' + ISNULL(e.name, '')) LIKE @searchPattern
OR (ISNULL(e.name, '') + ' ' + ISNULL(e.first_name, '')) LIKE @searchPattern
)`);
}
}
// Only unpaid (strictly boolean true; overpaid orders also count as unpaid)
if (opts?.onlyUnpaid === true) {
conditions.push('COALESCE(os.is_paid, 0) = 0');
}
// Only undelivered (strictly boolean true)
if (opts?.onlyUndelivered === true) {
conditions.push('COALESCE(os.is_shipped, 0) = 0');
}
const whereClause = conditions.join('\n AND ');
const sqlCount = `
SELECT COUNT(*) AS total
FROM manifest m
LEFT JOIN ordersummary os ON os.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
`;
let sqlList;
if (isSqlite) {
sqlList = `
SELECT
m.id,
m."number",
m."type",
m.entity_id AS entityId,
m.external_number AS externalNumber,
m."date",
m.created_at AS createdAt,
os.positions,
os.gross_total AS grossTotal,
os.paid_total AS paidTotal,
os.ordered_quantity AS orderedQuantity,
os.shipped_quantity AS shippedQuantity,
os.is_paid AS isPaid,
os.is_shipped AS isShipped,
os.customer_name AS customerName
FROM manifest m
LEFT JOIN ordersummary os ON os.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
ORDER BY m."date" DESC, m.id DESC
${isPaged ? 'LIMIT @limit OFFSET @offset' : ''}
`;
} else {
sqlList = `
SELECT
m.id,
m.[number],
m.[type],
m.entity_id AS entityId,
m.external_number AS externalNumber,
m.[date],
m.created_at AS createdAt,
os.positions,
os.gross_total AS grossTotal,
os.paid_total AS paidTotal,
os.ordered_quantity AS orderedQuantity,
os.shipped_quantity AS shippedQuantity,
os.is_paid AS isPaid,
os.is_shipped AS isShipped,
os.customer_name AS customerName
FROM manifest m
LEFT JOIN ordersummary os ON os.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
ORDER BY m.[date] DESC, m.id DESC
${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''}
`;
}
let manifestRows;
let total = 0;
if (isPaged) {
params.offset = offset;
params.limit = limit;
const [countResult, listResult] = await Promise.all([
runner.query(sqlCount, { companyId, type: orderType }),
runner.query(sqlPaged, { companyId, type: orderType, offset, limit }),
runner.query(sqlCount, params),
runner.query(sqlList, params),
]);
total = Number(countResult.rows[0]?.total ?? 0);
manifestRows = listResult.rows;
} else {
const result = await runner.query(sql, { companyId, type: orderType });
const result = await runner.query(sqlList, params);
manifestRows = result.rows;
total = manifestRows.length;
}
@@ -108,6 +232,14 @@ export default function list(provider) {
const orders = manifestRows.map((row) => ({
...shapeSummary(orderType, row),
lines: linesByManifestId.get(row.id) ?? [],
positions: row.positions !== undefined && row.positions !== null ? Number(row.positions) : undefined,
grossTotal: row.grossTotal !== undefined && row.grossTotal !== null ? Number(row.grossTotal) : undefined,
paidTotal: row.paidTotal !== undefined && row.paidTotal !== null ? Number(row.paidTotal) : undefined,
orderedQuantity: row.orderedQuantity !== undefined && row.orderedQuantity !== null ? Number(row.orderedQuantity) : undefined,
shippedQuantity: row.shippedQuantity !== undefined && row.shippedQuantity !== null ? Number(row.shippedQuantity) : undefined,
isPaid: row.isPaid !== undefined && row.isPaid !== null ? Boolean(row.isPaid) : undefined,
isShipped: row.isShipped !== undefined && row.isShipped !== null ? Boolean(row.isShipped) : undefined,
customerName: row.customerName ?? undefined,
}));
if (isPaged) {
@@ -122,5 +254,3 @@ export default function list(provider) {
return orders;
};
}

View File

@@ -0,0 +1,19 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { rebuildOrderSummaries } from './syncSummary.js';
/**
* Rebuilds the `ordersummary` 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 rebuildOrderSummaries(runner, companyId);
return { ok: true, companyId };
};
}

View File

@@ -0,0 +1,364 @@
/**
* Centralized sync and rebuild logic for the materialized `ordersummary` table.
*/
const SQL_GET_SHIPMENT_PREFIX = `
SELECT prefix FROM numbers WHERE company_id = @companyId AND [type] = 'shipment'
`;
const SQL_GET_ORDER_BY_ID_SQLITE = `
SELECT m.id, m.company_id, m."number", m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'order'
`;
const SQL_GET_ORDER_BY_ID_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'order'
`;
const SQL_GET_ORDER_BY_NUMBER_SQLITE = `
SELECT m.id, m.company_id, m."number", m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m."number" = @number AND m.company_id = @companyId AND m."type" = 'order'
`;
const SQL_GET_ORDER_BY_NUMBER_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.[number] = @number AND m.company_id = @companyId AND m.[type] = 'order'
`;
const SQL_LINE_TOTALS = `
SELECT
COUNT(*) AS positions,
COALESCE(SUM(amount), 0) AS ordered_quantity,
COALESCE(SUM(amount * (net * (1.0 - rebate / 100.0)) * (1.0 + taxrate / 100.0)), 0) AS gross_total
FROM manifestarticle
WHERE manifest_id = @manifestId
`;
const SQL_PAYMENT_TOTAL = `
SELECT COALESCE(SUM(amount), 0) AS paid_total
FROM payment
WHERE manifest_id = @manifestId
`;
const SQL_SHIPPED_QTY_SQLITE = `
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s."type" = 'shipment'
AND m_s."number" >= (@orderNumber || @shipmentPrefix)
AND m_s."number" < (@orderNumber || @shipmentPrefix || char(127))
`;
const SQL_SHIPPED_QTY_MSSQL = `
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s.[type] = 'shipment'
AND m_s.[number] >= (@orderNumber + @shipmentPrefix)
AND m_s.[number] < (@orderNumber + @shipmentPrefix + NCHAR(127))
`;
const SQL_CUSTOMER_NAME_SQLITE = `
SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS customer_name
FROM entity
WHERE id = @entityId
`;
const SQL_CUSTOMER_NAME_MSSQL = `
SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS customer_name
FROM entity
WHERE id = @entityId
`;
const SQL_UPSERT_SUMMARY_SQLITE = `
INSERT INTO ordersummary (
company_id, manifest_id, order_number, order_date, created_at,
entity_id, customer_name, positions, gross_total, paid_total,
ordered_quantity, shipped_quantity, is_paid, is_shipped
) VALUES (
@companyId, @manifestId, @orderNumber, @orderDate, @createdAt,
@entityId, @customerName, @positions, @grossTotal, @paidTotal,
@orderedQuantity, @shippedQuantity, @isPaid, @isShipped
)
ON CONFLICT(manifest_id) DO UPDATE SET
order_number = excluded.order_number,
order_date = excluded.order_date,
created_at = excluded.created_at,
entity_id = excluded.entity_id,
customer_name = excluded.customer_name,
positions = excluded.positions,
gross_total = excluded.gross_total,
paid_total = excluded.paid_total,
ordered_quantity = excluded.ordered_quantity,
shipped_quantity = excluded.shipped_quantity,
is_paid = excluded.is_paid,
is_shipped = excluded.is_shipped
`;
const SQL_UPSERT_SUMMARY_MSSQL = `
MERGE INTO ordersummary AS target
USING (SELECT @manifestId AS manifest_id) AS source
ON (target.manifest_id = source.manifest_id)
WHEN MATCHED THEN
UPDATE SET
order_number = @orderNumber,
order_date = @orderDate,
created_at = @createdAt,
entity_id = @entityId,
customer_name = @customerName,
positions = @positions,
gross_total = @grossTotal,
paid_total = @paidTotal,
ordered_quantity = @orderedQuantity,
shipped_quantity = @shippedQuantity,
is_paid = @isPaid,
is_shipped = @isShipped
WHEN NOT MATCHED THEN
INSERT (
company_id, manifest_id, order_number, order_date, created_at,
entity_id, customer_name, positions, gross_total, paid_total,
ordered_quantity, shipped_quantity, is_paid, is_shipped
) VALUES (
@companyId, @manifestId, @orderNumber, @orderDate, @createdAt,
@entityId, @customerName, @positions, @grossTotal, @paidTotal,
@orderedQuantity, @shippedQuantity, @isPaid, @isShipped
);
`;
/**
* Recalculates and upserts a single order summary into `ordersummary`.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} [opts.manifestId] - Order manifest id.
* @param {string} [opts.orderNumber] - Order number (if manifestId not given).
*/
export async function syncOrderSummary(runner, { companyId, manifestId, orderNumber }) {
const isSqlite = runner.dialect === 'sqlite3';
let orderRow;
if (manifestId) {
const res = await runner.query(
isSqlite ? SQL_GET_ORDER_BY_ID_SQLITE : SQL_GET_ORDER_BY_ID_MSSQL,
{ manifestId, companyId },
);
orderRow = res.rows[0];
} else if (orderNumber) {
const res = await runner.query(
isSqlite ? SQL_GET_ORDER_BY_NUMBER_SQLITE : SQL_GET_ORDER_BY_NUMBER_MSSQL,
{ number: orderNumber, companyId },
);
orderRow = res.rows[0];
}
if (!orderRow) {
if (manifestId) {
await runner.query('DELETE FROM ordersummary WHERE manifest_id = @manifestId', { manifestId });
}
return;
}
const orderId = orderRow.id;
const num = orderRow.number;
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const [lineRes, payRes, shipRes] = await Promise.all([
runner.query(SQL_LINE_TOTALS, { manifestId: orderId }),
runner.query(SQL_PAYMENT_TOTAL, { manifestId: orderId }),
runner.query(isSqlite ? SQL_SHIPPED_QTY_SQLITE : SQL_SHIPPED_QTY_MSSQL, {
companyId,
orderNumber: num,
shipmentPrefix,
}),
]);
let customerName = null;
if (orderRow.entity_id) {
const custRes = await runner.query(
isSqlite ? SQL_CUSTOMER_NAME_SQLITE : SQL_CUSTOMER_NAME_MSSQL,
{ entityId: orderRow.entity_id },
);
customerName = custRes.rows[0]?.customer_name || null;
}
const positions = Number(lineRes.rows[0]?.positions || 0);
const orderedQuantity = Math.round(Number(lineRes.rows[0]?.ordered_quantity || 0) * 1000) / 1000;
const grossTotal = Math.round(Number(lineRes.rows[0]?.gross_total || 0) * 100) / 100;
const paidTotal = Math.round(Number(payRes.rows[0]?.paid_total || 0) * 100) / 100;
const shippedQuantity = Math.round(Number(shipRes.rows[0]?.shipped_quantity || 0) * 1000) / 1000;
const isPaid = grossTotal > 0 && Math.abs(paidTotal - grossTotal) < 0.01 ? 1 : 0;
const isShipped = orderedQuantity > 0 && Math.abs(shippedQuantity - orderedQuantity) < 0.001 ? 1 : 0;
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
companyId,
manifestId: orderId,
orderNumber: num,
orderDate: orderRow.date,
createdAt: orderRow.created_at,
entityId: orderRow.entity_id ?? null,
customerName,
positions,
grossTotal,
paidTotal,
orderedQuantity,
shippedQuantity,
isPaid,
isShipped,
});
}
/**
* Full rebuild of all orders in `ordersummary` for a given company.
*
* @param {object} runner
* @param {number} companyId
*/
export async function rebuildOrderSummaries(runner, companyId) {
const isSqlite = runner.dialect === 'sqlite3';
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
if (isSqlite) {
const sqlRebuildSqlite = `
WITH
order_articles AS (
SELECT
manifest_id,
COUNT(*) AS positions,
SUM(amount) AS ordered_quantity,
SUM(amount * (net * (1.0 - rebate / 100.0)) * (1.0 + taxrate / 100.0)) AS gross_total
FROM manifestarticle
GROUP BY manifest_id
),
order_payments AS (
SELECT
manifest_id,
SUM(amount) AS paid_total
FROM payment
GROUP BY manifest_id
),
shipment_totals AS (
SELECT
SUBSTR(m_s."number", 1, INSTR(m_s."number", @shipmentPrefix) - 1) AS order_number,
SUM(ma_s.amount) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s."type" = 'shipment'
AND INSTR(m_s."number", @shipmentPrefix) > 0
GROUP BY SUBSTR(m_s."number", 1, INSTR(m_s."number", @shipmentPrefix) - 1)
)
INSERT INTO ordersummary (
company_id, manifest_id, order_number, order_date, created_at,
entity_id, customer_name, positions, gross_total, paid_total,
ordered_quantity, shipped_quantity, is_paid, is_shipped
)
SELECT
m.company_id,
m.id AS manifest_id,
m."number" AS order_number,
m."date" AS order_date,
m.created_at,
m.entity_id,
TRIM(COALESCE(e.first_name || ' ', '') || COALESCE(e.name, '')) AS customer_name,
COALESCE(oa.positions, 0) AS positions,
ROUND(COALESCE(oa.gross_total, 0), 2) AS gross_total,
ROUND(COALESCE(op.paid_total, 0), 2) AS paid_total,
ROUND(COALESCE(oa.ordered_quantity, 0), 3) AS ordered_quantity,
ROUND(COALESCE(st.shipped_quantity, 0), 3) AS shipped_quantity,
CASE WHEN COALESCE(oa.gross_total, 0) > 0 AND ABS(COALESCE(op.paid_total, 0) - oa.gross_total) < 0.01 THEN 1 ELSE 0 END AS is_paid,
CASE WHEN COALESCE(oa.ordered_quantity, 0) > 0 AND ABS(COALESCE(st.shipped_quantity, 0) - oa.ordered_quantity) < 0.001 THEN 1 ELSE 0 END AS is_shipped
FROM manifest m
LEFT JOIN entity e ON e.id = m.entity_id
LEFT JOIN order_articles oa ON oa.manifest_id = m.id
LEFT JOIN order_payments op ON op.manifest_id = m.id
LEFT JOIN shipment_totals st ON st.order_number = m."number"
WHERE m.company_id = @companyId AND m."type" = 'order'
ON CONFLICT(manifest_id) DO UPDATE SET
order_number = excluded.order_number,
order_date = excluded.order_date,
created_at = excluded.created_at,
entity_id = excluded.entity_id,
customer_name = excluded.customer_name,
positions = excluded.positions,
gross_total = excluded.gross_total,
paid_total = excluded.paid_total,
ordered_quantity = excluded.ordered_quantity,
shipped_quantity = excluded.shipped_quantity,
is_paid = excluded.is_paid,
is_shipped = excluded.is_shipped;
`;
await runner.query(sqlRebuildSqlite, { companyId, shipmentPrefix });
} else {
await runner.query('DELETE FROM ordersummary WHERE company_id = @companyId', { companyId });
const sqlRebuildMssql = `
WITH
order_articles AS (
SELECT
manifest_id,
COUNT(*) AS positions,
SUM(amount) AS ordered_quantity,
SUM(amount * (net * (1.0 - rebate / 100.0)) * (1.0 + taxrate / 100.0)) AS gross_total
FROM manifestarticle
GROUP BY manifest_id
),
order_payments AS (
SELECT
manifest_id,
SUM(amount) AS paid_total
FROM payment
GROUP BY manifest_id
),
shipment_totals AS (
SELECT
SUBSTRING(m_s.[number], 1, CHARINDEX(@shipmentPrefix, m_s.[number]) - 1) AS order_number,
SUM(ma_s.amount) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s.[type] = 'shipment'
AND CHARINDEX(@shipmentPrefix, m_s.[number]) > 0
GROUP BY SUBSTRING(m_s.[number], 1, CHARINDEX(@shipmentPrefix, m_s.[number]) - 1)
)
INSERT INTO ordersummary (
company_id, manifest_id, order_number, order_date, created_at,
entity_id, customer_name, positions, gross_total, paid_total,
ordered_quantity, shipped_quantity, is_paid, is_shipped
)
SELECT
m.company_id,
m.id AS manifest_id,
m.[number] AS order_number,
m.[date] AS order_date,
m.created_at,
m.entity_id,
LTRIM(RTRIM(ISNULL(e.first_name + ' ', '') + ISNULL(e.name, ''))) AS customer_name,
COALESCE(oa.positions, 0) AS positions,
ROUND(COALESCE(oa.gross_total, 0), 2) AS gross_total,
ROUND(COALESCE(op.paid_total, 0), 2) AS paid_total,
ROUND(COALESCE(oa.ordered_quantity, 0), 3) AS ordered_quantity,
ROUND(COALESCE(st.shipped_quantity, 0), 3) AS shipped_quantity,
CASE WHEN COALESCE(oa.gross_total, 0) > 0 AND ABS(COALESCE(op.paid_total, 0) - oa.gross_total) < 0.01 THEN 1 ELSE 0 END AS is_paid,
CASE WHEN COALESCE(oa.ordered_quantity, 0) > 0 AND ABS(COALESCE(st.shipped_quantity, 0) - oa.ordered_quantity) < 0.001 THEN 1 ELSE 0 END AS is_shipped
FROM manifest m
LEFT JOIN entity e ON e.id = m.entity_id
LEFT JOIN order_articles oa ON oa.manifest_id = m.id
LEFT JOIN order_payments op ON op.manifest_id = m.id
LEFT JOIN shipment_totals st ON st.order_number = m.[number]
WHERE m.company_id = @companyId AND m.[type] = 'order';
`;
await runner.query(sqlRebuildMssql, { companyId, shipmentPrefix });
}
}

View File

@@ -14,6 +14,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js';
import { syncOrderSummary } from './syncSummary.js';
import makeRunner from '../runner.js';
export default function update(provider) {
@@ -146,6 +147,7 @@ export default function update(provider) {
}
const doc = await loadDoc(runner, { type: orderType, companyId, id });
await syncOrderSummary(runner, { companyId, manifestId: id });
return doc;
};

View File

@@ -1,6 +1,7 @@
import { queriesFor, requireId, requireText } from '../logistics/helpers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveManifestAccess from '../manifestattachment/resolveManifestAccess.js';
import { syncOrderSummary } from '../order/syncSummary.js';
import makeRunner from '../runner.js';
function shape(row) {
@@ -64,6 +65,7 @@ export default function create(provider) {
type,
amount: input.amount,
};
await syncOrderSummary(runner, { companyId, manifestId });
return shape(row);
};

View File

@@ -1,5 +1,6 @@
import { queriesFor, requireId } from '../logistics/helpers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { syncOrderSummary } from '../order/syncSummary.js';
import makeRunner from '../runner.js';
/**
@@ -21,6 +22,17 @@ export default function del(provider) {
const paymentId = requireId(id, 'Payment');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const paymentRow = await runner.query(
'SELECT manifest_id FROM payment WHERE id = @id',
{ id: paymentId },
);
const manifestId = paymentRow.rows[0]?.manifest_id;
await runner.query(q.PAYMENT_DELETE, { id: paymentId, companyId });
if (manifestId) {
await syncOrderSummary(runner, { companyId, manifestId });
}
};
}

View File

@@ -12,21 +12,28 @@
* @returns {((sql: string, params?: object) => Promise<import('../providers/provider.js').QueryResult>) & { query: Function, dialect?: string }}
*/
import { logSlowQuery } from '../providers/provider.js';
/** Wraps a raw mssql pool/transaction in the provider query contract. */
function wrapMssql(target) {
return {
dialect: 'mssql',
async query(sql, params = {}) {
const req = target.request();
for (const [key, value] of Object.entries(params)) {
req.input(key, value);
const start = performance.now();
try {
const req = target.request();
for (const [key, value] of Object.entries(params)) {
req.input(key, value);
}
const result = await req.query(sql);
const rows = result.recordset ?? [];
const rowsAffected = Array.isArray(result.rowsAffected)
? result.rowsAffected.reduce((a, b) => a + b, 0)
: 0;
return { rows, rowsAffected, insertId: null };
} finally {
logSlowQuery(sql, performance.now() - start, params);
}
const result = await req.query(sql);
const rows = result.recordset ?? [];
const rowsAffected = Array.isArray(result.rowsAffected)
? result.rowsAffected.reduce((a, b) => a + b, 0)
: 0;
return { rows, rowsAffected, insertId: null };
},
};
}

View File

@@ -0,0 +1,198 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, parseDate } from '../manifestLines.js';
import makeRunner from '../runner.js';
import { loadShipment, queriesFor } from './helpers.js';
import { syncOrderSummary } from '../order/syncSummary.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Creates a new shipment manifest for an order.
* Validates that all article positions belong to the order and do not exceed remaining ordered quantities.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(provider);
const q = queriesFor(provider);
/**
* Creates a shipment manifest for an order.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The shipment input.
* @param {number} input.orderId - The parent order manifest id.
* @param {string|Date} [input.date] - Optional shipment date (defaults to current date).
* @param {string} [input.externalNumber] - Optional external delivery note number.
* @param {Array<{sku: string, amount: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created shipment manifest.
* @throws {Error} If order not found, invalid lines, or article ceiling invariant exceeded.
*
* @example
* const shipment = await store.shipment.create(googleAuthId, {
* orderId: 10,
* lines: [{ sku: 'ITEM-A', amount: 5 }]
* });
*/
return async (googleAuthId, input = {}, tx) => {
const orderId = requireId(input.orderId, 'Order');
if (!Array.isArray(input.lines) || input.lines.length === 0) {
throw new Error('Invalid lines');
}
const shipmentDate = parseDate(input.date) || new Date();
const externalNumber = input.externalNumber?.trim?.()
? input.externalNumber.trim()
: null;
const run = async (runner, innerTx) => {
const { companyId } = await access(googleAuthId, runner);
// 1. Retrieve parent order header
const orderRes = await runner.query(q.GET_ORDER_HEADER, {
orderId,
companyId,
});
const order = orderRes.rows[0];
if (!order) {
throw new Error('Order not found');
}
// 2. Retrieve parent order lines
const orderLinesRes = await runner.query(q.GET_ORDER_LINES, {
orderId,
});
const orderLines = orderLinesRes.rows ?? [];
const orderAmountBySku = new Map();
const orderLineBySku = new Map();
for (const ol of orderLines) {
const sku = String(ol.sku ?? '').trim();
if (!sku) continue;
orderAmountBySku.set(sku, (orderAmountBySku.get(sku) || 0) + Number(ol.amount || 0));
if (!orderLineBySku.has(sku)) {
orderLineBySku.set(sku, ol);
}
}
// 3. Validate proposed lines SKUs and amounts
const proposedBySku = new Map();
for (const line of input.lines) {
const sku = String(line?.sku ?? '').trim();
const amount = Number(line?.amount);
if (!sku || !orderAmountBySku.has(sku)) {
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
}
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error('Invalid lines');
}
proposedBySku.set(sku, (proposedBySku.get(sku) || 0) + amount);
}
// 4. Retrieve shipment prefix from numbers table
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
// 5. Retrieve already shipped amounts across existing shipment manifests of this order
const pattern = `${order.number}${shipmentPrefix}%`;
const shippedRes = await runner.query(q.GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN, {
companyId,
pattern,
excludeShipmentId: null,
});
const shippedAmountBySku = new Map();
for (const row of shippedRes.rows ?? []) {
const sku = String(row.sku ?? '').trim();
shippedAmountBySku.set(sku, Number(row.shippedAmount || 0));
}
// 6. Quantity ceiling check
for (const [sku, proposedAmount] of proposedBySku.entries()) {
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
const ordered = orderAmountBySku.get(sku) || 0;
if (alreadyShipped + proposedAmount > ordered) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
}
}
// 7. Resolve next index <y>
const existingShipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
companyId,
pattern,
});
const existingShipments = existingShipmentsRes.rows ?? [];
let maxY = 0;
const prefixExact = `${order.number}${shipmentPrefix}`;
for (const s of existingShipments) {
const num = String(s.number || '');
if (num.startsWith(prefixExact)) {
const suffix = parseInt(num.slice(prefixExact.length), 10);
if (Number.isInteger(suffix) && suffix > maxY) {
maxY = suffix;
}
}
}
const nextY = Math.max(existingShipments.length + 1, maxY + 1);
const shipmentNumber = `${order.number}${shipmentPrefix}${nextY}`;
// 8. Create shipment manifest header
const createdRes = await runner.query(q.CREATE_MANIFEST, {
companyId,
number: shipmentNumber,
type: 'shipment',
entityId: order.entityId ?? null,
externalNumber,
date: shipmentDate,
createdAt: new Date(),
});
const manifestId = createdRes.rows[0]?.id ?? createdRes.insertId;
// 9. Prepare lines copying defaults from order lines
const linesToInsert = input.lines.map((line) => {
const sku = String(line.sku).trim();
const ol = orderLineBySku.get(sku);
return {
sku,
name: line.name ?? ol?.name ?? sku,
pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1),
amount: Number(line.amount),
net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0),
rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0),
taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0),
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
};
});
await insertLines(runner, {
manifestId,
lines: linesToInsert,
ensureTax,
});
await syncOrderSummary(runner, { companyId, manifestId: orderId });
return loadShipment(runner, q, companyId, manifestId);
};
if (tx) {
return run(makeRunner(provider, tx), tx);
}
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, innerTx) => run(runner, innerTx));
}
return run(makeRunner(provider));
};
}

View File

@@ -0,0 +1,71 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { queriesFor } from './helpers.js';
import { syncOrderSummary } from '../order/syncSummary.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Deletes a shipment manifest of the selected company.
* Shipped shipments (with packages in 'shipped' state) cannot be deleted.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
/**
* Deletes a shipment manifest.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The shipment manifest ID.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If shipment not found or already has shipped packages.
*
* @example
* await store.shipment.delete(googleAuthId, 42);
*/
return async (googleAuthId, id, tx) => {
const shipmentId = requireId(id, 'Shipment');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
id: shipmentId,
companyId,
});
if (!check.rows[0]) {
throw new Error('Shipment not found');
}
const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, {
id: shipmentId,
});
if (shippedCheck.rows[0]) {
throw new Error('Shipped shipment cannot be deleted');
}
const shipmentNumber = String(check.rows[0].number || '');
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const pIdx = shipmentNumber.indexOf(shipmentPrefix);
const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null;
await runner.query(q.DELETE_SHIPMENT, {
id: shipmentId,
companyId,
});
if (orderNumber) {
await syncOrderSummary(runner, { companyId, orderNumber });
}
};
}

View File

@@ -0,0 +1,46 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { loadShipment, queriesFor } from './helpers.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Retrieves a shipment manifest by ID for the selected company, including lines and packages.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function getById(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
/**
* Retrieves a shipment manifest by ID.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The shipment manifest ID.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The shipment manifest.
* @throws {Error} If shipment not found.
*
* @example
* const shipment = await store.shipment.getById(googleAuthId, 42);
*/
return async (googleAuthId, id, tx) => {
const shipmentId = requireId(id, 'Shipment');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const shipment = await loadShipment(runner, q, companyId, shipmentId);
if (!shipment) {
throw new Error('Shipment not found');
}
return shipment;
};
}

Some files were not shown because too many files have changed in this diff Show More