319 lines
15 KiB
JavaScript
319 lines
15 KiB
JavaScript
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createTestStore, createTenant, createArticle, isoNow } from './helpers.js';
|
|
|
|
describe('store.delivery', () => {
|
|
let ctx;
|
|
let store;
|
|
let tenant;
|
|
let other;
|
|
let gid;
|
|
let supplier;
|
|
let warehouse;
|
|
let tracked;
|
|
|
|
const line = (sku, amount = 5, extra = {}) => ({ sku, name: `Line ${sku}`, pu: 1, amount, net: 10, taxrate: 19, ...extra });
|
|
const create = (body = {}) =>
|
|
store.delivery.create(gid, { supplierId: supplier.id, date: isoNow(), lines: [], ...body });
|
|
|
|
before(async () => {
|
|
ctx = await createTestStore('dash-delivery-');
|
|
store = ctx.store;
|
|
tenant = await createTenant(store, { name: 'Delivery Test GmbH' });
|
|
other = await createTenant(store, { name: 'Delivery Other GmbH' });
|
|
gid = tenant.googleAuthId;
|
|
supplier = await store.supplier.create(gid, { name: 'Supplier One' });
|
|
warehouse = await store.warehouse.create(gid, { name: 'Hauptlager' });
|
|
tracked = await createArticle(store, tenant, { name: 'Tracked', sku: 'DEL-TRACKED', track_stock: true });
|
|
});
|
|
|
|
after(async () => {
|
|
await ctx.cleanup();
|
|
});
|
|
|
|
describe('create / getById', () => {
|
|
it('creates a delivery with number, lines and an unbooked summary', async () => {
|
|
const d = await create({
|
|
externalNumber: ' EXT-1 ',
|
|
lines: [line('DEL-TRACKED', 5), { name: 'Free text', amount: 2, net: 3.5, taxrate: 7 }],
|
|
});
|
|
|
|
assert.equal(typeof d.id, 'number');
|
|
assert.equal(d.type, 'delivery');
|
|
assert.ok(d.number);
|
|
assert.equal(d.externalNumber, 'EXT-1');
|
|
assert.equal(d.supplierId, supplier.id);
|
|
assert.equal(d.lines.length, 2);
|
|
assert.equal(d.lines[0].sku, 'DEL-TRACKED');
|
|
assert.equal(d.lines[0].amount, 5);
|
|
assert.equal(d.lines[1].sku, null);
|
|
assert.equal(d.lines[1].name, 'Free text');
|
|
assert.equal(d.lines[1].pu, 1);
|
|
assert.equal(d.positions, 2);
|
|
assert.equal(d.isBooked, false);
|
|
assert.deepEqual(d.files, []);
|
|
|
|
const fetched = await store.delivery.getById(gid, d.id);
|
|
assert.equal(fetched.id, d.id);
|
|
assert.equal(fetched.number, d.number);
|
|
assert.equal(fetched.lines.length, 2);
|
|
});
|
|
|
|
it('allocates distinct sequential numbers', async () => {
|
|
const a = await create();
|
|
const b = await create();
|
|
assert.notEqual(a.number, b.number);
|
|
});
|
|
|
|
it('rejects a delivery without supplier', async () => {
|
|
await assert.rejects(
|
|
() => store.delivery.create(gid, { date: isoNow(), lines: [] }),
|
|
{ message: 'No supplier selected' },
|
|
);
|
|
});
|
|
|
|
it('validates input', async () => {
|
|
await assert.rejects(() => create({ date: 'garbage' }), { message: 'Invalid delivery' });
|
|
await assert.rejects(() => create({ createdAt: 'garbage' }), { message: 'Invalid delivery' });
|
|
await assert.rejects(() => create({ supplierId: null }), { message: 'No supplier selected' });
|
|
await assert.rejects(() => create({ supplierId: '1' }), { message: 'Invalid supplierId' });
|
|
await assert.rejects(() => create({ supplierId: 999999 }), { message: 'Supplier not found' });
|
|
await assert.rejects(() => create({ lines: 'x' }), { message: 'Invalid lines' });
|
|
await assert.rejects(() => create({ lines: [{ amount: 1, net: 1, taxrate: 19 }] }), { message: 'Invalid lines' });
|
|
await assert.rejects(() => create({ lines: [line('DEL-TRACKED', 'many')] }), { message: 'Invalid lines' });
|
|
await assert.rejects(() => create({ lines: [line('DEL-TRACKED', 1, { taxrate: -1 })] }), { message: 'Invalid lines' });
|
|
});
|
|
|
|
it('rejects a supplier of another company', async () => {
|
|
const foreign = await store.supplier.create(other.googleAuthId, { name: 'Foreign Supplier' });
|
|
await assert.rejects(() => create({ supplierId: foreign.id }), { message: 'Supplier not found' });
|
|
});
|
|
|
|
it('rejects booking a parent article on a document', async () => {
|
|
const attr = await store.attributename.create(gid, { name: 'Delivery Attr' });
|
|
const parent = await createArticle(store, tenant, { name: 'Parent', sku: 'DEL-PARENT' });
|
|
await store.article.toggleParent(gid, parent.sku, { isParent: true, attributeNameId: attr.id });
|
|
await assert.rejects(
|
|
() => create({ lines: [line('DEL-PARENT')] }),
|
|
/A parent article cannot be booked on documents \(DEL-PARENT\)/,
|
|
);
|
|
});
|
|
|
|
it('getById rejects unknown and foreign deliveries', async () => {
|
|
const d = await create();
|
|
await assert.rejects(() => store.delivery.getById(gid, 999999), { message: 'Delivery not found' });
|
|
await assert.rejects(() => store.delivery.getById(other.googleAuthId, d.id), { message: 'Delivery not found' });
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('updates header fields', async () => {
|
|
const d = await create({ externalNumber: 'OLD' });
|
|
const upd = await store.delivery.update(gid, d.id, { externalNumber: 'NEW', number: 'MY-NUMBER' });
|
|
assert.equal(upd.externalNumber, 'NEW');
|
|
assert.equal(upd.number, 'MY-NUMBER');
|
|
|
|
const cleared = await store.delivery.update(gid, d.id, { externalNumber: ' ' });
|
|
assert.equal(cleared.externalNumber, null);
|
|
|
|
await assert.rejects(
|
|
() => store.delivery.update(gid, d.id, { supplierId: null }),
|
|
{ message: 'No supplier selected' },
|
|
);
|
|
});
|
|
|
|
it('replaces lines of an unbooked delivery', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
|
|
const upd = await store.delivery.update(gid, d.id, { lines: [line('DEL-TRACKED', 7), line('DEL-TRACKED', 3)] });
|
|
assert.deepEqual(upd.lines.map((l) => l.amount), [7, 3]);
|
|
});
|
|
|
|
it('refuses to replace lines that are already booked', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 4)] });
|
|
await store.delivery.book(gid, { id: d.id, warehouseId: warehouse.id });
|
|
await assert.rejects(
|
|
() => store.delivery.update(gid, d.id, { lines: [line('DEL-TRACKED', 9)] }),
|
|
{ message: 'Delivery lines already booked on ledger' },
|
|
);
|
|
assert.equal((await store.delivery.getById(gid, d.id)).lines[0].amount, 4);
|
|
});
|
|
|
|
it('refuses duplicate delivery numbers on update', async () => {
|
|
const d1 = await create();
|
|
const d2 = await create();
|
|
await assert.rejects(
|
|
() => store.delivery.update(gid, d2.id, { number: d1.number }),
|
|
{ message: 'Delivery number already exists' },
|
|
);
|
|
});
|
|
|
|
it('validates input', async () => {
|
|
const d = await create();
|
|
await assert.rejects(() => store.delivery.update(gid, d.id, {}), { message: 'Invalid delivery' });
|
|
await assert.rejects(() => store.delivery.update(gid, d.id, { number: ' ' }), { message: 'Invalid delivery' });
|
|
await assert.rejects(() => store.delivery.update(gid, d.id, { date: 'x' }), { message: 'Invalid delivery' });
|
|
await assert.rejects(() => store.delivery.update(gid, d.id, { createdAt: 'x' }), { message: 'Invalid delivery' });
|
|
await assert.rejects(() => store.delivery.update(gid, d.id, { supplierId: 999999 }), { message: 'Supplier not found' });
|
|
await assert.rejects(() => store.delivery.update(gid, 999999, { number: 'X' }), { message: 'Delivery not found' });
|
|
await assert.rejects(() => store.delivery.update(gid, Number.NaN, { number: 'X' }), { message: 'Delivery not found' });
|
|
});
|
|
|
|
it('cannot update deliveries of another company', async () => {
|
|
const d = await create();
|
|
await assert.rejects(
|
|
() => store.delivery.update(other.googleAuthId, d.id, { number: 'HIJACK' }),
|
|
{ message: 'Delivery not found' },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('book', () => {
|
|
it('books all lines and marks the delivery as booked', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 6), line('DEL-TRACKED', 2)] });
|
|
const booked = await store.delivery.book(gid, { id: d.id, warehouseId: warehouse.id });
|
|
|
|
assert.equal(booked.isBooked, true);
|
|
assert.equal(booked.ledger.isBooked, true);
|
|
assert.equal(booked.ledger.completelyOnLedgerPositions, 2);
|
|
|
|
for (const l of booked.lines) {
|
|
const entries = await store.ledger.listByManifestarticleId(gid, l.id);
|
|
assert.deepEqual(entries.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE']);
|
|
assert.equal(entries[1].warehouse_id, warehouse.id);
|
|
}
|
|
});
|
|
|
|
it('accepts a plain id and rejects double booking', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
|
|
await store.delivery.book(gid, d.id);
|
|
await assert.rejects(
|
|
() => store.delivery.book(gid, d.id),
|
|
/Delivery line(s)? already (accepted|booked) on ledger/,
|
|
);
|
|
});
|
|
|
|
it('rejects invalid, unknown and empty deliveries', async () => {
|
|
await assert.rejects(() => store.delivery.book(gid, {}), { message: 'Delivery not found' });
|
|
await assert.rejects(() => store.delivery.book(gid, 999999), { message: 'Delivery not found' });
|
|
const empty = await create();
|
|
await assert.rejects(() => store.delivery.book(gid, empty.id), { message: 'Delivery has no lines' });
|
|
});
|
|
|
|
it('cannot book a delivery of another company', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
|
|
await assert.rejects(() => store.delivery.book(other.googleAuthId, d.id), { message: 'Delivery not found' });
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('deletes an unbooked delivery', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
|
|
await store.delivery.delete(gid, d.id);
|
|
await assert.rejects(() => store.delivery.getById(gid, d.id), { message: 'Delivery not found' });
|
|
});
|
|
|
|
it('refuses to delete a booked delivery', async () => {
|
|
const d = await create({ lines: [line('DEL-TRACKED', 1)] });
|
|
await store.delivery.book(gid, d.id);
|
|
await assert.rejects(() => store.delivery.delete(gid, d.id), { message: 'Delivery in use in ledger' });
|
|
assert.ok(await store.delivery.getById(gid, d.id));
|
|
});
|
|
|
|
it('rejects unknown ids and foreign deliveries', async () => {
|
|
const d = await create();
|
|
await assert.rejects(() => store.delivery.delete(gid, 999999), { message: 'Delivery not found' });
|
|
await assert.rejects(() => store.delivery.delete(gid, Number.NaN), { message: 'Delivery not found' });
|
|
await assert.rejects(() => store.delivery.delete(other.googleAuthId, d.id), { message: 'Delivery not found' });
|
|
assert.ok(await store.delivery.getById(gid, d.id));
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
let lt; // isolated tenant so counts are exact
|
|
let ls1;
|
|
let ls2;
|
|
let d1;
|
|
let d2;
|
|
let d3;
|
|
|
|
before(async () => {
|
|
lt = await createTenant(store, { name: 'Delivery List GmbH' });
|
|
await store.warehouse.create(lt.googleAuthId, { name: 'List WH' });
|
|
ls1 = await store.supplier.create(lt.googleAuthId, { name: 'Alpha Trading' });
|
|
ls2 = await store.supplier.create(lt.googleAuthId, { name: 'Beta Supplies' });
|
|
await createArticle(store, lt, { sku: 'LST-1', track_stock: true });
|
|
|
|
const mk = (body) => store.delivery.create(lt.googleAuthId, { lines: [line('LST-1', 2)], ...body });
|
|
d1 = await mk({ supplierId: ls1.id, externalNumber: 'EXT-ALPHA', date: '2026-01-10T00:00:00Z' });
|
|
d2 = await mk({ supplierId: ls2.id, externalNumber: 'EXT-BETA', date: '2026-02-10T00:00:00Z' });
|
|
d3 = await mk({ supplierId: ls2.id, externalNumber: 'EXT-BETA-2', date: '2026-03-10T00:00:00Z' });
|
|
await store.delivery.book(lt.googleAuthId, d1.id);
|
|
});
|
|
|
|
it('returns a plain array without options, newest date first', async () => {
|
|
const all = await store.delivery.list(lt.googleAuthId);
|
|
assert.ok(Array.isArray(all));
|
|
assert.deepEqual(all.map((d) => d.id), [d3.id, d2.id, d1.id]);
|
|
assert.equal(all[2].isBooked, true);
|
|
assert.equal(all[0].isBooked, false);
|
|
});
|
|
|
|
it('paginates', async () => {
|
|
const page = await store.delivery.list(lt.googleAuthId, { offset: 1, limit: 1 });
|
|
assert.equal(page.total, 3);
|
|
assert.equal(page.unfilteredTotal, 3);
|
|
assert.equal(page.offset, 1);
|
|
assert.equal(page.limit, 1);
|
|
assert.deepEqual(page.deliveries.map((d) => d.id), [d2.id]);
|
|
});
|
|
|
|
it('filters by search, supplier and unbooked state', async () => {
|
|
const bySearch = await store.delivery.list(lt.googleAuthId, { search: 'EXT-BETA' });
|
|
assert.equal(bySearch.total, 2);
|
|
assert.equal(bySearch.unfilteredTotal, 3);
|
|
|
|
const bySupplierName = await store.delivery.list(lt.googleAuthId, { search: 'Alpha' });
|
|
assert.deepEqual(bySupplierName.deliveries.map((d) => d.id), [d1.id]);
|
|
|
|
const bySupplier = await store.delivery.list(lt.googleAuthId, { supplierId: ls1.id });
|
|
assert.deepEqual(bySupplier.deliveries.map((d) => d.id), [d1.id]);
|
|
|
|
const unbooked = await store.delivery.list(lt.googleAuthId, { onlyUnbooked: true });
|
|
assert.deepEqual(unbooked.deliveries.map((d) => d.id).sort(), [d2.id, d3.id].sort());
|
|
});
|
|
|
|
it('validates options', async () => {
|
|
const l = (o) => store.delivery.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' }), { message: 'Invalid seekMonths' });
|
|
await assert.rejects(() => l({ supplierId: 0 }), { message: 'Invalid supplierId' });
|
|
});
|
|
|
|
it('does not leak deliveries of other companies', async () => {
|
|
const mine = await store.delivery.list(gid);
|
|
assert.ok(!mine.some((d) => [d1.id, d2.id, d3.id].includes(d.id)));
|
|
});
|
|
});
|
|
|
|
describe('rebuildSummaries', () => {
|
|
it('keeps list results stable after a rebuild', async () => {
|
|
const rt = await createTenant(store, { name: 'Delivery Rebuild GmbH' });
|
|
await store.warehouse.create(rt.googleAuthId, { name: 'Rebuild WH' });
|
|
await createArticle(store, rt, { sku: 'RB-1', track_stock: true });
|
|
const rs = await store.supplier.create(rt.googleAuthId, { name: 'Rebuild Supplier' });
|
|
const d = await store.delivery.create(rt.googleAuthId, { supplierId: rs.id, date: isoNow(), lines: [line('RB-1', 3)] });
|
|
await store.delivery.book(rt.googleAuthId, d.id);
|
|
|
|
const before = await store.delivery.list(rt.googleAuthId);
|
|
await store.delivery.rebuildSummaries(rt.googleAuthId);
|
|
const after = await store.delivery.list(rt.googleAuthId);
|
|
|
|
assert.equal(after.length, before.length);
|
|
assert.equal(after[0].isBooked, true);
|
|
assert.equal(after[0].positions, before[0].positions);
|
|
assert.equal(after[0].bookedQuantity, before[0].bookedQuantity);
|
|
});
|
|
});
|
|
});
|