import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { createTestStore, createTenant, createArticle, createCustomer } from './helpers.js'; describe('store.shipment', () => { let ctx; let store; let tenant; let other; let gid; let customer; let seq = 0; const makeOrder = async (lines = [ { sku: 'SH-A', amount: 10, net: 100, taxrate: 19 }, { sku: 'SH-B', amount: 5, net: 50, taxrate: 19 }, ], date = '2026-10-12') => { seq += 1; return store.order.create(gid, { number: `AU-${2000 + seq}`, date, entityId: customer.id, lines, }); }; before(async () => { ctx = await createTestStore('dash-shipment-'); store = ctx.store; tenant = await createTenant(store, { name: 'Shipment Test GmbH' }); other = await createTenant(store, { name: 'Shipment Other GmbH' }); gid = tenant.googleAuthId; customer = await createCustomer(store, tenant, 'Max Mustermann'); // untracked articles -> no stock prerequisites for shipments await createArticle(store, tenant, { name: 'Item A', sku: 'SH-A' }); await createArticle(store, tenant, { name: 'Item B', sku: 'SH-B' }); }); after(async () => { await ctx.cleanup(); }); describe('create', () => { it('creates numbered shipments from an order', async () => { const order = await makeOrder(); const s1 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 6 }, { sku: 'SH-B', amount: 3 }], }); const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] }); assert.equal(s1.number, `${order.number}-L-1`); assert.equal(s2.number, `${order.number}-L-2`); assert.equal(s1.type, 'shipment'); assert.equal(s1.state, 'new'); assert.equal(s1.lineCount, 2); assert.equal(s1.packageCount, 0); assert.equal(s1.entityId, customer.id); assert.deepEqual(s1.lines.map((l) => [l.sku, l.amount]), [['SH-A', 6], ['SH-B', 3]]); // line data defaults from the order line assert.equal(s1.lines[0].net, 100); assert.equal(s1.lines[0].taxrate, 19); }); it('defaults amount to the remaining quantity', async () => { const order = await makeOrder(); await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 3 }] }); const rest = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] }); assert.equal(rest.lines[0].amount, 7); }); it('rejects when lines are omitted or empty', async () => { const order = await makeOrder(); 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 () => { const order = await makeOrder(); const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id); assert.equal(summary().shipped_quantity, 0); 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, lines: [{ sku: 'SH-B', amount: 5 }] }); assert.equal(summary().shipped_quantity, 15); assert.equal(summary().is_shipped, 1); }); it('enforces the ordered quantity ceiling', async () => { const order = await makeOrder(); await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 6 }] }); await assert.rejects( () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 5 }] }), { message: 'Shipment amount exceeds ordered amount for article (SH-A)' }, ); await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] }); await assert.rejects( () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] }), { message: 'Shipment amount exceeds ordered amount for article (SH-A)' }, ); }); it('rejects foreign articles and invalid input', async () => { const order = await makeOrder(); await assert.rejects( () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-UNKNOWN', amount: 1 }] }), { message: `Article (SH-UNKNOWN) is not part of order (${order.number})` }, ); await assert.rejects(() => store.shipment.create(gid, {}), { message: 'Order is required' }); await assert.rejects(() => store.shipment.create(gid, { orderId: 0 }), { message: 'Order is required' }); await assert.rejects(() => store.shipment.create(gid, { orderId: order.id, lines: 'x' }), { message: 'Invalid lines' }); await assert.rejects( () => 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, 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, 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, lines: [{ sku: 'SH-A', amount: 1 }] }), { message: 'Order not found' }, ); }); it('honours custom state and external number', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }], state: 'delivered', externalNumber: ' DHL-77 ', }); assert.equal(s.state, 'delivered'); assert.equal(s.externalNumber, 'DHL-77'); }); }); describe('order.createShipment', () => { it('is equivalent to shipment.create with the order id', async () => { const order = await makeOrder(); const s = await store.order.createShipment(gid, order.id, { lines: [{ sku: 'SH-B', amount: 2 }] }); assert.equal(s.number, `${order.number}-L-1`); assert.deepEqual(s.lines.map((l) => [l.sku, l.amount]), [['SH-B', 2]]); }); }); describe('getById / listByOrderId', () => { it('returns shipments with lines and packages', async () => { const order = await makeOrder(); const s1 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-B', amount: 1 }] }); const carrier = await store.carrier.create(gid, { name: `DHL ${order.number}`, hasPackages: true }); await store.shipping.create(gid, { manifestId: s1.id, carrierId: carrier.id, type: 'parcel', state: 'draft', trackingCode: 'T-1' }); const fetched = await store.shipment.getById(gid, s1.id); assert.equal(fetched.id, s1.id); assert.equal(fetched.lines.length, 1); assert.equal(fetched.packageCount, 1); assert.equal(fetched.shippings[0].trackingCode, 'T-1'); assert.equal(fetched.shippings[0].carrier, carrier.name); const byOrder = await store.shipment.listByOrderId(gid, order.id); assert.deepEqual(byOrder.map((s) => s.id), [s1.id, s2.id]); assert.equal(byOrder[0].shippings.length, 1); assert.equal(byOrder[1].shippings.length, 0); }); it('rejects unknown ids, foreign access and invalid ids', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); await assert.rejects(() => store.shipment.getById(gid, 999999), { message: 'Shipment not found' }); await assert.rejects(() => store.shipment.getById(other.googleAuthId, s.id), { message: 'Shipment not found' }); await assert.rejects(() => store.shipment.getById(gid, 'x'), { message: 'Shipment is required' }); await assert.rejects(() => store.shipment.listByOrderId(gid, 999999), { message: 'Order not found' }); await assert.rejects(() => store.shipment.listByOrderId(other.googleAuthId, order.id), { message: 'Order not found' }); }); }); describe('shipping.create', () => { it('allows adding shippings while shipment is unshipped, but refuses once shipped', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 5 }] }); const carrier = await store.carrier.create(gid, { name: `Carrier ${order.number}`, hasPackages: true }); // First package: draft -> allowed const p1 = await store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft', }); assert.ok(p1.id); // Second package while still draft -> allowed const p2 = await store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft', }); assert.ok(p2.id); // Now mark package as shipped -> shipment is now shipped await store.shipping.update(gid, p1.id, { state: 'shipped' }); // Attempting to add a new shipping to the already shipped shipment must fail await assert.rejects( () => store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft', }), { message: 'Shipment is already shipped' }, ); }); it('refuses adding shipping if shipment is in delivered state', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 2 }] }); const carrier = await store.carrier.create(gid, { name: `Carrier Deliv ${order.number}`, hasPackages: true }); await store.shipment.update(gid, s.id, { state: 'delivered' }); await assert.rejects( () => store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft', }), { message: 'Shipment is already shipped' }, ); }); }); describe('update', () => { it('changes the state and validates it', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); assert.equal((await store.shipment.update(gid, s.id, { state: 'delivered' })).state, 'delivered'); assert.equal((await store.shipment.update(gid, s.id, { state: ' RETURNED ' })).state, 'returned'); assert.equal((await store.shipment.update(gid, s.id, {})).state, 'returned', 'empty patch keeps the state'); await assert.rejects(() => store.shipment.update(gid, s.id, { state: 'lost' }), { message: 'Invalid state' }); await assert.rejects(() => store.shipment.update(gid, 999999, { state: 'new' }), { message: 'Shipment not found' }); await assert.rejects(() => store.shipment.update(other.googleAuthId, s.id, { state: 'new' }), { message: 'Shipment not found' }); await assert.rejects(() => store.shipment.update(gid, 0, { state: 'new' }), { message: 'Shipment is required' }); }); }); describe('delete', () => { it('deletes an unshipped shipment and reopens the order quantity', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] }); const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id); assert.equal(summary().shipped_quantity, 10); await store.shipment.delete(gid, s.id); await assert.rejects(() => store.shipment.getById(gid, s.id), { message: 'Shipment not found' }); assert.equal(summary().shipped_quantity, 0); const again = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] }); assert.ok(again.id); }); it('refuses to delete a shipment with shipped packages', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 2 }] }); const carrier = await store.carrier.create(gid, { name: `Carrier ${order.number}`, hasPackages: true }); const pkg = await store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft' }); await store.shipping.update(gid, pkg.id, { state: 'shipped' }); await assert.rejects(() => store.shipment.delete(gid, s.id), { message: 'Shipped shipment cannot be deleted' }); assert.ok(await store.shipment.getById(gid, s.id)); }); it('rejects unknown, foreign and invalid ids', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); await assert.rejects(() => store.shipment.delete(gid, 999999), { message: 'Shipment not found' }); await assert.rejects(() => store.shipment.delete(other.googleAuthId, s.id), { message: 'Shipment not found' }); await assert.rejects(() => store.shipment.delete(gid, null), { message: 'Shipment is required' }); assert.ok(await store.shipment.getById(gid, s.id)); }); it('refuses to delete an order manifest when there is a shipment manifest referencing it', async () => { const order = await makeOrder(); const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); await assert.rejects( () => store.order.delete(gid, order.id), { message: 'Order has shipment manifests' }, ); // Once all shipment manifests referencing the order are deleted, the order can be deleted await store.shipment.delete(gid, s.id); await store.order.delete(gid, order.id); await assert.rejects(() => store.order.getById(gid, order.id), { message: 'Order not found' }); }); }); describe('list / rebuildSummaries', () => { let lt; let o1; let o2; let s1; let s2; let s3; before(async () => { lt = await createTenant(store, { name: 'Shipment List GmbH' }); const cust = await createCustomer(store, lt, 'Listy McList'); await createArticle(store, lt, { sku: 'LS-A' }); const mk = (number) => store.order.create(lt.googleAuthId, { number, date: '2026-10-12', entityId: cust.id, lines: [{ sku: 'LS-A', amount: 10, net: 1, taxrate: 19 }], }); o1 = await mk('LIST-1'); o2 = await mk('LIST-2'); s1 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 3 }] }); s2 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 2 }] }); s3 = await store.shipment.create(lt.googleAuthId, { orderId: o2.id, lines: [{ sku: 'LS-A', amount: 4 }] }); }); it('lists with summary data and pagination', async () => { const res = await store.shipment.list(lt.googleAuthId, { offset: 0, limit: 10 }); assert.equal(res.total, 3); assert.equal(res.unfilteredTotal, 3); assert.equal(res.limit, 10); assert.equal(res.offset, 0); const row = res.shipments.find((s) => s.id === s1.id); assert.equal(row.orderNumber, 'LIST-1'); assert.equal(row.orderId, o1.id); assert.equal(row.state, 'new'); assert.equal(row.positions, 1); assert.equal(row.totalQuantity, 3); const page = await store.shipment.list(lt.googleAuthId, { offset: 2, limit: 1 }); assert.equal(page.shipments.length, 1); assert.equal(page.total, 3); }); it('returns a plain array without options', async () => { const all = await store.shipment.list(lt.googleAuthId); assert.ok(Array.isArray(all)); assert.equal(all.length, 3); }); it('filters by order, search and state', async () => { const byOrder = await store.shipment.list(lt.googleAuthId, { orderId: o1.id }); assert.deepEqual(byOrder.shipments.map((s) => s.id).sort(), [s1.id, s2.id].sort()); const bySearch = await store.shipment.list(lt.googleAuthId, { search: 'LIST-2' }); assert.deepEqual(bySearch.shipments.map((s) => s.id), [s3.id]); assert.equal(bySearch.unfilteredTotal, 3); await store.shipment.update(lt.googleAuthId, s1.id, { state: 'delivered' }); const delivered = await store.shipment.list(lt.googleAuthId, { state: 'delivered' }); assert.deepEqual(delivered.shipments.map((s) => s.id), [s1.id]); }); it('validates options', async () => { const l = (o) => store.shipment.list(lt.googleAuthId, o); await assert.rejects(() => l({ offset: -1 }), { message: 'Invalid offset' }); await assert.rejects(() => l({ limit: 0 }), { message: 'Invalid limit' }); await assert.rejects(() => l({ seekMonths: 1.5 }), { message: 'Invalid seekMonths' }); }); it('rebuildSummaries preserves state and quantities', async () => { await store.shipment.update(lt.googleAuthId, s2.id, { state: 'returned' }); await store.shipment.rebuildSummaries(lt.googleAuthId); const returned = await store.shipment.list(lt.googleAuthId, { state: 'returned' }); assert.deepEqual(returned.shipments.map((s) => s.id), [s2.id]); assert.equal(returned.shipments[0].totalQuantity, 2); assert.equal(returned.shipments[0].orderNumber, 'LIST-1'); }); it('does not leak shipments of other companies', async () => { const mine = await store.shipment.list(gid, { limit: 1000, offset: 0 }); assert.ok(!mine.shipments.some((s) => [s1.id, s2.id, s3.id].includes(s.id))); }); }); });