From 8cc7d0eb0bb8be75e5d3b7b1b4f176cfc8ba460b Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 18 Oct 2026 20:29:51 +0200 Subject: [PATCH] u --- API.md | 2 +- API.order.md | 6 +++--- scripts/test-manifest-two-dates.js | 11 +++++++++++ src/providers/mssql/schema.js | 6 ++++++ src/providers/sqlite3/schema.js | 2 ++ src/store/delivery/create.js | 15 ++++++++++++++- src/store/delivery/update.js | 22 +++++++++++++++++++--- src/store/manifestDoc/helpers.js | 29 +++++++++++++++++++++++++++++ src/store/order/create.js | 21 ++++++++++++++++----- src/store/order/createFromQuote.js | 9 ++++++++- src/store/order/update.js | 14 ++++++++++++-- src/store/quote/create.js | 9 ++++++++- src/store/quote/update.js | 9 ++++++++- src/store/shipment/create.js | 8 ++++++++ test/delivery.test.js | 27 +++++++++++++++++++++------ 15 files changed, 166 insertions(+), 24 deletions(-) diff --git a/API.md b/API.md index 2021aa6..098115b 100644 --- a/API.md +++ b/API.md @@ -207,7 +207,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `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) - - `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}>}>` + - `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, 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}>}>` - `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}>}, 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}>}>` - `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}, tx?: object): Promise` - `delete(googleAuthId: number, id: number, tx?: object): Promise` diff --git a/API.order.md b/API.order.md index bc6e897..b51a891 100644 --- a/API.order.md +++ b/API.order.md @@ -16,18 +16,18 @@ Creates a new order for the user's selected company with its lines and records a | `body` | `object` | The order input. | | `body.number` | `string` | The order number. | | `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.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | The order lines. | | `[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. +`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** -`Error` — If the order is invalid. +`Error` — If the order is invalid, no entity is selected, or entity not found. **Example** diff --git a/scripts/test-manifest-two-dates.js b/scripts/test-manifest-two-dates.js index 0c14d5c..788a11f 100644 --- a/scripts/test-manifest-two-dates.js +++ b/scripts/test-manifest-two-dates.js @@ -21,6 +21,15 @@ async function testProvider(name, provider) { }); 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'; @@ -28,6 +37,7 @@ async function testProvider(name, provider) { const createdDelivery = await store.delivery.create(googleAuthId, { externalNumber: 'DEL-2DATES-1', + supplierId: supplier.id, date: deliveryDate, createdAt: customCreatedAt, lines: [ @@ -98,6 +108,7 @@ async function testProvider(name, provider) { 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'); diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index ef31e2d..93f46cf 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -646,6 +646,12 @@ BEGIN CONSTRAINT FK_manifest_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION ); END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UQ_manifest_company_type_number' AND object_id = OBJECT_ID('manifest')) +BEGIN + CREATE UNIQUE NONCLUSTERED INDEX UQ_manifest_company_type_number + ON manifest (company_id, [type], [number]); +END `; export const INIT_MANIFESTARTICLE = ` diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index c637bec..ea01691 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -432,6 +432,8 @@ CREATE TABLE IF NOT EXISTS manifest ( FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION ); +CREATE UNIQUE INDEX IF NOT EXISTS UQ_manifest_company_type_number + ON manifest (company_id, "type", "number"); `; export const INIT_MANIFESTARTICLE = ` diff --git a/src/store/delivery/create.js b/src/store/delivery/create.js index 1b6fd84..5a07bd6 100644 --- a/src/store/delivery/create.js +++ b/src/store/delivery/create.js @@ -8,6 +8,7 @@ import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { loadDelivery } from './helpers.js'; import { insertAttachments } from './attachments.js'; import { assertSupplier } from './writeLines.js'; +import { assertUniqueManifestNumber } from '../manifestDoc/helpers.js'; import makeRunner from '../runner.js'; export default function create(provider) { @@ -35,15 +36,27 @@ export default function create(provider) { const { companyId } = await access(googleAuthId); const run = async (runner, tx) => { + const targetSupplierId = body.supplierId !== undefined ? body.supplierId : body.entityId; + if (targetSupplierId == null) { + throw new Error('No supplier selected'); + } + const supplierId = await assertSupplier( provider, tx, companyId, - body.supplierId ?? null, + targetSupplierId, ); const number = await allocateNumber(companyId, queries.DELIVERY_TYPE, tx); + await assertUniqueManifestNumber(provider, tx, { + companyId, + type: queries.DELIVERY_TYPE, + number, + errorMessage: 'Delivery number already exists', + }); + const created = await runner(queries.CREATE_MANIFEST, { companyId, number, diff --git a/src/store/delivery/update.js b/src/store/delivery/update.js index 660e1d7..d1b6aad 100644 --- a/src/store/delivery/update.js +++ b/src/store/delivery/update.js @@ -8,6 +8,7 @@ import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { loadDelivery, shapeSummary } from './helpers.js'; import { insertAttachments } from './attachments.js'; import { assertSupplier } from './writeLines.js'; +import { assertUniqueManifestNumber } from '../manifestDoc/helpers.js'; import makeRunner from '../runner.js'; export default function update(provider) { @@ -28,11 +29,15 @@ export default function update(provider) { patch, 'supplierId', ); + const hasEntityId = Object.prototype.hasOwnProperty.call( + patch, + 'entityId', + ); const hasExternalNumber = Object.prototype.hasOwnProperty.call(patch, 'externalNumber'); const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines'); const hasFiles = Object.prototype.hasOwnProperty.call(patch, 'files'); - if (!hasNumber && !hasDate && !hasCreatedAt && !hasSupplierId && !hasExternalNumber && !hasLines && !hasFiles) { + if (!hasNumber && !hasDate && !hasCreatedAt && !hasSupplierId && !hasEntityId && !hasExternalNumber && !hasLines && !hasFiles) { throw new Error('Invalid delivery'); } @@ -63,6 +68,13 @@ export default function update(provider) { if (!number) { throw new Error('Invalid delivery'); } + await assertUniqueManifestNumber(provider, tx, { + companyId, + type: queries.DELIVERY_TYPE, + number, + excludeId: id, + errorMessage: 'Delivery number already exists', + }); } let date = summary.date; @@ -82,12 +94,16 @@ export default function update(provider) { } let supplierId = summary.supplierId; - if (hasSupplierId) { + if (hasSupplierId || hasEntityId) { + const val = hasSupplierId ? patch.supplierId : patch.entityId; + if (val == null) { + throw new Error('No supplier selected'); + } supplierId = await assertSupplier( provider, tx, companyId, - patch.supplierId ?? null, + val, ); } diff --git a/src/store/manifestDoc/helpers.js b/src/store/manifestDoc/helpers.js index 2aaa909..ddaa7f5 100644 --- a/src/store/manifestDoc/helpers.js +++ b/src/store/manifestDoc/helpers.js @@ -104,3 +104,32 @@ export async function loadDoc(runnerOrPool, txOrParams, maybeParams) { lines: lines.rows.map(shapeLine), }; } + +export async function assertUniqueManifestNumber(runnerOrPool, txOrParams, maybeParams) { + let runner; + let params; + + if (maybeParams !== undefined) { + runner = makeRunner(runnerOrPool, txOrParams); + params = maybeParams; + } else { + runner = makeRunner(runnerOrPool); + params = txOrParams; + } + + const { companyId, type, number, excludeId, errorMessage } = params; + if (!number) { + return; + } + + const isSqlite = runner?.dialect === 'sqlite3'; + const sql = isSqlite + ? 'SELECT id FROM manifest WHERE company_id = @companyId AND "type" = @type AND "number" = @number' + : 'SELECT id FROM manifest WHERE company_id = @companyId AND [type] = @type AND [number] = @number'; + + const res = await runner.query(sql, { companyId, type, number }); + const row = res.rows[0]; + if (row && (!excludeId || row.id !== excludeId)) { + throw new Error(errorMessage || `${type.charAt(0).toUpperCase() + type.slice(1)} number already exists`); + } +} diff --git a/src/store/order/create.js b/src/store/order/create.js index 63e86fb..0e0de23 100644 --- a/src/store/order/create.js +++ b/src/store/order/create.js @@ -15,7 +15,7 @@ import { import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; -import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc } from '../manifestDoc/helpers.js'; import { recordOrderHistory } from '../articlehistory/record.js'; import { syncOrderSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; @@ -36,12 +36,12 @@ export default function create(provider) { * @param {object} body - The order input. * @param {string} body.number - The order number. * @param {string} body.date - The order date. - * @param {number} [body.entityId] - The id of the customer entity. + * @param {number} body.entityId - The id of the customer entity. * @param {string} [body.externalNumber] - An external (customer) reference number. * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - The order lines. * @param {object} [tx] - 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 order is invalid. + * @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 order. + * @throws {Error} If the order is invalid, no entity is selected, or entity not found. * * @example * const order = await store.order.create(42, { number: 'B-2026-001', date: '2026-09-15', entityId: 12, lines: [...] }); @@ -66,12 +66,23 @@ export default function create(provider) { const runCreate = async (runner) => { const { companyId, companyuserId } = await access(googleAuthId, runner); + if (body.entityId == null) { + throw new Error('No entity selected'); + } + const entityId = await assertCustomerEntity( runner, companyId, - body.entityId ?? null, + body.entityId, ); + await assertUniqueManifestNumber(runner, { + companyId, + type: orderType, + number, + errorMessage: 'Order number already exists', + }); + const created = await runner.query(createManifestSql, { companyId, number, diff --git a/src/store/order/createFromQuote.js b/src/store/order/createFromQuote.js index 8f23dcb..787ddb2 100644 --- a/src/store/order/createFromQuote.js +++ b/src/store/order/createFromQuote.js @@ -18,7 +18,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import nextNumber from '../numbers/nextNumber.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; -import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc } from '../manifestDoc/helpers.js'; import { recordOrderHistory } from '../articlehistory/record.js'; import { syncOrderSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; @@ -90,6 +90,13 @@ export default function createFromQuote(provider) { number = await allocateNumber(companyId, orderType, runner); } + await assertUniqueManifestNumber(runner, { + companyId, + type: orderType, + number, + errorMessage: 'Order number already exists', + }); + const targetEntityId = body.entityId !== undefined ? body.entityId : quoteDoc.entityId; if (targetEntityId == null) { throw new Error('No entity selected'); diff --git a/src/store/order/update.js b/src/store/order/update.js index 212c3e0..cb9bb4b 100644 --- a/src/store/order/update.js +++ b/src/store/order/update.js @@ -13,7 +13,7 @@ import { import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; -import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; import { syncOrderSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; @@ -89,6 +89,13 @@ export default function update(provider) { if (!number) { throw new Error('Invalid order'); } + await assertUniqueManifestNumber(runner, { + companyId, + type: orderType, + number, + excludeId: id, + errorMessage: 'Order number already exists', + }); } let date = summary.date; @@ -109,10 +116,13 @@ export default function update(provider) { let entityId = summary.entityId; if (hasEntityId) { + if (patch.entityId == null) { + throw new Error('No entity selected'); + } entityId = await assertCustomerEntity( runner, companyId, - patch.entityId ?? null, + patch.entityId, ); } diff --git a/src/store/quote/create.js b/src/store/quote/create.js index 5289f98..bce3644 100644 --- a/src/store/quote/create.js +++ b/src/store/quote/create.js @@ -9,7 +9,7 @@ import { import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; -import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc } from '../manifestDoc/helpers.js'; import { syncQuoteSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; @@ -64,6 +64,13 @@ export default function create(provider) { body.entityId ?? null, ); + await assertUniqueManifestNumber(runner, { + companyId, + type: quoteType, + number, + errorMessage: 'Quote number already exists', + }); + const created = await runner.query(createManifestSql, { companyId, number, diff --git a/src/store/quote/update.js b/src/store/quote/update.js index 61c9a42..48330b6 100644 --- a/src/store/quote/update.js +++ b/src/store/quote/update.js @@ -13,7 +13,7 @@ import { import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; -import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; import { syncQuoteSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; @@ -89,6 +89,13 @@ export default function update(provider) { if (!number) { throw new Error('Invalid quote'); } + await assertUniqueManifestNumber(runner, { + companyId, + type: quoteType, + number, + excludeId: id, + errorMessage: 'Quote number already exists', + }); } let date = summary.date; diff --git a/src/store/shipment/create.js b/src/store/shipment/create.js index b2b9485..810e01d 100644 --- a/src/store/shipment/create.js +++ b/src/store/shipment/create.js @@ -1,6 +1,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, parseDate } from '../manifestLines.js'; +import { assertUniqueManifestNumber } from '../manifestDoc/helpers.js'; import makeRunner from '../runner.js'; import { loadShipment, queriesFor } from './helpers.js'; import { syncOrderSummary } from '../order/syncSummary.js'; @@ -212,6 +213,13 @@ export default function create(provider) { const nextY = Math.max(existingShipments.length + 1, maxY + 1); const shipmentNumber = `${order.number}${shipmentPrefix}${nextY}`; + await assertUniqueManifestNumber(runner, { + companyId, + type: 'shipment', + number: shipmentNumber, + errorMessage: 'Shipment number already exists', + }); + // 7. Create shipment manifest header const createdRes = await runner.query(q.CREATE_MANIFEST, { companyId, diff --git a/test/delivery.test.js b/test/delivery.test.js index 070e351..d803afe 100644 --- a/test/delivery.test.js +++ b/test/delivery.test.js @@ -65,14 +65,17 @@ describe('store.delivery', () => { assert.notEqual(a.number, b.number); }); - it('allows a delivery without supplier', async () => { - const d = await store.delivery.create(gid, { date: isoNow(), lines: [] }); - assert.equal(d.supplierId, null); + it('rejects a delivery without supplier', async () => { + await assert.rejects( + () => store.delivery.create(gid, { date: isoNow(), lines: [] }), + { message: 'No supplier selected' }, + ); }); it('validates input', async () => { await assert.rejects(() => create({ date: 'garbage' }), { message: 'Invalid delivery' }); await assert.rejects(() => create({ createdAt: 'garbage' }), { message: 'Invalid delivery' }); + await assert.rejects(() => create({ supplierId: null }), { message: 'No supplier selected' }); await assert.rejects(() => create({ supplierId: '1' }), { message: 'Invalid supplierId' }); await assert.rejects(() => create({ supplierId: 999999 }), { message: 'Supplier not found' }); await assert.rejects(() => create({ lines: 'x' }), { message: 'Invalid lines' }); @@ -113,8 +116,10 @@ describe('store.delivery', () => { const cleared = await store.delivery.update(gid, d.id, { externalNumber: ' ' }); assert.equal(cleared.externalNumber, null); - const noSupplier = await store.delivery.update(gid, d.id, { supplierId: null }); - assert.equal(noSupplier.supplierId, null); + await assert.rejects( + () => store.delivery.update(gid, d.id, { supplierId: null }), + { message: 'No supplier selected' }, + ); }); it('replaces lines of an unbooked delivery', async () => { @@ -133,6 +138,15 @@ describe('store.delivery', () => { assert.equal((await store.delivery.getById(gid, d.id)).lines[0].amount, 4); }); + it('refuses duplicate delivery numbers on update', async () => { + const d1 = await create(); + const d2 = await create(); + await assert.rejects( + () => store.delivery.update(gid, d2.id, { number: d1.number }), + { message: 'Delivery number already exists' }, + ); + }); + it('validates input', async () => { const d = await create(); await assert.rejects(() => store.delivery.update(gid, d.id, {}), { message: 'Invalid delivery' }); @@ -287,7 +301,8 @@ describe('store.delivery', () => { const rt = await createTenant(store, { name: 'Delivery Rebuild GmbH' }); await store.warehouse.create(rt.googleAuthId, { name: 'Rebuild WH' }); await createArticle(store, rt, { sku: 'RB-1', track_stock: true }); - const d = await store.delivery.create(rt.googleAuthId, { date: isoNow(), lines: [line('RB-1', 3)] }); + const rs = await store.supplier.create(rt.googleAuthId, { name: 'Rebuild Supplier' }); + const d = await store.delivery.create(rt.googleAuthId, { supplierId: rs.id, date: isoNow(), lines: [line('RB-1', 3)] }); await store.delivery.book(rt.googleAuthId, d.id); const before = await store.delivery.list(rt.googleAuthId);