diff --git a/API.md b/API.md index 9751835..97ba492 100644 --- a/API.md +++ b/API.md @@ -209,7 +209,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [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, 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` + - `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` - `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number}, tx?: object): Promise<{orders: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` @@ -220,7 +220,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `create(provider: import('../../providers/provider.js').DatabaseProvider): void` - `delete(provider: import('../../providers/provider.js').DatabaseProvider): void` - `getById(provider: import('../../providers/provider.js').DatabaseProvider): void` - - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, state?: 'new'|'delivered'|'returned', seekMonths?: number, orderId?: number}, tx?: object): Promise<{shipments: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` + - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, state?: 'new'|'delivered'|'returned'|'cancelled'|'all', includeCancelled?: boolean, seekMonths?: number, orderId?: number}, tx?: object): Promise<{shipments: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` - `listByOrderId(provider: import('../../providers/provider.js').DatabaseProvider): void` - `rebuildSummaries(googleAuthId: number, tx?: object): Promise` - `syncSummary(): void` diff --git a/API.order.md b/API.order.md index ef11405..4fc62af 100644 --- a/API.order.md +++ b/API.order.md @@ -76,7 +76,7 @@ const order = await store.order.createFromQuote(42, 10); *Source: `src/store/order/createShipment.js`* -Creates a new shipment manifest for an order. Can contain one, some, or all items from the parent order. If lines is omitted or empty, all remaining unshipped items are included. +Creates a new shipment manifest for an order. Can contain specified items from the parent order. **Parameters** @@ -84,8 +84,8 @@ Creates a new shipment manifest for an order. Can contain one, some, or all item | --- | --- | --- | | `googleAuthId` | `number` | The id of the Google auth user. | | `orderId` | `number` | The parent order manifest id. | -| `[input]` | `object` | Optional overrides. | -| `[input.lines]` | `Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>` | Positions (defaults to all remaining items). | +| `input` | `object` | Shipment input. | +| `input.lines` | `Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>` | Positions to ship. | | `[input.date]` | `string|Date` | Optional shipment date. | | `[input.externalNumber]` | `string` | Optional external delivery reference. | | `[tx]` | `object` | Optional transaction. | @@ -97,7 +97,7 @@ Creates a new shipment manifest for an order. Can contain one, some, or all item **Example** ```js -const shipment = await store.order.createShipment(googleAuthId, 10); +const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] }); ``` ## `order.delete` diff --git a/API.shipment.md b/API.shipment.md index b986a4d..bc76444 100644 --- a/API.shipment.md +++ b/API.shipment.md @@ -18,7 +18,7 @@ Creates a new shipment manifest for an order. Validates that all article positio *Source: `src/store/shipment/delete.js`* -Deletes a shipment manifest of the selected company. Shipped shipments (with packages in 'shipped' state) cannot be deleted. +Deletes or cancels a shipment manifest of the selected company. - If no ledger rows exist: physically deletes the shipment manifest. - If ledger rows exist: reverses the ledger entries (storno document entry + warehouse stock adjustment), marks the shipment as 'cancelled' in shipmentsummary, and updates the parent order's summary. - Shipped shipments (with packages in 'shipped' state) cannot be deleted. **Parameters** @@ -53,7 +53,8 @@ Lists the shipments of the user's selected company. | `[opts.offset=0]` | `number` | Number of shipments to skip. | | `[opts.limit=50]` | `number` | Number of shipments to return. | | `[opts.search]` | `string` | Free-text search matching shipment number, order number, external number, tracking code, or customer name. | -| `[opts.state]` | `'new'|'delivered'|'returned'` | Filter by delivery state. | +| `[opts.state]` | `'new'|'delivered'|'returned'|'cancelled'|'all'` | Filter by delivery state. | +| `[opts.includeCancelled=false]` | `boolean` | Whether to include cancelled shipments when state is not set. | | `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). | | `[opts.orderId]` | `number` | Optional filter for parent order manifest id. | | `[tx]` | `object` | Optional transaction. | diff --git a/scripts/test-ledger.js b/scripts/test-ledger.js index 633f9b6..4a38c3c 100644 --- a/scripts/test-ledger.js +++ b/scripts/test-ledger.js @@ -403,6 +403,40 @@ async function run() { 'Adjust must fail when article has no stock tracking', ); + // 10b. Test prevention of negative stock on shipment creation and stock adjust + console.log('10b. Testing prevention of negative stock on shipment creation and adjust...'); + const guardOrder = await store.order.create(googleAuthId, { + number: 'ORD-GUARD-1', + date: '2026-10-20', + entityId: customer.id, + lines: [{ sku: article.sku, name: 'Order Line', pu: 1, amount: 9999, net: 10, taxrate: 19 }], + }); + + const currentArt = await store.article.getBySku(googleAuthId, article.sku); + const curStock = currentArt.article.warehouses.find((w) => w.id === wh1.id)?.stock ?? 0; + + // Explicit shipment requesting more than available stock must be rejected + await assert.rejects( + async () => store.shipment.create(googleAuthId, { + orderId: guardOrder.id, + warehouseId: wh1.id, + lines: [{ sku: article.sku, amount: curStock + 10 }], + }), + /Insufficient stock for article/, + 'Shipment creation must fail when requested amount exceeds warehouse stock', + ); + + // Manual stock adjust attempting to deduct more than current stock must be rejected + await assert.rejects( + async () => store.stock.adjust(googleAuthId, { + sku: article.sku, + warehouseId: wh1.id, + amount: -(curStock + 10), + }), + /Insufficient stock for article/, + 'Stock adjust must fail when deduction exceeds available stock', + ); + // 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized console.log('11. Testing zero-adjustment before stock tracking is disabled...'); const artBeforeDisable = await store.article.getBySku(googleAuthId, article.sku); diff --git a/scripts/test-shipment.js b/scripts/test-shipment.js index 6586738..f150341 100644 --- a/scripts/test-shipment.js +++ b/scripts/test-shipment.js @@ -281,8 +281,8 @@ async function run() { assert.equal(sum2.is_shipped, 0); console.log('PASS: 9b. Shipped SOME items with omitted amount; ordersummary updated with shipped_quantity=10, is_shipped=0'); - // 9c. Ship ALL remaining items by omitting lines entirely ({}) - const shAll = await store.order.createShipment(googleAuthId, order2.id, {}); + // 9c. Ship remaining items with explicit lines + const shAll = await store.order.createShipment(googleAuthId, order2.id, { lines: [{ sku: 'ITEM-B', amount: 5 }] }); assert.equal(shAll.number, 'AU-1002-L-3'); assert.equal(shAll.lines.length, 1); assert.equal(shAll.lines[0].sku, 'ITEM-B'); @@ -292,16 +292,16 @@ async function run() { sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id); assert.equal(sum2.shipped_quantity, 15); assert.equal(sum2.is_shipped, 1); - console.log('PASS: 9c. Shipped ALL remaining items with omitted lines; ordersummary updated with shipped_quantity=15, is_shipped=1'); + console.log('PASS: 9c. Shipped remaining items with explicit lines; ordersummary updated with shipped_quantity=15, is_shipped=1'); - // 9d. Calling createShipment when all items already shipped throws 'All items already shipped' + // 9d. Calling createShipment without lines is rejected with "Invalid lines" await assert.rejects( async () => { await store.order.createShipment(googleAuthId, order2.id); }, - { message: 'All items already shipped' } + { message: 'Invalid lines' } ); - console.log('PASS: 9d. Re-shipping fully shipped order rejected with "All items already shipped"'); + console.log('PASS: 9d. Creating shipment without lines rejected with "Invalid lines"'); // 10. Verify peek next number works for entity and other types without error const nextEntity = await store.entity.peekNextNumber(googleAuthId); diff --git a/src/providers/mssql/queries/article.js b/src/providers/mssql/queries/article.js index 83fe149..db5ee34 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -664,8 +664,11 @@ SELECT SELECT SUM(ma_s.amount) FROM manifest m_s INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = m.company_id AND m_s.[type] = 'shipment' + AND ISNULL(ss.state, 'new') != 'cancelled' + AND m_s.[number] NOT LIKE '%#cancelled#%' AND ma_s.sku = a.sku AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%' ), 0), 3) AS amount @@ -682,8 +685,11 @@ SELECT SELECT SUM(ma_s.amount) FROM manifest m_s INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = m.company_id AND m_s.[type] = 'shipment' + AND ISNULL(ss.state, 'new') != 'cancelled' + AND m_s.[number] NOT LIKE '%#cancelled#%' AND ma_s.sku = a.sku AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%' ), 0)) > 0 diff --git a/src/providers/mssql/queries/ledger.js b/src/providers/mssql/queries/ledger.js index cb45c08..7085f53 100644 --- a/src/providers/mssql/queries/ledger.js +++ b/src/providers/mssql/queries/ledger.js @@ -145,3 +145,26 @@ WHERE ma.sku = @sku AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId) ORDER BY l.id ASC; `; + +export const GET_ARTICLE_TRACK_STOCK = ` +SELECT TOP 1 a.id, a.sku, a.track_stock +FROM article a +WHERE a.company_id = @companyId + AND ( + a.sku = @sku + OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku) + ); +`; + +export const GET_ARTICLE_WAREHOUSE_STOCK = ` +SELECT COALESCE(SUM(l.amount), 0) AS stock +FROM ledger l +INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id +INNER JOIN manifest m ON m.id = ma.manifest_id +WHERE l.warehouse_id = @warehouseId + AND m.company_id = @companyId + AND ( + ma.sku = @sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId) + ); +`; diff --git a/src/providers/mssql/queries/shipment.js b/src/providers/mssql/queries/shipment.js index 90b56a1..68378f3 100644 --- a/src/providers/mssql/queries/shipment.js +++ b/src/providers/mssql/queries/shipment.js @@ -22,9 +22,11 @@ WHERE company_id = @companyId AND [type] = 'shipment'; export const LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN = ` SELECT m.id, m.[number], m.[type], m.entity_id AS entityId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt FROM manifest m +LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id WHERE m.company_id = @companyId AND m.[type] = 'shipment' AND m.[number] LIKE @pattern + AND (ss.state IS NULL OR ss.state != 'cancelled') ORDER BY m.id ASC; `; @@ -32,10 +34,12 @@ export const GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN = ` SELECT ma.sku, SUM(ma.amount) AS shippedAmount FROM manifest m INNER JOIN manifestarticle ma ON ma.manifest_id = m.id +LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id WHERE m.company_id = @companyId AND m.[type] = 'shipment' AND m.[number] LIKE @pattern AND (@excludeShipmentId IS NULL OR m.id != @excludeShipmentId) + AND (ss.state IS NULL OR ss.state != 'cancelled') GROUP BY ma.sku; `; @@ -57,6 +61,7 @@ FROM manifest m LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id AND ss.company_id = @companyId WHERE m.company_id = @companyId AND m.[type] = 'shipment' + AND (ss.state IS NULL OR ss.state != 'cancelled') AND ( m.[number] LIKE @pattern OR ss.order_id = @orderId @@ -69,3 +74,33 @@ DELETE FROM manifest WHERE id = @id AND company_id = @companyId AND [type] = 'shipment'; `; +export const GET_DEFAULT_WAREHOUSE = ` +SELECT TOP 1 id, name +FROM warehouse +WHERE company_id = @companyId +ORDER BY sort ASC, name ASC, id ASC; +`; + +export const GET_ARTICLE_TRACK_STOCK = ` +SELECT TOP 1 a.id, a.sku, a.track_stock +FROM article a +WHERE a.company_id = @companyId + AND ( + a.sku = @sku + OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku) + ); +`; + +export const GET_ARTICLE_WAREHOUSE_STOCK = ` +SELECT COALESCE(SUM(l.amount), 0) AS stock +FROM ledger l +INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id +INNER JOIN manifest m ON m.id = ma.manifest_id +WHERE l.warehouse_id = @warehouseId + AND m.company_id = @companyId + AND ( + ma.sku = @sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId) + ); +`; + diff --git a/src/providers/sqlite3/queries/article.js b/src/providers/sqlite3/queries/article.js index dca6527..eff91e3 100644 --- a/src/providers/sqlite3/queries/article.js +++ b/src/providers/sqlite3/queries/article.js @@ -307,8 +307,11 @@ SELECT auth.*, SELECT SUM(ma_s.amount) FROM manifest m_s INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = m.company_id AND m_s."type" = 'shipment' + AND COALESCE(ss.state, 'new') != 'cancelled' + AND m_s."number" NOT LIKE '%#cancelled#%' AND ma_s.sku = a.sku AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%' ), 0), 3) AS amount @@ -321,7 +324,18 @@ SELECT auth.*, AND ma.sku = a.sku AND COALESCE(os.is_shipped, 0) = 0 GROUP BY m.id, m."number", m."date", os.customer_name, e.first_name, e.name - HAVING amount > 0 + HAVING (SUM(ma.amount) - COALESCE(( + SELECT SUM(ma_s.amount) + FROM manifest m_s + INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id + WHERE m_s.company_id = m.company_id + AND m_s."type" = 'shipment' + AND COALESCE(ss.state, 'new') != 'cancelled' + AND m_s."number" NOT LIKE '%#cancelled#%' + AND ma_s.sku = a.sku + AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%' + ), 0)) > 0 ORDER BY m."date" ASC, m.id ASC ) uo )) diff --git a/src/providers/sqlite3/queries/ledger.js b/src/providers/sqlite3/queries/ledger.js index 661de65..601daa6 100644 --- a/src/providers/sqlite3/queries/ledger.js +++ b/src/providers/sqlite3/queries/ledger.js @@ -147,3 +147,27 @@ WHERE ma.sku = @sku AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId) ORDER BY l.id ASC; `; + +export const GET_ARTICLE_TRACK_STOCK = ` +SELECT a.id, a.sku, a.track_stock +FROM article a +WHERE a.company_id = @companyId + AND ( + a.sku = @sku + OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku) + ) +LIMIT 1; +`; + +export const GET_ARTICLE_WAREHOUSE_STOCK = ` +SELECT COALESCE(SUM(l.amount), 0) AS stock +FROM ledger l +INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id +INNER JOIN manifest m ON m.id = ma.manifest_id +WHERE l.warehouse_id = @warehouseId + AND m.company_id = @companyId + AND ( + ma.sku = @sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId) + ); +`; diff --git a/src/providers/sqlite3/queries/shipment.js b/src/providers/sqlite3/queries/shipment.js index 904f7a7..38ad832 100644 --- a/src/providers/sqlite3/queries/shipment.js +++ b/src/providers/sqlite3/queries/shipment.js @@ -13,5 +13,8 @@ export const { CHECK_SHIPMENT_PACKAGES_SHIPPED, CHECK_ORDER_HAS_SHIPMENTS, DELETE_SHIPMENT, + GET_DEFAULT_WAREHOUSE, + GET_ARTICLE_TRACK_STOCK, + GET_ARTICLE_WAREHOUSE_STOCK, } = queries; diff --git a/src/store/ledger/add.js b/src/store/ledger/add.js index b021471..e6b0988 100644 --- a/src/store/ledger/add.js +++ b/src/store/ledger/add.js @@ -315,6 +315,17 @@ export default function add(provider) { warehouseName = defWh.rows[0].name; } + const stockRes = await runner.query(queries.GET_ARTICLE_WAREHOUSE_STOCK, { + warehouseId: effectiveWhId, + companyId, + sku, + articleId: articleMatches[0]?.id ?? null, + }); + const currentStock = Number(stockRes.rows[0]?.stock ?? 0); + if (currentStock + outboundAmount < 0) { + throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(outboundAmount)}`); + } + await insertLedgerRow({ whId: effectiveWhId, type: 'WAREHOUSE', @@ -363,6 +374,19 @@ export default function add(provider) { throw new Error('Warehouse not found'); } + if (amount < 0) { + const stockRes = await runner.query(queries.GET_ARTICLE_WAREHOUSE_STOCK, { + warehouseId, + companyId, + sku, + articleId: articleMatches[0]?.id ?? null, + }); + const currentStock = Number(stockRes.rows[0]?.stock ?? 0); + if (currentStock + amount < 0) { + throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`); + } + } + const row = await insertLedgerRow({ whId: warehouseId, type: transactionType, diff --git a/src/store/order/createShipment.js b/src/store/order/createShipment.js index 18bfb06..569809f 100644 --- a/src/store/order/createShipment.js +++ b/src/store/order/createShipment.js @@ -5,20 +5,19 @@ export default function createShipment(provider) { /** * Creates a new shipment manifest for an order. - * Can contain one, some, or all items from the parent order. - * If lines is omitted or empty, all remaining unshipped items are included. + * Can contain specified items from the parent order. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} orderId - The parent order manifest id. - * @param {object} [input] - Optional overrides. - * @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - Positions (defaults to all remaining items). + * @param {object} input - Shipment input. + * @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - Positions to ship. * @param {string|Date} [input.date] - Optional shipment date. * @param {string} [input.externalNumber] - Optional external delivery reference. * @param {object} [tx] - Optional transaction. * @returns {Promise} The created shipment. * * @example - * const shipment = await store.order.createShipment(googleAuthId, 10); + * const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] }); */ return async (googleAuthId, orderId, input = {}, tx) => { return create(googleAuthId, { ...input, orderId }, tx); diff --git a/src/store/order/syncSummary.js b/src/store/order/syncSummary.js index d7adac4..118d05e 100644 --- a/src/store/order/syncSummary.js +++ b/src/store/order/syncSummary.js @@ -49,8 +49,10 @@ const SQL_SHIPPED_QTY_SQLITE = ` SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity FROM manifest m_s JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = @companyId AND m_s."type" = 'shipment' + AND COALESCE(ss.state, 'new') != 'cancelled' AND m_s."number" >= (@orderNumber || @shipmentPrefix) AND m_s."number" < (@orderNumber || @shipmentPrefix || char(127)) `; @@ -59,8 +61,10 @@ const SQL_SHIPPED_QTY_MSSQL = ` SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity FROM manifest m_s JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = @companyId AND m_s.[type] = 'shipment' + AND ISNULL(ss.state, 'new') != 'cancelled' AND m_s.[number] >= (@orderNumber + @shipmentPrefix) AND m_s.[number] < (@orderNumber + @shipmentPrefix + NCHAR(127)) `; @@ -255,8 +259,10 @@ export async function rebuildOrderSummaries(runner, companyId) { SUM(ma_s.amount) AS shipped_quantity FROM manifest m_s JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = @companyId AND m_s."type" = 'shipment' + AND COALESCE(ss.state, 'new') != 'cancelled' AND INSTR(m_s."number", @shipmentPrefix) > 0 GROUP BY SUBSTR(m_s."number", 1, INSTR(m_s."number", @shipmentPrefix) - 1) ) @@ -327,8 +333,10 @@ export async function rebuildOrderSummaries(runner, companyId) { SUM(ma_s.amount) AS shipped_quantity FROM manifest m_s JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id WHERE m_s.company_id = @companyId AND m_s.[type] = 'shipment' + AND ISNULL(ss.state, 'new') != 'cancelled' AND CHARINDEX(@shipmentPrefix, m_s.[number]) > 0 GROUP BY SUBSTRING(m_s.[number], 1, CHARINDEX(@shipmentPrefix, m_s.[number]) - 1) ) diff --git a/src/store/shipment/create.js b/src/store/shipment/create.js index 810e01d..b9461a6 100644 --- a/src/store/shipment/create.js +++ b/src/store/shipment/create.js @@ -27,34 +27,30 @@ export default function create(provider) { const q = queriesFor(provider); /** - * Creates a shipment manifest for an order containing one, some, or all items from the order. - * If input.lines is omitted or empty, all remaining unshipped items from the order are shipped. + * Creates a shipment manifest for an order containing specified items from the order. * On individual lines, amount is optional and defaults to the remaining unshipped quantity. * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The shipment input. * @param {number} input.orderId - The parent order manifest id. + * @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions. * @param {string|Date} [input.date] - Optional shipment date (defaults to current date). * @param {'new'|'delivered'|'returned'} [input.state='new'] - Optional delivery state (defaults to 'new'). * @param {string} [input.externalNumber] - Optional external delivery note number (defaults to order.externalNumber or order.number). - * @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - The shipment positions (optional). * @param {object} [tx] - Optional transaction. * @returns {Promise} The created shipment manifest. - * @throws {Error} If order not found, invalid lines, all items already shipped, or article ceiling invariant exceeded. + * @throws {Error} If order not found, invalid lines, or article ceiling invariant exceeded. * * @example * const shipment = await store.shipment.create(googleAuthId, { * orderId: 10, * lines: [{ sku: 'ITEM-A', amount: 5 }] * }); - * - * // Ship all remaining items from the order: - * const fullShipment = await store.shipment.create(googleAuthId, { orderId: 10 }); */ return async (googleAuthId, input = {}, tx) => { const orderId = requireId(input.orderId, 'Order'); - if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) { + if (!Array.isArray(input.lines) || input.lines.length === 0) { throw new Error('Invalid lines'); } @@ -115,81 +111,97 @@ export default function create(provider) { shippedAmountBySku.set(sku, Number(row.shippedAmount || 0)); } - // 5. Determine lines to ship (one, some, or all items) - const rawLines = input.lines; - const isAutoAll = !Array.isArray(rawLines) || rawLines.length === 0; + // 5. Determine warehouse and load stock for tracked articles + let targetWarehouseId = input.warehouseId ?? input.warehouse_id; + if (targetWarehouseId != null) { + targetWarehouseId = Number(targetWarehouseId); + } else { + const defWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { companyId }); + targetWarehouseId = defWh.rows[0]?.id ?? null; + } + const articleInfoBySku = new Map(); + const remainingStockBySku = new Map(); + + for (const sku of orderAmountBySku.keys()) { + const artRes = await runner.query(q.GET_ARTICLE_TRACK_STOCK, { companyId, sku }); + const art = artRes.rows[0]; + const isTracked = Number(art?.track_stock ?? 0) === 1; + let availableStock = Infinity; + + if (isTracked && targetWarehouseId != null) { + const stockRes = await runner.query(q.GET_ARTICLE_WAREHOUSE_STOCK, { + warehouseId: targetWarehouseId, + companyId, + sku, + articleId: art?.id ?? null, + }); + availableStock = Number(stockRes.rows[0]?.stock ?? 0); + } + + articleInfoBySku.set(sku, { + articleId: art?.id, + isTracked, + availableStock, + }); + remainingStockBySku.set(sku, availableStock); + } + + // 6. Determine lines to ship const linesToInsert = []; const proposedBySku = new Map(); - if (isAutoAll) { - // Automatically ship all remaining unshipped items from the order - for (const [sku, ordered] of orderAmountBySku.entries()) { - const alreadyShipped = shippedAmountBySku.get(sku) || 0; - const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000; - if (remaining > 0) { - const ol = orderLineBySku.get(sku); - linesToInsert.push({ - sku, - name: ol?.name ?? sku, - pu: ol?.pu ?? 1, - amount: remaining, - net: Number(ol?.net ?? 0), - rebate: Number(ol?.rebate ?? 0), - taxrate: Number(ol?.taxrate ?? 0), - comment: ol?.comment ?? null, - }); - proposedBySku.set(sku, remaining); - } + for (const line of input.lines) { + const sku = String(line?.sku ?? '').trim(); + if (!sku || !orderAmountBySku.has(sku)) { + throw new Error(`Article (${sku}) is not part of order (${order.number})`); } - if (linesToInsert.length === 0) { - throw new Error('All items already shipped'); - } - } else { - // Specific lines requested (one, some, or all items) - for (const line of rawLines) { - const sku = String(line?.sku ?? '').trim(); - if (!sku || !orderAmountBySku.has(sku)) { - throw new Error(`Article (${sku}) is not part of order (${order.number})`); + const ordered = orderAmountBySku.get(sku) || 0; + const alreadyShipped = shippedAmountBySku.get(sku) || 0; + const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000; + + let amount; + if (line.amount !== undefined && line.amount !== null) { + amount = Number(line.amount); + if (!Number.isFinite(amount) || amount === 0) { + throw new Error('Invalid lines'); } - - const ordered = orderAmountBySku.get(sku) || 0; - const alreadyShipped = shippedAmountBySku.get(sku) || 0; - const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000; - - let amount; - if (line.amount !== undefined && line.amount !== null) { - amount = Number(line.amount); - if (!Number.isFinite(amount) || amount === 0) { - throw new Error('Invalid lines'); - } - } else { - // Default to remaining quantity - if (remaining <= 0) { - throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`); - } - amount = remaining; - } - - const currentProposed = proposedBySku.get(sku) || 0; - if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) { + } else { + // Default to remaining quantity + if (remaining <= 0) { throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`); } - proposedBySku.set(sku, currentProposed + amount); - - const ol = orderLineBySku.get(sku); - linesToInsert.push({ - sku, - name: line.name ?? ol?.name ?? sku, - pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1), - amount, - net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0), - rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0), - taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0), - comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null), - }); + amount = remaining; } + + const currentProposed = proposedBySku.get(sku) || 0; + if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) { + throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`); + } + + const info = articleInfoBySku.get(sku); + if (amount > 0 && info?.isTracked) { + const available = remainingStockBySku.get(sku) ?? 0; + if (amount > available) { + throw new Error(`Insufficient stock for article (${sku}): available ${available}, requested ${amount}`); + } + remainingStockBySku.set(sku, available - amount); + } + + proposedBySku.set(sku, currentProposed + amount); + + const ol = orderLineBySku.get(sku); + linesToInsert.push({ + sku, + name: line.name ?? ol?.name ?? sku, + pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1), + amount, + net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0), + rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0), + taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0), + comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null), + }); } // 6. Resolve next index diff --git a/src/store/shipment/delete.js b/src/store/shipment/delete.js index 00e4918..5d28eac 100644 --- a/src/store/shipment/delete.js +++ b/src/store/shipment/delete.js @@ -2,6 +2,12 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import makeRunner from '../runner.js'; import { queriesFor } from './helpers.js'; import { syncOrderSummary } from '../order/syncSummary.js'; +import { syncShipmentSummary } from './syncSummary.js'; +import stockAdjust from '../stock/adjust.js'; +import { computePrevHash } from '../ledger/add.js'; +import { recordShipmentHistory } from '../articlehistory/record.js'; +import * as mssqlLedgerQueries from '../../providers/mssql/queries/ledger.js'; +import * as sqliteLedgerQueries from '../../providers/sqlite3/queries/ledger.js'; function requireId(val, name) { const n = Number(val); @@ -12,60 +18,205 @@ function requireId(val, name) { } /** - * Deletes a shipment manifest of the selected company. - * Shipped shipments (with packages in 'shipped' state) cannot be deleted. + * Deletes or cancels a shipment manifest of the selected company. + * - If no ledger rows exist: physically deletes the shipment manifest. + * - If ledger rows exist: reverses the ledger entries (storno document entry + warehouse stock adjustment), + * marks the shipment as 'cancelled' in shipmentsummary, and updates the parent order's summary. + * - Shipped shipments (with packages in 'shipped' state) cannot be deleted. * * @param {import('../../providers/provider.js').DatabaseProvider} provider */ export default function del(provider) { const access = resolveSelectedCompanyAccess(provider); const q = queriesFor(provider); + const adjust = stockAdjust(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const ledgerQueries = isSqlite ? sqliteLedgerQueries : mssqlLedgerQueries; /** - * Deletes a shipment manifest. + * Deletes or cancels a shipment manifest. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The shipment manifest ID. * @param {object} [tx] - Optional transaction. - * @returns {Promise} - * @throws {Error} If shipment not found or already has shipped packages. + * @returns {Promise} Result indicating whether shipment was deleted or cancelled. + * @throws {Error} If shipment not found, already cancelled, or already has shipped packages. * * @example * await store.shipment.delete(googleAuthId, 42); */ return async (googleAuthId, id, tx) => { const shipmentId = requireId(id, 'Shipment'); - const runner = makeRunner(provider, tx); - const { companyId } = await access(googleAuthId, runner); - const check = await runner.query(q.GET_SHIPMENT_HEADER, { - id: shipmentId, - companyId, - }); - if (!check.rows[0]) { - throw new Error('Shipment not found'); - } - - const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, { - id: shipmentId, - }); - if (shippedCheck.rows[0]) { - throw new Error('Shipped shipment cannot be deleted'); - } - - const shipmentNumber = String(check.rows[0].number || ''); - const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId }); - const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-'; - const pIdx = shipmentNumber.indexOf(shipmentPrefix); - const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null; - - await runner.query(q.DELETE_SHIPMENT, { - id: shipmentId, - companyId, - }); - - if (orderNumber) { - await syncOrderSummary(runner, { companyId, orderNumber }); + const run = async (runner, activeTx) => { + const { companyId, companyuserId } = await access(googleAuthId, runner); + + const check = await runner.query(q.GET_SHIPMENT_HEADER, { + id: shipmentId, + companyId, + }); + if (!check.rows[0]) { + throw new Error('Shipment not found'); + } + + const sumRes = await runner.query( + 'SELECT state, customer_name FROM shipmentsummary WHERE manifest_id = @manifestId', + { manifestId: shipmentId }, + ); + if (sumRes.rows[0]?.state === 'cancelled') { + throw new Error('Shipment already cancelled'); + } + + const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, { + id: shipmentId, + }); + if (shippedCheck.rows[0]) { + throw new Error('Shipped shipment cannot be deleted'); + } + + const shipmentNumber = String(check.rows[0].number || ''); + const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId }); + const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-'; + const pIdx = shipmentNumber.indexOf(shipmentPrefix); + const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null; + + // Find all ledger rows booked for lines of this shipment + const ledgerRowsRes = await runner.query( + `SELECT l.id, l.manifestarticle_id, l.warehouse_id, l.transaction_type, l.amount, l.transaction_text, + ma.sku, ma.name AS article_name, ma.amount AS line_amount + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + WHERE ma.manifest_id = @shipmentId + ORDER BY l.id ASC`, + { shipmentId }, + ); + const ledgerRows = ledgerRowsRes.rows ?? []; + + if (ledgerRows.length === 0) { + await runner.query(q.DELETE_SHIPMENT, { + id: shipmentId, + companyId, + }); + await runner.query('DELETE FROM shipmentsummary WHERE manifest_id = @id', { id: shipmentId }); + if (orderNumber) { + await syncOrderSummary(runner, { companyId, orderNumber }); + } + return { deleted: true, shipmentId }; + } + + // Ledger entries exist -> Storno / Compensating entries + const customerName = sumRes.rows[0]?.customer_name || null; + const stornoDocText = shipmentNumber ? `Storno Lieferschein ${shipmentNumber}` : 'Storno Lieferschein'; + + // Group ledger entries by manifestarticle_id + const byLine = new Map(); + for (const row of ledgerRows) { + const lineId = row.manifestarticle_id; + if (!byLine.has(lineId)) { + byLine.set(lineId, { + sku: row.sku, + articleName: row.article_name, + docAmount: 0, + warehouseAmounts: new Map(), + }); + } + const lineData = byLine.get(lineId); + if (row.warehouse_id == null) { + lineData.docAmount += Number(row.amount || 0); + } else { + const whId = Number(row.warehouse_id); + lineData.warehouseAmounts.set( + whId, + (lineData.warehouseAmounts.get(whId) || 0) + Number(row.amount || 0), + ); + } + } + + for (const [lineId, data] of byLine.entries()) { + // Revert document ledger entry if net balance is not 0 + if (data.docAmount !== 0) { + const compensatingDocAmount = -data.docAmount; + const latest = await runner.query(ledgerQueries.GET_LATEST_ROW); + const prevRow = latest.rows[0]; + const prevHash = computePrevHash(prevRow); + + await runner.query(ledgerQueries.INSERT_ROW, { + manifestarticleId: lineId, + warehouseId: null, + transactionType: 'STORNO', + amount: compensatingDocAmount, + transactionText: stornoDocText, + hashVersion: 1, + prevHash, + }); + + // Write article history for storno + if (data.sku) { + const trackRes = await runner.query( + `SELECT a.id FROM article a + WHERE a.company_id = @companyId + AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))`, + { companyId, sku: data.sku }, + ); + for (const match of trackRes.rows ?? []) { + await recordShipmentHistory(runner, { + articleId: match.id, + companyuserId, + manifestNumber: shipmentNumber, + customerName, + amount: compensatingDocAmount, + transactionText: stornoDocText, + }); + } + } + } + + // Revert warehouse ledger entries + for (const [whId, whAmount] of data.warehouseAmounts.entries()) { + if (whAmount !== 0) { + const compensatingWhAmount = -whAmount; + await adjust( + googleAuthId, + { + manifestarticleId: lineId, + warehouseId: whId, + amount: compensatingWhAmount, + text: stornoDocText, + }, + activeTx, + ); + } + } + } + + // Free the shipment number in manifest so subsequent shipments can reuse the number + const freedManifestNumber = `${shipmentNumber}#cancelled#${shipmentId}`; + await runner.query( + isSqlite + ? 'UPDATE manifest SET "number" = @freedManifestNumber WHERE id = @shipmentId' + : 'UPDATE manifest SET [number] = @freedManifestNumber WHERE id = @shipmentId', + { freedManifestNumber, shipmentId }, + ); + + // Mark shipment as cancelled in shipmentsummary + await syncShipmentSummary(runner, { + companyId, + manifestId: shipmentId, + state: 'cancelled', + }); + + // Resync order summary to reopen positions on parent order + if (orderNumber) { + await syncOrderSummary(runner, { companyId, orderNumber }); + } + + return { cancelled: true, shipmentId, restoredStock: true }; + }; + + if (tx) return run(makeRunner(provider, tx), tx); + if (typeof provider?.transaction === 'function') { + return provider.transaction((runner, activeTx) => run(runner, activeTx)); } + return run(makeRunner(provider)); }; } diff --git a/src/store/shipment/helpers.js b/src/store/shipment/helpers.js index f94cf35..3c3bdec 100644 --- a/src/store/shipment/helpers.js +++ b/src/store/shipment/helpers.js @@ -51,7 +51,7 @@ export async function loadShipment(runner, q, companyId, id) { return { id: row.id, - number: row.number, + number: String(row.number || '').split('#cancelled#')[0], type: 'shipment', entityId: row.entityId ?? null, externalNumber: row.externalNumber ?? null, diff --git a/src/store/shipment/list.js b/src/store/shipment/list.js index 18be821..7cf50fd 100644 --- a/src/store/shipment/list.js +++ b/src/store/shipment/list.js @@ -31,7 +31,8 @@ export default function list(provider) { * @param {number} [opts.offset=0] - Number of shipments to skip. * @param {number} [opts.limit=50] - Number of shipments to return. * @param {string} [opts.search] - Free-text search matching shipment number, order number, external number, tracking code, or customer name. - * @param {'new'|'delivered'|'returned'} [opts.state] - Filter by delivery state. + * @param {'new'|'delivered'|'returned'|'cancelled'|'all'} [opts.state] - Filter by delivery state. + * @param {boolean} [opts.includeCancelled=false] - Whether to include cancelled shipments when state is not set. * @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1). * @param {number} [opts.orderId] - Optional filter for parent order manifest id. * @param {object} [tx] - Optional transaction. @@ -109,10 +110,15 @@ export default function list(provider) { } } - // Delivery state filter ('new' | 'delivered' | 'returned') + // Delivery state filter ('new' | 'delivered' | 'returned' | 'cancelled' | 'all') if (typeof opts?.state === 'string' && opts.state.trim() !== '') { - params.filterState = opts.state.trim().toLowerCase(); - conditions.push("COALESCE(ss.state, 'new') = @filterState"); + const stateFilter = opts.state.trim().toLowerCase(); + if (stateFilter !== 'all') { + params.filterState = stateFilter; + conditions.push("COALESCE(ss.state, 'new') = @filterState"); + } + } else if (!opts?.includeCancelled) { + conditions.push("COALESCE(ss.state, 'new') != 'cancelled'"); } // Free-text search diff --git a/src/store/shipment/syncSummary.js b/src/store/shipment/syncSummary.js index 0994937..e1df847 100644 --- a/src/store/shipment/syncSummary.js +++ b/src/store/shipment/syncSummary.js @@ -126,7 +126,7 @@ const SQL_UPSERT_SUMMARY_MSSQL = ` ); `; -const VALID_STATES = new Set(['new', 'delivered', 'returned']); +const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']); /** * Recalculates and upserts a single shipment summary into `shipmentsummary`. @@ -153,6 +153,8 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order return; } + const cleanShipmentNumber = String(shipment.number || '').split('#cancelled#')[0]; + // 1. Line stats const lineRes = await runner.query(SQL_LINE_STATS, { manifestId }); const positions = Number(lineRes.rows[0]?.positions || 0); @@ -175,7 +177,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId }); const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-'; - const num = String(shipment.number || ''); + const num = cleanShipmentNumber; if (num.includes(shipmentPrefix)) { const parsedOrderNum = num.split(shipmentPrefix)[0]; if (parsedOrderNum) { @@ -201,7 +203,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order customerName = custRes.rows[0]?.customer_name || null; } - // 5. State ('new' | 'delivered' | 'returned') + // 5. State ('new' | 'delivered' | 'returned' | 'cancelled') let resolvedState = 'new'; if (state && VALID_STATES.has(state)) { resolvedState = state; @@ -229,7 +231,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, { companyId, manifestId, - shipmentNumber: shipment.number, + shipmentNumber: cleanShipmentNumber, shipmentDate: shipment.date, createdAt: shipment.created_at, orderId: resolvedOrderId, diff --git a/src/store/shipment/update.js b/src/store/shipment/update.js index ab4fc80..856cdfe 100644 --- a/src/store/shipment/update.js +++ b/src/store/shipment/update.js @@ -11,7 +11,7 @@ function requireId(val, name) { return n; } -const VALID_STATES = new Set(['new', 'delivered', 'returned']); +const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']); /** * Updates a shipment's state or metadata. diff --git a/src/store/stock/adjust.js b/src/store/stock/adjust.js index 87f351d..fb1348d 100644 --- a/src/store/stock/adjust.js +++ b/src/store/stock/adjust.js @@ -126,6 +126,19 @@ export default function adjust(provider) { const text = input.text ?? input.transactionText ?? input.transaction_text ?? (amount > 0 ? `Stock addition (+${amount})` : `Stock deduction (${amount})`); + if (amount < 0) { + const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, { + warehouseId, + companyId, + sku, + articleId: art.id, + }); + const currentStock = Number(stockRes.rows[0]?.stock ?? 0); + if (currentStock + amount < 0) { + throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`); + } + } + const latest = await runner.query(ledgerQueries.GET_LATEST_ROW); const prevRow = latest.rows[0]; const prevHash = computePrevHash(prevRow); @@ -301,6 +314,19 @@ export default function adjust(provider) { const createdLedgerEntries = []; for (const item of resolvedArticles) { + if (item.amount < 0) { + const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, { + warehouseId: item.warehouseId, + companyId, + sku: item.sku, + articleId: item.articleId, + }); + const currentStock = Number(stockRes.rows[0]?.stock ?? 0); + if (currentStock + item.amount < 0) { + throw new Error(`Insufficient stock for article (${item.sku}): available ${currentStock}, requested ${Math.abs(item.amount)}`); + } + } + // Create manifestarticle row const lineRes = await runner.query(queries.CREATE_LINE, { manifestId, diff --git a/test/article.test.js b/test/article.test.js index 63b66c6..8aef6ea 100644 --- a/test/article.test.js +++ b/test/article.test.js @@ -753,6 +753,7 @@ describe('store.article', () => { await store.shipment.create(gid, { orderId: o2.id, + lines: [{ sku: art.sku, amount: 5 }], }); res = await store.article.getBySku(gid, art.sku); diff --git a/test/shipment.test.js b/test/shipment.test.js index 8ff3f1e..e3ecc09 100644 --- a/test/shipment.test.js +++ b/test/shipment.test.js @@ -69,15 +69,10 @@ describe('store.shipment', () => { assert.equal(rest.lines[0].amount, 7); }); - it('ships everything remaining when lines are omitted', async () => { + it('rejects when lines are omitted or empty', async () => { const order = await makeOrder(); - await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] }); - const rest = await store.shipment.create(gid, { orderId: order.id }); - assert.deepEqual( - rest.lines.map((l) => [l.sku, l.amount]).sort(), - [['SH-A', 6], ['SH-B', 5]], - ); - await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'All items already shipped' }); + await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'Invalid lines' }); + await assert.rejects(() => store.shipment.create(gid, { orderId: order.id, lines: [] }), { message: 'Invalid lines' }); }); it('updates the order summary', async () => { @@ -88,7 +83,7 @@ describe('store.shipment', () => { await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] }); assert.equal(summary().shipped_quantity, 10); assert.equal(summary().is_shipped, 0); - await store.shipment.create(gid, { orderId: order.id }); + await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-B', amount: 5 }] }); assert.equal(summary().shipped_quantity, 15); assert.equal(summary().is_shipped, 1); }); @@ -120,18 +115,18 @@ describe('store.shipment', () => { () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 0 }] }), { message: 'Invalid lines' }, ); - await assert.rejects(() => store.shipment.create(gid, { orderId: 999999 }), { message: 'Order not found' }); + await assert.rejects(() => store.shipment.create(gid, { orderId: 999999, lines: [{ sku: 'SH-A', amount: 1 }] }), { message: 'Order not found' }); }); it('rejects orders without lines', async () => { const empty = await makeOrder([]); - await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id }), { message: 'Order has no lines' }); + await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id, lines: [{ sku: 'SH-A', amount: 1 }] }), { message: 'Order has no lines' }); }); it('cannot ship orders of another company', async () => { const order = await makeOrder(); await assert.rejects( - () => store.shipment.create(other.googleAuthId, { orderId: order.id }), + () => store.shipment.create(other.googleAuthId, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }), { message: 'Order not found' }, ); });