diff --git a/README.md b/README.md
index 2f42be1..aa3e4dc 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ Entities belong to a `customergroup` of the company. Auth via `resolveCompanyAcc
| Method | Signature | Returns |
| --- | --- | --- |
-| `list` | `(googleAuthId, companyId, { customergroupId? })` | `Entity[]` |
+| `list` | `(googleAuthId, companyId, { customergroupId?, role? })` | `Entity[]` |
| `getById` | `(googleAuthId, companyId, id)` | `Entity` |
| `create` | `(googleAuthId, companyId, { customergroupId, name, firstName? })` | `Entity` |
| `update` | `(googleAuthId, companyId, id, { name?, firstName? })` | `Entity` |
@@ -225,7 +225,9 @@ type Entity = {
}
```
-`create` / `update` write `entityhistory` (`Erstellt`, or name/firstName change lines). `customergroupId` must belong to the company. Errors: `Company not found`, `Not authorized`, `Name required`, `Customer group not found`, `Entity not found`.
+`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`.
+
+`create` / `update` write `entityhistory` (`Erstellt`, or name/firstName change lines). `customergroupId` must belong to the company. Errors: `Company not found`, `Not authorized`, `Name required`, `Customer group not found`, `Entity not found`, `Invalid role`.
---
@@ -317,6 +319,75 @@ type Delivery = DeliverySummary & { lines: DeliveryLine[] }
---
+## `quote`
+
+Company-scoped quotes on `manifest` / `manifestarticle` with fixed `type = 'quote'`. Auth via `resolveCompanyAccess`. Same line shape as `delivery` (no `transactedAmount` — ledger owns movements). `manifest.date` is the document date; HTTP/OpenAPI may describe it as valid-until.
+
+| Method | Signature | Returns |
+| --- | --- | --- |
+| `list` | `(googleAuthId, companyId)` | `QuoteSummary[]` |
+| `getById` | `(googleAuthId, companyId, id)` | `Quote` |
+| `create` | `(googleAuthId, companyId, { number, date, entityId?, lines })` | `Quote` |
+| `update` | `(googleAuthId, companyId, id, { number?, date?, entityId?, lines? })` | `Quote` |
+| `delete` | `(googleAuthId, companyId, id)` | `void` |
+
+```ts
+type QuoteSummary = {
+ id: number,
+ number: string,
+ type: 'quote',
+ entityId: number | null, // manifest.entity_id — customer
+ date: Date,
+}
+
+type ManifestLine = {
+ id: number,
+ articleId: number | null,
+ sku: string | null,
+ amount: number,
+ net: number,
+ rebate: number,
+ tax: number, // tax.rate
+ comment: string | null,
+}
+
+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`.
+
+`update`: present fields only; `lines` present → replace-all. `delete` cascades lines. Errors: `Company not found`, `Not authorized`, `Quote not found`, `Entity not found`, `Article not found`, `Invalid quote`, `Invalid lines`.
+
+---
+
+## `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.
+
+| Method | Signature | Returns |
+| --- | --- | --- |
+| `list` | `(googleAuthId, companyId)` | `OrderSummary[]` |
+| `getById` | `(googleAuthId, companyId, id)` | `Order` |
+| `create` | `(googleAuthId, companyId, { number, date, entityId?, lines })` | `Order` |
+| `update` | `(googleAuthId, companyId, id, { number?, date?, entityId?, lines? })` | `Order` |
+| `delete` | `(googleAuthId, companyId, id)` | `void` |
+
+```ts
+type OrderSummary = {
+ id: number,
+ number: string,
+ type: 'order',
+ entityId: number | null,
+ date: Date,
+}
+
+type Order = OrderSummary & { lines: ManifestLine[] } // ManifestLine as in quote
+```
+
+Errors: `Company not found`, `Not authorized`, `Order not found`, `Entity not found`, `Article not found`, `Invalid order`, `Invalid lines`.
+
+---
+
## `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.
@@ -328,6 +399,7 @@ Append-only SQL Server ledger table (`WITH (LEDGER = ON (APPEND_ONLY = ON))`). R
| `transaction_type` | `NVARCHAR(100)` | |
| `amount` | `FLOAT` | |
| `transaction_text` | `NVARCHAR(MAX)` | |
+| `hash_version` | `INT` | hash algorithm / payload version |
| `prev_hash` | `NVARCHAR(128)` | nullable (genesis row) |
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).
@@ -611,7 +683,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer);
| `POST /api/customergroup` | `customergroup.create(googleAuthId, companyId, { name })` |
| `PATCH /api/customergroup/:id` | `customergroup.update(googleAuthId, companyId, id, { name })` |
| `DELETE /api/customergroup/:id` | `customergroup.delete(googleAuthId, companyId, id)` |
-| `GET /api/entity` | `entity.list(googleAuthId, companyId, { customergroupId? })` |
+| `GET /api/entity` | `entity.list(googleAuthId, companyId, { customergroupId?, role? })` |
| `GET /api/entity/:id` | `entity.getById(googleAuthId, companyId, id)` |
| `POST /api/entity` | `entity.create(googleAuthId, companyId, { customergroupId, name, firstName? })` |
| `PATCH /api/entity/:id` | `entity.update(googleAuthId, companyId, id, { name?, firstName? })` |
@@ -633,6 +705,16 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer);
| `POST /api/delivery` | `delivery.create(googleAuthId, companyId, { number, date, supplierId?, lines })` |
| `PATCH /api/delivery/:id` | `delivery.update(googleAuthId, companyId, id, { number?, date?, supplierId?, lines? })` |
| `DELETE /api/delivery/:id` | `delivery.delete(googleAuthId, companyId, id)` |
+| `GET /api/quote` | `quote.list(googleAuthId, companyId)` |
+| `GET /api/quote/:id` | `quote.getById(googleAuthId, companyId, id)` |
+| `POST /api/quote` | `quote.create(googleAuthId, companyId, { number, date, entityId?, lines })` |
+| `PATCH /api/quote/:id` | `quote.update(googleAuthId, companyId, id, { number?, date?, entityId?, lines? })` |
+| `DELETE /api/quote/:id` | `quote.delete(googleAuthId, companyId, id)` |
+| `GET /api/order` | `order.list(googleAuthId, companyId)` |
+| `GET /api/order/:id` | `order.getById(googleAuthId, companyId, id)` |
+| `POST /api/order` | `order.create(googleAuthId, companyId, { number, date, entityId?, lines })` |
+| `PATCH /api/order/:id` | `order.update(googleAuthId, companyId, id, { number?, date?, entityId?, lines? })` |
+| `DELETE /api/order/:id` | `order.delete(googleAuthId, companyId, id)` |
| `GET /api/posterminal` | `posterminal.list(googleAuthId, companyId)` |
| `PUT /api/posterminal` | `posterminal.upsert(googleAuthId, companyId, { id?, name, token?, pairingpin })` |
| `DELETE /api/posterminal/:id` | `posterminal.delete(googleAuthId, companyId, id)` |
diff --git a/schema-graph.dot b/schema-graph.dot
index 6e3ae78..382b790 100644
--- a/schema-graph.dot
+++ b/schema-graph.dot
@@ -59,7 +59,7 @@ digraph schema {
label="Pricing & Documents"; labeljust="l"; fontsize=15;
style="rounded,filled"; fillcolor="#ffffff60"; color="#a0aec0";
fontcolor="#1a365d"; margin=14;
- ledger [label=<
| ledger |
|
|
| transaction_type | NVARCHAR(100) |
|
|
| transaction_text | NVARCHAR(MAX) |
|
|
>];
+ ledger [label=<| ledger |
|
|
| transaction_type | NVARCHAR(100) |
|
|
| transaction_text | NVARCHAR(MAX) |
|
|
|
>];
manifestarticle [label=<>];
manifest [label=<>];
price [label=<| price |
|
|
|
|
| minmultiplier | DECIMAL(18,4) |
|
|
>];
diff --git a/schema-graph.svg b/schema-graph.svg
index 2c784ec..132327c 100644
--- a/schema-graph.svg
+++ b/schema-graph.svg
@@ -31,8 +31,8 @@
cluster_commerce
-
-Pricing & Documents
+
+Pricing & Documents
cluster_units
@@ -1468,35 +1468,39 @@
ledger
-
-
-ledger
-
-
-id
-INT
-PK
-
-
-manifestarticle_id
-INT
-FK
-
-
-transaction_type
-NVARCHAR(100)
-
-
-amount
-FLOAT
-
-
-transaction_text
-NVARCHAR(MAX)
-
-
-prev_hash?
-NVARCHAR(128)
+
+
+ledger
+
+
+id
+INT
+PK
+
+
+manifestarticle_id
+INT
+FK
+
+
+transaction_type
+NVARCHAR(100)
+
+
+amount
+FLOAT
+
+
+transaction_text
+NVARCHAR(MAX)
+
+
+hash_version
+INT
+
+
+prev_hash?
+NVARCHAR(128)
@@ -1547,7 +1551,7 @@
ledger:ne->manifestarticle:n
-
+
diff --git a/scripts/test-api.js b/scripts/test-api.js
index b231e09..416faaa 100644
--- a/scripts/test-api.js
+++ b/scripts/test-api.js
@@ -170,7 +170,7 @@ const MENU = {
entity: {
label: 'entity',
items: [
- { value: 'entity.list', label: 'list', hint: 'googleAuthId, companyId, customergroupId? → rows' },
+ { value: 'entity.list', label: 'list', hint: 'googleAuthId, companyId, customergroupId?|role=customer → rows' },
{ value: 'entity.getById', label: 'getById', hint: 'googleAuthId, companyId, id → row' },
{ value: 'entity.create', label: 'create', hint: 'googleAuthId, companyId, JSON → row' },
{ value: 'entity.update', label: 'update', hint: 'googleAuthId, companyId, id, JSON → row' },
@@ -250,6 +250,66 @@ const MENU = {
},
],
},
+ quote: {
+ label: 'quote',
+ items: [
+ {
+ value: 'quote.list',
+ label: 'list',
+ hint: 'googleAuthId, companyId → summaries',
+ },
+ {
+ value: 'quote.getById',
+ label: 'getById',
+ hint: 'googleAuthId, companyId, id → quote',
+ },
+ {
+ value: 'quote.create',
+ label: 'create',
+ hint: 'googleAuthId, companyId, JSON body → quote',
+ },
+ {
+ value: 'quote.update',
+ label: 'update',
+ hint: 'googleAuthId, companyId, id, JSON patch → quote',
+ },
+ {
+ value: 'quote.delete',
+ label: 'delete',
+ hint: 'googleAuthId, companyId, id',
+ },
+ ],
+ },
+ order: {
+ label: 'order',
+ items: [
+ {
+ value: 'order.list',
+ label: 'list',
+ hint: 'googleAuthId, companyId → summaries',
+ },
+ {
+ value: 'order.getById',
+ label: 'getById',
+ hint: 'googleAuthId, companyId, id → order',
+ },
+ {
+ value: 'order.create',
+ label: 'create',
+ hint: 'googleAuthId, companyId, JSON body → order',
+ },
+ {
+ value: 'order.update',
+ label: 'update',
+ hint: 'googleAuthId, companyId, id, JSON patch → order',
+ },
+ {
+ value: 'order.delete',
+ label: 'delete',
+ hint: 'googleAuthId, companyId, id',
+ },
+ ],
+ },
category: {
label: 'category',
items: [
@@ -784,19 +844,35 @@ async function runMethod(client, action) {
if (googleAuthId == null) return;
const companyId = await askInt('company id');
if (companyId == null) return;
- const groupRaw = await askText('customergroup id (empty = all)', {
+ const roleRaw = await askText('role (customer|empty)', {
placeholder: '',
- validate: (v) =>
- v?.trim() && !/^\d+$/.test(v.trim()) ? 'Integer or empty' : undefined,
+ validate: (v) => {
+ const t = v?.trim?.() ?? '';
+ if (!t || t === 'customer') return undefined;
+ return 'customer or empty';
+ },
});
- if (groupRaw == null) return;
- const customergroupId = groupRaw.trim()
- ? Number(groupRaw.trim())
- : undefined;
+ if (roleRaw == null) return;
+ const role = roleRaw.trim() || undefined;
+ let customergroupId;
+ if (!role) {
+ const groupRaw = await askText('customergroup id (empty = all)', {
+ placeholder: '',
+ validate: (v) =>
+ v?.trim() && !/^\d+$/.test(v.trim())
+ ? 'Integer or empty'
+ : undefined,
+ });
+ if (groupRaw == null) return;
+ customergroupId = groupRaw.trim()
+ ? Number(groupRaw.trim())
+ : undefined;
+ }
return [
'result[]',
await client.entity.list(Number(googleAuthId), Number(companyId), {
customergroupId,
+ role,
}),
];
}
@@ -1237,6 +1313,210 @@ async function runMethod(client, action) {
);
return ['ok', true];
}
+ case 'quote.list': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ return [
+ 'result[]',
+ await client.quote.list(Number(googleAuthId), Number(companyId)),
+ ];
+ }
+ case 'quote.getById': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const id = await askInt('quote id');
+ if (id == null) return;
+ return [
+ 'result',
+ await client.quote.getById(
+ Number(googleAuthId),
+ Number(companyId),
+ Number(id),
+ ),
+ ];
+ }
+ case 'quote.create': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const bodyJson = await askText('quote JSON', {
+ placeholder:
+ '{"number":"A-2026-001","date":"2026-09-01","entityId":12,"lines":[{"sku":"1","amount":10,"net":99.5,"tax":19}]}',
+ validate: (v) => {
+ if (!v?.trim()) return 'Required';
+ try {
+ const parsed = JSON.parse(v);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return 'Must be a JSON object';
+ }
+ } catch {
+ return 'Invalid JSON';
+ }
+ },
+ });
+ if (bodyJson == null) return;
+ return [
+ 'result',
+ await client.quote.create(
+ Number(googleAuthId),
+ Number(companyId),
+ JSON.parse(bodyJson),
+ ),
+ ];
+ }
+ case 'quote.update': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const id = await askInt('quote id');
+ if (id == null) return;
+ const patchJson = await askText('patch JSON', {
+ placeholder: '{"number":"A-2026-002","lines":[]}',
+ validate: (v) => {
+ if (!v?.trim()) return 'Required';
+ try {
+ const parsed = JSON.parse(v);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return 'Must be a JSON object';
+ }
+ } catch {
+ return 'Invalid JSON';
+ }
+ },
+ });
+ if (patchJson == null) return;
+ return [
+ 'result',
+ await client.quote.update(
+ Number(googleAuthId),
+ Number(companyId),
+ Number(id),
+ JSON.parse(patchJson),
+ ),
+ ];
+ }
+ case 'quote.delete': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const id = await askInt('quote id');
+ if (id == null) return;
+ await client.quote.delete(
+ Number(googleAuthId),
+ Number(companyId),
+ Number(id),
+ );
+ return ['ok', true];
+ }
+ case 'order.list': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ return [
+ 'result[]',
+ await client.order.list(Number(googleAuthId), Number(companyId)),
+ ];
+ }
+ case 'order.getById': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const id = await askInt('order id');
+ if (id == null) return;
+ return [
+ 'result',
+ await client.order.getById(
+ Number(googleAuthId),
+ Number(companyId),
+ Number(id),
+ ),
+ ];
+ }
+ case 'order.create': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const bodyJson = await askText('order JSON', {
+ placeholder:
+ '{"number":"B-2026-001","date":"2026-09-15","entityId":12,"lines":[{"sku":"1","amount":10,"net":99.5,"tax":19}]}',
+ validate: (v) => {
+ if (!v?.trim()) return 'Required';
+ try {
+ const parsed = JSON.parse(v);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return 'Must be a JSON object';
+ }
+ } catch {
+ return 'Invalid JSON';
+ }
+ },
+ });
+ if (bodyJson == null) return;
+ return [
+ 'result',
+ await client.order.create(
+ Number(googleAuthId),
+ Number(companyId),
+ JSON.parse(bodyJson),
+ ),
+ ];
+ }
+ case 'order.update': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const id = await askInt('order id');
+ if (id == null) return;
+ const patchJson = await askText('patch JSON', {
+ placeholder: '{"number":"B-2026-002","lines":[]}',
+ validate: (v) => {
+ if (!v?.trim()) return 'Required';
+ try {
+ const parsed = JSON.parse(v);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return 'Must be a JSON object';
+ }
+ } catch {
+ return 'Invalid JSON';
+ }
+ },
+ });
+ if (patchJson == null) return;
+ return [
+ 'result',
+ await client.order.update(
+ Number(googleAuthId),
+ Number(companyId),
+ Number(id),
+ JSON.parse(patchJson),
+ ),
+ ];
+ }
+ case 'order.delete': {
+ const googleAuthId = await askInt('google_auth id');
+ if (googleAuthId == null) return;
+ const companyId = await askInt('company id');
+ if (companyId == null) return;
+ const id = await askInt('order id');
+ if (id == null) return;
+ await client.order.delete(
+ Number(googleAuthId),
+ Number(companyId),
+ Number(id),
+ );
+ return ['ok', true];
+ }
case 'lang.create': {
const iso639 = await askText('ISO 639', {
placeholder: 'de',
diff --git a/src/index.js b/src/index.js
index 3d17d3e..c0fbd7e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -65,6 +65,8 @@ import deliveryGetById from './store/delivery/getById.js';
import deliveryCreate from './store/delivery/create.js';
import deliveryUpdate from './store/delivery/update.js';
import deliveryDelete from './store/delivery/delete.js';
+import createQuoteStore from './store/quote/index.js';
+import createOrderStore from './store/order/index.js';
import posterminalList from './store/posterminal/list.js';
import posterminalUpsert from './store/posterminal/upsert.js';
import posterminalDelete from './store/posterminal/delete.js';
@@ -219,6 +221,9 @@ export default class LibDashStore {
delete: deliveryDelete(pool),
};
+ this.quote = createQuoteStore(pool);
+ this.order = createOrderStore(pool);
+
this.posterminal = {
list: posterminalList(pool),
upsert: posterminalUpsert(pool),
diff --git a/src/schema.js b/src/schema.js
index 3daa938..e3e94ef 100644
--- a/src/schema.js
+++ b/src/schema.js
@@ -541,6 +541,7 @@ BEGIN
transaction_type NVARCHAR(100) NOT NULL,
amount FLOAT NOT NULL,
transaction_text NVARCHAR(MAX) NOT NULL,
+ hash_version INT NOT NULL,
prev_hash NVARCHAR(128) NULL,
CONSTRAINT FK_ledger_manifestarticle FOREIGN KEY (manifestarticle_id) REFERENCES manifestarticle(id) ON DELETE NO ACTION
) WITH (LEDGER = ON (APPEND_ONLY = ON));
diff --git a/src/sql/entity.js b/src/sql/entity.js
index fe0053c..631f303 100644
--- a/src/sql/entity.js
+++ b/src/sql/entity.js
@@ -1,4 +1,5 @@
export const SUPPLIER_GROUP_NAME = 'Lieferanten';
+export const POS_GROUP_NAME = 'POS';
export const ENTITY_COLUMNS = `
e.id,
@@ -28,6 +29,17 @@ WHERE cg.company_id = @companyId
ORDER BY e.name, e.id
`;
+/** Entities whose group is not Lieferanten and not POS (customer picker). */
+export const LIST_CUSTOMERS_BY_COMPANY = `
+SELECT
+${ENTITY_COLUMNS}
+FROM entity e
+INNER JOIN customergroup cg ON cg.id = e.customergroup_id
+WHERE cg.company_id = @companyId
+ AND cg.name NOT IN (@supplierGroupName, @posGroupName)
+ORDER BY e.name, e.id
+`;
+
export const GET_BY_COMPANY_AND_ID = `
SELECT
${ENTITY_COLUMNS}
@@ -37,6 +49,17 @@ WHERE e.id = @id
AND cg.company_id = @companyId
`;
+/** Customer entity: company-scoped and not Lieferanten/POS. */
+export const GET_CUSTOMER_BY_ID = `
+SELECT
+${ENTITY_COLUMNS}
+FROM entity e
+INNER JOIN customergroup cg ON cg.id = e.customergroup_id
+WHERE e.id = @id
+ AND cg.company_id = @companyId
+ AND cg.name NOT IN (@supplierGroupName, @posGroupName)
+`;
+
export const LIST_SUPPLIERS_BY_COMPANY = `
SELECT
e.id,
diff --git a/src/sql/manifestDoc.js b/src/sql/manifestDoc.js
new file mode 100644
index 0000000..8078b5b
--- /dev/null
+++ b/src/sql/manifestDoc.js
@@ -0,0 +1,125 @@
+/**
+ * Shared SQL for typed manifest documents (quote / order).
+ * Delivery keeps its own SQL with supplierId naming.
+ */
+
+export const QUOTE_TYPE = 'quote';
+export const ORDER_TYPE = 'order';
+
+export const LIST_MANIFESTS = `
+SELECT
+ m.id,
+ m.[number],
+ m.[type],
+ m.entity_id AS entityId,
+ m.[date]
+FROM manifest m
+WHERE m.company_id = @companyId
+ AND m.[type] = @type
+ORDER BY m.[date] DESC, m.id DESC
+`;
+
+export const GET_MANIFEST_HEADER = `
+SELECT
+ m.id,
+ m.[number],
+ m.[type],
+ m.entity_id AS entityId,
+ m.[date]
+FROM manifest m
+WHERE m.id = @id
+ AND m.company_id = @companyId
+ AND m.[type] = @type
+`;
+
+export const GET_MANIFEST_LINES = `
+SELECT
+ ma.id,
+ ma.article_id AS articleId,
+ a.sku,
+ ma.amount,
+ ma.net,
+ ma.rebate,
+ t.rate AS tax,
+ ma.comment
+FROM manifestarticle ma
+LEFT JOIN article a ON a.id = ma.article_id
+INNER JOIN tax t ON t.id = ma.tax_id
+WHERE ma.manifest_id = @manifestId
+ORDER BY ma.id
+`;
+
+export const CREATE_MANIFEST = `
+INSERT INTO manifest (company_id, [number], [type], entity_id, [date])
+OUTPUT INSERTED.id
+VALUES (@companyId, @number, @type, @entityId, @date)
+`;
+
+export const UPDATE_MANIFEST = `
+UPDATE manifest
+SET
+ [number] = @number,
+ entity_id = @entityId,
+ [date] = @date
+WHERE id = @id
+ AND company_id = @companyId
+ AND [type] = @type
+`;
+
+export const DELETE_MANIFEST = `
+DELETE FROM manifest
+WHERE id = @id
+ AND company_id = @companyId
+ AND [type] = @type
+`;
+
+export const DELETE_LINES_BY_MANIFEST = `
+DELETE FROM manifestarticle
+WHERE manifest_id = @manifestId
+`;
+
+export const CREATE_LINE = `
+INSERT INTO manifestarticle (
+ manifest_id,
+ article_id,
+ amount,
+ net,
+ comment,
+ rebate,
+ tax_id
+)
+OUTPUT INSERTED.id
+VALUES (
+ @manifestId,
+ @articleId,
+ @amount,
+ @net,
+ @comment,
+ @rebate,
+ @taxId
+)
+`;
+
+/** Resolve SKU to article id if reachable via the company category tree. */
+export const RESOLVE_ARTICLE_ID_BY_SKU = `
+;WITH category_tree AS (
+ SELECT id
+ FROM category
+ WHERE id = @rootCategoryId
+
+ UNION ALL
+
+ SELECT c.id
+ FROM category c
+ INNER JOIN category_tree ct ON c.parent_id = ct.id
+)
+SELECT TOP 1 a.id
+FROM article a
+WHERE a.sku = @sku
+ AND EXISTS (
+ SELECT 1
+ FROM categoryarticle ca
+ INNER JOIN category_tree ct ON ct.id = ca.category_id
+ WHERE ca.article_id = a.id
+)
+`;
diff --git a/src/store/entity/list.js b/src/store/entity/list.js
index 2aa3703..34a1d59 100644
--- a/src/store/entity/list.js
+++ b/src/store/entity/list.js
@@ -1,6 +1,9 @@
import {
LIST_BY_COMPANY,
LIST_BY_COMPANY_AND_GROUP,
+ LIST_CUSTOMERS_BY_COMPANY,
+ POS_GROUP_NAME,
+ SUPPLIER_GROUP_NAME,
} from '../../sql/entity.js';
import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js';
@@ -20,9 +23,27 @@ export default function list(pool) {
const access = resolveCompanyAccess(pool);
const groupAccess = resolveCustomerGroupAccess(pool);
- return async (googleAuthId, companyId, { customergroupId } = {}) => {
+ return async (
+ googleAuthId,
+ companyId,
+ { customergroupId, role } = {},
+ ) => {
await access(googleAuthId, companyId);
+ if (role != null && role !== 'customer') {
+ throw new Error('Invalid role');
+ }
+
+ if (role === 'customer') {
+ const result = await pool
+ .request()
+ .input('companyId', companyId)
+ .input('supplierGroupName', SUPPLIER_GROUP_NAME)
+ .input('posGroupName', POS_GROUP_NAME)
+ .query(LIST_CUSTOMERS_BY_COMPANY);
+ return result.recordset.map(shape);
+ }
+
if (customergroupId != null) {
await groupAccess(companyId, customergroupId);
const result = await pool
diff --git a/src/store/manifestDoc/createStore.js b/src/store/manifestDoc/createStore.js
new file mode 100644
index 0000000..6adc9b3
--- /dev/null
+++ b/src/store/manifestDoc/createStore.js
@@ -0,0 +1,389 @@
+import {
+ CREATE_LINE,
+ CREATE_MANIFEST,
+ DELETE_LINES_BY_MANIFEST,
+ DELETE_MANIFEST,
+ GET_MANIFEST_HEADER,
+ GET_MANIFEST_LINES,
+ LIST_MANIFESTS,
+ RESOLVE_ARTICLE_ID_BY_SKU,
+ UPDATE_MANIFEST,
+} from '../../sql/manifestDoc.js';
+import {
+ GET_CUSTOMER_BY_ID,
+ POS_GROUP_NAME,
+ SUPPLIER_GROUP_NAME,
+} from '../../sql/entity.js';
+import request from '../request.js';
+import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
+import taxEnsure from '../tax/ensure.js';
+
+function shapeSummary(type, row) {
+ return {
+ id: row.id,
+ number: row.number,
+ type,
+ entityId: row.entityId ?? null,
+ date: row.date,
+ };
+}
+
+function shapeLine(row) {
+ return {
+ id: row.id,
+ articleId: row.articleId ?? null,
+ sku: row.sku ?? null,
+ amount: row.amount,
+ net: row.net,
+ rebate: row.rebate,
+ tax: row.tax,
+ comment: row.comment ?? null,
+ };
+}
+
+function parseDate(value) {
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
+ return value;
+ }
+ if (typeof value === 'string' && value.trim()) {
+ const d = new Date(value);
+ if (!Number.isNaN(d.getTime())) {
+ return d;
+ }
+ }
+ return null;
+}
+
+function normalizeLines(lines) {
+ if (!Array.isArray(lines)) {
+ throw new Error('Invalid lines');
+ }
+
+ return lines.map((entry) => {
+ const sku = entry?.sku?.trim?.() ? entry.sku.trim() : null;
+ if (!sku) {
+ throw new Error('Invalid lines');
+ }
+
+ const amount = entry?.amount;
+ const net = entry?.net;
+ const rebate =
+ entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate;
+ const tax = entry?.tax;
+ const comment =
+ entry?.comment === undefined || entry?.comment === null
+ ? null
+ : String(entry.comment);
+
+ if (
+ !Number.isFinite(amount) ||
+ !Number.isFinite(net) ||
+ !Number.isFinite(rebate) ||
+ !Number.isFinite(tax)
+ ) {
+ throw new Error('Invalid lines');
+ }
+
+ return { sku, amount, net, rebate, tax, comment };
+ });
+}
+
+async function assertCustomerEntity(pool, tx, companyId, entityId) {
+ if (entityId == null) {
+ return null;
+ }
+ if (!Number.isFinite(entityId)) {
+ throw new Error('Entity not found');
+ }
+
+ const result = await request(pool, tx)
+ .input('id', entityId)
+ .input('companyId', companyId)
+ .input('supplierGroupName', SUPPLIER_GROUP_NAME)
+ .input('posGroupName', POS_GROUP_NAME)
+ .query(GET_CUSTOMER_BY_ID);
+
+ if (!result.recordset[0]) {
+ throw new Error('Entity not found');
+ }
+ return entityId;
+}
+
+async function resolveArticleId(pool, tx, rootCategoryId, sku) {
+ const result = await request(pool, tx)
+ .input('rootCategoryId', rootCategoryId)
+ .input('sku', sku)
+ .query(RESOLVE_ARTICLE_ID_BY_SKU);
+
+ const articleId = result.recordset[0]?.id;
+ if (!articleId) {
+ throw new Error('Article not found');
+ }
+ return articleId;
+}
+
+async function insertLines(
+ pool,
+ tx,
+ { manifestId, rootCategoryId, lines, ensureTax },
+) {
+ for (const line of lines) {
+ const articleId = await resolveArticleId(
+ pool,
+ tx,
+ rootCategoryId,
+ line.sku,
+ );
+ const taxId = await ensureTax(line.tax, tx);
+
+ await request(pool, tx)
+ .input('manifestId', manifestId)
+ .input('articleId', articleId)
+ .input('amount', line.amount)
+ .input('net', line.net)
+ .input('comment', line.comment)
+ .input('rebate', line.rebate)
+ .input('taxId', taxId)
+ .query(CREATE_LINE);
+ }
+}
+
+async function loadDoc(pool, tx, { type, companyId, id }) {
+ const header = await request(pool, tx)
+ .input('id', id)
+ .input('companyId', companyId)
+ .input('type', type)
+ .query(GET_MANIFEST_HEADER);
+
+ const row = header.recordset[0];
+ if (!row) {
+ return null;
+ }
+
+ const lines = await request(pool, tx)
+ .input('manifestId', id)
+ .query(GET_MANIFEST_LINES);
+
+ return {
+ ...shapeSummary(type, row),
+ lines: lines.recordset.map(shapeLine),
+ };
+}
+
+/**
+ * Factory for quote / order (and similar) stores on manifest / manifestarticle.
+ *
+ * @param {object} pool
+ * @param {{ type: string, label: string }} options
+ * `type` → manifest.type (`quote` / `order`)
+ * `label` → error prefix (`Quote` / `Order` → "Quote not found")
+ */
+export default function createManifestDocStore(pool, { type, label }) {
+ const access = resolveCompanyAccess(pool);
+ const ensureTax = taxEnsure(pool);
+ const notFound = `${label} not found`;
+ const invalid = `Invalid ${label.toLowerCase()}`;
+
+ async function list(googleAuthId, companyId) {
+ await access(googleAuthId, companyId);
+
+ const result = await pool
+ .request()
+ .input('companyId', companyId)
+ .input('type', type)
+ .query(LIST_MANIFESTS);
+
+ return result.recordset.map((row) => shapeSummary(type, row));
+ }
+
+ async function getById(googleAuthId, companyId, id) {
+ if (!Number.isFinite(id)) {
+ throw new Error(notFound);
+ }
+
+ await access(googleAuthId, companyId);
+
+ const doc = await loadDoc(pool, null, { type, companyId, id });
+ if (!doc) {
+ throw new Error(notFound);
+ }
+ return doc;
+ }
+
+ async function create(googleAuthId, companyId, body = {}) {
+ const number = body.number?.trim?.() ? body.number.trim() : null;
+ const date = parseDate(body.date);
+ if (!number || !date) {
+ throw new Error(invalid);
+ }
+
+ const lines = normalizeLines(body.lines ?? []);
+ const { rootCategoryId } = await access(googleAuthId, companyId);
+
+ const tx = pool.transaction();
+ await tx.begin();
+
+ try {
+ const entityId = await assertCustomerEntity(
+ pool,
+ tx,
+ companyId,
+ body.entityId ?? null,
+ );
+
+ const created = await request(pool, tx)
+ .input('companyId', companyId)
+ .input('number', number)
+ .input('type', type)
+ .input('entityId', entityId)
+ .input('date', date)
+ .query(CREATE_MANIFEST);
+
+ const manifestId = created.recordset[0].id;
+
+ await insertLines(pool, tx, {
+ manifestId,
+ rootCategoryId,
+ lines,
+ ensureTax,
+ });
+
+ const doc = await loadDoc(pool, tx, { type, companyId, id: manifestId });
+ await tx.commit();
+ return doc;
+ } catch (err) {
+ try {
+ await tx.rollback();
+ } catch {
+ // ignore
+ }
+ throw err;
+ }
+ }
+
+ async function update(googleAuthId, companyId, id, patch = {}) {
+ if (!Number.isFinite(id)) {
+ throw new Error(notFound);
+ }
+
+ const hasNumber = Object.prototype.hasOwnProperty.call(patch, 'number');
+ const hasDate = Object.prototype.hasOwnProperty.call(patch, 'date');
+ const hasEntityId = Object.prototype.hasOwnProperty.call(patch, 'entityId');
+ const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines');
+
+ if (!hasNumber && !hasDate && !hasEntityId && !hasLines) {
+ throw new Error(invalid);
+ }
+
+ let normalizedLines;
+ if (hasLines) {
+ normalizedLines = normalizeLines(patch.lines);
+ }
+
+ const { rootCategoryId } = await access(googleAuthId, companyId);
+
+ const tx = pool.transaction();
+ await tx.begin();
+
+ try {
+ const existing = await request(pool, tx)
+ .input('id', id)
+ .input('companyId', companyId)
+ .input('type', type)
+ .query(GET_MANIFEST_HEADER);
+
+ const current = existing.recordset[0];
+ if (!current) {
+ throw new Error(notFound);
+ }
+
+ const summary = shapeSummary(type, current);
+
+ let number = summary.number;
+ if (hasNumber) {
+ number = patch.number?.trim?.() ? patch.number.trim() : null;
+ if (!number) {
+ throw new Error(invalid);
+ }
+ }
+
+ let date = summary.date;
+ if (hasDate) {
+ date = parseDate(patch.date);
+ if (!date) {
+ throw new Error(invalid);
+ }
+ }
+
+ let entityId = summary.entityId;
+ if (hasEntityId) {
+ entityId = await assertCustomerEntity(
+ pool,
+ tx,
+ companyId,
+ patch.entityId ?? null,
+ );
+ }
+
+ await request(pool, tx)
+ .input('id', id)
+ .input('companyId', companyId)
+ .input('type', type)
+ .input('number', number)
+ .input('entityId', entityId)
+ .input('date', date)
+ .query(UPDATE_MANIFEST);
+
+ if (hasLines) {
+ await request(pool, tx)
+ .input('manifestId', id)
+ .query(DELETE_LINES_BY_MANIFEST);
+
+ await insertLines(pool, tx, {
+ manifestId: id,
+ rootCategoryId,
+ lines: normalizedLines,
+ ensureTax,
+ });
+ }
+
+ const doc = await loadDoc(pool, tx, { type, companyId, id });
+ await tx.commit();
+ return doc;
+ } catch (err) {
+ try {
+ await tx.rollback();
+ } catch {
+ // ignore
+ }
+ throw err;
+ }
+ }
+
+ async function del(googleAuthId, companyId, id) {
+ if (!Number.isFinite(id)) {
+ throw new Error(notFound);
+ }
+
+ await access(googleAuthId, companyId);
+
+ const result = await pool
+ .request()
+ .input('id', id)
+ .input('companyId', companyId)
+ .input('type', type)
+ .query(DELETE_MANIFEST);
+
+ if (!result.rowsAffected[0]) {
+ throw new Error(notFound);
+ }
+ }
+
+ return {
+ list,
+ getById,
+ create,
+ update,
+ delete: del,
+ };
+}
diff --git a/src/store/order/index.js b/src/store/order/index.js
new file mode 100644
index 0000000..e8c5582
--- /dev/null
+++ b/src/store/order/index.js
@@ -0,0 +1,6 @@
+import { ORDER_TYPE } from '../../sql/manifestDoc.js';
+import createManifestDocStore from '../manifestDoc/createStore.js';
+
+export default function createOrderStore(pool) {
+ return createManifestDocStore(pool, { type: ORDER_TYPE, label: 'Order' });
+}
diff --git a/src/store/quote/index.js b/src/store/quote/index.js
new file mode 100644
index 0000000..6cee215
--- /dev/null
+++ b/src/store/quote/index.js
@@ -0,0 +1,6 @@
+import { QUOTE_TYPE } from '../../sql/manifestDoc.js';
+import createManifestDocStore from '../manifestDoc/createStore.js';
+
+export default function createQuoteStore(pool) {
+ return createManifestDocStore(pool, { type: QUOTE_TYPE, label: 'Quote' });
+}