diff --git a/API.md b/API.md index fd3d40d..156020e 100644 --- a/API.md +++ b/API.md @@ -324,6 +324,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [runner.js](API.runner.js.md) - `runner(provider: import('../providers/provider.js').DatabaseProvider, tx?: object): ((sql: string, params?: object) => Promise) & { query: Function, dialect?: string }` - [shipping](API.shipping.md) - - `create(googleAuthId: number, input: {manifestId: number, type: string, state: string}, tx?: object): Promise<{id: number, manifestId: number, number: string, type: string, state: string}>` + - `create(googleAuthId: number, input: {manifestId: number, type: string, state: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, type: string, state: string}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` - `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise>` + - `update(provider: import('../../providers/provider.js').DatabaseProvider): void` diff --git a/API.shipping.md b/API.shipping.md index 77744d7..32e978b 100644 --- a/API.shipping.md +++ b/API.shipping.md @@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin *Source: `src/store/shipping/create.js`* -Creates a shipping for a manifest (order). The number is allocated from the `shipping` numbers sequence (own prefix + counter). +Creates a shipping for a manifest (order). The number is allocated from the `shipping` numbers sequence (own prefix + counter). If state is 'shipped', adjusts stock by appending negative ledger movements for tracked order lines, writes article history, and ensures the order was not already shipped. **Parameters** @@ -16,7 +16,8 @@ Creates a shipping for a manifest (order). The number is allocated from the `shi | `input` | `object` | Shipping input. | | `input.manifestId` | `number` | The manifest (order) id. | | `input.type` | `string` | Shipping type. | -| `input.state` | `string` | Shipping state. | +| `input.state` | `string` | Shipping state ('shipped' or other). | +| `[input.warehouseId]` | `number` | Optional warehouse ID for stock deduction (defaults to company's first warehouse). | | `[tx]` | `object` | Optional transaction to run within. | **Returns** @@ -25,19 +26,19 @@ Creates a shipping for a manifest (order). The number is allocated from the `shi **Throws** -`Error` — If manifest is not found or input is invalid. +`Error` — If manifest is not found, order already shipped, or input is invalid. **Example** ```js -await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'open' }); +await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped' }); ``` ## `shipping.delete` *Source: `src/store/shipping/delete.js`* -Deletes a shipping of the user's selected company. Its packages are removed by cascade. +Deletes a shipping of the user's selected company. Its packages are removed by cascade. Once shipped, a shipping cannot be deleted because stock movements have been ledgered. **Parameters** @@ -51,6 +52,10 @@ Deletes a shipping of the user's selected company. Its packages are removed by c `Promise` +**Throws** + +`Error` — If shipping is not found or is already shipped. + **Example** ```js @@ -80,3 +85,15 @@ Lists shippings of a manifest (order) in the user's selected company. ```js const shippings = await store.shipping.listByManifestId(googleAuthId, 42); ``` + +## `shipping.update` + +*Source: `src/store/shipping/update.js`* + +Updates a shipping record for the user's selected company. If transitioned to 'shipped', books ledger movements and adjusts stock. Once shipped, shipping cannot be undone (state cannot be changed away from 'shipped'). + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `import('../../providers/provider.js').DatabaseProvider` | | diff --git a/scripts/test-order-shipping-ledger.js b/scripts/test-order-shipping-ledger.js new file mode 100644 index 0000000..4c3ed28 --- /dev/null +++ b/scripts/test-order-shipping-ledger.js @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import LibDashStore from '../src/index.js'; +import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; + +async function run() { + console.log('--- Testing order shipping ledger & stock adjustment ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-order-shipping-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + // 1. Setup company and user + const googleAuthId = await store.google_auth.add('shipping-test@example.com'); + const company = await store.company.create({ + name: 'Shipping Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // Setup warehouse + const warehouse = await store.warehouse.create(googleAuthId, { + name: 'Hauptlager', + }); + assert(warehouse.id, 'Warehouse should have id'); + + // Setup units + await store.unit.upsert(googleAuthId, { + si: 'stk', + basesi: 'stk', + basesifactor: 1, + symbol: 'Stk', + name: 'Stück', + }); + + // Setup customer group & customer entity + const groups = await store.customergroup.list(googleAuthId); + const customer = await store.entity.create(googleAuthId, { + customergroupId: groups[0].id, + name: 'Max Mustermann', + }); + + // 2. Setup tracked article and untracked article + const trackedArt = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Tracked Article', + track_stock: 1, + }); + const untrackedArt = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Untracked Service Item', + track_stock: 0, + }); + + // Initial stock for tracked article: +10 + await store.stock.adjust(googleAuthId, { + sku: trackedArt.sku, + warehouseId: warehouse.id, + amount: 10, + text: 'Initial stock intake', + }); + + const artBefore = await store.article.getBySku(googleAuthId, trackedArt.sku); + const stockBefore = artBefore.article.warehouses.reduce((sum, w) => sum + w.stock, 0); + assert.equal(stockBefore, 10, 'Initial stock should be 10'); + + // 3. Create sales order with tracked and untracked items + const order = await store.order.create(googleAuthId, { + number: 'O-2026-001', + date: new Date().toISOString(), + entityId: customer.id, + lines: [ + { + sku: trackedArt.sku, + name: 'Tracked Article', + pu: 1, + amount: 3, + net: 50, + taxrate: 19, + }, + { + sku: untrackedArt.sku, + name: 'Untracked Service Item', + pu: 1, + amount: 2, + net: 20, + taxrate: 19, + }, + ], + }); + assert.equal(order.lines.length, 2); + + // 4. Create shipping with state 'open' (not shipped yet) + console.log('4. Creating shipping with state "open"...'); + const shippingOpen = await store.shipping.create(googleAuthId, { + manifestId: order.id, + type: 'parcel', + state: 'open', + }); + assert.equal(shippingOpen.state, 'open'); + + // Assert stock is still 10 + const artStill10 = await store.article.getBySku(googleAuthId, trackedArt.sku); + assert.equal(artStill10.article.warehouses.reduce((sum, w) => sum + w.stock, 0), 10, 'Stock should still be 10 when shipping is open'); + + // 5. Update shipping to state 'shipped' + console.log('5. Updating shipping to state "shipped"...'); + const shippingShipped = await store.shipping.update(googleAuthId, shippingOpen.id, { + state: 'shipped', + warehouseId: warehouse.id, + }); + assert.equal(shippingShipped.state, 'shipped'); + + // Assert stock is now 7 (10 - 3) + const artAfter = await store.article.getBySku(googleAuthId, trackedArt.sku); + assert.equal(artAfter.article.warehouses.reduce((sum, w) => sum + w.stock, 0), 7, 'Stock should be reduced to 7'); + + // Assert ledger entries + const ledgerRows = await store.ledger.listBySku(googleAuthId, trackedArt.sku); + assert.equal(ledgerRows.length, 2, 'Should have 2 ledger entries for tracked article (adjust + shipping)'); + const shippingLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPPING'); + assert(shippingLedger, 'Shipping ledger entry should exist'); + assert.equal(shippingLedger.amount, -3, 'Shipping ledger entry should be -3'); + + // Assert untracked article has 0 warehouse ledger entries + const untrackedLedger = await store.ledger.listBySku(googleAuthId, untrackedArt.sku); + assert.equal(untrackedLedger.length, 0, 'Untracked article should not have warehouse ledger entries'); + + // Assert article history + const history = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku); + console.log('Tracked article history:', history.map((h) => h.text)); + const shippingHistory = history.find((h) => h.text.includes('Versand Auftrag O-2026-001')); + assert(shippingHistory, 'Article history should contain shipping entry'); + assert(shippingHistory.text.includes('Max Mustermann'), 'Article history should mention customer'); + assert(shippingHistory.text.includes('Hauptlager'), 'Article history should mention warehouse'); + + // 6. Test immutability: once shipped, cannot be deleted + console.log('6. Testing cannot delete shipped shipping...'); + await assert.rejects( + async () => { + await store.shipping.delete(googleAuthId, shippingShipped.id); + }, + { message: 'Shipped shipping cannot be deleted' }, + ); + + // 7. Test immutability: once shipped, cannot be undone + console.log('7. Testing cannot undo shipped shipping...'); + await assert.rejects( + async () => { + await store.shipping.update(googleAuthId, shippingShipped.id, { state: 'open' }); + }, + { message: 'Shipped shipping cannot be undone' }, + ); + + // 8. Test order cannot be shipped again + console.log('8. Testing order cannot be shipped twice...'); + await assert.rejects( + async () => { + await store.shipping.create(googleAuthId, { + manifestId: order.id, + type: 'express', + state: 'shipped', + }); + }, + { message: 'Order already shipped' }, + ); + + // 9. Test direct creation with state 'shipped' + console.log('9. Testing direct creation with state "shipped"...'); + const order2 = await store.order.create(googleAuthId, { + number: 'O-2026-002', + date: new Date().toISOString(), + entityId: customer.id, + lines: [ + { + sku: trackedArt.sku, + name: 'Tracked Article', + pu: 1, + amount: 2, + net: 50, + taxrate: 19, + }, + ], + }); + + const directShipped = await store.shipping.create(googleAuthId, { + manifestId: order2.id, + type: 'parcel', + state: 'shipped', + }); + assert.equal(directShipped.state, 'shipped'); + + const artFinal = await store.article.getBySku(googleAuthId, trackedArt.sku); + assert.equal(artFinal.article.warehouses.reduce((sum, w) => sum + w.stock, 0), 5, 'Stock should now be 5 (7 - 2)'); + + console.log('✓ All order shipping ledger tests passed successfully!'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/src/index.js b/src/index.js index 930aa8f..7e5462e 100644 --- a/src/index.js +++ b/src/index.js @@ -182,6 +182,7 @@ import paymentCreate from './store/payment/create.js'; import paymentListByManifestId from './store/payment/listByManifestId.js'; import paymentDelete from './store/payment/delete.js'; import shippingCreate from './store/shipping/create.js'; +import shippingUpdate from './store/shipping/update.js'; import shippingListByManifestId from './store/shipping/listByManifestId.js'; import shippingDelete from './store/shipping/delete.js'; import packageCreate from './store/package/create.js'; @@ -451,6 +452,7 @@ export default class LibDashStore { this.shipping = { create: shippingCreate(provider), + update: shippingUpdate(provider), listByManifestId: shippingListByManifestId(provider), delete: shippingDelete(provider), }; diff --git a/src/providers/mssql/queries/ledger.js b/src/providers/mssql/queries/ledger.js index 607ee2c..d191892 100644 --- a/src/providers/mssql/queries/ledger.js +++ b/src/providers/mssql/queries/ledger.js @@ -29,7 +29,12 @@ FROM articlesupplier asup INNER JOIN entity e ON e.id = asup.entity_id INNER JOIN customergroup cg ON cg.id = e.customergroup_id WHERE asup.sku = @sku - AND cg.company_id = @companyId; + AND cg.company_id = @companyId +UNION +SELECT id AS articleId +FROM article +WHERE sku = @sku + AND company_id = @companyId; `; export const INSERT_ARTICLE_HISTORY = ` diff --git a/src/providers/mssql/queries/logistics.js b/src/providers/mssql/queries/logistics.js index 0a617f3..355db8b 100644 --- a/src/providers/mssql/queries/logistics.js +++ b/src/providers/mssql/queries/logistics.js @@ -75,6 +75,59 @@ WHERE id = @id AND manifest_id IN (SELECT m.id FROM manifest m WHERE m.company_id = @companyId) `; +export const GET_SHIPPING_BY_ID = ` +SELECT s.id, s.manifest_id AS manifestId, s.[number], s.[type], s.[state] +FROM shipping s +INNER JOIN manifest m ON m.id = s.manifest_id +WHERE s.id = @id AND m.company_id = @companyId; +`; + +export const SHIPPING_UPDATE = ` +UPDATE shipping +SET [type] = @type, [state] = @state +OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.[type], INSERTED.[state] +WHERE id = @id; +`; + +export const CHECK_ORDER_SHIPPED = ` +SELECT TOP 1 s.id +FROM shipping s +WHERE s.manifest_id = @manifestId AND LOWER(s.[state]) = 'shipped'; +`; + +export const GET_ORDER_LINES_FOR_SHIPPING = ` +SELECT id, sku, name, amount +FROM manifestarticle +WHERE manifest_id = @manifestId +ORDER BY id ASC; +`; + +export const GET_MANIFEST_HEADER_BY_ID = ` +SELECT id, company_id AS companyId, [number], [type], entity_id AS entityId +FROM manifest +WHERE id = @manifestId AND company_id = @companyId; +`; + +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 CHECK_WAREHOUSE = ` +SELECT id, name +FROM warehouse +WHERE id = @warehouseId AND company_id = @companyId; +`; + +export const CHECK_ARTICLE_TRACK_STOCK = ` +SELECT 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 PACKAGE_CREATE = ` INSERT INTO package (shipping_id, entityaddress_id, [number], [type], [state], details) OUTPUT INSERTED.id, INSERTED.shipping_id, INSERTED.entityaddress_id, INSERTED.[number], INSERTED.[type], INSERTED.[state], INSERTED.details diff --git a/src/providers/sqlite3/queries/ledger.js b/src/providers/sqlite3/queries/ledger.js index f0d08cb..b55bbb3 100644 --- a/src/providers/sqlite3/queries/ledger.js +++ b/src/providers/sqlite3/queries/ledger.js @@ -29,7 +29,12 @@ FROM articlesupplier asup INNER JOIN entity e ON e.id = asup.entity_id INNER JOIN customergroup cg ON cg.id = e.customergroup_id WHERE asup.sku = @sku - AND cg.company_id = @companyId; + AND cg.company_id = @companyId +UNION +SELECT id AS articleId +FROM article +WHERE sku = @sku + AND company_id = @companyId; `; export const INSERT_ARTICLE_HISTORY = ` diff --git a/src/providers/sqlite3/queries/logistics.js b/src/providers/sqlite3/queries/logistics.js index dc3fe59..1d12569 100644 --- a/src/providers/sqlite3/queries/logistics.js +++ b/src/providers/sqlite3/queries/logistics.js @@ -15,6 +15,14 @@ export const { SHIPPING_CREATE, SHIPPING_LIST_BY_MANIFEST, SHIPPING_DELETE, + GET_SHIPPING_BY_ID, + SHIPPING_UPDATE, + CHECK_ORDER_SHIPPED, + GET_ORDER_LINES_FOR_SHIPPING, + GET_MANIFEST_HEADER_BY_ID, + GET_DEFAULT_WAREHOUSE, + CHECK_WAREHOUSE, + CHECK_ARTICLE_TRACK_STOCK, PACKAGE_CREATE, PACKAGE_LIST_BY_SHIPPING, PACKAGE_DELETE, diff --git a/src/store/ledger/add.js b/src/store/ledger/add.js index f3454e1..84ea6ee 100644 --- a/src/store/ledger/add.js +++ b/src/store/ledger/add.js @@ -197,6 +197,13 @@ export default function add(provider) { !upperType.includes('ADJUST') && !upperType.includes('LOSS') && !upperType.includes('WRITE_OFF')); + const isShipping = + upperType === 'SHIPPING' || + upperType === 'ORDER_SHIPPED' || + (manifestArticle.manifestType === 'order' && + !upperType.includes('ADJUST') && + !upperType.includes('LOSS') && + !upperType.includes('WRITE_OFF')); let historyText = transactionText; if (isDelivery) { @@ -224,6 +231,22 @@ export default function add(provider) { transactionText?.trim() || (manifestArticle.manifestNumber ? `Lieferung ${manifestArticle.manifestNumber}` : 'Lieferung'); historyText = `${base}${suffix}`; + } else if (isShipping) { + const details = []; + if (manifestArticle.supplierName) { + details.push(`Kunde: "${manifestArticle.supplierName}"`); + } + if (warehouseName) { + details.push(`Lager: "${warehouseName}"`); + } + if (amount != null && Number.isFinite(Number(amount))) { + details.push(`Menge: ${amount}`); + } + const suffix = details.length ? ` (${details.join(', ')})` : ''; + const base = + transactionText?.trim() || + (manifestArticle.manifestNumber ? `Versand Auftrag ${manifestArticle.manifestNumber}` : 'Versand'); + historyText = `${base}${suffix}`; } else if (warehouseName && !transactionText.includes(warehouseName)) { historyText = `${transactionText} (Lager: "${warehouseName}")`; } diff --git a/src/store/shipping/create.js b/src/store/shipping/create.js index 37431dc..07945cd 100644 --- a/src/store/shipping/create.js +++ b/src/store/shipping/create.js @@ -1,7 +1,7 @@ import { queriesFor, requireId, requireText } from '../logistics/helpers.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; -import resolveManifestAccess from '../manifestattachment/resolveManifestAccess.js'; import nextNumber from '../numbers/nextNumber.js'; +import ledgerAdd from '../ledger/add.js'; import makeRunner from '../runner.js'; function shape(row) { @@ -16,37 +16,60 @@ function shape(row) { export default function create(provider) { const access = resolveSelectedCompanyAccess(provider); - const manifestAccess = resolveManifestAccess(provider); const allocateNumber = nextNumber(provider); + const addLedger = ledgerAdd(provider); const q = queriesFor(provider); /** * Creates a shipping for a manifest (order). The number is allocated from the - * `shipping` numbers sequence (own prefix + counter). + * `shipping` numbers sequence (own prefix + counter). If state is 'shipped', + * adjusts stock by appending negative ledger movements for tracked order lines, + * writes article history, and ensures the order was not already shipped. * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - Shipping input. * @param {number} input.manifestId - The manifest (order) id. * @param {string} input.type - Shipping type. - * @param {string} input.state - Shipping state. + * @param {string} input.state - Shipping state ('shipped' or other). + * @param {number} [input.warehouseId] - Optional warehouse ID for stock deduction (defaults to company's first warehouse). * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, manifestId: number, number: string, type: string, state: string}>} - * @throws {Error} If manifest is not found or input is invalid. + * @throws {Error} If manifest is not found, order already shipped, or input is invalid. * * @example - * await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'open' }); + * await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped' }); */ return async (googleAuthId, input = {}, tx) => { const manifestId = requireId(input.manifestId, 'Manifest'); const type = requireText(input.type, 'Shipping type'); const state = requireText(input.state, 'Shipping state'); + const isShipped = state.trim().toLowerCase() === 'shipped'; const run = async (runner, innerTx) => { const { companyId } = await access(googleAuthId, runner); - await manifestAccess(companyId, manifestId, innerTx); + // Verify manifest exists and belongs to company + const manifestRes = await runner.query(q.GET_MANIFEST_HEADER_BY_ID, { + manifestId, + companyId, + }); + const manifest = manifestRes.rows[0]; + if (!manifest) { + throw new Error('Manifest not found'); + } + + if (isShipped) { + // Check if order was already shipped + const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, { + manifestId, + }); + if (shippedCheck.rows[0]) { + throw new Error('Order already shipped'); + } + } + + // Allocate shipping number and insert shipping record const number = await allocateNumber(companyId, 'shipping', innerTx); - const result = await runner.query(q.SHIPPING_CREATE, { manifestId, number, @@ -60,12 +83,83 @@ export default function create(provider) { type, state, }; + + if (isShipped) { + // Load order lines + const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, { + manifestId, + }); + const lines = linesRes.rows ?? []; + + // Check which lines need warehouse stock deduction + const trackedLines = []; + for (const line of lines) { + const sku = line.sku ? String(line.sku).trim() : ''; + const amount = Number(line.amount); + if (!sku || !Number.isFinite(amount) || amount <= 0) { + continue; + } + + const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, { + companyId, + sku, + }); + const isUntracked = (trackRes.rows ?? []).some( + (r) => Number(r.track_stock ?? 1) === 0 + ); + if (!isUntracked && (trackRes.rows ?? []).length > 0) { + trackedLines.push({ ...line, amount }); + } + } + + if (trackedLines.length > 0) { + let warehouseId = null; + if (input.warehouseId != null) { + const requestedWhId = requireId(input.warehouseId, 'Warehouse'); + const whCheck = await runner.query(q.CHECK_WAREHOUSE, { + warehouseId: requestedWhId, + companyId, + }); + if (!whCheck.rows[0]) { + throw new Error('Warehouse not found'); + } + warehouseId = requestedWhId; + } else { + const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { + companyId, + }); + if (!defaultWh.rows[0]) { + throw new Error('Warehouse not found'); + } + warehouseId = defaultWh.rows[0].id; + } + + const manifestNumber = manifest.number || String(manifestId); + for (const line of trackedLines) { + await addLedger( + googleAuthId, + { + manifestarticle_id: line.id, + warehouse_id: warehouseId, + transaction_type: 'SHIPPING', + amount: -line.amount, + transaction_text: `Versand Auftrag ${manifestNumber}`, + }, + innerTx, + ); + } + } + } + return shape(row); }; + if (tx) { + return run(makeRunner(provider, tx), tx); + } if (typeof provider?.transaction === 'function') { return provider.transaction((runner, innerTx) => run(runner, innerTx)); } - return run(makeRunner(provider, tx)); + return run(makeRunner(provider)); }; } diff --git a/src/store/shipping/delete.js b/src/store/shipping/delete.js index 0348edf..ca470b0 100644 --- a/src/store/shipping/delete.js +++ b/src/store/shipping/delete.js @@ -4,11 +4,13 @@ import makeRunner from '../runner.js'; /** * Deletes a shipping of the user's selected company. Its packages are removed by cascade. + * Once shipped, a shipping cannot be deleted because stock movements have been ledgered. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The shipping id. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} + * @throws {Error} If shipping is not found or is already shipped. * * @example * await store.shipping.delete(googleAuthId, 9); @@ -21,6 +23,19 @@ export default function del(provider) { const shippingId = requireId(id, 'Shipping'); const runner = makeRunner(provider, tx); const { companyId } = await access(googleAuthId, runner); + + const check = await runner.query(q.GET_SHIPPING_BY_ID, { + id: shippingId, + companyId, + }); + const shipping = check.rows[0]; + if (!shipping) { + throw new Error('Shipping not found'); + } + if (String(shipping.state || '').trim().toLowerCase() === 'shipped') { + throw new Error('Shipped shipping cannot be deleted'); + } + await runner.query(q.SHIPPING_DELETE, { id: shippingId, companyId }); }; } diff --git a/src/store/shipping/update.js b/src/store/shipping/update.js new file mode 100644 index 0000000..87ae4f2 --- /dev/null +++ b/src/store/shipping/update.js @@ -0,0 +1,182 @@ +import { queriesFor, requireId, requireText } from '../logistics/helpers.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import ledgerAdd from '../ledger/add.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + manifestId: row.manifest_id, + number: row.number, + type: row.type, + state: row.state, + }; +} + +/** + * Updates a shipping record for the user's selected company. + * If transitioned to 'shipped', books ledger movements and adjusts stock. + * Once shipped, shipping cannot be undone (state cannot be changed away from 'shipped'). + * + * @param {import('../../providers/provider.js').DatabaseProvider} provider + */ +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const addLedger = ledgerAdd(provider); + const q = queriesFor(provider); + + /** + * Updates a shipping record. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - Shipping ID. + * @param {object} patch - Shipping patch fields. + * @param {string} [patch.type] - Shipping type. + * @param {string} [patch.state] - Shipping state. + * @param {number} [patch.warehouseId] - Warehouse ID if transitioning to 'shipped'. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, manifestId: number, number: string, type: string, state: string}>} + * @throws {Error} If shipping is not found, cannot be undone, or order already shipped. + */ + return async (googleAuthId, id, patch = {}, tx) => { + const shippingId = requireId(id, 'Shipping'); + + const run = async (runner, innerTx) => { + const { companyId } = await access(googleAuthId, runner); + + const check = await runner.query(q.GET_SHIPPING_BY_ID, { + id: shippingId, + companyId, + }); + const current = check.rows[0]; + if (!current) { + throw new Error('Shipping not found'); + } + + const isCurrentlyShipped = + String(current.state || '').trim().toLowerCase() === 'shipped'; + const hasState = Object.prototype.hasOwnProperty.call(patch, 'state'); + const hasType = Object.prototype.hasOwnProperty.call(patch, 'type'); + + if (isCurrentlyShipped) { + if ( + hasState && + String(patch.state || '').trim().toLowerCase() !== 'shipped' + ) { + throw new Error('Shipped shipping cannot be undone'); + } + } + + const nextType = hasType + ? requireText(patch.type, 'Shipping type') + : current.type; + const nextState = hasState + ? requireText(patch.state, 'Shipping state') + : current.state; + const willBeShipped = + !isCurrentlyShipped && + String(nextState).trim().toLowerCase() === 'shipped'; + + if (willBeShipped) { + const manifestId = current.manifestId; + const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, { + manifestId, + }); + if (shippedCheck.rows[0]) { + throw new Error('Order already shipped'); + } + + const manifestRes = await runner.query(q.GET_MANIFEST_HEADER_BY_ID, { + manifestId, + companyId, + }); + const manifest = manifestRes.rows[0] ?? { number: String(manifestId) }; + + // Load lines & book ledger + const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, { + manifestId, + }); + const lines = linesRes.rows ?? []; + const trackedLines = []; + for (const line of lines) { + const sku = line.sku ? String(line.sku).trim() : ''; + const amount = Number(line.amount); + if (!sku || !Number.isFinite(amount) || amount <= 0) continue; + + const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, { + companyId, + sku, + }); + const isUntracked = (trackRes.rows ?? []).some( + (r) => Number(r.track_stock ?? 1) === 0 + ); + if (!isUntracked && (trackRes.rows ?? []).length > 0) { + trackedLines.push({ ...line, amount }); + } + } + + if (trackedLines.length > 0) { + let warehouseId = null; + if (patch.warehouseId != null) { + const requestedWhId = requireId(patch.warehouseId, 'Warehouse'); + const whCheck = await runner.query(q.CHECK_WAREHOUSE, { + warehouseId: requestedWhId, + companyId, + }); + if (!whCheck.rows[0]) { + throw new Error('Warehouse not found'); + } + warehouseId = requestedWhId; + } else { + const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { + companyId, + }); + if (!defaultWh.rows[0]) { + throw new Error('Warehouse not found'); + } + warehouseId = defaultWh.rows[0].id; + } + + const manifestNumber = manifest.number || String(manifestId); + for (const line of trackedLines) { + await addLedger( + googleAuthId, + { + manifestarticle_id: line.id, + warehouse_id: warehouseId, + transaction_type: 'SHIPPING', + amount: -line.amount, + transaction_text: `Versand Auftrag ${manifestNumber}`, + }, + innerTx, + ); + } + } + } + + const updateRes = await runner.query(q.SHIPPING_UPDATE, { + id: shippingId, + type: nextType, + state: nextState, + }); + + const updatedRow = updateRes.rows[0] ?? { + id: shippingId, + manifest_id: current.manifestId, + number: current.number, + type: nextType, + state: nextState, + }; + + return shape(updatedRow); + }; + + if (tx) { + return run(makeRunner(provider, tx), tx); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction((runner, innerTx) => run(runner, innerTx)); + } + return run(makeRunner(provider)); + }; +}