Compare commits

...

177 Commits

Author SHA1 Message Date
seb
26f21cd7ac u 2026-11-02 00:51:18 +01:00
seb
d0a33b1f82 u 2026-11-02 00:19:21 +01:00
seb
55f04de8a8 u 2026-10-31 21:46:43 +01:00
seb
76ea6642f4 u 2026-10-31 21:02:50 +01:00
seb
f612c87b6e u 2026-10-31 20:28:39 +01:00
seb
c7d1b1a5d2 u 2026-10-30 18:09:33 +01:00
seb
745d0d0077 u 2026-10-30 18:06:03 +01:00
seb
2e00f4adb3 u 2026-10-30 17:28:22 +01:00
seb
60cf5873cd u 2026-10-30 16:56:28 +01:00
seb
211acd651d u 2026-10-30 16:48:36 +01:00
seb
a4718d81c0 u 2026-10-30 15:43:28 +01:00
seb
0fdffb864f u 2026-10-30 06:43:49 +01:00
seb
64050dffaf u 2026-10-30 05:50:41 +01:00
seb
3cb5ec5a7a u 2026-10-29 16:17:38 +01:00
seb
a5cef78d30 u 2026-10-29 03:39:07 +01:00
seb
c7db2262b0 u 2026-10-29 03:29:07 +01:00
seb
0fb5893a70 u 2026-10-29 03:28:39 +01:00
seb
75a83bdc28 u 2026-10-29 03:17:47 +01:00
seb
b0521a4ea6 u 2026-10-29 02:55:51 +01:00
seb
2ded87abb7 u 2026-10-28 18:33:32 +01:00
seb
2d4d894881 sharp by wasm 2026-10-28 01:46:48 +01:00
seb
ccb81cdb26 x 2026-10-27 06:48:35 +00:00
seb
bab5c151d0 u 2026-10-26 19:22:04 +01:00
seb
563945394b u 2026-10-26 01:01:08 +01:00
seb
358e3009b2 u 2026-10-25 22:38:04 +01:00
seb
5fd7f3228c u 2026-10-25 17:13:32 +01:00
seb
53f1b24ec0 u 2026-10-25 16:59:53 +01:00
seb
6b6c473f9d u 2026-10-25 15:46:58 +01:00
seb
c6171a1d90 u 2026-10-25 11:06:03 +00:00
seb
62d0643b99 u 2026-10-24 06:19:37 +02:00
seb
1da82e2eb9 u 2026-10-24 01:24:25 +02:00
seb
feffe4e1ab u 2026-10-23 23:18:31 +02:00
seb
c172f2f4b1 u 2026-10-23 22:22:32 +02:00
seb
82ab27ef2b u 2026-10-23 20:31:39 +02:00
seb
8002ca56e6 u 2026-10-23 04:59:04 +02:00
seb
8a085986e5 u 2026-10-23 02:43:35 +02:00
seb
919713fa53 u 2026-10-23 01:38:08 +02:00
seb
5fc316461d u 2026-10-23 00:47:38 +02:00
seb
e31566e6ee u 2026-10-22 23:33:00 +02:00
seb
d6e5da93cb u 2026-10-22 23:30:15 +02:00
seb
e9b4ac5d79 u 2026-10-22 14:39:45 +02:00
seb
9495ad51cc u 2026-10-22 12:30:06 +02:00
seb
bb880e3c84 u 2026-10-21 11:59:41 +02:00
seb
eb1e9e2eb9 u 2026-10-21 11:55:21 +02:00
seb
e085bcaeba u 2026-10-21 06:13:29 +02:00
seb
b0014d6629 u 2026-10-21 04:28:56 +02:00
seb
e9e98d041c u 2026-10-21 03:31:52 +02:00
seb
e3761bc48b u 2026-10-20 23:19:43 +02:00
seb
813e7c89b8 u 2026-10-20 23:16:47 +02:00
seb
bcefd81af7 u 2026-10-20 05:33:11 +02:00
seb
5cb38874ca u 2026-10-20 02:22:58 +02:00
seb
4b34902b92 u 2026-10-19 23:56:56 +02:00
seb
a5eee3f3d0 u 2026-10-19 13:24:59 +02:00
seb
5259e3bef9 u 2026-10-19 02:28:15 +02:00
seb
cce966d021 u 2026-10-19 01:32:42 +02:00
seb
c730244902 u 2026-10-18 20:54:00 +02:00
seb
9ed7e4ee3b u 2026-10-18 20:48:47 +02:00
seb
8cc7d0eb0b u 2026-10-18 20:29:51 +02:00
seb
f6b0ff7483 u 2026-10-18 14:51:33 +02:00
seb
d587f31953 u 2026-10-18 14:44:22 +02:00
seb
9e1edd5cad u 2026-10-18 14:39:03 +02:00
seb
da2cdd5b23 u 2026-10-18 14:20:35 +02:00
seb
bf4109d8ec u 2026-10-18 14:07:01 +02:00
seb
97c909d10b u 2026-10-18 03:57:53 +02:00
seb
c687553eea u 2026-10-18 03:15:20 +02:00
seb
fda895f75a u 2026-10-17 23:59:26 +02:00
seb
ce5da810a5 u 2026-10-17 23:34:29 +02:00
seb
3c2b933f93 u 2026-10-17 23:32:42 +02:00
seb
688fa4f3fd u 2026-10-17 23:31:37 +02:00
seb
ab42a3ffb4 u 2026-10-17 19:03:24 +02:00
seb
f8cc01baf0 u 2026-10-17 05:10:28 +02:00
seb
a194cf6b8b u 2026-10-17 02:59:37 +02:00
seb
59395f95d8 u 2026-10-17 02:47:20 +02:00
seb
8ee080b744 u 2026-10-17 00:52:45 +02:00
seb
e16c3b3f67 u 2026-10-16 22:41:03 +02:00
seb
c7d709de1f u 2026-10-16 22:34:48 +02:00
seb
7635821dad u 2026-10-16 22:09:12 +02:00
seb
796f6a3272 u 2026-10-16 12:35:07 +02:00
seb
70e3ae7a87 u 2026-10-16 02:31:54 +02:00
seb
fcbcb243e3 u 2026-10-16 01:59:11 +02:00
seb
0d726c76f8 u 2026-10-15 22:49:14 +02:00
seb
2179b2e241 u 2026-10-15 20:14:51 +02:00
seb
7c8c24eb4d u 2026-10-15 15:54:59 +02:00
seb
f38d1f11a6 u 2026-10-14 22:48:00 +02:00
seb
b718cdb047 u 2026-10-14 13:31:23 +02:00
seb
3256ab233d u 2026-10-13 22:23:45 +02:00
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
seb
3d6bc97411 u 2026-10-12 13:52:16 +02:00
seb
c614a67b97 u 2026-10-12 13:11:33 +02:00
seb
17cb4f4c72 u 2026-10-11 18:55:08 +02:00
seb
7f4092971d u 2026-10-11 17:26:11 +02:00
seb
7ffb21aabb u 2026-10-11 03:30:26 +02:00
seb
cf1c8d1126 u 2026-10-10 02:14:03 +02:00
seb
906ebafbbe u 2026-10-09 21:43:20 +02:00
seb
ba376686f3 u 2026-10-09 21:37:42 +02:00
seb
00cf5b418e u 2026-10-09 20:05:25 +02:00
seb
f840a12b78 u 2026-10-09 19:02:19 +02:00
seb
178055f1d8 u 2026-10-09 18:26:01 +02:00
seb
07c4f0bff9 u 2026-10-09 18:10:51 +02:00
seb
4ae3439289 u 2026-10-09 17:55:47 +02:00
seb
f3674edcb7 u 2026-10-09 10:41:17 +02:00
seb
760f074b4e u 2026-10-08 21:50:38 +02:00
seb
d36c2827cc u 2026-10-08 16:11:44 +02:00
seb
46ea04c0cb u 2026-10-08 12:59:10 +02:00
seb
b51149c7f0 u 2026-10-08 12:46:55 +02:00
seb
4e308cc177 i 2026-10-08 03:02:56 +02:00
seb
69eb2c9a0d u 2026-10-08 01:37:26 +02:00
seb
36dd81ee69 u 2026-10-07 21:23:03 +02:00
seb
e02bbd65c5 u 2026-10-07 16:46:41 +02:00
seb
95ef6f2dd1 u 2026-10-06 17:57:52 +02:00
seb
86ce3a3316 u 2026-10-05 23:57:01 +02:00
seb
191a0b29e2 u 2026-10-05 19:04:48 +02:00
seb
4718cb5726 u 2026-10-04 21:22:05 +02:00
seb
0268583ae3 u 2026-10-04 21:22:04 +02:00
seb
c80bfee8e8 u 2026-10-04 19:38:12 +02:00
seb
067ddc088c u 2026-10-04 19:12:30 +02:00
seb
2158ac475b u 2026-10-04 18:56:55 +02:00
seb
9a1c19cf60 u 2026-10-04 16:22:23 +02:00
seb
54398d90f2 u 2026-10-04 11:25:14 +02:00
seb
89fe6e6451 u 2026-10-04 09:33:27 +02:00
seb
3b74c12f4e u 2026-10-03 23:13:40 +02:00
seb
6520665dcf u 2026-10-01 22:17:19 +02:00
seb
397ae00735 u 2026-10-01 21:28:27 +02:00
seb
856d5419a1 u 2026-10-01 21:15:55 +02:00
seb
84e547660e u 2026-10-01 17:40:04 +02:00
seb
62c3e69ed4 u 2026-10-01 07:12:39 +02:00
seb
9c770d765e u 2026-09-30 23:11:04 +02:00
seb
74ed74fc68 u 2026-09-30 22:37:40 +02:00
seb
3d2f8e9296 u 2026-09-30 21:56:37 +02:00
seb
bc467e29da u 2026-09-30 21:27:27 +02:00
seb
ed91ccbaee u 2026-09-30 21:12:32 +02:00
seb
69d172d49b u 2026-09-30 20:12:33 +02:00
seb
894f72eba5 u 2026-09-30 17:35:22 +02:00
seb
280a45a11f u 2026-09-30 09:48:01 +02:00
seb
9885c489f0 u 2026-09-29 20:10:49 +02:00
seb
23d6742a9f u 2026-09-29 18:48:15 +02:00
seb
69bd0935cc u 2026-09-29 18:26:42 +02:00
seb
30820ba8c7 u 2026-09-29 17:29:13 +02:00
seb
ae5d5cc6f8 u 2026-09-29 16:16:05 +02:00
seb
26d2f83d64 u 2026-09-29 13:44:28 +02:00
seb
4bc99b3fdb u 2026-09-29 11:37:29 +02:00
seb
6d3e328126 u 2026-09-29 11:31:37 +02:00
seb
7bb674d5e7 u 2026-09-29 11:21:57 +02:00
seb
5d2c8fdfed u 2026-09-28 06:50:10 +02:00
seb
87da854de4 u 2026-09-27 22:01:56 +02:00
seb
0cafa11a32 u 2026-09-27 21:01:30 +02:00
seb
f9c570a036 u 2026-09-27 18:32:27 +02:00
seb
516feb51d4 u 2026-09-27 16:47:37 +02:00
seb
cd57f21af8 u 2026-09-27 16:19:45 +02:00
seb
e7f92ebef8 u 2026-09-26 23:06:05 +02:00
seb
e491ecfa47 u 2026-09-26 07:34:37 +02:00
seb
607e6cc0ac u 2026-09-25 12:30:45 +02:00
seb
b5255bdd17 u 2026-09-25 11:34:00 +02:00
seb
387147ccf3 u 2026-09-25 07:27:22 +02:00
seb
f8f61a1212 u 2026-09-24 23:26:25 +02:00
seb
075b1f0ddc u 2026-09-22 08:35:36 +02:00
seb
dce65db435 bench 2026-09-22 08:31:01 +02:00
seb
cca131d83a sqlite 2026-09-22 07:25:16 +02:00
seb
4ed6260e91 providerrefactor part1 2026-09-21 03:33:55 +02:00
seb
53a20c4353 refactor: introduce database provider abstraction, isolate MSSQL layer
- Add provider contract (src/providers/provider.js): query() -> {rows,
  rowsAffected, insertId}, callback transaction(fn), exec() for stored
  procs, initialize/purge lifecycle
- Add MssqlProvider (src/providers/mssql/index.js) wrapping an mssql
  ConnectionPool with normalized results, tx commit/rollback and
  constraint-error classifiers (unique 2627/2601, FK 547)
- LibDashStore constructor now takes a provider instead of a pool
  (breaking); adds LibDashStore.create() async factory; keeps pool as
  transitional bridge until store modules are converted (see TODO.md)
- Move MSSQL-specific code into the provider: src/sql/* ->
  src/providers/mssql/queries/*, src/schema.js ->
  src/providers/mssql/schema.js, purge SQL -> purgeSql.js
- Update scripts, generate-schema-graph.js and package.json exports
  (./providers/mssql, ./providers/provider)
- Add TODO.md with the remaining provider/SQLite migration plan

No SQL or behavioral changes.
2026-09-20 13:51:01 +02:00
seb
67b3f8b6bf u 2026-09-20 09:21:29 +02:00
seb
8a6f724ec5 u 2026-09-20 07:46:07 +02:00
seb
8bc1def4ef u 2026-09-20 07:41:44 +02:00
seb
b20803eda0 mssql 2026-09-20 07:23:26 +02:00
seb
a9460e886d u 2026-09-20 06:11:59 +02:00
seb
68372055fa pdf convert wasm only 2026-09-20 04:09:22 +02:00
seb
9d88823167 thumb 2026-09-19 06:33:45 +02:00
seb
b69110f1c2 fllabcks hmmm 2026-09-18 08:28:44 +02:00
657 changed files with 59937 additions and 8708 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/.env /.env
/node_modules /node_modules
/output

32
AGENTS.md Normal file
View File

@@ -0,0 +1,32 @@
# AGENTS.md — dashdbschema (`libdashstore`)
MSSQL schema + domain store API. ESM (`type: module`). One class `LibDashStore`; methods mirror `src/store/<domain>/<action>.js`.
## Architecture
- Entry: `src/index.js` wires namespaces (`article`, `category`, `company`, `delivery`, `order`, `quote`, `ledger`, `stock`, `numbers`, `warehouse`, …).
- Provider contract: `src/providers/provider.js` (`query`, `transaction`, `initialize`, `purge`). `src/providers/mssql/` production (raw SQL in `queries/<domain>.js`, DDL in `schema.js`, `OUTPUT INSERTED.*`, `TOP 1`, `WITH (UPDLOCK, HOLDLOCK)`); `src/providers/sqlite3/` dev/test (`better-sqlite3`, re-exports mssql queries via `translateQueries`, `RETURNING`/`LIMIT 1`).
- Store convention: each module `export default function <action>(provider) => async (googleAuthId, input, tx)`. `googleAuthId` is always `google_auth.id`. Company scoping via `resolveSelectedCompanyAccess` (`selected_company`) — no `companyId` arg except `setSelectedCompany`, `companyuser.add`, `company.delete`.
- Ledger is append-only + hash-chained (`store/ledger/add.js`); stock is derived (`SUM(ledger.amount)` filtered by company, matched via `article.sku` or `articlesupplier.sku`). `store/stock/adjust.js` writes implicit `adjustment` manifest + ledger + `articlehistory`. `track_stock==0` blocks warehouse-scoped writes (`Stock tracking is disabled…`).
- Errors are plain `Error` with stable strings; HTTP mapping lives in dashback route `*ErrorStatus()`. Keep messages greppable.
## Build / Test
```bash
npm install
npm run reset-db # purge + init → fresh DB (source of truth)
npm run api-doc # regenerates API.md + API.<module>.md from JSDoc
node scripts/test-ledger.js # sqlite temp-DB regression (also: test-stueckliste-stock, test-variant-flow, test-sqlite-storage, …)
```
- Never run `scripts/test-api.js` for verification — interactive `@clack/prompts` menu (MSSQL), hangs.
- Schema changes go directly in DDL (`CREATE TABLE … IF NOT EXISTS`); no migration/upgrade paths in code. Shared-DB fixes are manual `sqlcmd` only. See `.cursor/rules/no-schema-migrations.mdc`.
- After store changes run `npm run api-doc`; edit JSDoc source, never the generated `API.*.md`.
## Conventions
- Sibling repos (`../dashback`, `../dashfront`) are **read-only**: may read docs/code + `git -C ../<repo> log --oneline -20`, never write/install/start. Missing HTTP/UI contract → ask human, don't guess. No fallbacks. See `.cursor/rules/sibling-repos.mdc`.
- Touching SQL → update **both** `mssql/queries/*` and check `sqlite3/queries/*` translation (`OUTPUT INSERTED` vs `RETURNING`, `TOP 1` vs `LIMIT 1`).
- Stock invariants belong in `src/store/` (covers both `stock.adjust` and direct `ledger.add`); mirror new error strings in dashback route mappers via human.
- Contracts: `README.md` (conventions), `API.md` (index), `src/store/` (implementation).
- Full conventions: `/home/seb/src/dashback/.agents/skills/project-overview/SKILL.md`.

84
API.account.md Normal file
View File

@@ -0,0 +1,84 @@
# account
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `account.create`
*Source: `src/store/account/create.js`*
Creates an account for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Account input. |
| `input.type` | `string` | Account type. |
| `input.name` | `string` | Account name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, type: string, name: string}>`
**Throws**
`Error` — If type or name is missing.
**Example**
```js
const account = await store.account.create(googleAuthId, { type: 'bank', name: 'Main' });
```
## `account.delete`
*Source: `src/store/account/delete.js`*
Deletes an account of the user's selected company. Its payments are removed by cascade.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The account id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If the account id is invalid.
**Example**
```js
await store.account.delete(googleAuthId, 3);
```
## `account.list`
*Source: `src/store/account/list.js`*
Lists accounts for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, companyId: number, type: string, name: string}>>`
**Example**
```js
const accounts = await store.account.list(googleAuthId);
```

119
API.agentprinter.md Normal file
View File

@@ -0,0 +1,119 @@
# agentprinter
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `agentprinter.delete`
*Source: `src/store/agentprinter/delete.js`*
Deletes an agent printer by id for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The agent printer id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number}>`
## `agentprinter.findOrCreate`
*Source: `src/store/agentprinter/findOrCreate.js`*
Finds or creates an agentprinter by (printeragentId, type, model, serial) combo.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `printeragentId` | `number` | The parent printer agent id. |
| `input` | `object` | |
| `input.type` | `string` | Printer type ("zpl", "esc/pos", etc.). |
| `[input.model]` | `string|null` | Discovered model name. |
| `[input.serial]` | `string|null` | Discovered serial number. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{created: boolean, agentprinter: {id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}}>`
## `agentprinter.getById`
*Source: `src/store/agentprinter/getById.js`*
Retrieves an agent printer by id, scoped to the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The agent printer id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}|null>`
## `agentprinter.list`
*Source: `src/store/agentprinter/list.js`*
Lists agent printers of the user's selected company, optionally filtered by printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[printeragentId]` | `number` | Optional printer agent id filter. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>>`
## `agentprinter.order`
*Source: `src/store/agentprinter/order.js`*
Reorders agent printers for a given printer agent or globally across the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `[input.printeragentId]` | `number` | Optional printer agent id. If omitted, reorders all printers in company. |
| `input.agentprinterIds` | `number[]` | Complete ordered list of agent printer IDs. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{agentprinterIds: number[]}>`
## `agentprinter.update`
*Source: `src/store/agentprinter/update.js`*
Updates enabled and/or sort of an agent printer for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `input.id` | `number` | Agent printer id. |
| `[input.enabled]` | `boolean` | Whether the printer is enabled. |
| `[input.sort]` | `number` | Display order integer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>`

View File

@@ -2,11 +2,37 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `article.addChild`
*Source: `src/store/article/addChild.js`*
Adds a child article to a parent article (creates and attaches with auto-generated SKU).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `parentSku` | `string` | The parent article SKU. |
| `options` | `object` | |
| `[options.attach=false]` | `boolean` | If true, attaches an existing article instead of creating. |
| `[options.attributeValueId]` | `number` | Existing attribute value ID for parent's attribute. |
| `[options.value]` | `string` | String value to find or create under parent's attribute. |
| `[options.name]` | `string` | Custom name for child article (defaults to parent name + value). |
| `[options.unitamount]` | `number` | Optional unitamount override. |
| `[options.weight]` | `number` | Optional weight override. |
| `[options.track_stock]` | `boolean|number` | Optional track_stock override. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The created child article detail.
## `article.addImage` ## `article.addImage`
*Source: `src/store/article/addImage.js`* *Source: `src/store/article/addImage.js`*
Adds an image to an article by its SKU, processing it into AVIF variants. Adds an image to an article by its SKU, processing full, thumb, and 32px-edge small-thumb AVIF variants.
**Parameters** **Parameters**
@@ -15,18 +41,46 @@ Adds an image to an article by its SKU, processing it into AVIF variants.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `image` | `Buffer` | The source image buffer. | | `image` | `Buffer` | The source image buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>` — The added image info. `Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, smallThumb: {w: number, h: number}, fullHash: string, thumbHash: string}>` — The added image info.
**Example** **Example**
```js ```js
const result = await store.article.addImage(42, 'SKU-1', imageBuffer); const result = await store.article.addImage(42, 'SKU-1', imageBuffer);
// => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash } // => { picId, sort, full: { w, h, size }, thumb: { w, h }, smallThumb: { w, h }, fullHash, thumbHash }
``` ```
## `article.attachChild`
*Source: `src/store/article/attachChild.js`*
Attaches an existing article as a child to a parent article.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `parentSku` | `string` | The parent article SKU. |
| `options` | `object` | |
| `options.sku` | `string` | The child article SKU to attach. |
| `[options.childSku]` | `string` | Alias for options.sku. |
| `[options.attributeValueId]` | `number` | Existing attribute value ID for parent's attribute. |
| `[options.value]` | `string` | String value to find or create under parent's attribute. |
| `[options.reparent=false]` | `boolean` | Whether to allow re-parenting if child belongs to another parent. |
| `[options.unitamount]` | `number` | Optional unitamount override. |
| `[options.weight]` | `number` | Optional weight override. |
| `[options.track_stock]` | `boolean|number` | Optional track_stock override. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{article: object, children: Array<object>}>`
## `article.createUnderParent` ## `article.createUnderParent`
*Source: `src/store/article/createUnderParent.js`* *Source: `src/store/article/createUnderParent.js`*
@@ -41,23 +95,27 @@ Creates a new article under a given parent category, allocating a SKU and a defa
| `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.parentId` | `number` | The id of the parent category. | | `input.parentId` | `number` | The id of the parent category. |
| `input.name` | `string` | The article name. | | `input.name` | `string` | The article name. |
| `[input.desc]` | `string` | The article description text/HTML. |
| `[input.seo]` | `string` | The article SEO text. |
| `[input.track_stock=false]` | `boolean|number` | Whether inventory stock is tracked. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, parentId: number, name: string, sku: string}>` — The created article. `Promise<{id: number, parentId: number, name: string, sku: string, track_stock: boolean}>` — The created article.
**Example** **Example**
```js ```js
const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' }); const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' });
// => { id, parentId, name, sku } // => { id, parentId, name, sku, track_stock }
``` ```
## `article.delete` ## `article.delete`
*Source: `src/store/article/delete.js`* *Source: `src/store/article/delete.js`*
Deletes an article by its SKU, refusing when it still has children. Deletes an article by its SKU, refusing when it still has children unless cascade is true.
**Parameters** **Parameters**
@@ -65,6 +123,8 @@ Deletes an article by its SKU, refusing when it still has children.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `[optionsOrTx]` | `object|*` | Options object ({ cascade?: boolean }) or optional transaction. |
| `[maybeTx]` | `object` | Optional transaction if options was passed. |
**Returns** **Returns**
@@ -72,12 +132,13 @@ Deletes an article by its SKU, refusing when it still has children.
**Throws** **Throws**
`Error` — If the article has children or is not found. `Error` — If the article has children (when cascade=false) or is not found.
**Example** **Example**
```js ```js
await store.article.delete(42, 'SKU-1'); await store.article.delete(42, 'SKU-1');
await store.article.delete(42, 'SKU-1', { cascade: true });
``` ```
## `article.deleteImage` ## `article.deleteImage`
@@ -93,6 +154,7 @@ Deletes an image from an article by its SKU and full image hash.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `fullHash` | `string` | The full image hash to delete. | | `fullHash` | `string` | The full image hash to delete. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -119,11 +181,10 @@ Returns the full detail of an article by its SKU, including unit, brand, categor
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
**Returns** **Returns**
`Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|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, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` — The full article detail. `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, incoming: Array<{date: string, amount: number}>}>, warehouses: Array<{id: number, name: string, sort: number, stock: number}>, undeliveredOrders: Array<{orderId: number, orderNumber: string, date: string, customerName: string|null, amount: number}>, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null}}>` — The full article detail.
**Throws** **Throws**
@@ -148,23 +209,33 @@ Returns the change history of an article by its SKU.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of entries to skip. |
| `[opts.limit=50]` | `number` | Number of entries to return. |
| `[opts.search]` | `string` | Optional search filter for history text. |
| `[opts.seekMonths]` | `number` | Negative integer cutoff months ago. |
| `[opts.paged]` | `boolean` | Explicitly return paged result object. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
`Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>` — The article history entries. `Promise<{history: Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>>` — The article history entries.
**Example** **Example**
```js ```js
const result = await store.article.getHistoryBySku(42, 'SKU-1', { offset: 0, limit: 50, search: 'Tee' });
// => { history: [...], total, unfilteredTotal, offset, limit }
const history = await store.article.getHistoryBySku(42, 'SKU-1'); const history = await store.article.getHistoryBySku(42, 'SKU-1');
// => [{ id, companyuserId, date, text }] // => [{ id, companyuserId, date, text, meta }]
``` ```
## `article.importRows` ## `article.importRows`
*Source: `src/store/article/importRows.js`* *Source: `src/store/article/importRows.js`*
Imports article rows, creating articles by their SKU and returning per-row import, skip and error outcomes. The import runs in **two passes** so a `fatherSku` that references a row appearing later in the file still resolves. A row is **skipped** when its SKU already exists in the company (upsert mode is not applied), and an EAN is skipped when already used by another row. Imports article rows, creating articles by their SKU and returning per-row import, skip and error outcomes. The import runs in **two passes** so a `parentSku` that references a row appearing later in the file still resolves. A row is **skipped** when its SKU already exists in the company (upsert mode is not applied), and an EAN is skipped when already used by another row.
**Parameters** **Parameters**
@@ -175,15 +246,17 @@ Imports article rows, creating articles by their SKU and returning per-row impor
| `input.rows` | `Array<object>` | The article rows to import. | | `input.rows` | `Array<object>` | The article rows to import. |
| `input.rows[].sku` | `string` | The article SKU. | | `input.rows[].sku` | `string` | The article SKU. |
| `input.rows[].name` | `string` | The article name. | | `input.rows[].name` | `string` | The article name. |
| `[input.rows[].vk]` | `string` | `'V'` (father/compound) or `'K'` (variant) marker. | | `[input.rows[].vk]` | `string` | `'V'` (parent/compound) or `'K'` (variant) marker. |
| `[input.rows[].parentId]` | `number|string` | The id of the parent category. | | `[input.rows[].parentId]` | `number|string` | The id of the parent category. |
| `[input.rows[].parentCategory]` | `string` | The category breadcrumb path, e.g. `'Flowers | CBN'`. | | `[input.rows[].parentCategory]` | `string` | The category breadcrumb path, e.g. `'Flowers | CBN'`. |
| `[input.rows[].brand]` | `string` | The brand name to upsert. | | `[input.rows[].brand]` | `string` | The brand name to upsert. |
| `[input.rows[].net]` | `number|string` | The POS net price (accepts German decimals). | | `[input.rows[].net]` | `number|string` | The POS net price (accepts German decimals). |
| `[input.rows[].tax]` | `number|string` | The tax rate in percent (e.g. `19`). | | `[input.rows[].tax]` | `number|string` | The tax rate in percent (e.g. `19`). |
| `[input.rows[].eans]` | `Array<string>|string` | The EANs to set. | | `[input.rows[].eans]` | `Array<string>|string` | The EANs to set. |
| `[input.rows[].fatherSku]` | `string` | The father article SKU for a V/K compound link. | | `[input.rows[].track_stock=false]` | `boolean|number` | Whether inventory stock is tracked. |
| `[input.rows[].parentSku]` | `string` | The parent article SKU for a V/K compound link. |
| `[input.rows[].deposit]` | `number|string` | The deposit (Pfand) value; currently ignored. | | `[input.rows[].deposit]` | `number|string` | The deposit (Pfand) value; currently ignored. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -195,13 +268,31 @@ Imports article rows, creating articles by their SKU and returning per-row impor
const result = await store.article.importRows({ const result = await store.article.importRows({
googleAuthId: 42, googleAuthId: 42,
rows: [ rows: [
{ sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], fatherSku: '42001190' }, { sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], parentSku: '42001190' },
{ sku: '42001190', name: '420Strainz', vk: 'V', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '0,00', tax: 19 }, { sku: '42001190', name: '420Strainz', vk: 'V', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '0,00', tax: 19 },
], ],
}); });
// => { imported: [...], skipped: [...], errors: [...] } // => { imported: [...], skipped: [...], errors: [...] }
``` ```
## `article.listChildren`
*Source: `src/store/article/listChildren.js`*
Lists all children of a parent article.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `parentSku` | `string` | The parent article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`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}>>`
## `article.listExport` ## `article.listExport`
*Source: `src/store/article/listExport.js`* *Source: `src/store/article/listExport.js`*
@@ -214,6 +305,7 @@ Returns articles for export, optionally scoped to a category, including category
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[categoryId]` | `number|null` | Optional category id to scope the export to; defaults to the company root. | | `[categoryId]` | `number|null` | Optional category id to scope the export to; defaults to the company root. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -230,6 +322,29 @@ const articles = await store.article.listExport(42, null);
// => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }] // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }]
``` ```
## `article.listHistory`
*Source: `src/store/article/listHistory.js`*
Lists article history entries for the user's selected company, supporting pagination, date cutoff via seekMonths, and search.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').Provider` | |
**Returns**
`(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
**Example**
```js
const res = await store.article.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'hjg', seekMonths: -13 });
// => { history: [...], total, unfilteredTotal, offset, limit }
```
## `article.listSearchIndex` ## `article.listSearchIndex`
*Source: `src/store/article/listSearchIndex.js`* *Source: `src/store/article/listSearchIndex.js`*
@@ -241,6 +356,7 @@ Returns a lightweight search index of all articles for the user's selected compa
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -267,6 +383,7 @@ Reorders the images of an article by its SKU.
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `input` | `object` | The reorder input. | | `input` | `object` | The reorder input. |
| `input.fullHashes` | `Array<string>` | The full image hashes in their new order. | | `input.fullHashes` | `Array<string>` | The full image hashes in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -283,6 +400,49 @@ const result = await store.article.orderImages(42, 'SKU-1', { fullHashes: ['a',
// => { fullHashes: ['a', 'b'] } // => { fullHashes: ['a', 'b'] }
``` ```
## `article.removeChild`
*Source: `src/store/article/removeChild.js`*
Removes a child article from a parent article, either by unlinking or deleting it.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `parentSku` | `string` | The parent article SKU. |
| `childSku` | `string` | The child article SKU. |
| `[options]` | `object` | |
| `[options.deleteArticle=false]` | `boolean` | Whether to delete the child article entity entirely. |
| `[options.cascade=true]` | `boolean` | Whether to cascade delete child subtrees when deleteArticle is true. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>`
## `article.resolveUniqueSlug`
*Source: `src/store/article/resolveUniqueSlug.js`*
Resolves a company- and language-unique slug for an article. Disambiguates duplicate slugs with a numeric suffix (-2, -3, ...).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `runner` | `object` | DB runner or tx. |
| `params` | `object` | |
| `params.companyId` | `number` | The company ID. |
| `params.languageId` | `number` | The language ID. |
| `params.name` | `string` | The article name. |
| `[params.excludeArticleId]` | `number` | Optional article ID to exclude (e.g. for updates). |
**Returns**
`Promise<string>` — The unique slug.
## `article.setBrandIdBySku` ## `article.setBrandIdBySku`
*Source: `src/store/article/setBrandIdBySku.js`* *Source: `src/store/article/setBrandIdBySku.js`*
@@ -296,6 +456,7 @@ Sets (or clears) the brand of an article by its SKU.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `brandId` | `number|null` | The brand id, or `null` to clear the brand. | | `brandId` | `number|null` | The brand id, or `null` to clear the brand. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -325,6 +486,7 @@ Replaces the category membership of an article by its SKU.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `categoryIds` | `Array<number>` | The category ids the article should belong to (at least one). | | `categoryIds` | `Array<number>` | The category ids the article should belong to (at least one). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -354,6 +516,7 @@ Replaces the EAN list of an article by its SKU.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `eans` | `Array<string>` | The EANs to set (must be unique, non-empty strings). | | `eans` | `Array<string>` | The EANs to set (must be unique, non-empty strings). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -370,11 +533,11 @@ const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890'])
// => { id, sku, name, ... } // => { id, sku, name, ... }
``` ```
## `article.setFatherBySku` ## `article.setParentBySku`
*Source: `src/store/article/setFatherBySku.js`* *Source: `src/store/article/setParentBySku.js`*
Links an article to a father article (V/K compound) by their SKUs. Links an article to a parent article (V/K compound) by their SKUs.
**Parameters** **Parameters**
@@ -382,7 +545,8 @@ Links an article to a father article (V/K compound) by their SKUs.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The child article SKU. | | `sku` | `string` | The child article SKU. |
| `fatherSku` | `string|null` | The father article SKU, or `null` to unlink. | | `parentSku` | `string|null` | The parent article SKU, or `null` to unlink. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -390,13 +554,13 @@ Links an article to a father article (V/K compound) by their SKUs.
**Throws** **Throws**
`Error` — If the article or father is not found, or the link is invalid. `Error` — If the article or parent is not found, or the link is invalid.
**Example** **Example**
```js ```js
const article = await store.article.setFatherBySku(42, '42001190-1', '42001190'); const article = await store.article.setParentBySku(42, '42001190-1', '42001190');
// => { id, sku, name, fatherSku: '42001190', ... } // => { id, sku, name, parentSku: '42001190', ... }
``` ```
## `article.setPricesBySku` ## `article.setPricesBySku`
@@ -411,7 +575,8 @@ Replaces the price list of an article by its SKU.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `prices` | `Array<{price: number, tax: number, minmultiplier?: number}>` | The price entries (minmultiplier defaults to 1). | | `prices` | `Array<{net?: number, gross?: number, price?: number, taxRate?: number, tax?: number, minmultiplier?: number}>` | The price entries (net or gross, taxRate or tax percentage, minmultiplier defaults to 1). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -424,10 +589,35 @@ Replaces the price list of an article by its SKU.
**Example** **Example**
```js ```js
const article = await store.article.setPricesBySku(42, 'SKU-1', [{ price: 1.5, tax: 19, minmultiplier: 1 }]); const article = await store.article.setPricesBySku(42, 'SKU-1', [{ net: 10.0, taxRate: 19, minmultiplier: 1 }]);
// or with gross:
const article = await store.article.setPricesBySku(42, 'SKU-1', [{ gross: 11.9, taxRate: 19 }]);
// => { id, sku, name, ... } // => { id, sku, name, ... }
``` ```
## `article.setTrackStockBySku`
*Source: `src/store/article/setTrackStockBySku.js`*
Sets the track_stock flag of an article by its SKU. If stock tracking is being disabled (1 -> 0), all non-zero warehouse stocks are adjusted to 0 on the ledger before disabling tracking.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `trackStock` | `boolean|number` | Whether stock tracking is enabled. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The updated article.
**Throws**
`Error` — If trackStock is invalid, article not found, or no warehouse exists when enabling.
## `article.setWeightBySku` ## `article.setWeightBySku`
*Source: `src/store/article/setWeightBySku.js`* *Source: `src/store/article/setWeightBySku.js`*
@@ -441,6 +631,7 @@ Sets the weight of an article by its SKU.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `weight` | `number` | The weight (must be >= 0). | | `weight` | `number` | The weight (must be >= 0). |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -457,6 +648,28 @@ const article = await store.article.setWeightBySku(42, 'SKU-1', 1.5);
// => { id, sku, name, ... } // => { id, sku, name, ... }
``` ```
## `article.toggleParent`
*Source: `src/store/article/toggleParent.js`*
Toggles an article into or out of parent mode.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `options` | `object` | |
| `options.isParent` | `boolean` | Whether the article should be a parent article. |
| `[options.attributeNameId]` | `number` | The attribute name id required when enabling parent mode. |
| `[options.parentAttributeNameId]` | `number` | Alias for attributeNameId. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The updated article detail.
## `article.updateBySku` ## `article.updateBySku`
*Source: `src/store/article/updateBySku.js`* *Source: `src/store/article/updateBySku.js`*
@@ -471,8 +684,12 @@ Updates an article's name and/or unit (SI and amount), recording history.
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `patch` | `object` | The update patch. | | `patch` | `object` | The update patch. |
| `[patch.name]` | `string` | The new article name. | | `[patch.name]` | `string` | The new article name. |
| `[patch.desc]` | `string` | The article description text/HTML. |
| `[patch.seo]` | `string` | The article SEO text. |
| `[patch.unitSi]` | `string` | The new unit SI code. | | `[patch.unitSi]` | `string` | The new unit SI code. |
| `[patch.unitamount]` | `number` | The new unit amount (must be > 0). | | `[patch.unitamount]` | `number` | The new unit amount (must be > 0). |
| `[patch.track_stock]` | `boolean|number` | Whether inventory stock is tracked. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -485,6 +702,6 @@ Updates an article's name and/or unit (SI and amount), recording history.
**Example** **Example**
```js ```js
const article = await store.article.updateBySku(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1 }); const article = await store.article.updateBySku(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1, track_stock: true });
// => { id, sku, name, ... } // => { id, sku, name, ... }
``` ```

View File

@@ -0,0 +1,40 @@
# articleattributevalue
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `articleattributevalue.listByArticleSku`
*Source: `src/store/articleattributevalue/listByArticleSku.js`*
Lists the attribute values assigned to an article by its SKU.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`
## `articleattributevalue.setByArticleSku`
*Source: `src/store/articleattributevalue/setByArticleSku.js`*
Replaces all attribute values for an article by its SKU. Validates company ownership and enforces the `exclusive` flag on attributes.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `values` | `Array<number|{attributeValueId: number, sort?: number}>` | Attribute value IDs or items. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`

9
API.articlehistory.md Normal file
View File

@@ -0,0 +1,9 @@
# 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`*
============================================================================ ARTICLE HISTORY LOGGER ============================================================================ Recorded History Event Types (`meta.type`): 1. Orders & Inventory Ledger - `order` : Customer order placement (`recordOrderHistory`) - `acceptance` : Incoming supplier delivery without warehouse (`recordAcceptanceHistory`) - `shipment` : Outgoing customer shipment (`recordShipmentHistory`) - `warehouse_in` : Inbound warehouse movement (`recordWarehouseHistory`) - `warehouse_out` : Outbound warehouse movement (`recordWarehouseHistory`) - `delivery` : Legacy incoming delivery with warehouse (`recordDeliveryHistory`) - `movement` : Legacy generic warehouse movement (`recordDeliveryHistory`) - `stock_adjustment` : Manual stock correction (`recordStockAdjustmentHistory`) 2. Core Article Lifecycle & Master Data - `article_created` : Initial article creation (`recordArticleCreatedHistory`) - `article_imported` : Article created via import (`recordArticleImportedHistory`) - `article_update` : General field updates (`recordArticleUpdateHistory`) - `price_change` : Price tier / net / tax modifications (`recordPriceChangeHistory`) - `category_change` : Category assignment changes (`recordCategoryChangeHistory`) - `brand_change` : Brand name updates (`recordBrandChangeHistory`) - `ean_change` : EAN list updates (`recordEanChangeHistory`) - `weight_change` : Article weight changes (`recordWeightChangeHistory`) - `track_stock_change` : Stock tracking toggle (`recordTrackStockChangeHistory`) 3. Variants & Parent/Child Relationships - `parent_converted` : Article converted to parent (`recordParentConvertedHistory`) - `parent_deactivated` : Parent mode disabled (`recordParentDeactivatedHistory`) - `variant_added_to_parent` : Variant linked on parent record (`recordVariantAddedOnParentHistory`) - `child_created_under_parent` : New variant created under parent (`recordChildAssignedToParentHistory`) - `child_assigned_to_parent` : Existing article assigned as variant (`recordChildAssignedToParentHistory`) - `child_unlinked` : Child unlinked from parent - child log (`recordChildUnlinkedFromParentHistory`) - `variant_separated` : Variant separated from parent - parent log (`recordChildUnlinkedFromParentHistory`) - `variant_deleted` : Variant deleted from parent (`recordChildDeletedFromParentHistory`) 4. Suppliers - `supplier_add` : Supplier linked to article (`recordSupplierAddHistory`) - `supplier_update` : Supplier details modified (`recordSupplierUpdateHistory`) - `supplier_delete` : Supplier unlinked from article (`recordSupplierDeleteHistory`) 5. Properties, Attributes & Bills of Materials (BOM) - `property_change` : Property key/value changes (`recordPropertyChangeHistory`) - `property_delete` : Single property removed (`recordPropertyDeleteHistory`) - `attribute_change` : Variant/article attribute changes (`recordAttributeChangeHistory`) - `articlelist_change` : Stückliste / component BOM set (`recordArticleListChangeHistory`) - `optionslist_change` : Optionsliste set (`recordOptionsListChangeHistory`) 6. Media & Images - `image_add` : Image uploaded/linked (`recordImageAddHistory`) - `image_delete` : Image removed (`recordImageDeleteHistory`) - `image_order` : Image gallery reordered (`recordImageOrderHistory`) ============================================================================

40
API.articlelist.md Normal file
View File

@@ -0,0 +1,40 @@
# articlelist
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `articlelist.getItemsByArticleSku`
*Source: `src/store/articlelist/getItemsByArticleSku.js`*
Returns the Stückliste (bundle items) for an article by its SKU.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`
## `articlelist.setItemsByArticleSku`
*Source: `src/store/articlelist/setItemsByArticleSku.js`*
Sets or clears the Stückliste (bundle items) for an article.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The master article SKU. |
| `[items=[]]` | `Array<{articleId?: number, sku?: string, count?: number, sort?: number}>` | Array of component items. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`

59
API.articleproperty.md Normal file
View File

@@ -0,0 +1,59 @@
# articleproperty
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `articleproperty.delete`
*Source: `src/store/articleproperty/delete.js`*
Deletes a property from an article by SKU and propertyId.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `propertyId` | `number` | The property id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
## `articleproperty.listByArticleSku`
*Source: `src/store/articleproperty/listByArticleSku.js`*
Lists the properties of an article by its SKU.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
## `articleproperty.setByArticleSku`
*Source: `src/store/articleproperty/setByArticleSku.js`*
Replaces all properties for an article by its SKU. Enforces exclusive flag on property names. An exclusive property that already has a value is never overwritten: a different value throws `Exclusive property cannot be overwritten: <name>`.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `properties` | `Array<{propertyId: number, value: string, sort?: number}>` | The properties to set. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`

View File

@@ -23,10 +23,11 @@ Assigns a supplier to an article, optionally creating a purchase price.
| `[input.netPrice]` | `number|null` | Net purchase price. | | `[input.netPrice]` | `number|null` | Net purchase price. |
| `[input.taxId]` | `number` | The tax id for the purchase price. | | `[input.taxId]` | `number` | The tax id for the purchase price. |
| `[input.taxRate]` | `number` | The tax rate in percent (used if taxId is not provided). | | `[input.taxRate]` | `number` | The tax rate in percent (used if taxId is not provided). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>` — The created assignment. `Promise<{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}>` — The created assignment.
**Throws** **Throws**
@@ -41,7 +42,7 @@ const created = await store.articlesupplier.create(42, 'SKU-1', {
pu: 1, pu: 1,
netPrice: 4.20, netPrice: 4.20,
}); });
// => { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } // => { id, articleId, entityId, supplierName, sku, pu, stock, sort, price }
``` ```
## `articlesupplier.delete` ## `articlesupplier.delete`
@@ -57,6 +58,7 @@ Deletes a supplier assignment and cleans up its associated purchase price.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `id` | `number` | The id of the articlesupplier record. | | `id` | `number` | The id of the articlesupplier record. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -84,10 +86,11 @@ Lists the supplier assignments for an article by its SKU.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
`Promise<Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>>` — The supplier assignments. `Promise<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}>>` — The supplier assignments.
**Throws** **Throws**
@@ -97,14 +100,14 @@ Lists the supplier assignments for an article by its SKU.
```js ```js
const suppliers = await store.articlesupplier.listByArticleSku(42, 'SKU-1'); const suppliers = await store.articlesupplier.listByArticleSku(42, 'SKU-1');
// => [{ id, articleId, entityId, supplierName, sku, pu, stock, priceId, price }] // => [{ id, articleId, entityId, supplierName, sku, pu, stock, price }]
``` ```
## `articlesupplier.listByEntityAndSkus` ## `articlesupplier.listByEntityAndSkus`
*Source: `src/store/articlesupplier/listByEntityAndSkus.js`* *Source: `src/store/articlesupplier/listByEntityAndSkus.js`*
Lists article-supplier assignments for a given supplier (entityId) and SKU list, including linked article details (name, slug, photos/articlepic, eans/articleean). Lists article-supplier assignments for a given supplier (entityId) and SKU list, including linked article details (name, slug, photos, eans).
**Parameters** **Parameters**
@@ -113,10 +116,11 @@ Lists article-supplier assignments for a given supplier (entityId) and SKU list,
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `entityIdOrOptions` | `number|{entityId?: number, supplierId?: number, skus?: string[]|string}` | The supplier entity id or an options object. | | `entityIdOrOptions` | `number|{entityId?: number, supplierId?: number, skus?: string[]|string}` | The supplier entity id or an options object. |
| `[maybeSkus]` | `string[]|string` | The list of SKUs to filter by (or single SKU string). | | `[maybeSkus]` | `string[]|string` | The list of SKUs to filter by (or single SKU string). |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
`Promise<Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null, article: {id: number, sku: string, name: string|null, slug: string|null, photos: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>` — The supplier assignments with article details. `Promise<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, article: {id: number, sku: string, name: string|null, slug: string|null, photos: Array<{sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[]}}>>` — The supplier assignments with article details.
**Throws** **Throws**
@@ -143,6 +147,7 @@ Reorders the supplier assignments of an article by its SKU.
| `sku` | `string` | The article SKU. | | `sku` | `string` | The article SKU. |
| `input` | `object` | The reorder input. | | `input` | `object` | The reorder input. |
| `input.ids` | `Array<number>` | The articlesupplier ids in their new order. | | `input.ids` | `Array<number>` | The articlesupplier ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -180,10 +185,11 @@ Updates an existing supplier assignment and its associated purchase price.
| `[patch.netPrice]` | `number|null` | Net purchase price. | | `[patch.netPrice]` | `number|null` | Net purchase price. |
| `[patch.taxId]` | `number` | The tax id for the purchase price. | | `[patch.taxId]` | `number` | The tax id for the purchase price. |
| `[patch.taxRate]` | `number` | The tax rate in percent. | | `[patch.taxRate]` | `number` | The tax rate in percent. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>` — The updated assignment. `Promise<{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}>` — The updated assignment.
**Throws** **Throws**
@@ -197,5 +203,5 @@ const updated = await store.articlesupplier.update(42, 'SKU-1', 12, {
pu: 6, pu: 6,
netPrice: 22.50, netPrice: 22.50,
}); });
// => { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } // => { id, articleId, entityId, supplierName, sku, pu, stock, sort, price }
``` ```

View File

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

112
API.attributename.md Normal file
View File

@@ -0,0 +1,112 @@
# attributename
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `attributename.create`
*Source: `src/store/attributename/create.js`*
Creates an attribute name for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The attribute name input. |
| `input.name` | `string` | The attribute name. |
| `[input.sort]` | `number` | Optional sort order. |
| `[input.exclusive=false]` | `boolean` | Whether values are mutually exclusive on an article. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
**Throws**
`Error` — If the name is missing or already exists for the company.
## `attributename.delete`
*Source: `src/store/attributename/delete.js`*
Deletes an attribute name for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The attribute name id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If attribute name not found or in use.
## `attributename.list`
*Source: `src/store/attributename/list.js`*
Lists the attribute names for the user's selected company, including values.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, name: string, sort: number, exclusive: boolean, articleCount: number, values: Array<{id: number, value: string, sort: number, articleCount: number}>}>>`
## `attributename.order`
*Source: `src/store/attributename/order.js`*
Reorders the attribute names for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Reorder input. |
| `input.attributenameIds` | `Array<number>` | IDs in their new order. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{attributenameIds: Array<number>}>`
## `attributename.update`
*Source: `src/store/attributename/update.js`*
Updates an attribute name for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The attribute name id. |
| `input` | `object` | Update patch. |
| `[input.name]` | `string` | New name. |
| `[input.sort]` | `number` | New sort. |
| `[input.exclusive]` | `boolean` | New exclusive flag. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
**Throws**
`Error` — If the attribute name is unknown, the name is missing, or the name is already taken.

154
API.attributevalue.md Normal file
View File

@@ -0,0 +1,154 @@
# attributevalue
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `attributevalue.addImage`
*Source: `src/store/attributevalue/addImage.js`*
Adds an image to an attribute value, processing full, thumb, and 32px-edge small-thumb AVIF variants.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeValueId` | `number` | The id of the attribute value. |
| `image` | `Buffer` | The source image buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>`
## `attributevalue.create`
*Source: `src/store/attributevalue/create.js`*
Creates an attribute value under a given attribute.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeId` | `number` | The attribute id. |
| `input` | `object` | Input value data. |
| `input.value` | `string` | The value text. |
| `[input.sort]` | `number` | Optional sort order. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, attributeId: number, value: string, sort: number}>`
**Throws**
`Error` — If the value is missing, the attribute is unknown, or the value already exists for the attribute.
## `attributevalue.delete`
*Source: `src/store/attributevalue/delete.js`*
Deletes an attribute value.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The attribute value id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If attribute value not found or in use.
## `attributevalue.deleteImage`
*Source: `src/store/attributevalue/deleteImage.js`*
Deletes the image of an attribute value.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeValueId` | `number` | The id of the attribute value. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null, pics: Array}>`
**Throws**
`Error` — If attribute value not found or image not found.
## `attributevalue.listByAttributeId`
*Source: `src/store/attributevalue/listByAttributeId.js`*
Lists the values for an attribute.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeId` | `number` | The attribute id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, attributeId: number, value: string, sort: number, articleCount: number}>>`
## `attributevalue.order`
*Source: `src/store/attributevalue/order.js`*
Reorders attribute values under an attribute.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeId` | `number` | The attribute id. |
| `input` | `object` | Reorder input. |
| `input.attributevalueIds` | `Array<number>` | Value IDs in their new order. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{attributevalueIds: Array<number>}>`
## `attributevalue.update`
*Source: `src/store/attributevalue/update.js`*
Updates an attribute value.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The attribute value id. |
| `input` | `object` | Update patch. |
| `[input.value]` | `string` | New value text. |
| `[input.sort]` | `number` | New sort order. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, attributeId: number, value: string, sort: number}>`
**Throws**
`Error` — If the attribute value is unknown, the value is missing, or the value is already taken.

86
API.autoinvite.md Normal file
View File

@@ -0,0 +1,86 @@
# autoinvite
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `autoinvite.add`
*Source: `src/store/autoinvite/add.js`*
Registers an email for automatic company membership. When that email is registered (`google_auth.add`) the invitation is consumed: it is removed from the list and the new user is added to the company with the given role.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `input` | `{email: string, role?: 'admin'|'user'}` | The email to invite and the role (default 'user'). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, email: string, role: 'admin'|'user'}>` — The created invitation.
**Throws**
`Error` — 'Not authorized', 'Invalid email', 'Invalid role', 'User already registered', 'Invitation already exists'
**Example**
```js
await store.autoinvite.add(42, { email: 'new@example.com', role: 'user' });
```
## `autoinvite.list`
*Source: `src/store/autoinvite/list.js`*
Lists the pending auto-invitations of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user (must be admin of the selected company). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, email: string, role: 'admin'|'user', createdAt: string}>>` — The invitations ordered by email.
**Throws**
`Error` — 'Not authorized' / 'No company selected'
**Example**
```js
const invites = await store.autoinvite.list(42);
```
## `autoinvite.remove`
*Source: `src/store/autoinvite/remove.js`*
Deletes a pending auto-invitation of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `id` | `number` | The invitation id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — 'Not authorized', 'Invitation not found'
**Example**
```js
await store.autoinvite.remove(42, 3);
```

View File

@@ -2,6 +2,56 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `brand.addImage`
*Source: `src/store/brand/addImage.js`*
Adds an image to a brand, processing full, thumb, and 32px-edge small-thumb AVIF variants.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `brandId` | `number` | The id of the brand. |
| `image` | `Buffer` | The source image buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>`
## `brand.create`
*Source: `src/store/brand/create.js`*
Creates a brand for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The brand input. |
| `input.name` | `string` | The brand name. |
| `[input.gsprpinfo]` | `string` | Optional GSPRP info. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>` — The created brand.
**Throws**
`Error` — If the brand is invalid or already exists.
**Example**
```js
const brand = await store.brand.create(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
// => { id, name, gsprpinfo, pics: [] }
```
## `brand.delete` ## `brand.delete`
*Source: `src/store/brand/delete.js`* *Source: `src/store/brand/delete.js`*
@@ -14,6 +64,7 @@ Deletes a brand for the user's selected company by its id.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the brand to delete. | | `id` | `number` | The id of the brand to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -33,23 +84,60 @@ await store.brand.delete(42, 3);
*Source: `src/store/brand/list.js`* *Source: `src/store/brand/list.js`*
Lists the brands of the user's selected company. Lists the brands of the user's selected company, sorted by name.
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination options. |
| `[opts.offset=0]` | `number` | Number of brands to skip. |
| `[opts.limit=10]` | `number` | Number of brands to return (max 100). |
| `[opts.includeSmallThumbs=false]` | `boolean` | Include 32px-edge small thumbnail dimensions and base64 AVIF data. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>` — The brands. `Promise<{brands: Array<{id: number, name: string, articleCount: number, gsprpinfo: string|null, pics: Array<{fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW?: number|null, smallThumbH?: number|null, smallThumbBinary?: string|null}>}>, total: number, offset: number, limit: number}>` — The brands plus the total unpaged count. Small-thumbnail fields are present only when `includeSmallThumbs` is true; the binary is base64-encoded AVIF data.
**Example** **Example**
```js ```js
const brands = await store.brand.list(42); const result = await store.brand.list(42, { offset: 0, limit: 10 });
// => [{ id, name, gsprpinfo }] // => { brands: [{ id, name, articleCount, gsprpinfo, pics: [...] }], total: 25, offset: 0, limit: 10 }
```
## `brand.update`
*Source: `src/store/brand/update.js`*
Renames/updates a brand for the user's selected company by id. Touching `article.updated_at` for all articles linked via `brand_id` when the name actually changes, so list caches invalidate.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the brand to update. |
| `input` | `object` | The update input. |
| `[input.name]` | `string` | The new brand name. |
| `[input.gsprpinfo]` | `string` | The new GSPRP info. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, gsprpinfo: string|null, touchedArticleCount: number}>` — The updated brand plus the number of linked articles whose `updated_at` was bumped.
**Throws**
`Error` — If the brand is not found, invalid, or the name is already taken.
**Example**
```js
const brand = await store.brand.update(42, 3, { name: 'Coca-Cola GmbH' });
// => { id: 3, name, gsprpinfo, touchedArticleCount: 2 }
``` ```
## `brand.upsert` ## `brand.upsert`
@@ -66,10 +154,11 @@ Creates or updates a brand for the user's selected company, keyed by name.
| `input` | `object` | The brand input. | | `input` | `object` | The brand input. |
| `input.name` | `string` | The brand name. | | `input.name` | `string` | The brand name. |
| `[input.gsprpinfo]` | `string` | Optional GSPRP info. | | `[input.gsprpinfo]` | `string` | Optional GSPRP info. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, name: string, gsprpinfo: string|null}>` — The upserted brand. `Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>` — The upserted brand.
**Throws** **Throws**
@@ -79,5 +168,5 @@ Creates or updates a brand for the user's selected company, keyed by name.
```js ```js
const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' }); const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
// => { id, name, gsprpinfo } // => { id, name, gsprpinfo, pics: [] }
``` ```

176
API.carrier.md Normal file
View File

@@ -0,0 +1,176 @@
# carrier
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `carrier.create`
*Source: `src/store/carrier/create.js`*
Creates a new carrier for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Carrier input. |
| `input.name` | `string` | The carrier name. |
| `[input.sort]` | `number` | Optional sort order (auto-increments if omitted). |
| `[input.hasPackages=false]` | `boolean` | Whether this carrier uses packages. |
| `[input.isReverse=false]` | `boolean` | Whether this carrier handles reverse shipments. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>` — The created carrier.
**Throws**
`Error` — If name is missing, sort is invalid, or carrier already exists.
**Example**
```js
const c = await store.carrier.create(googleAuthId, { name: 'DHL', hasPackages: true });
// => { id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false }
```
## `carrier.delete`
*Source: `src/store/carrier/delete.js`*
Deletes a carrier for the user's selected company, unless referenced in shipping.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The carrier ID to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If carrier not found or in use in shipping.
**Example**
```js
await store.carrier.delete(googleAuthId, 1);
```
## `carrier.getById`
*Source: `src/store/carrier/getById.js`*
Retrieves a carrier by its ID for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The carrier ID. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}|undefined>` — The carrier or undefined.
**Example**
```js
const c = await store.carrier.getById(googleAuthId, 1);
// => { id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false }
```
## `carrier.list`
*Source: `src/store/carrier/list.js`*
Lists all carriers for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>>` — List of carriers.
**Example**
```js
const carriers = await store.carrier.list(googleAuthId);
// => [{ id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false }]
```
## `carrier.order`
*Source: `src/store/carrier/order.js`*
Reorders the carriers of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The reorder input. |
| `input.carrierIds` | `Array<number>` | The carrier ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{carrierIds: Array<number>}>` — The applied order.
**Throws**
`Error` — If the order is invalid.
**Example**
```js
await store.carrier.order(googleAuthId, { carrierIds: [2, 1, 3] });
```
## `carrier.update`
*Source: `src/store/carrier/update.js`*
Updates a carrier name, sort order, hasPackages, and/or isReverse for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The carrier ID to update. |
| `input` | `object` | Update payload. |
| `[input.name]` | `string` | The new carrier name. |
| `[input.sort]` | `number` | The new sort order. |
| `[input.hasPackages]` | `boolean` | Whether carrier uses packages. |
| `[input.isReverse]` | `boolean` | Whether carrier handles reverse shipments. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>` — The updated carrier.
**Throws**
`Error` — If carrier not found, name is empty, sort is invalid, or carrier already exists.
**Example**
```js
const updated = await store.carrier.update(googleAuthId, 1, { name: 'DHL Express', hasPackages: true });
// => { id: 1, name: 'DHL Express', sort: 0, hasPackages: true, isReverse: false }
```

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/category/addImage.js`* *Source: `src/store/category/addImage.js`*
Adds an image to a category, processing it into AVIF full and thumb variants. Adds an image to a category, processing full, thumb, and 32px-edge small-thumb AVIF variants.
**Parameters** **Parameters**
@@ -15,10 +15,11 @@ Adds an image to a category, processing it into AVIF full and thumb variants.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The id of the category. | | `categoryId` | `number` | The id of the category. |
| `image` | `Buffer` | The source image buffer. | | `image` | `Buffer` | The source image buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>` — The added image info. `Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number}>` — The added image info.
**Example** **Example**
@@ -41,6 +42,7 @@ Creates a new category under a given parent category.
| `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.parentId` | `number` | The id of the parent category. | | `input.parentId` | `number` | The id of the parent category. |
| `input.name` | `string` | The category name. | | `input.name` | `string` | The category name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -57,7 +59,7 @@ const category = await store.category.createUnderParent({ googleAuthId: 42, pare
*Source: `src/store/category/delete.js`* *Source: `src/store/category/delete.js`*
Deletes a category, refusing to delete the company's root category. Deletes a category, refusing to delete the company's root category. If recursive is true, recursively deletes the subtree and articles that only belong to it.
**Parameters** **Parameters**
@@ -66,6 +68,8 @@ Deletes a category, refusing to delete the company's root category.
| `input` | `object` | The delete input. | | `input` | `object` | The delete input. |
| `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.id` | `number` | The id of the category to delete. | | `input.id` | `number` | The id of the category to delete. |
| `[input.recursive=false]` | `boolean` | Whether to delete the category subtree recursively. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -73,12 +77,13 @@ Deletes a category, refusing to delete the company's root category.
**Throws** **Throws**
`Error` — If the category is the root category. `Error` — If the category is the root category or contains articles/subcategories (when recursive is false).
**Example** **Example**
```js ```js
await store.category.delete({ googleAuthId: 42, id: 5 }); await store.category.delete({ googleAuthId: 42, id: 5 });
await store.category.delete({ googleAuthId: 42, id: 5, recursive: true });
``` ```
## `category.deleteImage` ## `category.deleteImage`
@@ -93,6 +98,7 @@ Deletes the image of a category.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The id of the category. | | `categoryId` | `number` | The id of the category. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -136,6 +142,37 @@ const id = await store.category.ensureByPath({ googleAuthId: 42, parentId: 5, pa
// => 12 // => 12
``` ```
## `category.generateImage`
*Source: `src/store/category/generateImage.js`*
Generates a mosaic image directly from the category's and its subcategories' articles (grouped by subcategory) and sets it as the new main picture for the category.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `inputOrCategoryId` | `number|object` | The id of the category or an options object. |
| `[maybeOptsOrSize={}]` | `object|number` | Options object or numeric size. |
| `[maybeOptsOrSize.size]` | `number` | Grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3 (falls back to 2 if < 7 images). |
| `[maybeTx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>` — The new main image info.
**Throws**
`Error` — If the category is not found, not authorized, size is invalid, or no images are found.
**Example**
```js
const result = await store.category.generateImage(42, 5, { size: 3 });
// => { id: 5, fullHash, thumbW, thumbH, thumbHash, smallThumbW, smallThumbH, size: 3, grid: '3x3', articleCount: 12 }
```
## `category.getCategoryArticles` ## `category.getCategoryArticles`
*Source: `src/store/category/getCategoryArticles.js`* *Source: `src/store/category/getCategoryArticles.js`*
@@ -148,39 +185,41 @@ Returns the articles belonging to a single category.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The id of the category. | | `categoryId` | `number` | The id of the category. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<Array<object>>` — The articles in the category. `Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, smallThumbW: number|null, smallThumbH: number|null, smallThumbBinary: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>` — The articles in the category.
**Example** **Example**
```js ```js
const articles = await store.category.getCategoryArticles(42, 5); const articles = await store.category.getCategoryArticles(42, 5);
// => [{ id, sku, name, ... }] // => [{ id, sku, name, brand, ... }]
``` ```
## `category.getTreeForCompany` ## `category.getTreeForCompany`
*Source: `src/store/category/getTreeForCompany.js`* *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** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **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** **Example**
```js ```js
const tree = await store.category.getTreeForCompany(42); const tree = await store.category.getTreeForCompany(42);
// => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] } // => { languageId, tree: [{ id, articleCount, posterminalIds, ... }], articleCount: 25 }
``` ```
## `category.move` ## `category.move`
@@ -197,6 +236,7 @@ Moves a category to a new parent, preventing cycles and cross-company moves.
| `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.id` | `number` | The id of the category to move. | | `input.id` | `number` | The id of the category to move. |
| `input.parentId` | `number` | The id of the new parent category. | | `input.parentId` | `number` | The id of the new parent category. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -227,6 +267,7 @@ Reorders the child categories of a given parent.
| `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.parentId` | `number` | The id of the parent category. | | `input.parentId` | `number` | The id of the parent category. |
| `input.categoryIds` | `Array<number>` | The child category ids in their new order. | | `input.categoryIds` | `Array<number>` | The child category ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -257,6 +298,7 @@ Renames a category and updates its slug.
| `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.id` | `number` | The id of the category to rename. | | `input.id` | `number` | The id of the category to rename. |
| `input.name` | `string` | The new category name. | | `input.name` | `string` | The new category name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -0,0 +1,40 @@
# categoryattributevalue
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `categoryattributevalue.listByCategoryId`
*Source: `src/store/categoryattributevalue/listByCategoryId.js`*
Lists the attribute values assigned to a category by its ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The category ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, categoryId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`
## `categoryattributevalue.setByCategoryId`
*Source: `src/store/categoryattributevalue/setByCategoryId.js`*
Replaces all attribute values for a category by its ID. Validates company ownership and enforces the `exclusive` flag on attributes.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The category ID. |
| `values` | `Array<number|{attributeValueId: number, sort?: number}>` | Attribute value IDs or items. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, categoryId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`

59
API.categoryproperty.md Normal file
View File

@@ -0,0 +1,59 @@
# categoryproperty
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `categoryproperty.delete`
*Source: `src/store/categoryproperty/delete.js`*
Deletes a property from a category by categoryId and propertyId.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The category ID. |
| `propertyId` | `number` | The property id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
## `categoryproperty.listByCategoryId`
*Source: `src/store/categoryproperty/listByCategoryId.js`*
Lists the properties of a category by its ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The category ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
## `categoryproperty.setByCategoryId`
*Source: `src/store/categoryproperty/setByCategoryId.js`*
Replaces all properties for a category by its ID. Enforces exclusive flag on property names.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `categoryId` | `number` | The category ID. |
| `properties` | `Array<{propertyId: number, value: string, sort?: number}>` | The properties to set. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`

View File

@@ -2,6 +2,36 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `company.addHistory`
*Source: `src/store/company/addHistory.js`*
Adds an entry to the company history for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object|string` | The history input object or text string. |
| `[input.text]` | `string` | The history entry text. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, companyuserId: number|null, date: string, text: string}>` — The inserted entry.
**Throws**
`Error` — If text is missing or user is not authorized.
**Example**
```js
const entry = await store.company.addHistory(42, { text: 'API suggestion: add barcode scanner endpoint' });
// => { id, companyuserId, date, text }
```
## `company.create` ## `company.create`
*Source: `src/store/company/create.js`* *Source: `src/store/company/create.js`*
@@ -14,19 +44,16 @@ Creates a new company together with its root category, POS customer group, defau
| --- | --- | --- | | --- | --- | --- |
| `input` | `object` | The company creation input. | | `input` | `object` | The company creation input. |
| `input.iso639` | `string` | The ISO 639 language code for the company. | | `input.iso639` | `string` | The ISO 639 language code for the company. |
| `input.name` | `string` | The company name (must be unique). | | `input.name` | `string` | The company name. |
| `input.taxrate` | `number` | The standard tax rate in percent. | | `input.taxrate` | `number` | The standard tax rate in percent. |
| `input.reducedtaxrate` | `number` | The reduced tax rate in percent. | | `input.reducedtaxrate` | `number` | The reduced tax rate in percent. |
| `input.googleAuthId` | `number` | The id of the Google auth user to link. | | `input.googleAuthId` | `number` | The id of the Google auth user to link. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>` — The created company ids. `Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>` — The created company ids.
**Throws**
`Error` — If the company name is already taken.
**Example** **Example**
```js ```js
@@ -51,6 +78,7 @@ Deletes a company by its id.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `companyId` | `number` | The id of the company to delete. | | `companyId` | `number` | The id of the company to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -110,6 +138,35 @@ const company = await store.company.getByName('Acme');
// => { id, name, ... } // => { id, name, ... }
``` ```
## `company.getHistory`
*Source: `src/store/company/getHistory.js`*
Returns the company history for the user's selected company, newest first.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional options. |
| `[opts.limit]` | `number` | Maximum number of entries to return (newest first). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>` — The company history entries.
**Example**
```js
const history = await store.company.getHistory(42);
// => [{ id, companyuserId, date, text }]
const recent = await store.company.getHistory(42, { limit: 5 });
// => up to 5 most recent entries
```
## `company.list` ## `company.list`
*Source: `src/store/company/list.js`* *Source: `src/store/company/list.js`*
@@ -121,16 +178,17 @@ Lists the companies accessible to the given Google auth user.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **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** **Example**
```js ```js
const companies = await store.company.list(42); const companies = await store.company.list(42);
// => [{ id, name, ... }] // => [{ id, name, rootCategoryId, languageId, iso639, taxrate, reducedtaxrate, taxfreerate }]
``` ```
## `company.update` ## `company.update`
@@ -146,9 +204,10 @@ Updates an existing company's name, language and tax rates.
| `id` | `number` | The id of the company to update. | | `id` | `number` | The id of the company to update. |
| `input` | `object` | The update input. | | `input` | `object` | The update input. |
| `input.iso639` | `string` | The ISO 639 language code. | | `input.iso639` | `string` | The ISO 639 language code. |
| `input.name` | `string` | The new company name (must be unique). | | `input.name` | `string` | The new company name. |
| `input.taxrate` | `number` | The standard tax rate in percent. | | `input.taxrate` | `number` | The standard tax rate in percent. |
| `input.reducedtaxrate` | `number` | The reduced tax rate in percent. | | `input.reducedtaxrate` | `number` | The reduced tax rate in percent. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -156,7 +215,7 @@ Updates an existing company's name, language and tax rates.
**Throws** **Throws**
`Error` — If the company is not found or the name is taken. `Error` — If the company is not found.
**Example** **Example**

View File

@@ -17,6 +17,7 @@ Returns a single company setting by key for the user's selected company.
| `[options]` | `object` | Optional options. | | `[options]` | `object` | Optional options. |
| `[options.frontendOnly]` | `boolean` | Only return the setting if it is frontend-visible. | | `[options.frontendOnly]` | `boolean` | Only return the setting if it is frontend-visible. |
| `[options.maskWriteonly]` | `boolean` | Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value). | | `[options.maskWriteonly]` | `boolean` | Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -43,6 +44,7 @@ Lists the settings of the user's selected company, optionally only frontend-visi
| `[options]` | `object` | Optional options. | | `[options]` | `object` | Optional options. |
| `[options.frontendOnly]` | `boolean` | Only return frontend-visible settings. | | `[options.frontendOnly]` | `boolean` | Only return frontend-visible settings. |
| `[options.maskWriteonly]` | `boolean` | Whether to mask writeonly settings with '***'. Defaults to `true`. | | `[options.maskWriteonly]` | `boolean` | Whether to mask writeonly settings with '***'. Defaults to `true`. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -59,7 +61,7 @@ const settings = await store.companysetting.list(42, { frontendOnly: true });
*Source: `src/store/companysetting/set.js`* *Source: `src/store/companysetting/set.js`*
Sets (upserts) a company setting by key for the user's selected company. Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. Keys in the write-only set (`keys.tavily`, `keys.openrouter`) are stored with `writeonly = 1`; when retrieved by the GUI the value is returned as `"***"`. The backend always receives the real value. Sets (upserts) a company setting by key for the user's selected company.
**Parameters** **Parameters**
@@ -70,7 +72,8 @@ Sets (upserts) a company setting by key for the user's selected company. Only a
| `input.key` | `string` | The setting key (must be in the allow-list). | | `input.key` | `string` | The setting key (must be in the allow-list). |
| `input.value` | `string` | The setting value. | | `input.value` | `string` | The setting value. |
| `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. | | `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. |
| `[input.writeonly]` | `boolean` | Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. | | `[input.writeonly]` | `boolean` | Whether the setting is write-only. Defaults to true for `keys.tavily`, `keys.openrouter`, and `keys.serper`, false otherwise. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/companyuser/add.js`* *Source: `src/store/companyuser/add.js`*
Adds a company user linking a company to a Google auth account. Adds a company user linking a company to a Google auth account. No admin check: this is the low-level link primitive (company creation, invitations). Use `companyuser.addByEmail` for the admin-guarded flow.
**Parameters** **Parameters**
@@ -14,18 +14,52 @@ Adds a company user linking a company to a Google auth account.
| --- | --- | --- | | --- | --- | --- |
| `companyId` | `number` | The id of the company. | | `companyId` | `number` | The id of the company. |
| `googleAuthId` | `number` | The id of the Google auth account. | | `googleAuthId` | `number` | The id of the Google auth account. |
| `[role='user']` | `'admin'|'user'` | The role within the company. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<number>` — The id of the created company user link. `Promise<number>` — The id of the created company user link.
**Throws**
`Error` — 'Invalid role' if the role is not 'admin' or 'user'.
**Example** **Example**
```js ```js
const id = await store.companyuser.add(1, 42); const id = await store.companyuser.add(1, 42, 'admin');
// => 7 // => 7
``` ```
## `companyuser.addByEmail`
*Source: `src/store/companyuser/addByEmail.js`*
Adds an existing Google auth user to the admin's selected company. The added user can then select the company as their own selected company. For emails that are not registered yet use `autoinvite.add`.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `input` | `{email: string, role?: 'admin'|'user'}` | Email of the existing user and role (default 'user'). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>` — The created company user.
**Throws**
`Error` — 'Not authorized', 'Invalid email', 'Invalid role', 'User not found', 'User already in company'
**Example**
```js
const member = await store.companyuser.addByEmail(42, { email: 'new@example.com', role: 'user' });
```
## `companyuser.delete` ## `companyuser.delete`
*Source: `src/store/companyuser/delete.js`* *Source: `src/store/companyuser/delete.js`*
@@ -37,6 +71,7 @@ Deletes a company user link by its id.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `id` | `number` | The id of the company user link to delete. | | `id` | `number` | The id of the company user link to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -59,6 +94,7 @@ Returns the company user ids associated with the given email address.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `email` | `string` | The email address. | | `email` | `string` | The email address. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -70,3 +106,134 @@ Returns the company user ids associated with the given email address.
const ids = await store.companyuser.getByEmail('user@example.com'); const ids = await store.companyuser.getByEmail('user@example.com');
// => [1, 2, 3] // => [1, 2, 3]
``` ```
## `companyuser.list`
*Source: `src/store/companyuser/list.js`*
Lists the members of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user (must be admin of the selected company). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>>` — The company users ordered by email.
**Throws**
`Error` — 'Not authorized' / 'No company selected'
**Example**
```js
const members = await store.companyuser.list(42);
```
## `companyuser.remove`
*Source: `src/store/companyuser/remove.js`*
Removes a member from the admin's selected company. History entries keep existing but lose their author link; if the removed user had this company selected, their selection is cleared.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `id` | `number` | The company user id to remove. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, googleAuthId: number}>` — The removed company user.
**Throws**
`Error` — 'Not authorized', 'Company user not found', 'Cannot remove the last admin'
**Example**
```js
await store.companyuser.remove(42, 7);
```
## `companyuser.requireAdmin`
*Source: `src/store/companyuser/requireAdmin.js`*
Resolves the selected company and requires the user to be its admin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | |
| `[tx]` | `object` | |
**Returns**
`Promise<{companyId: number, companyuserId: number, role: string}>`
**Throws**
`Error` — 'Not authorized' if the user is not an admin of the selected company.
## `companyuser.roles`
*Source: `src/store/companyuser/roles.js`*
Validates a company user role.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `role` | `unknown` | |
**Returns**
`'admin'|'user'`
**Throws**
`Error` — 'Invalid role'
## `companyuser.setRole`
*Source: `src/store/companyuser/setRole.js`*
Changes the role of a member of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `id` | `number` | The company user id. |
| `role` | `'admin'|'user'` | The new role. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>` — The updated company user.
**Throws**
`Error` — 'Not authorized', 'Invalid role', 'Company user not found', 'Cannot demote the last admin'
**Example**
```js
await store.companyuser.setRole(42, 7, 'admin');
```
## `companyuser.withTransaction`
*Source: `src/store/companyuser/withTransaction.js`*
Runs `fn(runner)` inside the given transaction, a new one, or (providers without transaction support) directly.

View File

@@ -15,6 +15,7 @@ Creates a new customer group for the user's selected company.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The customer group input. | | `input` | `object` | The customer group input. |
| `input.name` | `string` | The customer group name (must not be a reserved name). | | `input.name` | `string` | The customer group name (must not be a reserved name). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -43,6 +44,7 @@ Deletes a customer group for the user's selected company by its id.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the customer group to delete. | | `id` | `number` | The id of the customer group to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -69,6 +71,7 @@ Lists the customer groups of the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -94,6 +97,7 @@ Reorders the customer groups of the user's selected company.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The reorder input. | | `input` | `object` | The reorder input. |
| `input.customergroupIds` | `Array<number>` | The customer group ids in their new order. | | `input.customergroupIds` | `Array<number>` | The customer group ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -124,6 +128,7 @@ Updates the name of a customer group for the user's selected company.
| `id` | `number` | The id of the customer group to update. | | `id` | `number` | The id of the customer group to update. |
| `input` | `object` | The update input. | | `input` | `object` | The update input. |
| `input.name` | `string` | The new customer group name. | | `input.name` | `string` | The new customer group name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -8,92 +8,21 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
Loads attachment metadata (without binary content) for a manifest. Loads attachment metadata (without binary content) for a manifest.
## `delivery.create` ## `delivery.book`
*Source: `src/store/delivery/create.js`* *Source: `src/store/delivery/book.js`*
Creates a new delivery for the user's selected company with its lines. The delivery number is allocated internally via `getnextnumberwithprefixandpadding` (`type` `delivery`); supply `externalNumber` for an external (supplier) reference. Books all positions of an incoming delivery manifest to the ledger by delegating to store.stock.accept.
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `provider` | `object` | Storage provider instance. |
| `body` | `object` | The delivery input. |
| `body.date` | `string` | The delivery date. |
| `body.supplierId` | `number` | The id of the supplier. |
| `[body.externalNumber]` | `string` | An external (supplier) reference number. |
| `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The delivery lines. |
**Returns** **Returns**
`Promise<{id: number, number: string, type: string, entityId: number, 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}>}>` — The created delivery. `Function` — `(googleAuthId, input, tx) => Promise<object>`
**Throws**
`Error` — If the delivery is invalid.
**Example**
```js
const delivery = await store.delivery.create(42, { date: '2026-01-01', supplierId: 5, lines: [...] });
// => { id, number, type, entityId, externalNumber, date, lines: [...] }
```
## `delivery.delete`
*Source: `src/store/delivery/delete.js`*
Deletes a delivery by its id within the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the delivery to delete. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If the delivery is not found.
**Example**
```js
await store.delivery.delete(42, 5);
```
## `delivery.getById`
*Source: `src/store/delivery/getById.js`*
Returns a single delivery by its id within the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the delivery. |
**Returns**
`Promise<{id: number, number: string, type: string, entityId: number, 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}>}>` — The delivery.
**Throws**
`Error` — If the delivery is not found.
**Example**
```js
const delivery = await store.delivery.getById(42, 5);
// => { id, number, type, entityId, externalNumber, date, lines: [...] }
```
## `delivery.list` ## `delivery.list`
@@ -106,48 +35,33 @@ Lists the deliveries of the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of deliveries to skip. |
| `[opts.limit=50]` | `number` | Number of deliveries to return. |
| `[opts.search]` | `string` | Free-text search matching delivery number, external number, or supplier name. |
| `[opts.onlyUnbooked=false]` | `boolean` | When true, returns only deliveries with unbooked stock items. |
| `[opts.supplierId]` | `number` | Filter by supplier entity id. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
`Promise<Array<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string}>>` — The deliveries. `Promise<{deliveries: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
**Example** ## `delivery.rebuildSummaries`
```js *Source: `src/store/delivery/rebuildSummaries.js`*
const deliveries = await store.delivery.list(42);
// => [{ id, number, type, entityId, externalNumber, date }]
```
## `delivery.update` Rebuilds the `deliverysummary` table for the user's selected company.
*Source: `src/store/delivery/update.js`*
Updates a delivery's header and/or lines.
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
| `id` | `number` | The id of the delivery to update. |
| `patch` | `object` | The update patch. |
| `[patch.number]` | `string` | The new delivery number. |
| `[patch.date]` | `string` | The new delivery date. |
| `[patch.supplierId]` | `number` | The new supplier id. |
| `[patch.externalNumber]` | `string` | The new external (supplier) reference number. |
| `[patch.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The new delivery lines. |
**Returns** ## `delivery.syncSummary`
`Promise<{id: number, number: string, type: string, entityId: number, 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}>}>` — The updated delivery. *Source: `src/store/delivery/syncSummary.js`*
**Throws** Centralized sync and rebuild logic for the materialized `deliverysummary` table. Keeps `deliverysummary` in sync with manifest (delivery), manifestarticle, article (track_stock), and ledger (DELIVERY transactions).
`Error` — If the delivery is not found.
**Example**
```js
const delivery = await store.delivery.update(42, 5, { number: 'D-2', lines: [...] });
// => { id, number, type, entityId, date, lines: [...] }
```

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/entity/create.js`* *Source: `src/store/entity/create.js`*
Creates a new entity (customer) in a customer group, recording history. Optional `number`; if omitted, allocates via `getnextnumberwithprefixandpadding` (`type` `entity`). Creates a new entity (customer) in a customer group, recording history. Optional `number`; if omitted, allocates via `numbers.nextNumber` (`type` `entity`).
**Parameters** **Parameters**
@@ -18,6 +18,7 @@ Creates a new entity (customer) in a customer group, recording history. Optional
| `input.name` | `string` | The entity name. | | `input.name` | `string` | The entity name. |
| `[input.firstName]` | `string` | The entity first name. | | `[input.firstName]` | `string` | The entity first name. |
| `[input.number]` | `string` | Optional explicit customer number. | | `[input.number]` | `string` | Optional explicit customer number. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -46,6 +47,7 @@ Deletes an entity by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the entity to delete. | | `id` | `number` | The id of the entity to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -53,7 +55,7 @@ Deletes an entity by its id within the user's selected company.
**Throws** **Throws**
`Error` — If the entity is not found. `Error` — If the entity is not found or is in use.
**Example** **Example**
@@ -73,6 +75,7 @@ Returns a single entity by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the entity. | | `id` | `number` | The id of the entity. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -103,6 +106,7 @@ Returns whether a customer number is free within the user's selected company. Pa
| `number` | `string` | The customer number to check. | | `number` | `string` | The customer number to check. |
| `[options]` | `object` | Optional options. | | `[options]` | `object` | Optional options. |
| `[options.excludeEntityId]` | `number` | An entity id to exclude from the check. | | `[options.excludeEntityId]` | `number` | An entity id to exclude from the check. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -135,6 +139,7 @@ Lists the entities (customers) of the user's selected company, optionally filter
| `[options]` | `object` | Optional options. | | `[options]` | `object` | Optional options. |
| `[options.customergroupId]` | `number` | Filter by customer group id. | | `[options.customergroupId]` | `number` | Filter by customer group id. |
| `[options.role]` | `string` | Restrict to `'customer'` role if set. | | `[options.role]` | `string` | Restrict to `'customer'` role if set. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -162,6 +167,7 @@ Returns the next customer number that would be allocated for the user's selected
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -190,6 +196,7 @@ Updates name, first name, and/or customer number of an entity, recording history
| `[patch.name]` | `string` | The new entity name. | | `[patch.name]` | `string` | The new entity name. |
| `[patch.firstName]` | `string` | The new first name. | | `[patch.firstName]` | `string` | The new first name. |
| `[patch.number]` | `string` | The new customer number. | | `[patch.number]` | `string` | The new customer number. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -24,6 +24,7 @@ Creates a new address for an entity.
| `[input.city]` | `string` | The city. | | `[input.city]` | `string` | The city. |
| `[input.county]` | `string` | The county. | | `[input.county]` | `string` | The county. |
| `[input.sort]` | `number` | Optional sort order. | | `[input.sort]` | `number` | Optional sort order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -53,6 +54,7 @@ Deletes an address of an entity.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `entityId` | `number` | The id of the entity. | | `entityId` | `number` | The id of the entity. |
| `id` | `number` | The id of the address to delete. | | `id` | `number` | The id of the address to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -80,6 +82,7 @@ Lists the addresses of an entity.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `entityId` | `number` | The id of the entity. | | `entityId` | `number` | The id of the entity. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -115,6 +118,7 @@ Updates an address of an entity.
| `[patch.city]` | `string` | The city. | | `[patch.city]` | `string` | The city. |
| `[patch.county]` | `string` | The county. | | `[patch.county]` | `string` | The county. |
| `[patch.sort]` | `number` | The sort order. | | `[patch.sort]` | `number` | The sort order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -18,6 +18,7 @@ Creates a new contact for an entity.
| `input.contactType` | `string` | The contact type (e.g. `'email'`). | | `input.contactType` | `string` | The contact type (e.g. `'email'`). |
| `input.contact` | `string` | The contact value. | | `input.contact` | `string` | The contact value. |
| `[input.sort]` | `number` | Optional sort order. | | `[input.sort]` | `number` | Optional sort order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -47,6 +48,7 @@ Deletes a contact of an entity.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `entityId` | `number` | The id of the entity. | | `entityId` | `number` | The id of the entity. |
| `id` | `number` | The id of the contact to delete. | | `id` | `number` | The id of the contact to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -74,6 +76,7 @@ Lists the contacts of an entity.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `entityId` | `number` | The id of the entity. | | `entityId` | `number` | The id of the entity. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -100,6 +103,7 @@ Updates a contact of an entity.
| `entityId` | `number` | The id of the entity. | | `entityId` | `number` | The id of the entity. |
| `id` | `number` | The id of the contact to update. | | `id` | `number` | The id of the contact to update. |
| `patch` | `object` | The update patch (any subset of contact fields). | | `patch` | `object` | The update patch (any subset of contact fields). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/google_auth/add.js`* *Source: `src/store/google_auth/add.js`*
Adds a new allowed Google auth email. Adds a new allowed Google auth email (registration). Pending `autoinvite` rows for this email are consumed in the same transaction: each is deleted and turned into a `companyuser` with the invited role.
**Parameters** **Parameters**
@@ -144,7 +144,7 @@ await store.google_auth.setProfile(42, { picture: 'url', pictureBinary: buffer,
*Source: `src/store/google_auth/setSelectedCompany.js`* *Source: `src/store/google_auth/setSelectedCompany.js`*
Sets the currently selected company for a Google auth user. Sets the currently selected company for a Google auth user. The user must be a member (`companyuser`) of the company.
**Parameters** **Parameters**
@@ -157,6 +157,10 @@ Sets the currently selected company for a Google auth user.
`Promise<void>` `Promise<void>`
**Throws**
`Error` — 'Not authorized' if the user is not a member of the company.
**Example** **Example**
```js ```js

26
API.healthcheck.md Normal file
View File

@@ -0,0 +1,26 @@
# healthcheck
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `healthcheck.check`
*Source: `src/store/healthcheck/check.js`*
Executes a database health check: 1. Ensures the healthcheck table has one row (creates with sequence 0 if not). 2. Selects that row for update with minimal, reasonable locking within a transaction. 3. Updates the sequence by 1 in a separate statement within the transaction.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{ok: boolean, sequence: number}>` — The health check result.
**Example**
```js
const result = await store.healthcheck.check();
// => { ok: true, sequence: 1 }
```

144
API.labeltemplate.md Normal file
View File

@@ -0,0 +1,144 @@
# labeltemplate
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `labeltemplate.create`
*Source: `src/store/labeltemplate/create.js`*
Creates a new label template for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Label template input. |
| `input.name` | `string` | Template name. |
| `input.width` | `number` | Canvas width in pixels. |
| `input.height` | `number` | Canvas height in pixels. |
| `input.dpi` | `number` | Printer DPI (203 or 300). |
| `input.type` | `string` | Template type ("zpl"). |
| `input.content` | `object` | Template content (JSON). |
| `[input.thumb]` | `Buffer|null` | 50×50 1-bit PNG thumbnail buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>`
**Throws**
`Error` — If required fields are missing or invalid.
**Example**
```js
const template = await store.labeltemplate.create(googleAuthId, {
name: 'Product Label',
width: 448,
height: 200,
dpi: 203,
type: 'zpl',
content: { objects: [] },
});
```
## `labeltemplate.delete`
*Source: `src/store/labeltemplate/delete.js`*
Deletes a label template for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The label template ID to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If template is not found.
## `labeltemplate.getById`
*Source: `src/store/labeltemplate/getById.js`*
Retrieves a label template by its ID for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The label template ID. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}|undefined>`
**Example**
```js
const template = await store.labeltemplate.getById(googleAuthId, 1);
```
## `labeltemplate.list`
*Source: `src/store/labeltemplate/list.js`*
Lists all label templates for the user's selected company. `content.objects` is omitted to reduce payload size.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>>`
**Example**
```js
const templates = await store.labeltemplate.list(42);
```
## `labeltemplate.update`
*Source: `src/store/labeltemplate/update.js`*
Updates a label template for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The label template ID to update. |
| `input` | `object` | Update payload. |
| `[input.name]` | `string` | Template name. |
| `[input.width]` | `number` | Canvas width in pixels. |
| `[input.height]` | `number` | Canvas height in pixels. |
| `[input.dpi]` | `number` | Printer DPI (203 or 300). |
| `[input.type]` | `string` | Template type ("zpl"). |
| `[input.content]` | `object` | Template content (JSON). |
| `[input.thumb]` | `Buffer|null` | 50×50 1-bit PNG thumbnail buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>`
**Throws**
`Error` — If template not found or input invalid.

109
API.ledger.md Normal file
View File

@@ -0,0 +1,109 @@
# ledger
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `ledger.add`
*Source: `src/store/ledger/add.js`*
Appends new append-only entry/entries to the ledger table according to manifest rules: - quote / order: no bookings possible (throws error) - delivery: acceptance entry (warehouse_id=null) + warehouse entry (if tracked) in one transaction - shipment: shipment entry (warehouse_id=null, negative amount) + warehouse entry (if tracked) in one transaction - adjustment: single warehouse inventory movement
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Ledger entry payload. |
| `input.manifestarticle_id` | `number` | ID of the referenced manifest line (can also pass manifestarticleId). |
| `[input.warehouse_id]` | `number|null` | Optional warehouse ID (can also pass warehouseId). |
| `input.transaction_type` | `string` | Transaction type (can also pass transactionType). |
| `input.amount` | `number` | Movement amount. |
| `input.transaction_text` | `string` | Transaction memo/details (can also pass transactionText). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>` — The primary ledger row.
## `ledger.listByManifestarticleId`
*Source: `src/store/ledger/listByManifestarticleId.js`*
Lists ledger entries for a specific manifest article line, optionally filtered by transaction type and/or warehouse.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `manifestarticleId` | `number` | The id of the manifest article line. |
| `[optionsOrType]` | `string|object` | Transaction type string or options object { transaction_type?: string, transactionType?: string, warehouse_id?: number, warehouseId?: number }. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, manifestarticle_id: number, warehouse_id: number|null, sku: string|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>>`
**Throws**
`Error` — If user not authorized, company not selected, or manifest article not found.
**Example**
```js
const entries = await store.ledger.listByManifestarticleId(googleAuthId, 42, 'DELIVERY');
// => [{ id, manifestarticle_id, warehouse_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }]
```
## `ledger.listBySku`
*Source: `src/store/ledger/listBySku.js`*
Lists ledger entries for a given article SKU, optionally filtered by transaction type and/or warehouse.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `[optionsOrType]` | `string|object` | Transaction type string or options object { transaction_type?: string, transactionType?: string, warehouse_id?: number, warehouseId?: number }. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, manifestarticle_id: number, warehouse_id: number|null, sku: string|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>>`
**Throws**
`Error` — If user not authorized, company not selected, or SKU invalid.
**Example**
```js
const entries = await store.ledger.listBySku(googleAuthId, 'SKU-001', 'DELIVERY');
// => [{ id, manifestarticle_id, warehouse_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }]
```
## `ledger.listHistory`
*Source: `src/store/ledger/listHistory.js`*
Lists ledger entries for the user's selected company across all articles, supporting pagination, date cutoff via seekMonths, and search.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').Provider` | |
**Returns**
`(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
**Example**
```js
const res = await store.ledger.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'kjh', seekMonths: -1 });
// => { ledger: [...], total, unfilteredTotal, offset, limit }
```

View File

@@ -2,51 +2,6 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `manifestattachment.add`
*Source: `src/store/manifestattachment/add.js`*
Attaches a file to an existing manifest.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `manifestId` | `number` | The id of the manifest. |
| `input` | `object` | The file data. |
| `input.mimetype` | `string` | The MIME type of the file (e.g. `'application/pdf'`). |
| `input.name` | `string` | The file name. |
| `input.content` | `Buffer` | The file content. |
| `[input.sort]` | `number` | Optional display sort order. |
| `[input.size]` | `number` | Optional file size in bytes (defaults to content length). |
| `[input.created]` | `Date|string` | Optional creation timestamp (defaults to current date). |
| `[input.modified]` | `Date|string` | Optional modified timestamp (defaults to created date). |
| `[input.hash]` | `string` | The file hash (auto-computed SHA-256 if omitted). |
| `[input.thumbavif]` | `Buffer` | Optional AVIF thumbnail buffer. |
| `[input.thumbx]` | `number` | Optional thumbnail width. |
| `[input.thumby]` | `number` | Optional thumbnail height. |
| `[input.meta]` | `object` | Optional string key/value metadata stored on the file row. |
**Returns**
`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` — The created attachment record.
**Throws**
`Error` — If the manifest is not found or file input is invalid.
**Example**
```js
const attachment = await store.manifestattachment.add(42, 1, {
mimetype: 'application/pdf',
name: 'invoice.pdf',
content: fileBuffer,
});
// => { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby }
```
## `manifestattachment.getById` ## `manifestattachment.getById`
*Source: `src/store/manifestattachment/getById.js`* *Source: `src/store/manifestattachment/getById.js`*
@@ -60,6 +15,7 @@ Gets an attachment for a manifest by its attachment ID or file ID, including ful
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `manifestId` | `number` | The manifest ID. | | `manifestId` | `number` | The manifest ID. |
| `id` | `number` | The attachment ID or file ID. | | `id` | `number` | The attachment ID or file ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -96,6 +52,7 @@ Reorders the attachments of a manifest.
| `manifestId` | `number` | The manifest ID. | | `manifestId` | `number` | The manifest ID. |
| `input` | `object|Array<number>` | The reorder input or array of IDs. | | `input` | `object|Array<number>` | The reorder input or array of IDs. |
| `[input.ids]` | `Array<number>` | The attachment ids in their new order. | | `[input.ids]` | `Array<number>` | The attachment ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -125,6 +82,7 @@ Removes a file attachment from a manifest and deletes the underlying file.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `manifestId` | `number` | The manifest ID. | | `manifestId` | `number` | The manifest ID. |
| `id` | `number` | The attachment ID or file ID to remove. | | `id` | `number` | The attachment ID or file ID to remove. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**

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.

483
API.md
View File

@@ -8,90 +8,137 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
> **Convention**: `PK` primary key · `?` nullable · `->tbl` foreign key · `UQ` unique · `s(N)` string · `txt` text · `i` int · `bi` bigint · `f` float · `d` decimal · `b` bit · `dt` datetime · `bin` binary · `=val` default > **Convention**: `PK` primary key · `?` nullable · `->tbl` foreign key · `UQ` unique · `s(N)` string · `txt` text · `i` int · `bi` bigint · `f` float · `d` decimal · `b` bit · `dt` datetime · `bin` binary · `=val` default
- **article**: `id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_father:b=0, fatherarticle_id?->article, fathersort:i=0, created_at:dt, updated_at:dt, UQ(fatherarticle_id, fathersort)` - **account**: `id PK, company_id->company, type:s(100), name:s(255)`
- **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` - **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` - **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)` - **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)` - **articlepic**: `id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)`
- **articlesupplier**: `id PK, article_id->article, entity_id->entity, price_id?->price, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)` - **articleproperty**: `id PK, article_id->article, property_id->propertyname, value:txt, sort:i=0`
- **articlesupplier**: `id PK, article_id->article, entity_id->entity, net?:f, tax_id?->tax, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)`
- **articletext**: `id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)` - **articletext**: `id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)`
- **attributename**: `id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)`
- **attributevalue**: `id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)`
- **attributevaluepic**: `id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)`
- **brand**: `id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)` - **brand**: `id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)`
- **brandpic**: `id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)` - **brandpic**: `id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)`
- **category**: `id PK, parent_id?->category, sort:i=0` - **category**: `id PK, parent_id?->category, sort:i=0`
- **categoryarticle**: `id PK, category_id->category, article_id->article, UQ(category_id, article_id)` - **categoryarticle**: `id PK, category_id->category, article_id->article, UQ(category_id, article_id)`
- **categoryattributevalue**: `id PK, category_id->category, attributevalue_id->attributevalue, sort:i=0, UQ(category_id, attributevalue_id)`
- **categorypic**: `id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)` - **categorypic**: `id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)`
- **categoryproperty**: `id PK, category_id->category, property_id->propertyname, value:txt, sort:i=0`
- **categorytext**: `id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)` - **categorytext**: `id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)`
- **company**: `id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup` - **company**: `id PK, name:s(255), root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup`
- **companyhistory**: `id PK, company_id->company, companyuser_id?->companyuser, date:dt, text:txt`
- **companysetting**: `id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)` - **companysetting**: `id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)`
- **companyuser**: `id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)` - **companyuser**: `id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)`
- **customergroup**: `id PK, name:s(255), sort:i=0, company_id?->company` - **customergroup**: `id PK, name:s(255), sort:i=0, company_id?->company`
- **articlewarehousesummary**: `id PK, article_id->article, warehouse_id->warehouse, stock:f=0, UQ(article_id, warehouse_id)`
- **deliverysummary**: `id PK, company_id->company, manifest_id->manifest UQ, delivery_number:s(100), external_number?:s(100), delivery_date:dt, created_at:dt, supplier_id?->entity, supplier_name?:s(255), positions:i=0, gross_total:f=0, net_total:f=0, total_quantity:f=0, stock_positions:i=0, booked_positions:i=0, unbooked_positions:i=0, stock_quantity:f=0, booked_quantity:f=0, unbooked_quantity:f=0, is_booked:b=0`
- **entity**: `id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)` - **entity**: `id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)`
- **entityaddress**: `id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0` - **entityaddress**: `id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0`
- **entitycontact**: `id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0` - **entitycontact**: `id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0`
- **entityhistory**: `id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt` - **entityhistory**: `id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt`
- **file**: `id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt` - **file**: `id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt`
- **google_auth**: `id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company` - **google_auth**: `id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company`
- **labeltemplate**: `id PK, company_id->company, name:s(255), type:s(50), width:f, height:f, data:txt, sort:i=0`
- **language**: `id PK, iso639:s(10) UQ` - **language**: `id PK, iso639:s(10) UQ`
- **ledger**: `id PK, manifestarticle_id->manifestarticle, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]` - **ledger**: `id PK, manifestarticle_id->manifestarticle, warehouse_id?->warehouse, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]`
- **manifest**: `id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt` - **manifest**: `id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt, created_at:dt`
- **manifestarticle**: `id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0` - **manifestarticle**: `id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0`
- **manifestattachment**: `id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)` - **manifestattachment**: `id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)`
- **mcp_auth_code**: `code:s(128) PK, client_id:s(100), google_auth_id->google_auth, redirect_uri:s(2048), code_challenge:s(255), code_challenge_method:s(32), scope?:s(255), expires_at:dt, used:b=0, created_at:dt`
- **mcp_client**: `id PK, client_id:s(100) UQ, client_secret?:s(255), client_name:s(255), redirect_uris:txt, grant_types?:s(255), response_types?:s(255), scope?:s(255), created_at:dt`
- **mcp_token**: `id PK, refresh_token:s(128) UQ, client_id:s(100), google_auth_id->google_auth, scope?:s(255), expires_at:dt, revoked:b=0, created_at:dt`
- **meta**: `key:s(255) PK, value:txt` - **meta**: `key:s(255) PK, value:txt`
- **modelconversation**: `id PK, companyuser_id->companyuser, provider:s(100), model:s(255), created_at:dt, updated_at:dt, state:s(20)='idle', started_by?:i`
- **modelmessage**: `id PK, conversation_id->modelconversation, created_at:dt, role:s(20), message:txt`
- **modelvfs**: `id PK, company_id->company, name:s(255), mime:s(100), content:bin, meta?:txt, created_at:dt, updated_at:dt, UQ(company_id, name)`
- **numbers**: `id PK, company_id->company, type:s(100), prefix:s(50)='', padding:i=0, next:i=1, UQ(company_id, type)` - **numbers**: `id PK, company_id->company, type:s(100), prefix:s(50)='', padding:i=0, next:i=1, UQ(company_id, type)`
- **optionslist**: `id PK` - **optionslist**: `id PK`
- **optionslistarticle**: `id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)` - **optionslistarticle**: `id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)`
- **pic**: `id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin` - **ordersummary**: `id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0`
- **posterminal**: `id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin:s(50)` - **package**: `id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt`
- **payment**: `id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f`
- **pic**: `id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), smallthumbw:i, smallthumbh:i, smallthumbbinary:bin, source:txt`
- **posterminal**: `id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), maxknownobituaryid?:i, root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt`
- **posterminalcategory**: `id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)` - **posterminalcategory**: `id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)`
- **posterminalobituary**: `id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt`
- **posterminalsyncqueue**: `id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i`
- **price**: `id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f` - **price**: `id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f`
- **syncpos**: `id PK, company_id->company, paircode:s(50) UQ, token?:s(255) UQ` - **printer**: `id PK, company_id->company, address:s(255), type:s(50), model?:s(255), serial?:s(255), sort:i=0, desc?:txt`
- **propertyname**: `id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)`
- **quotesummary**: `id PK, company_id->company, manifest_id->manifest UQ, quote_number:s(100), quote_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, total_quantity:f=0`
- **shipmentsummary**: `id PK, company_id->company, manifest_id->manifest UQ, shipment_number:s(100), shipment_date:dt, created_at:dt, order_id?:i, order_number?:s(100), entity_id?->entity, customer_name?:s(255), positions:i=0, total_quantity:f=0, package_count:i=0, carrier?:s(100), tracking_code?:s(255), state:s(50)='new'`
- **shipping**: `id PK, manifest_id->manifest, number:s(100), tracking_code?:s(255), carrier?:s(100), type:s(100), state:s(100)`
- **subagentschedule**: `id PK, company_id->company, desc?:txt, prompt:txt, skills?:txt, lastagentnote?:txt, max_itr:i=15, cron:s(100), created_at:dt, lastrun_at?:dt`
- **tax**: `id PK, rate:d(18,4) UQ` - **tax**: `id PK, rate:d(18,4) UQ`
- **unit**: `id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)` - **unit**: `id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)`
- **unittext**: `id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)` - **unittext**: `id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)`
- **warehouse**: `id PK, company_id->company, name:s(255), sort:i=0, UQ(company_id, name)`
## Table of Contents ## Table of Contents
- [companyuser](API.companyuser.md) - [companyuser](API.companyuser.md)
- `add(companyId: number, googleAuthId: number): Promise<number>` - `add(companyId: number, googleAuthId: number, role?: 'admin'|'user', tx?: object): Promise<number>`
- `delete(id: number): Promise<void>` - `addByEmail(googleAuthId: number, input: {email: string, role?: 'admin'|'user'}, tx?: object): Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>`
- `getByEmail(email: string): Promise<Array<number>>` - `delete(id: number, tx?: object): Promise<void>`
- `getByEmail(email: string, tx?: object): Promise<Array<number>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>>`
- `remove(googleAuthId: number, id: number, tx?: object): Promise<{id: number, googleAuthId: number}>`
- `requireAdmin(googleAuthId: number, tx?: object): Promise<{companyId: number, companyuserId: number, role: string}>`
- `roles(role: unknown): 'admin'|'user'`
- `setRole(googleAuthId: number, id: number, role: 'admin'|'user', tx?: object): Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>`
- `withTransaction(): void`
- [company](API.company.md) - [company](API.company.md)
- `create(input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number, googleAuthId: number}): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>` - `addHistory(googleAuthId: number, input: {text?: string}, tx?: object): Promise<{id: number, companyuserId: number|null, date: string, text: string}>`
- `delete(companyId: number): Promise<void>` - `create(input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number, googleAuthId: number}, tx?: object): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>`
- `delete(companyId: number, tx?: object): Promise<void>`
- `getById(id: number, tx?: object): Promise<object|undefined>` - `getById(id: number, tx?: object): Promise<object|undefined>`
- `getByName(name: string, tx?: object): Promise<object|undefined>` - `getByName(name: string, tx?: object): Promise<object|undefined>`
- `list(googleAuthId: number): Promise<Array<object>>` - `getHistory(googleAuthId: number, opts?: {limit?: number}, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `update(id: number, input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number}): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number}>` - `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) - [category](API.category.md)
- `addImage(googleAuthId: number, categoryId: number, image: Buffer): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>` - `addImage(googleAuthId: number, categoryId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number}>`
- `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}): Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>` - `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}, tx?: object): Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>`
- `delete(input: {googleAuthId: number, id: number}): Promise<void>` - `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>` - `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>` - `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `getCategoryArticles(googleAuthId: number, categoryId: number): Promise<Array<object>>` - `generateImage(googleAuthId: number, inputOrCategoryId: number|object, maybeOptsOrSize?: {size?: number}, maybeTx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>`
- `getTreeForCompany(googleAuthId: number): Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>` - `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, smallThumbW: number|null, smallThumbH: number|null, smallThumbBinary: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>`
- `move(input: {googleAuthId: number, id: number, parentId: number}): Promise<{id: number, parentId: number, sort: number}>` - `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<{id: number, parentId: number|null, sort: number, name: string|null, slug: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, articleCount: number, posterminalIds: number[]}>, articleCount: number}>`
- `order(input: {googleAuthId: number, parentId: number, categoryIds: Array<number>}): Promise<{categoryIds: Array<number>}>` - `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>`
- `rename(input: {googleAuthId: number, id: number, name: string}): Promise<{id: number, name: string, slug: string}>` - `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}>`
- [article](API.article.md) - [article](API.article.md)
- `addImage(googleAuthId: number, sku: string, image: Buffer): Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>` - `addChild(googleAuthId: number, parentSku: string, options: {attach?: boolean, attributeValueId?: number, value?: string, name?: string, unitamount?: number, weight?: number, track_stock?: boolean|number}, tx?: object): Promise<object>`
- `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}): Promise<{id: number, parentId: number, name: string, sku: string}>` - `addImage(googleAuthId: number, sku: string, image: Buffer, tx?: object): Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, smallThumb: {w: number, h: number}, fullHash: string, thumbHash: string}>`
- `delete(googleAuthId: number, sku: string): Promise<void>` - `attachChild(googleAuthId: number, parentSku: string, options: {sku: string, childSku?: string, attributeValueId?: number, value?: string, reparent?: boolean, unitamount?: number, weight?: number, track_stock?: boolean|number}, tx?: object): Promise<{article: object, children: Array<object>}>`
- `deleteImage(googleAuthId: number, sku: string, fullHash: string): Promise<void>` - `createUnderParent(input: {googleAuthId: number, parentId: number, name: string, desc?: string, seo?: string, track_stock?: boolean|number}, tx?: object): Promise<{id: number, parentId: number, name: string, sku: string, track_stock: boolean}>`
- `getBySku(googleAuthId: number, sku: string): Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|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, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` - `delete(googleAuthId: number, sku: string, optionsOrTx?: object|*, maybeTx?: object): Promise<void>`
- `getHistoryBySku(googleAuthId: number, sku: string): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>` - `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise<void>`
- `importRows(input: {googleAuthId: number, rows: Array<object>}): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>` - `getBySku(googleAuthId: number): 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, incoming: Array<{date: string, amount: number}>}>, warehouses: Array<{id: number, name: string, sort: number, stock: number}>, undeliveredOrders: Array<{orderId: number, orderNumber: string, date: string, customerName: string|null, amount: number}>, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null}}>`
- `listExport(googleAuthId: number, categoryId?: number|null): Promise<Array<object>>` - `getHistoryBySku(googleAuthId: number, sku: string, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number, paged?: boolean}, tx?: object): Promise<{history: Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<{id: number, companyuserId: number|null, date: string, text: string, meta: string|null}>>`
- `listSearchIndex(googleAuthId: number): Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>` - `importRows(input: {googleAuthId: number, rows: Array<object>}, tx?: object): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>`
- `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array<string>}): Promise<{fullHashes: Array<string>}>` - `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}>>`
- `setBrandIdBySku(googleAuthId: number, sku: string, brandId: number|null): Promise<object>` - `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise<Array<object>>`
- `setCategoryIdsBySku(googleAuthId: number, sku: string, categoryIds: Array<number>): Promise<object>` - `listHistory(provider: import('../../providers/provider.js').Provider): (googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
- `setEansBySku(googleAuthId: number, sku: string, eans: Array<string>): Promise<object>` - `listSearchIndex(googleAuthId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>`
- `setFatherBySku(googleAuthId: number, sku: string, fatherSku: string|null): Promise<object>` - `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array<string>}, tx?: object): Promise<{fullHashes: Array<string>}>`
- `setPricesBySku(googleAuthId: number, sku: string, prices: Array<{price: number, tax: number, minmultiplier?: number}>): Promise<object>` - `removeChild(googleAuthId: number, parentSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>`
- `setWeightBySku(googleAuthId: number, sku: string, weight: number): Promise<object>` - `resolveUniqueSlug(runner: object, params: {companyId: number, languageId: number, name: string, excludeArticleId?: number}): Promise<string>`
- `updateBySku(googleAuthId: number, sku: string, patch: {name?: string, unitSi?: string, unitamount?: number}): Promise<object>` - `setBrandIdBySku(googleAuthId: number, sku: string, brandId: number|null, tx?: object): Promise<object>`
- `setCategoryIdsBySku(googleAuthId: number, sku: string, categoryIds: Array<number>, tx?: object): Promise<object>`
- `setEansBySku(googleAuthId: number, sku: string, eans: Array<string>, tx?: object): Promise<object>`
- `setParentBySku(googleAuthId: number, sku: string, parentSku: string|null, tx?: object): Promise<object>`
- `setPricesBySku(googleAuthId: number, sku: string, prices: Array<{net?: number, gross?: number, price?: number, taxRate?: number, tax?: number, minmultiplier?: number}>, tx?: object): Promise<object>`
- `setTrackStockBySku(googleAuthId: number, sku: string, trackStock: boolean|number, tx?: object): Promise<object>`
- `setWeightBySku(googleAuthId: number, sku: string, weight: number, tx?: object): Promise<object>`
- `toggleParent(googleAuthId: number, sku: string, options: {isParent: boolean, attributeNameId?: number, parentAttributeNameId?: number}, tx?: object): Promise<object>`
- `updateBySku(googleAuthId: number, sku: string, patch: {name?: string, desc?: string, seo?: string, unitSi?: string, unitamount?: number, track_stock?: boolean|number}, tx?: object): Promise<object>`
- [lang](API.lang.md) - [lang](API.lang.md)
- `create(iso639: string, tx?: object): Promise<number>` - `create(iso639: string, tx?: object): Promise<number>`
- `delete(id: number): Promise<void>` - `delete(id: number): Promise<void>`
@@ -104,86 +151,151 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `getByRate(rate: number, tx?: object): Promise<{id: number, rate: number}>` - `getByRate(rate: number, tx?: object): Promise<{id: number, rate: number}>`
- `list(): Promise<Array<{id: number, rate: number}>>` - `list(): Promise<Array<{id: number, rate: number}>>`
- [unit](API.unit.md) - [unit](API.unit.md)
- `delete(googleAuthId: number, si: string): Promise<void>` - `delete(googleAuthId: number, si: string, tx?: object): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>>` - `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>>`
- `upsert(googleAuthId: number, input: {si: string, basesi: string, basesifactor: number, symbol: string, name: string}): Promise<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>` - `upsert(googleAuthId: number, input: {si: string, basesi: string, basesifactor: number, symbol: string, name: string}, tx?: object): Promise<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>`
- [companysetting](API.companysetting.md) - [companysetting](API.companysetting.md)
- `get(googleAuthId: number, key: string, options?: {frontendOnly?: boolean, maskWriteonly?: boolean}): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>` - `get(googleAuthId: number, key: string, options?: {frontendOnly?: boolean, maskWriteonly?: boolean}, tx?: object): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>`
- `list(googleAuthId: number, options?: {frontendOnly?: boolean, maskWriteonly?: boolean}): Promise<Array<{key: string, value: string, frontend: boolean, writeonly: boolean}>>` - `list(googleAuthId: number, options?: {frontendOnly?: boolean, maskWriteonly?: boolean}, tx?: object): Promise<Array<{key: string, value: string, frontend: boolean, writeonly: boolean}>>`
- `set(googleAuthId: number, input: {key: string, value: string, frontend?: boolean, writeonly?: boolean}): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>` - `set(googleAuthId: number, input: {key: string, value: string, frontend?: boolean, writeonly?: boolean}, tx?: object): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>`
- [brand](API.brand.md) - [brand](API.brand.md)
- `delete(googleAuthId: number, id: number): Promise<void>` - `addImage(googleAuthId: number, brandId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>` - `create(googleAuthId: number, input: {name: string, gsprpinfo?: string}, tx?: object): Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>`
- `upsert(googleAuthId: number, input: {name: string, gsprpinfo?: string}): Promise<{id: number, name: string, gsprpinfo: string|null}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, includeSmallThumbs?: boolean}, tx?: object): Promise<{brands: Array<{id: number, name: string, articleCount: number, gsprpinfo: string|null, pics: Array<{fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW?: number|null, smallThumbH?: number|null, smallThumbBinary?: string|null}>}>, total: number, offset: number, limit: number}>`
- `update(googleAuthId: number, id: number, input: {name?: string, gsprpinfo?: string}, tx?: object): Promise<{id: number, name: string, gsprpinfo: string|null, touchedArticleCount: number}>`
- `upsert(googleAuthId: number, input: {name: string, gsprpinfo?: string}, tx?: object): Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>`
- [customergroup](API.customergroup.md) - [customergroup](API.customergroup.md)
- `create(googleAuthId: number, input: {name: string}): Promise<{id: number, name: string, sort: number}>` - `create(googleAuthId: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- `delete(googleAuthId: number, id: number): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, sort: number}>>` - `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, sort: number}>>`
- `order(googleAuthId: number, input: {customergroupIds: Array<number>}): Promise<{customergroupIds: Array<number>}>` - `order(googleAuthId: number, input: {customergroupIds: Array<number>}, tx?: object): Promise<{customergroupIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name: string}): Promise<{id: number, name: string, sort: number}>` - `update(googleAuthId: number, id: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- [entity](API.entity.md) - [entity](API.entity.md)
- `create(googleAuthId: number, input: {customergroupId: number, name: string, firstName?: string, number?: string}): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>` - `create(googleAuthId: number, input: {customergroupId: number, name: string, firstName?: string, number?: string}, tx?: object): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>`
- `delete(googleAuthId: number, id: number): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>` - `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>`
- `isNumberFree(googleAuthId: number, number: string, options?: {excludeEntityId?: number}): Promise<boolean>` - `isNumberFree(googleAuthId: number, number: string, options?: {excludeEntityId?: number}, tx?: object): Promise<boolean>`
- `list(googleAuthId: number, options?: {customergroupId?: number, role?: string}): Promise<Array<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>>` - `list(googleAuthId: number, options?: {customergroupId?: number, role?: string}, tx?: object): Promise<Array<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>>`
- `peekNextNumber(googleAuthId: number): Promise<string>` - `peekNextNumber(googleAuthId: number, tx?: object): Promise<string>`
- `update(googleAuthId: number, id: number, patch: {name?: string, firstName?: string, number?: string}): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>` - `update(googleAuthId: number, id: number, patch: {name?: string, firstName?: string, number?: string}, tx?: object): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>`
- [entityaddress](API.entityaddress.md) - [entityaddress](API.entityaddress.md)
- `create(googleAuthId: number, entityId: number, input: {addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string, sort?: number}): Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>` - `create(googleAuthId: number, entityId: number, input: {addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string, sort?: number}, tx?: object): Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>`
- `delete(googleAuthId: number, entityId: number, id: number): Promise<void>` - `delete(googleAuthId: number, entityId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, entityId: number): Promise<Array<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>>` - `list(googleAuthId: number, entityId: number, tx?: object): Promise<Array<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>>`
- `update(googleAuthId: number, entityId: number, id: number, patch: {addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string, sort?: number}): Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>` - `update(googleAuthId: number, entityId: number, id: number, patch: {addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string, sort?: number}, tx?: object): Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>`
- [entitycontact](API.entitycontact.md) - [entitycontact](API.entitycontact.md)
- `create(googleAuthId: number, entityId: number, input: {contactType: string, contact: string, sort?: number}): Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>` - `create(googleAuthId: number, entityId: number, input: {contactType: string, contact: string, sort?: number}, tx?: object): Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>`
- `delete(googleAuthId: number, entityId: number, id: number): Promise<void>` - `delete(googleAuthId: number, entityId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, entityId: number): Promise<Array<{id: number, entityId: number, contactType: string, contact: string, sort: number}>>` - `list(googleAuthId: number, entityId: number, tx?: object): Promise<Array<{id: number, entityId: number, contactType: string, contact: string, sort: number}>>`
- `update(googleAuthId: number, entityId: number, id: number, patch: object): Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>` - `update(googleAuthId: number, entityId: number, id: number, patch: object, tx?: object): Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>`
- [supplier](API.supplier.md) - [supplier](API.supplier.md)
- `create(googleAuthId: number, input: {name: string}): Promise<{id: number, name: string, createdAt: string, updatedAt: string}>` - `create(googleAuthId: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, createdAt: string, updatedAt: string}>`
- `delete(googleAuthId: number, id: number): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, createdAt: string, updatedAt: string}>>` - `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, createdAt: string, updatedAt: string}>>`
- `update(googleAuthId: number, id: number, input: {name: string}): Promise<{id: number, name: string, createdAt: string, updatedAt: string}>` - `update(googleAuthId: number, id: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, createdAt: string, updatedAt: string}>`
- [articlesupplier](API.articlesupplier.md) - [articlesupplier](API.articlesupplier.md)
- `create(googleAuthId: number, sku: string, input: {entityId: number, sku?: string, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>` - `create(googleAuthId: number, sku: string, input: {entityId: number, sku?: string, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}, tx?: object): Promise<{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}>`
- `delete(googleAuthId: number, sku: string, id: number): Promise<void>` - `delete(googleAuthId: number, sku: string, id: number, tx?: object): Promise<void>`
- `listByArticleSku(googleAuthId: number, sku: string): Promise<Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>>` - `listByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<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}>>`
- `listByEntityAndSkus(googleAuthId: number, entityIdOrOptions: number|{entityId?: number, supplierId?: number, skus?: string[]|string}, maybeSkus?: string[]|string): Promise<Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null, article: {id: number, sku: string, name: string|null, slug: string|null, photos: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>` - `listByEntityAndSkus(googleAuthId: number, entityIdOrOptions: number|{entityId?: number, supplierId?: number, skus?: string[]|string}, maybeSkus?: string[]|string, tx?: object): Promise<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, article: {id: number, sku: string, name: string|null, slug: string|null, photos: Array<{sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[]}}>>`
- `order(googleAuthId: number, sku: string, input: {ids: Array<number>}): Promise<{ids: Array<number>}>` - `order(googleAuthId: number, sku: string, input: {ids: Array<number>}, tx?: object): Promise<{ids: Array<number>}>`
- `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>` - `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}, tx?: object): Promise<{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}>`
- [delivery](API.delivery.md) - [delivery](API.delivery.md)
- `attachments(): void` - `attachments(): void`
- `create(googleAuthId: number, body: {date: string, supplierId: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number, 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}>}>` - `book(provider: object): Function`
- `delete(googleAuthId: number, id: number): Promise<void>` - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number}, tx?: object): Promise<{deliveries: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
- `getById(googleAuthId: number, id: number): Promise<{id: number, number: string, type: string, entityId: number, 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}>}>` - `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `list(googleAuthId: number): Promise<Array<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string}>>` - `syncSummary(): void`
- `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, supplierId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number, 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}>}>`
- [quote](API.quote.md) - [quote](API.quote.md)
- `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}>}): 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}>}>` - `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): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number): 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}>}>` - `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): Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>` - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object): Promise<{quotes: 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}>, positions?: number, grossTotal?: number, totalQuantity?: number, customerName?: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
- `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}>}): 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}>}>` - `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}>}>`
- [order](API.order.md) - [order](API.order.md)
- `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}>}): 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}>}>` - `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, shippable?: boolean}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number, 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): Promise<void>` - `createFromQuote(googleAuthId: number, quoteId: number, body?: {number?: string, date?: string, createdAt?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null, shippable?: boolean}>}, 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}>}>`
- `getById(googleAuthId: number, id: number): 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}>}>` - `createShipment(googleAuthId: number, orderId: number, input: {lines: Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>, date?: string|Date, externalNumber?: string, shipping: object}, tx?: object): Promise<object>`
- `list(googleAuthId: number): Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<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}>}): 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}>}>` - `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, 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, unfilteredTotal: 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, shippable?: boolean}>}, 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`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, state?: 'new'|'delivered'|'cancelled'|'all', includeCancelled?: boolean, seekMonths?: number, orderId?: number}, tx?: object): Promise<{shipments: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
- `listByOrderId(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `rebuildSummaries(googleAuthId: number, tx?: object): Promise<void>`
- `syncSummary(): void`
- `update(provider: import('../../providers/provider.js').DatabaseProvider): void`
- [shipping](API.shipping.md)
- `create(googleAuthId: number, input: {manifestId: number, carrierId: number, type: string, state: string, trackingCode?: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrierId: number, 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) - [manifestattachment](API.manifestattachment.md)
- `add(googleAuthId: number, manifestId: number, input: {mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string, thumbavif?: Buffer, thumbx?: number, thumby?: number, meta?: object}): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` - `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}>`
- `getById(googleAuthId: number, manifestId: number, id: number): 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` - `listByManifestId(): void`
- `order(googleAuthId: number, manifestId: number, input: {ids?: Array<number>}): Promise<{ids: Array<number>}>` - `order(googleAuthId: number, manifestId: number, input: {ids?: Array<number>}, tx?: object): Promise<{ids: Array<number>}>`
- `remove(googleAuthId: number, manifestId: number, id: number): Promise<void>` - `remove(googleAuthId: number, manifestId: number, id: number, tx?: object): Promise<void>`
- [ledger](API.ledger.md)
- `add(googleAuthId: number, input: {manifestarticle_id: number, warehouse_id?: number|null, transaction_type: string, amount: number, transaction_text: string}, tx?: object): Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>`
- `listByManifestarticleId(googleAuthId: number, manifestarticleId: number, optionsOrType?: string|object, tx?: object): Promise<Array<{id: number, manifestarticle_id: number, warehouse_id: number|null, sku: string|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>>`
- `listBySku(googleAuthId: number, sku: string, optionsOrType?: string|object, tx?: object): Promise<Array<{id: number, manifestarticle_id: number, warehouse_id: number|null, sku: string|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>>`
- `listHistory(provider: import('../../providers/provider.js').Provider): (googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>`
- [warehouse](API.warehouse.md)
- `create(googleAuthId: number, input: {name: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, name: string, sort: number}|undefined>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, sort: number}>>`
- `order(googleAuthId: number, input: {warehouseIds: Array<number>}, tx?: object): Promise<{warehouseIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- [stock](API.stock.md)
- `accept(provider: object): Function`
- `adjust(googleAuthId: number, input: {sku?: string, warehouseId?: number, amount?: number, text?: string, externalNumber?: string, date?: string|Date, lines?: Array<object>}, tx?: object): Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>`
- `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `ship(provider: object): Function`
- [posterminal](API.posterminal.md) - [posterminal](API.posterminal.md)
- `delete(googleAuthId: number, id: number): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, token: string, pairingpin: string}>>` - `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>`
- `setOff(googleAuthId: number, posterminalId: number, categoryId: number): Promise<{id: number, categoryIds: Array<number>}>` - `findByToken(token: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>`
- `setOffAll(googleAuthId: number, posterminalId: number): Promise<{id: number, categoryIds: Array<number>}>` - `getCategoryCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `setOn(googleAuthId: number, posterminalId: number, categoryId: number): Promise<{id: number, categoryIds: Array<number>}>` - `getCategoryList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `setOnAll(googleAuthId: number, posterminalId: number): Promise<{id: number, categoryIds: Array<number>}>` - `getCompositeProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number}): Promise<{id: number, name: string, token: string, pairingpin: string}>` - `getCustomerCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getCustomerGroupCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getCustomerGroupList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<{customerGroupId: string, name: string, standard: string, discountPercent: string, lastChanged: string}>>`
- `getCustomerList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `getDeletedEntityCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getDeletedEntityList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<{id: number, entityType: string, entityId: number}>>`
- `getMaxOrderIdCount(input: {posterminalId: number}, tx?: object): Promise<number>`
- `getProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getProductList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>`
- `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string, clientversion?: string|null}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>`
- `recordObituary(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise<void>`
- `recordSyncQueue(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise<void>`
- `setClientversion(id: number, clientversion: string|null, tx?: object): Promise<{id: number, clientversion: string|null}>`
- `setMaxKnownObituaryId(id: number, maxknownobituaryid: number|null, tx?: object): Promise<{id: number, maxknownobituaryid: number|null}>`
- `setOff(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOffAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOn(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOnAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `touchLastseen(id: number, tx?: object): Promise<{id: number}>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number, sort?: number, enabled?: boolean}, tx?: object): Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>`
- [printer](API.printer.md)
- `create(googleAuthId: number, input: {address: string, type: string, model?: string, serial?: string, sort?: number, desc?: string}, tx?: object): Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}|undefined>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>>`
- `order(googleAuthId: number, input: {printerIds: Array<number>}, tx?: object): Promise<{printerIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {address?: string, type?: string, model?: string|null, serial?: string|null, sort?: number, desc?: string|null}, tx?: object): Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>`
- [google_auth](API.google_auth.md) - [google_auth](API.google_auth.md)
- `add(email: string): Promise<number>` - `add(email: string): Promise<number>`
- `getByEmail(email: string): Promise<object|undefined>` - `getByEmail(email: string): Promise<object|undefined>`
@@ -193,8 +305,157 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `setProfile(googleAuthId: number, input: {picture?: string, pictureBinary?: Buffer, sub?: string}): Promise<void>` - `setProfile(googleAuthId: number, input: {picture?: string, pictureBinary?: Buffer, sub?: string}): Promise<void>`
- `setSelectedCompany(googleAuthId: number, companyId: number): Promise<void>` - `setSelectedCompany(googleAuthId: number, companyId: number): Promise<void>`
- [pic](API.pic.md) - [pic](API.pic.md)
- `getImageByHash(googleAuthId: number, fullHash: string): Promise<{buffer: Buffer, w: number, h: number, size: number, contentType: string}>` - `getImageByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, size: number, contentType: string}>`
- `getThumbByHash(googleAuthId: number, fullHash: string): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>` - `getSmallThumbByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- `getThumbByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- [numbers](API.numbers.md) - [numbers](API.numbers.md)
- `list(googleAuthId: number): Promise<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>` - `ensureType(runner: object, companyId: number, type: string): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>`
- `update(googleAuthId: number, id: number, input: {prefix?: string, padding?: number, next?: number}): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>` - `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>`
- `update(googleAuthId: number, id: number, input: {prefix?: string, padding?: number, next?: number}, tx?: object): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>`
- [propertyname](API.propertyname.md)
- `create(googleAuthId: number, input: {name: string, sort?: number, exclusive?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, exclusive: boolean}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, sort: number, exclusive: boolean, articleCount: number}>>`
- `order(googleAuthId: number, input: {propertynameIds: Array<number>}, tx?: object): Promise<{propertynameIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number, exclusive?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, exclusive: boolean}>`
- [articleproperty](API.articleproperty.md)
- `delete(googleAuthId: number, sku: string, propertyId: number, tx?: object): Promise<void>`
- `listByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
- `setByArticleSku(googleAuthId: number, sku: string, properties: Array<{propertyId: number, value: string, sort?: number}>, tx?: object): Promise<Array<{id: number, articleId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
- [attributename](API.attributename.md)
- `create(googleAuthId: number, input: {name: string, sort?: number, exclusive?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, sort: number, exclusive: boolean, articleCount: number, values: Array<{id: number, value: string, sort: number, articleCount: number}>}>>`
- `order(googleAuthId: number, input: {attributenameIds: Array<number>}, tx?: object): Promise<{attributenameIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number, exclusive?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
- [attributevalue](API.attributevalue.md)
- `addImage(googleAuthId: number, attributeValueId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>`
- `create(googleAuthId: number, attributeId: number, input: {value: string, sort?: number}, tx?: object): Promise<{id: number, attributeId: number, value: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, attributeValueId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null, pics: Array}>`
- `listByAttributeId(googleAuthId: number, attributeId: number, tx?: object): Promise<Array<{id: number, attributeId: number, value: string, sort: number, articleCount: number}>>`
- `order(googleAuthId: number, attributeId: number, input: {attributevalueIds: Array<number>}, tx?: object): Promise<{attributevalueIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {value?: string, sort?: number}, tx?: object): Promise<{id: number, attributeId: number, value: string, sort: number}>`
- [articleattributevalue](API.articleattributevalue.md)
- `listByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`
- `setByArticleSku(googleAuthId: number, sku: string, values: Array<number|{attributeValueId: number, sort?: number}>, tx?: object): Promise<Array<{id: number, articleId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`
- [labeltemplate](API.labeltemplate.md)
- `create(googleAuthId: number, input: {name: string, width: number, height: number, dpi: number, type: string, content: object, thumb?: Buffer|null}, tx?: object): Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}|undefined>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>>`
- `update(googleAuthId: number, id: number, input: {name?: string, width?: number, height?: number, dpi?: number, type?: string, content?: object, thumb?: Buffer|null}, tx?: object): Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>`
- [healthcheck](API.healthcheck.md)
- `check(tx?: object): Promise<{ok: boolean, sequence: number}>`
- [account](API.account.md)
- `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}>>`
- [agentprinter](API.agentprinter.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<{id: number}>`
- `findOrCreate(printeragentId: number, input: {type: string, model?: string|null, serial?: string|null}, tx?: object): Promise<{created: boolean, agentprinter: {id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}}>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}|null>`
- `list(googleAuthId: number, printeragentId?: number, tx?: object): Promise<Array<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>>`
- `order(googleAuthId: number, input: {printeragentId?: number, agentprinterIds: number[]}, tx?: object): Promise<{agentprinterIds: number[]}>`
- `update(googleAuthId: number, input: {id: number, enabled?: boolean, sort?: number}, tx?: object): Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>`
- [articlehistory](API.articlehistory.md)
- `record(): void`
- [articlelist](API.articlelist.md)
- `getItemsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`
- `setItemsByArticleSku(googleAuthId: number, sku: string, items?: Array<{articleId?: number, sku?: string, count?: number, sort?: number}>, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`
- [articlewarehousesummary](API.articlewarehousesummary.md)
- `syncSummary(): void`
- [autoinvite](API.autoinvite.md)
- `add(googleAuthId: number, input: {email: string, role?: 'admin'|'user'}, tx?: object): Promise<{id: number, email: string, role: 'admin'|'user'}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, email: string, role: 'admin'|'user', createdAt: string}>>`
- `remove(googleAuthId: number, id: number, tx?: object): Promise<void>`
- [carrier](API.carrier.md)
- `create(googleAuthId: number, input: {name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}|undefined>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>>`
- `order(googleAuthId: number, input: {carrierIds: Array<number>}, tx?: object): Promise<{carrierIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>`
- [categoryattributevalue](API.categoryattributevalue.md)
- `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, categoryId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`
- `setByCategoryId(googleAuthId: number, categoryId: number, values: Array<number|{attributeValueId: number, sort?: number}>, tx?: object): Promise<Array<{id: number, categoryId: number, attributeId: number, attributeName: string, exclusive: boolean, attributeValueId: number, value: string, sort: number}>>`
- [categoryproperty](API.categoryproperty.md)
- `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, 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, 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}>>`
- [modelvfs](API.modelvfs.md)
- `delete(googleAuthId: number, name: string, tx?: object): Promise<{deleted: boolean}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, company_id: number, name: string, mime: string, meta: any, created_at: string, updated_at: string}>>`
- `read(googleAuthId: number, name: string, tx?: object): Promise<{id: number, company_id: number, name: string, mime: string, content: Buffer, meta: any, created_at: string, updated_at: string}>`
- `reset(googleAuthId: number, tx?: object): Promise<{deletedCount: number}>`
- `write(googleAuthId: number, input: {name: string, mime?: string, content: Buffer|string, meta?: object|string}, tx?: object): Promise<object>`
- [optionslist](API.optionslist.md)
- `getOptionsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>`
- `setOptionsByArticleSku(googleAuthId: number, sku: string, options?: Array<{articleId?: number, sku?: string, count?: number, sort?: number, mandated?: boolean}>, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>`
- [package](API.package.md)
- `create(googleAuthId: number, input: {shippingId: number, entityaddressId: number, type: string, state: string, details?: string}, tx?: object): Promise<{id: number, shippingId: number, entityaddressId: number, number: string, type: string, state: string, details: string|null}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByShippingId(googleAuthId: number, shippingId: number, tx?: object): Promise<Array<{id: number, shippingId: number, entityaddressId: number, number: string, type: string, state: string, details: string|null}>>`
- [payment](API.payment.md)
- `create(googleAuthId: number, input: {accountId: number, manifestId: number, type: string, amount: number}, tx?: object): Promise<{id: number, accountId: number, manifestId: number, type: string, amount: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, accountId: number, manifestId: number, type: string, amount: number}>>`
- [printeragent](API.printeragent.md)
- `create(googleAuthId: number, input: {name: string, pairingpin: string, sort?: number}, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>`
- `findByToken(token: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>>`
- `pair(id: number, input: {token: string, clientserial?: string, clientmodel?: string, agentversion?: string, pairedAt?: string}, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- `touchLastseen(id: number, agentversion?: string|null, tx?: object): Promise<{id: number}>`
- `update(googleAuthId: number, input: {id: number, name?: string, sort?: number}, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- `updateState(id: number, clientstate: string|null, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- [printerjob](API.printerjob.md)
- `create(googleAuthId: number, input: {agentprinterId: number, data: string, type?: string, state?: string}, tx?: object): Promise<object>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<object>`
- `getById(auth: number|{agentId: number}, id: number, tx?: object): Promise<object|null>`
- `list(auth: number|{agentId: number}, filters?: {agentprinterId?: number, state?: string}, tx?: object): Promise<Array<object>>`
- `updateState(auth: number|{agentId: number}, id: number, state: string, tx?: object): Promise<object>`
- [retoure](API.retoure.md)
- `create(googleAuthId: number, body: {number?: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<object>}, tx?: object): Promise<object>`
- `createShipment(googleAuthId: number, retoureId: number, input: object, tx?: object): Promise<object>`
- `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, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number}, tx?: object): Promise<{retoures: Array<object>, total: number, unfilteredTotal: 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<object>}, tx?: object): Promise<object>`
- [return](API.return.md)
- `book(provider: object): Function`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<object>`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number}, tx?: object): Promise<{returns: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
- `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `syncSummary(): void`
- [subagentschedule](API.subagentschedule.md)
- `create(googleAuthId: number, input: {prompt: string, cron: string, desc?: string, skills?: string|Array<string>, lastagentnote?: string, max_itr?: number}, tx?: object): Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}|null>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>>`
- `update(googleAuthId: number, id: number, input: {prompt?: string, cron?: string, desc?: string|null, skills?: string|Array<string>|null, lastagentnote?: string|null, max_itr?: number, lastrun_at?: string|null}, tx?: object): Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>`

124
API.modelconversation.md Normal file
View File

@@ -0,0 +1,124 @@
# modelconversation
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `modelconversation.create`
*Source: `src/store/modelconversation/create.js`*
Creates a new (idle) model conversation for the user in the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `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, started_by: number|null}>`
**Throws**
`Error` — 'Provider required', 'Model required' or 'Invalid started_by'.
**Example**
```js
const c = await store.modelconversation.create(42, { provider: 'openrouter', model: 'openai/gpt-5' });
```
## `modelconversation.delete`
*Source: `src/store/modelconversation/delete.js`*
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**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — 'Conversation not found'.
## `modelconversation.get`
*Source: `src/store/modelconversation/get.js`*
Gets a single model conversation by ID for the user in the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `[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, 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'.
## `modelconversation.list`
*Source: `src/store/modelconversation/list.js`*
Lists the user's own model conversations in the selected company, newest activity first.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | 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, 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**
```js
const conversations = await store.modelconversation.list(42);
```
## `modelconversation.setState`
*Source: `src/store/modelconversation/setState.js`*
Sets the state of a model conversation ('idle' | 'busy').
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `state` | `string` | The new state ('idle' | 'busy'). |
| `[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, started_by: number|null}>`
**Throws**
`Error` — 'Invalid state' or 'Conversation not found'.

56
API.modelmessage.md Normal file
View File

@@ -0,0 +1,56 @@
# modelmessage
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `modelmessage.add`
*Source: `src/store/modelmessage/add.js`*
Appends a message to one of the user's own conversations. `user` messages are refused unless the conversation state is `idle`; other roles ('assistant', 'system', 'tool', 'reasoning') touch `updated_at` unconditionally.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `input` | `object` | Message input. |
| `input.message` | `string` | Message text. |
| `[input.role='user']` | `'user'|'assistant'|'system'|'tool'|'reasoning'|'usage'` | Message role. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, conversation_id: number, created_at: string, role: string, message: string}>`
**Throws**
`Error` — 'Conversation not found', 'Conversation is not idle', 'Message required', 'Invalid role'.
## `modelmessage.list`
*Source: `src/store/modelmessage/list.js`*
Lists messages of one of the user's own conversations, oldest first.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, conversation_id: number, created_at: string, role: string, message: string}>>`
**Throws**
`Error` — 'Conversation not found' if missing or owned by another user.
**Example**
```js
const messages = await store.modelmessage.list(42, 7);
```

99
API.modelvfs.md Normal file
View File

@@ -0,0 +1,99 @@
# modelvfs
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `modelvfs.delete`
*Source: `src/store/modelvfs/delete.js`*
Deletes a virtual file by name for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `name` | `string` | File name. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{deleted: boolean}>`
## `modelvfs.list`
*Source: `src/store/modelvfs/list.js`*
Lists virtual files for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, company_id: number, name: string, mime: string, meta: any, created_at: string, updated_at: string}>>`
## `modelvfs.read`
*Source: `src/store/modelvfs/read.js`*
Reads a virtual file by name for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `name` | `string` | File name. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, company_id: number, name: string, mime: string, content: Buffer, meta: any, created_at: string, updated_at: string}>`
**Throws**
`Error` — 'File not found'
## `modelvfs.reset`
*Source: `src/store/modelvfs/reset.js`*
Deletes all virtual files for the selected company (reset VFS).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{deletedCount: number}>`
## `modelvfs.write`
*Source: `src/store/modelvfs/write.js`*
Writes (inserts or overwrites) a virtual file for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `input.name` | `string` | File path/name (e.g. 'candidate.avif', 'report.json'). |
| `[input.mime='application/octet-stream']` | `string` | Mime type. |
| `input.content` | `Buffer|string` | File content buffer or string. |
| `[input.meta]` | `object|string` | Optional metadata object or JSON string. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The stored file record metadata.

View File

@@ -2,17 +2,36 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `numbers.ensureType`
*Source: `src/store/numbers/ensureType.js`*
Ensures a number sequence row exists for a company and type, inferring the company's prefix style, padding, and next counter if not yet created or unconfigured.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `runner` | `object` | The database runner. |
| `companyId` | `number` | The id of the company. |
| `type` | `string` | The number type (e.g. 'order', 'quote', 'sku'). |
**Returns**
`Promise<{id: number, type: string, prefix: string, padding: number, next: number}>` — The sequence row.
## `numbers.list` ## `numbers.list`
*Source: `src/store/numbers/list.js`* *Source: `src/store/numbers/list.js`*
Lists the number sequences of the user's selected company. Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment', 'quote', 'order', 'return', 'retoure') exist.
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -41,6 +60,7 @@ Updates the sequence settings of a number for the user's selected company. Only
| `[input.prefix]` | `string` | The new prefix. | | `[input.prefix]` | `string` | The new prefix. |
| `[input.padding]` | `number` | The new padding length. | | `[input.padding]` | `number` | The new padding length. |
| `[input.next]` | `number` | The new next value. | | `[input.next]` | `number` | The new next value. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

40
API.optionslist.md Normal file
View File

@@ -0,0 +1,40 @@
# optionslist
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `optionslist.getOptionsByArticleSku`
*Source: `src/store/optionslist/getOptionsByArticleSku.js`*
Returns the options list for an article by its SKU.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>`
## `optionslist.setOptionsByArticleSku`
*Source: `src/store/optionslist/setOptionsByArticleSku.js`*
Sets or clears the options list for an article.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The master article SKU. |
| `[options=[]]` | `Array<{articleId?: number, sku?: string, count?: number, sort?: number, mandated?: boolean}>` | Array of options items. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>`

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`* *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** **Parameters**
@@ -16,17 +16,18 @@ Creates a new order for the user's selected company with its lines.
| `body` | `object` | The order input. | | `body` | `object` | The order input. |
| `body.number` | `string` | The order number. | | `body.number` | `string` | The order number. |
| `body.date` | `string` | The order date. | | `body.date` | `string` | The order date. |
| `[body.entityId]` | `number` | The id of the customer entity. | | `body.entityId` | `number` | The id of the customer entity. |
| `[body.externalNumber]` | `string` | An external (customer) reference number. | | `[body.externalNumber]` | `string` | An external (customer) reference number. |
| `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The order lines. | | `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null, shippable?: boolean}>` | The order lines. `shippable` (default true) false marks non-shippable lines (e.g. shipping cost, rounding) that are excluded from shipment and order completeness. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`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}>}>` — The created order. `Promise<{id: number, number: string, type: string, entityId: number, 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}>}>` — The created order.
**Throws** **Throws**
`Error` — If the order is invalid. `Error` — If the order is invalid, no entity is selected, or entity not found.
**Example** **Example**
@@ -35,11 +36,76 @@ const order = await store.order.create(42, { number: 'B-2026-001', date: '2026-0
// => { id, number, type, entityId, externalNumber, date, lines: [...] } // => { id, number, type, entityId, externalNumber, date, lines: [...] }
``` ```
## `order.createFromQuote`
*Source: `src/store/order/createFromQuote.js`*
Creates a new sales order from an existing quote for the user's selected company. Copies lines and customer entity from the quote. Sets external reference to the quote number (quote.number) unless explicitly overridden. If order number is omitted, it is auto-allocated from the 'order' number sequence.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `quoteId` | `number` | The id of the source quote. |
| `[body]` | `object` | Optional overrides for the order. |
| `[body.number]` | `string` | The order number; auto-allocated from sequence if omitted. |
| `[body.date]` | `string` | The order date; defaults to current date if omitted. |
| `[body.createdAt]` | `string` | Creation timestamp; defaults to current time if omitted. |
| `[body.entityId]` | `number` | Customer entity id override; defaults to quote's entityId. |
| `[body.externalNumber]` | `string` | Customer reference override; defaults to quote's number. |
| `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null, shippable?: boolean}>` | Lines override; defaults to quote's lines. `shippable` (default true) false excludes a line from shipment and order completeness. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`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}>}>` — The created order.
**Throws**
`Error` — If the quote is not found, no entity is selected, or the order is invalid.
**Example**
```js
const order = await store.order.createFromQuote(42, 10);
// => { id, number, type: 'order', entityId, externalNumber, date, lines: [...] }
```
## `order.createShipment`
*Source: `src/store/order/createShipment.js`*
Creates a new shipment manifest for an order. Can contain specified items from the parent order.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `orderId` | `number` | The parent order manifest id. |
| `input` | `object` | Shipment input. |
| `input.lines` | `Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>` | Positions to ship. |
| `[input.date]` | `string|Date` | Optional shipment date. |
| `[input.externalNumber]` | `string` | Optional external delivery reference. |
| `input.shipping` | `object` | Required shipping ({carrierId, type, state, trackingCode?, warehouseId?}); see shipment.create. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The created shipment.
**Example**
```js
const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }], shipping: { carrierId: 1, type: 'parcel', state: 'draft' } });
```
## `order.delete` ## `order.delete`
*Source: `src/store/order/delete.js`* *Source: `src/store/order/delete.js`*
Deletes an order by its id within the user's selected company. Deletes an order by its id within the user's selected company. Refuses to delete the order if any shipment manifest references it.
**Parameters** **Parameters**
@@ -47,6 +113,7 @@ Deletes an order by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the order to delete. | | `id` | `number` | The id of the order to delete. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -54,7 +121,7 @@ Deletes an order by its id within the user's selected company.
**Throws** **Throws**
`Error` — If the order is not found. `Error` — If the order is not found or has shipment manifests.
**Example** **Example**
@@ -74,6 +141,7 @@ Returns a single order by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the order. | | `id` | `number` | The id of the order. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -101,18 +169,47 @@ Lists the orders of the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of orders to skip. |
| `[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** **Returns**
`Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>` — The orders. `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, unfilteredTotal: number, offset: number, limit: number}|Array<object>>` — The orders.
**Example** **Example**
```js ```js
const result = await store.order.list(42, { offset: 0, limit: 50, search: '1002', onlyUnpaid: true, onlyUndelivered: true, seekMonths: -1 });
// => { orders: [...], total: 10, unfilteredTotal: 42, offset: 0, limit: 50 }
const orders = await store.order.list(42); const orders = await store.order.list(42);
// => [{ id, number, type, entityId, externalNumber, date }] // => [{ 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` ## `order.update`
*Source: `src/store/order/update.js`* *Source: `src/store/order/update.js`*
@@ -130,7 +227,8 @@ Updates an order's header and/or lines.
| `[patch.date]` | `string` | The new order date. | | `[patch.date]` | `string` | The new order date. |
| `[patch.entityId]` | `number` | The new customer entity id. | | `[patch.entityId]` | `number` | The new customer entity id. |
| `[patch.externalNumber]` | `string` | The new external (customer) reference number. | | `[patch.externalNumber]` | `string` | The new external (customer) reference number. |
| `[patch.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The new order lines. | | `[patch.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null, shippable?: boolean}>` | The new order lines. `shippable` (default true) false marks non-shippable lines excluded from shipment and order completeness. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

84
API.package.md Normal file
View File

@@ -0,0 +1,84 @@
# package
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `package.create`
*Source: `src/store/package/create.js`*
Creates a package for a shipping. The number is allocated from the `package` numbers sequence (own prefix + counter). The package must target an entity address.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Package input. |
| `input.shippingId` | `number` | The shipping id. |
| `input.entityaddressId` | `number` | The target entity address id (required). |
| `input.type` | `string` | Package type. |
| `input.state` | `string` | Package state. |
| `[input.details]` | `string` | Optional details. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, shippingId: number, entityaddressId: number, number: string, type: string, state: string, details: string|null}>`
**Throws**
`Error` — If shipping or entity address is not found, or input is invalid.
**Example**
```js
await store.package.create(googleAuthId, { shippingId: 9, entityaddressId: 3, type: 'box', state: 'packed' });
```
## `package.delete`
*Source: `src/store/package/delete.js`*
Deletes a package of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The package id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Example**
```js
await store.package.delete(googleAuthId, 12);
```
## `package.listByShippingId`
*Source: `src/store/package/listByShippingId.js`*
Lists packages of a shipping in the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `shippingId` | `number` | The shipping id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, shippingId: number, entityaddressId: number, number: string, type: string, state: string, details: string|null}>>`
**Example**
```js
const packages = await store.package.listByShippingId(googleAuthId, 9);
```

83
API.payment.md Normal file
View File

@@ -0,0 +1,83 @@
# payment
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `payment.create`
*Source: `src/store/payment/create.js`*
Adds a payment to a manifest (order) using an account of the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Payment input. |
| `input.accountId` | `number` | The account id. |
| `input.manifestId` | `number` | The manifest (order) id. |
| `input.type` | `string` | Payment type. |
| `input.amount` | `number` | Payment amount. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, accountId: number, manifestId: number, type: string, amount: number}>`
**Throws**
`Error` — If account or manifest is not found, or input is invalid.
**Example**
```js
await store.payment.create(googleAuthId, { accountId: 1, manifestId: 42, type: 'cash', amount: 19.9 });
```
## `payment.delete`
*Source: `src/store/payment/delete.js`*
Deletes a payment belonging to a manifest of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The payment id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Example**
```js
await store.payment.delete(googleAuthId, 7);
```
## `payment.listByManifestId`
*Source: `src/store/payment/listByManifestId.js`*
Lists payments of a manifest (order) in the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `manifestId` | `number` | The manifest id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, accountId: number, manifestId: number, type: string, amount: number}>>`
**Example**
```js
const payments = await store.payment.listByManifestId(googleAuthId, 42);
```

View File

@@ -14,6 +14,7 @@ Returns the full-size image (AVIF) for a given hash.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `fullHash` | `string` | The full image hash. | | `fullHash` | `string` | The full image hash. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -30,6 +31,35 @@ const image = await store.pic.getImageByHash(42, 'abc123');
// => { buffer, w, h, size, contentType: 'image/avif' } // => { buffer, w, h, size, contentType: 'image/avif' }
``` ```
## `pic.getSmallThumbByHash`
*Source: `src/store/pic/getSmallThumbByHash.js`*
Returns the small thumbnail image (AVIF, 32px max edge) for a given hash.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `fullHash` | `string` | The full image hash. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{buffer: Buffer, w: number, h: number, contentType: string}>` — The small thumbnail image.
**Throws**
`Error` — If the image is not found.
**Example**
```js
const thumb = await store.pic.getSmallThumbByHash(42, 'abc123');
// => { buffer, w, h, contentType: 'image/avif' }
```
## `pic.getThumbByHash` ## `pic.getThumbByHash`
*Source: `src/store/pic/getThumbByHash.js`* *Source: `src/store/pic/getThumbByHash.js`*
@@ -42,6 +72,7 @@ Returns the thumbnail image (AVIF) for a given hash.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `fullHash` | `string` | The full image hash. | | `fullHash` | `string` | The full image hash. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -14,6 +14,7 @@ Deletes a posterminal by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the posterminal to delete. | | `id` | `number` | The id of the posterminal to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -29,6 +30,272 @@ Deletes a posterminal by its id within the user's selected company.
await store.posterminal.delete(42, 5); await store.posterminal.delete(42, 5);
``` ```
## `posterminal.findByPairingpin`
*Source: `src/store/posterminal/findByPairingpin.js`*
Finds a posterminal by its pairingpin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `pairingpin` | `string` | The pairing pin to search for. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>` — The posterminal, or null if not found.
## `posterminal.findByToken`
*Source: `src/store/posterminal/findByToken.js`*
Finds a posterminal by its auth token.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `token` | `string` | The auth token to search for. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>` — The posterminal, or null if not found.
## `posterminal.getCategoryCount`
*Source: `src/store/posterminal/getCategoryCount.js`*
Counts categories assigned to the POS terminal queued after the given sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — The count of changed records.
## `posterminal.getCategoryList`
*Source: `src/store/posterminal/getCategoryList.js`*
Lists categories for a POS terminal queued after the sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[input.limit=20]` | `number` | Max items to return. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<object>>`
## `posterminal.getCompositeProductCount`
*Source: `src/store/posterminal/getCompositeProductCount.js`*
Counts composite products (Stücklisten) assigned to the POS terminal queued after the given sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — The count of changed records.
## `posterminal.getCustomerCount`
*Source: `src/store/posterminal/getCustomerCount.js`*
Counts customers of the POS terminal company queued after the given sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — The count of changed records.
## `posterminal.getCustomerGroupCount`
*Source: `src/store/posterminal/getCustomerGroupCount.js`*
Counts customer groups of the POS terminal company queued after the given sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — The count of changed records.
## `posterminal.getCustomerGroupList`
*Source: `src/store/posterminal/getCustomerGroupList.js`*
Lists customer groups for a POS terminal queued after the sync queue cursor ID, excluding 'Lieferanten'.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[input.limit=50]` | `number` | Max items to return. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{customerGroupId: string, name: string, standard: string, discountPercent: string, lastChanged: string}>>`
## `posterminal.getCustomerList`
*Source: `src/store/posterminal/getCustomerList.js`*
Lists customers for a POS terminal queued after the sync queue cursor ID, excluding 'Lieferanten'.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[input.limit=20]` | `number` | Max items to return. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<object>>`
## `posterminal.getDeletedEntityCount`
*Source: `src/store/posterminal/getDeletedEntityCount.js`*
Counts deleted entities for a POS terminal after the given cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | The highest synced obituary ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<number>` — Count of deleted entities.
## `posterminal.getDeletedEntityList`
*Source: `src/store/posterminal/getDeletedEntityList.js`*
Lists deleted entities for a POS terminal after the given cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | The highest synced obituary ID. |
| `[input.limit=200]` | `number` | Max items to return. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, entityType: string, entityId: number}>>`
## `posterminal.getMaxOrderIdCount`
*Source: `src/store/posterminal/getMaxOrderIdCount.js`*
Returns the maximum order manifest id for the POS terminal company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — Maximum order id or 0.
## `posterminal.getProductCount`
*Source: `src/store/posterminal/getProductCount.js`*
Counts products assigned to the POS terminal queued after the given sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — The count of changed records.
## `posterminal.getProductList`
*Source: `src/store/posterminal/getProductList.js`*
Lists products for a POS terminal queued after the sync queue cursor ID.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.posterminalId` | `number` | The POS terminal id. |
| `[input.cursor=0]` | `number` | Sequential ID cursor of last synced posterminalsyncqueue entry. |
| `[input.limit=20]` | `number` | Max items to return. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<object>>`
## `posterminal.list` ## `posterminal.list`
*Source: `src/store/posterminal/list.js`* *Source: `src/store/posterminal/list.js`*
@@ -40,18 +307,124 @@ Lists the posterminals of the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<Array<{id: number, name: string, token: string, pairingpin: string}>>` — The posterminals. `Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>` — The posterminals.
**Example** **Example**
```js ```js
const terminals = await store.posterminal.list(42); const terminals = await store.posterminal.list(42);
// => [{ id, name, token, pairingpin }] // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt, sort, enabled, clientversion }]
``` ```
## `posterminal.pair`
*Source: `src/store/posterminal/pair.js`*
Completes POS device pairing for a terminal.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The posterminal id. |
| `input` | `object` | |
| `input.token` | `string` | The auth token for the paired device. |
| `[input.clientserial]` | `string` | The client name or serial identifier. |
| `[input.pairedAt]` | `string` | Timestamp when paired. |
| `[input.clientversion]` | `string|null` | Client software version reported by the POS device during pairing. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>`
## `posterminal.recordObituary`
*Source: `src/store/posterminal/recordObituary.js`*
Records an obituary for all active paired POS terminals of a company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.companyId` | `number` | The company id. |
| `input.entityType` | `string` | 'category' | 'article' | 'customer' | 'customergroup' |
| `input.entityId` | `number` | The deleted entity's id. |
| `[tx]` | `object` | Optional transaction/runner. |
**Returns**
`Promise<void>`
## `posterminal.recordSyncQueue`
*Source: `src/store/posterminal/recordSyncQueue.js`*
Enqueues a sync notification for all POS terminals of a company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `input` | `object` | |
| `input.companyId` | `number` | The company id. |
| `input.entityType` | `string` | 'category' | 'article' | 'customer' | 'customergroup' |
| `input.entityId` | `number` | The entity's id. |
| `[tx]` | `object` | Optional transaction/runner. |
**Returns**
`Promise<void>`
## `posterminal.setClientversion`
*Source: `src/store/posterminal/setClientversion.js`*
Stores the software version reported by a POS terminal.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The posterminal id. |
| `clientversion` | `string|null` | Version string reported by the client (trimmed; empty → null). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, clientversion: string|null}>`
**Example**
```js
await store.posterminal.setClientversion(7, '1.10.12.0');
// => { id: 7, clientversion: '1.10.12.0' }
```
## `posterminal.setMaxKnownObituaryId`
*Source: `src/store/posterminal/setMaxKnownObituaryId.js`*
Updates the max known obituary ID for a POS terminal.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The posterminal id. |
| `maxknownobituaryid` | `number|null` | The highest synced obituary ID. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, maxknownobituaryid: number|null}>`
## `posterminal.setOff` ## `posterminal.setOff`
*Source: `src/store/posterminal/setOff.js`* *Source: `src/store/posterminal/setOff.js`*
@@ -65,6 +438,7 @@ Removes a category assignment from a posterminal (turns it off).
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `posterminalId` | `number` | The id of the posterminal. | | `posterminalId` | `number` | The id of the posterminal. |
| `categoryId` | `number` | The id of the category to remove. | | `categoryId` | `number` | The id of the category to remove. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -93,6 +467,7 @@ Removes all category assignments from a posterminal.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `posterminalId` | `number` | The id of the posterminal. | | `posterminalId` | `number` | The id of the posterminal. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -122,6 +497,7 @@ Assigns a category to a posterminal (turns it on for that category).
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `posterminalId` | `number` | The id of the posterminal. | | `posterminalId` | `number` | The id of the posterminal. |
| `categoryId` | `number` | The id of the category to assign. | | `categoryId` | `number` | The id of the category to assign. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -150,6 +526,7 @@ Assigns all categories of the user's selected company to a posterminal.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `posterminalId` | `number` | The id of the posterminal. | | `posterminalId` | `number` | The id of the posterminal. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -166,6 +543,23 @@ const result = await store.posterminal.setOnAll(42, 5);
// => { id, categoryIds: [...] } // => { id, categoryIds: [...] }
``` ```
## `posterminal.touchLastseen`
*Source: `src/store/posterminal/touchLastseen.js`*
Updates lastseen_at for a POS terminal to the current timestamp.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The posterminal id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number}>`
## `posterminal.upsert` ## `posterminal.upsert`
*Source: `src/store/posterminal/upsert.js`* *Source: `src/store/posterminal/upsert.js`*
@@ -182,10 +576,13 @@ Creates or updates a posterminal for the user's selected company.
| `input.pairingpin` | `string` | The pairing pin. | | `input.pairingpin` | `string` | The pairing pin. |
| `[input.token]` | `string` | Optional token. | | `[input.token]` | `string` | Optional token. |
| `[input.id]` | `number` | Optional id to update an existing posterminal. | | `[input.id]` | `number` | Optional id to update an existing posterminal. |
| `[input.sort]` | `number` | Display order (integer, ascending). Default 0 on create. |
| `[input.enabled]` | `boolean` | Whether the terminal may sync. Default true on create. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, name: string, token: string, pairingpin: string}>` — The upserted posterminal. `Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>` — The upserted posterminal.
**Throws** **Throws**
@@ -195,5 +592,5 @@ Creates or updates a posterminal for the user's selected company.
```js ```js
const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' }); const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
// => { id, name, token, pairingpin } // => { id, name, token, pairingpin, sort, enabled }
``` ```

165
API.printer.md Normal file
View File

@@ -0,0 +1,165 @@
# printer
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `printer.create`
*Source: `src/store/printer/create.js`*
Creates a new printer for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Printer input. |
| `input.address` | `string` | Printer address (network IP/host/path). |
| `input.type` | `string` | Printer type ("esc/pos", "zpl", or "ipp"). |
| `[input.model]` | `string` | Optional printer model name. |
| `[input.serial]` | `string` | Optional printer serial number. |
| `[input.sort]` | `number` | Optional sort order (auto-increments if omitted). |
| `[input.desc]` | `string` | Optional description. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>`
**Throws**
`Error` — If address is missing, type is invalid, or sort is invalid.
**Example**
```js
const printer = await store.printer.create(googleAuthId, {
address: '192.168.1.200',
type: 'esc/pos',
model: 'TM-T88VI',
desc: 'Bar receipt printer',
});
```
## `printer.delete`
*Source: `src/store/printer/delete.js`*
Deletes a printer for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The printer ID to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If printer is not found.
## `printer.getById`
*Source: `src/store/printer/getById.js`*
Retrieves a printer by its ID for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The printer ID. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}|undefined>`
**Example**
```js
const printer = await store.printer.getById(googleAuthId, 1);
// => { id: 1, company_id: 1, address: '192.168.1.100', type: 'esc/pos', ... }
```
## `printer.list`
*Source: `src/store/printer/list.js`*
Lists all printers for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>>`
**Example**
```js
const printers = await store.printer.list(42);
// => [{ id: 1, company_id: 1, address: '192.168.1.100', type: 'esc/pos', ... }]
```
## `printer.order`
*Source: `src/store/printer/order.js`*
Reorders the printers of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The reorder input. |
| `input.printerIds` | `Array<number>` | The printer ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{printerIds: Array<number>}>` — The applied order.
**Throws**
`Error` — If the order is invalid.
## `printer.update`
*Source: `src/store/printer/update.js`*
Updates a printer for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The printer ID to update. |
| `input` | `object` | Update payload. |
| `[input.address]` | `string` | The printer address. |
| `[input.type]` | `string` | The printer type ("esc/pos", "zpl", or "ipp"). |
| `[input.model]` | `string|null` | The printer model. |
| `[input.serial]` | `string|null` | The printer serial. |
| `[input.sort]` | `number` | The sort order. |
| `[input.desc]` | `string|null` | The description. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>`
**Throws**
`Error` — If printer not found, address is empty, type is invalid, or sort is invalid.

222
API.printeragent.md Normal file
View File

@@ -0,0 +1,222 @@
# printeragent
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `printeragent.create`
*Source: `src/store/printeragent/create.js`*
Creates an unpaired printer agent for the user's selected company. The agent registers itself later using the pairing pin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The printer agent input. |
| `input.name` | `string` | The display name. |
| `input.pairingpin` | `string` | The pairing pin the agent uses to register. |
| `[input.sort]` | `number` | Display order (integer, ascending). Default 0. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>` — The created printer agent.
**Throws**
`Error` — 'Invalid printeragent' if name or pairingpin is missing/blank or sort is not an integer.
**Example**
```js
const agent = await store.printeragent.create(42, { name: 'Bar printer', pairingpin: '123456' });
```
## `printeragent.delete`
*Source: `src/store/printeragent/delete.js`*
Deletes a printer agent of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The printer agent id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — 'Printeragent not found' if the id is unknown in the company.
**Example**
```js
await store.printeragent.delete(42, 5);
```
## `printeragent.findByPairingpin`
*Source: `src/store/printeragent/findByPairingpin.js`*
Finds a printer agent by its pairing pin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `pairingpin` | `string` | The pairing pin to search for. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>` — The printer agent, or null if not found.
**Example**
```js
const agent = await store.printeragent.findByPairingpin('123456');
```
## `printeragent.findByToken`
*Source: `src/store/printeragent/findByToken.js`*
Finds a printer agent by its authentication token.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `token` | `string` | The auth token to search for. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>` — The printer agent, or null if not found.
**Example**
```js
const agent = await store.printeragent.findByToken('abc...');
```
## `printeragent.list`
*Source: `src/store/printeragent/list.js`*
Lists the printer agents of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>>` — The printer agents.
**Example**
```js
const agents = await store.printeragent.list(42);
// => [{ id, companyId, name, token, pairingpin, pairedAt, clientserial, clientmodel, clientstate, agentversion, lastseenAt, updatedAt, sort }]
```
## `printeragent.pair`
*Source: `src/store/printeragent/pair.js`*
Completes device pairing for a printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The printer agent id. |
| `input` | `object` | |
| `input.token` | `string` | Auth token for the paired device. |
| `[input.clientserial]` | `string` | Device serial identifier. |
| `[input.clientmodel]` | `string` | Device model name. |
| `[input.agentversion]` | `string` | Device agent software version. |
| `[input.pairedAt]` | `string` | Timestamp when paired. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
## `printeragent.touchLastseen`
*Source: `src/store/printeragent/touchLastseen.js`*
Updates lastseen_at (and optionally agentversion) for a printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The printer agent id. |
| `[agentversion]` | `string|null` | Optional agent version to update. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number}>`
## `printeragent.update`
*Source: `src/store/printeragent/update.js`*
Updates name and/or sort of a printer agent of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The update input. |
| `input.id` | `number` | The printer agent id. |
| `[input.name]` | `string` | The new display name. |
| `[input.sort]` | `number` | The new display order (integer). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>` — The updated printer agent.
**Throws**
`Error` — 'Invalid printeragent' if name is blank, sort is not an integer, or neither is given; 'Printeragent not found' if id is unknown in the company.
**Example**
```js
const agent = await store.printeragent.update(42, { id: 5, name: 'Kitchen printer', sort: 1 });
```
## `printeragent.updateState`
*Source: `src/store/printeragent/updateState.js`*
Updates the client state of a printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The printer agent id. |
| `clientstate` | `string|null` | The state payload/string. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`

100
API.printerjob.md Normal file
View File

@@ -0,0 +1,100 @@
# printerjob
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `printerjob.create`
*Source: `src/store/printerjob/create.js`*
Creates a new printer job for an agent printer belonging to the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `input.agentprinterId` | `number` | Target agent printer ID. |
| `input.data` | `string` | Job payload (ZPL code, etc.). |
| `[input.type]` | `string` | Job type ("zpl", "pdf", etc., defaults to agentprinter type or "zpl"). |
| `[input.state]` | `string` | Initial state (defaults to "pending"). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<object>` — Created printer job with printeragentId attached.
## `printerjob.delete`
*Source: `src/store/printerjob/delete.js`*
Deletes a printer job belonging to the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | Printer job ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The deleted job's snapshot.
## `printerjob.getById`
*Source: `src/store/printerjob/getById.js`*
Retrieves a printer job by ID. If caller is an agent ({ agentId: number }), checks that the job belongs to that printer agent. If caller is a user (googleAuthId: number), checks that the job belongs to the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `auth` | `number|{agentId: number}` | User's googleAuthId or { agentId }. |
| `id` | `number` | Printer job ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object|null>`
## `printerjob.list`
*Source: `src/store/printerjob/list.js`*
Lists printer jobs. If caller is an agent ({ agentId: number }), lists jobs for that agent. If caller is a user (googleAuthId: number), lists jobs for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `auth` | `number|{agentId: number}` | User's googleAuthId or { agentId }. |
| `[filters]` | `object` | |
| `[filters.agentprinterId]` | `number` | Filter by agentprinterId (company callers only). |
| `[filters.state]` | `string` | Filter by job state (e.g. "pending"). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<object>>`
## `printerjob.updateState`
*Source: `src/store/printerjob/updateState.js`*
Updates a printer job's state ("processing", "completed", "cancelled", etc.).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `auth` | `number|{agentId: number}` | User's googleAuthId or { agentId }. |
| `id` | `number` | Printer job ID. |
| `state` | `string` | New state. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — Updated printer job.

112
API.propertyname.md Normal file
View File

@@ -0,0 +1,112 @@
# propertyname
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `propertyname.create`
*Source: `src/store/propertyname/create.js`*
Creates a property name for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The property name input. |
| `input.name` | `string` | The property name. |
| `[input.sort]` | `number` | Optional sort order. |
| `[input.exclusive=false]` | `boolean` | Whether values are mutually exclusive on an article. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, name: string, sort: number, exclusive: boolean}>`
**Throws**
`Error` — If the name is missing or already exists for the company.
## `propertyname.delete`
*Source: `src/store/propertyname/delete.js`*
Deletes a property name for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The property name id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If property name not found or in use.
## `propertyname.list`
*Source: `src/store/propertyname/list.js`*
Lists the property names for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, name: string, sort: number, exclusive: boolean, articleCount: number}>>`
## `propertyname.order`
*Source: `src/store/propertyname/order.js`*
Reorders the property names for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Reorder input. |
| `input.propertynameIds` | `Array<number>` | IDs in their new order. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{propertynameIds: Array<number>}>`
## `propertyname.update`
*Source: `src/store/propertyname/update.js`*
Updates a property name for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The property name id. |
| `input` | `object` | Update patch. |
| `[input.name]` | `string` | New name. |
| `[input.sort]` | `number` | New sort. |
| `[input.exclusive]` | `boolean` | New exclusive flag. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, name: string, sort: number, exclusive: boolean}>`
**Throws**
`Error` — If the property name is unknown, the name is missing, or the name is already taken.

View File

@@ -19,6 +19,7 @@ Creates a new quote for the user's selected company with its lines.
| `[body.entityId]` | `number` | The id of the customer entity. | | `[body.entityId]` | `number` | The id of the customer entity. |
| `[body.externalNumber]` | `string` | An external (customer) reference number. | | `[body.externalNumber]` | `string` | An external (customer) reference number. |
| `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The quote lines. | | `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The quote lines. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -47,6 +48,7 @@ Deletes a quote by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the quote to delete. | | `id` | `number` | The id of the quote to delete. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -74,6 +76,7 @@ Returns a single quote by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the quote. | | `id` | `number` | The id of the quote. |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
@@ -101,18 +104,45 @@ Lists the quotes of the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of quotes to skip. |
| `[opts.limit=50]` | `number` | Number of quotes to return. |
| `[opts.search]` | `string` | Free-text search matching quote number, external number, or customer name. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[tx]` | `object` | Optional transaction. |
**Returns** **Returns**
`Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>` — The quotes. `Promise<{quotes: 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}>, positions?: number, grossTotal?: number, totalQuantity?: number, customerName?: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>` — The quotes.
**Example** **Example**
```js ```js
const result = await store.quote.list(42, { offset: 0, limit: 50, search: 'A-1002' });
// => { quotes: [...], total: 10, unfilteredTotal: 42, offset: 0, limit: 50 }
const quotes = await store.quote.list(42); const quotes = await store.quote.list(42);
// => [{ id, number, type, entityId, externalNumber, date }] // => [{ id, number, type, entityId, externalNumber, date, createdAt, lines: [...] }]
``` ```
## `quote.rebuildSummaries`
*Source: `src/store/quote/rebuildSummaries.js`*
Rebuilds the `quotesummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `quote.syncSummary`
*Source: `src/store/quote/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `quotesummary` table.
## `quote.update` ## `quote.update`
*Source: `src/store/quote/update.js`* *Source: `src/store/quote/update.js`*
@@ -131,6 +161,7 @@ Updates a quote's header and/or lines.
| `[patch.entityId]` | `number` | The new customer entity id. | | `[patch.entityId]` | `number` | The new customer entity id. |
| `[patch.externalNumber]` | `string` | The new external (customer) reference number. | | `[patch.externalNumber]` | `string` | The new external (customer) reference number. |
| `[patch.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The new quote lines. | | `[patch.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The new quote lines. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

155
API.retoure.md Normal file
View File

@@ -0,0 +1,155 @@
# retoure
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `retoure.create`
*Source: `src/store/retoure/create.js`*
Creates a new retoure for the user's selected company with its lines.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `body` | `object` | The retoure input. |
| `[body.number]` | `string` | The retoure number. |
| `body.date` | `string` | The retoure date. |
| `[body.entityId]` | `number` | The id of the customer entity. |
| `[body.externalNumber]` | `string` | An external reference number. |
| `[body.lines]` | `Array<object>` | The retoure lines. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<object>` — The created retoure.
## `retoure.createShipment`
*Source: `src/store/retoure/createShipment.js`*
Creates a shipment manifest for a retoure.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `retoureId` | `number` | The parent retoure manifest id. |
| `input` | `object` | Shipment input. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The created shipment.
## `retoure.delete`
*Source: `src/store/retoure/delete.js`*
Deletes a retoure by its id within the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the retoure to delete. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If the retoure is not found.
## `retoure.getById`
*Source: `src/store/retoure/getById.js`*
Returns a single retoure by its id within the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the retoure. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`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}>}>` — The retoure with its lines.
**Throws**
`Error` — If the retoure is not found.
## `retoure.list`
*Source: `src/store/retoure/list.js`*
Lists the retoures of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of retoures to skip. |
| `[opts.limit=50]` | `number` | Number of retoures to return. |
| `[opts.search]` | `string` | Free-text search matching retoure number, external number, or customer name. |
| `[opts.onlyUnpaid=false]` | `boolean` | When true, returns only unpaid retoures. |
| `[opts.onlyUndelivered=false]` | `boolean` | When true, returns only undelivered retoures. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{retoures: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
## `retoure.rebuildSummaries`
*Source: `src/store/retoure/rebuildSummaries.js`*
Rebuilds the `retouresummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `retoure.syncSummary`
*Source: `src/store/retoure/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `retouresummary` table.
## `retoure.update`
*Source: `src/store/retoure/update.js`*
Updates a retoure's header and/or lines.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the retoure to update. |
| `patch` | `object` | The update patch. |
| `[patch.number]` | `string` | The new retoure number. |
| `[patch.date]` | `string` | The new retoure date. |
| `[patch.entityId]` | `number` | The new customer entity id. |
| `[patch.externalNumber]` | `string` | The new external reference number. |
| `[patch.lines]` | `Array<object>` | The new retoure lines. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<object>` — The updated retoure.

83
API.return.md Normal file
View File

@@ -0,0 +1,83 @@
# return
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `return.book`
*Source: `src/store/return/book.js`*
Books all positions of a return manifest to the ledger.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `object` | Storage provider instance. |
**Returns**
`Function` — `(googleAuthId, input, tx) => Promise<object>`
## `return.getById`
*Source: `src/store/return/getById.js`*
Returns a return manifest by its id within the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the return manifest. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The return manifest with lines and summary.
**Throws**
`Error` — If the return is not found.
## `return.list`
*Source: `src/store/return/list.js`*
Lists the returns of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of returns to skip. |
| `[opts.limit=50]` | `number` | Number of returns to return. |
| `[opts.search]` | `string` | Free-text search matching return number, external number, or supplier name. |
| `[opts.onlyUnbooked=false]` | `boolean` | When true, returns only returns with unbooked stock items. |
| `[opts.supplierId]` | `number` | Filter by supplier entity id. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{returns: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
## `return.rebuildSummaries`
*Source: `src/store/return/rebuildSummaries.js`*
Rebuilds the `returnsummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `return.syncSummary`
*Source: `src/store/return/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `returnsummary` table. Keeps `returnsummary` in sync with manifest (return), manifestarticle, article (track_stock), and ledger (RETURN transactions).

111
API.shipment.md Normal file
View File

@@ -0,0 +1,111 @@
# 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 or cancels a shipment manifest of the selected company. - If no ledger rows exist: physically deletes the shipment manifest. - If ledger rows exist: reverses the ledger entries (storno document entry + warehouse stock adjustment), marks the shipment as 'cancelled' in shipmentsummary, and updates the parent order's summary. - 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.list`
*Source: `src/store/shipment/list.js`*
Lists the shipments of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of shipments to skip. |
| `[opts.limit=50]` | `number` | Number of shipments to return. |
| `[opts.search]` | `string` | Free-text search matching shipment number, order number, external number, tracking code, or customer name. |
| `[opts.state]` | `'new'|'delivered'|'cancelled'|'all'` | Filter by delivery state. |
| `[opts.includeCancelled=false]` | `boolean` | Whether to include cancelled shipments when state is not set. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[opts.orderId]` | `number` | Optional filter for parent order manifest id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{shipments: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
## `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` | |
## `shipment.rebuildSummaries`
*Source: `src/store/shipment/rebuildSummaries.js`*
Rebuilds the `shipmentsummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
## `shipment.syncSummary`
*Source: `src/store/shipment/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `shipmentsummary` table.
## `shipment.update`
*Source: `src/store/shipment/update.js`*
Updates a shipment's state or metadata.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |

101
API.shipping.md Normal file
View File

@@ -0,0 +1,101 @@
# shipping
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `shipping.create`
*Source: `src/store/shipping/create.js`*
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**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Shipping input. |
| `input.manifestId` | `number` | The shipment manifest id. |
| `input.carrierId` | `number` | The carrier id. |
| `input.type` | `string` | Shipping type. |
| `input.state` | `string` | Shipping state ('shipped' or other). |
| `[input.trackingCode]` | `string` | Carrier tracking code. |
| `[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, trackingCode: string|null, carrierId: number, carrier: string|null, type: string, state: string}>`
**Throws**
`Error` — If manifest is not found, carrier is not found, manifest is not a shipment, shipment is already shipped, or input is invalid.
**Example**
```js
await store.shipping.create(googleAuthId, { manifestId: 42, carrierId: 1, type: 'parcel', state: 'shipped', trackingCode: '1Z999' });
```
## `shipping.delete`
*Source: `src/store/shipping/delete.js`*
Deletes a shipping of the user's selected company. Its packages are removed by cascade. Once shipped, a shipping cannot be deleted because stock movements have been ledgered.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The shipping id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If shipping is not found or is already shipped.
**Example**
```js
await store.shipping.delete(googleAuthId, 9);
```
## `shipping.listByManifestId`
*Source: `src/store/shipping/listByManifestId.js`*
Lists shippings of a manifest (order) in the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `manifestId` | `number` | The manifest id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, manifestId: number, number: string, type: string, state: string}>>`
**Example**
```js
const shippings = await store.shipping.listByManifestId(googleAuthId, 42);
```
## `shipping.update`
*Source: `src/store/shipping/update.js`*
Updates a shipping record for the user's selected company. If transitioned to 'shipped', books ledger movements and adjusts stock. Once shipped, shipping cannot be undone (state cannot be changed away from 'shipped').
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |

72
API.stock.md Normal file
View File

@@ -0,0 +1,72 @@
# stock
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `stock.accept`
*Source: `src/store/stock/accept.js`*
Accepts a delivery manifest item (or multiple delivery items). Requirements: - Every accepted delivery manifest item receives a document ledger entry: - If amount >= 0: transaction_type = 'ACCEPTANCE', amount = +amount, warehouse_id = null. - If amount < 0: transaction_type = 'RETURN', amount = amount (negative), warehouse_id = null. - Always records article history with delivery number, supplier, EK brutto, and quantity. - If the connected article has stock tracking (track_stock == 1): Calls adjust with the item's amount to adjust physical inventory in the target warehouse. - If the connected article does not have stock tracking: Only document acceptance is booked; no warehouse adjust is performed. - Double acceptance of an already accepted line is strictly prevented.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `object` | The database provider. |
**Returns**
`Function` — Async function accepting delivery items.
## `stock.adjust`
*Source: `src/store/stock/adjust.js`*
Adjusts article stock manually by creating an implicit manifest of type "adjustment", allocating a number from the "adjustment" number scheme, and appending to the ledger.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Adjustment payload. |
| `[input.sku]` | `string` | Article SKU (if single-item adjustment). |
| `[input.warehouseId]` | `number` | Warehouse ID (can also pass warehouse_id). |
| `[input.amount]` | `number` | Signed adjustment amount (+ to add, - to remove). |
| `[input.text]` | `string` | Optional adjustment reason/memo. |
| `[input.externalNumber]` | `string` | Optional external reference. |
| `[input.date]` | `string|Date` | Optional date (defaults to now). |
| `[input.lines]` | `Array<object>` | Optional array of items to adjust in one manifest. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>`
## `stock.rebuildSummaries`
*Source: `src/store/stock/rebuildSummaries.js`*
Rebuilds the `articlewarehousesummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `stock.ship`
*Source: `src/store/stock/ship.js`*
Ships a shipment manifest item (or multiple shipment items). Requirements: - Every shipped shipment manifest item receives a document ledger entry: - transaction_type = 'SHIPMENT', amount = -amount, warehouse_id = null. - Line amount must be positive (> 0); negative or zero line amounts are rejected. - Always records article history with shipment number, customer, and quantity. - If the connected article has stock tracking (track_stock == 1): Calls adjust with: - -amount (outgoing stock from warehouse). - If the connected article does not have stock tracking: Only document shipment is booked; no warehouse adjust is performed. - Double shipping of an already shipped line is strictly prevented.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `object` | The database provider. |
**Returns**
`Function` — Async function shipping shipment items.

130
API.subagentschedule.md Normal file
View File

@@ -0,0 +1,130 @@
# subagentschedule
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `subagentschedule.create`
*Source: `src/store/subagentschedule/create.js`*
Creates a new subagent schedule for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Schedule input. |
| `input.prompt` | `string` | The task prompt for the subagent. |
| `input.cron` | `string` | Standard 5-field cron timetable string. |
| `[input.desc]` | `string` | Optional schedule description. |
| `[input.skills]` | `string|Array<string>` | Optional skill names or JSON string. |
| `[input.lastagentnote]` | `string` | Optional initial agent note. |
| `[input.max_itr=15]` | `number` | Optional max iterations per run. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>`
**Throws**
`Error` — If prompt is missing, cron is invalid, or max_itr is invalid.
**Example**
```js
const schedule = await store.subagentschedule.create(googleAuthId, {
desc: 'Nightly inventory audit',
prompt: 'Check stock discrepancies across all warehouses and summarize.',
cron: '0 2 * * *',
skills: ['stock', 'reporting'],
max_itr: 20,
});
```
## `subagentschedule.delete`
*Source: `src/store/subagentschedule/delete.js`*
Deletes a subagent schedule by id for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The schedule ID to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If schedule not found.
## `subagentschedule.getById`
*Source: `src/store/subagentschedule/getById.js`*
Retrieves a single subagent schedule by id for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The schedule ID. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}|null>`
## `subagentschedule.list`
*Source: `src/store/subagentschedule/list.js`*
Lists all subagent schedules for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>>`
## `subagentschedule.update`
*Source: `src/store/subagentschedule/update.js`*
Updates an existing subagent schedule for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The schedule ID to update. |
| `input` | `object` | Update payload. |
| `[input.prompt]` | `string` | Optional updated task prompt. |
| `[input.cron]` | `string` | Optional updated cron expression. |
| `[input.desc]` | `string|null` | Optional updated description. |
| `[input.skills]` | `string|Array<string>|null` | Optional updated skills. |
| `[input.lastagentnote]` | `string|null` | Optional updated agent note. |
| `[input.max_itr]` | `number` | Optional updated max iterations. |
| `[input.lastrun_at]` | `string|null` | Optional updated last run timestamp. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>`
**Throws**
`Error` — If schedule not found, prompt is empty, cron is invalid, or max_itr is invalid.

View File

@@ -15,6 +15,7 @@ Creates a new supplier for the user's selected company, recording history.
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The supplier input. | | `input` | `object` | The supplier input. |
| `input.name` | `string` | The supplier name. | | `input.name` | `string` | The supplier name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -43,6 +44,7 @@ Deletes a supplier by its id within the user's selected company.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The id of the supplier to delete. | | `id` | `number` | The id of the supplier to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -50,7 +52,7 @@ Deletes a supplier by its id within the user's selected company.
**Throws** **Throws**
`Error` — If the supplier is not found. `Error` — If the supplier is not found or is in use.
**Example** **Example**
@@ -69,6 +71,7 @@ Lists the suppliers of the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -95,6 +98,7 @@ Updates the name of a supplier, recording history.
| `id` | `number` | The id of the supplier to update. | | `id` | `number` | The id of the supplier to update. |
| `input` | `object` | The update input. | | `input` | `object` | The update input. |
| `input.name` | `string` | The new supplier name. | | `input.name` | `string` | The new supplier name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

View File

@@ -14,6 +14,7 @@ Deletes a unit for the user's selected company by its SI code.
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `si` | `string` | The SI code of the unit to delete. | | `si` | `string` | The SI code of the unit to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -40,6 +41,7 @@ Lists the units available for the user's selected company.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
@@ -69,6 +71,7 @@ Creates or updates a unit for the user's selected company, keyed by its SI code.
| `input.basesifactor` | `number` | The base SI factor (must be > 0). | | `input.basesifactor` | `number` | The base SI factor (must be > 0). |
| `input.symbol` | `string` | The unit symbol. | | `input.symbol` | `string` | The unit symbol. |
| `input.name` | `string` | The unit name. | | `input.name` | `string` | The unit name. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

166
API.warehouse.md Normal file
View File

@@ -0,0 +1,166 @@
# warehouse
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `warehouse.create`
*Source: `src/store/warehouse/create.js`*
Creates a new warehouse for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Warehouse input. |
| `input.name` | `string` | The warehouse name. |
| `[input.sort]` | `number` | Optional sort order (auto-increments if omitted). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, sort: number}>` — The created warehouse.
**Throws**
`Error` — If name is missing, sort is invalid, or name already exists.
**Example**
```js
const wh = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
// => { id: 1, name: 'Hauptlager', sort: 0 }
```
## `warehouse.delete`
*Source: `src/store/warehouse/delete.js`*
Deletes a warehouse for the user's selected company, unless referenced in the ledger or it is the last warehouse while articles have stock tracking enabled.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The warehouse ID to delete. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — If warehouse not found, in use in the ledger, or is the last warehouse with track_stock articles.
**Example**
```js
await store.warehouse.delete(googleAuthId, 1);
```
## `warehouse.getById`
*Source: `src/store/warehouse/getById.js`*
Retrieves a warehouse by its ID for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The warehouse ID. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, sort: number}|undefined>` — The warehouse or undefined.
**Example**
```js
const wh = await store.warehouse.getById(googleAuthId, 1);
// => { id: 1, name: 'Hauptlager', sort: 0 }
```
## `warehouse.list`
*Source: `src/store/warehouse/list.js`*
Lists all warehouses for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, name: string, sort: number}>>` — List of warehouses.
**Example**
```js
const warehouses = await store.warehouse.list(googleAuthId);
// => [{ id: 1, name: 'Hauptlager', sort: 0 }]
```
## `warehouse.order`
*Source: `src/store/warehouse/order.js`*
Reorders the warehouses of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The reorder input. |
| `input.warehouseIds` | `Array<number>` | The warehouse ids in their new order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{warehouseIds: Array<number>}>` — The applied order.
**Throws**
`Error` — If the order is invalid.
## `warehouse.update`
*Source: `src/store/warehouse/update.js`*
Updates a warehouse name and/or sort order for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The warehouse ID to update. |
| `input` | `object` | Update payload. |
| `[input.name]` | `string` | The new warehouse name. |
| `[input.sort]` | `number` | The new sort order. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, sort: number}>` — The updated warehouse.
**Throws**
`Error` — If warehouse not found, name is empty, sort is invalid, or name already exists.
**Example**
```js
const updated = await store.warehouse.update(googleAuthId, 1, { name: 'Zentrallager', sort: 1 });
// => { id: 1, name: 'Zentrallager', sort: 1 }
```

387
BENCHMARK_REPORT.md Normal file
View File

@@ -0,0 +1,387 @@
# Database Drivers API Timing & Benchmark Report
- **Date**: 2026-09-22T06:35:11.546Z
- **Scale**: small
- **Read Iterations on Filled DB**: 2
## Comparative Summary
| Metric | MSSQL | better-sqlite3 | Delta |
| :--- | :---: | :---: | :---: |
| Total API Calls | 450 | 450 | - |
| Total Runtime | 6.64s | 0.62s | 10.7x |
## API Timings Comparison (in milliseconds)
| API Method | Calls | MSSQL Avg (ms) | MSSQL P95 (ms) | SQLite Avg (ms) | SQLite P95 (ms) | Ratio (MSSQL / SQLite) |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
| `article.addImage` | 4 | 47.64 | 60.57 | 35.52 | 44.72 | 1.34x |
| `article.createUnderParent` | 19 | 14.88 | 33.74 | 0.53 | 0.99 | 27.83x |
| `article.delete` | 1 | 26.64 | 26.64 | 0.62 | 0.62 | 42.87x |
| `article.deleteImage` | 1 | 9.93 | 9.93 | 0.90 | 0.90 | 11.03x |
| `article.getBySku` | 2 | 81.30 | 119.37 | 0.69 | 0.78 | 118.26x |
| `article.getHistoryBySku` | 2 | 6.93 | 9.43 | 0.29 | 0.31 | 24.25x |
| `article.importRows` | 1 | 69.17 | 69.17 | 2.43 | 2.43 | 28.47x |
| `article.listExport` | 4 | 8.72 | 19.16 | 0.87 | 1.29 | 10.06x |
| `article.listSearchIndex` | 2 | 10.62 | 17.54 | 0.55 | 0.63 | 19.39x |
| `article.orderImages` | 3 | 17.31 | 37.98 | 0.50 | 0.70 | 34.72x |
| `article.setBrandIdBySku` | 18 | 15.50 | 24.98 | 0.57 | 0.81 | 27.21x |
| `article.setCategoryIdsBySku` | 18 | 15.08 | 33.45 | 0.74 | 1.01 | 20.24x |
| `article.setEansBySku` | 18 | 11.86 | 19.23 | 0.56 | 0.85 | 21.15x |
| `article.setParentBySku` | 13 | 14.79 | 25.32 | 0.66 | 0.92 | 22.52x |
| `article.setPricesBySku` | 18 | 14.16 | 29.51 | 0.69 | 1.12 | 20.48x |
| `article.setWeightBySku` | 18 | 14.18 | 36.45 | 0.52 | 0.68 | 27.02x |
| `article.updateBySku` | 18 | 18.71 | 84.26 | 0.83 | 1.65 | 22.52x |
| `articlesupplier.create` | 19 | 12.88 | 39.44 | 0.58 | 1.03 | 22.29x |
| `articlesupplier.delete` | 1 | 8.07 | 8.07 | 0.43 | 0.43 | 18.76x |
| `articlesupplier.listByArticleSku` | 2 | 8.19 | 11.57 | 0.27 | 0.29 | 30.66x |
| `articlesupplier.listByEntityAndSkus` | 2 | 20.65 | 29.06 | 0.44 | 0.51 | 47.12x |
| `articlesupplier.listBySupplierAndSkus` | 2 | 9.30 | 13.09 | 0.35 | 0.36 | 26.24x |
| `articlesupplier.order` | 18 | 8.66 | 13.15 | 0.29 | 0.49 | 29.49x |
| `articlesupplier.update` | 18 | 8.83 | 15.43 | 0.42 | 0.79 | 20.98x |
| `brand.delete` | 1 | 9.21 | 9.21 | 0.25 | 0.25 | 37.49x |
| `brand.list` | 1 | 12.07 | 12.07 | 0.26 | 0.26 | 45.96x |
| `brand.upsert` | 3 | 22.35 | 34.39 | 0.26 | 0.31 | 86.44x |
| `category.addImage` | 2 | 51.42 | 73.36 | 43.24 | 65.95 | 1.19x |
| `category.createUnderParent` | 10 | 16.35 | 52.04 | 0.41 | 0.93 | 39.47x |
| `category.delete` | 1 | 10.38 | 10.38 | 0.25 | 0.25 | 41.40x |
| `category.deleteImage` | 1 | 6.82 | 6.82 | 0.29 | 0.29 | 23.65x |
| `category.ensureByPath` | 1 | 57.16 | 57.16 | 0.66 | 0.66 | 86.66x |
| `category.getCategoryArticles` | 2 | 2.47 | 2.66 | 0.40 | 0.42 | 6.12x |
| `category.getTreeForCompany` | 2 | 54.69 | 106.10 | 0.73 | 0.77 | 75.38x |
| `category.move` | 1 | 14.14 | 14.14 | 0.53 | 0.53 | 26.74x |
| `category.order` | 1 | 11.45 | 11.45 | 0.39 | 0.39 | 29.29x |
| `category.rename` | 1 | 12.30 | 12.30 | 0.26 | 0.26 | 47.06x |
| `company.create` | 2 | 33.01 | 43.31 | 1.15 | 1.48 | 28.69x |
| `company.delete` | 1 | 29.27 | 29.27 | 0.23 | 0.23 | 127.66x |
| `company.list` | 1 | 28.81 | 28.81 | 0.29 | 0.29 | 98.16x |
| `company.update` | 1 | 13.54 | 13.54 | 0.50 | 0.50 | 27.01x |
| `companysetting.get` | 1 | 11.64 | 11.64 | 0.24 | 0.24 | 48.51x |
| `companysetting.list` | 3 | 6.40 | 13.33 | 0.22 | 0.32 | 28.78x |
| `companysetting.set` | 2 | 51.43 | 59.91 | 0.36 | 0.44 | 142.93x |
| `companyuser.add` | 1 | 4.54 | 4.54 | 0.12 | 0.12 | 37.82x |
| `companyuser.delete` | 1 | 18.55 | 18.55 | 0.10 | 0.10 | 183.31x |
| `companyuser.getByEmail` | 1 | 7.52 | 7.52 | 0.11 | 0.11 | 70.75x |
| `customergroup.create` | 3 | 15.74 | 21.61 | 0.32 | 0.43 | 48.99x |
| `customergroup.delete` | 1 | 21.84 | 21.84 | 0.27 | 0.27 | 80.42x |
| `customergroup.list` | 2 | 9.48 | 11.19 | 0.20 | 0.22 | 48.26x |
| `customergroup.order` | 1 | 22.29 | 22.29 | 0.41 | 0.41 | 53.85x |
| `customergroup.update` | 1 | 20.70 | 20.70 | 0.34 | 0.34 | 61.11x |
| `delivery.create` | 7 | 43.28 | 173.79 | 9.73 | 63.60 | 4.45x |
| `delivery.delete` | 1 | 10.03 | 10.03 | 0.26 | 0.26 | 38.58x |
| `delivery.getById` | 2 | 5.34 | 5.48 | 0.47 | 0.68 | 11.37x |
| `delivery.list` | 2 | 7.52 | 9.45 | 0.33 | 0.39 | 22.61x |
| `delivery.update` | 6 | 13.35 | 21.43 | 0.53 | 1.11 | 25.38x |
| `entity.create` | 7 | 16.47 | 21.70 | 0.51 | 0.91 | 32.34x |
| `entity.delete` | 1 | 32.28 | 32.28 | 0.25 | 0.25 | 129.68x |
| `entity.getById` | 2 | 4.86 | 5.77 | 0.18 | 0.21 | 26.39x |
| `entity.isNumberFree` | 6 | 7.91 | 17.94 | 0.18 | 0.25 | 44.36x |
| `entity.list` | 4 | 8.82 | 10.89 | 0.23 | 0.28 | 38.90x |
| `entity.peekNextNumber` | 6 | 11.85 | 21.28 | 0.25 | 0.41 | 47.87x |
| `entityaddress.create` | 7 | 14.43 | 22.53 | 0.34 | 0.60 | 42.26x |
| `entityaddress.delete` | 1 | 14.65 | 14.65 | 0.27 | 0.27 | 53.70x |
| `entityaddress.list` | 2 | 6.32 | 6.87 | 0.25 | 0.29 | 25.38x |
| `entityaddress.update` | 6 | 14.55 | 21.88 | 0.39 | 0.59 | 37.04x |
| `entitycontact.create` | 7 | 12.02 | 21.40 | 0.29 | 0.41 | 41.25x |
| `entitycontact.delete` | 1 | 14.58 | 14.58 | 0.26 | 0.26 | 55.87x |
| `entitycontact.list` | 2 | 6.00 | 6.24 | 0.23 | 0.26 | 25.85x |
| `entitycontact.update` | 6 | 13.67 | 22.68 | 0.34 | 0.49 | 39.90x |
| `google_auth.add` | 3 | 6.50 | 8.22 | 0.22 | 0.46 | 29.40x |
| `google_auth.getByEmail` | 2 | 1.46 | 2.17 | 0.06 | 0.07 | 25.66x |
| `google_auth.getById` | 2 | 0.92 | 1.07 | 0.07 | 0.08 | 13.25x |
| `google_auth.listAllowedEmails` | 1 | 3.03 | 3.03 | 0.14 | 0.14 | 21.29x |
| `google_auth.removeAllowedEmail` | 1 | 12.88 | 12.88 | 0.10 | 0.10 | 128.56x |
| `google_auth.setProfile` | 1 | 11.57 | 11.57 | 0.17 | 0.17 | 66.27x |
| `google_auth.setSelectedCompany` | 2 | 5.45 | 6.44 | 0.07 | 0.08 | 80.61x |
| `lang.create` | 2 | 4.36 | 4.68 | 0.07 | 0.07 | 60.79x |
| `lang.delete` | 1 | 11.88 | 11.88 | 0.11 | 0.11 | 112.34x |
| `lang.ensure` | 2 | 3.64 | 5.93 | 0.10 | 0.11 | 36.95x |
| `lang.getByIso639` | 1 | 1.29 | 1.29 | 0.05 | 0.05 | 25.28x |
| `lang.list` | 1 | 1.74 | 1.74 | 0.06 | 0.06 | 26.85x |
| `manifestattachment.add` | 3 | 70.39 | 107.85 | 35.59 | 55.74 | 1.98x |
| `manifestattachment.getById` | 2 | 4.94 | 6.69 | 0.83 | 0.94 | 5.97x |
| `manifestattachment.listByManifestId` | 3 | 3.92 | 4.37 | 0.61 | 0.72 | 6.46x |
| `manifestattachment.order` | 1 | 17.83 | 17.83 | 0.56 | 0.56 | 31.79x |
| `manifestattachment.remove` | 1 | 16.69 | 16.69 | 0.85 | 0.85 | 19.58x |
| `numbers.list` | 1 | 4.61 | 4.61 | 0.23 | 0.23 | 20.36x |
| `order.create` | 7 | 15.03 | 27.23 | 0.53 | 0.69 | 28.36x |
| `order.delete` | 1 | 11.60 | 11.60 | 0.24 | 0.24 | 47.50x |
| `order.getById` | 2 | 4.00 | 4.19 | 0.23 | 0.25 | 17.27x |
| `order.list` | 2 | 4.00 | 4.91 | 0.21 | 0.26 | 19.19x |
| `order.update` | 6 | 13.83 | 28.49 | 0.36 | 0.57 | 38.68x |
| `pic.getImageByHash` | 2 | 14.93 | 20.18 | 0.71 | 0.82 | 20.91x |
| `pic.getThumbByHash` | 2 | 7.43 | 9.27 | 0.64 | 0.70 | 11.65x |
| `posterminal.delete` | 1 | 12.11 | 12.11 | 0.21 | 0.21 | 58.46x |
| `posterminal.getCategoryPosterminalIds` | 1 | 7.53 | 7.53 | 0.11 | 0.11 | 68.81x |
| `posterminal.list` | 3 | 3.36 | 4.78 | 0.19 | 0.21 | 18.10x |
| `posterminal.setOff` | 1 | 13.82 | 13.82 | 0.42 | 0.42 | 32.74x |
| `posterminal.setOffAll` | 1 | 10.53 | 10.53 | 0.32 | 0.32 | 33.00x |
| `posterminal.setOn` | 1 | 23.16 | 23.16 | 0.45 | 0.45 | 51.06x |
| `posterminal.setOnAll` | 1 | 17.22 | 17.22 | 0.57 | 0.57 | 30.41x |
| `posterminal.upsert` | 3 | 8.53 | 8.85 | 0.29 | 0.44 | 28.97x |
| `quote.create` | 7 | 14.98 | 42.98 | 0.62 | 1.33 | 24.22x |
| `quote.delete` | 1 | 8.85 | 8.85 | 0.24 | 0.24 | 36.55x |
| `quote.getById` | 2 | 3.95 | 4.08 | 0.22 | 0.24 | 17.63x |
| `quote.list` | 2 | 2.84 | 2.86 | 0.21 | 0.24 | 13.79x |
| `quote.update` | 6 | 12.05 | 25.22 | 0.37 | 0.64 | 32.58x |
| `supplier.create` | 4 | 11.97 | 17.41 | 0.43 | 0.68 | 27.82x |
| `supplier.delete` | 1 | 14.46 | 14.46 | 0.30 | 0.30 | 48.16x |
| `supplier.list` | 3 | 4.87 | 6.92 | 0.21 | 0.28 | 23.59x |
| `supplier.update` | 3 | 9.49 | 13.39 | 0.45 | 0.62 | 20.93x |
| `tax.create` | 1 | 5.67 | 5.67 | 0.09 | 0.09 | 62.58x |
| `tax.ensure` | 2 | 1.15 | 1.19 | 0.07 | 0.09 | 16.24x |
| `tax.getByRate` | 1 | 1.28 | 1.28 | 0.05 | 0.05 | 24.46x |
| `tax.list` | 1 | 1.87 | 1.87 | 0.07 | 0.07 | 26.92x |
| `unit.delete` | 1 | 9.09 | 9.09 | 0.25 | 0.25 | 36.18x |
| `unit.list` | 1 | 17.29 | 17.29 | 0.28 | 0.28 | 61.77x |
| `unit.upsert` | 3 | 23.97 | 39.26 | 0.54 | 0.82 | 44.60x |
## MSSQL (MssqlProvider) Detailed Breakdown
| API Method | Calls | Avg (ms) | Min (ms) | Max (ms) | P50 (ms) | P95 (ms) | Errors |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| `article.addImage` | 4 | 47.64 | 21.74 | 60.57 | 59.44 | 60.57 | 0 |
| `article.createUnderParent` | 19 | 14.88 | 8.57 | 33.74 | 11.24 | 33.74 | 0 |
| `article.delete` | 1 | 26.64 | 26.64 | 26.64 | 26.64 | 26.64 | 0 |
| `article.deleteImage` | 1 | 9.93 | 9.93 | 9.93 | 9.93 | 9.93 | 0 |
| `article.getBySku` | 2 | 81.30 | 43.23 | 119.37 | 119.37 | 119.37 | 0 |
| `article.getHistoryBySku` | 2 | 6.93 | 4.44 | 9.43 | 9.43 | 9.43 | 0 |
| `article.importRows` | 1 | 69.17 | 69.17 | 69.17 | 69.17 | 69.17 | 0 |
| `article.listExport` | 4 | 8.72 | 4.95 | 19.16 | 5.80 | 19.16 | 0 |
| `article.listSearchIndex` | 2 | 10.62 | 3.70 | 17.54 | 17.54 | 17.54 | 0 |
| `article.orderImages` | 3 | 17.31 | 6.66 | 37.98 | 7.31 | 37.98 | 0 |
| `article.setBrandIdBySku` | 18 | 15.50 | 9.85 | 24.98 | 14.23 | 24.98 | 0 |
| `article.setCategoryIdsBySku` | 18 | 15.08 | 9.51 | 33.45 | 13.12 | 33.45 | 0 |
| `article.setEansBySku` | 18 | 11.86 | 8.23 | 19.23 | 11.67 | 19.23 | 0 |
| `article.setParentBySku` | 13 | 14.79 | 8.86 | 25.32 | 14.06 | 25.32 | 0 |
| `article.setPricesBySku` | 18 | 14.16 | 8.82 | 29.51 | 12.62 | 29.51 | 0 |
| `article.setWeightBySku` | 18 | 14.18 | 9.15 | 36.45 | 12.90 | 36.45 | 0 |
| `article.updateBySku` | 18 | 18.71 | 8.88 | 84.26 | 13.70 | 84.26 | 0 |
| `articlesupplier.create` | 19 | 12.88 | 7.89 | 39.44 | 11.51 | 39.44 | 0 |
| `articlesupplier.delete` | 1 | 8.07 | 8.07 | 8.07 | 8.07 | 8.07 | 0 |
| `articlesupplier.listByArticleSku` | 2 | 8.19 | 4.82 | 11.57 | 11.57 | 11.57 | 0 |
| `articlesupplier.listByEntityAndSkus` | 2 | 20.65 | 12.24 | 29.06 | 29.06 | 29.06 | 0 |
| `articlesupplier.listBySupplierAndSkus` | 2 | 9.30 | 5.51 | 13.09 | 13.09 | 13.09 | 0 |
| `articlesupplier.order` | 18 | 8.66 | 6.17 | 13.15 | 7.88 | 13.15 | 0 |
| `articlesupplier.update` | 18 | 8.83 | 6.21 | 15.43 | 8.05 | 15.43 | 0 |
| `brand.delete` | 1 | 9.21 | 9.21 | 9.21 | 9.21 | 9.21 | 0 |
| `brand.list` | 1 | 12.07 | 12.07 | 12.07 | 12.07 | 12.07 | 0 |
| `brand.upsert` | 3 | 22.35 | 9.62 | 34.39 | 23.03 | 34.39 | 0 |
| `category.addImage` | 2 | 51.42 | 29.49 | 73.36 | 73.36 | 73.36 | 0 |
| `category.createUnderParent` | 10 | 16.35 | 8.77 | 52.04 | 13.12 | 52.04 | 0 |
| `category.delete` | 1 | 10.38 | 10.38 | 10.38 | 10.38 | 10.38 | 0 |
| `category.deleteImage` | 1 | 6.82 | 6.82 | 6.82 | 6.82 | 6.82 | 0 |
| `category.ensureByPath` | 1 | 57.16 | 57.16 | 57.16 | 57.16 | 57.16 | 0 |
| `category.getCategoryArticles` | 2 | 2.47 | 2.29 | 2.66 | 2.66 | 2.66 | 0 |
| `category.getTreeForCompany` | 2 | 54.69 | 3.27 | 106.10 | 106.10 | 106.10 | 0 |
| `category.move` | 1 | 14.14 | 14.14 | 14.14 | 14.14 | 14.14 | 0 |
| `category.order` | 1 | 11.45 | 11.45 | 11.45 | 11.45 | 11.45 | 0 |
| `category.rename` | 1 | 12.30 | 12.30 | 12.30 | 12.30 | 12.30 | 0 |
| `company.create` | 2 | 33.01 | 22.72 | 43.31 | 43.31 | 43.31 | 0 |
| `company.delete` | 1 | 29.27 | 29.27 | 29.27 | 29.27 | 29.27 | 0 |
| `company.list` | 1 | 28.81 | 28.81 | 28.81 | 28.81 | 28.81 | 0 |
| `company.update` | 1 | 13.54 | 13.54 | 13.54 | 13.54 | 13.54 | 0 |
| `companysetting.get` | 1 | 11.64 | 11.64 | 11.64 | 11.64 | 11.64 | 0 |
| `companysetting.list` | 3 | 6.40 | 2.62 | 13.33 | 3.25 | 13.33 | 0 |
| `companysetting.set` | 2 | 51.43 | 42.96 | 59.91 | 59.91 | 59.91 | 0 |
| `companyuser.add` | 1 | 4.54 | 4.54 | 4.54 | 4.54 | 4.54 | 0 |
| `companyuser.delete` | 1 | 18.55 | 18.55 | 18.55 | 18.55 | 18.55 | 0 |
| `companyuser.getByEmail` | 1 | 7.52 | 7.52 | 7.52 | 7.52 | 7.52 | 0 |
| `customergroup.create` | 3 | 15.74 | 9.18 | 21.61 | 16.43 | 21.61 | 0 |
| `customergroup.delete` | 1 | 21.84 | 21.84 | 21.84 | 21.84 | 21.84 | 0 |
| `customergroup.list` | 2 | 9.48 | 7.76 | 11.19 | 11.19 | 11.19 | 0 |
| `customergroup.order` | 1 | 22.29 | 22.29 | 22.29 | 22.29 | 22.29 | 0 |
| `customergroup.update` | 1 | 20.70 | 20.70 | 20.70 | 20.70 | 20.70 | 0 |
| `delivery.create` | 7 | 43.28 | 11.06 | 173.79 | 18.67 | 173.79 | 0 |
| `delivery.delete` | 1 | 10.03 | 10.03 | 10.03 | 10.03 | 10.03 | 0 |
| `delivery.getById` | 2 | 5.34 | 5.21 | 5.48 | 5.48 | 5.48 | 0 |
| `delivery.list` | 2 | 7.52 | 5.59 | 9.45 | 9.45 | 9.45 | 0 |
| `delivery.update` | 6 | 13.35 | 7.88 | 21.43 | 11.92 | 21.43 | 0 |
| `entity.create` | 7 | 16.47 | 8.63 | 21.70 | 16.97 | 21.70 | 0 |
| `entity.delete` | 1 | 32.28 | 32.28 | 32.28 | 32.28 | 32.28 | 0 |
| `entity.getById` | 2 | 4.86 | 3.95 | 5.77 | 5.77 | 5.77 | 0 |
| `entity.isNumberFree` | 6 | 7.91 | 4.24 | 17.94 | 6.84 | 17.94 | 0 |
| `entity.list` | 4 | 8.82 | 6.96 | 10.89 | 9.67 | 10.89 | 0 |
| `entity.peekNextNumber` | 6 | 11.85 | 7.67 | 21.28 | 11.36 | 21.28 | 0 |
| `entityaddress.create` | 7 | 14.43 | 6.45 | 22.53 | 13.11 | 22.53 | 0 |
| `entityaddress.delete` | 1 | 14.65 | 14.65 | 14.65 | 14.65 | 14.65 | 0 |
| `entityaddress.list` | 2 | 6.32 | 5.76 | 6.87 | 6.87 | 6.87 | 0 |
| `entityaddress.update` | 6 | 14.55 | 7.32 | 21.88 | 15.28 | 21.88 | 0 |
| `entitycontact.create` | 7 | 12.02 | 6.84 | 21.40 | 11.16 | 21.40 | 0 |
| `entitycontact.delete` | 1 | 14.58 | 14.58 | 14.58 | 14.58 | 14.58 | 0 |
| `entitycontact.list` | 2 | 6.00 | 5.77 | 6.24 | 6.24 | 6.24 | 0 |
| `entitycontact.update` | 6 | 13.67 | 7.53 | 22.68 | 14.58 | 22.68 | 0 |
| `google_auth.add` | 3 | 6.50 | 5.37 | 8.22 | 5.93 | 8.22 | 0 |
| `google_auth.getByEmail` | 2 | 1.46 | 0.76 | 2.17 | 2.17 | 2.17 | 0 |
| `google_auth.getById` | 2 | 0.92 | 0.78 | 1.07 | 1.07 | 1.07 | 0 |
| `google_auth.listAllowedEmails` | 1 | 3.03 | 3.03 | 3.03 | 3.03 | 3.03 | 0 |
| `google_auth.removeAllowedEmail` | 1 | 12.88 | 12.88 | 12.88 | 12.88 | 12.88 | 0 |
| `google_auth.setProfile` | 1 | 11.57 | 11.57 | 11.57 | 11.57 | 11.57 | 0 |
| `google_auth.setSelectedCompany` | 2 | 5.45 | 4.45 | 6.44 | 6.44 | 6.44 | 0 |
| `lang.create` | 2 | 4.36 | 4.05 | 4.68 | 4.68 | 4.68 | 0 |
| `lang.delete` | 1 | 11.88 | 11.88 | 11.88 | 11.88 | 11.88 | 0 |
| `lang.ensure` | 2 | 3.64 | 1.34 | 5.93 | 5.93 | 5.93 | 0 |
| `lang.getByIso639` | 1 | 1.29 | 1.29 | 1.29 | 1.29 | 1.29 | 0 |
| `lang.list` | 1 | 1.74 | 1.74 | 1.74 | 1.74 | 1.74 | 0 |
| `manifestattachment.add` | 3 | 70.39 | 44.00 | 107.85 | 59.31 | 107.85 | 0 |
| `manifestattachment.getById` | 2 | 4.94 | 3.18 | 6.69 | 6.69 | 6.69 | 0 |
| `manifestattachment.listByManifestId` | 3 | 3.92 | 3.28 | 4.37 | 4.10 | 4.37 | 0 |
| `manifestattachment.order` | 1 | 17.83 | 17.83 | 17.83 | 17.83 | 17.83 | 0 |
| `manifestattachment.remove` | 1 | 16.69 | 16.69 | 16.69 | 16.69 | 16.69 | 0 |
| `numbers.list` | 1 | 4.61 | 4.61 | 4.61 | 4.61 | 4.61 | 0 |
| `order.create` | 7 | 15.03 | 8.12 | 27.23 | 11.21 | 27.23 | 0 |
| `order.delete` | 1 | 11.60 | 11.60 | 11.60 | 11.60 | 11.60 | 0 |
| `order.getById` | 2 | 4.00 | 3.81 | 4.19 | 4.19 | 4.19 | 0 |
| `order.list` | 2 | 4.00 | 3.09 | 4.91 | 4.91 | 4.91 | 0 |
| `order.update` | 6 | 13.83 | 6.56 | 28.49 | 9.63 | 28.49 | 0 |
| `pic.getImageByHash` | 2 | 14.93 | 9.67 | 20.18 | 20.18 | 20.18 | 0 |
| `pic.getThumbByHash` | 2 | 7.43 | 5.59 | 9.27 | 9.27 | 9.27 | 0 |
| `posterminal.delete` | 1 | 12.11 | 12.11 | 12.11 | 12.11 | 12.11 | 0 |
| `posterminal.getCategoryPosterminalIds` | 1 | 7.53 | 7.53 | 7.53 | 7.53 | 7.53 | 0 |
| `posterminal.list` | 3 | 3.36 | 2.62 | 4.78 | 2.69 | 4.78 | 0 |
| `posterminal.setOff` | 1 | 13.82 | 13.82 | 13.82 | 13.82 | 13.82 | 0 |
| `posterminal.setOffAll` | 1 | 10.53 | 10.53 | 10.53 | 10.53 | 10.53 | 0 |
| `posterminal.setOn` | 1 | 23.16 | 23.16 | 23.16 | 23.16 | 23.16 | 0 |
| `posterminal.setOnAll` | 1 | 17.22 | 17.22 | 17.22 | 17.22 | 17.22 | 0 |
| `posterminal.upsert` | 3 | 8.53 | 8.02 | 8.85 | 8.74 | 8.85 | 0 |
| `quote.create` | 7 | 14.98 | 7.38 | 42.98 | 11.00 | 42.98 | 0 |
| `quote.delete` | 1 | 8.85 | 8.85 | 8.85 | 8.85 | 8.85 | 0 |
| `quote.getById` | 2 | 3.95 | 3.81 | 4.08 | 4.08 | 4.08 | 0 |
| `quote.list` | 2 | 2.84 | 2.83 | 2.86 | 2.86 | 2.86 | 0 |
| `quote.update` | 6 | 12.05 | 7.41 | 25.22 | 8.80 | 25.22 | 0 |
| `supplier.create` | 4 | 11.97 | 7.19 | 17.41 | 15.82 | 17.41 | 0 |
| `supplier.delete` | 1 | 14.46 | 14.46 | 14.46 | 14.46 | 14.46 | 0 |
| `supplier.list` | 3 | 4.87 | 3.32 | 6.92 | 4.37 | 6.92 | 0 |
| `supplier.update` | 3 | 9.49 | 7.39 | 13.39 | 7.70 | 13.39 | 0 |
| `tax.create` | 1 | 5.67 | 5.67 | 5.67 | 5.67 | 5.67 | 0 |
| `tax.ensure` | 2 | 1.15 | 1.12 | 1.19 | 1.19 | 1.19 | 0 |
| `tax.getByRate` | 1 | 1.28 | 1.28 | 1.28 | 1.28 | 1.28 | 0 |
| `tax.list` | 1 | 1.87 | 1.87 | 1.87 | 1.87 | 1.87 | 0 |
| `unit.delete` | 1 | 9.09 | 9.09 | 9.09 | 9.09 | 9.09 | 0 |
| `unit.list` | 1 | 17.29 | 17.29 | 17.29 | 17.29 | 17.29 | 0 |
| `unit.upsert` | 3 | 23.97 | 12.82 | 39.26 | 19.84 | 39.26 | 0 |
## SQLite (BetterSqlite3Provider) Detailed Breakdown
| API Method | Calls | Avg (ms) | Min (ms) | Max (ms) | P50 (ms) | P95 (ms) | Errors |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| `article.addImage` | 4 | 35.52 | 13.14 | 44.72 | 43.84 | 44.72 | 0 |
| `article.createUnderParent` | 19 | 0.53 | 0.46 | 0.99 | 0.51 | 0.99 | 0 |
| `article.delete` | 1 | 0.62 | 0.62 | 0.62 | 0.62 | 0.62 | 0 |
| `article.deleteImage` | 1 | 0.90 | 0.90 | 0.90 | 0.90 | 0.90 | 0 |
| `article.getBySku` | 2 | 0.69 | 0.60 | 0.78 | 0.78 | 0.78 | 0 |
| `article.getHistoryBySku` | 2 | 0.29 | 0.26 | 0.31 | 0.31 | 0.31 | 0 |
| `article.importRows` | 1 | 2.43 | 2.43 | 2.43 | 2.43 | 2.43 | 0 |
| `article.listExport` | 4 | 0.87 | 0.64 | 1.29 | 0.81 | 1.29 | 0 |
| `article.listSearchIndex` | 2 | 0.55 | 0.47 | 0.63 | 0.63 | 0.63 | 0 |
| `article.orderImages` | 3 | 0.50 | 0.39 | 0.70 | 0.41 | 0.70 | 0 |
| `article.setBrandIdBySku` | 18 | 0.57 | 0.53 | 0.81 | 0.55 | 0.81 | 0 |
| `article.setCategoryIdsBySku` | 18 | 0.74 | 0.69 | 1.01 | 0.73 | 1.01 | 0 |
| `article.setEansBySku` | 18 | 0.56 | 0.51 | 0.85 | 0.54 | 0.85 | 0 |
| `article.setParentBySku` | 13 | 0.66 | 0.60 | 0.92 | 0.63 | 0.92 | 0 |
| `article.setPricesBySku` | 18 | 0.69 | 0.63 | 1.12 | 0.65 | 1.12 | 0 |
| `article.setWeightBySku` | 18 | 0.52 | 0.49 | 0.68 | 0.51 | 0.68 | 0 |
| `article.updateBySku` | 18 | 0.83 | 0.73 | 1.65 | 0.78 | 1.65 | 0 |
| `articlesupplier.create` | 19 | 0.58 | 0.50 | 1.03 | 0.54 | 1.03 | 0 |
| `articlesupplier.delete` | 1 | 0.43 | 0.43 | 0.43 | 0.43 | 0.43 | 0 |
| `articlesupplier.listByArticleSku` | 2 | 0.27 | 0.25 | 0.29 | 0.29 | 0.29 | 0 |
| `articlesupplier.listByEntityAndSkus` | 2 | 0.44 | 0.36 | 0.51 | 0.51 | 0.51 | 0 |
| `articlesupplier.listBySupplierAndSkus` | 2 | 0.35 | 0.35 | 0.36 | 0.36 | 0.36 | 0 |
| `articlesupplier.order` | 18 | 0.29 | 0.27 | 0.49 | 0.28 | 0.49 | 0 |
| `articlesupplier.update` | 18 | 0.42 | 0.36 | 0.79 | 0.39 | 0.79 | 0 |
| `brand.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `brand.list` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `brand.upsert` | 3 | 0.26 | 0.23 | 0.31 | 0.24 | 0.31 | 0 |
| `category.addImage` | 2 | 43.24 | 20.54 | 65.95 | 65.95 | 65.95 | 0 |
| `category.createUnderParent` | 10 | 0.41 | 0.31 | 0.93 | 0.36 | 0.93 | 0 |
| `category.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `category.deleteImage` | 1 | 0.29 | 0.29 | 0.29 | 0.29 | 0.29 | 0 |
| `category.ensureByPath` | 1 | 0.66 | 0.66 | 0.66 | 0.66 | 0.66 | 0 |
| `category.getCategoryArticles` | 2 | 0.40 | 0.38 | 0.42 | 0.42 | 0.42 | 0 |
| `category.getTreeForCompany` | 2 | 0.73 | 0.69 | 0.77 | 0.77 | 0.77 | 0 |
| `category.move` | 1 | 0.53 | 0.53 | 0.53 | 0.53 | 0.53 | 0 |
| `category.order` | 1 | 0.39 | 0.39 | 0.39 | 0.39 | 0.39 | 0 |
| `category.rename` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `company.create` | 2 | 1.15 | 0.82 | 1.48 | 1.48 | 1.48 | 0 |
| `company.delete` | 1 | 0.23 | 0.23 | 0.23 | 0.23 | 0.23 | 0 |
| `company.list` | 1 | 0.29 | 0.29 | 0.29 | 0.29 | 0.29 | 0 |
| `company.update` | 1 | 0.50 | 0.50 | 0.50 | 0.50 | 0.50 | 0 |
| `companysetting.get` | 1 | 0.24 | 0.24 | 0.24 | 0.24 | 0.24 | 0 |
| `companysetting.list` | 3 | 0.22 | 0.17 | 0.32 | 0.18 | 0.32 | 0 |
| `companysetting.set` | 2 | 0.36 | 0.28 | 0.44 | 0.44 | 0.44 | 0 |
| `companyuser.add` | 1 | 0.12 | 0.12 | 0.12 | 0.12 | 0.12 | 0 |
| `companyuser.delete` | 1 | 0.10 | 0.10 | 0.10 | 0.10 | 0.10 | 0 |
| `companyuser.getByEmail` | 1 | 0.11 | 0.11 | 0.11 | 0.11 | 0.11 | 0 |
| `customergroup.create` | 3 | 0.32 | 0.24 | 0.43 | 0.29 | 0.43 | 0 |
| `customergroup.delete` | 1 | 0.27 | 0.27 | 0.27 | 0.27 | 0.27 | 0 |
| `customergroup.list` | 2 | 0.20 | 0.17 | 0.22 | 0.22 | 0.22 | 0 |
| `customergroup.order` | 1 | 0.41 | 0.41 | 0.41 | 0.41 | 0.41 | 0 |
| `customergroup.update` | 1 | 0.34 | 0.34 | 0.34 | 0.34 | 0.34 | 0 |
| `delivery.create` | 7 | 9.73 | 0.70 | 63.60 | 0.75 | 63.60 | 0 |
| `delivery.delete` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `delivery.getById` | 2 | 0.47 | 0.26 | 0.68 | 0.68 | 0.68 | 0 |
| `delivery.list` | 2 | 0.33 | 0.28 | 0.39 | 0.39 | 0.39 | 0 |
| `delivery.update` | 6 | 0.53 | 0.37 | 1.11 | 0.41 | 1.11 | 0 |
| `entity.create` | 7 | 0.51 | 0.43 | 0.91 | 0.43 | 0.91 | 0 |
| `entity.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `entity.getById` | 2 | 0.18 | 0.15 | 0.21 | 0.21 | 0.21 | 0 |
| `entity.isNumberFree` | 6 | 0.18 | 0.16 | 0.25 | 0.17 | 0.25 | 0 |
| `entity.list` | 4 | 0.23 | 0.19 | 0.28 | 0.22 | 0.28 | 0 |
| `entity.peekNextNumber` | 6 | 0.25 | 0.21 | 0.41 | 0.22 | 0.41 | 0 |
| `entityaddress.create` | 7 | 0.34 | 0.28 | 0.60 | 0.30 | 0.60 | 0 |
| `entityaddress.delete` | 1 | 0.27 | 0.27 | 0.27 | 0.27 | 0.27 | 0 |
| `entityaddress.list` | 2 | 0.25 | 0.21 | 0.29 | 0.29 | 0.29 | 0 |
| `entityaddress.update` | 6 | 0.39 | 0.34 | 0.59 | 0.36 | 0.59 | 0 |
| `entitycontact.create` | 7 | 0.29 | 0.26 | 0.41 | 0.28 | 0.41 | 0 |
| `entitycontact.delete` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `entitycontact.list` | 2 | 0.23 | 0.20 | 0.26 | 0.26 | 0.26 | 0 |
| `entitycontact.update` | 6 | 0.34 | 0.30 | 0.49 | 0.33 | 0.49 | 0 |
| `google_auth.add` | 3 | 0.22 | 0.08 | 0.46 | 0.12 | 0.46 | 0 |
| `google_auth.getByEmail` | 2 | 0.06 | 0.05 | 0.07 | 0.07 | 0.07 | 0 |
| `google_auth.getById` | 2 | 0.07 | 0.06 | 0.08 | 0.08 | 0.08 | 0 |
| `google_auth.listAllowedEmails` | 1 | 0.14 | 0.14 | 0.14 | 0.14 | 0.14 | 0 |
| `google_auth.removeAllowedEmail` | 1 | 0.10 | 0.10 | 0.10 | 0.10 | 0.10 | 0 |
| `google_auth.setProfile` | 1 | 0.17 | 0.17 | 0.17 | 0.17 | 0.17 | 0 |
| `google_auth.setSelectedCompany` | 2 | 0.07 | 0.05 | 0.08 | 0.08 | 0.08 | 0 |
| `lang.create` | 2 | 0.07 | 0.07 | 0.07 | 0.07 | 0.07 | 0 |
| `lang.delete` | 1 | 0.11 | 0.11 | 0.11 | 0.11 | 0.11 | 0 |
| `lang.ensure` | 2 | 0.10 | 0.09 | 0.11 | 0.11 | 0.11 | 0 |
| `lang.getByIso639` | 1 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0 |
| `lang.list` | 1 | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | 0 |
| `manifestattachment.add` | 3 | 35.59 | 1.32 | 55.74 | 49.72 | 55.74 | 0 |
| `manifestattachment.getById` | 2 | 0.83 | 0.72 | 0.94 | 0.94 | 0.94 | 0 |
| `manifestattachment.listByManifestId` | 3 | 0.61 | 0.52 | 0.72 | 0.58 | 0.72 | 0 |
| `manifestattachment.order` | 1 | 0.56 | 0.56 | 0.56 | 0.56 | 0.56 | 0 |
| `manifestattachment.remove` | 1 | 0.85 | 0.85 | 0.85 | 0.85 | 0.85 | 0 |
| `numbers.list` | 1 | 0.23 | 0.23 | 0.23 | 0.23 | 0.23 | 0 |
| `order.create` | 7 | 0.53 | 0.41 | 0.69 | 0.47 | 0.69 | 0 |
| `order.delete` | 1 | 0.24 | 0.24 | 0.24 | 0.24 | 0.24 | 0 |
| `order.getById` | 2 | 0.23 | 0.22 | 0.25 | 0.25 | 0.25 | 0 |
| `order.list` | 2 | 0.21 | 0.16 | 0.26 | 0.26 | 0.26 | 0 |
| `order.update` | 6 | 0.36 | 0.28 | 0.57 | 0.30 | 0.57 | 0 |
| `pic.getImageByHash` | 2 | 0.71 | 0.61 | 0.82 | 0.82 | 0.82 | 0 |
| `pic.getThumbByHash` | 2 | 0.64 | 0.57 | 0.70 | 0.70 | 0.70 | 0 |
| `posterminal.delete` | 1 | 0.21 | 0.21 | 0.21 | 0.21 | 0.21 | 0 |
| `posterminal.getCategoryPosterminalIds` | 1 | 0.11 | 0.11 | 0.11 | 0.11 | 0.11 | 0 |
| `posterminal.list` | 3 | 0.19 | 0.15 | 0.21 | 0.19 | 0.21 | 0 |
| `posterminal.setOff` | 1 | 0.42 | 0.42 | 0.42 | 0.42 | 0.42 | 0 |
| `posterminal.setOffAll` | 1 | 0.32 | 0.32 | 0.32 | 0.32 | 0.32 | 0 |
| `posterminal.setOn` | 1 | 0.45 | 0.45 | 0.45 | 0.45 | 0.45 | 0 |
| `posterminal.setOnAll` | 1 | 0.57 | 0.57 | 0.57 | 0.57 | 0.57 | 0 |
| `posterminal.upsert` | 3 | 0.29 | 0.19 | 0.44 | 0.25 | 0.44 | 0 |
| `quote.create` | 7 | 0.62 | 0.39 | 1.33 | 0.52 | 1.33 | 0 |
| `quote.delete` | 1 | 0.24 | 0.24 | 0.24 | 0.24 | 0.24 | 0 |
| `quote.getById` | 2 | 0.22 | 0.21 | 0.24 | 0.24 | 0.24 | 0 |
| `quote.list` | 2 | 0.21 | 0.17 | 0.24 | 0.24 | 0.24 | 0 |
| `quote.update` | 6 | 0.37 | 0.29 | 0.64 | 0.33 | 0.64 | 0 |
| `supplier.create` | 4 | 0.43 | 0.34 | 0.68 | 0.35 | 0.68 | 0 |
| `supplier.delete` | 1 | 0.30 | 0.30 | 0.30 | 0.30 | 0.30 | 0 |
| `supplier.list` | 3 | 0.21 | 0.17 | 0.28 | 0.17 | 0.28 | 0 |
| `supplier.update` | 3 | 0.45 | 0.31 | 0.62 | 0.43 | 0.62 | 0 |
| `tax.create` | 1 | 0.09 | 0.09 | 0.09 | 0.09 | 0.09 | 0 |
| `tax.ensure` | 2 | 0.07 | 0.05 | 0.09 | 0.09 | 0.09 | 0 |
| `tax.getByRate` | 1 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0 |
| `tax.list` | 1 | 0.07 | 0.07 | 0.07 | 0.07 | 0.07 | 0 |
| `unit.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `unit.list` | 1 | 0.28 | 0.28 | 0.28 | 0.28 | 0.28 | 0 |
| `unit.upsert` | 3 | 0.54 | 0.33 | 0.82 | 0.46 | 0.82 | 0 |

425
FEATURE_PLAN_EXPORT.md Normal file
View File

@@ -0,0 +1,425 @@
# FEATURE PLAN — Provider-neutraler Voll-Export / -Import
Status: **Entwurf** · Ziel-Namespace: `store.transfer` · Betrifft: `src/transfer/**` (neu), `src/index.js`, `scripts/`, Docs
## 1. Ziel
Eine API, mit der die **komplette Datenbank** unabhängig vom Provider (MSSQL, better-sqlite3, später weitere) exportiert und wieder importiert werden kann:
- Export aus Provider A → Import in Provider B (MSSQL ⇄ SQLite, in beide Richtungen).
- Das Exportformat kennt **keine** Dialektdetails (kein `DATETIME2`, kein `VARBINARY`, keine `IDENTITY`-Semantik).
- Verlustfrei: IDs, Fremdschlüssel, Sortierung, Zeitstempel, Binärdaten und die **Ledger-Hashkette** bleiben bit-genau erhalten.
- Streaming: Der Speicherbedarf hängt nicht von der DB-Größe ab (Bilder und Anhänge können GB groß sein).
- Prüfbar: Ein Export kann ohne DB verifiziert werden. Nach dem Import läuft eine Integritätsprüfung.
Nicht-Ziele (Phase 1): inkrementelle Backups, Replikation, Live-Sync, Verschlüsselung, Teil-Export einzelner Firmen (siehe Phase 5).
## 2. Ausgangslage im Code (relevante Befunde)
| Befund | Quelle | Folge für das Design |
|---|---|---|
| Provider-Vertrag: `query`, `transaction`, `initialize`, `purge`, `dialect`. Kein Export- oder Schema-Introspection-Hook. | `src/providers/provider.js` | Neuer, optionaler Adapter pro Provider (§6). |
| Store-Methoden sind mandantenbezogen (`googleAuthId` → `selected_company`). Es gibt keine Ebene „alle Firmen“. | `src/store/**` | Voll-Export läuft **nicht** über die bestehenden Store-Methoden, sondern über eigene Tabellen-Reader. |
| **SQLite speichert große Binärdaten nicht in der DB**, sondern im Dateisystem (`storageDir/{content,thumbavif,fullbinary,thumbbinary}/<hash>.<ext>`). `file.content`, `file.thumbavif`, `pic.fullbinary`, `pic.thumbbinary` existieren dort nicht als Spalten. | `sqlite3/schema.js`, `sqlite3/index.js`, `scripts/test-sqlite-storage.js` | Blobs brauchen eine eigene Abstraktion (`BlobAccess`), ein reines Spalten-Mapping reicht nicht. |
| MSSQL hält Blobs inline (`VARBINARY(MAX)`): `pic.fullbinary/thumbbinary`, `file.content/thumbavif`, `google_auth.picturebinary`, `labeltemplate.thumb`. SQLite hält `google_auth.picturebinary` und `labeltemplate.thumb` ebenfalls **inline** als `BLOB`. | `mssql/schema.js`, `sqlite3/schema.js` | Zwei Blob-Klassen: *inline* (beide DBs) und *extern* (SQLite-Dateien). |
| Zeitstempel: MSSQL `DATETIME2`, SQLite `TEXT` ISO-8601 (`%Y-%m-%dT%H:%M:%fZ`, ms-Präzision). | Schemas | Kanonisches Format: ISO-8601 UTC, `Z`, **genau 3 Nachkommastellen**. |
| Die **Ledger-Hashkette** (`prev_hash`) ist SHA-512 über `JSON.stringify({id, manifestarticle_id, warehouse_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at})`. Sie hängt von `id` und `created_at` (ms-genau) ab. | `src/store/ledger/add.js` | IDs und `created_at` müssen 1:1 erhalten bleiben. Import in aufsteigender `id`-Reihenfolge, Kette wird nach dem Import verifiziert. |
| MSSQL-Ledger ist `APPEND_ONLY`; `purge` hinterlässt `MSSQL_DroppedLedgerTable_*`. | `mssql/schema.js`, README | Import in MSSQL nur in eine **frisch initialisierte** Ledger-Tabelle. Kein Merge in vorhandene Ledger-Daten. |
| Zirkuläre FKs: `company.poscustomergroup_id → customergroup` und `customergroup.company_id → company`. Selbstreferenzen: `category.parent_id`, `article.parentarticle_id`. Außerdem `company.root_category_id → category`. | Schemas | Die Importreihenfolge ist nicht rein topologisch; die FK-Prüfung muss zeitweise aufgeschoben werden (dialektspezifisch). |
| SQLite hat `entity.company_id` (denormalisiert, per Trigger gepflegt). MSSQL löst das über die View `v_entity_company_number`. | `sqlite3/schema.js`, `mssql/schema.js` | `entity.company_id` ist eine **abgeleitete Spalte**: nicht exportieren, das Ziel setzt sie selbst. |
| SQLite-`initialize` seedet `tax` (0, 7, 19) und `meta.version`. | `sqlite3/schema.js` | Import-Modus „replace“ muss Seed-Zeilen entfernen oder überschreiben, sonst PK-/UQ-Konflikte. |
| Collation: SQLite-Spalten teils `COLLATE NOCASE`, MSSQL-Default ist case-insensitive. Nicht jede SQLite-UQ ist `NOCASE` (z. B. `attributename(company_id, name)`). | Schemas | SQLite → MSSQL kann an einer UQ scheitern (`Foo` vs. `foo`). Pre-Flight-Check (§8). |
| Der `LibDashStore`-Konstruktor ruft `initialize()` automatisch auf. | `src/index.js` | Die Zieldatenbank ist beim Import schon initialisiert: „leer, aber mit Schema“ ist der Normalfall. |
| Der Doku-Generator liest `src/store/**` und die Namespaces aus `src/index.js`. | `generate-api-doc.js` | Neue Methoden in `src/store/transfer/*.js` mit JSDoc, damit `API.transfer.md` automatisch entsteht. |
## 3. Architektur
Drei strikt getrennte Schichten unterhalb von `store.transfer`:
1. **TableRegistry**: beschreibt, *was* exportiert wird (Tabellen, Spalten, Typen, Reihenfolge, Blobs). Provider-neutral, eine Quelle der Wahrheit.
2. **Format**: beschreibt, *wie* es gespeichert wird (Dateilayout, Serialisierung, Prüfsummen). Kennt weder SQL noch Dialekte.
3. **TransferAdapter** (pro Provider): beschreibt, *wie man mit einer konkreten DB* liest und schreibt (Quoting, Paging, Insert, FK-Deferral, Blob-Zugriff).
Ein neuer Provider braucht nur einen neuen Adapter. Registry und Format bleiben unverändert.
## 4. Neutrales Datenmodell (`TableRegistry`)
Datei: `src/transfer/registry.js`.
### 4.1 Eintrag pro Tabelle
```js
{
name: 'article',
pk: ['id'], // stabile Sortierung + Keyset-Paging
identity: true, // id wird beim Import explizit gesetzt (§6.2)
columns: [
{ name: 'id', type: 'int' },
{ name: 'company_id', type: 'int', fk: 'company' },
{ name: 'sku', type: 'string', nullable: true },
{ name: 'track_stock', type: 'bool' },
{ name: 'unitamount', type: 'float' },
{ name: 'created_at', type: 'datetime' },
// ...
],
derived: [], // nur lesen, nie schreiben (z. B. entity.company_id in SQLite)
blobs: [], // Blob-Spalten (§4.3)
deferredFks: [], // z. B. company.poscustomergroup_id
secret: false, // enthält Zugangsdaten (§9)
}
```
### 4.2 Neutrale Typen
| Registry-Typ | JSON-Darstellung | MSSQL | SQLite |
|---|---|---|---|
| `int` | Zahl | `INT` | `INTEGER` |
| `bigint` | **String** (Dezimal) | `BIGINT` (Treiber liefert teils String) | `INTEGER` |
| `float` | Zahl | `FLOAT` | `REAL` |
| `decimal` | **String** (normalisiert, z. B. `"19"`) | `DECIMAL(18,4)` | `NUMERIC` |
| `bool` | `true` / `false` | `BIT` | `INTEGER` 0/1 |
| `string`, `text` | String | `NVARCHAR` | `TEXT` |
| `datetime` | `YYYY-MM-DDTHH:mm:ss.SSSZ` | `DATETIME2` | `TEXT` |
| `blob` | `{"$blob":"sha256:<hex>"}` oder `null` | `VARBINARY(MAX)` | `BLOB` / Datei |
Regeln:
- `bigint` und `decimal` werden als String exportiert, um Präzisionsverlust zu vermeiden (`pic.size`, `file.size` sind `BIGINT`).
- `float` muss verlustfrei round-trippen (`JSON.stringify` eines JS-`number` genügt). `NaN` und `Infinity` werden als Fehler gemeldet.
- `datetime` wird immer nach UTC/ms normalisiert. Die Normalisierung sitzt im Adapter (MSSQL liefert `Date`, SQLite einen String).
### 4.3 Blobs
| Kategorie | Tabelle.Spalte | MSSQL-Quelle | SQLite-Quelle |
|---|---|---|---|
| inline | `google_auth.picturebinary`, `labeltemplate.thumb` | Spalte | Spalte |
| extern | `pic.fullbinary`, `pic.thumbbinary` | Spalte | `storageDir/fullbinary/<pic.hash>.avif`, `…/thumbbinary/<pic.hash>.avif` |
| extern | `file.content`, `file.thumbavif` | Spalte | `storageDir/content/<file.hash><ext>`, `…/thumbavif/<file.hash>.avif` |
Im Export wird jeder Blob **inhaltsadressiert** unter `blobs/<sha256[0:2]>/<sha256>` abgelegt (Deduplizierung gratis) und in der Zeile als `{"$blob":"sha256:…"}` referenziert. Der SHA-256 wird über die Bytes berechnet. Die vorhandenen Spalten `pic.hash` und `file.hash` werden nicht als Inhaltshash angenommen, weil ihre Semantik (Original oder AVIF) nicht garantiert ist.
Fehlt ein SQLite-Blob im Dateisystem (DB-Zeile ohne Datei), erzeugt das eine **Warnung** im Export-Report und das Feld wird `null`. Bei `NOT NULL`-Spalten (`pic.fullbinary`) scheitert der Import nach MSSQL. Das ist gewollt, siehe Option `strict`.
### 4.4 Tabellenreihenfolge
Abgeleitet aus den Schemata (Eltern vor Kindern). Die Registry speichert sie explizit als `ORDER` und testet sie gegen die FK-Metadaten (§10). Gruppen:
1. `meta`, `language`, `tax`
2. `category`, `pic`, `google_auth`
3. `company`, `customergroup` (zirkulär → `deferredFks`)
4. `entity`, `entityaddress`, `entitycontact`, `unit`, `unittext`, `brand`
5. `propertyname`, `attributename`, `attributevalue`, `optionslist`, `articlelist`
6. `article` (Selbstreferenz), `articletext`, `articleean`, `articlepic`, `articleproperty`, `articleattributevalue`, `articlelistarticle`, `optionslistarticle`, `brandpic`, `categoryarticle`, `categorypic`, `categorytext`
7. `price`, `articlesupplier`
8. `manifest`, `manifestarticle`, `file`, `manifestattachment`, `warehouse`, `ledger`
9. `numbers`, `companyuser`, `articlehistory`, `entityhistory`, `companysetting`, `posterminal`, `posterminalcategory`, `printer`, `labeltemplate`
Selbstreferenzen (`category.parent_id`, `article.parentarticle_id`): Der Export sortiert nach `id`. Beim Import wird die FK-Prüfung zurückgestellt (§6.3), ein Zwei-Pass-Update ist nicht nötig.
## 5. Öffentliche API (`store.transfer`)
```js
await store.transfer.export(target, options?) // -> ExportReport
await store.transfer.import(source, options?) // -> ImportReport
await store.transfer.verify(source, options?) // -> VerifyReport (ohne DB)
await store.transfer.inspect(source) // -> Manifest (ohne DB)
```
`target` und `source` sind in Phase 1 Verzeichnispfade (String). Stream- und Archivziele folgen in Phase 4.
Das sind **Administrator-Operationen** ohne `googleAuthId`: Sie sehen alle Mandanten. Der Aufrufer (z. B. dashback) muss die Berechtigung selbst prüfen. Das steht im JSDoc und in `API.transfer.md`.
### 5.1 Optionen
```js
export(target, {
tables, // Whitelist von Tabellennamen (Default: alle)
exclude, // z. B. ['articlehistory']
includeSecrets: true, // false = Tokens/PINs/Secrets schwärzen (§9)
includeBlobs: true, // false = nur Metadaten (Blobs -> null)
compress: false, // 'gzip' möglich
pageSize: 1000, // Zeilen pro Keyset-Page (Blob-Tabellen intern kleiner)
strict: false, // true: Warnungen (z. B. fehlende Blob-Datei) -> Fehler
onProgress, // ({ table, rows, total, bytes }) => void
signal, // AbortSignal
})
import(source, {
mode: 'empty', // 'empty' (Default) | 'replace'
confirmDestroy: false, // bei 'replace' zwingend true
tables, exclude,
batchSize, // Multi-Row-Insert, dialektabhängig gedeckelt
verify: true, // Post-Import-Prüfung (§8.1)
dryRun: false, // liest, validiert, Pre-Flight; schreibt nichts
onProgress, signal,
})
```
### 5.2 Rückgabe (Auszug)
```js
ExportReport = {
formatVersion, schemaVersion, dialect,
tables: { article: { rows, bytes, sha256 }, /* ... */ },
blobs: { count, bytes, deduplicated },
warnings: [{ table, id, column, message }],
durationMs,
}
ImportReport = {
tables: { article: { read, inserted }, /* ... */ },
blobs: { written },
preflight: { issues: [/* ... */] },
verify: { ok, fkViolations: [], ledger: { ok, brokenAtId }, counts: {} },
durationMs,
}
```
## 6. Provider-Adapter
Datei pro Provider: `src/providers/<name>/transfer.js`. Der Provider bekommt die optionale Methode `transferAdapter()`. `provider.js` dokumentiert den Vertrag als JSDoc-Typedef (passt zu TODO §3, „Document provider contract“).
```js
/**
* @typedef {Object} TransferAdapter
* @property {string} dialect
* @property {(identifier: string) => string} quote
* @property {() => Promise<SchemaInfo>} introspect // Tabellen, Spalten, FKs der Live-DB
* @property {(table, columns, pk, pageSize) => AsyncIterable<Row[]>} readPages
* @property {(table, columns, rows) => Promise<number>} insertRows
* @property {(fn) => Promise<*>} withImportTransaction // inkl. FK-Deferral und Identity-Insert
* @property {BlobAccess} blobs
* @property {() => Promise<void>} afterImport // Identity-Stand prüfen, Constraints reaktivieren
*/
```
### 6.1 Lesen (`readPages`)
- **Keyset-Paging** über `pk` (`WHERE id > @after ORDER BY id`, dazu `LIMIT n` bzw. `TOP n`). Kein `OFFSET`: stabil und linear.
- Dialekt-SQL im Adapter, Spaltenlisten kommen aus der Registry.
- Blob-Spalten werden im MSSQL-Adapter nicht in der Hauptabfrage mitgelesen. Sie kommen pro Zeile in einer zweiten Abfrage (oder mit sehr kleiner `pageSize`), um den Speicher zu begrenzen.
- Konsistenter Snapshot: SQLite = ein Lese-`BEGIN` (WAL liefert einen stabilen Snapshot). MSSQL = `SNAPSHOT`-Isolation, falls `ALLOW_SNAPSHOT_ISOLATION` aktiv ist, sonst `REPEATABLE READ` mit Hinweis im Report.
- Normalisierung im Adapter: `Date` → ISO-ms-Z, `bit` → boolean, `bigint`/`decimal` → String, `Buffer` bleibt Buffer.
### 6.2 Schreiben (`insertRows`)
- Multi-Row-Insert mit **benannten Parametern** (passt zum `@name`-Konvent). Batchgröße = `floor(Parameterlimit / Spaltenanzahl)`. MSSQL erlaubt ca. 2100 Parameter je Request, SQLite je nach Build deutlich mehr.
- **Explizite IDs**:
- MSSQL: `SET IDENTITY_INSERT <table> ON` / `OFF` je Tabelle.
- SQLite: `id INTEGER PRIMARY KEY` nimmt explizite Werte direkt an.
- **Zeitstempel nicht als JS-`Date` binden.** Der mssql-Treiber bindet `Date` standardmäßig nicht als `DATETIME2(3)`. Das kann ms-genaue `created_at`-Werte verfälschen und damit die Ledger-Kette brechen. Der MSSQL-Adapter bindet `datetime` deshalb typisiert (`sql.DateTime2(3)`) oder über einen ISO-String mit expliziter Konvertierung. **Das muss ein Spike in Phase 0 bestätigen.** Dazu muss `MssqlExecutor.query` typisierte Parameter akzeptieren, was heute nicht der Fall ist (er ruft nur `req.input(key, value)` auf).
- `created_at`/`updated_at` werden explizit gesetzt und überschreiben die Defaults. Ob im Ziel Trigger oder Defaults Werte ändern, prüft Phase 0.
- `derived`-Spalten (`entity.company_id` in SQLite) werden nicht geschrieben; die Trigger des Ziels füllen sie.
### 6.3 FK-Deferral (`withImportTransaction`)
Phase 1 nutzt eine Transaktion (Atomarität vor Durchsatz):
- **SQLite**: `PRAGMA defer_foreign_keys = ON` in der Transaktion, die FKs werden beim `COMMIT` geprüft. Vorher `PRAGMA foreign_key_check` für eine brauchbare Fehlermeldung (Tabelle, rowid, Parent).
- **MSSQL**: `ALTER TABLE ... NOCHECK CONSTRAINT` für die FKs aus `deferredFks`, am Ende `ALTER TABLE ... WITH CHECK CHECK CONSTRAINT`, damit die Constraints wieder *trusted* sind. Der Zustand wird im `finally` immer wiederhergestellt. Im Verify-Schritt läuft `DBCC CHECKCONSTRAINTS`.
- **Risiko MSSQL**: Bei großen Blob-Mengen wird das Transaktionslog groß. Phase 1 dokumentiert das Limit, Phase 4 führt blockweise Commits für Blob-Tabellen ein (resume-fähig).
### 6.4 `BlobAccess`
```js
{
read(table, column, row) // -> Promise<Buffer|null>
write(table, column, row, buffer) // -> Promise<void>
}
```
- SQLite nutzt die vorhandenen Helfer (`readPicFull`, `writePicFull`, `readPicThumb`, `writePicThumb`, `readFileContent`, `writeFileContent`, `readThumbAvif`, `writeThumbAvif`). Die Dateiendung bei `file.content` kommt aus `extFromMimetype(mimetype)`.
- **Schreibreihenfolge SQLite**: erst Datei, dann Zeile. Bei Abbruch oder Rollback räumt der Import alle in diesem Lauf geschriebenen Dateien auf (Liste im Import-Kontext).
- MSSQL: Blobs als `Buffer`-Parameter im `INSERT`. Blob-Tabellen werden mit einer Zeile pro Statement geschrieben.
## 7. Exportformat
### 7.1 Layout (Verzeichnis, Phase 1)
```
export-2026-09-30T19-30-00Z/
manifest.json
tables/
language.ndjson[.gz]
tax.ndjson[.gz]
company.ndjson[.gz]
...
blobs/
ab/ab12...ef (Rohbytes, Dateiname = SHA-256)
```
- **NDJSON**: eine Zeile = ein Objekt, Spaltenreihenfolge = Registry-Reihenfolge, Zeilen nach `pk` sortiert. Der Export ist damit **deterministisch**: gleicher DB-Inhalt → byte-gleiche Dateien.
- Kanonische Serialisierung: feste Schlüsselreihenfolge, keine Leerzeichen, UTF-8, `\n` als Trenner.
- `compress: 'gzip'` über `node:zlib` (keine neue Abhängigkeit). Die Prüfsumme wird über die **unkomprimierten** Bytes berechnet, damit die Kompression die Gleichheitsprüfung nicht beeinflusst.
### 7.2 `manifest.json`
```json
{
"format": "libdashstore-export",
"formatVersion": 1,
"createdAt": "2026-09-30T19:30:00.000Z",
"source": { "dialect": "mssql", "schemaVersion": "1", "libVersion": "0.1.0" },
"options": { "includeSecrets": true, "includeBlobs": true, "compress": false },
"containsSecrets": true,
"tables": {
"article": { "file": "tables/article.ndjson", "rows": 1234, "sha256": "...", "columns": ["id", "company_id"] }
},
"blobs": { "count": 987, "bytes": 123456789 },
"ledger": { "rows": 4321, "lastId": 4321, "headHash": "sha512 der letzten Zeile" }
}
```
- `formatVersion` ist unabhängig von `meta.version` (DB-Schema). Der Import akzeptiert `formatVersion` ≤ der eigenen und lehnt neuere ab.
- `schemaVersion` stammt aus `meta.version`. Bei Abweichung zum Ziel gibt es eine Warnung bzw. in Phase 4 einen Migrationspfad, nie stilles Weiterimportieren.
- `columns` im Manifest erlauben den Import in **neuere** Schemas (neue Spalten bekommen den DB-Default) und liefern eine klare Fehlermeldung bei Spalten, die das Ziel nicht kennt.
### 7.3 Streaming
Alles läuft über `AsyncIterable` bzw. Node-Streams mit Backpressure: DB-Page → Serializer → (gzip) → Datei. Der Import läuft umgekehrt: Datei → (gunzip) → Zeilenparser → Batches → `insertRows`. Kein Schritt hält eine ganze Tabelle im Speicher.
## 8. Import-Ablauf
1. **Open + Verify-light**: `manifest.json` lesen, `formatVersion` und `schemaVersion` prüfen, Dateien vorhanden?
2. **Pre-Flight** (nur Lesen):
- Zieltabellen leer (`mode: 'empty'`)? Seed-Zeilen (`tax`, `meta`) zählen nicht als Daten und werden im Import ersetzt.
- Spalten im Export ⊆ Spalten im Ziel (per `introspect`).
- **Case-Kollisionen**: Für UQ-Indizes, die im Ziel case-insensitive sind (MSSQL), wird auf Duplikate bei `lower(value)` geprüft (`attributename`, `propertyname`, `attributevalue`, `unit` u. a.). Ergebnis ist eine Liste konkreter Zeilen statt eines kryptischen UQ-Fehlers mitten im Import.
- CHECK-Constraints des Ziels (`printer.type`, `labeltemplate.dpi`, `labeltemplate.type`) gegen die Daten prüfen.
- Bei `mode: 'replace'` ist `confirmDestroy: true` Pflicht.
3. **Transaktion + FK-Deferral** (§6.3). Bei `mode: 'replace'`: zuerst `provider.purge()` und `provider.initialize()`, danach die Seed-Zeilen löschen.
4. **Tabellen in `ORDER`**: Zeilen lesen → Blobs auflösen und schreiben → `insertRows`.
5. **Nachbereitung**: Identity-Stand prüfen (MSSQL: `DBCC CHECKIDENT(..., NORESEED)` mit `MAX(id)` vergleichen, bei Bedarf `RESEED`; SQLite: nichts nötig), Constraints wieder aktivieren (`WITH CHECK`).
6. **Commit**, danach (außerhalb der Transaktion) Verify (§8.1).
### 8.1 Verify nach dem Import
| Prüfung | Beschreibung |
|---|---|
| Zeilenanzahl | `COUNT(*)` je Tabelle == `manifest.tables[t].rows` |
| FK-Integrität | SQLite: `PRAGMA foreign_key_check`; MSSQL: `DBCC CHECKCONSTRAINTS WITH ALL_CONSTRAINTS` |
| Ledger-Kette | `prev_hash[i] == sha512(canonical(row[i-1]))`, Start = `INIT_HASH`; meldet die erste defekte `id`; `headHash` == Manifest |
| Blob-Hashes | Stichprobe, mit `verify: 'full'` vollständig: SHA-256 der gespeicherten Bytes == Manifest-Referenz |
| Round-Trip (nur Test, §10) | Re-Export der Zieldatenbank liefert dieselben Tabellen-SHA-256 wie der Quell-Export |
`transfer.verify(source)` arbeitet ohne DB und prüft nur die Dateiebene: Manifest, Tabellen-Prüfsummen, Blob-Existenz und -Hash, `$blob`-Referenzen, FK-Abschluss (jede `*_id` zeigt auf eine vorhandene Zeile, rein aus den NDJSON-Dateien) und die Ledger-Kette.
### 8.2 Ledger-Sonderfall
- Nur in aufsteigender `id` einfügen; `created_at` und `prev_hash` **unverändert** übernehmen. Der Import darf `ledger.add()` nicht benutzen, das würde neue Hashes erzeugen.
- Die Ledger-Tabelle im Ziel muss **leer** sein, sonst Abbruch. Bei `replace` auf MSSQL kommt ein Hinweis auf die Soft-Drop-Tabellen.
- **Offene Frage**: Die README (§ledger) nennt `warehouse_id` nicht in der Hash-Payload, `add.js` (`computePrevHash`) enthält es. Vor Phase 2 klären, damit `verify` die richtige kanonische Payload nutzt. Gegebenenfalls nach `hash_version` verzweigen.
## 9. Sicherheit und Datenschutz
Betroffene Felder (Registry-Flag `secret`):
- `google_auth`: `email`, `sub`, `picture`, `picturebinary` (personenbezogen)
- `posterminal`: `token`, `pairingpin`
- `companysetting`: Zeilen mit `writeonly = 1` (z. B. API-Schlüssel)
Regeln:
- Default `includeSecrets: true`, weil ein Backup ohne Zugangsdaten unvollständig ist. Das Manifest setzt `containsSecrets: true`, die CLI warnt. (Default siehe offene Frage 4.)
- `includeSecrets: false` ersetzt die Werte durch `null` bzw. ein Platzhalter-Token. Importierte, geschwärzte Terminals müssen neu gekoppelt werden (Hinweis im Report).
- Dateien und Verzeichnisse werden mit `0600` bzw. `0700` angelegt.
- Verschlüsselung (z. B. AES-256-GCM mit Schlüssel vom Aufrufer) ist bewusst **Phase 5**. Das Format reserviert `manifest.encryption`.
- `export` und `import` loggen keine Zeilendaten, nur Tabellennamen und Zähler.
## 10. Tests und Qualitätssicherung
Neue Datei `scripts/test-transfer.js` (Stil wie `scripts/test-api.js` und `test-sqlite-storage.js`, `node:assert`) und das Script `npm run test:transfer`.
| Test | Inhalt |
|---|---|
| **Registry ↔ Live-Schema** | `introspect()` beider Provider; Registry-Spalten == Live-Spalten (Namen, Nullability, Typklasse). Fehlende oder zusätzliche Spalten → Fehler. **Verhindert, dass neue Spalten unbemerkt im Export fehlen.** |
| FK-Reihenfolge | `ORDER` ist mit den FK-Metadaten beider Provider verträglich (außer `deferredFks`) |
| Fixture | Datensatz über alle Tabellen (Artikelvarianten, Stücklisten, Optionen, Bilder, Anhänge, Lieferungen, Ledger, POS, Labels). Kann auf `scripts/benchmark/workload.js` aufsetzen. |
| **Round-Trip-Matrix** | `sqlite→dir→sqlite`, `sqlite→dir→mssql`, `mssql→dir→sqlite`, `mssql→dir→mssql` (MSSQL nur, wenn eine Test-Verbindung konfiguriert ist) |
| **Akzeptanzkriterium** | Export(Quelle) und Re-Export(Ziel) haben identische Tabellen-SHA-256 **und** identische Blob-Menge |
| Ledger | Kette nach dem Import intakt; eine manipulierte Zeile → `verify` meldet genau diese `id` |
| Zeitstempel | ms-genaue `created_at` überstehen MSSQL ⇄ SQLite unverändert |
| Blobs | Bytes identisch (pic, file, picturebinary, thumb); fehlende SQLite-Datei → Warnung bzw. `strict`-Fehler |
| Fehlerpfade | Import in nicht-leere DB, unbekannte Spalte, neueres `formatVersion`, korrupte Prüfsumme, Case-Kollision, Abbruch per `AbortSignal` → DB unverändert (Rollback) und keine verwaisten SQLite-Dateien |
| Große Daten | Benchmark-Szenario; der Speicherverbrauch bleibt bei steigender Datenmenge flach |
| Determinismus | Zweimal exportieren → byte-identische `tables/*.ndjson` |
## 11. Umsetzungsphasen
### Phase 0 — Spikes (vor dem Design-Freeze)
- [ ] MSSQL: `DATETIME2(3)`-Binding mit dem `mssql`-Treiber; ms-Round-Trip, besonders `ledger.created_at`
- [ ] MSSQL: `IDENTITY_INSERT` in die `LEDGER = ON (APPEND_ONLY = ON)`-Tabelle (erlaubt? Verhalten der Ledger-Systemspalten?)
- [ ] MSSQL: `NOCHECK` / `WITH CHECK CHECK` bei zirkulären FKs innerhalb einer Transaktion
- [ ] MSSQL: Laufen beim Massen-Insert Trigger (z. B. `TR_manifestattachment_delete_file`) mit unerwarteten Effekten?
- [ ] SQLite: `PRAGMA defer_foreign_keys` zusammen mit den Triggern `TR_entity_set_company_*` beim Massen-Insert
- [ ] Klären: `warehouse_id` in der Ledger-Hash-Payload (§8.2)
- [ ] Klären: Semantik von `pic.hash` / `file.hash` (nur Doku; der Export nutzt einen eigenen SHA-256)
### Phase 1 — Kern (Export + Import in leere DB, Verzeichnisformat)
- [ ] `src/transfer/registry.js` (alle Tabellen, Typen, Blobs, `ORDER`, `secret`, `derived`, `deferredFks`)
- [ ] `src/transfer/format/` (NDJSON-Writer und -Reader, Manifest, SHA-256, gzip, Blob-Store)
- [ ] `TransferAdapter`-Typedef in `src/providers/provider.js`
- [ ] `src/providers/sqlite3/transfer.js` und `src/providers/mssql/transfer.js`
- [ ] `src/store/transfer/{export,import,verify,inspect}.js` und Einbindung in `src/index.js` (`this.transfer = { ... }`)
- [ ] Export-Subpath `"./transfer"` in `package.json`
- [ ] Tests: Registry ↔ Schema, Round-Trip SQLite→SQLite, Determinismus
### Phase 2 — Cross-Provider und Integrität
- [ ] Round-Trip-Matrix inkl. MSSQL (CI mit SQL-Server-Container)
- [ ] Ledger-Verify, Pre-Flight (Case-Kollisionen, CHECK-Constraints), Post-Import-Verify
- [ ] `includeSecrets`, Dateirechte, Manifest-Flag
- [ ] `AbortSignal`, `onProgress`, Fehlerberichte mit Tabelle und ID
### Phase 3 — Tooling und Doku
- [ ] CLI `scripts/export-db.js` und `scripts/import-db.js` (`--provider=`, `--out=`, `--in=`, `--mode=`, `--no-secrets`; Provider-Auswahl analog TODO §7)
- [ ] `npm run export` und `npm run import` in `package.json`
- [ ] `API.transfer.md` per `npm run api-doc` erzeugen; README-Abschnitt „Backup / Migration“ (Beispiele MSSQL→SQLite und zurück)
- [ ] TODO.md um einen Verweis auf diesen Plan ergänzen
### Phase 4 — Robustheit
- [ ] Blockweise Commits für Blob-Tabellen mit Resume (Checkpoint im Import-Kontext)
- [ ] Schema-Migrationspfade: Export von `schemaVersion` N in ein Ziel N+k (Spalten-Defaults, Umbenennungen über Registry-Felder wie `since` / `renamedFrom`)
- [ ] Archiv-Targets: einzelne `.tar.gz`/`.zip`-Datei und Stream-Target (HTTP-Download in dashback)
### Phase 5 — Optional
- [ ] Verschlüsselung (AES-256-GCM, Schlüssel vom Aufrufer)
- [ ] Export/Import **einer Firma** in eine bestehende DB (ID-Remapping, siehe §12.2)
- [ ] Inkrementeller Export (Wasserzeichen über `updated_at`/`id`; Ledger nur Append)
## 12. Risiken und offene Entscheidungen
### 12.1 Risiken
| Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|
| Registry driftet vom Schema (neue Spalte vergessen) | Stiller Datenverlust im Export | Pflichttest „Registry ↔ Live-Schema“ auf beiden Providern, blockierend in CI |
| `Date`-Binding im mssql-Treiber | Ledger-Hashkette bricht nach dem Import | Typisierte `DATETIME2(3)`-Parameter, Spike, Regressionstest, Ketten-Verify |
| Collation-Unterschiede SQLite ↔ MSSQL | UQ-Verletzung beim Import nach MSSQL | Pre-Flight mit konkreter Zeilenliste |
| Große Blobs in einer Transaktion (MSSQL-Log) | Log-Wachstum, Timeout | Phase 1 dokumentiert das Limit; Phase 4 blockweise Commits |
| `APPEND_ONLY`-Ledger verweigert explizite IDs oder Systemspalten | Ledger nicht importierbar | Phase-0-Spike; Fallback ist ein dokumentierter Sonderpfad oder der Verzicht auf den Ledger-Import in MSSQL |
| SQLite-Dateien und DB-Zeilen laufen auseinander (Datei fehlt) | Export unvollständig | Warnung bzw. `strict`; der Report listet die betroffenen IDs |
| Export enthält Geheimnisse | Datenleck | Manifest-Flag, Dateirechte, `includeSecrets: false`, Doku |
| `mode: 'replace'` zerstört Daten | Datenverlust | `confirmDestroy`-Pflicht, Standard ist `empty` |
### 12.2 Offene Fragen
1. **Scope**: Nur Voll-Export der ganzen DB (wie in diesem Plan) oder in Phase 1 zusätzlich Export **pro Firma**? Ein Pro-Firma-Import in eine bestehende DB braucht ID-Remapping (alle PKs und FKs neu vergeben). Dabei bricht die Ledger-Hashkette, weil `id` Teil der Hash-Payload ist. Deshalb erst Phase 5.
2. **Zielformat**: Reicht ein Verzeichnis (plus optional `.tar.gz` in Phase 4) oder braucht dashback sofort eine einzelne Archivdatei bzw. einen HTTP-Stream?
3. **Berechtigungen**: Wer darf `transfer.*` aufrufen? Der Plan sieht nur serverseitigen Gebrauch vor, die Autorisierung liegt beim Aufrufer.
4. **Secrets**: Ist der Default `includeSecrets: true` in Ordnung oder soll er `false` sein?
5. **Ledger im Ziel MSSQL**: Ist ein Import in eine `LEDGER`-Tabelle fachlich und rechtlich erlaubt (Manipulationssicherheit, TSE-Bezug „TODO_TSE_BASED_INITHASH“)? Gegebenenfalls wird der Ledger-Import ein eigener, bestätigungspflichtiger Schritt.
6. **Umfang**: Sollen `articlehistory` und `entityhistory` standardmäßig mitexportiert werden (Datenmenge)?
## 13. Betroffene Dateien
Neu:
- `FEATURE_PLAN_EXPORT.md` (dieses Dokument)
- `src/transfer/registry.js`, `src/transfer/format/*.js`, `src/transfer/index.js`
- `src/providers/sqlite3/transfer.js`, `src/providers/mssql/transfer.js`
- `src/store/transfer/{export,import,verify,inspect}.js`
- `scripts/test-transfer.js`, `scripts/export-db.js`, `scripts/import-db.js`
- `API.transfer.md` (generiert)
Geändert:
- `src/index.js` (Namespace `transfer`)
- `src/providers/provider.js` (Typedef `TransferAdapter`, `transferAdapter()`)
- `package.json` (Scripts `test:transfer`, `export`, `import`; Export `./transfer`)
- `README.md`, `TODO.md` (Verweis auf den Plan), `API.md` (regeneriert)

115
README.md
View File

@@ -42,12 +42,11 @@ Company-scoped methods take **no** `companyId` argument: they use `google_auth.s
For `POST /api/company`: For `POST /api/company`:
1. Fails if `name` is taken (`Company name not available`) 1. Ensures language by `iso639` (get or create)
2. Ensures language by `iso639` (get or create) 2. Ensures tax rows for `taxrate`, `reducedtaxrate`, and `0` → `taxfreerateId`
3. Ensures tax rows for `taxrate`, `reducedtaxrate`, and `0` → `taxfreerateId` 3. Creates root category (`parentId = null`)
4. Creates root category (`parentId = null`) 4. Creates POS `customergroup` (name `POS`), then inserts company with `poscustomergroup_id` (**NOT NULL**), then links the group via `customergroup.company_id`
5. Creates POS `customergroup` (name `POS`), then inserts company with `poscustomergroup_id` (**NOT NULL**), then links the group via `customergroup.company_id` 5. Links creator as `companyuser` (`googleAuthId` → `company_id`)
6. Links creator as `companyuser` (`googleAuthId` → `company_id`)
`company.poscustomergroup_id` is required (POS pricing in `getTreeForCompany` / category articles). Circular FK with `customergroup.company_id`: the group is inserted first with `company_id` null, then linked after the company row exists. `company.poscustomergroup_id` is required (POS pricing in `getTreeForCompany` / category articles). Circular FK with `customergroup.company_id`: the group is inserted first with `company_id` null, then linked after the company row exists.
@@ -59,7 +58,7 @@ Returns only companies where `googleAuthId` has a `companyuser` row.
For `PATCH /api/company/:id`: For `PATCH /api/company/:id`:
1. Fails if company missing / name taken by another company 1. Fails if company missing
2. Ensures lang + tax rates 2. Ensures lang + tax rates
3. Updates `name`, `default_language_id`, `taxrate`, `reducedtaxrate` 3. Updates `name`, `default_language_id`, `taxrate`, `reducedtaxrate`
(`taxfreerateId` / root category unchanged) (`taxfreerateId` / root category unchanged)
@@ -168,8 +167,8 @@ Company-scoped key/value settings. Auth via selected company (`google_auth.selec
- `frontend: true` → readable by frontend routes (`frontendOnly: true`). Default on `set` is `false` (backend-only). - `frontend: true` → readable by frontend routes (`frontendOnly: true`). Default on `set` is `false` (backend-only).
- With `frontendOnly: true`, backend-only keys are omitted / `get` returns `undefined` (no leak via error type beyond missing). - With `frontendOnly: true`, backend-only keys are omitted / `get` returns `undefined` (no leak via error type beyond missing).
- `writeonly: true` → usergui gets a `"***"` scrambled version when retrieving the key (in `list`, or in `get` when `maskWriteonly` is enabled / `frontendOnly: true`). The backend receives the full value. - `writeonly: true` → usergui gets a `"***"` scrambled version when retrieving the key (in `list`, or in `get` when `maskWriteonly` is enabled / `frontendOnly: true`). The backend receives the full value.
- `keys.tavily` and `keys.openrouter` default to `writeonly: true` on `set`. - `keys.tavily`, `keys.openrouter`, and `keys.serper` default to `writeonly: true` on `set`.
- `set` only allows a fixed allow-list of keys: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. - `set` only allows a fixed allow-list of keys: `ui.theme`, `keys.tavily`, `keys.openrouter`, `keys.serper`, `systemprompt-prefix`, `systemprompt-suffix`, `default-model`, `modelslug`, `modelslugflag`, `modelreasoning`, `modelslugimage`.
- No delete endpoint — settings are upsert / read only. - No delete endpoint — settings are upsert / read only.
Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`. Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`.
@@ -234,7 +233,7 @@ type Entity = {
} }
``` ```
`list` optional filters: `customergroupId` (must belong to the company), or `role: 'customer'` (entities whose customergroup name is not `Lieferanten` and not `POS` — for quote/order pickers). `role` wins over `customergroupId` when both are set. Unknown `role` → `Invalid role`. `list` optional filters: `customergroupId` (must belong to the company), or `role: 'customer'` (entities whose customergroup name is not `Lieferanten` — for quote/order pickers). `role` wins over `customergroupId` when both are set. Unknown `role` → `Invalid role`.
`number` is optional on the row (e.g. suppliers leave it null). Non-null numbers are unique per company (via `customergroup`). `create` allocates via `getnextnumberwithprefixandpadding` (`type` `entity`) when `number` is omitted. `update` only changes `number` when the key is present (no auto-allocate). `peekNextNumber` returns what the next allocate would yield without consuming it (`peeknextnumberwithprefixandpadding`; ensures the `numbers` row exists). `isNumberFree` checks occupancy company-wide; use `excludeEntityId` on edit so the current entity’s number stays free. Empty `number` → `Number required`. `number` is optional on the row (e.g. suppliers leave it null). Non-null numbers are unique per company (via `customergroup`). `create` allocates via `getnextnumberwithprefixandpadding` (`type` `entity`) when `number` is omitted. `update` only changes `number` when the key is present (no auto-allocate). `peekNextNumber` returns what the next allocate would yield without consuming it (`peeknextnumberwithprefixandpadding`; ensures the `numbers` row exists). `isNumberFree` checks occupancy company-wide; use `excludeEntityId` on edit so the current entity’s number stays free. Empty `number` → `Number required`.
@@ -296,7 +295,8 @@ Maps articles to suppliers (`entity`) with purchase pricing, stock, packaging un
| `id` | `INT` | Primary Key (`IDENTITY(1,1)`) | | `id` | `INT` | Primary Key (`IDENTITY(1,1)`) |
| `article_id` | `INT` | FK → `article(id)` (`ON DELETE CASCADE`) | | `article_id` | `INT` | FK → `article(id)` (`ON DELETE CASCADE`) |
| `entity_id` | `INT` | FK → `entity(id)` (`ON DELETE CASCADE`) | | `entity_id` | `INT` | FK → `entity(id)` (`ON DELETE CASCADE`) |
| `price_id` | `INT` | FK → `price(id)` (`ON DELETE SET NULL`), nullable | | `net` | `FLOAT` | Purchase net price, nullable |
| `tax_id` | `INT` | FK → `tax(id)` (`ON DELETE NO ACTION`), nullable |
| `sku` | `NVARCHAR(100)` | Supplier's SKU code, nullable | | `sku` | `NVARCHAR(100)` | Supplier's SKU code, nullable |
| `stock` | `FLOAT` | Stock available at supplier, nullable | | `stock` | `FLOAT` | Stock available at supplier, nullable |
| `pu` | `FLOAT` | Packaging unit (Verpackungseinheit), default `1` | | `pu` | `FLOAT` | Packaging unit (Verpackungseinheit), default `1` |
@@ -322,13 +322,31 @@ Company-scoped goods receipts on `manifest` / `manifestarticle` with fixed `type
| Method | Signature | Returns | | Method | Signature | Returns |
| --- | --- | --- | | --- | --- | --- |
| `list` | `(googleAuthId)` | `DeliverySummary[]` | | `list` | `(googleAuthId, opts?: DeliveryListOpts)` | `DeliverySummary[] | WindowedDeliveries` |
| `getById` | `(googleAuthId, id)` | `Delivery` | | `getById` | `(googleAuthId, id)` | `Delivery` |
| `create` | `(googleAuthId, { date, supplierId?, externalNumber?, lines })` | `Delivery` | | `create` | `(googleAuthId, { date, supplierId?, externalNumber?, lines })` | `Delivery` |
| `update` | `(googleAuthId, id, { number?, date?, supplierId?, externalNumber?, lines? })` | `Delivery` | | `update` | `(googleAuthId, id, { number?, date?, supplierId?, externalNumber?, lines? })` | `Delivery` |
| `delete` | `(googleAuthId, id)` | `void` | | `delete` | `(googleAuthId, id)` | `void` |
| `rebuildSummaries` | `(googleAuthId)` | `{ ok: boolean, companyId: number }` |
```ts ```ts
type DeliveryListOpts = {
offset?: number,
limit?: number,
search?: string,
onlyUnbooked?: boolean,
supplierId?: number,
seekMonths?: number,
}
type WindowedDeliveries = {
deliveries: DeliverySummary[],
total: number,
unfilteredTotal: number,
offset: number,
limit: number,
}
type DeliverySummary = { type DeliverySummary = {
id: number, id: number,
number: string, number: string,
@@ -336,6 +354,31 @@ type DeliverySummary = {
supplierId: number | null, // manifest.entity_id supplierId: number | null, // manifest.entity_id
externalNumber: string | null, // manifest.external_number externalNumber: string | null, // manifest.external_number
date: Date, date: Date,
createdAt?: string,
supplierName?: string | null,
summary?: {
positions: number,
grossTotal: number,
netTotal: number,
totalQuantity: number,
stockPositions: number,
bookedPositions: number,
unbookedPositions: number,
stockQuantity: number,
bookedQuantity: number,
unbookedQuantity: number,
isBooked: boolean,
},
ledger?: {
totalPositions: number,
completelyOnLedgerPositions: number,
bookedPositions: number,
unbookedPositions: number,
totalQuantity: number,
bookedQuantity: number,
unbookedQuantity: number,
isBooked: boolean,
},
} }
type DeliveryLine = { type DeliveryLine = {
@@ -397,7 +440,7 @@ type ManifestLine = {
type Quote = QuoteSummary & { lines: ManifestLine[] } type Quote = QuoteSummary & { lines: ManifestLine[] }
``` ```
`entityId` null or a company entity whose customergroup is not named `Lieferanten` or `POS` (else `Entity not found`). Lines: `sku` (category tree → `Article not found`), `amount`, `net`, `tax` (`tax.ensure`); `rebate` defaults `0`. Empty/invalid `number` or `date` → `Invalid quote`; bad lines → `Invalid lines`. `entityId` null or a company entity whose customergroup is not named `Lieferanten` (else `Entity not found`). Lines: `sku` (category tree → `Article not found`), `amount`, `net`, `tax` (`tax.ensure`); `rebate` defaults `0`. Empty/invalid `number` or `date` → `Invalid quote`; bad lines → `Invalid lines`.
`update`: present fields only; `lines` present → replace-all. `delete` cascades lines. Errors: `No company selected`, `Company not found`, `Not authorized`, `Quote not found`, `Entity not found`, `Article not found`, `Invalid quote`, `Invalid lines`. `update`: present fields only; `lines` present → replace-all. `delete` cascades lines. Errors: `No company selected`, `Company not found`, `Not authorized`, `Quote not found`, `Entity not found`, `Article not found`, `Invalid quote`, `Invalid lines`.
@@ -405,7 +448,7 @@ type Quote = QuoteSummary & { lines: ManifestLine[] }
## `order` ## `order`
Company-scoped orders on `manifest` / `manifestarticle` with fixed `type = 'order'`. Same API shape as `quote` (`entityId` customer, not Lieferanten/POS). `manifest.date` may be documented as expected delivery in HTTP docs. Company-scoped orders on `manifest` / `manifestarticle` with fixed `type = 'order'`. Same API shape as `quote` (`entityId` customer, not Lieferanten). `manifest.date` may be documented as expected delivery in HTTP docs.
| Method | Signature | Returns | | Method | Signature | Returns |
| --- | --- | --- | | --- | --- | --- |
@@ -434,7 +477,16 @@ Errors: `No company selected`, `Company not found`, `Not authorized`, `Order not
## `ledger` ## `ledger`
Append-only SQL Server ledger table (`WITH (LEDGER = ON (APPEND_ONLY = ON))`). Rows cannot be updated or deleted. References `manifestarticle` with `ON DELETE NO ACTION` (a line with ledger entries cannot be removed). No store API yet — INSERT only via future store methods / direct SQL. Append-only SQL Server ledger table (`WITH (LEDGER = ON (APPEND_ONLY = ON))`). Rows cannot be updated or deleted. References `manifestarticle` with `ON DELETE NO ACTION` (a line with ledger entries cannot be removed). Auth via selected company (`google_auth.selected_company` + membership; line must belong to company).
| Method | Signature | Returns |
| --- | --- | --- |
| `add` | `(googleAuthId, { manifestarticle_id, transaction_type, amount, transaction_text })` | `{ id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }` |
| `listByManifestarticleId` | `(googleAuthId, manifestarticleId, [transactionType])` | `{ id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }[]` |
| `listBySku` | `(googleAuthId, sku, [transactionType])` | `{ id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }[]` |
- `hash_version`: 1
- `prev_hash`: For the first entry in the database, `TODO_TSE_BASED_INITHASH`. For subsequent entries, SHA-512 hex of canonical JSON `JSON.stringify({ id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at })` of the entry with the highest `id`.
| Column | Type | Notes | | Column | Type | Notes |
| --- | --- | --- | | --- | --- | --- |
@@ -443,8 +495,9 @@ Append-only SQL Server ledger table (`WITH (LEDGER = ON (APPEND_ONLY = ON))`). R
| `transaction_type` | `NVARCHAR(100)` | | | `transaction_type` | `NVARCHAR(100)` | |
| `amount` | `FLOAT` | | | `amount` | `FLOAT` | |
| `transaction_text` | `NVARCHAR(MAX)` | | | `transaction_text` | `NVARCHAR(MAX)` | |
| `hash_version` | `INT` | hash algorithm / payload version | | `hash_version` | `INT` | hash algorithm / payload version (1) |
| `prev_hash` | `NVARCHAR(128)` | nullable (genesis row) | | `prev_hash` | `NVARCHAR(128)` | SHA-512 hex of previous row or init hash |
| `created_at` | `DATETIME2` | Creation timestamp |
Engine also maintains `ledger_start_transaction_id` / `ledger_start_sequence_number`. `purge`/`reset-db` soft-drops the table (`MSSQL_DroppedLedgerTable_*` remains; a new `ledger` can still be created). Engine also maintains `ledger_start_transaction_id` / `ledger_start_sequence_number`. `purge`/`reset-db` soft-drops the table (`MSSQL_DroppedLedgerTable_*` remains; a new `ledger` can still be created).
@@ -491,7 +544,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c
| `order` | `({ googleAuthId, parentId, categoryIds })` | `{ categoryIds }` | | `order` | `({ googleAuthId, parentId, categoryIds })` | `{ categoryIds }` |
| `delete` | `({ googleAuthId, id })` | `void` — `Category has children` if child categories remain; FK `categoryarticle` also blocks deletion | | `delete` | `({ googleAuthId, id })` | `void` — `Category has children` if child categories remain; FK `categoryarticle` also blocks deletion |
| `getTreeForCompany` | `(googleAuthId)` | `{ languageId, tree, articles }` — tree rows carry `name` + `slug` | | `getTreeForCompany` | `(googleAuthId)` | `{ languageId, tree, articles }` — tree rows carry `name` + `slug` |
| `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, fatherArticleId, fatherSku, fullHash, thumbW, thumbH, thumbHash }[]` | | `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, parentArticleId, parentSku, fullHash, thumbW, thumbH, thumbHash }[]` |
| `addImage` | `(googleAuthId, categoryId, imageBuffer)` | `{ id, thumbW, thumbH, thumbHash }` | | `addImage` | `(googleAuthId, categoryId, imageBuffer)` | `{ id, thumbW, thumbH, thumbHash }` |
| `deleteImage` | `(googleAuthId, categoryId)` | `{ id, thumbW: null, thumbH: null, thumbHash: null }` | | `deleteImage` | `(googleAuthId, categoryId)` | `{ id, thumbW: null, thumbH: null, thumbHash: null }` |
@@ -510,7 +563,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c
`move` moves a category to a new parent. Both `id` (source) and `parentId` (target) are validated via `resolveCategoryAccess` — the target must be a real category of the same authorized company, else `Parent not found`. Moving into itself or into a descendant (cycle) → `Invalid parent`. The moved category gets the next sibling `sort` under the new parent. Returns `{ id, parentId, sort }`. For `POST /api/category/:id/move`. `move` moves a category to a new parent. Both `id` (source) and `parentId` (target) are validated via `resolveCategoryAccess` — the target must be a real category of the same authorized company, else `Parent not found`. Moving into itself or into a descendant (cycle) → `Invalid parent`. The moved category gets the next sibling `sort` under the new parent. Returns `{ id, parentId, sort }`. For `POST /api/category/:id/move`.
`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`. `addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `wasm-image-optimization`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`.
`addImage` returns: `addImage` returns:
@@ -533,21 +586,22 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c
| --- | --- | --- | | --- | --- | --- |
| `listSearchIndex` | `(googleAuthId: string, companyId: number)` | `SearchIndexEntry[]` (see below) | | `listSearchIndex` | `(googleAuthId: string, companyId: number)` | `SearchIndexEntry[]` (see below) |
| `listExport` | `(googleAuthId: string, companyId: number, categoryId?: number)` | `ArticleExportEntry[]` (see below) | | `listExport` | `(googleAuthId: string, companyId: number, categoryId?: number)` | `ArticleExportEntry[]` (see below) |
| `createUnderParent` | `({ googleAuthId: string, parentId: number, name: string })` | `{ id: number, parentId: number, name: string, sku: string }` | | `createUnderParent` | `({ googleAuthId: string, parentId: number, name: string, track_stock?: boolean })` | `{ id: number, parentId: number, name: string, sku: string, track_stock: boolean }` |
| `getBySku` | `(googleAuthId: string, sku: string)` | `ArticleDetail` (see below) | | `getBySku` | `(googleAuthId: string, sku: string)` | `ArticleDetail` (see below) |
| `getHistoryBySku` | `(googleAuthId: string, sku: string)` | `{ id: number, companyuserId: number \| null, date: Date, text: string }[]` | | `getHistoryBySku` | `(googleAuthId: string, sku: string)` | `{ id: number, companyuserId: number \| null, date: Date, text: string }[]` |
| `updateBySku` | `(googleAuthId: string, sku: string, { name?: string, unitamount?: number, unitSi?: string })` | `ArticleDetail` | | `updateBySku` | `(googleAuthId: string, sku: string, { name?: string, unitamount?: number, unitSi?: string, track_stock?: boolean })` | `ArticleDetail` |
| `setPricesBySku` | `(googleAuthId: string, sku: string, PriceInput[])` | `ArticleDetail` | | `setPricesBySku` | `(googleAuthId: string, sku: string, PriceInput[])` | `ArticleDetail` |
| `setEansBySku` | `(googleAuthId: string, sku: string, string[])` | `ArticleDetail` | | `setEansBySku` | `(googleAuthId: string, sku: string, string[])` | `ArticleDetail` |
| `setBrandIdBySku` | `(googleAuthId: string, sku: string, number \| null)` | `ArticleDetail` | | `setBrandIdBySku` | `(googleAuthId: string, sku: string, number \| null)` | `ArticleDetail` |
| `setWeightBySku` | `(googleAuthId: string, sku: string, number)` | `ArticleDetail` | | `setWeightBySku` | `(googleAuthId: string, sku: string, number)` | `ArticleDetail` |
| `setTrackStockBySku` | `(googleAuthId: string, sku: string, boolean)` | `ArticleDetail` |
| `setCategoryIdsBySku` | `(googleAuthId: string, sku: string, number[])` | `ArticleDetail` | | `setCategoryIdsBySku` | `(googleAuthId: string, sku: string, number[])` | `ArticleDetail` |
| `addImage` | `(googleAuthId: string, sku: string, Buffer)` | `ImageResult` (see below) | | `addImage` | `(googleAuthId: string, sku: string, Buffer)` | `ImageResult` (see below) |
| `deleteImage` | `(googleAuthId: string, sku: string, fullHash: string)` | `void` | | `deleteImage` | `(googleAuthId: string, sku: string, fullHash: string)` | `void` |
| `orderImages` | `(googleAuthId: string, sku: string, { fullHashes: string[] })` | `{ fullHashes: string[] }` | | `orderImages` | `(googleAuthId: string, sku: string, { fullHashes: string[] })` | `{ fullHashes: string[] }` |
| `delete` | `(googleAuthId: string, sku: string)` | `void` | | `delete` | `(googleAuthId: string, sku: string)` | `void` |
`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. `addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `wasm-image-optimization`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`.
`addImage` returns `ImageResult`: `addImage` returns `ImageResult`:
@@ -595,7 +649,7 @@ type ArticleExportEntry = {
} }
``` ```
`getBySku` / `updateBySku` / `setPricesBySku` / `setEansBySku` / `setBrandIdBySku` / `setWeightBySku` / `setCategoryIdsBySku` all return `ArticleDetail`: `getBySku` / `updateBySku` / `setPricesBySku` / `setEansBySku` / `setBrandIdBySku` / `setWeightBySku` / `setTrackStockBySku` / `setCategoryIdsBySku` all return `ArticleDetail`:
```ts ```ts
type ArticleDetail = { type ArticleDetail = {
@@ -605,6 +659,7 @@ type ArticleDetail = {
slug: string | null, slug: string | null,
unitamount: number, unitamount: number,
weight: number, weight: number,
track_stock: boolean,
unit: { unit: {
si: string, // e.g. "pc" si: string, // e.g. "pc"
basesi: string, // e.g. "pc" basesi: string, // e.g. "pc"
@@ -631,8 +686,8 @@ type ArticleDetail = {
optionsCount: number, // 0 when no optionslist optionsCount: number, // 0 when no optionslist
articleListCount: number, // 0 when no articlelist articleListCount: number, // 0 when no articlelist
childCount: number, // 0 when no children childCount: number, // 0 when no children
fatherArticleId: number | null, // null when root article parentArticleId: number | null, // null when root article
fatherSku: string | null, // null when root article parentSku: string | null, // null when root article
} }
type PriceEntry = { type PriceEntry = {
@@ -661,13 +716,15 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err
`setWeightBySku` sets the article’s `weight`. Must be finite and `>= 0` (else `Invalid weight`). Errors: `Article not found`, `Not authorized`, `Invalid weight`. `setWeightBySku` sets the article’s `weight`. Must be finite and `>= 0` (else `Invalid weight`). Errors: `Article not found`, `Not authorized`, `Invalid weight`.
`setTrackStockBySku` sets the article’s `track_stock` flag. Must be boolean or 0/1 (else `Invalid track_stock`). Errors: `Article not found`, `Not authorized`, `Invalid track_stock`.
`setCategoryIdsBySku` replaces the article’s category list (`categoryarticle`) in one transaction. Each category must belong to the article’s company and ids must be unique (else `Invalid categoryIds`); empty array is rejected — an article must have at least one category (else `Article must have at least one category`). Returns `ArticleDetail` (whose `categoryIds` reflects the new list). Errors: `Article not found`, `Not authorized`, `Invalid categoryIds`, `Article must have at least one category`. `setCategoryIdsBySku` replaces the article’s category list (`categoryarticle`) in one transaction. Each category must belong to the article’s company and ids must be unique (else `Invalid categoryIds`); empty array is rejected — an article must have at least one category (else `Article must have at least one category`). Returns `ArticleDetail` (whose `categoryIds` reflects the new list). Errors: `Article not found`, `Not authorized`, `Invalid categoryIds`, `Article must have at least one category`.
`deleteImage` removes `articlepic` and the `pic` row for this article (`fullHash`). Errors: `Article not found`, `Image not found`, `Not authorized`. `deleteImage` removes `articlepic` and the `pic` row for this article (`fullHash`). Errors: `Article not found`, `Image not found`, `Not authorized`.
`orderImages` sets `articlepic.sort` to `0..n-1` in the given order. `fullHashes` must list every image of the article exactly once. Errors: `Article not found`, `Invalid image order`, `Not authorized`. `orderImages` sets `articlepic.sort` to `0..n-1` in the given order. `fullHashes` must list every image of the article exactly once. Errors: `Article not found`, `Invalid image order`, `Not authorized`.
`delete` removes the article when it has no child articles (`fatherarticle_id` FK is `ON DELETE NO ACTION`). Cascades: `articlehistory`, `articleean`, `categoryarticle`, `price`, `articlesupplier`, `articletext`, `articlelistarticle`, `optionslistarticle`, `articlepic`; deletes linked `pic` rows and owned `articlelist` / `optionslist`. Errors: `Article not found`, `Article has children`, `Not authorized`. `delete` removes the article when it has no child articles (`parentarticle_id` FK is `ON DELETE NO ACTION`). Cascades: `articlehistory`, `articleean`, `categoryarticle`, `price`, `articlesupplier`, `articletext`, `articlelistarticle`, `optionslistarticle`, `articlepic`; deletes linked `pic` rows and owned `articlelist` / `optionslist`. Errors: `Article not found`, `Article has children`, `Not authorized`.
--- ---
@@ -764,7 +821,8 @@ Company-scoped number sequences (`numbers` table). Auth via selected company (`g
| `PATCH /api/article/:sku/supplier/:id` | `articlesupplier.update(googleAuthId, sku, id, patch)` | | `PATCH /api/article/:sku/supplier/:id` | `articlesupplier.update(googleAuthId, sku, id, patch)` |
| `PUT /api/article/:sku/supplier/order` | `articlesupplier.order(googleAuthId, sku, { ids })` | | `PUT /api/article/:sku/supplier/order` | `articlesupplier.order(googleAuthId, sku, { ids })` |
| `DELETE /api/article/:sku/supplier/:id` | `articlesupplier.delete(googleAuthId, sku, id)` | | `DELETE /api/article/:sku/supplier/:id` | `articlesupplier.delete(googleAuthId, sku, id)` |
| `GET /api/delivery` | `delivery.list(googleAuthId)` | | `GET /api/delivery` | `delivery.list(googleAuthId, opts)` |
| `POST /api/delivery/rebuild-summaries` | `delivery.rebuildSummaries(googleAuthId)` |
| `GET /api/delivery/:id` | `delivery.getById(googleAuthId, id)` | | `GET /api/delivery/:id` | `delivery.getById(googleAuthId, id)` |
| `POST /api/delivery` | `delivery.create(googleAuthId, { number, date, supplierId?, lines })` | | `POST /api/delivery` | `delivery.create(googleAuthId, { number, date, supplierId?, lines })` |
| `PATCH /api/delivery/:id` | `delivery.update(googleAuthId, id, { number?, date?, supplierId?, lines? })` | | `PATCH /api/delivery/:id` | `delivery.update(googleAuthId, id, { number?, date?, supplierId?, lines? })` |
@@ -791,11 +849,12 @@ Company-scoped number sequences (`numbers` table). Auth via selected company (`g
| `GET /api/article/:sku/history` | `article.getHistoryBySku(googleAuthId, sku)` | | `GET /api/article/:sku/history` | `article.getHistoryBySku(googleAuthId, sku)` |
| `GET /api/articleIndex` | `article.listSearchIndex(googleAuthId)` | | `GET /api/articleIndex` | `article.listSearchIndex(googleAuthId)` |
| `GET /api/article/export.zip` | `article.listExport(googleAuthId, categoryId?)` | | `GET /api/article/export.zip` | `article.listExport(googleAuthId, categoryId?)` |
| `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name?, unitamount?, unitSi? })` | | `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name?, unitamount?, unitSi?, track_stock? })` |
| `PUT /api/article/:sku/prices` | `article.setPricesBySku(googleAuthId, sku, [{ price, tax, minmultiplier? }])` | | `PUT /api/article/:sku/prices` | `article.setPricesBySku(googleAuthId, sku, [{ price, tax, minmultiplier? }])` |
| `PUT /api/article/:sku/eans` | `article.setEansBySku(googleAuthId, sku, string[])` | | `PUT /api/article/:sku/eans` | `article.setEansBySku(googleAuthId, sku, string[])` |
| `PUT /api/article/:sku/brand` | `article.setBrandIdBySku(googleAuthId, sku, brandId \| null)` | | `PUT /api/article/:sku/brand` | `article.setBrandIdBySku(googleAuthId, sku, brandId \| null)` |
| `PUT /api/article/:sku/weight` | `article.setWeightBySku(googleAuthId, sku, weight)` | | `PUT /api/article/:sku/weight` | `article.setWeightBySku(googleAuthId, sku, weight)` |
| `PUT /api/article/:sku/track_stock` | `article.setTrackStockBySku(googleAuthId, sku, track_stock)` |
| `PUT /api/article/:sku/categories` | `article.setCategoryIdsBySku(googleAuthId, sku, number[])` | | `PUT /api/article/:sku/categories` | `article.setCategoryIdsBySku(googleAuthId, sku, number[])` |
| `DELETE /api/article/:sku` | `article.delete(googleAuthId, sku)` | | `DELETE /api/article/:sku` | `article.delete(googleAuthId, sku)` |
| `POST /api/article/:sku/image` | `article.addImage(googleAuthId, sku, imageBuffer)` | | `POST /api/article/:sku/image` | `article.addImage(googleAuthId, sku, imageBuffer)` |

202
TODO.md Normal file
View File

@@ -0,0 +1,202 @@
# TODO — Database provider reorganization
Milestones 1–3 are done (provider contract, MSSQL provider, repo reorg).
SQLite provider, native schema, and query translations now exist; store wiring,
migrations, lifecycle methods, and parity tests remain outstanding.
## 1. Convert store modules to the provider API (per area, keep behavior)
Replace raw `pool` usage in `src/store/**` with `provider.query()` /
`provider.transaction()` / `provider.exec()` and the normalized result shape
(`{ rows, rowsAffected, insertId }` instead of `recordset` / `rowsAffected[]`).
For every module:
- [x] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)`
- [x] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })`
- [x] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected`
- [x] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — deleted
- [x] `src/store/runner.js` bridges mixed call sites: converted modules accept a
provider runner *or* a legacy mssql Transaction/`pool` (wrapped on the fly)
so partially converted callers keep working
- [x] `recordset[0].id` after `OUTPUT INSERTED.id` → keep, but prefer `insertId` where trivial
Conversion order (bottom-up by dependency):
- [x] `lang`, `tax` (leaf helpers, used by company create)
- [x] `google_auth`
- [x] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first)
- [x] `company`, `companysetting`
- [x] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory`
- [x] `unit` (incl. `ensureDefault`), `brand`, `supplier`
- [x] `numbers` (see §2 — stored procs!)
- [x] `category` (recursive CTEs!)
- [x] `article` + articletext/articlepic/articleean/price/articlehistory
- [x] `articlesupplier`
- [x] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`)
- [x] `posterminal`, `pic`
- [x] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`)
- [x] Delete `src/store/request.js` once no module uses it
## 2. Replace MSSQL stored procedures (blocker for SQLite)
- [x] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param)
- [x] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param)
- [x] Implement both in plain JS inside the store layer: `SELECT next/prefix/padding`,
compute `prefix + padded next`, `UPDATE numbers SET next = next + 1 WHERE ...`.
Must stay atomic: use `provider.transaction()` with row locking semantics
(MSSQL: `WITH (UPDLOCK, ROWLOCK)` in the SELECT; SQLite: within its write tx).
- [ ] Drop the proc definitions from schema/init scripts (`scripts/init-schema.js`).
## 3. provider contract additions / hygiene
- [ ] Add `isUniqueConstraintError` / `isForeignKeyConstraintError` to the
contract and use them in stores instead of raw mssql error numbers
(currently error messages like `Customer group in use`, `Brand in use`,
`Unit in use` rely on FK/unique detection — grep for these).
- [ ] Make `initialize()`/`purge()` real provider methods (schema migrations move
into the provider; store delegates). Decide per §5.
- [ ] Document provider contract (JSDoc typedef in `src/providers/provider.js`).
## 4. better-sqlite3 provider
- [x] Add `src/providers/better-sqlite3/index.js` implementing the same contract:
- `query()` → prepare + `.all()`/`.get()`/`.run()`; normalize
`lastInsertRowid` → `insertId`, `changes` → `rowsAffected`
- `transaction(fn)` → `db.transaction()` wrapper (note: better-sqlite3 is sync;
wrap in async signature so store code stays unchanged)
- Named params: mssql uses `@name`; better-sqlite3 uses `:name` / `@name` /
`$name` — define one (e.g. `@name`) in SQLite SQL so params pass through
- Pragmas on open: `foreign_keys = ON`, `journal_mode = WAL`, `busy_timeout = 5000`
- `BLOB` handling: return `Buffer` for pic/image/attachment binaries
- Error classification: unique → `SQLITE_CONSTRAINT_UNIQUE`, FK → `SQLITE_CONSTRAINT_FOREIGNKEY`
- Do NOT close the caller-owned db in the provider; expose `close()`
- [x] `better-sqlite3` as peerDependency (optional) in `package.json`
- [x] package.json exports: `"./providers/better-sqlite3"`
## 5. SQLite schema (native, not mechanically translated)
- [x] Native schema in `src/providers/sqlite3/schema.js`:
- `INT IDENTITY` → `INTEGER PRIMARY KEY` (rowid)
- `NVARCHAR(n)` → `TEXT`; `VARBINARY(MAX)` → `BLOB`
- `DATETIME2 DEFAULT SYSUTCDATETIME()` → `TEXT` ISO-8601 UTC
(`strftime('%Y-%m-%dT%H:%M:%fZ','now')`)
- `IF NOT EXISTS ... sys.tables` → `CREATE TABLE IF NOT EXISTS`
- `OUTPUT INSERTED...` in queries → `RETURNING ...`
- `MERGE companysetting` → `INSERT ... ON CONFLICT ... DO UPDATE`
- `[identifier]` brackets → rename or quote (`"number"`)
- [ ] Add versioned SQLite migrations around the native schema
- [ ] **Entity number uniqueness parity** (MSSQL still uses indexed view
`v_entity_company_number`):
- [x] SQLite: add `company_id` to `entity` (denormalized from customergroup, kept
in sync on insert + customergroup moves) and enforce
`CREATE UNIQUE INDEX uq_entity_company_number ON entity(company_id, number) WHERE number IS NOT NULL`
- [ ] Add the equivalent MSSQL migration for parity; remove the indexed view
- [ ] Versioned migrations via `meta` table (already exists) instead of pure
`IF NOT EXISTS` scripts for both providers
- [ ] Implement `initialize()` (migrations) and `purge()` (drop tables, FK-order)
in the SQLite provider; port `purgeSql.js` logic (currently topological
drop order — replicate)
## 6. SQL translation per area (SQLite queries)
- [x] Add all 32 modules under `src/providers/sqlite3/queries/`, preserving the
MSSQL query modules' named-export API (plus SQLite number helpers)
- [x] `TOP 1` → `LIMIT 1` (mind: TOP without ORDER BY — keep identical ordering)
- [x] `OUTER APPLY (SELECT TOP 1 ...)` (article/category first-pic joins) →
correlated subquery or `JOIN` on min(sort,id)
- [x] `FOR JSON PATH` queries → SQLite JSON1 aggregation with shapes compatible
with the current store parsers (building JSON in JS remains a future cleanup)
- [x] Recursive CTEs (`SEARCH_ARTICLES`, `RESOLVE_ARTICLE_ACCESS_BY_SKU`,
`GET_CATEGORY_TREE_FLAT`) → SQLite `WITH RECURSIVE` (mostly compatible)
- [x] `ISNULL` / `COALESCE` differences; `FLOAT` vs `REAL`
- [x] Case-insensitive comparisons relying on MSSQL default collation
(e.g. name-uniqueness checks) → `COLLATE NOCASE` in SQLite schema or
explicit `LOWER()` in both dialects
- [x] Compile all 212 complete SQLite query exports against an in-memory native
schema (zero syntax/schema errors)
## 7. Contract / parity tests
- [ ] Test harness that runs the identical suite against both providers
(SQLite in-memory for speed; MSSQL in CI)
- [ ] Cover: return shapes, auth errors (`No company selected`, `Not authorized`),
domain errors, tx rollback, FK/unique behavior (`Brand in use` etc.),
binary round-trips (pic, manifestattachment, google_auth picturebinary),
date handling, recursive category tree, importRows two-pass V/K linking,
numbers.next/peek atomicity
- [ ] `scripts/test-api.js`: parametrize over provider
- [ ] `scripts/init-schema.js`, `reset-db.js`, `purge-db.js`: accept provider
selection (e.g. `--provider=better-sqlite3` / env `DASH_DB_PROVIDER`)
## 8. Docs & tooling
- [ ] README: provider-based usage, both examples, migration notes for dashback
- [ ] `API.md` / `API.*.md` generators (`generate-api-doc.js`): update paths if
they scan `src/sql` / `src/schema`
- [ ] `generate-schema-graph.js` / `schema-graph.dot`: point at both schemas
- [ ] Deprecation note: `new LibDashStore(pool)` removed — constructor now takes
a provider (breaking change; coordinate with dashback)
## 9. Cleanup
- [ ] Remove transitional `this.pool` / `provider.pool` usage from `index.js`
- [ ] Remove `mssql` from peerDependencies only if no runtime coupling remains
(queries/schema stay mssql-specific, so likely keep it)
- [ ] Decide export for `SCHEMA_SCRIPTS` (currently re-exported from `index.js`
from the moved schema file)
I'll check how auth is applied across the routes.
Viewed index.js:1-305
Searched for "app\.(post|put|patch|delete|get|use|all)\("
Viewed auth.js:1-132
The middleware skips auth on several path prefixes. Next I'll check which of those can write.
Searched for "\.(post|put|patch|delete)\("
Searched for "'/(api/)?(public|mcp|oauth|\.well-known)"
Next: the telemetry public route, the `/oauth/register` handler, and the v1 router.
Viewed telemetry.js:1-118
Viewed mcp.js:360-460
Searched for "(router|app)\.(post|put|patch|delete|use|all)\(|auth|token"
Viewed index.js:1-27
Searched for "registerClient|export (async )?function"
Viewed mcp.js:455-540
Searched for "db\.\w+|verifyIdToken|allow|jwt\.sign"
**No. With no JWT, an attacker can still write to the database.** The checks I did are static code review only, and I haven't run anything.
`authMiddleware` ([auth.js](file:///home/seb/src/dashback/src/api/middleware/auth.js#L49-L68)) lets several path groups through without a token. Most of them can't write to the SQL database, but two can.
### 1. Open OAuth registration: `POST /oauth/register`
- This route is public, and it calls `mcpDb.registerClient` ([mcp.js:368](file:///home/seb/src/dashback/src/api/routes/mcp.js#L368)) to insert a client row.
- Anyone can create unlimited rows with no auth and no rate limit.
- `client_name`, `redirect_uris`, `grant_types` and `scope` all come straight from the request body.
### 2. Auth bypass: `POST /oauth/authorize`
- The middleware calls `next()` on this route even with no token ([auth.js:70-79](file:///home/seb/src/dashback/src/api/middleware/auth.js#L70-L79)).
- The handler ([mcp.js:455-503](file:///home/seb/src/dashback/src/api/routes/mcp.js#L455-L503)) takes an `email` from the body when there's no `req.user`.
- If that email is on the allowlist, it writes an auth code (`createAuthCode`) for that user and redirects with it. **No password, Google token or JWT is checked.**
- An attacker can chain this with the previous hole:
1. Register a client with `/oauth/register`.
2. Post to `/oauth/authorize` with the victim's allowlisted email and their own PKCE `code_challenge`.
3. Exchange the code at `/oauth/token`, which I haven't read.
4. Call `/mcp` as that user.
- That would be a full impersonation if the MCP tools can write. Please confirm this by testing against a dev instance.
- Also, `new URL(redirect_uri)` runs with no try/catch and no check that `redirect_uri` matches the registered URIs. A bad value throws, and a valid one is an open redirect that sends the code to the attacker.
### Not writing to the SQL database
- `/api/public/telemetry/crash` ([telemetry.js](file:///home/seb/src/dashback/src/api/routes/telemetry.js#L87-L104)) is public and unauthenticated. It writes to a file on disk rather than SQL, but there's no size or rate limit beyond `bodyLimit`, so it can fill the disk.
- `/v1` and `/api/v1` skip the middleware, but the routes I saw are all `GET`. The ones with data use `posAuthMiddleware`, and `/client`, `/pimage` and `/cimage` have no auth.
- `/api/auth/login` verifies a Google ID token and checks the allowlist. If `IMPLICIT_CREATE=true`, any valid Google account gets added to the allowlist ([login.js:107](file:///home/seb/src/dashback/src/api/routes/auth/login.js#L107)). Make sure that's off in production.
### Suggested fixes
- Make `/oauth/authorize` POST require `req.user`, and remove the `email` fallback.
- Validate `redirect_uri` against the registered URIs for the client.
- Rate-limit `/oauth/register`, or gate it.
Want me to implement these? I haven't changed any code.

132
bench-graph.tmp.mjs Normal file
View File

@@ -0,0 +1,132 @@
import { readFileSync } from 'node:fs';
import { Graphviz } from '@hpcc-js/wasm-graphviz';
const src = readFileSync(
'/home/seb/src/dashdbschema/generate-schema-graph.js',
'utf8'
);
const parser = src.slice(
src.indexOf('function parseSchema'),
src.indexOf('const EDGE_COLORS')
);
const edgeColorsConst = src.slice(
src.indexOf('const EDGE_COLORS'),
src.indexOf('function edgeKey')
);
const edgeKeyFn = src.slice(
src.indexOf('function edgeKey'),
src.indexOf('/**\n * Only a spanning forest')
);
const ranking = src.slice(
src.indexOf('function selectRankingEdgeKeys'),
src.indexOf('function assignEdgeColors')
);
const edgeColors = src.slice(
src.indexOf('function assignEdgeColors'),
src.indexOf('function escapeHtml')
);
const escape = src.slice(
src.indexOf('function escapeHtml'),
src.indexOf('/**\n * Domain grouping')
);
const clusters = src.slice(
src.indexOf('const CLUSTERS'),
src.indexOf('const COLORS =')
);
const colors = src.slice(
src.indexOf('const COLORS ='),
src.indexOf('const COLORS =') +
src.slice(src.indexOf('const COLORS =')).indexOf('};') +
2
);
const orderMembers = src.slice(
src.indexOf('function orderClusterMembers'),
src.indexOf('function buildTableLabel')
);
const plainPositions = src.slice(
src.indexOf('function parsePlainPositions'),
src.indexOf('function flipCompassLR')
);
const reorder = src.slice(
src.indexOf('function reorderColumnsForCrossingReduction'),
src.indexOf('function parsePlainPositions')
);
const builder = src.slice(
src.indexOf('function buildTableLabel'),
src.indexOf('function countSvgCrossings')
);
const crossingsFn = src.slice(
src.indexOf('function countSvgCrossings'),
src.indexOf('/**\n * Graphviz self-edges')
);
const mod = await import(
'data:text/javascript,' +
encodeURIComponent(
parser +
'\n' +
edgeColorsConst +
'\n' +
clusters +
'\n' +
colors +
'\n' +
orderMembers +
'\n' +
plainPositions +
'\n' +
reorder +
'\n' +
edgeKeyFn +
'\n' +
ranking +
'\n' +
edgeColors +
'\n' +
escape +
'\n' +
builder +
'\n' +
crossingsFn +
'\nexport { parseSchema, buildDot, assignEdgeAnchors, parsePlainPositions, reorderColumnsForCrossingReduction, orderClusterMembers, CLUSTERS, COLORS, countSvgCrossings };'
)
);
const schemaSource = readFileSync(
'/home/seb/src/dashdbschema/src/providers/mssql/schema.js',
'utf8'
);
const { tables, edges } = mod.parseSchema(schemaSource);
const graphviz = await Graphviz.load();
let positions = null;
for (let pass = 0; pass < 3; pass++) {
const probeDot = mod.buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = mod.parsePlainPositions(plain);
mod.reorderColumnsForCrossingReduction(tables, edges, positions);
}
const anchors = mod.assignEdgeAnchors(edges, positions);
const baseDot = mod.buildDot(tables, edges, positions, anchors);
const variants = [
['mclimit=4.0 splines=spline (current)', 'mclimit=4.0', 'splines=spline'],
['mclimit=1.0 splines=spline', 'mclimit=1.0', 'splines=spline'],
['mclimit=0.5 splines=spline', 'mclimit=0.5', 'splines=spline'],
['mclimit=1.0 splines=true', 'mclimit=1.0', 'splines=true'],
['mclimit=1.0 splines=polyline', 'mclimit=1.0', 'splines=polyline'],
];
for (const [label, mclimit, splines] of variants) {
const dot = baseDot
.replace('mclimit=4.0', mclimit)
.replace('splines=spline', splines);
const t = process.hrtime.bigint();
const svg = graphviz.layout(dot, 'svg', 'dot');
const ms = Number(process.hrtime.bigint() - t) / 1e6;
const t2 = process.hrtime.bigint();
const crossings = mod.countSvgCrossings(svg);
const cms = Number(process.hrtime.bigint() - t2) / 1e6;
console.log(
`${label}: layout=${ms.toFixed(0)}ms crossings=${crossings} (count=${cms.toFixed(0)}ms) total=${(ms + cms).toFixed(0)}ms`
);
}

View File

@@ -27,11 +27,24 @@ const MODULE_ORDER = [
'delivery', 'delivery',
'quote', 'quote',
'order', 'order',
'shipment',
'shipping',
'manifestattachment', 'manifestattachment',
'ledger',
'warehouse',
'stock',
'posterminal', 'posterminal',
'printer',
'google_auth', 'google_auth',
'pic', 'pic',
'numbers', 'numbers',
'propertyname',
'articleproperty',
'attributename',
'attributevalue',
'articleattributevalue',
'labeltemplate',
'healthcheck',
]; ];
// Files that are internal helpers and should not appear as public API entries. // Files that are internal helpers and should not appear as public API entries.
@@ -63,6 +76,8 @@ const INTERNAL_FILES = new Set([
'shape.js', 'shape.js',
'createStore.js', 'createStore.js',
'manifestLines.js', 'manifestLines.js',
'runner.js',
'cron.js',
'index.js', 'index.js',
]); ]);
@@ -309,47 +324,76 @@ function render() {
} }
const SCHEMA_TABLES = [ const SCHEMA_TABLES = [
['article', 'id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_father:b=0, fatherarticle_id?->article, fathersort:i=0, created_at:dt, updated_at:dt, UQ(fatherarticle_id, fathersort)'], ['account', 'id PK, company_id->company, type:s(100), name:s(255)'],
['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'], ['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'], ['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)'], ['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)'], ['articlepic', 'id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)'],
['articlesupplier', 'id PK, article_id->article, entity_id->entity, price_id?->price, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)'], ['articleproperty', 'id PK, article_id->article, property_id->propertyname, value:txt, sort:i=0'],
['articlesupplier', 'id PK, article_id->article, entity_id->entity, net?:f, tax_id?->tax, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)'],
['articletext', 'id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)'], ['articletext', 'id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)'],
['attributename', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
['attributevalue', 'id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)'],
['attributevaluepic', 'id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)'],
['brand', 'id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)'], ['brand', 'id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)'],
['brandpic', 'id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)'], ['brandpic', 'id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)'],
['category', 'id PK, parent_id?->category, sort:i=0'], ['category', 'id PK, parent_id?->category, sort:i=0'],
['categoryarticle', 'id PK, category_id->category, article_id->article, UQ(category_id, article_id)'], ['categoryarticle', 'id PK, category_id->category, article_id->article, UQ(category_id, article_id)'],
['categoryattributevalue', 'id PK, category_id->category, attributevalue_id->attributevalue, sort:i=0, UQ(category_id, attributevalue_id)'],
['categorypic', 'id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)'], ['categorypic', 'id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)'],
['categoryproperty', 'id PK, category_id->category, property_id->propertyname, value:txt, sort:i=0'],
['categorytext', 'id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)'], ['categorytext', 'id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)'],
['company', 'id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup'], ['company', 'id PK, name:s(255), root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup'],
['companyhistory', 'id PK, company_id->company, companyuser_id?->companyuser, date:dt, text:txt'],
['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'], ['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'],
['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'], ['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'],
['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'], ['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'],
['articlewarehousesummary', 'id PK, article_id->article, warehouse_id->warehouse, stock:f=0, UQ(article_id, warehouse_id)'],
['deliverysummary', 'id PK, company_id->company, manifest_id->manifest UQ, delivery_number:s(100), external_number?:s(100), delivery_date:dt, created_at:dt, supplier_id?->entity, supplier_name?:s(255), positions:i=0, gross_total:f=0, net_total:f=0, total_quantity:f=0, stock_positions:i=0, booked_positions:i=0, unbooked_positions:i=0, stock_quantity:f=0, booked_quantity:f=0, unbooked_quantity:f=0, is_booked:b=0'],
['entity', 'id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)'], ['entity', 'id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)'],
['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'], ['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'],
['entitycontact', 'id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0'], ['entitycontact', 'id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0'],
['entityhistory', 'id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt'], ['entityhistory', 'id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt'],
['file', 'id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt'], ['file', 'id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt'],
['google_auth', 'id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company'], ['google_auth', 'id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company'],
['labeltemplate', 'id PK, company_id->company, name:s(255), type:s(50), width:f, height:f, data:txt, sort:i=0'],
['language', 'id PK, iso639:s(10) UQ'], ['language', 'id PK, iso639:s(10) UQ'],
['ledger', 'id PK, manifestarticle_id->manifestarticle, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]'], ['ledger', 'id PK, manifestarticle_id->manifestarticle, warehouse_id?->warehouse, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]'],
['manifest', 'id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt'], ['manifest', 'id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt, created_at:dt'],
['manifestarticle', 'id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0'], ['manifestarticle', 'id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0'],
['manifestattachment', 'id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)'], ['manifestattachment', 'id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)'],
['mcp_auth_code', 'code:s(128) PK, client_id:s(100), google_auth_id->google_auth, redirect_uri:s(2048), code_challenge:s(255), code_challenge_method:s(32), scope?:s(255), expires_at:dt, used:b=0, created_at:dt'],
['mcp_client', 'id PK, client_id:s(100) UQ, client_secret?:s(255), client_name:s(255), redirect_uris:txt, grant_types?:s(255), response_types?:s(255), scope?:s(255), created_at:dt'],
['mcp_token', 'id PK, refresh_token:s(128) UQ, client_id:s(100), google_auth_id->google_auth, scope?:s(255), expires_at:dt, revoked:b=0, created_at:dt'],
['meta', 'key:s(255) PK, value:txt'], ['meta', 'key:s(255) PK, value:txt'],
['modelconversation', 'id PK, companyuser_id->companyuser, provider:s(100), model:s(255), created_at:dt, updated_at:dt, state:s(20)=\'idle\', started_by?:i'],
['modelmessage', 'id PK, conversation_id->modelconversation, created_at:dt, role:s(20), message:txt'],
['modelvfs', 'id PK, company_id->company, name:s(255), mime:s(100), content:bin, meta?:txt, created_at:dt, updated_at:dt, UQ(company_id, name)'],
['numbers', 'id PK, company_id->company, type:s(100), prefix:s(50)=\'\', padding:i=0, next:i=1, UQ(company_id, type)'], ['numbers', 'id PK, company_id->company, type:s(100), prefix:s(50)=\'\', padding:i=0, next:i=1, UQ(company_id, type)'],
['optionslist', 'id PK'], ['optionslist', 'id PK'],
['optionslistarticle', 'id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)'], ['optionslistarticle', 'id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)'],
['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin'], ['ordersummary', 'id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0'],
['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin:s(50)'], ['package', 'id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt'],
['payment', 'id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f'],
['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), smallthumbw:i, smallthumbh:i, smallthumbbinary:bin, source:txt'],
['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), maxknownobituaryid?:i, root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt'],
['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'], ['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'],
['posterminalobituary', 'id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt'],
['posterminalsyncqueue', 'id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i'],
['price', 'id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f'], ['price', 'id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f'],
['syncpos', 'id PK, company_id->company, paircode:s(50) UQ, token?:s(255) UQ'], ['printer', 'id PK, company_id->company, address:s(255), type:s(50), model?:s(255), serial?:s(255), sort:i=0, desc?:txt'],
['propertyname', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
['quotesummary', 'id PK, company_id->company, manifest_id->manifest UQ, quote_number:s(100), quote_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, total_quantity:f=0'],
['shipmentsummary', 'id PK, company_id->company, manifest_id->manifest UQ, shipment_number:s(100), shipment_date:dt, created_at:dt, order_id?:i, order_number?:s(100), entity_id?->entity, customer_name?:s(255), positions:i=0, total_quantity:f=0, package_count:i=0, carrier?:s(100), tracking_code?:s(255), state:s(50)=\'new\''],
['shipping', 'id PK, manifest_id->manifest, number:s(100), tracking_code?:s(255), carrier?:s(100), type:s(100), state:s(100)'],
['subagentschedule', 'id PK, company_id->company, desc?:txt, prompt:txt, skills?:txt, lastagentnote?:txt, max_itr:i=15, cron:s(100), created_at:dt, lastrun_at?:dt'],
['tax', 'id PK, rate:d(18,4) UQ'], ['tax', 'id PK, rate:d(18,4) UQ'],
['unit', 'id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)'], ['unit', 'id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)'],
['unittext', 'id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)'], ['unittext', 'id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)'],
['warehouse', 'id PK, company_id->company, name:s(255), sort:i=0, UQ(company_id, name)'],
]; ];
// Write the index API.md linking to each section file. // Write the index API.md linking to each section file.

View File

@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { Graphviz } from '@hpcc-js/wasm-graphviz'; import { Graphviz } from '@hpcc-js/wasm-graphviz';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const schemaPath = join(__dirname, 'src', 'schema.js'); const schemaPath = join(__dirname, 'src', 'providers', 'mssql', 'schema.js');
const outputPath = join(__dirname, 'schema-graph.svg'); const outputPath = join(__dirname, 'schema-graph.svg');
const dotOutputPath = join(__dirname, 'schema-graph.dot'); const dotOutputPath = join(__dirname, 'schema-graph.dot');
@@ -170,6 +170,9 @@ function addInferredEdges(tables, edges) {
const match = column.name.match(/^(.+)_id$/i); const match = column.name.match(/^(.+)_id$/i);
if (!match) continue; if (!match) continue;
// Obituary and sync queue tables store polymorphic entity_id references, not active foreign keys to entity.
if (tableName.includes('obituary') || tableName.includes('tombstone') || tableName.includes('syncqueue')) continue;
const candidate = match[1]; const candidate = match[1];
if (!tables.has(candidate) || candidate === tableName) continue; if (!tables.has(candidate) || candidate === tableName) continue;
if (explicitlyConstrained.has(`${tableName}.${column.name}`)) continue; if (explicitlyConstrained.has(`${tableName}.${column.name}`)) continue;
@@ -325,8 +328,8 @@ const CLUSTERS = [
'meta', 'meta',
'google_auth', 'google_auth',
'companyuser', 'companyuser',
'syncpos',
'company', 'company',
'companyhistory',
'companysetting', 'companysetting',
'customergroup', 'customergroup',
'entity', 'entity',
@@ -361,20 +364,51 @@ const CLUSTERS = [
{ {
id: 'media', id: 'media',
label: 'Media', label: 'Media',
tables: ['pic', 'articlepic', 'categorypic', 'brandpic', 'file'], tables: ['pic', 'articlepic', 'categorypic', 'brandpic', 'attributevaluepic', 'file'],
}, },
{ {
id: 'manifests', id: 'manifests',
label: 'Documents', label: 'Documents & Shipping',
tables: ['manifest', 'manifestarticle', 'manifestattachment', 'ledger'], tables: [
'manifest',
'manifestarticle',
'manifestattachment',
'articlewarehousesummary',
'deliverysummary',
'ordersummary',
'quotesummary',
'shipmentsummary',
'ledger',
'warehouse',
'shipping',
'package',
],
}, },
{ {
id: 'commerce', id: 'commerce',
label: 'Pricing', label: 'Pricing & Payments',
tables: ['tax', 'price'], tables: ['tax', 'price', 'account', 'payment'],
}, },
{ id: 'units', label: 'Units', tables: ['unit'] }, { id: 'units', label: 'Units', tables: ['unit'] },
{ id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory'] }, { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'posterminalobituary', 'posterminalsyncqueue', 'printer', 'labeltemplate'] },
{
id: 'ai',
label: 'AI & Models',
tables: ['modelconversation', 'modelmessage', 'modelvfs', 'subagentschedule'],
},
{
id: 'attributes',
label: 'Properties & Attributes',
tables: [
'propertyname',
'articleproperty',
'categoryproperty',
'attributename',
'attributevalue',
'articleattributevalue',
'categoryattributevalue',
],
},
]; ];
const COLORS = { const COLORS = {
@@ -478,14 +512,33 @@ function flipCompassNS(compass) {
* Greedy exit/port tweaks against real Graphviz splines. Tries L/R flips, * Greedy exit/port tweaks against real Graphviz splines. Tries L/R flips,
* N/S diagonal flips, and adjacent head-port swaps; keeps moves that cut * N/S diagonal flips, and adjacent head-port swaps; keeps moves that cut
* SVG segment crossings. * SVG segment crossings.
*
* Each trial is a full Graphviz layout, so the search is bounded by
* `maxLayouts` and reports progress — otherwise a large schema silently
* runs for many minutes with no output.
*/ */
function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors) { function optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
anchors,
maxLayouts = 400
) {
const tailCompass = new Map(anchors.tailCompass); const tailCompass = new Map(anchors.tailCompass);
const headPort = new Map(anchors.headPort); const headPort = new Map(anchors.headPort);
const inboundCounts = anchors.inboundCounts; const inboundCounts = anchors.inboundCounts;
let current = { headPort, tailCompass, inboundCounts }; let current = { headPort, tailCompass, inboundCounts };
let layouts = 0;
const started = Date.now();
const measure = () => { const measure = () => {
layouts++;
if (layouts % 25 === 0) {
console.log(
` optimizing anchors: ${layouts} layouts, ${best} crossings, ${((Date.now() - started) / 1000).toFixed(0)}s`
);
}
const dot = buildDot(tables, edges, positions, current); const dot = buildDot(tables, edges, positions, current);
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot')); const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
return { svg, crossings: countSvgCrossings(svg), dot }; return { svg, crossings: countSvgCrossings(svg), dot };
@@ -503,6 +556,8 @@ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors)
return false; return false;
}; };
const budgetLeft = () => layouts < maxLayouts;
const trySeed = (seedCompass) => { const trySeed = (seedCompass) => {
const saved = new Map(tailCompass); const saved = new Map(tailCompass);
for (const [k, v] of seedCompass) tailCompass.set(k, v); for (const [k, v] of seedCompass) tailCompass.set(k, v);
@@ -529,9 +584,11 @@ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors)
let improved = true; let improved = true;
for (let sweep = 0; sweep < 4 && improved; sweep++) { for (let sweep = 0; sweep < 4 && improved; sweep++) {
improved = false; improved = false;
if (!budgetLeft()) break;
for (const edge of edges) { for (const edge of edges) {
if (edge.from === edge.to) continue; if (edge.from === edge.to) continue;
if (!budgetLeft()) break;
const key = edgeKey(edge); const key = edgeKey(edge);
const prev = tailCompass.get(key); const prev = tailCompass.get(key);
if (!prev) continue; if (!prev) continue;
@@ -552,6 +609,7 @@ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors)
} }
for (const group of byTarget.values()) { for (const group of byTarget.values()) {
if (!budgetLeft()) break;
const keys = group const keys = group
.map((e) => edgeKey(e)) .map((e) => edgeKey(e))
.sort( .sort(
@@ -807,10 +865,12 @@ function buildDot(tables, edges, positions = null, anchors = null) {
const lines = [ const lines = [
'digraph schema {', 'digraph schema {',
// TB = landscape for this schema (many tables per rank spread horizontally). // TB = landscape for this schema (many tables per rank spread horizontally).
// mclimit bumps Graphviz's own crossing-minimization iterations. // mclimit is Graphviz's crossing-minimization iteration budget. 4.0 (the
// old value) costs ~12x the layout time for only ~15% fewer crossings,
// which multiplied by the ~157 optimizer trials below. 1.0 is the default.
` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`, ` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`,
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",', ' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, newrank=true, mclimit=4.0];', ' compound=true, mclimit=1.0];',
' node [shape=plaintext, fontname="Helvetica"];', ' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];', ' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
'', '',
@@ -919,6 +979,27 @@ function countSvgCrossings(svg) {
edgeSegs.push(segs); edgeSegs.push(segs);
} }
// Two splines can only intersect if their bounding boxes overlap, so the
// sweep below can skip almost every pair. Without this the check is
// O(edges^2 * segments^2) and dominates each optimizer trial.
const boxes = edgeSegs.map((segs) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [a] of segs) {
if (a[0] < minX) minX = a[0];
if (a[0] > maxX) maxX = a[0];
if (a[1] < minY) minY = a[1];
if (a[1] > maxY) maxY = a[1];
}
return { minX, minY, maxX, maxY };
});
const order = boxes
.map((_, i) => i)
.sort((a, b) => boxes[a].minX - boxes[b].minX);
const orient = (a, b, c) => const orient = (a, b, c) =>
(b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]); (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
const crosses = (a, b, c, d) => { const crosses = (a, b, c, d) => {
@@ -930,8 +1011,17 @@ function countSvgCrossings(svg) {
}; };
let n = 0; let n = 0;
for (let e1 = 0; e1 < edgeSegs.length; e1++) { for (let ii = 0; ii < order.length; ii++) {
for (let e2 = e1 + 1; e2 < edgeSegs.length; e2++) { const e1 = order[ii];
const b1 = boxes[e1];
for (let jj = ii + 1; jj < order.length; jj++) {
const e2 = order[jj];
const b2 = boxes[e2];
// Sorted by minX: once a box starts past b1's right edge, no later
// box can overlap it either.
if (b2.minX > b1.maxX) break;
if (b1.minX > b2.maxX || b1.minY > b2.maxY || b2.minY > b1.maxY) continue;
let pairCrosses = false; let pairCrosses = false;
outer: for (const [a, b] of edgeSegs[e1]) { outer: for (const [a, b] of edgeSegs[e1]) {
for (const [c, d] of edgeSegs[e2]) { for (const [c, d] of edgeSegs[e2]) {
@@ -1032,20 +1122,25 @@ const graphviz = await Graphviz.load();
// Layout passes: learn positions, reorder columns/cluster members (barycenter). // Layout passes: learn positions, reorder columns/cluster members (barycenter).
let positions = null; let positions = null;
for (let pass = 0; pass < 3; pass++) { for (let pass = 0; pass < 3; pass++) {
console.log(`Layout pass ${pass + 1}/3…`);
const probeDot = buildDot(tables, edges, positions); const probeDot = buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot'); const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = parsePlainPositions(plain); positions = parsePlainPositions(plain);
reorderColumnsForCrossingReduction(tables, edges, positions); reorderColumnsForCrossingReduction(tables, edges, positions);
} }
// Hill-climb L/R exits against real spline crossings. // Hill-climb L/R exits against real spline crossings. Each trial is a full
// layout, so the budget keeps the worst case bounded; raise it via
// SCHEMA_GRAPH_MAX_LAYOUTS if you want a longer search.
const maxLayouts = Number(process.env.SCHEMA_GRAPH_MAX_LAYOUTS ?? 400);
const baseAnchors = assignEdgeAnchors(edges, positions); const baseAnchors = assignEdgeAnchors(edges, positions);
const optimized = optimizeAnchorsWithLayout( const optimized = optimizeAnchorsWithLayout(
graphviz, graphviz,
tables, tables,
edges, edges,
positions, positions,
baseAnchors baseAnchors,
maxLayouts
); );
// Fill the whole canvas (including Graphviz's pad margin) so there is no // Fill the whole canvas (including Graphviz's pad margin) so there is no

867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,12 @@
"main": "./src/index.js", "main": "./src/index.js",
"exports": { "exports": {
".": "./src/index.js", ".": "./src/index.js",
"./schema": "./src/schema.js" "./schema": "./src/providers/mssql/schema.js",
"./providers/better-sqlite3": "./src/providers/sqlite3/index.js",
"./providers/sqlite3": "./src/providers/sqlite3/index.js",
"./providers/sqlite3/schema": "./src/providers/sqlite3/schema.js",
"./providers/mssql": "./src/providers/mssql/index.js",
"./providers/provider": "./src/providers/provider.js"
}, },
"scripts": { "scripts": {
"schema-graph": "node generate-schema-graph.js", "schema-graph": "node generate-schema-graph.js",
@@ -15,20 +20,32 @@
"init-schema": "node scripts/init-schema.js", "init-schema": "node scripts/init-schema.js",
"purge-db": "node scripts/purge-db.js", "purge-db": "node scripts/purge-db.js",
"reset-db": "node scripts/reset-db.js", "reset-db": "node scripts/reset-db.js",
"test:api": "node scripts/test-api.js" "downgrade": "node scripts/downgrade.js",
"test": "node --test \"test/*.test.js\"",
"test:api": "node scripts/test-api.js",
"test:benchmark": "node scripts/benchmark.js"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
"@hpcc-js/wasm-graphviz": "^1.29.1", "@hpcc-js/wasm-graphviz": "^1.29.2",
"chalk": "^6.0.0", "chalk": "^6.0.1",
"dotenv": "^17.4.2", "dotenv": "^18.0.8",
"sharp": "^0.35.4", "pdfconvert-wasm": "^0.3.0",
"thumbhash": "^0.1.1" "skelehash-wasm": "^0.1.1",
"thumbhash": "^0.1.1",
"wasm-image-optimization": "^2.0.13"
}, },
"peerDependencies": { "peerDependencies": {
"mssql": "^12.6.0" "better-sqlite3": "^12.0.0",
"mssql": "^12.7.2"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
}
}, },
"devDependencies": { "devDependencies": {
"mssql": "^12.7.2" "better-sqlite3": "^13.0.3",
"mssql": "^12.7.9"
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 459 KiB

View File

@@ -0,0 +1,171 @@
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 test() {
console.log('--- Testing setOn and setOff with subcategories ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-posterminal-subcats-'));
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('subcat-tester@example.com');
const company = await store.company.create({
name: 'SubCat Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const terminal = await store.posterminal.upsert(googleAuthId, {
name: 'POS 1',
pairingpin: '1234',
});
await store.posterminal.pair(terminal.id, {
token: 'pos-token-subcat',
clientserial: 'SN-001',
pairedAt: new Date().toISOString(),
});
// Create Category Tree:
// Root -> Cat 4 -> SubCat 41 -> SubCat 411
// Root -> Cat 5
const cat4 = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Getränke',
});
const subCat41 = await store.category.createUnderParent({
googleAuthId,
parentId: cat4.id,
name: 'Alkoholfrei',
});
const subCat411 = await store.category.createUnderParent({
googleAuthId,
parentId: subCat41.id,
name: 'Cola',
});
const cat5 = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Snacks',
});
// Create articles in various categories
const artCola = await store.article.createUnderParent({
googleAuthId,
parentId: subCat411.id,
name: 'Cola 0.5L',
});
const artSnack = await store.article.createUnderParent({
googleAuthId,
parentId: cat5.id,
name: 'Chips',
});
// Set old timestamps on categories and articles to test updated_at touch
provider.db.prepare("UPDATE category SET updated_at = '2020-01-01T00:00:00.000Z'").run();
provider.db.prepare("UPDATE article SET updated_at = '2020-01-01T00:00:00.000Z'").run();
console.log('1. Testing setOn on parent category Cat 4 (should enable Cat 4, SubCat 41, and SubCat 411)...');
const onResult = await store.posterminal.setOn(googleAuthId, terminal.id, cat4.id);
console.log('Category IDs after setOn(cat4):', onResult.categoryIds);
assert(onResult.categoryIds.includes(cat4.id), 'Should include cat4');
assert(onResult.categoryIds.includes(subCat41.id), 'Should include subCat41');
assert(onResult.categoryIds.includes(subCat411.id), 'Should include subCat411');
assert(!onResult.categoryIds.includes(cat5.id), 'Should NOT include cat5');
// Check that categories in the subtree were touched
const catsAfterOn = provider.db.prepare('SELECT id, updated_at FROM category WHERE id IN (?, ?, ?)').all(cat4.id, subCat41.id, subCat411.id);
for (const c of catsAfterOn) {
assert.notEqual(c.updated_at, '2020-01-01T00:00:00.000Z', `Category ${c.id} updated_at should be touched on setOn`);
}
// Check that articles in the subtree were touched
const artAfterOn = provider.db.prepare('SELECT id, updated_at FROM article WHERE id = ?').get(artCola.id);
assert.notEqual(artAfterOn.updated_at, '2020-01-01T00:00:00.000Z', 'Article updated_at should be touched on setOn');
// Cat 5 article should NOT be touched
const artSnackAfterOn = provider.db.prepare('SELECT id, updated_at FROM article WHERE id = ?').get(artSnack.id);
assert.equal(artSnackAfterOn.updated_at, '2020-01-01T00:00:00.000Z', 'Unrelated article updated_at should not be touched');
console.log('2. Also enabling Cat 5...');
await store.posterminal.setOn(googleAuthId, terminal.id, cat5.id);
// Reset timestamps to test setOff touch
provider.db.prepare("UPDATE category SET updated_at = '2020-01-01T00:00:00.000Z'").run();
provider.db.prepare("UPDATE article SET updated_at = '2020-01-01T00:00:00.000Z'").run();
console.log('3. Testing setOff on parent category Cat 4 (should disable Cat 4, SubCat 41, and SubCat 411)...');
const offResult = await store.posterminal.setOff(googleAuthId, terminal.id, cat4.id);
console.log('Category IDs after setOff(cat4):', offResult.categoryIds);
assert(!offResult.categoryIds.includes(cat4.id), 'Should NOT include cat4');
assert(!offResult.categoryIds.includes(subCat41.id), 'Should NOT include subCat41');
assert(!offResult.categoryIds.includes(subCat411.id), 'Should NOT include subCat411');
assert(offResult.categoryIds.includes(cat5.id), 'Should still include cat5');
// Check that categories in the subtree were touched on setOff
const catsAfterOff = provider.db.prepare('SELECT id, updated_at FROM category WHERE id IN (?, ?, ?)').all(cat4.id, subCat41.id, subCat411.id);
for (const c of catsAfterOff) {
assert.notEqual(c.updated_at, '2020-01-01T00:00:00.000Z', `Category ${c.id} updated_at should be touched on setOff`);
}
// Check that article in the subtree was touched on setOff
const artAfterOff = provider.db.prepare('SELECT id, updated_at FROM article WHERE id = ?').get(artCola.id);
assert.notEqual(artAfterOff.updated_at, '2020-01-01T00:00:00.000Z', 'Article updated_at should be touched on setOff');
// Verify obituaries generated
const obituariesAfter = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ? ORDER BY id ASC')
.all(terminal.id);
console.log('Obituaries after setOff:', obituariesAfter);
const catObituaryIds = obituariesAfter.filter(o => o.entity_type === 'category').map(o => o.entity_id);
assert(catObituaryIds.includes(cat4.id), 'Should have obituary for cat4');
assert(catObituaryIds.includes(subCat41.id), 'Should have obituary for subCat41');
assert(catObituaryIds.includes(subCat411.id), 'Should have obituary for subCat411');
const artObituaryIds = obituariesAfter.filter(o => o.entity_type === 'article').map(o => o.entity_id);
assert(artObituaryIds.includes(artCola.id), 'Should have obituary for artCola');
assert(!artObituaryIds.includes(artSnack.id), 'Should NOT have obituary for artSnack');
console.log('4. Testing setOn on an intermediate subcategory (SubCat 41)...');
const subOnResult = await store.posterminal.setOn(googleAuthId, terminal.id, subCat41.id);
console.log('Category IDs after setOn(subCat41):', subOnResult.categoryIds);
assert(!subOnResult.categoryIds.includes(cat4.id), 'Parent cat4 should not be enabled when enabling subCat41');
assert(subOnResult.categoryIds.includes(subCat41.id), 'subCat41 should be enabled');
assert(subOnResult.categoryIds.includes(subCat411.id), 'Child subCat411 should be enabled');
console.log('✅ ALL SUBCATEGORY SET_ON / SET_OFF TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
test()
.then(() => {
process.exit(0);
})
.catch(err => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,179 @@
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 deposit via mandated optionslist ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-deposit-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Deposit Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// 1. Create a category
const cat = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Getränke',
});
// 2. Create terminal and enable category
const terminal = await store.posterminal.upsert(googleAuthId, {
name: 'POS 1',
pairingpin: '1234',
});
await store.posterminal.pair(terminal.id, {
token: 'pos-token-xyz',
clientserial: 'SN-001',
pairedAt: new Date().toISOString(),
});
await store.posterminal.setOn(googleAuthId, terminal.id, cat.id);
// 3. Create beverage article
const clubMate = await store.article.createUnderParent({
googleAuthId,
parentId: cat.id,
name: 'Club Mate 0.5l',
sku: 'MATE-05',
});
await store.article.setPricesBySku(googleAuthId, clubMate.sku, [
{ net: 1.5, tax: 19 },
]);
// 4. Create deposit article
const pfandArt = await store.article.createUnderParent({
googleAuthId,
parentId: cat.id,
name: 'Mehrwegpfand 0,08 €',
sku: 'PFAND-0.08',
track_stock: 0,
});
await store.article.setPricesBySku(googleAuthId, pfandArt.sku, [
{ gross: 0.08, tax: 19 },
]);
// 5. Query products BEFORE linking mandated option
let prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
let mateProd = prods.find((p) => p.sku === 'MATE-05');
let pfandProd = prods.find((p) => p.sku === 'PFAND-0.08');
assert.ok(mateProd, 'Club Mate should be listed');
assert.equal(mateProd.deposit, '0');
assert.equal(mateProd.d_price, '0.0');
assert.equal(mateProd.deposit_name, '');
assert.ok(pfandProd, 'Pfand article should be listed');
assert.equal(pfandProd.deposit, '0');
// 6. Link Mehrwegpfand as mandated option to Club Mate
console.log('Linking Mehrwegpfand as mandated option...');
await store.optionslist.setOptionsByArticleSku(googleAuthId, clubMate.sku, [
{
articleId: pfandArt.id,
sku: pfandArt.sku,
count: 1,
sort: 0,
mandated: true,
},
]);
// 7. Query products AFTER linking mandated option
prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
mateProd = prods.find((p) => p.sku === 'MATE-05');
console.log('Club Mate with deposit option:', {
sku: mateProd.sku,
deposit: mateProd.deposit,
d_price: mateProd.d_price,
deposit_name: mateProd.deposit_name,
});
assert.equal(mateProd.deposit, '1');
assert.equal(mateProd.d_price, '0.08');
assert.equal(mateProd.deposit_name, 'Mehrwegpfand 0,08 €');
// 8. Test option name change touching parent article
console.log('Testing option name change touching parent article...');
const mateBeforeRename = (await provider.query('SELECT updated_at FROM article WHERE id = @id', { id: clubMate.id })).rows[0].updated_at;
await new Promise((r) => setTimeout(r, 1100)); // wait >1s for timestamp delta
await store.article.updateBySku(googleAuthId, pfandArt.sku, {
name: 'Glas-Mehrwegpfand 0,08 €',
});
const mateAfterRename = (await provider.query('SELECT updated_at FROM article WHERE id = @id', { id: clubMate.id })).rows[0].updated_at;
assert.notEqual(
mateBeforeRename,
mateAfterRename,
'Club Mate updated_at must be bumped when its option article is renamed'
);
prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
mateProd = prods.find((p) => p.sku === 'MATE-05');
assert.equal(mateProd.deposit_name, 'Glas-Mehrwegpfand 0,08 €');
console.log('Renamed deposit reflected on parent product:', mateProd.deposit_name);
// 9. Test option price change touching parent article
console.log('Testing option price change touching parent article...');
const mateBeforePrice = mateAfterRename;
await new Promise((r) => setTimeout(r, 1100));
await store.article.setPricesBySku(googleAuthId, pfandArt.sku, [
{ gross: 0.15, tax: 19 },
]);
const mateAfterPrice = (await provider.query('SELECT updated_at FROM article WHERE id = @id', { id: clubMate.id })).rows[0].updated_at;
assert.notEqual(
mateBeforePrice,
mateAfterPrice,
'Club Mate updated_at must be bumped when its option article price changes'
);
prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
mateProd = prods.find((p) => p.sku === 'MATE-05');
assert.equal(mateProd.d_price, '0.15');
console.log('New deposit price reflected on parent product:', mateProd.d_price);
// 10. Test clearing options
console.log('Clearing options...');
await store.optionslist.setOptionsByArticleSku(googleAuthId, clubMate.sku, []);
prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
mateProd = prods.find((p) => p.sku === 'MATE-05');
assert.equal(mateProd.deposit, '0');
assert.equal(mateProd.d_price, '0.0');
assert.equal(mateProd.deposit_name, '');
console.log('✅ ALL DEPOSIT OPTIONS TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,159 @@
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 main() {
console.log('--- Testing updated_at touch on all inventory movements ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-touch-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Inventory Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Create warehouse
const wh = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
const cat = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Waren',
});
// Create article with past updated_at
const art = await store.article.createUnderParent({
googleAuthId,
parentId: cat.id,
sku: 'TEST-SKU',
name: 'Test Article',
track_stock: 1,
});
const oldTime = '2020-01-01T00:00:00.000Z';
await provider.query(`UPDATE article SET updated_at = @oldTime WHERE id = @id`, { oldTime, id: art.id });
const getUpdatedAt = async (articleId) => {
const res = await provider.query(`SELECT updated_at FROM article WHERE id = @articleId`, { articleId });
return res.rows[0]?.updated_at;
};
const initialUpdatedAt = await getUpdatedAt(art.id);
assert.equal(initialUpdatedAt, oldTime, 'Should start with old updated_at');
// 1. Test store.stock.adjust
console.log('1. Testing store.stock.adjust touches article.updated_at...');
await store.stock.adjust(googleAuthId, {
sku: 'TEST-SKU',
warehouseId: wh.id,
amount: 2,
// reason omitted so it defaults to "Manual stock addition (+2)"
});
const afterAdjustUpdatedAt = await getUpdatedAt(art.id);
assert.notEqual(afterAdjustUpdatedAt, oldTime, 'stock.adjust should touch updated_at');
assert.ok(new Date(afterAdjustUpdatedAt).getTime() > new Date(oldTime).getTime());
// Check history was recorded with exact text
const historyRes = await provider.query(`SELECT text FROM articlehistory WHERE article_id = @id`, { id: art.id });
const adjustmentEntry = historyRes.rows.find((h) => h.text.includes('Bestandskorrektur +2 (Manual stock addition (+2))'));
assert.ok(adjustmentEntry, 'Should have exact article history entry');
// Reset updated_at to old time
await provider.query(`UPDATE article SET updated_at = @oldTime WHERE id = @id`, { oldTime, id: art.id });
// 2. Test Stückliste component touch
console.log('2. Testing Stückliste parent is touched when component has stock adjustment...');
const bundle = await store.article.createUnderParent({
googleAuthId,
parentId: cat.id,
sku: 'BUNDLE-1',
name: 'Bundle 1',
track_stock: 0,
});
await store.articlelist.setItemsByArticleSku(googleAuthId, 'BUNDLE-1', [
{ sku: 'TEST-SKU', count: 1 },
]);
await provider.query(`UPDATE article SET updated_at = @oldTime WHERE id = @id`, { oldTime, id: bundle.id });
await store.stock.adjust(googleAuthId, {
sku: 'TEST-SKU',
warehouseId: wh.id,
amount: 5,
text: 'Restock component',
});
const bundleUpdatedAt = await getUpdatedAt(bundle.id);
assert.notEqual(bundleUpdatedAt, oldTime, 'Stückliste bundle updated_at should be touched when component changes stock');
// 3. Test delivery acceptance touches article.updated_at
console.log('3. Testing delivery touches article.updated_at...');
const supplier = await store.supplier.create(googleAuthId, { name: 'Supplier Inc' });
const delivery = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
date: new Date().toISOString(),
lines: [{ sku: 'TEST-SKU', amount: 10, net: 5, taxrate: 19 }],
});
await provider.query(`UPDATE article SET updated_at = @oldTime WHERE id = @id`, { oldTime, id: art.id });
await store.delivery.book(googleAuthId, { id: delivery.id, warehouseId: wh.id });
const deliveryUpdatedAt = await getUpdatedAt(art.id);
assert.notEqual(deliveryUpdatedAt, oldTime, 'delivery.book should touch article.updated_at');
// 4. Test customer shipment touches article.updated_at
console.log('4. Testing shipment touches article.updated_at...');
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, { name: 'Customer A', customergroupId: groups[0].id });
const order = await store.order.create(googleAuthId, {
number: 'AUF-000001',
date: new Date().toISOString(),
entityId: customer.id,
lines: [{ sku: 'TEST-SKU', amount: 3, net: 10, taxrate: 19 }],
});
const carrier = await store.carrier.create(googleAuthId, { name: 'DHL' });
await provider.query(`UPDATE article SET updated_at = @oldTime WHERE id = @id`, { oldTime, id: art.id });
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'TEST-SKU', amount: 3 }],
warehouseId: wh.id,
shipping: {
carrierId: carrier.id,
type: 'standard',
state: 'shipped',
warehouseId: wh.id,
},
});
const shipmentUpdatedAt = await getUpdatedAt(art.id);
assert.notEqual(shipmentUpdatedAt, oldTime, 'shipment.create should touch article.updated_at');
console.log('✅ ALL INVENTORY TOUCH TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
main().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

164
scratch/test-variants.js Normal file
View File

@@ -0,0 +1,164 @@
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 POS father/child variants and options ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-variants-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Variant Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// 1. Create a category
const cat = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Kleidung',
});
// 2. Create terminal and enable category
const terminal = await store.posterminal.upsert(googleAuthId, {
name: 'POS 1',
pairingpin: '1234',
});
await store.posterminal.pair(terminal.id, {
token: 'pos-token-xyz',
clientserial: 'SN-001',
pairedAt: new Date().toISOString(),
});
await store.posterminal.setOn(googleAuthId, terminal.id, cat.id);
// 3. Create attribute name "Farbe"
const attrName = await store.attributename.create(googleAuthId, {
name: 'Farbe',
sort: 1,
exclusive: true,
});
// 4. Create base article and convert to parent
const tShirt = await store.article.createUnderParent({
googleAuthId,
parentId: cat.id,
name: 'T-Shirt Basic',
sku: 'TSHIRT',
});
await store.article.toggleParent(googleAuthId, tShirt.sku, {
isParent: true,
attributeNameId: attrName.id,
});
// 5. Add children "Rot" and "Blau"
console.log('Adding children "Rot" and "Blau"...');
const childRot = await store.article.addChild(googleAuthId, tShirt.sku, {
value: 'Rot',
name: 'T-Shirt Basic - Rot',
});
const childBlau = await store.article.addChild(googleAuthId, tShirt.sku, {
value: 'Blau',
name: 'T-Shirt Basic - Blau',
});
// 6. Query POS products
let prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
let parentProd = prods.find((p) => p.sku === 'TSHIRT');
let rotProd = prods.find((p) => p.sku === childRot.article.sku);
let blauProd = prods.find((p) => p.sku === childBlau.article.sku);
console.log('Parent product:', {
sku: parentProd.sku,
is_parent: parentProd.is_parent,
parent: parentProd.parent,
variants: parentProd.variants,
options: parentProd.options,
});
assert.equal(parentProd.is_parent, '1');
assert.equal(parentProd.parent, '0');
assert.equal(parentProd.variants, '');
assert.deepEqual(parentProd.options, [{ name: 'Farbe', value: 'Rot,Blau' }]);
console.log('Child Rot:', {
sku: rotProd.sku,
is_parent: rotProd.is_parent,
parent: rotProd.parent,
variants: rotProd.variants,
sort: rotProd.sort,
options: rotProd.options,
});
assert.equal(rotProd.is_parent, '0');
assert.equal(rotProd.parent, String(parentProd._id));
assert.equal(rotProd.variants, 'Rot');
assert.equal(rotProd.sort, '0');
assert.equal(rotProd.options, null);
console.log('Child Blau:', {
sku: blauProd.sku,
is_parent: blauProd.is_parent,
parent: blauProd.parent,
variants: blauProd.variants,
sort: blauProd.sort,
options: blauProd.options,
});
assert.equal(blauProd.is_parent, '0');
assert.equal(blauProd.parent, String(parentProd._id));
assert.equal(blauProd.variants, 'Blau');
assert.equal(blauProd.sort, '1');
assert.equal(blauProd.options, null);
// 7. Add third child "Grün" and verify options list update
console.log('Adding third child "Grün"...');
const childGruen = await store.article.addChild(googleAuthId, tShirt.sku, {
value: 'Grün',
name: 'T-Shirt Basic - Grün',
});
prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
parentProd = prods.find((p) => p.sku === 'TSHIRT');
assert.deepEqual(parentProd.options, [{ name: 'Farbe', value: 'Rot,Blau,Grün' }]);
console.log('Updated parent options:', parentProd.options);
// 8. Remove child "Blau" and verify options list update
console.log('Removing child "Blau"...');
await store.article.removeChild(googleAuthId, tShirt.sku, childBlau.article.sku, { deleteArticle: true });
prods = await store.posterminal.getProductList({ posterminalId: terminal.id });
parentProd = prods.find((p) => p.sku === 'TSHIRT');
assert.deepEqual(parentProd.options, [{ name: 'Farbe', value: 'Rot,Grün' }]);
console.log('Parent options after removal:', parentProd.options);
console.log('✅ ALL FATHER/CHILD VARIANT TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

197
scripts/benchmark.js Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
import 'dotenv/config';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
import chalk from 'chalk';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { MssqlProvider } from '../src/providers/mssql/index.js';
import { MetricsCollector, instrumentStore } from './benchmark/instrument.js';
import { fillDatabase, benchmarkFilledDatabase, testLifecycleDeletions } from './benchmark/workload.js';
import { BenchmarkReporter } from './benchmark/reporter.js';
// CLI argument parsing
function parseArgs() {
const args = process.argv.slice(2);
const options = {
driver: 'both',
scale: 'small',
iterations: 5,
reportFile: 'BENCHMARK_REPORT.md',
keepDb: false,
};
for (const arg of args) {
if (arg.startsWith('--driver=')) {
options.driver = arg.split('=')[1].toLowerCase();
} else if (arg.startsWith('--scale=')) {
options.scale = arg.split('=')[1].toLowerCase();
} else if (arg.startsWith('--iterations=')) {
options.iterations = parseInt(arg.split('=')[1], 10);
} else if (arg.startsWith('--report-file=')) {
options.reportFile = arg.split('=')[1];
} else if (arg === '--keep-db') {
options.keepDb = true;
}
}
return options;
}
async function runSqliteBenchmark(options) {
console.log(chalk.blue(`\n▶ Starting SQLite benchmark (scale: ${options.scale}, iterations: ${options.iterations})...`));
const tempDir = mkdtempSync(join(tmpdir(), 'libdashstore-bench-sqlite-'));
const dbFile = join(tempDir, 'benchmark.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const rawStore = await LibDashStore.create(provider);
const metrics = new MetricsCollector();
const { store } = instrumentStore(rawStore, metrics);
const startTotal = performance.now();
try {
console.log(chalk.dim(' 1. Filling SQLite database using domain APIs...'));
const fillStart = performance.now();
const ctx = await fillDatabase(store, options);
console.log(chalk.green(` ✓ Filled SQLite DB in ${((performance.now() - fillStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 2. Benchmarking queries and lookups on filled database...'));
const benchStart = performance.now();
await benchmarkFilledDatabase(store, ctx, options);
console.log(chalk.green(` ✓ Read queries completed in ${((performance.now() - benchStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 3. Testing lifecycle deletions...'));
const delStart = performance.now();
await testLifecycleDeletions(store, ctx);
console.log(chalk.green(` ✓ Deletions completed in ${((performance.now() - delStart) / 1000).toFixed(2)}s`));
const totalElapsedMs = performance.now() - startTotal;
const stats = metrics.getAllStats();
return { stats, totalElapsedMs };
} finally {
provider.close();
if (!options.keepDb) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
} else {
console.log(chalk.dim(` [i] SQLite test database kept at: ${tempDir}`));
}
}
}
async function runMssqlBenchmark(options) {
console.log(chalk.blue(`\n▶ Starting MSSQL benchmark (scale: ${options.scale}, iterations: ${options.iterations})...`));
const config = {
server: process.env.MSSQL_SERVER || '127.0.0.1',
database: process.env.MSSQL_DATABASE || 'dashback',
user: process.env.MSSQL_USER || 'dashback',
password: process.env.MSSQL_PASSWORD || 'dashback',
options: {
encrypt: false,
trustServerCertificate: true,
},
};
const provider = new MssqlProvider(config);
await provider.ensurePool();
console.log(chalk.dim(' Resetting MSSQL schema (purge & init)...'));
await provider.purge();
await provider.initialize();
const rawStore = await LibDashStore.create(provider);
const metrics = new MetricsCollector();
const { store } = instrumentStore(rawStore, metrics);
const startTotal = performance.now();
try {
console.log(chalk.dim(' 1. Filling MSSQL database using domain APIs...'));
const fillStart = performance.now();
const ctx = await fillDatabase(store, options);
console.log(chalk.green(` ✓ Filled MSSQL DB in ${((performance.now() - fillStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 2. Benchmarking queries and lookups on filled database...'));
const benchStart = performance.now();
await benchmarkFilledDatabase(store, ctx, options);
console.log(chalk.green(` ✓ Read queries completed in ${((performance.now() - benchStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 3. Testing lifecycle deletions...'));
const delStart = performance.now();
await testLifecycleDeletions(store, ctx);
console.log(chalk.green(` ✓ Deletions completed in ${((performance.now() - delStart) / 1000).toFixed(2)}s`));
const totalElapsedMs = performance.now() - startTotal;
const stats = metrics.getAllStats();
return { stats, totalElapsedMs };
} finally {
await provider.close();
}
}
async function main() {
const options = parseArgs();
console.log(chalk.bold.yellow(`========================================================================`));
console.log(chalk.bold.yellow(` LIBDASHSTORE DATABASE DRIVERS TEST & BENCHMARK SYSTEM`));
console.log(chalk.bold.yellow(`========================================================================`));
console.log(`Driver mode : ${chalk.cyan(options.driver)}`);
console.log(`Scale : ${chalk.cyan(options.scale)}`);
console.log(`Iterations : ${chalk.cyan(options.iterations)}`);
console.log(`Report File : ${chalk.cyan(options.reportFile)}`);
let sqliteResult = null;
let mssqlResult = null;
if (options.driver === 'both' || options.driver === 'sqlite') {
sqliteResult = await runSqliteBenchmark(options);
BenchmarkReporter.printDriverReport('better-sqlite3', sqliteResult.stats, sqliteResult.totalElapsedMs);
}
if (options.driver === 'both' || options.driver === 'mssql') {
mssqlResult = await runMssqlBenchmark(options);
BenchmarkReporter.printDriverReport('mssql', mssqlResult.stats, mssqlResult.totalElapsedMs);
}
if (sqliteResult && mssqlResult) {
BenchmarkReporter.printComparisonReport(
mssqlResult.stats,
sqliteResult.stats,
mssqlResult.totalElapsedMs,
sqliteResult.totalElapsedMs,
);
}
const markdown = BenchmarkReporter.generateMarkdownReport({
mssqlStats: mssqlResult?.stats,
sqliteStats: sqliteResult?.stats,
mssqlElapsedMs: mssqlResult?.totalElapsedMs,
sqliteElapsedMs: sqliteResult?.totalElapsedMs,
options,
});
if (options.reportFile) {
BenchmarkReporter.saveReportToFile(options.reportFile, markdown);
}
console.log(chalk.bold.green(`\n✓ All database driver tests & benchmarks finished successfully!`));
}
main().catch((err) => {
console.error(chalk.bold.red(`\nBenchmark failed with error:`), err);
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
import { performance } from 'node:perf_hooks';
export class MetricsCollector {
constructor() {
this.entries = new Map();
}
record(name, durationMs, error = null) {
let entry = this.entries.get(name);
if (!entry) {
entry = {
name,
durations: [],
count: 0,
errors: 0,
totalMs: 0,
};
this.entries.set(name, entry);
}
entry.count += 1;
entry.totalMs += durationMs;
entry.durations.push(durationMs);
if (error) {
entry.errors += 1;
}
}
getStats(name) {
const entry = this.entries.get(name);
if (!entry || entry.count === 0) return null;
const sorted = [...entry.durations].sort((a, b) => a - b);
const count = entry.count;
const totalMs = entry.totalMs;
const avgMs = totalMs / count;
const minMs = sorted[0];
const maxMs = sorted[count - 1];
const p50Index = Math.min(Math.floor(count * 0.5), count - 1);
const p95Index = Math.min(Math.floor(count * 0.95), count - 1);
const p99Index = Math.min(Math.floor(count * 0.99), count - 1);
const p50Ms = sorted[p50Index];
const p95Ms = sorted[p95Index];
const p99Ms = sorted[p99Index];
return {
name,
count,
errors: entry.errors,
totalMs,
avgMs,
minMs,
maxMs,
p50Ms,
p95Ms,
p99Ms,
};
}
getAllStats() {
const results = [];
for (const name of this.entries.keys()) {
results.push(this.getStats(name));
}
return results.sort((a, b) => a.name.localeCompare(b.name));
}
}
/**
* Wraps a LibDashStore instance so all method calls across all namespaces
* are intercepted and measured with high-resolution performance timers.
*
* @param {import('../src/index.js').default} store
* @param {MetricsCollector} metrics
* @returns {import('../src/index.js').default} Instrumented store
*/
export function instrumentStore(store, metrics = new MetricsCollector()) {
const ignoredProperties = new Set(['provider', 'pool', 'constructor']);
for (const [namespaceKey, namespaceVal] of Object.entries(store)) {
if (ignoredProperties.has(namespaceKey)) continue;
// Direct root function like store.purge or store.initialize
if (typeof namespaceVal === 'function') {
const originalFn = namespaceVal.bind(store);
store[namespaceKey] = async (...args) => {
const start = performance.now();
let error = null;
try {
return await originalFn(...args);
} catch (err) {
error = err;
throw err;
} finally {
const duration = performance.now() - start;
metrics.record(`store.${namespaceKey}`, duration, error);
}
};
continue;
}
if (typeof namespaceVal === 'object' && namespaceVal !== null) {
for (const [methodKey, methodVal] of Object.entries(namespaceVal)) {
if (typeof methodVal === 'function') {
const originalMethod = methodVal.bind(namespaceVal);
namespaceVal[methodKey] = async (...args) => {
const start = performance.now();
let error = null;
try {
return await originalMethod(...args);
} catch (err) {
error = err;
throw err;
} finally {
const duration = performance.now() - start;
metrics.record(`${namespaceKey}.${methodKey}`, duration, error);
}
};
}
}
}
}
return { store, metrics };
}

View File

@@ -0,0 +1,186 @@
import { writeFileSync } from 'node:fs';
import chalk from 'chalk';
function fmtMs(ms) {
if (ms == null || Number.isNaN(ms)) return '-';
if (ms < 1) return `${ms.toFixed(2)} ms`;
if (ms < 10) return `${ms.toFixed(2)} ms`;
return `${ms.toFixed(1)} ms`;
}
function padRight(str, len) {
const s = String(str);
return s + ' '.repeat(Math.max(0, len - s.length));
}
function padLeft(str, len) {
const s = String(str);
return ' '.repeat(Math.max(0, len - s.length)) + s;
}
export class BenchmarkReporter {
/**
* Prints the single-driver timing summary to stdout.
*/
static printDriverReport(driverName, stats, totalElapsedMs) {
console.log();
console.log(chalk.bold.cyan(`========================================================================================`));
console.log(chalk.bold.cyan(` DATABASE DRIVER TIMING REPORT: ${driverName.toUpperCase()}`));
console.log(chalk.bold.cyan(`========================================================================================`));
const totalOps = stats.reduce((acc, s) => acc + s.count, 0);
const totalErrors = stats.reduce((acc, s) => acc + s.errors, 0);
const header = `${padRight('API Method', 38)} | ${padLeft('Calls', 6)} | ${padLeft('Avg (ms)', 10)} | ${padLeft('Min (ms)', 10)} | ${padLeft('Max (ms)', 10)} | ${padLeft('P50 (ms)', 10)} | ${padLeft('P95 (ms)', 10)}`;
console.log(chalk.gray('-'.repeat(header.length)));
console.log(chalk.bold(header));
console.log(chalk.gray('-'.repeat(header.length)));
for (const item of stats) {
const avgStr = fmtMs(item.avgMs);
const minStr = fmtMs(item.minMs);
const maxStr = fmtMs(item.maxMs);
const p50Str = fmtMs(item.p50Ms);
const p95Str = fmtMs(item.p95Ms);
let line = `${padRight(item.name, 38)} | ${padLeft(item.count, 6)} | ${padLeft(avgStr, 10)} | ${padLeft(minStr, 10)} | ${padLeft(maxStr, 10)} | ${padLeft(p50Str, 10)} | ${padLeft(p95Str, 10)}`;
if (item.errors > 0) {
line = chalk.red(line + ` [${item.errors} ERR]`);
}
console.log(line);
}
console.log(chalk.gray('-'.repeat(header.length)));
console.log(
chalk.green(
`Total Operations: ${totalOps} | Total Time: ${(totalElapsedMs / 1000).toFixed(2)}s | APIs Covered: ${stats.length} | Errors: ${totalErrors}`,
),
);
console.log();
}
/**
* Prints the side-by-side comparison between MSSQL and SQLite.
*/
static printComparisonReport(mssqlStats, sqliteStats, mssqlElapsedMs, sqliteElapsedMs) {
console.log();
console.log(chalk.bold.magenta(`========================================================================================================`));
console.log(chalk.bold.magenta(` COMPARATIVE BENCHMARK: MSSQL vs BETTER-SQLITE3 (Timings in ms)`));
console.log(chalk.bold.magenta(`========================================================================================================`));
const mssqlMap = new Map(mssqlStats.map((s) => [s.name, s]));
const sqliteMap = new Map(sqliteStats.map((s) => [s.name, s]));
const allNames = Array.from(new Set([...mssqlMap.keys(), ...sqliteMap.keys()])).sort();
const header = `${padRight('API Method', 36)} | ${padLeft('Calls', 6)} | ${padLeft('MSSQL Avg', 11)} | ${padLeft('MSSQL P95', 11)} | ${padLeft('SQLite Avg', 11)} | ${padLeft('SQLite P95', 11)} | ${padLeft('Ratio (M/S)', 12)}`;
console.log(chalk.gray('-'.repeat(header.length)));
console.log(chalk.bold(header));
console.log(chalk.gray('-'.repeat(header.length)));
for (const name of allNames) {
const m = mssqlMap.get(name);
const s = sqliteMap.get(name);
const calls = m?.count || s?.count || 0;
const mAvg = m ? fmtMs(m.avgMs) : '-';
const mP95 = m ? fmtMs(m.p95Ms) : '-';
const sAvg = s ? fmtMs(s.avgMs) : '-';
const sP95 = s ? fmtMs(s.p95Ms) : '-';
let ratioStr = '-';
if (m && s && s.avgMs > 0) {
const ratio = m.avgMs / s.avgMs;
ratioStr = `${ratio.toFixed(1)}x`;
if (ratio > 1.5) {
ratioStr = chalk.yellow(ratioStr + ' (SQLite faster)');
} else if (ratio < 0.8) {
ratioStr = chalk.cyan(ratioStr + ' (MSSQL faster)');
}
}
console.log(
`${padRight(name, 36)} | ${padLeft(calls, 6)} | ${padLeft(mAvg, 11)} | ${padLeft(mP95, 11)} | ${padLeft(sAvg, 11)} | ${padLeft(sP95, 11)} | ${padLeft(ratioStr, 12)}`,
);
}
console.log(chalk.gray('-'.repeat(header.length)));
console.log(
chalk.bold(
`Total Runtime: MSSQL = ${(mssqlElapsedMs / 1000).toFixed(2)}s vs SQLite = ${(sqliteElapsedMs / 1000).toFixed(2)}s`,
),
);
console.log();
}
/**
* Generates a GitHub-flavored Markdown report.
*/
static generateMarkdownReport({ mssqlStats, sqliteStats, mssqlElapsedMs, sqliteElapsedMs, options = {} }) {
const lines = [];
lines.push('# Database Drivers API Timing & Benchmark Report');
lines.push('');
lines.push(`- **Date**: ${new Date().toISOString()}`);
lines.push(`- **Scale**: ${options.scale || 'small'}`);
lines.push(`- **Read Iterations on Filled DB**: ${options.iterations || 5}`);
lines.push('');
if (mssqlStats && sqliteStats) {
lines.push('## Comparative Summary');
lines.push('');
lines.push(`| Metric | MSSQL | better-sqlite3 | Delta |`);
lines.push(`| :--- | :---: | :---: | :---: |`);
const mssqlTotalOps = mssqlStats.reduce((acc, s) => acc + s.count, 0);
const sqliteTotalOps = sqliteStats.reduce((acc, s) => acc + s.count, 0);
lines.push(`| Total API Calls | ${mssqlTotalOps} | ${sqliteTotalOps} | - |`);
lines.push(`| Total Runtime | ${(mssqlElapsedMs / 1000).toFixed(2)}s | ${(sqliteElapsedMs / 1000).toFixed(2)}s | ${(mssqlElapsedMs / sqliteElapsedMs).toFixed(1)}x |`);
lines.push('');
lines.push('## API Timings Comparison (in milliseconds)');
lines.push('');
lines.push('| API Method | Calls | MSSQL Avg (ms) | MSSQL P95 (ms) | SQLite Avg (ms) | SQLite P95 (ms) | Ratio (MSSQL / SQLite) |');
lines.push('| :--- | :---: | :---: | :---: | :---: | :---: | :---: |');
const mssqlMap = new Map(mssqlStats.map((s) => [s.name, s]));
const sqliteMap = new Map(sqliteStats.map((s) => [s.name, s]));
const allNames = Array.from(new Set([...mssqlMap.keys(), ...sqliteMap.keys()])).sort();
for (const name of allNames) {
const m = mssqlMap.get(name);
const s = sqliteMap.get(name);
const calls = m?.count || s?.count || 0;
const mAvg = m ? m.avgMs.toFixed(2) : '-';
const mP95 = m ? m.p95Ms.toFixed(2) : '-';
const sAvg = s ? s.avgMs.toFixed(2) : '-';
const sP95 = s ? s.p95Ms.toFixed(2) : '-';
const ratio = m && s && s.avgMs > 0 ? (m.avgMs / s.avgMs).toFixed(2) + 'x' : '-';
lines.push(`| \`${name}\` | ${calls} | ${mAvg} | ${mP95} | ${sAvg} | ${sP95} | ${ratio} |`);
}
lines.push('');
}
const drivers = [];
if (mssqlStats) drivers.push({ name: 'MSSQL (MssqlProvider)', stats: mssqlStats, elapsed: mssqlElapsedMs });
if (sqliteStats) drivers.push({ name: 'SQLite (BetterSqlite3Provider)', stats: sqliteStats, elapsed: sqliteElapsedMs });
for (const driver of drivers) {
lines.push(`## ${driver.name} Detailed Breakdown`);
lines.push('');
lines.push('| API Method | Calls | Avg (ms) | Min (ms) | Max (ms) | P50 (ms) | P95 (ms) | Errors |');
lines.push('| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |');
for (const item of driver.stats) {
lines.push(
`| \`${item.name}\` | ${item.count} | ${item.avgMs.toFixed(2)} | ${item.minMs.toFixed(2)} | ${item.maxMs.toFixed(2)} | ${item.p50Ms.toFixed(2)} | ${item.p95Ms.toFixed(2)} | ${item.errors} |`,
);
}
lines.push('');
}
return lines.join('\n');
}
static saveReportToFile(filePath, markdownContent) {
writeFileSync(filePath, markdownContent, 'utf-8');
console.log(chalk.green(`✓ Benchmark report written to: ${filePath}`));
}
}

View File

@@ -0,0 +1,775 @@
import { optimizeImage } from 'wasm-image-optimization';
/**
* Creates sample PNG image buffer.
*/
async function makeSampleImage(width = 200, height = 200, color = { r: 50, g: 120, b: 220 }) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" fill="rgb(${color.r},${color.g},${color.b})"/></svg>`;
const res = await optimizeImage({ image: svg, format: 'png' });
return Buffer.from(res.data);
}
/**
* Creates sample AVIF thumbnail buffer.
*/
async function makeSampleThumb(width = 64, height = 64) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" fill="rgb(200,60,60)"/></svg>`;
const res = await optimizeImage({ image: svg, format: 'avif' });
return Buffer.from(res.data);
}
/**
* Executes Phase 1: Database Population / Fill.
* Systematically calls creation and write APIs to populate the database
* with realistic entities and relationships according to the configured scale.
*/
export async function fillDatabase(store, options = {}) {
const scale = options.scale || 'small';
const counts = {
small: { categories: 6, articles: 18, entities: 6, manifests: 6 },
medium: { categories: 15, articles: 60, entities: 20, manifests: 20 },
large: { categories: 30, articles: 200, entities: 50, manifests: 60 },
}[scale] || { categories: 6, articles: 18, entities: 6, manifests: 6 };
const ctx = {
googleAuthId: null,
company: null,
secondaryGoogleAuthId: null,
secondaryCompany: null,
categories: [],
articles: [],
parentArticles: [],
childArticles: [],
customergroups: [],
entities: [],
suppliers: [],
deliveries: [],
orders: [],
quotes: [],
terminals: [],
sampleImageHash: null,
sampleImageThumbHash: null,
attachments: [],
};
const sampleImage = await makeSampleImage();
const sampleThumb = await makeSampleThumb();
// 1. Google Auth setup
const primaryEmail = `benchmark.admin.${Date.now()}@example.com`;
const secEmail = `benchmark.secondary.${Date.now()}@example.com`;
ctx.googleAuthId = await store.google_auth.add(primaryEmail);
await store.google_auth.setProfile(ctx.googleAuthId, {
sub: `sub-${ctx.googleAuthId}`,
picture: 'https://example.com/pic.png',
});
await store.google_auth.listAllowedEmails();
ctx.secondaryGoogleAuthId = await store.google_auth.add(secEmail);
// 2. Company setup
ctx.company = await store.company.create({
name: `Bench Corp ${Date.now()}`,
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId: ctx.googleAuthId,
});
await store.google_auth.setSelectedCompany(ctx.googleAuthId, ctx.company.id);
ctx.secondaryCompany = await store.company.create({
name: `Secondary Corp ${Date.now()}`,
iso639: 'en',
taxrate: 20,
reducedtaxrate: 10,
googleAuthId: ctx.secondaryGoogleAuthId,
});
await store.google_auth.setSelectedCompany(ctx.secondaryGoogleAuthId, ctx.secondaryCompany.id);
await store.company.update(ctx.company.id, {
name: `Bench Corp Updated ${Date.now()}`,
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
});
await store.company.list(ctx.googleAuthId);
// 3. Company Users
const compUserId = await store.companyuser.add(ctx.company.id, ctx.secondaryGoogleAuthId);
ctx.compUserId = compUserId;
await store.companyuser.getByEmail(secEmail);
// 4. Lang APIs
await store.lang.ensure('en');
await store.lang.ensure('fr');
await store.lang.create('es');
await store.lang.getByIso639('en');
await store.lang.list();
// 5. Tax APIs
await store.tax.ensure(19);
await store.tax.ensure(7);
const taxIdSample = await store.tax.create(5.5);
ctx.taxSampleId = taxIdSample;
await store.tax.getByRate(19);
await store.tax.list();
// 6. Unit APIs
await store.unit.upsert(ctx.googleAuthId, {
si: 'kg',
basesi: 'g',
basesifactor: 1000,
symbol: 'kg',
name: 'Kilogramm',
});
await store.unit.upsert(ctx.googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
});
await store.unit.list(ctx.googleAuthId);
// 7. Company Settings
await store.companysetting.set(ctx.googleAuthId, {
key: 'ui.theme',
value: 'dark',
frontend: true,
writeonly: false,
});
await store.companysetting.set(ctx.googleAuthId, {
key: 'default-model',
value: 'openai/gpt-6-luna:floor',
frontend: false,
writeonly: false,
});
await store.companysetting.get(ctx.googleAuthId, 'ui.theme');
await store.companysetting.list(ctx.googleAuthId);
// 8. Brand APIs
const brand1 = await store.brand.upsert(ctx.googleAuthId, {
name: 'BenchBrand Pro',
gsprpinfo: 'Manufacturer Pro GmbH',
});
const brand2 = await store.brand.upsert(ctx.googleAuthId, {
name: 'BenchBrand Ultra',
gsprpinfo: 'Manufacturer Ultra AG',
});
ctx.brands = [brand1, brand2];
await store.brand.list(ctx.googleAuthId);
// 9. Customer Groups
const cgVip = await store.customergroup.create(ctx.googleAuthId, { name: 'VIP Members' });
const cgRetail = await store.customergroup.create(ctx.googleAuthId, { name: 'Retail Customers' });
await store.customergroup.update(ctx.googleAuthId, cgVip.id, { name: 'VIP Gold Members' });
const allCgs = await store.customergroup.list(ctx.googleAuthId);
await store.customergroup.order(ctx.googleAuthId, {
customergroupIds: allCgs.map((g) => g.id).reverse(),
});
await store.customergroup.list(ctx.googleAuthId);
ctx.customergroups.push(cgRetail, cgVip);
// 10. Categories Hierarchy
const rootCatId = ctx.company.rootCategoryId;
const numCats = counts.categories;
for (let i = 1; i <= numCats; i++) {
const parentId = i > 3 && ctx.categories.length > 0 ? ctx.categories[(i % 3)].id : rootCatId;
const cat = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId,
name: `Category ${i}`,
});
ctx.categories.push(cat);
}
// Ensure by path
const deepCatId = await store.category.ensureByPath({
googleAuthId: ctx.googleAuthId,
parentId: rootCatId,
path: 'Hardware / Components / Processors',
});
ctx.deepCatId = deepCatId;
// Category manipulation APIs
if (ctx.categories.length >= 2) {
await store.category.rename({
googleAuthId: ctx.googleAuthId,
id: ctx.categories[0].id,
name: 'Main Catalog Category',
});
const orderParent = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: rootCatId,
name: 'Order Test Parent',
});
const oChild1 = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: orderParent.id,
name: 'Order Child 1',
});
const oChild2 = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: orderParent.id,
name: 'Order Child 2',
});
await store.category.move({
googleAuthId: ctx.googleAuthId,
id: oChild2.id,
parentId: orderParent.id,
});
await store.category.order({
googleAuthId: ctx.googleAuthId,
parentId: orderParent.id,
categoryIds: [oChild2.id, oChild1.id],
});
}
// Category image
await store.category.addImage(ctx.googleAuthId, ctx.categories[0].id, sampleImage);
// 11. Numbers APIs
const numList = await store.numbers.list(ctx.googleAuthId);
if (numList.length > 0) {
await store.numbers.update(ctx.googleAuthId, numList[0].id, {
prefix: 'BN-',
padding: 6,
next: 1001,
});
}
// 12. POS Terminals
const term1 = await store.posterminal.upsert(ctx.googleAuthId, {
name: 'Counter Terminal 1',
pairingpin: '1234',
});
const term2 = await store.posterminal.upsert(ctx.googleAuthId, {
name: 'Mobile Terminal 2',
pairingpin: '5678',
});
ctx.terminals.push(term1, term2);
await store.posterminal.setOn(ctx.googleAuthId, term1.id, ctx.categories[0].id);
await store.posterminal.setOff(ctx.googleAuthId, term1.id, ctx.categories[0].id);
await store.posterminal.setOnAll(ctx.googleAuthId, term1.id);
await store.posterminal.setOffAll(ctx.googleAuthId, term1.id);
await store.posterminal.getCategoryPosterminalIds(ctx.googleAuthId, ctx.categories[0].id);
await store.posterminal.list(ctx.googleAuthId);
// 13. Entities (Customers)
const numEntities = counts.entities;
for (let i = 1; i <= numEntities; i++) {
const nextNum = await store.entity.peekNextNumber(ctx.googleAuthId);
await store.entity.isNumberFree(ctx.googleAuthId, nextNum);
const ent = await store.entity.create(ctx.googleAuthId, {
customergroupId: ctx.customergroups[i % ctx.customergroups.length].id,
name: `Customer Company ${i}`,
firstName: `Firstname ${i}`,
});
ctx.entities.push(ent);
// Entity Address
const addr = await store.entityaddress.create(ctx.googleAuthId, ent.id, {
addressType: 'shipping',
name: `Branch ${i}`,
street: `Main Street ${i}`,
streetNumber: `${i}`,
zip: '10115',
city: 'Berlin',
county: 'Berlin',
sort: 1,
});
await store.entityaddress.update(ctx.googleAuthId, ent.id, addr.id, {
street: `Updated Street ${i}`,
});
// Entity Contact
const contact = await store.entitycontact.create(ctx.googleAuthId, ent.id, {
contactType: 'email',
contact: `contact${i}@benchmark.test`,
sort: 1,
});
await store.entitycontact.update(ctx.googleAuthId, ent.id, contact.id, {
contact: `updated-contact${i}@benchmark.test`,
});
}
// 14. Suppliers
for (let i = 1; i <= 3; i++) {
const sup = await store.supplier.create(ctx.googleAuthId, {
name: `Supplier Express ${i}`,
});
await store.supplier.update(ctx.googleAuthId, sup.id, {
name: `Supplier Express ${i} GmbH`,
});
ctx.suppliers.push(sup);
}
await store.supplier.list(ctx.googleAuthId);
// 15. Articles
const numArticles = counts.articles;
for (let i = 1; i <= numArticles; i++) {
const targetCat = ctx.categories[i % ctx.categories.length];
const isParent = i % 4 === 1;
const art = await store.article.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: targetCat.id,
name: `Bench Article ${i}`,
});
const sku = art.sku;
ctx.articles.push(art);
// Update article details
await store.article.updateBySku(ctx.googleAuthId, sku, {
name: `Bench Article ${i} Pro`,
unitSi: 'stk',
unitamount: 1,
});
// Prices
await store.article.setPricesBySku(ctx.googleAuthId, sku, [
{ price: 10 + i * 2.5, tax: 19, minmultiplier: 1 },
{ price: 8 + i * 2.5, tax: 19, minmultiplier: 5 },
]);
// EANs
const ean = `4012345${String(i).padStart(6, '0')}`;
await store.article.setEansBySku(ctx.googleAuthId, sku, [ean]);
// Brand
await store.article.setBrandIdBySku(ctx.googleAuthId, sku, ctx.brands[i % ctx.brands.length].id);
// Weight
await store.article.setWeightBySku(ctx.googleAuthId, sku, 0.25 * i);
// Categories
await store.article.setCategoryIdsBySku(ctx.googleAuthId, sku, [
targetCat.id,
ctx.categories[(i + 1) % ctx.categories.length].id,
]);
// V/K Parent-Child hierarchy
if (isParent) {
ctx.parentArticles.push(art);
} else if (ctx.parentArticles.length > 0) {
const parentParent = ctx.parentArticles[ctx.parentArticles.length - 1];
await store.article.setParentBySku(ctx.googleAuthId, sku, parentParent.sku);
ctx.childArticles.push(art);
}
// Add image on select articles
if (i <= 3) {
const imgRes = await store.article.addImage(ctx.googleAuthId, sku, sampleImage);
ctx.sampleImageHash = imgRes.fullHash;
ctx.sampleImageThumbHash = imgRes.thumbHash;
await store.article.orderImages(ctx.googleAuthId, sku, {
fullHashes: [imgRes.fullHash],
});
}
// Article Suppliers
if (ctx.suppliers.length > 0) {
const sup = ctx.suppliers[i % ctx.suppliers.length];
const artSup = await store.articlesupplier.create(ctx.googleAuthId, sku, {
entityId: sup.id,
sku: `SUP-SKU-${i}`,
pu: 1,
stock: 100,
sort: 1,
netPrice: 7.5 + i,
taxId: ctx.company.taxrateId,
});
await store.articlesupplier.update(ctx.googleAuthId, sku, artSup.id, {
stock: 120,
pu: 2,
});
await store.articlesupplier.order(ctx.googleAuthId, sku, {
ids: [artSup.id],
});
}
}
// 16. Article Import Rows API
const importRows = [
{
sku: `IMP-${Date.now()}-1`,
name: 'Imported Keyboard Model X',
vk: 'V',
parentCategory: 'Hardware / Components / Processors',
brand: 'BenchBrand Pro',
net: '45,50',
tax: 19,
eans: [`4262557${String(Date.now()).slice(-6)}`],
},
{
sku: `IMP-${Date.now()}-2`,
name: 'Imported Keyboard Variant Black',
vk: 'K',
parentCategory: 'Hardware / Components / Processors',
brand: 'BenchBrand Pro',
net: '45,50',
tax: 19,
parentSku: `IMP-${Date.now()}-1`,
},
];
await store.article.importRows({
googleAuthId: ctx.googleAuthId,
rows: importRows,
});
// 17. Manifests: Quotes, Orders, Deliveries
const numManifests = counts.manifests;
for (let i = 1; i <= numManifests; i++) {
const ent = ctx.entities[i % ctx.entities.length];
const sup = ctx.suppliers[i % ctx.suppliers.length];
const art = ctx.articles[i % ctx.articles.length];
// Quote
const quote = await store.quote.create(ctx.googleAuthId, {
number: `QUO-${Date.now()}-${i}`,
date: new Date().toISOString(),
entityId: ent.id,
externalNumber: `EXT-Q-${i}`,
lines: [
{
sku: art.sku,
name: `Item ${art.sku}`,
amount: 2,
net: 25.0,
pu: 1,
rebate: 0,
taxrate: 19,
comment: 'Standard quote item',
},
],
});
await store.quote.update(ctx.googleAuthId, quote.id, {
externalNumber: `EXT-Q-${i}-UPDATED`,
});
ctx.quotes.push(quote);
// Order
const order = await store.order.create(ctx.googleAuthId, {
number: `ORD-${Date.now()}-${i}`,
date: new Date().toISOString(),
entityId: ent.id,
externalNumber: `EXT-O-${i}`,
lines: [
{
sku: art.sku,
name: `Item ${art.sku}`,
amount: 5,
net: 25.0,
pu: 1,
rebate: 5,
taxrate: 19,
comment: 'Expedited delivery order',
},
],
});
await store.order.update(ctx.googleAuthId, order.id, {
externalNumber: `EXT-O-${i}-CONFIRMED`,
});
ctx.orders.push(order);
// Delivery with attached files
const delivery = await store.delivery.create(ctx.googleAuthId, {
supplierId: sup.id,
externalNumber: `DEL-${Date.now()}-${i}`,
date: new Date().toISOString(),
lines: [
{
sku: art.sku,
name: `Delivery Item ${art.sku}`,
amount: 50,
net: 15.0,
pu: 1,
rebate: 0,
taxrate: 19,
comment: 'Warehouse receipt',
},
],
files: i === 1 ? [
{
name: 'packing-slip.txt',
mimetype: 'text/plain',
content: Buffer.from(`Packing slip details for delivery ${i}`),
},
] : [],
});
await store.delivery.update(ctx.googleAuthId, delivery.id, {
externalNumber: `DEL-${Date.now()}-${i}-STOCKED`,
});
ctx.deliveries.push(delivery);
}
// 18. Manifest Attachments (file storage API)
if (ctx.deliveries.length > 0) {
const primaryManifest = ctx.deliveries[0];
const attach1 = await store.manifestattachment.add(ctx.googleAuthId, primaryManifest.id, {
name: 'invoice.pdf',
mimetype: 'application/pdf',
content: Buffer.from('%PDF-1.4 ... benchmark dummy pdf content ...'),
thumbavif: sampleThumb,
thumbw: 64,
thumbh: 64,
});
const attach2 = await store.manifestattachment.add(ctx.googleAuthId, primaryManifest.id, {
name: 'quality-report.txt',
mimetype: 'text/plain',
content: Buffer.from('Quality inspection passed successfully.'),
});
const currentList = await store.manifestattachment.listByManifestId(
ctx.googleAuthId,
primaryManifest.id,
);
await store.manifestattachment.order(ctx.googleAuthId, primaryManifest.id, {
ids: currentList.map((a) => a.id).reverse(),
});
ctx.attachments.push({ manifestId: primaryManifest.id, ...attach1 });
}
return ctx;
}
/**
* Executes Phase 2: Filled Database Performance Benchmark.
* Runs read, query, search, and lookup APIs against the populated database
* over multiple iterations to measure realistic data access speeds.
*/
export async function benchmarkFilledDatabase(store, ctx, options = {}) {
const iterations = options.iterations || 5;
for (let iter = 0; iter < iterations; iter++) {
// 1. Category Tree & Articles
await store.category.getTreeForCompany(ctx.googleAuthId);
if (ctx.categories.length > 0) {
await store.category.getCategoryArticles(ctx.googleAuthId, ctx.categories[0].id);
}
// 2. Article Catalog Read APIs
await store.article.listSearchIndex(ctx.googleAuthId);
await store.article.listExport(ctx.googleAuthId);
if (ctx.categories.length > 0) {
await store.article.listExport(ctx.googleAuthId, ctx.categories[0].id);
}
// Article SKU lookups & history
if (ctx.articles.length > 0) {
const sampleSku = ctx.articles[iter % ctx.articles.length].sku;
await store.article.getBySku(ctx.googleAuthId, sampleSku);
await store.article.getHistoryBySku(ctx.googleAuthId, sampleSku);
}
// 3. Article Supplier Lookups
if (ctx.articles.length > 0) {
const sampleSku = ctx.articles[iter % ctx.articles.length].sku;
await store.articlesupplier.listByArticleSku(ctx.googleAuthId, sampleSku);
if (ctx.suppliers.length > 0) {
await store.articlesupplier.listByEntityAndSkus(ctx.googleAuthId, {
supplierId: ctx.suppliers[0].id,
skus: [sampleSku],
});
await store.articlesupplier.listBySupplierAndSkus(ctx.googleAuthId, {
supplierId: ctx.suppliers[0].id,
skus: [sampleSku],
});
}
}
// 4. Pic & Thumbnail Reads
if (ctx.sampleImageHash) {
await store.pic.getImageByHash(ctx.googleAuthId, ctx.sampleImageHash);
await store.pic.getThumbByHash(ctx.googleAuthId, ctx.sampleImageHash);
}
// 5. Entities (Customers) Lookups
await store.entity.list(ctx.googleAuthId);
if (ctx.customergroups.length > 0) {
await store.entity.list(ctx.googleAuthId, { customergroupId: ctx.customergroups[0].id });
}
if (ctx.entities.length > 0) {
const sampleEnt = ctx.entities[iter % ctx.entities.length];
await store.entity.getById(ctx.googleAuthId, sampleEnt.id);
await store.entityaddress.list(ctx.googleAuthId, sampleEnt.id);
await store.entitycontact.list(ctx.googleAuthId, sampleEnt.id);
}
// 6. Suppliers Lookups
await store.supplier.list(ctx.googleAuthId);
// 7. Manifests Lookups (Delivery, Order, Quote)
await store.delivery.list(ctx.googleAuthId);
if (ctx.deliveries.length > 0) {
const sampleDel = ctx.deliveries[iter % ctx.deliveries.length];
await store.delivery.getById(ctx.googleAuthId, sampleDel.id);
}
await store.order.list(ctx.googleAuthId);
if (ctx.orders.length > 0) {
const sampleOrd = ctx.orders[iter % ctx.orders.length];
await store.order.getById(ctx.googleAuthId, sampleOrd.id);
}
await store.quote.list(ctx.googleAuthId);
if (ctx.quotes.length > 0) {
const sampleQuo = ctx.quotes[iter % ctx.quotes.length];
await store.quote.getById(ctx.googleAuthId, sampleQuo.id);
}
// 8. Manifest Attachments
if (ctx.attachments.length > 0) {
const sampleAttach = ctx.attachments[0];
await store.manifestattachment.listByManifestId(ctx.googleAuthId, sampleAttach.manifestId);
await store.manifestattachment.getById(ctx.googleAuthId, sampleAttach.manifestId, sampleAttach.id);
}
// 9. Auth & Admin Queries
await store.google_auth.getById(ctx.googleAuthId);
await store.google_auth.getByEmail(`benchmark.admin.${ctx.googleAuthId}`); // may return undefined or auth object
await store.companysetting.list(ctx.googleAuthId);
await store.posterminal.list(ctx.googleAuthId);
}
}
/**
* Executes Phase 3: Lifecycle & Deletion APIs.
* Uses dedicated throwaway test items to exercise all delete and removal APIs
* without destroying the core benchmarked dataset.
*/
export async function testLifecycleDeletions(store, ctx) {
const sampleImage = await makeSampleImage(80, 80, { r: 180, g: 80, b: 80 });
// 1. Temporary Category with Image & Delete
const tempCat = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: ctx.company.rootCategoryId,
name: 'Temporary Cat for Delete',
});
await store.category.addImage(ctx.googleAuthId, tempCat.id, sampleImage);
await store.category.deleteImage(ctx.googleAuthId, tempCat.id);
await store.category.delete({ googleAuthId: ctx.googleAuthId, id: tempCat.id });
// 2. Temporary Article with Image & Delete
const tempArt = await store.article.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: ctx.categories[0].id,
name: 'Temporary Article for Delete',
});
const artImg = await store.article.addImage(ctx.googleAuthId, tempArt.sku, sampleImage);
await store.article.deleteImage(ctx.googleAuthId, tempArt.sku, artImg.fullHash);
// Article Supplier Delete
if (ctx.suppliers.length > 0) {
const tempArtSup = await store.articlesupplier.create(ctx.googleAuthId, tempArt.sku, {
entityId: ctx.suppliers[0].id,
sku: 'TEMP-SUP-SKU',
});
await store.articlesupplier.delete(ctx.googleAuthId, tempArt.sku, tempArtSup.id);
}
await store.article.delete(ctx.googleAuthId, tempArt.sku);
// 3. Manifest Attachment Remove
if (ctx.deliveries.length > 0) {
const tempAttach = await store.manifestattachment.add(ctx.googleAuthId, ctx.deliveries[0].id, {
name: 'delete-me.txt',
mimetype: 'text/plain',
content: Buffer.from('temp file to remove'),
});
await store.manifestattachment.remove(ctx.googleAuthId, ctx.deliveries[0].id, tempAttach.id);
}
// 4. Temporary Delivery, Order, Quote Delete
if (ctx.suppliers.length > 0) {
const tempDel = await store.delivery.create(ctx.googleAuthId, {
supplierId: ctx.suppliers[0].id,
date: new Date().toISOString(),
lines: [{ name: 'Temp item', amount: 1, net: 10, taxrate: 19 }],
});
await store.delivery.delete(ctx.googleAuthId, tempDel.id);
}
const tempOrd = await store.order.create(ctx.googleAuthId, {
number: `TEMP-ORD-${Date.now()}`,
date: new Date().toISOString(),
lines: [{ name: 'Temp item', amount: 1, net: 10, taxrate: 19 }],
});
await store.order.delete(ctx.googleAuthId, tempOrd.id);
const tempQuo = await store.quote.create(ctx.googleAuthId, {
number: `TEMP-QUO-${Date.now()}`,
date: new Date().toISOString(),
lines: [{ name: 'Temp item', amount: 1, net: 10, taxrate: 19 }],
});
await store.quote.delete(ctx.googleAuthId, tempQuo.id);
// 5. Temporary Entity with Contact & Address Delete
const tempEnt = await store.entity.create(ctx.googleAuthId, {
customergroupId: ctx.customergroups[0].id,
name: 'Temporary Delete Entity',
});
const tempAddr = await store.entityaddress.create(ctx.googleAuthId, tempEnt.id, {
addressType: 'billing',
street: 'Temp St',
});
await store.entityaddress.delete(ctx.googleAuthId, tempEnt.id, tempAddr.id);
const tempContact = await store.entitycontact.create(ctx.googleAuthId, tempEnt.id, {
contactType: 'phone',
contact: '+4912345678',
});
await store.entitycontact.delete(ctx.googleAuthId, tempEnt.id, tempContact.id);
await store.entity.delete(ctx.googleAuthId, tempEnt.id);
// 6. Temporary Supplier Delete
const tempSup = await store.supplier.create(ctx.googleAuthId, { name: 'Temp Sup for Delete' });
await store.supplier.delete(ctx.googleAuthId, tempSup.id);
// 7. Temporary Customergroup Delete
const tempCg = await store.customergroup.create(ctx.googleAuthId, { name: 'Temp CG Delete' });
await store.customergroup.delete(ctx.googleAuthId, tempCg.id);
// 8. Temporary Brand Delete
const tempBrand = await store.brand.upsert(ctx.googleAuthId, { name: `TempBrand-${Date.now()}` });
await store.brand.delete(ctx.googleAuthId, tempBrand.id);
// 9. Temporary Unit Delete
const tempUnitSi = `tunit-${Date.now()}`.slice(0, 15);
await store.unit.upsert(ctx.googleAuthId, {
si: tempUnitSi,
basesi: tempUnitSi,
basesifactor: 1,
symbol: 'tu',
name: 'Temp Unit',
});
await store.unit.delete(ctx.googleAuthId, tempUnitSi);
// 10. Temporary Terminal Delete
const tempTerm = await store.posterminal.upsert(ctx.googleAuthId, {
name: 'Temp Terminal Delete',
pairingpin: '9999',
});
await store.posterminal.delete(ctx.googleAuthId, tempTerm.id);
// 11. Company User Delete
if (ctx.compUserId) {
await store.companyuser.delete(ctx.compUserId);
}
// 12. Allowed Email Remove
const tempEmail = `bench.remove.${Date.now()}@example.com`;
await store.google_auth.add(tempEmail);
await store.google_auth.removeAllowedEmail(tempEmail);
// 13. Lang Delete
const tempLangId = await store.lang.create('nl');
await store.lang.delete(tempLangId);
// 14. Secondary Company Delete
if (ctx.secondaryCompany) {
await store.company.delete(ctx.secondaryCompany.id);
}
}

54
scripts/downgrade.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* Downgrades the database schema to a target version.
*
* npm run downgrade -- <targetVersion>
*
* Provider from env: DB_PROVIDER=sqlite (+ SQLITE_PATH) or mssql (MSSQL_*, default).
* Runs src/providers/<provider>/downgrade/<from>_to_<to>.js one step at a time,
* each in its own transaction. Not part of the server — the server only upgrades.
* Note: starting a server whose code knows a higher version upgrades again.
*/
import 'dotenv/config';
const [rawTarget] = process.argv.slice(2);
const target = Number(rawTarget);
if (!rawTarget || !Number.isInteger(target) || target < 1) {
console.error('Usage: npm run downgrade -- <targetVersion>');
process.exit(1);
}
const providerName = process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql');
let provider;
if (providerName === 'sqlite' || providerName === 'better-sqlite3') {
if (!process.env.SQLITE_PATH) {
console.error('SQLITE_PATH is required for DB_PROVIDER=sqlite');
process.exit(1);
}
const { BetterSqlite3Provider } = await import('../src/providers/sqlite3/index.js');
provider = new BetterSqlite3Provider({ filename: process.env.SQLITE_PATH, fileMustExist: true });
} else if (providerName === 'mssql') {
const { MssqlProvider } = await import('../src/providers/mssql/index.js');
provider = new MssqlProvider({
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: { encrypt: false, trustServerCertificate: true },
});
} else {
console.error(`Unknown DB_PROVIDER: ${providerName}`);
process.exit(1);
}
try {
const before = await provider.getSchemaVersion();
const steps = await provider.downgradeSchema(target);
console.log(`Schema ${providerName}: ${before} -> ${target} (${steps} step(s))`);
} catch (err) {
console.error(`Downgrade failed: ${err.message}`);
process.exitCode = 1;
} finally {
await provider.close();
}

View File

@@ -1,6 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import sql from 'mssql'; import sql from 'mssql';
import { SCHEMA_SCRIPTS } from '../src/schema.js'; import { SCHEMA_SCRIPTS } from '../src/providers/mssql/schema.js';
const config = { const config = {
server: process.env.MSSQL_SERVER, server: process.env.MSSQL_SERVER,

View File

@@ -1,6 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import sql from 'mssql'; import sql from 'mssql';
import { buildPurgeSql } from '../src/sql/purge.js'; import { buildPurgeSql } from '../src/providers/mssql/purgeSql.js';
const pool = await sql.connect({ const pool = await sql.connect({
server: process.env.MSSQL_SERVER, server: process.env.MSSQL_SERVER,

View File

@@ -1,7 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import sql from 'mssql'; import sql from 'mssql';
import { buildPurgeSql } from '../src/sql/purge.js'; import { buildPurgeSql } from '../src/providers/mssql/purgeSql.js';
import { SCHEMA_SCRIPTS } from '../src/schema.js'; import { SCHEMA_SCRIPTS } from '../src/providers/mssql/schema.js';
const pool = await sql.connect({ const pool = await sql.connect({
server: process.env.MSSQL_SERVER, server: process.env.MSSQL_SERVER,

View File

@@ -4,6 +4,7 @@ import sql from 'mssql';
import chalk from 'chalk'; import chalk from 'chalk';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import LibDashStore from '../src/index.js'; import LibDashStore from '../src/index.js';
import { MssqlProvider } from '../src/providers/mssql/index.js';
const config = { const config = {
server: process.env.MSSQL_SERVER, server: process.env.MSSQL_SERVER,
@@ -368,6 +369,11 @@ const MENU = {
label: 'setWeightBySku', label: 'setWeightBySku',
hint: 'googleAuthId, sku, weight → row', hint: 'googleAuthId, sku, weight → row',
}, },
{
value: 'article.setTrackStockBySku',
label: 'setTrackStockBySku',
hint: 'googleAuthId, sku, trackStock (true|false) → row',
},
{ {
value: 'article.setCategoryIdsBySku', value: 'article.setCategoryIdsBySku',
label: 'setCategoryIdsBySku', label: 'setCategoryIdsBySku',
@@ -1761,6 +1767,27 @@ async function runMethod(client, action) {
), ),
]; ];
} }
case 'article.setTrackStockBySku': {
const googleAuthId = await askInt('google_auth id');
if (googleAuthId == null) return;
const sku = await askText('sku', {
validate: (v) => (!v?.trim() ? 'Required' : undefined),
});
if (sku == null) return;
const trackStockRaw = await p.confirm({
message: 'Track stock?',
initialValue: true,
});
if (p.isCancel(trackStockRaw)) return;
return [
'result',
await client.article.setTrackStockBySku(
Number(googleAuthId),
sku,
Boolean(trackStockRaw),
),
];
}
case 'article.setCategoryIdsBySku': { case 'article.setCategoryIdsBySku': {
const googleAuthId = await askInt('google_auth id'); const googleAuthId = await askInt('google_auth id');
if (googleAuthId == null) return; if (googleAuthId == null) return;
@@ -2004,7 +2031,7 @@ async function main() {
spin.message('Initializing schema…'); spin.message('Initializing schema…');
let client; let client;
try { try {
client = await new LibDashStore(pool); client = await new LibDashStore(new MssqlProvider(pool));
spin.stop(chalk.green('Ready')); spin.stop(chalk.green('Ready'));
} catch (err) { } catch (err) {
spin.stop(chalk.red('Initialize failed')); spin.stop(chalk.red('Initialize failed'));

View File

@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { optimizeImage } from 'wasm-image-optimization';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing attributevaluepic & attributevalue image operations ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-attrval-pic-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const attr = await store.attributename.create(googleAuthId, {
name: 'Color',
exclusive: true,
});
const val = await store.attributevalue.create(googleAuthId, attr.id, {
value: 'Midnight Blue',
});
assert.ok(val.id > 0);
// Create a 100x100 test image
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="rgb(25,25,112)"/></svg>`;
const encoded = await optimizeImage({ image: svg, format: 'avif' });
const imageBuffer = Buffer.from(encoded.data);
console.log('1. Adding image to attribute value...');
const addResult = await store.attributevalue.addImage(googleAuthId, val.id, imageBuffer);
assert.equal(addResult.id, val.id);
assert.ok(addResult.fullHash);
assert.ok(addResult.thumbW > 0);
assert.ok(addResult.thumbH > 0);
assert.ok(addResult.thumbHash);
assert.equal(addResult.pics.length, 1);
assert.equal(addResult.pics[0].fullHash, addResult.fullHash);
// Verify row in attributevaluepic
const rows = await provider.query('SELECT * FROM attributevaluepic WHERE attributevalue_id = @id', {
id: val.id,
});
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0].attributevalue_id, val.id);
// Add again to test replacement
console.log('2. Replacing image on attribute value...');
const replaceResult = await store.attributevalue.addImage(googleAuthId, val.id, imageBuffer);
assert.equal(replaceResult.id, val.id);
const rowsAfterReplace = await provider.query('SELECT * FROM attributevaluepic WHERE attributevalue_id = @id', {
id: val.id,
});
assert.equal(rowsAfterReplace.rows.length, 1);
// Delete image
console.log('3. Deleting image from attribute value...');
const deleteResult = await store.attributevalue.deleteImage(googleAuthId, val.id);
assert.equal(deleteResult.id, val.id);
assert.equal(deleteResult.thumbW, null);
assert.equal(deleteResult.thumbH, null);
assert.equal(deleteResult.thumbHash, null);
assert.deepEqual(deleteResult.pics, []);
const rowsAfterDelete = await provider.query('SELECT * FROM attributevaluepic WHERE attributevalue_id = @id', {
id: val.id,
});
assert.equal(rowsAfterDelete.rows.length, 0);
// Deleting again should fail with Image not found
await assert.rejects(
async () => {
await store.attributevalue.deleteImage(googleAuthId, val.id);
},
/Image not found/
);
console.log('All attributevaluepic tests passed successfully!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

142
scripts/test-carrier.js Normal file
View File

@@ -0,0 +1,142 @@
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 Carrier Store API ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-carrier-'));
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('carrier-test@example.com');
const company = await store.company.create({
name: 'Carrier Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// 1. Create carrier with defaults
const c1 = await store.carrier.create(googleAuthId, { name: 'DHL' });
assert.equal(c1.name, 'DHL');
assert.equal(c1.sort, 0);
assert.equal(c1.hasPackages, false);
assert.equal(c1.isReverse, false);
console.log('PASS: Carrier 1 created with defaults');
// 2. Create carrier with full flags
const c2 = await store.carrier.create(googleAuthId, {
name: 'UPS',
sort: 5,
hasPackages: true,
isReverse: true,
});
assert.equal(c2.name, 'UPS');
assert.equal(c2.sort, 5);
assert.equal(c2.hasPackages, true);
assert.equal(c2.isReverse, true);
console.log('PASS: Carrier 2 created with custom flags');
// 3. Auto-increment sort
const c3 = await store.carrier.create(googleAuthId, {
name: 'DPD',
hasPackages: true,
});
assert.equal(c3.name, 'DPD');
assert.equal(c3.sort, 6);
assert.equal(c3.hasPackages, true);
assert.equal(c3.isReverse, false);
console.log('PASS: Carrier 3 auto-incremented sort');
// 4. Duplicate name validation
await assert.rejects(
() => store.carrier.create(googleAuthId, { name: 'DHL' }),
{ message: 'Carrier already exists' },
);
console.log('PASS: Duplicate carrier rejected');
// 5. Empty name validation
await assert.rejects(
() => store.carrier.create(googleAuthId, { name: ' ' }),
{ message: 'Name required' },
);
console.log('PASS: Empty carrier name rejected');
// 6. getById
const fetched = await store.carrier.getById(googleAuthId, c2.id);
assert.deepEqual(fetched, c2);
const notFound = await store.carrier.getById(googleAuthId, 9999);
assert.equal(notFound, undefined);
console.log('PASS: getById fetched carrier correctly');
// 7. list
const list = await store.carrier.list(googleAuthId);
assert.equal(list.length, 3);
assert.equal(list[0].name, 'DHL');
assert.equal(list[1].name, 'UPS');
assert.equal(list[2].name, 'DPD');
console.log('PASS: list returned sorted carriers');
// 8. update
const updated = await store.carrier.update(googleAuthId, c1.id, {
name: 'DHL Express',
hasPackages: true,
isReverse: false,
});
assert.equal(updated.name, 'DHL Express');
assert.equal(updated.hasPackages, true);
console.log('PASS: update modified carrier');
// 9. reorder
const ordered = await store.carrier.order(googleAuthId, {
carrierIds: [c3.id, c2.id, c1.id],
});
assert.deepEqual(ordered.carrierIds, [c3.id, c2.id, c1.id]);
const listAfterOrder = await store.carrier.list(googleAuthId);
assert.equal(listAfterOrder[0].id, c3.id);
assert.equal(listAfterOrder[0].sort, 0);
assert.equal(listAfterOrder[1].id, c2.id);
assert.equal(listAfterOrder[1].sort, 1);
assert.equal(listAfterOrder[2].id, c1.id);
assert.equal(listAfterOrder[2].sort, 2);
console.log('PASS: order successfully reordered carriers');
// 10. delete
await store.carrier.delete(googleAuthId, c3.id);
const listAfterDelete = await store.carrier.list(googleAuthId);
assert.equal(listAfterDelete.length, 2);
console.log('PASS: delete removed carrier');
await assert.rejects(
() => store.carrier.delete(googleAuthId, c3.id),
{ message: 'Carrier not found' },
);
console.log('PASS: delete non-existent carrier rejected');
console.log('--- ALL CARRIER TESTS PASSED! ---');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,219 @@
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 category recursive deletion ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-cat-del-rec-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Recursive Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const rootId = company.rootCategoryId;
// 1. Refuse to delete root category, even with recursive: true
console.log('1. Testing refusal to delete root category...');
await assert.rejects(
() => store.category.delete({ googleAuthId, id: rootId, recursive: true }),
/Cannot delete root category/
);
// 2. Build category tree:
// Root
// ├── OutsideCat (for preserved articles)
// └── MainCat (to be recursively deleted)
// ├── SubCat1
// │ └── SubSubCat1
// └── SubCat2
console.log('2. Building category tree...');
const outsideCat = await store.category.createUnderParent({
googleAuthId,
parentId: rootId,
name: 'Outside Category',
});
const mainCat = await store.category.createUnderParent({
googleAuthId,
parentId: rootId,
name: 'Main Category',
});
const subCat1 = await store.category.createUnderParent({
googleAuthId,
parentId: mainCat.id,
name: 'Sub Category 1',
});
const subSubCat1 = await store.category.createUnderParent({
googleAuthId,
parentId: subCat1.id,
name: 'Sub Sub Category 1',
});
const subCat2 = await store.category.createUnderParent({
googleAuthId,
parentId: mainCat.id,
name: 'Sub Category 2',
});
// 3. Test non-recursive delete guards
console.log('3. Testing non-recursive delete guards...');
await assert.rejects(
() => store.category.delete({ googleAuthId, id: mainCat.id, recursive: false }),
/Category has subcategories/
);
// 4. Create articles:
// Article 1: Only in SubSubCat1 -> Should be deleted
// Article 2: In SubCat1 AND OutsideCat -> Should survive, removed from SubCat1
// Article 3: Parent in SubCat2 with a variant child -> Both should be deleted
// Article 4: Outside article in OutsideCat -> Untouched
console.log('4. Creating articles in categories...');
const artOnlySub = await store.article.createUnderParent({
googleAuthId,
parentId: subSubCat1.id,
name: 'Only SubSubCat Article',
});
const artMulti = await store.article.createUnderParent({
googleAuthId,
parentId: subCat1.id,
name: 'Multi Category Article',
});
// Add outsideCat to artMulti
await store.article.setCategoryIdsBySku(googleAuthId, artMulti.sku, [subCat1.id, outsideCat.id]);
const attrName = await store.attributename.create(googleAuthId, { name: 'Size' });
const artParent = await store.article.createUnderParent({
googleAuthId,
parentId: subCat2.id,
name: 'Parent Article In Sub2',
});
await store.article.toggleParent(googleAuthId, artParent.sku, {
isParent: true,
attributeNameId: attrName.id,
});
const artChild = await store.article.addChild(googleAuthId, artParent.sku, {
value: 'M',
});
const artOutside = await store.article.createUnderParent({
googleAuthId,
parentId: outsideCat.id,
name: 'Purely Outside Article',
});
// 5. Test POS terminal assignment unlinking on recursive delete
console.log('5. Setting up POS terminal assignment...');
const posTerminal = await store.posterminal.upsert(googleAuthId, {
name: 'POS Register 1',
pairingpin: '1234',
});
await store.posterminal.pair(posTerminal.id, {
token: 'pos-token-1',
clientserial: 'SN1',
pairedAt: new Date().toISOString(),
});
await store.posterminal.setOn(googleAuthId, posTerminal.id, subCat2.id);
await store.posterminal.setOn(googleAuthId, posTerminal.id, outsideCat.id);
const posCatsBefore = provider.db.prepare(
'SELECT category_id FROM posterminalcategory WHERE posterminal_id = ?'
).all(posTerminal.id).map(r => r.category_id);
assert.ok(posCatsBefore.includes(subCat2.id));
assert.ok(posCatsBefore.includes(outsideCat.id));
// 6. Execute recursive deletion of mainCat
console.log('6. Executing recursive category delete on mainCat...');
await store.category.delete({
googleAuthId,
id: mainCat.id,
recursive: true,
});
// 7. Verify category tree state
console.log('7. Verifying category tree...');
const catsRemaining = provider.db.prepare('SELECT id FROM category').all().map(r => r.id);
assert.ok(catsRemaining.includes(rootId));
assert.ok(catsRemaining.includes(outsideCat.id));
assert.ok(!catsRemaining.includes(mainCat.id));
assert.ok(!catsRemaining.includes(subCat1.id));
assert.ok(!catsRemaining.includes(subSubCat1.id));
assert.ok(!catsRemaining.includes(subCat2.id));
// 8. Verify POS terminal assignment was unlinked for deleted category
console.log('8. Verifying POS terminal categories...');
const posCatsAfter = provider.db.prepare(
'SELECT category_id FROM posterminalcategory WHERE posterminal_id = ?'
).all(posTerminal.id).map(r => r.category_id);
assert.ok(!posCatsAfter.includes(subCat2.id));
assert.ok(posCatsAfter.includes(outsideCat.id));
// 9. Verify article states:
console.log('9. Verifying article states...');
// artOnlySub: deleted
await assert.rejects(
() => store.article.getBySku(googleAuthId, artOnlySub.sku),
/Article not found/
);
// obituary for artOnlySub exists
const t1 = provider.db.prepare("SELECT * FROM posterminalobituary WHERE posterminal_id = ? AND entity_type = 'article' AND entity_id = ?").all(posTerminal.id, artOnlySub.id);
assert.equal(t1.length, 1);
// artParent and its child artChild: both deleted
await assert.rejects(
() => store.article.getBySku(googleAuthId, artParent.sku),
/Article not found/
);
await assert.rejects(
() => store.article.getBySku(googleAuthId, artChild.article.sku),
/Article not found/
);
const tParent = provider.db.prepare("SELECT * FROM posterminalobituary WHERE posterminal_id = ? AND entity_type = 'article' AND entity_id = ?").all(posTerminal.id, artParent.id);
const tChild = provider.db.prepare("SELECT * FROM posterminalobituary WHERE posterminal_id = ? AND entity_type = 'article' AND entity_id = ?").all(posTerminal.id, artChild.article.id);
assert.equal(tParent.length, 1);
assert.equal(tChild.length, 1);
// artMulti: continues to exist!
const artMultiDetail = await store.article.getBySku(googleAuthId, artMulti.sku);
assert.equal(artMultiDetail.article.sku, artMulti.sku);
// artMulti categoryarticle now ONLY contains outsideCat
const artMultiCats = provider.db.prepare(
'SELECT category_id FROM categoryarticle WHERE article_id = ?'
).all(artMultiDetail.article.id).map(r => r.category_id);
assert.deepEqual(artMultiCats, [outsideCat.id]);
// artOutside: completely untouched
const artOutsideDetail = await store.article.getBySku(googleAuthId, artOutside.sku);
assert.equal(artOutsideDetail.article.sku, artOutside.sku);
console.log('✅ ALL RECURSIVE CATEGORY DELETE TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,175 @@
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 CategoryProperty & CategoryAttributeValue ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-cat-prop-attr-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Create a subcategory under root
const category = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Test Category',
});
assert.ok(category.id > 0);
// 1. Test CategoryProperty
console.log('1. Testing categoryproperty CRUD...');
const prop1 = await store.propertyname.create(googleAuthId, {
name: 'Material Care',
exclusive: true,
});
const prop2 = await store.propertyname.create(googleAuthId, {
name: 'Season',
exclusive: false,
});
// Set properties
const setProps = await store.categoryproperty.setByCategoryId(googleAuthId, category.id, [
{ propertyId: prop1.id, value: 'Dry Clean Only', sort: 10 },
{ propertyId: prop2.id, value: 'Summer 2026', sort: 20 },
]);
assert.equal(setProps.length, 2);
// List properties
const listedProps = await store.categoryproperty.listByCategoryId(googleAuthId, category.id);
assert.equal(listedProps.length, 2);
assert.equal(listedProps[0].name, 'Material Care');
assert.equal(listedProps[0].value, 'Dry Clean Only');
assert.equal(listedProps[0].exclusive, true);
assert.equal(listedProps[1].name, 'Season');
assert.equal(listedProps[1].value, 'Summer 2026');
assert.equal(listedProps[1].exclusive, false);
// Test exclusive violation on categoryproperty
await assert.rejects(
async () => {
await store.categoryproperty.setByCategoryId(googleAuthId, category.id, [
{ propertyId: prop1.id, value: 'Val 1' },
{ propertyId: prop1.id, value: 'Val 2' },
]);
},
/exclusive/i,
'Should reject multiple values for exclusive property'
);
// Delete single property
await store.categoryproperty.delete(googleAuthId, category.id, prop1.id);
const afterDeleteProps = await store.categoryproperty.listByCategoryId(googleAuthId, category.id);
assert.equal(afterDeleteProps.length, 1);
assert.equal(afterDeleteProps[0].propertyId, prop2.id);
console.log('CategoryProperty tests passed!');
// 2. Test CategoryAttributeValue
console.log('2. Testing categoryattributevalue CRUD...');
const attrExclusive = await store.attributename.create(googleAuthId, {
name: 'Department',
exclusive: true,
});
const valDeptMen = await store.attributevalue.create(googleAuthId, attrExclusive.id, {
value: 'Men',
});
const valDeptWomen = await store.attributevalue.create(googleAuthId, attrExclusive.id, {
value: 'Women',
});
const attrMulti = await store.attributename.create(googleAuthId, {
name: 'Tags',
exclusive: false,
});
const valTagSale = await store.attributevalue.create(googleAuthId, attrMulti.id, {
value: 'Sale',
});
const valTagNew = await store.attributevalue.create(googleAuthId, attrMulti.id, {
value: 'New',
});
// Bulk set with numeric array
const setAttrValues = await store.categoryattributevalue.setByCategoryId(googleAuthId, category.id, [
valDeptMen.id,
valTagSale.id,
valTagNew.id,
]);
assert.equal(setAttrValues.length, 3);
// List attribute values
const listedAttrValues = await store.categoryattributevalue.listByCategoryId(googleAuthId, category.id);
assert.equal(listedAttrValues.length, 3);
const deptMatch = listedAttrValues.find((v) => v.attributeName === 'Department');
assert.ok(deptMatch);
assert.equal(deptMatch.value, 'Men');
assert.equal(deptMatch.exclusive, true);
// Test exclusive violation
await assert.rejects(
async () => {
await store.categoryattributevalue.setByCategoryId(googleAuthId, category.id, [
valDeptMen.id,
valDeptWomen.id,
]);
},
/exclusive/i,
'Should reject multiple values for exclusive attribute'
);
// Test flexible object shapes in setByCategoryId
const setWithObjs = await store.categoryattributevalue.setByCategoryId(googleAuthId, category.id, [
{ attributeValueId: valDeptWomen.id, sort: 5 },
{ id: valTagSale.id, sort: 15 },
]);
assert.equal(setWithObjs.length, 2);
const listedAfterObjSet = await store.categoryattributevalue.listByCategoryId(googleAuthId, category.id);
assert.equal(listedAfterObjSet.length, 2);
assert.equal(listedAfterObjSet[0].value, 'Women');
assert.equal(listedAfterObjSet[0].sort, 5);
// 3. Test cascade deletion on category delete
console.log('3. Testing cascade deletion when category is deleted...');
await store.category.delete({ googleAuthId, id: category.id });
// Directly query database to ensure categoryproperty and categoryattributevalue rows were deleted by FK cascade
const remainingProps = await provider.query('SELECT * FROM categoryproperty WHERE category_id = @id', { id: category.id });
assert.equal(remainingProps.rows.length, 0);
const remainingAttrValues = await provider.query('SELECT * FROM categoryattributevalue WHERE category_id = @id', { id: category.id });
assert.equal(remainingAttrValues.rows.length, 0);
console.log('All CategoryProperty and CategoryAttributeValue 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

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
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 open(tempDir) {
const provider = new BetterSqlite3Provider({ filename: join(tempDir, 'test.db'), storageDir: join(tempDir, 'storage') });
await provider.initialize();
return { provider, store: await new LibDashStore(provider) };
}
const tempDir = mkdtempSync(join(tmpdir(), 'test-cu-roles-'));
try {
console.log('--- Testing companyuser roles + autoinvite ---');
let { provider, store } = await open(tempDir);
const adminId = await store.google_auth.add('admin@example.com');
const userId = await store.google_auth.add('user@example.com');
const company = await store.company.create({ name: 'Roles Corp', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId: adminId });
await store.google_auth.setSelectedCompany(adminId, company.id);
// creator is admin
let members = await store.companyuser.list(adminId);
assert.deepEqual(members.map((m) => [m.email, m.role]), [['admin@example.com', 'admin']]);
assert.equal((await store.company.list(adminId))[0].role, 'admin');
// non-member cannot select the company
await assert.rejects(store.google_auth.setSelectedCompany(userId, company.id), /Not authorized/);
// admin adds existing user
await assert.rejects(store.companyuser.addByEmail(adminId, { email: 'nobody@example.com' }), /User not found/);
await assert.rejects(store.companyuser.addByEmail(adminId, { email: 'user@example.com', role: 'root' }), /Invalid role/);
const added = await store.companyuser.addByEmail(adminId, { email: 'User@Example.com' });
assert.equal(added.role, 'user');
await assert.rejects(store.companyuser.addByEmail(adminId, { email: 'user@example.com' }), /already in company/);
// user can now select it, but is not admin
await store.google_auth.setSelectedCompany(userId, company.id);
await assert.rejects(store.companyuser.list(userId), /Not authorized/);
await assert.rejects(store.companyuser.addByEmail(userId, { email: 'admin@example.com' }), /Not authorized/);
await assert.rejects(store.companyuser.setRole(userId, added.id, 'admin'), /Not authorized/);
await assert.rejects(store.companyuser.remove(userId, added.id), /Not authorized/);
await assert.rejects(store.autoinvite.add(userId, { email: 'x@example.com' }), /Not authorized/);
// last admin guard
const adminCu = members[0].id;
await assert.rejects(store.companyuser.setRole(adminId, adminCu, 'user'), /last admin/);
await assert.rejects(store.companyuser.remove(adminId, adminCu), /last admin/);
await store.companyuser.setRole(adminId, added.id, 'admin');
await store.companyuser.setRole(adminId, adminCu, 'user');
await store.companyuser.setRole(userId, adminCu, 'admin');
await store.companyuser.setRole(userId, added.id, 'user');
await assert.rejects(store.companyuser.setRole(adminId, 99999, 'user'), /Company user not found/);
// autoinvite
await assert.rejects(store.autoinvite.add(adminId, { email: 'user@example.com' }), /already registered/);
await assert.rejects(store.autoinvite.add(adminId, { email: 'bad' }), /Invalid email/);
await store.autoinvite.add(adminId, { email: 'New@Example.com', role: 'admin' });
await assert.rejects(store.autoinvite.add(adminId, { email: 'new@example.com' }), /already exists/);
await store.autoinvite.add(adminId, { email: 'gone@example.com' });
assert.equal((await store.autoinvite.list(adminId)).length, 2);
await store.autoinvite.remove(adminId, (await store.autoinvite.list(adminId)).find((i) => i.email === 'gone@example.com').id);
await assert.rejects(store.autoinvite.remove(adminId, 99999), /Invitation not found/);
const newId = await store.google_auth.add('new@example.com');
assert.deepEqual(await store.autoinvite.list(adminId), []);
await store.google_auth.setSelectedCompany(newId, company.id);
members = await store.companyuser.list(adminId);
assert.equal(members.find((m) => m.email === 'new@example.com').role, 'admin');
// remove clears selection
const userCu = members.find((m) => m.email === 'user@example.com');
await store.companyuser.remove(adminId, userCu.id);
assert.equal((await store.google_auth.getById(userId)).selectedCompanyId, null);
// downgrade → upgrade through the migration engine
await provider.close?.();
const env = { ...process.env, DB_PROVIDER: 'sqlite', SQLITE_PATH: join(tempDir, 'test.db') };
execFileSync('node', ['scripts/downgrade.js', '5'], { env, stdio: 'inherit' });
({ provider, store } = await open(tempDir).catch((e) => { throw e; }));
// opening upgrades to latest again; roles reset to admin for all members
const rows = (await provider.query('SELECT role FROM companyuser')).rows;
assert.ok(rows.length > 0 && rows.every((r) => r.role === 'admin'));
assert.deepEqual((await provider.query('SELECT * FROM autoinvite')).rows, []);
await provider.close?.();
console.log('OK');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}

View File

@@ -0,0 +1,145 @@
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 Delivery Link Check & Sync in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-delivery-link-check-'));
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('link-check-test@example.com');
const company = await store.company.create({
name: 'Link Check Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
const supplier = await store.supplier.create(googleAuthId, {
name: 'eks',
});
const art = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Club Mate 0,5l',
track_stock: 1,
});
// Assign supplier with default pu=1, without EK
await store.articlesupplier.create(googleAuthId, art.sku, {
entityId: supplier.id,
sku: 'mate',
pu: 1,
});
let asup = await store.articlesupplier.listByArticleSku(googleAuthId, art.sku);
assert.equal(asup.length, 1);
assert.equal(asup[0].pu, 1);
assert.equal(asup[0].price, null);
// Create delivery with manifest line: sku: 'mate', pu: 20, net: 12
const delivery = await store.delivery.create(googleAuthId, {
date: '2026-10-25T10:00:00Z',
externalNumber: 'LS-000001',
supplierId: supplier.id,
lines: [
{ sku: 'mate', name: 'Club Mate 0,5l', pu: 20, amount: 2, net: 12, taxrate: 19 },
],
});
assert(delivery.id > 0);
assert.equal(delivery.lines[0].pu, 20);
// Verify articlesupplier link was updated!
asup = await store.articlesupplier.listByArticleSku(googleAuthId, art.sku);
assert.equal(asup.length, 1);
assert.equal(asup[0].pu, 20, 'VPE in articlesupplier must be synced to 20');
assert.equal(asup[0].price.net, 12, 'Net price in articlesupplier must be synced to 12');
assert.equal(asup[0].price.taxRate, 19, 'Tax rate in articlesupplier must be synced to 19');
// Verify article history
const history = await store.article.getHistoryBySku(googleAuthId, art.sku);
const historyTexts = (Array.isArray(history) ? history : history.history).map((h) => h.text);
console.log('Article history entries:', historyTexts);
assert(
historyTexts.some((t) => t.includes('Lieferant "eks" aktualisiert (VPE: 20, EK: € 12,00, MwSt.: 19%)')),
'Must record history entry for supplier update with VPE, EK, and MwSt'
);
// Now update delivery with new line pu: 24, net: 11.50, and taxrate: 7
await store.delivery.update(googleAuthId, delivery.id, {
lines: [
{ sku: 'mate', name: 'Club Mate 0,5l', pu: 24, amount: 3, net: 11.5, taxrate: 7 },
],
});
asup = await store.articlesupplier.listByArticleSku(googleAuthId, art.sku);
assert.equal(asup[0].pu, 24, 'VPE in articlesupplier must be synced to 24 on update');
assert.equal(asup[0].price.net, 11.5, 'Net price in articlesupplier must be synced to 11.5 on update');
assert.equal(asup[0].price.taxRate, 7, 'Tax rate in articlesupplier must be synced to 7 on update');
const historyAfterUpdate = await store.article.getHistoryBySku(googleAuthId, art.sku);
const historyAfterTexts = (Array.isArray(historyAfterUpdate) ? historyAfterUpdate : historyAfterUpdate.history).map((h) => h.text);
console.log('Article history entries after delivery update:', historyAfterTexts);
assert(
historyAfterTexts.some((t) => t.includes('Lieferant "eks" aktualisiert (VPE: 24, EK: € 11,50, MwSt.: 7%)')),
'Must record history entry for supplier update on delivery update'
);
// Now test booking the delivery with amount: 3, pu: 24
// Should add 3 * 24 = 72 to stock and record clear history!
await store.delivery.book(googleAuthId, delivery.id);
const artDetail = await store.article.getBySku(googleAuthId, art.sku);
const totalStock = (artDetail.article.warehouses || []).reduce((acc, w) => acc + (w.stock || 0), 0);
console.log('Article stock after booking delivery with amount 3, pu 24:', totalStock);
assert.equal(totalStock, 72, 'Physical warehouse stock must be amount * pu (3 * 24 = 72)');
const historyAfterBooking = await store.article.getHistoryBySku(googleAuthId, art.sku);
const bookingTexts = (Array.isArray(historyAfterBooking) ? historyAfterBooking : historyAfterBooking.history).map((h) => h.text);
console.log('Article history entries after delivery booking:', bookingTexts);
assert(
bookingTexts.some((t) => t.includes('Lager: "Hauptlager"') && t.includes('Menge: 72 (3 x VPE 24)')),
'Warehouse history must clearly show Menge: 72 (3 x VPE 24)'
);
assert(
bookingTexts.some((t) => t.includes('Lieferant: "eks"') && t.includes('Menge: 3, VPE: 24')),
'Acceptance history must clearly show Menge: 3, VPE: 24'
);
console.log('✓ Delivery link check and sync passed successfully!');
} finally {
try {
provider.close();
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,231 @@
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 Delivery Summary, Ledger Sync & Filtering in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-delivery-summary-'));
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('delivery-summary-test@example.com');
const company = await store.company.create({
name: 'Delivery Summary 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 supplierAlpha = await store.supplier.create(googleAuthId, {
name: 'Alpha Supplier GmbH',
});
const supplierBeta = await store.supplier.create(googleAuthId, {
name: 'Beta Logistics Inc',
});
// 1. Create articles:
// Art1: track_stock = 1 (Stock item)
const artStock1 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Stock Item 1',
track_stock: 1,
});
// Art2: track_stock = 1 (Stock item)
const artStock2 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Stock Item 2',
track_stock: 1,
});
// Art3: track_stock = 0 (Service / Non-stock item)
const artService = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Shipping Freight Fee',
track_stock: 0,
});
// 2. Create Delivery 1:
// Has 2 lines: artStock1 (qty 10), artService (qty 1)
const delivery1 = await store.delivery.create(googleAuthId, {
date: '2026-08-01T10:00:00Z',
externalNumber: 'DEL-ALPHA-1001',
supplierId: supplierAlpha.id,
lines: [
{ sku: artStock1.sku, name: 'Stock Item 1', amount: 10, net: 50, taxrate: 19 },
{ sku: artService.sku, name: 'Shipping Freight Fee', amount: 1, net: 10, taxrate: 19 },
],
});
console.log('Delivery 1 created with id:', delivery1.id);
// Verify deliverysummary row for delivery 1:
// positions = 2, total_quantity = 11, stock_positions = 1, booked_positions = 0, unbooked_positions = 1, is_booked = 0
let sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert(sum1, 'deliverysummary row for delivery 1 must exist');
assert.equal(sum1.positions, 2, 'Total positions should be 2');
assert.equal(sum1.stock_positions, 1, 'Stock positions should be 1');
assert.equal(sum1.booked_positions, 0, 'Booked positions should initially be 0');
assert.equal(sum1.unbooked_positions, 1, 'Unbooked positions should initially be 1');
assert.equal(sum1.stock_quantity, 10, 'Stock quantity should be 10');
assert.equal(sum1.booked_quantity, 0, 'Booked quantity should initially be 0');
assert.equal(sum1.unbooked_quantity, 10, 'Unbooked quantity should initially be 10');
assert.equal(sum1.is_booked, 0, 'is_booked should be 0');
assert.equal(sum1.supplier_name, 'Alpha Supplier GmbH');
// 3. Create Delivery 2:
// Has 1 line: artStock2 (qty 5), Supplier: Beta
const delivery2 = await store.delivery.create(googleAuthId, {
date: '2026-10-01T10:00:00Z',
externalNumber: 'DEL-BETA-2002',
supplierId: supplierBeta.id,
lines: [
{ sku: artStock2.sku, name: 'Stock Item 2', amount: 5, net: 20, taxrate: 19 },
],
});
let sum2 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery2.id);
assert(sum2, 'deliverysummary row for delivery 2 must exist');
assert.equal(sum2.stock_positions, 1);
assert.equal(sum2.unbooked_positions, 1);
assert.equal(sum2.is_booked, 0);
// 4. Book Delivery 1 partially:
// Book 4 units of artStock1 in warehouse
const lineStock1 = delivery1.lines.find((l) => l.sku === artStock1.sku);
assert(lineStock1, 'lineStock1 must exist');
await store.ledger.add(googleAuthId, {
manifestarticle_id: lineStock1.id,
warehouse_id: warehouse.id,
transaction_type: 'DELIVERY',
amount: 4,
transaction_text: 'Partial goods receipt',
});
sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(sum1.booked_positions, 0, 'Booked positions should still be 0 since 4 < 10');
assert.equal(sum1.unbooked_positions, 1);
assert.equal(sum1.booked_quantity, 4);
assert.equal(sum1.unbooked_quantity, 6);
assert.equal(sum1.is_booked, 0);
console.log('✓ Partial booking properly updated deliverysummary');
// 5. Book remaining 6 units of artStock1
await store.ledger.add(googleAuthId, {
manifestarticle_id: lineStock1.id,
warehouse_id: warehouse.id,
transaction_type: 'DELIVERY',
amount: 6,
transaction_text: 'Final goods receipt',
});
sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(sum1.booked_positions, 1, 'Booked positions should now be 1');
assert.equal(sum1.unbooked_positions, 0, 'Unbooked positions should now be 0');
assert.equal(sum1.booked_quantity, 10);
assert.equal(sum1.unbooked_quantity, 0);
assert.equal(sum1.is_booked, 1, 'Delivery 1 should now be fully booked (is_booked = 1)');
console.log('✓ Full booking properly updated deliverysummary (is_booked = 1)');
// 6. Test windowed / paged list:
// Both deliveries present
const pagedAll = await store.delivery.list(googleAuthId, { offset: 0, limit: 10 });
assert.equal(pagedAll.total, 2);
assert.equal(pagedAll.unfilteredTotal, 2);
assert.equal(pagedAll.deliveries.length, 2);
assert.equal(pagedAll.offset, 0);
assert.equal(pagedAll.limit, 10);
// Filter onlyUnbooked (Delivery 1 is fully booked, Delivery 2 is unbooked)
const pagedUnbooked = await store.delivery.list(googleAuthId, { onlyUnbooked: true });
assert.equal(pagedUnbooked.total, 1, 'Only 1 delivery is unbooked');
assert.equal(pagedUnbooked.unfilteredTotal, 2, 'Unfiltered total remains 2');
assert.equal(pagedUnbooked.deliveries[0].id, delivery2.id);
console.log('✓ onlyUnbooked filter returned only unbooked deliveries');
// Search filter: 'Beta'
const searchBeta = await store.delivery.list(googleAuthId, { search: 'Beta' });
assert.equal(searchBeta.total, 1);
assert.equal(searchBeta.unfilteredTotal, 2);
assert.equal(searchBeta.deliveries[0].id, delivery2.id);
// Search filter: 'ALPHA-1001'
const searchNum = await store.delivery.list(googleAuthId, { search: 'ALPHA-1001' });
assert.equal(searchNum.total, 1);
assert.equal(searchNum.deliveries[0].id, delivery1.id);
// Test unpaged backwards compatibility
const unpaged = await store.delivery.list(googleAuthId);
assert(Array.isArray(unpaged), 'Unpaged list should return Array');
assert.equal(unpaged.length, 2);
const d1 = unpaged.find((d) => d.id === delivery1.id);
assert(d1.ledger, 'd1 should contain ledger summary object');
assert.equal(d1.ledger.totalPositions, 1);
assert.equal(d1.ledger.bookedPositions, 1);
assert.equal(d1.ledger.unbookedPositions, 0);
assert.equal(d1.ledger.isBooked, true);
const d2 = unpaged.find((d) => d.id === delivery2.id);
assert.equal(d2.ledger.unbookedPositions, 1);
assert.equal(d2.ledger.isBooked, false);
console.log('✓ Unpaged list returns enriched deliveries with ledger summary');
// 7. Test rebuildSummaries
provider.db.prepare('DELETE FROM deliverysummary').run();
const countEmpty = provider.db.prepare('SELECT COUNT(*) AS cnt FROM deliverysummary').get().cnt;
assert.equal(countEmpty, 0);
await store.delivery.rebuildSummaries(googleAuthId);
const countRebuilt = provider.db.prepare('SELECT COUNT(*) AS cnt FROM deliverysummary').get().cnt;
assert.equal(countRebuilt, 2, 'Rebuilt deliverysummary should have 2 rows');
const rebuiltD1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(rebuiltD1.is_booked, 1);
assert.equal(rebuiltD1.booked_quantity, 10);
assert.equal(rebuiltD1.unbooked_quantity, 0);
const rebuiltD2 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery2.id);
assert.equal(rebuiltD2.is_booked, 0);
assert.equal(rebuiltD2.unbooked_positions, 1);
console.log('✓ rebuildSummaries restored all deliverysummary rows accurately');
console.log('--- ALL DELIVERY SUMMARY & FILTER TESTS PASSED ---');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,45 @@
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 healthcheck store method on in-memory SQLite...');
const provider = new BetterSqlite3Provider({ path: ':memory:' });
await provider.initialize();
const store = await new LibDashStore(provider);
assert(store.healthcheck, 'store.healthcheck should exist');
assert.strictEqual(typeof store.healthcheck.check, 'function', 'store.healthcheck.check should be a function');
// First call: table is empty, should ensure row (0), select for update, increment to 1 and update
const res1 = await store.healthcheck.check();
console.log('Healthcheck call 1 result:', res1);
assert.strictEqual(res1.ok, true, 'res1.ok should be true');
assert.strictEqual(res1.sequence, 1, 'res1.sequence should be 1');
// Second call: row exists with 1, should increment to 2 and update
const res2 = await store.healthcheck.check();
console.log('Healthcheck call 2 result:', res2);
assert.strictEqual(res2.ok, true, 'res2.ok should be true');
assert.strictEqual(res2.sequence, 2, 'res2.sequence should be 2');
// Third call: row exists with 2, should increment to 3 and update
const res3 = await store.healthcheck.check();
console.log('Healthcheck call 3 result:', res3);
assert.strictEqual(res3.ok, true, 'res3.ok should be true');
assert.strictEqual(res3.sequence, 3, 'res3.sequence should be 3');
// Verify DB state directly
const rows = await provider.query('SELECT * FROM healthcheck');
console.log('Healthcheck DB rows:', rows.rows);
assert.strictEqual(rows.rows.length, 1, 'healthcheck should have exactly 1 row');
assert.strictEqual(Number(rows.rows[0].sequence), 3, 'sequence in DB should be 3');
await provider.close();
console.log('✓ All healthcheck store tests passed successfully!');
}
main().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,121 @@
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 Parent History & AttributeValue Shapes ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-hist-attr-'));
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('tester@example.com');
const company = await store.company.create({
name: 'Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
console.log('1. Testing setParentBySku history logging...');
const parent = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Parent Shirt',
});
const child = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Child Shirt Red',
});
// Check history before linking
let parentHist = await store.article.getHistoryBySku(googleAuthId, parent.sku);
assert.equal(parentHist.length, 1);
assert.match(parentHist[0].text, /angelegt/);
// Link child to parent
await store.article.setParentBySku(googleAuthId, child.sku, parent.sku);
// Parent history must now contain the conversion entry and the child entry!
parentHist = await store.article.getHistoryBySku(googleAuthId, parent.sku);
const hasConversion = parentHist.some((h) => h.text.includes('Artikel zu Vaterartikel konvertiert'));
const hasVariantAdded = parentHist.some((h) => h.text.includes('hinzugefügt'));
assert.ok(hasConversion, `Parent history missing conversion row: ${JSON.stringify(parentHist)}`);
assert.ok(hasVariantAdded, `Parent history missing variant added row: ${JSON.stringify(parentHist)}`);
// Child history must contain the assignment entry
const childHist = await store.article.getHistoryBySku(googleAuthId, child.sku);
const hasAssigned = childHist.some((h) => h.text.includes('als Variante von'));
assert.ok(hasAssigned, `Child history missing variant assigned row: ${JSON.stringify(childHist)}`);
console.log('2. Testing importRows Pass 2 history logging...');
const importRes = await store.article.importRows({
googleAuthId,
rows: [
{ sku: 'IMP-PARENT', name: 'Imported Parent', parentId: company.rootCategoryId },
{ sku: 'IMP-CHILD', name: 'Imported Child', parentId: company.rootCategoryId, parentSku: 'IMP-PARENT' },
],
});
assert.equal(importRes.errors.length, 0);
const impParentHist = await store.article.getHistoryBySku(googleAuthId, 'IMP-PARENT');
const impHasConversion = impParentHist.some((h) => h.text.includes('Artikel zu Vaterartikel konvertiert'));
assert.ok(impHasConversion, `Imported parent missing conversion row: ${JSON.stringify(impParentHist)}`);
console.log('3. Testing flexible attributevalue request shapes in setByArticleSku...');
const attrName = await store.attributename.create(googleAuthId, { name: 'Farbe' });
const val1 = await store.attributevalue.create(googleAuthId, attrName.id, { value: 'Rot' });
const val2 = await store.attributevalue.create(googleAuthId, attrName.id, { value: 'Blau' });
// Test A: Array of IDs [val1.id, val2.id]
let res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [val1.id]);
assert.equal(res.length, 1);
assert.equal(res[0].attributeValueId, val1.id);
// Test B: Array of objects with .id [{ id: val1.id }]
res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ id: val2.id }]);
assert.equal(res.length, 1);
assert.equal(res[0].attributeValueId, val2.id);
// Test C: Array of objects with .attributeValueId
res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ attributeValueId: val1.id }]);
assert.equal(res.length, 1);
assert.equal(res[0].attributeValueId, val1.id);
// Test D: Array of objects with attributeId and value
res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ attributeId: attrName.id, value: 'Blau' }]);
assert.equal(res.length, 1);
assert.equal(res[0].attributeValueId, val2.id);
// Test E: Array of objects with attributeName and value
res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ attributeName: 'Farbe', value: 'Rot' }]);
assert.equal(res.length, 1);
assert.equal(res[0].attributeValueId, val1.id);
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);
});

485
scripts/test-ledger.js Normal file
View File

@@ -0,0 +1,485 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js';
async function run() {
const tempDir = mkdtempSync(join(tmpdir(), 'dash-ledger-test-'));
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 {
console.log('--- Testing store.ledger (acceptance, warehouse, shipment, guards) ---');
const googleAuthId = await store.google_auth.add('ledger-tester@example.com');
const company = await store.company.create({
name: 'Ledger Test GmbH',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const supplier = await store.supplier.create(googleAuthId, {
name: 'Supplier X',
});
// Create an article with track_stock = false initially
const article = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Ledger Linked Article',
track_stock: false,
});
assert.equal(article.track_stock, false, 'Newly created article must have track_stock disabled');
await store.articlesupplier.create(googleAuthId, article.sku, {
entityId: supplier.id,
sku: 'SKU-ABC',
pu: 1,
});
const customer1 = await store.entity.create(googleAuthId, {
name: 'Customer Quote Test',
isCustomer: true,
customergroupId: company.rootCategoryId,
});
// Create a quote and verify ledger bookings are rejected
const quote = await store.quote.create(googleAuthId, {
number: 'Q-1',
entityId: customer1.id,
date: new Date().toISOString(),
lines: [{ sku: 'SKU-ABC', name: 'Quote item', pu: 1, amount: 2, net: 10, taxrate: 19 }],
});
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: quote.lines[0].id,
transaction_type: 'ACCEPTANCE',
amount: 2,
transaction_text: 'Should fail for quote',
}),
/Cannot book ledger entries for quote/,
'Should reject booking ledger entries for quote manifest',
);
// Create an order and verify ledger bookings are rejected
const order = await store.order.create(googleAuthId, {
number: 'O-1',
entityId: customer1.id,
date: new Date().toISOString(),
lines: [{ sku: 'SKU-ABC', name: 'Order item', pu: 1, amount: 2, net: 10, taxrate: 19 }],
});
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: order.lines[0].id,
transaction_type: 'ACCEPTANCE',
amount: 2,
transaction_text: 'Should fail for order',
}),
/Cannot book ledger entries for order/,
'Should reject booking ledger entries for order manifest',
);
// 1. Create a delivery manifest with lines
const delivery = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
externalNumber: 'DEL-1',
date: new Date().toISOString(),
lines: [
{
sku: 'SKU-ABC',
name: 'Ledger Item 1',
pu: 1,
amount: 5,
net: 10,
rebate: 0,
taxrate: 19,
},
{
sku: 'SKU-ABC',
name: 'Ledger Item 2',
pu: 1,
amount: 10,
net: 20,
rebate: 0,
taxrate: 19,
},
],
});
const manifestarticleId1 = delivery.lines[0].id;
const manifestarticleId2 = delivery.lines[1].id;
// 2. Book line 1 for untracked article (acceptance only, warehouse_id is null)
console.log('1. Booking acceptance for untracked article line 1...');
const row1 = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(row1.id, 1);
assert.equal(row1.manifestarticle_id, manifestarticleId1);
assert.equal(row1.warehouse_id, null);
assert.equal(row1.transaction_type, 'ACCEPTANCE');
assert.equal(row1.amount, 5);
assert.equal(row1.hash_version, 1);
assert.equal(row1.prev_hash, INIT_HASH);
assert(row1.created_at, 'created_at should exist');
// Attempting to book line 1 again must be rejected
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: 'Double booking attempt',
}),
/Delivery line already booked on ledger/,
'Should reject booking an already booked delivery line',
);
// 3. Create warehouse and enable tracking on the article
console.log('2. Creating warehouse and enabling stock tracking...');
const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
await store.article.setTrackStockBySku(googleAuthId, article.sku, true);
// Even though tracking is now enabled, line 1 is already accepted so it cannot be booked again
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
warehouse_id: wh1.id,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: 'Late booking attempt after tracking enabled',
}),
/Delivery line already booked on ledger/,
'Cannot book a delivery line later once stock tracking is enabled if already accepted',
);
// 4. Book line 2 (now tracked): books ACCEPTANCE (row 2) + WAREHOUSE (row 3) in one transaction
console.log('3. Booking line 2 with tracking enabled (dual entries: ACCEPTANCE + WAREHOUSE)...');
const expectedHash2 = computePrevHash(row1);
const row2 = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId2,
warehouse_id: wh1.id,
transaction_type: 'ACCEPTANCE',
amount: 10,
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(row2.id, 2);
assert.equal(row2.manifestarticle_id, manifestarticleId2);
assert.equal(row2.warehouse_id, null);
assert.equal(row2.transaction_type, 'ACCEPTANCE');
assert.equal(row2.amount, 10);
assert.equal(row2.prev_hash, expectedHash2);
// Query ledger lines for manifestarticle 2 to verify row 3 (WAREHOUSE entry)
const line2Entries = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId2);
assert.equal(line2Entries.length, 2, 'Line 2 must have 2 ledger entries (ACCEPTANCE + WAREHOUSE)');
assert.equal(line2Entries[0].transaction_type, 'ACCEPTANCE');
assert.equal(line2Entries[0].warehouse_id, null);
assert.equal(line2Entries[1].transaction_type, 'WAREHOUSE');
assert.equal(line2Entries[1].warehouse_id, wh1.id);
assert.equal(line2Entries[1].amount, 10);
assert.equal(line2Entries[1].id, 3);
assert.equal(line2Entries[1].prev_hash, computePrevHash(line2Entries[0]));
// 5. Check warehouse stock for article
console.log('4. Checking warehouse stock calculation...');
const artDetail = await store.article.getBySku(googleAuthId, article.sku);
const whStock = artDetail.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(whStock, 10, 'Warehouse stock must be 10 (from line 2 WAREHOUSE ledger entry only)');
// 6. Check deliverysummary is_booked
const updatedDelivery = await store.delivery.getById(googleAuthId, delivery.id);
assert.equal(updatedDelivery.ledger.isBooked, true, 'Delivery should be fully booked');
assert.equal(updatedDelivery.ledger.completelyOnLedgerPositions, 2);
// 7. Check article history
console.log('5. Verifying article history...');
const history = await store.article.getHistoryBySku(googleAuthId, article.sku);
const historyTexts = history.map((h) => h.text);
console.log('History entries:', historyTexts);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')),
'History must include acceptance of line 1 with Lieferung number, supplier, EK brutto, amount',
);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')),
'History must include acceptance of line 2',
);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lager: "Hauptlager"') && t.includes('Menge: 10')),
'History must include warehouse entry for tracked line 2',
);
// 8. Test store.delivery.book on a fresh delivery
console.log('6. Testing store.delivery.book batch booking...');
const delivery2 = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
externalNumber: 'DEL-2',
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Batch Item 1', pu: 1, amount: 4, net: 15, taxrate: 19 },
],
});
const bookedDelivery2 = await store.delivery.book(googleAuthId, { id: delivery2.id, warehouseId: wh1.id });
assert.equal(bookedDelivery2.ledger.isBooked, true);
assert.equal(bookedDelivery2.ledger.completelyOnLedgerPositions, 1);
// Double booking delivery2 must reject
await assert.rejects(
async () => store.delivery.book(googleAuthId, { id: delivery2.id }),
/Delivery line(s)? already (accepted|booked) on ledger/,
'Should reject booking already booked delivery',
);
// 9. Test shipment booking
console.log('7. Testing shipment booking (SHIPMENT + WAREHOUSE dual entries)...');
const customer = await store.entity.create(googleAuthId, {
name: 'Customer Y',
isCustomer: true,
customergroupId: company.rootCategoryId,
});
const order2 = await store.order.create(googleAuthId, {
number: 'O-2',
entityId: customer.id,
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Ordered Item', pu: 1, amount: 3, net: 25, taxrate: 19 },
],
});
const testCarrier = await store.carrier.create(googleAuthId, { name: 'Test Carrier', hasPackages: true });
const testShipping = { carrierId: testCarrier.id, type: 'parcel', state: 'draft' };
const shipment = await store.shipment.create(googleAuthId, {
orderId: order2.id,
shipping: testShipping,
lines: [{ sku: 'SKU-ABC', amount: 3 }],
});
const shipmentLineId = shipment.lines[0].id;
const shipRow = await store.ledger.add(googleAuthId, {
manifestarticle_id: shipmentLineId,
warehouse_id: wh1.id,
transaction_type: 'SHIPMENT',
amount: 3,
transaction_text: `Lieferschein ${shipment.number}`,
});
assert.equal(shipRow.transaction_type, 'SHIPMENT');
assert.equal(shipRow.warehouse_id, null);
assert.equal(shipRow.amount, -3);
const shipEntries = await store.ledger.listByManifestarticleId(googleAuthId, shipmentLineId);
assert.equal(shipEntries.length, 2, 'Shipment line must have 2 entries (SHIPMENT + WAREHOUSE)');
assert.equal(shipEntries[0].transaction_type, 'SHIPMENT');
assert.equal(shipEntries[0].warehouse_id, null);
assert.equal(shipEntries[0].amount, -3);
assert.equal(shipEntries[1].transaction_type, 'WAREHOUSE');
assert.equal(shipEntries[1].warehouse_id, wh1.id);
assert.equal(shipEntries[1].amount, -3);
// Stock should now be: 10 (line 2) + 4 (delivery2) - 3 (shipment) = 11
const artDetailAfterShip = await store.article.getBySku(googleAuthId, article.sku);
const whStockAfterShip = artDetailAfterShip.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(whStockAfterShip, 11, 'Warehouse stock must be 11 (10 + 4 - 3)');
// Verify shipment history
const historyAfterShip = await store.article.getHistoryBySku(googleAuthId, article.sku);
const shipHistoryTexts = historyAfterShip.map((h) => h.text);
assert(
shipHistoryTexts.some((t) => t.includes(`Lieferschein ${shipment.number}`) && t.includes('Menge: 3')),
'Article history should record outgoing shipment',
);
// 8. Test store.stock.accept with deliverymanifestitem
console.log('8. Testing store.stock.accept (positive acceptance & negative return)...');
const delivery3 = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
externalNumber: 'DEL-3',
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Positive Delivery Line', pu: 1, amount: 6, net: 10, taxrate: 19 },
{ sku: 'SKU-ABC', name: 'Negative Delivery Line (Return)', pu: 1, amount: -2, net: 10, taxrate: 19 },
],
});
const posDeliveryLineId = delivery3.lines[0].id;
const negDeliveryLineId = delivery3.lines[1].id;
const acceptPosResult = await store.stock.accept(googleAuthId, {
manifestarticleId: posDeliveryLineId,
warehouseId: wh1.id,
});
assert.equal(acceptPosResult.document_ledger.transaction_type, 'ACCEPTANCE');
assert.equal(acceptPosResult.document_ledger.amount, 6);
assert.equal(acceptPosResult.document_ledger.warehouse_id, null);
assert.equal(acceptPosResult.adjust_ledger.amount, 6);
assert.equal(acceptPosResult.adjust_ledger.warehouse_id, wh1.id);
// Negative delivery line: return of a delivery (outgoing)
const acceptNegResult = await store.stock.accept(googleAuthId, {
manifestarticleId: negDeliveryLineId,
warehouseId: wh1.id,
});
assert.equal(acceptNegResult.document_ledger.transaction_type, 'RETURN');
assert.equal(acceptNegResult.document_ledger.amount, -2);
assert.equal(acceptNegResult.document_ledger.warehouse_id, null);
assert.equal(acceptNegResult.adjust_ledger.amount, -2);
assert.equal(acceptNegResult.adjust_ledger.warehouse_id, wh1.id);
// 9. Test store.stock.ship with shipmentmanifestitem
console.log('9. Testing store.stock.ship (positive shipment & rejection of negative line)...');
const order3 = await store.order.create(googleAuthId, {
number: 'O-3',
entityId: customer.id,
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Shipment Item Normal', pu: 1, amount: 5, net: 10, taxrate: 19 },
],
});
const shipment3 = await store.shipment.create(googleAuthId, {
orderId: order3.id,
shipping: testShipping,
lines: [
{ sku: 'SKU-ABC', amount: 4 },
{ sku: 'SKU-ABC', amount: -1 },
],
});
const posShipLineId = shipment3.lines[0].id;
const negShipLineId = shipment3.lines[1].id;
const shipPosResult = await store.stock.ship(googleAuthId, {
manifestarticleId: posShipLineId,
warehouseId: wh1.id,
});
assert.equal(shipPosResult.document_ledger.transaction_type, 'SHIPMENT');
assert.equal(shipPosResult.document_ledger.amount, -4);
assert.equal(shipPosResult.adjust_ledger.amount, -4);
// Negative shipment line: shipping is rejected
await assert.rejects(
store.stock.ship(googleAuthId, {
manifestarticleId: negShipLineId,
warehouseId: wh1.id,
}),
/Amount must be positive/,
);
// 10. Test store.stock.adjust
console.log('10. Testing store.stock.adjust failure on untracked article...');
const untrackedArt = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Pure Untracked Article',
track_stock: false,
});
await assert.rejects(
async () => store.stock.adjust(googleAuthId, {
sku: untrackedArt.sku,
warehouseId: wh1.id,
amount: 5,
}),
/Stock tracking is disabled for article/,
'Adjust must fail when article has no stock tracking',
);
// 10b. Test prevention of negative stock on shipment creation and stock adjust
console.log('10b. Testing prevention of negative stock on shipment creation and adjust...');
const guardOrder = await store.order.create(googleAuthId, {
number: 'ORD-GUARD-1',
date: '2026-10-20',
entityId: customer.id,
lines: [{ sku: article.sku, name: 'Order Line', pu: 1, amount: 9999, net: 10, taxrate: 19 }],
});
const currentArt = await store.article.getBySku(googleAuthId, article.sku);
const curStock = currentArt.article.warehouses.find((w) => w.id === wh1.id)?.stock ?? 0;
// Explicit shipment requesting more than available stock must be rejected
await assert.rejects(
async () => store.shipment.create(googleAuthId, {
orderId: guardOrder.id,
shipping: testShipping,
warehouseId: wh1.id,
lines: [{ sku: article.sku, amount: curStock + 10 }],
}),
/Insufficient stock for article/,
'Shipment creation must fail when requested amount exceeds warehouse stock',
);
// Manual stock adjust attempting to deduct more than current stock must be rejected
await assert.rejects(
async () => store.stock.adjust(googleAuthId, {
sku: article.sku,
warehouseId: wh1.id,
amount: -(curStock + 10),
}),
/Insufficient stock for article/,
'Stock adjust must fail when deduction exceeds available stock',
);
// 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized
console.log('11. Testing zero-adjustment before stock tracking is disabled...');
const artBeforeDisable = await store.article.getBySku(googleAuthId, article.sku);
const stockBeforeDisable = artBeforeDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock;
console.log('Current stock before disabling tracking:', stockBeforeDisable);
assert(stockBeforeDisable > 0, 'Article must have non-zero stock before disabling');
// Disable stock tracking
await store.article.setTrackStockBySku(googleAuthId, article.sku, false);
const artAfterDisable = await store.article.getBySku(googleAuthId, article.sku);
assert.equal(artAfterDisable.article.track_stock, false, 'Article track_stock must now be false');
const stockAfterDisable = artAfterDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(stockAfterDisable, 0, 'Warehouse stock must be adjusted to 0 on the ledger');
// Calling adjust now must fail
await assert.rejects(
async () => store.stock.adjust(googleAuthId, {
sku: article.sku,
warehouseId: wh1.id,
amount: 1,
}),
/Stock tracking is disabled for article/,
'Subsequent adjust must fail once tracking is disabled',
);
console.log('✓ All ledger tests passed successfully!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import 'dotenv/config';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { MssqlProvider } from '../src/providers/mssql/index.js';
async function testProvider(name, provider) {
console.log(`\n=== Testing ${name} ===`);
await provider.initialize();
const store = await new LibDashStore(provider);
// 1. Setup company and user
const email = `test-manifest-${Date.now()}@example.com`;
const googleAuthId = await store.google_auth.add(email);
const company = await store.company.create({
name: `Test Manifest Corp ${Date.now()}`,
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const supplier = await store.supplier.create(googleAuthId, {
name: 'Test Supplier',
});
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, {
name: 'Test Customer',
customergroupId: groups[0].id,
});
// 2. Test delivery with both dates
console.log('Testing delivery creation with date and createdAt...');
const deliveryDate = '2026-10-15T12:00:00.000Z';
const customCreatedAt = '2026-09-01T08:30:00.000Z';
const createdDelivery = await store.delivery.create(googleAuthId, {
externalNumber: 'DEL-2DATES-1',
supplierId: supplier.id,
date: deliveryDate,
createdAt: customCreatedAt,
lines: [
{ name: 'Article 1', amount: 2, net: 50, taxrate: 19 },
],
});
assert(createdDelivery.id, 'Delivery should have an id');
assert(createdDelivery.date, 'Delivery should have a delivery date');
assert(createdDelivery.createdAt, 'Delivery should have createdAt');
console.log('Delivery created:', {
id: createdDelivery.id,
date: createdDelivery.date,
createdAt: createdDelivery.createdAt,
});
// Verify delivery getById
const fetchedDelivery = await store.delivery.getById(googleAuthId, createdDelivery.id);
assert.equal(new Date(fetchedDelivery.date).toISOString().slice(0, 10), '2026-10-15');
assert.equal(new Date(fetchedDelivery.createdAt).toISOString().slice(0, 10), '2026-09-01');
// Verify delivery update
const updatedDeliveryDate = '2026-11-20T12:00:00.000Z';
const updatedDelivery = await store.delivery.update(googleAuthId, createdDelivery.id, {
date: updatedDeliveryDate,
});
assert.equal(new Date(updatedDelivery.date).toISOString().slice(0, 10), '2026-11-20');
assert.equal(new Date(updatedDelivery.createdAt).toISOString().slice(0, 10), '2026-09-01');
// Verify delivery.list ledger info before booking
let listBefore = await store.delivery.list(googleAuthId);
const foundBefore = listBefore.find((d) => d.id === createdDelivery.id);
assert(foundBefore, 'Delivery should be in delivery.list');
assert.equal(foundBefore.ledger.totalPositions, 1, 'Delivery ledger should have 1 totalPosition');
assert.equal(foundBefore.ledger.completelyOnLedgerPositions, 0, 'Delivery ledger should have 0 completelyOnLedgerPositions initially');
assert.equal(foundBefore.totalPositions, undefined, 'totalPositions should not be at top level');
assert.equal(foundBefore.completelyOnLedgerPositions, undefined, 'completelyOnLedgerPositions should not be at top level');
// Book line to ledger
await store.ledger.add(googleAuthId, {
manifestarticle_id: createdDelivery.lines[0].id,
transaction_type: 'DELIVERY',
amount: 2,
transaction_text: `Lieferung ${createdDelivery.id}`,
});
// Verify delivery.list ledger info after booking
let listAfter = await store.delivery.list(googleAuthId);
const foundAfter = listAfter.find((d) => d.id === createdDelivery.id);
assert(foundAfter, 'Delivery should be in delivery.list after booking');
assert.equal(foundAfter.ledger.totalPositions, 1, 'Delivery ledger should have 1 totalPosition');
assert.equal(foundAfter.ledger.completelyOnLedgerPositions, 1, 'Delivery ledger should have 1 completelyOnLedgerPosition after booking');
console.log('✓ Verified delivery.list ledger info (unbooked -> booked):', foundAfter.ledger);
// 3. Test quote with both dates
console.log('Testing quote creation...');
const quote = await store.quote.create(googleAuthId, {
number: `Q-${Date.now()}`,
date: '2026-12-01T00:00:00.000Z',
createdAt: '2026-09-10T00:00:00.000Z',
lines: [{ name: 'Quote item', amount: 1, net: 100, taxrate: 19 }],
});
assert(quote.createdAt, 'Quote should have createdAt');
assert(quote.date, 'Quote should have date');
// 4. Test order with default createdAt
console.log('Testing order creation with default createdAt...');
const order = await store.order.create(googleAuthId, {
number: `ORD-${Date.now()}`,
date: '2026-12-15T00:00:00.000Z',
entityId: customer.id,
lines: [{ name: 'Order item', amount: 3, net: 25, taxrate: 19 }],
});
assert(order.createdAt, 'Order should have default createdAt populated');
assert(order.date, 'Order should have date');
console.log('Order created with auto createdAt:', {
id: order.id,
date: order.date,
createdAt: order.createdAt,
});
console.log(`✓ All tests passed for ${name}!`);
}
async function main() {
// Test SQLite provider with existing dashback database
const sqliteProvider = new BetterSqlite3Provider({
filename: '/home/seb/src/dashback/data/dash.db',
});
try {
await testProvider('SQLite (dash.db)', sqliteProvider);
} finally {
sqliteProvider.close();
}
// Test MSSQL provider
const mssqlConfig = {
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: {
encrypt: false,
trustServerCertificate: true,
},
};
const mssqlProvider = new MssqlProvider(mssqlConfig);
try {
await testProvider('MSSQL (dashback)', mssqlProvider);
} finally {
await mssqlProvider.close();
}
}
main().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

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

@@ -0,0 +1,117 @@
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 store.modelconversation / store.modelmessage ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-modelconv-'));
const provider = new BetterSqlite3Provider({
filename: join(tempDir, 'test.db'),
storageDir: join(tempDir, 'storage'),
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const userA = await store.google_auth.add('mc-a@example.com');
const company = await store.company.create({
name: 'MC Corp', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId: userA,
});
await store.google_auth.setSelectedCompany(userA, company.id);
const conv = await store.modelconversation.create(userA, {
provider: 'openrouter', model: 'test/model',
});
assert.equal(conv.state, 'idle');
assert.equal(conv.provider, 'openrouter');
await assert.rejects(store.modelconversation.create(userA, { model: 'x' }), /Provider required/);
await assert.rejects(store.modelconversation.create(userA, { provider: 'x' }), /Model required/);
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), []);
const m1 = await store.modelmessage.add(userA, conv.id, { message: 'hello' });
assert.equal(m1.role, 'user');
assert.equal(m1.conversation_id, conv.id);
await assert.rejects(store.modelmessage.add(userA, conv.id, { message: ' ' }), /Message required/);
await assert.rejects(store.modelmessage.add(userA, 9999, { message: 'x' }), /Conversation not found/);
// Non-idle conversation refuses user messages but allows assistant messages
const busyConv = await store.modelconversation.setState(userA, conv.id, 'busy');
assert.equal(busyConv.state, 'busy');
const fetchedBusy = await store.modelconversation.get(userA, conv.id);
assert.equal(fetchedBusy.state, 'busy');
await assert.rejects(
store.modelmessage.add(userA, conv.id, { message: 'again' }),
/Conversation is not idle/,
);
// Assistant message can be added while busy
const mAssistant = await store.modelmessage.add(userA, conv.id, { message: 'echo: hello', role: 'assistant' });
assert.equal(mAssistant.role, 'assistant');
assert.equal(mAssistant.message, 'echo: hello');
// Back to idle
await store.modelconversation.setState(userA, conv.id, 'idle');
await store.modelmessage.add(userA, conv.id, { message: 'second' });
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');
await store.companyuser.add(company.id, userB);
await store.google_auth.setSelectedCompany(userB, company.id);
assert.equal((await store.modelconversation.list(userB)).length, 0);
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?.();
rmSync(tempDir, { recursive: true, force: true });
}
}
run().catch((err) => {
console.error('FAIL', err);
process.exit(1);
});

295
scripts/test-obituary.js Normal file
View File

@@ -0,0 +1,295 @@
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 posterminalobituary table and deletion flow ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-obituary-'));
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 {
// 1. Verify posterminalobituary table exists and has expected columns
console.log('1. Checking posterminalobituary schema...');
const tableInfo = provider.db.pragma('table_info("posterminalobituary")');
const colNames = tableInfo.map((c) => c.name);
console.log('posterminalobituary columns:', colNames);
assert.deepEqual(colNames, [
'id',
'posterminal_id',
'entity_type',
'entity_id',
'created_at',
]);
// Setup company first
console.log('2. Setting up base data...');
const googleAuthId = await store.google_auth.add('obituary-tester@example.com');
const company = await store.company.create({
name: 'Obituary Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Setup active paired POS terminal
const terminal = await store.posterminal.upsert(googleAuthId, {
name: 'POS Terminal 1',
pairingpin: '1234',
});
await store.posterminal.pair(terminal.id, {
token: 'pos-token-123',
clientserial: 'SN-001',
pairedAt: new Date().toISOString(),
});
// 3. Test article deletion flow
console.log('3. Testing article deletion obituary...');
const article = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Coffee Mug',
});
await store.article.delete(googleAuthId, article.sku);
// 4. Test category deletion flow
console.log('4. Testing category deletion obituary...');
const cat = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Snacks',
});
await store.category.delete({ googleAuthId, id: cat.id });
// 5. Test customer deletion flow
console.log('5. Testing customer deletion obituary...');
const customer = await store.entity.create(googleAuthId, {
name: 'Mustermann',
firstName: 'Max',
customergroupId: company.posCustomerGroupId,
});
await store.entity.delete(googleAuthId, customer.id);
// 6. Test customer group deletion flow
console.log('6. Testing customer group deletion obituary...');
const cg = await store.customergroup.create(googleAuthId, {
name: 'VIP Guests',
});
await store.customergroup.delete(googleAuthId, cg.id);
// 7. Verify all obituaries exist in posterminalobituary
console.log('7. Verifying obituaries in database...');
const allObituaries = provider.db
.prepare('SELECT * FROM posterminalobituary WHERE posterminal_id = ? ORDER BY id ASC')
.all(terminal.id);
console.log('Created obituaries:', allObituaries);
assert.equal(allObituaries.length, 4);
assert.equal(allObituaries[0].entity_type, 'article');
assert.equal(allObituaries[0].entity_id, article.id);
assert.equal(allObituaries[1].entity_type, 'category');
assert.equal(allObituaries[1].entity_id, cat.id);
assert.equal(allObituaries[2].entity_type, 'customer');
assert.equal(allObituaries[2].entity_id, customer.id);
assert.equal(allObituaries[3].entity_type, 'customergroup');
assert.equal(allObituaries[3].entity_id, cg.id);
// 8. Test getDeletedEntityCount & getDeletedEntityList APIs
console.log('8. Testing posterminal.getDeletedEntityCount & getDeletedEntityList...');
const initialCount = await store.posterminal.getDeletedEntityCount({
posterminalId: terminal.id,
cursor: 0,
});
assert.equal(initialCount, 4);
const list = await store.posterminal.getDeletedEntityList({
posterminalId: terminal.id,
cursor: 0,
limit: 10,
});
assert.equal(list.length, 4);
assert.equal(list[0].entityType, 'article');
assert.equal(list[0].entityId, article.id);
assert.equal(list[1].entityType, 'category');
assert.equal(list[2].entityType, 'customer');
assert.equal(list[3].entityType, 'customergroup');
// Test cursor pagination
const cursor = allObituaries[1].id;
const countAfterCursor = await store.posterminal.getDeletedEntityCount({
posterminalId: terminal.id,
cursor,
});
assert.equal(countAfterCursor, 2);
const listAfterCursor = await store.posterminal.getDeletedEntityList({
posterminalId: terminal.id,
cursor,
limit: 10,
});
assert.equal(listAfterCursor.length, 2);
assert.equal(listAfterCursor[0].id, allObituaries[2].id);
assert.equal(listAfterCursor[1].id, allObituaries[3].id);
// 9. Test maxknownobituaryid
console.log('9. Testing maxknownobituaryid...');
await store.posterminal.setMaxKnownObituaryId(terminal.id, allObituaries[3].id);
const updatedTerminal = await store.posterminal.findByToken('pos-token-123');
assert.equal(updatedTerminal.maxknownobituaryid, allObituaries[3].id);
// 10. Test setOff category & orphaned article obituary generation
console.log('10. Testing setOff with orphaned and non-orphaned articles...');
// Create Cat A and Cat B
const catA = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Category A',
});
const catB = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Category B',
});
// Art A only in Cat A
const artA = await store.article.createUnderParent({
googleAuthId,
parentId: catA.id,
name: 'Article A (only in Cat A)',
});
// Art AB in both Cat A and Cat B
const artAB = await store.article.createUnderParent({
googleAuthId,
parentId: catA.id,
name: 'Article AB (in Cat A and B)',
});
await store.article.setCategoryIdsBySku(googleAuthId, artAB.sku, [catA.id, catB.id]);
// Turn both categories on for the terminal
await store.posterminal.setOn(googleAuthId, terminal.id, catA.id);
await store.posterminal.setOn(googleAuthId, terminal.id, catB.id);
// Clear previous obituaries to test cleanly
provider.db.prepare('DELETE FROM posterminalobituary WHERE posterminal_id = ?').run(terminal.id);
// Turn off Cat A:
// - Cat A should get an obituary
// - Art A should get an obituary (orphaned on this terminal)
// - Art AB should NOT get an obituary (still has active Cat B on this terminal)
await store.posterminal.setOff(googleAuthId, terminal.id, catA.id);
const obituariesAfterSetOff = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ? ORDER BY id ASC')
.all(terminal.id);
console.log('Obituaries after setOff(Cat A):', obituariesAfterSetOff);
assert.equal(obituariesAfterSetOff.length, 2);
assert.deepEqual(obituariesAfterSetOff, [
{ entity_type: 'category', entity_id: catA.id },
{ entity_type: 'article', entity_id: artA.id },
]);
// 11. Test setOn category clears pending obituaries for that category and its articles
console.log('11. Testing setOn clears pending category and article obituaries...');
await store.posterminal.setOn(googleAuthId, terminal.id, catA.id);
const obituariesAfterSetOn = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ?')
.all(terminal.id);
console.log('Obituaries after setOn(Cat A):', obituariesAfterSetOn);
assert.equal(obituariesAfterSetOn.length, 0);
// 12. Test setOffAll generates obituaries for all active categories and all their articles
console.log('12. Testing setOffAll obituaries...');
await store.posterminal.setOffAll(googleAuthId, terminal.id);
const obituariesAfterSetOffAll = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ? ORDER BY id ASC')
.all(terminal.id);
console.log('Obituaries after setOffAll:', obituariesAfterSetOffAll);
// Cat A, Cat B, Art A, Art AB
const catObituaries = obituariesAfterSetOffAll.filter((t) => t.entity_type === 'category').map((t) => t.entity_id);
const artObituaries = obituariesAfterSetOffAll.filter((t) => t.entity_type === 'article').map((t) => t.entity_id);
assert(catObituaries.includes(catA.id));
assert(catObituaries.includes(catB.id));
assert(artObituaries.includes(artA.id));
assert(artObituaries.includes(artAB.id));
// 13. Test setOnAll clears category and article obituaries
console.log('13. Testing setOnAll clears all category & article obituaries...');
await store.posterminal.setOnAll(googleAuthId, terminal.id);
const obituariesAfterSetOnAll = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ?')
.all(terminal.id);
console.log('Obituaries after setOnAll:', obituariesAfterSetOnAll);
assert.equal(obituariesAfterSetOnAll.length, 0);
// 14. Test setCategoryIdsBySku generates obituary when removed from POS-enabled category
console.log('14. Testing setCategoryIdsBySku obituary generation and clearing...');
// Setup terminal: only has Cat A enabled
await store.posterminal.setOffAll(googleAuthId, terminal.id);
// Clear obituaries from setOffAll to start clean
provider.db.prepare('DELETE FROM posterminalobituary WHERE posterminal_id = ?').run(terminal.id);
await store.posterminal.setOn(googleAuthId, terminal.id, catA.id);
// Create article in Cat A
const artMoving = await store.article.createUnderParent({
googleAuthId,
parentId: catA.id,
name: 'Moving Article',
});
// Should have 0 obituaries on terminal
let obits = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ?')
.all(terminal.id);
assert.equal(obits.length, 0);
// Move article from Cat A to Cat B (which is not enabled on terminal)
console.log('Moving article to Cat B (not on terminal)...');
await store.article.setCategoryIdsBySku(googleAuthId, artMoving.sku, [catB.id]);
obits = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ?')
.all(terminal.id);
console.log('Obituaries after moving to Cat B:', obits);
assert.equal(obits.length, 1);
assert.deepEqual(obits[0], { entity_type: 'article', entity_id: artMoving.id });
// Move article back to Cat A (enabled on terminal)
console.log('Moving article back to Cat A (on terminal)...');
await store.article.setCategoryIdsBySku(googleAuthId, artMoving.sku, [catA.id]);
obits = provider.db
.prepare('SELECT entity_type, entity_id FROM posterminalobituary WHERE posterminal_id = ?')
.all(terminal.id);
console.log('Obituaries after moving back to Cat A:', obits);
assert.equal(obits.length, 0);
console.log('✅ ALL POSTERMINALOBITUARY TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,151 @@
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 store.order.createFromQuote in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-order-create-from-quote-'));
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('order-from-quote-test@example.com');
const company = await store.company.create({
name: 'Order Quote Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
firstName: 'Alice',
name: 'Wonderland',
});
const art = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Gadget',
track_stock: 0,
});
// 1. Create a quote
const quote = await store.quote.create(googleAuthId, {
number: 'Q-2026-001',
date: '2026-09-01T10:00:00Z',
entityId: customer.id,
externalNumber: 'EXT-QUOTE-123',
lines: [
{ sku: art.sku, name: 'Gadget', pu: 1, amount: 5, net: 20, rebate: 10, taxrate: 19, comment: 'test item' },
],
});
assert.equal(quote.number, 'Q-2026-001');
assert.equal(quote.lines.length, 1);
// 2. Create order from quote with default auto-allocated number & inherited fields
const order1 = await store.order.createFromQuote(googleAuthId, quote.id);
assert.ok(order1.id, 'Order must have an id');
assert.equal(order1.type, 'order');
assert.equal(order1.entityId, customer.id);
assert.equal(order1.externalNumber, quote.number);
assert.equal(order1.number, 'AUF-000001');
assert.equal(order1.lines.length, 1);
assert.equal(order1.lines[0].sku, art.sku);
assert.equal(order1.lines[0].amount, 5);
assert.equal(order1.lines[0].net, 20);
assert.equal(order1.lines[0].rebate, 10);
assert.equal(order1.lines[0].taxrate, 19);
assert.equal(order1.lines[0].comment, 'test item');
// Verify ordersummary row
const summaryRows = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').all(order1.id);
assert.equal(summaryRows.length, 1);
assert.equal(summaryRows[0].customer_name, 'Alice Wonderland');
assert.equal(summaryRows[0].positions, 1);
assert.equal(summaryRows[0].ordered_quantity, 5);
// 3. Create order from quote with custom overrides (number, externalNumber, date)
const order2 = await store.order.createFromQuote(googleAuthId, quote.id, {
number: 'ORD-CUSTOM-999',
date: '2026-09-15T12:00:00Z',
externalNumber: 'CUSTOM-EXT',
});
assert.equal(order2.number, 'ORD-CUSTOM-999');
assert.equal(order2.externalNumber, 'CUSTOM-EXT');
assert.equal(order2.lines.length, 1);
// 4. Non-existent quote should throw 'Quote not found'
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, 999999);
},
{ message: 'Quote not found' }
);
// 5. Invalid quoteId should throw 'Quote not found'
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, 'invalid');
},
{ message: 'Quote not found' }
);
// 6. Quote without entity should throw 'No entity selected' if no entity passed
const quoteWithoutEntity = await store.quote.create(googleAuthId, {
number: 'Q-NO-ENT',
date: '2026-09-01T10:00:00Z',
lines: [{ sku: art.sku, name: 'Gadget', amount: 1, net: 10, taxrate: 19 }],
});
assert.equal(quoteWithoutEntity.entityId, null);
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, quoteWithoutEntity.id);
},
{ message: 'No entity selected' }
);
// 7. Explicitly overriding with entityId: null should also throw 'No entity selected'
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, quote.id, { entityId: null });
},
{ message: 'No entity selected' }
);
// 8. Passing entityId for quote without entity should succeed
const orderFromNoEnt = await store.order.createFromQuote(googleAuthId, quoteWithoutEntity.id, {
entityId: customer.id,
});
assert.equal(orderFromNoEnt.entityId, customer.id);
console.log('PASS: All order.createFromQuote tests passed successfully!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
run().catch((err) => {
console.error('FAIL: Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,207 @@
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 testCarrier = await store.carrier.create(googleAuthId, { name: 'Test Carrier', hasPackages: true });
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order2.id,
lines: [{ sku: art.sku, amount: 2 }],
shipping: { carrierId: testCarrier.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!
});
await store.shipment.create(googleAuthId, {
orderId: order4.id,
lines: [{ sku: art.sku, amount: 1 }],
shipping: { carrierId: testCarrier.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.equal(resAlice.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.equal(resBob.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.equal(resUnpaid.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.equal(resUndelivered.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.unfilteredTotal, 4, 'Unfiltered total should be 4');
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.unfilteredTotal, 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.unfilteredTotal, 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

@@ -0,0 +1,287 @@
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 shipping ledger & stock adjustment ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-order-shipping-'));
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 {
// 1. Setup company and user
const googleAuthId = await store.google_auth.add('shipping-test@example.com');
const company = await store.company.create({
name: 'Shipping Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Setup warehouse
const warehouse = await store.warehouse.create(googleAuthId, {
name: 'Hauptlager',
});
assert(warehouse.id, 'Warehouse should have id');
// Setup units
await store.unit.upsert(googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
});
// Setup customer group & customer entity
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
name: 'Max Mustermann',
});
const carrier = await store.carrier.create(googleAuthId, {
name: 'Standard Carrier',
});
// 2. Setup tracked article and untracked article
const trackedArt = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Tracked Article',
track_stock: 1,
});
const untrackedArt = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Untracked Service Item',
track_stock: 0,
});
// Initial stock for tracked article: +10
await store.stock.adjust(googleAuthId, {
sku: trackedArt.sku,
warehouseId: warehouse.id,
amount: 10,
text: 'Initial stock intake',
});
const artBefore = await store.article.getBySku(googleAuthId, trackedArt.sku);
const stockBefore = artBefore.article.warehouses.reduce((sum, w) => sum + w.stock, 0);
assert.equal(stockBefore, 10, 'Initial stock should be 10');
// 3. Create sales order with tracked and untracked items
const order = await store.order.create(googleAuthId, {
number: 'O-2026-001',
date: new Date().toISOString(),
entityId: customer.id,
lines: [
{
sku: trackedArt.sku,
name: 'Tracked Article',
pu: 1,
amount: 3,
net: 50,
taxrate: 19,
},
{
sku: untrackedArt.sku,
name: 'Untracked Service Item',
pu: 1,
amount: 2,
net: 20,
taxrate: 19,
},
],
});
assert.equal(order.lines.length, 2);
// 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 },
],
shipping: { carrierId: carrier.id, type: 'parcel', state: 'open' },
});
const shippingOpen = shipment.shippings[0];
assert.equal(shippingOpen.state, 'open');
// Assert stock is still 10
const artStill10 = await store.article.getBySku(googleAuthId, trackedArt.sku);
assert.equal(artStill10.article.warehouses.reduce((sum, w) => sum + w.stock, 0), 10, 'Stock should still be 10 when shipping is open');
// 5. Update shipping to state 'shipped'
console.log('5. Updating shipping to state "shipped"...');
const shippingShipped = await store.shipping.update(googleAuthId, shippingOpen.id, {
state: 'shipped',
warehouseId: warehouse.id,
});
assert.equal(shippingShipped.state, 'shipped');
// Assert stock is now 7 (10 - 3)
const artAfter = await store.article.getBySku(googleAuthId, trackedArt.sku);
assert.equal(artAfter.article.warehouses.reduce((sum, w) => sum + w.stock, 0), 7, 'Stock should be reduced to 7');
// Assert ledger entries
const ledgerRows = await store.ledger.listBySku(googleAuthId, trackedArt.sku);
assert.equal(ledgerRows.length, 3, 'Should have 3 ledger entries for tracked article (adjust + shipment doc + warehouse)');
const shipmentDocLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPMENT' && l.warehouse_id == null);
assert(shipmentDocLedger, 'Shipment document ledger entry should exist with warehouse_id null');
assert.equal(shipmentDocLedger.amount, -3, 'Shipment document ledger entry should be -3');
const shipmentWhLedger = ledgerRows.find((l) => l.transaction_type === 'WAREHOUSE' && l.warehouse_id === warehouse.id);
assert(shipmentWhLedger, 'Shipment warehouse ledger entry should exist with warehouse_id set');
assert.equal(shipmentWhLedger.amount, -3, 'Shipment warehouse ledger entry should be -3');
// Assert untracked article has 1 document ledger entry and 0 warehouse ledger entries
const untrackedLedger = await store.ledger.listBySku(googleAuthId, untrackedArt.sku);
assert.equal(untrackedLedger.length, 1, 'Untracked article should have 1 shipment document ledger entry');
assert.equal(untrackedLedger[0].transaction_type, 'SHIPMENT');
assert.equal(untrackedLedger[0].warehouse_id, null, 'Untracked article should not have warehouse ledger entry');
assert.equal(untrackedLedger[0].amount, -2);
// Assert article history: order goes into history, and shipment produces history
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, € 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 shipmentHistory = history.find((h) => h.text.startsWith(`Lieferschein ${shipment.number}`) && h.text.includes('Kunde: "Max Mustermann"'));
assert(shipmentHistory, 'Article history should report the shipment document');
// Untracked article also has the order in history and shipment in history
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, € 23,80)'),
'Untracked article history should contain order creation entry with formatted gross price',
);
assert(
untrackedHistory.some((h) => h.text.startsWith(`Lieferschein ${shipment.number}`)),
'Untracked article history should 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,
entityId: customer.id,
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(
async () => {
await store.shipping.delete(googleAuthId, shippingShipped.id);
},
{ message: 'Shipped shipping cannot be deleted' },
);
// 7. Test immutability: once shipped, cannot be undone
console.log('7. Testing cannot undo shipped shipping...');
await assert.rejects(
async () => {
await store.shipping.update(googleAuthId, shippingShipped.id, { state: 'open' });
},
{ message: 'Shipped shipping cannot be undone' },
);
// 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, {
manifestId: order.id,
carrierId: carrier.id,
type: 'express',
state: 'shipped',
});
},
{ message: 'Manifest is not a shipment' },
);
// 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',
date: new Date().toISOString(),
entityId: customer.id,
lines: [
{
sku: trackedArt.sku,
name: 'Tracked Article',
pu: 1,
amount: 2,
net: 50,
taxrate: 19,
},
],
});
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order2.id,
lines: [{ sku: trackedArt.sku, amount: 2 }],
shipping: { carrierId: carrier.id, type: 'parcel', state: 'shipped' },
});
const directShipped = shipment2.shippings[0];
// 9b. Import-style: delivered shipment with shipped shipping created atomically
const order3 = await store.order.create(googleAuthId, {
number: 'O-2026-003',
date: new Date().toISOString(),
entityId: customer.id,
lines: [{ sku: trackedArt.sku, name: 'Tracked Article', pu: 1, amount: 1, net: 50, taxrate: 19 }],
});
const shipment3 = await store.shipment.create(googleAuthId, {
orderId: order3.id,
state: 'delivered',
lines: [{ sku: trackedArt.sku, amount: 1 }],
shipping: { carrierId: carrier.id, type: 'parcel', state: 'shipped' },
});
assert.equal(shipment3.shippings[0].state, 'shipped');
assert.equal(directShipped.state, 'shipped');
const artFinal = await store.article.getBySku(googleAuthId, trackedArt.sku);
assert.equal(artFinal.article.warehouses.reduce((sum, w) => sum + w.stock, 0), 4, 'Stock should now be 4 (7 - 2 - 1)');
console.log('✓ All order shipping ledger 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

@@ -0,0 +1,118 @@
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';
const tempDir = mkdtempSync(join(tmpdir(), 'dash-printeragent-test-'));
const provider = new BetterSqlite3Provider({ filename: join(tempDir, 'test.db'), storageDir: join(tempDir, 'storage') });
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('printeragent-tester@example.com');
const company = await store.company.create({ name: 'PA GmbH', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId });
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
assert.deepEqual(await store.printeragent.list(googleAuthId), []);
await assert.rejects(store.printeragent.create(googleAuthId, { name: ' ', pairingpin: '1' }), /Invalid printeragent/);
const a = await store.printeragent.create(googleAuthId, { name: ' Bar ', pairingpin: '123456', sort: 5 });
assert.equal(a.name, 'Bar');
assert.equal(a.pairingpin, '123456');
assert.equal(a.token, null);
assert.equal(a.sort, 5);
assert.equal(a.companyId, company.id);
assert.ok(a.updatedAt);
// findByPairingpin
const foundByPin = await store.printeragent.findByPairingpin('123456');
assert.equal(foundByPin.id, a.id);
assert.equal(await store.printeragent.findByPairingpin('999999'), null);
// pair
const paired = await store.printeragent.pair(a.id, {
token: 'test-token-123',
agentversion: '1.0.0',
});
assert.equal(paired.token, 'test-token-123');
assert.equal(paired.agentversion, '1.0.0');
assert.equal(paired.pairingpin, null);
assert.equal(paired.enabled, true);
assert.ok(paired.pairedAt);
// findByToken
const foundByToken = await store.printeragent.findByToken('test-token-123');
assert.equal(foundByToken.id, a.id);
assert.equal(foundByToken.enabled, true);
assert.equal(await store.printeragent.findByToken('invalid-token'), null);
// touchLastseen
await store.printeragent.touchLastseen(a.id, '1.0.1');
const afterTouch = await store.printeragent.findByToken('test-token-123');
assert.equal(afterTouch.agentversion, '1.0.1');
// update name, sort, and enabled
const b = await store.printeragent.update(googleAuthId, { id: a.id, name: 'Kitchen', sort: 1, enabled: false });
assert.equal(b.name, 'Kitchen');
assert.equal(b.sort, 1);
assert.equal(b.enabled, false);
// list
const list = await store.printeragent.list(googleAuthId);
assert.equal(list.length, 1);
assert.equal(list[0].name, 'Kitchen');
assert.equal(list[0].sort, 1);
assert.equal(list[0].enabled, false);
// test agentprinter findOrCreate
const ap1 = await store.agentprinter.findOrCreate(a.id, { type: 'zpl', model: 'ZD410-200dpi', serial: '50J203002218' });
assert.equal(ap1.created, true);
assert.equal(ap1.agentprinter.type, 'zpl');
assert.equal(ap1.agentprinter.model, 'ZD410-200dpi');
assert.equal(ap1.agentprinter.serial, '50J203002218');
assert.equal(ap1.agentprinter.enabled, true);
// second call with same combo returns existing
const ap1Again = await store.agentprinter.findOrCreate(a.id, { type: 'zpl', model: 'ZD410-200dpi', serial: '50J203002218' });
assert.equal(ap1Again.created, false);
assert.equal(ap1Again.agentprinter.id, ap1.agentprinter.id);
// create second printer
const ap2 = await store.agentprinter.findOrCreate(a.id, { type: 'esc/pos', model: 'TM-T88VI', serial: 'SN999' });
assert.equal(ap2.created, true);
// list agentprinters
const apList = await store.agentprinter.list(googleAuthId, a.id);
assert.equal(apList.length, 2);
// getById
const apGet = await store.agentprinter.getById(googleAuthId, ap1.agentprinter.id);
assert.equal(apGet.serial, '50J203002218');
// update agentprinter
const apUpdated = await store.agentprinter.update(googleAuthId, { id: ap1.agentprinter.id, enabled: false, sort: 10 });
assert.equal(apUpdated.enabled, false);
assert.equal(apUpdated.sort, 10);
// order agentprinters
const ordered = await store.agentprinter.order(googleAuthId, {
printeragentId: a.id,
agentprinterIds: [ap2.agentprinter.id, ap1.agentprinter.id],
});
assert.deepEqual(ordered.agentprinterIds, [ap2.agentprinter.id, ap1.agentprinter.id]);
// delete agentprinter
await store.agentprinter.delete(googleAuthId, ap2.agentprinter.id);
assert.equal((await store.agentprinter.list(googleAuthId, a.id)).length, 1);
// delete
await store.printeragent.delete(googleAuthId, a.id);
assert.equal(await store.printeragent.findByToken('test-token-123'), null);
assert.deepEqual(await store.printeragent.list(googleAuthId), []);
console.log('printeragent full test OK');
} finally {
await provider.close?.();
rmSync(tempDir, { recursive: true, force: true });
}

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