405 lines
18 KiB
JavaScript
405 lines
18 KiB
JavaScript
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('ships everything remaining when lines are omitted', 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' });
|
|
});
|
|
|
|
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 });
|
|
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 }), { 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' });
|
|
});
|
|
|
|
it('cannot ship orders of another company', async () => {
|
|
const order = await makeOrder();
|
|
await assert.rejects(
|
|
() => store.shipment.create(other.googleAuthId, { orderId: order.id }),
|
|
{ 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)));
|
|
});
|
|
});
|
|
});
|