From b095ab61993221f355ee3f4a343a0d6627d20c67 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 6 Aug 2026 19:58:35 +0200 Subject: [PATCH] Add quote and order stores plus ledger hash_version. Mirror delivery on manifest for quote/order with customer entityId (excluding Lieferanten/POS), and support entity.list role=customer for pickers. --- README.md | 88 +++++- schema-graph.dot | 2 +- schema-graph.svg | 68 ++--- scripts/test-api.js | 296 +++++++++++++++++++- src/index.js | 5 + src/schema.js | 1 + src/sql/entity.js | 23 ++ src/sql/manifestDoc.js | 125 +++++++++ src/store/entity/list.js | 23 +- src/store/manifestDoc/createStore.js | 389 +++++++++++++++++++++++++++ src/store/order/index.js | 6 + src/store/quote/index.js | 6 + 12 files changed, 987 insertions(+), 45 deletions(-) create mode 100644 src/sql/manifestDoc.js create mode 100644 src/store/manifestDoc/createStore.js create mode 100644 src/store/order/index.js create mode 100644 src/store/quote/index.js 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
idINT PK
manifestarticle_idINT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
prev_hash?NVARCHAR(128)
>]; + ledger [label=<
ledger
idINT PK
manifestarticle_idINT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
hash_versionINT
prev_hash?NVARCHAR(128)
>]; manifestarticle [label=<
manifestarticle
idINT PK
manifest_idINT FK
tax_idINT FK
article_id?INT FK
amountFLOAT
netFLOAT
comment?NVARCHAR(MAX)
rebateFLOAT
>]; manifest [label=<
manifest
idINT PK
entity_id?INT FK
company_idINT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
dateDATETIME2
>]; price [label=<
price
idINT PK
tax_idINT FK
customergroup_idINT FK
article_idINT FK
minmultiplierDECIMAL(18,4)
netFLOAT
>]; 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' }); +}