Files
dashlibstorage/scripts/test-ledger.js
2026-10-23 20:31:39 +02:00

486 lines
19 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js';
async function run() {
const tempDir = mkdtempSync(join(tmpdir(), 'dash-ledger-test-'));
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 {
console.log('--- Testing store.ledger (acceptance, warehouse, shipment, guards) ---');
const googleAuthId = await store.google_auth.add('ledger-tester@example.com');
const company = await store.company.create({
name: 'Ledger Test GmbH',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const supplier = await store.supplier.create(googleAuthId, {
name: 'Supplier X',
});
// Create an article with track_stock = false initially
const article = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Ledger Linked Article',
track_stock: false,
});
assert.equal(article.track_stock, false, 'Newly created article must have track_stock disabled');
await store.articlesupplier.create(googleAuthId, article.sku, {
entityId: supplier.id,
sku: 'SKU-ABC',
pu: 1,
});
const customer1 = await store.entity.create(googleAuthId, {
name: 'Customer Quote Test',
isCustomer: true,
customergroupId: company.rootCategoryId,
});
// Create a quote and verify ledger bookings are rejected
const quote = await store.quote.create(googleAuthId, {
number: 'Q-1',
entityId: customer1.id,
date: new Date().toISOString(),
lines: [{ sku: 'SKU-ABC', name: 'Quote item', pu: 1, amount: 2, net: 10, taxrate: 19 }],
});
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: quote.lines[0].id,
transaction_type: 'ACCEPTANCE',
amount: 2,
transaction_text: 'Should fail for quote',
}),
/Cannot book ledger entries for quote/,
'Should reject booking ledger entries for quote manifest',
);
// Create an order and verify ledger bookings are rejected
const order = await store.order.create(googleAuthId, {
number: 'O-1',
entityId: customer1.id,
date: new Date().toISOString(),
lines: [{ sku: 'SKU-ABC', name: 'Order item', pu: 1, amount: 2, net: 10, taxrate: 19 }],
});
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: order.lines[0].id,
transaction_type: 'ACCEPTANCE',
amount: 2,
transaction_text: 'Should fail for order',
}),
/Cannot book ledger entries for order/,
'Should reject booking ledger entries for order manifest',
);
// 1. Create a delivery manifest with lines
const delivery = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
externalNumber: 'DEL-1',
date: new Date().toISOString(),
lines: [
{
sku: 'SKU-ABC',
name: 'Ledger Item 1',
pu: 1,
amount: 5,
net: 10,
rebate: 0,
taxrate: 19,
},
{
sku: 'SKU-ABC',
name: 'Ledger Item 2',
pu: 1,
amount: 10,
net: 20,
rebate: 0,
taxrate: 19,
},
],
});
const manifestarticleId1 = delivery.lines[0].id;
const manifestarticleId2 = delivery.lines[1].id;
// 2. Book line 1 for untracked article (acceptance only, warehouse_id is null)
console.log('1. Booking acceptance for untracked article line 1...');
const row1 = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(row1.id, 1);
assert.equal(row1.manifestarticle_id, manifestarticleId1);
assert.equal(row1.warehouse_id, null);
assert.equal(row1.transaction_type, 'ACCEPTANCE');
assert.equal(row1.amount, 5);
assert.equal(row1.hash_version, 1);
assert.equal(row1.prev_hash, INIT_HASH);
assert(row1.created_at, 'created_at should exist');
// Attempting to book line 1 again must be rejected
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: 'Double booking attempt',
}),
/Delivery line already booked on ledger/,
'Should reject booking an already booked delivery line',
);
// 3. Create warehouse and enable tracking on the article
console.log('2. Creating warehouse and enabling stock tracking...');
const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
await store.article.setTrackStockBySku(googleAuthId, article.sku, true);
// Even though tracking is now enabled, line 1 is already accepted so it cannot be booked again
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
warehouse_id: wh1.id,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: 'Late booking attempt after tracking enabled',
}),
/Delivery line already booked on ledger/,
'Cannot book a delivery line later once stock tracking is enabled if already accepted',
);
// 4. Book line 2 (now tracked): books ACCEPTANCE (row 2) + WAREHOUSE (row 3) in one transaction
console.log('3. Booking line 2 with tracking enabled (dual entries: ACCEPTANCE + WAREHOUSE)...');
const expectedHash2 = computePrevHash(row1);
const row2 = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId2,
warehouse_id: wh1.id,
transaction_type: 'ACCEPTANCE',
amount: 10,
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(row2.id, 2);
assert.equal(row2.manifestarticle_id, manifestarticleId2);
assert.equal(row2.warehouse_id, null);
assert.equal(row2.transaction_type, 'ACCEPTANCE');
assert.equal(row2.amount, 10);
assert.equal(row2.prev_hash, expectedHash2);
// Query ledger lines for manifestarticle 2 to verify row 3 (WAREHOUSE entry)
const line2Entries = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId2);
assert.equal(line2Entries.length, 2, 'Line 2 must have 2 ledger entries (ACCEPTANCE + WAREHOUSE)');
assert.equal(line2Entries[0].transaction_type, 'ACCEPTANCE');
assert.equal(line2Entries[0].warehouse_id, null);
assert.equal(line2Entries[1].transaction_type, 'WAREHOUSE');
assert.equal(line2Entries[1].warehouse_id, wh1.id);
assert.equal(line2Entries[1].amount, 10);
assert.equal(line2Entries[1].id, 3);
assert.equal(line2Entries[1].prev_hash, computePrevHash(line2Entries[0]));
// 5. Check warehouse stock for article
console.log('4. Checking warehouse stock calculation...');
const artDetail = await store.article.getBySku(googleAuthId, article.sku);
const whStock = artDetail.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(whStock, 10, 'Warehouse stock must be 10 (from line 2 WAREHOUSE ledger entry only)');
// 6. Check deliverysummary is_booked
const updatedDelivery = await store.delivery.getById(googleAuthId, delivery.id);
assert.equal(updatedDelivery.ledger.isBooked, true, 'Delivery should be fully booked');
assert.equal(updatedDelivery.ledger.completelyOnLedgerPositions, 2);
// 7. Check article history
console.log('5. Verifying article history...');
const history = await store.article.getHistoryBySku(googleAuthId, article.sku);
const historyTexts = history.map((h) => h.text);
console.log('History entries:', historyTexts);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')),
'History must include acceptance of line 1 with Lieferung number, supplier, EK brutto, amount',
);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')),
'History must include acceptance of line 2',
);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lager: "Hauptlager"') && t.includes('Menge: 10')),
'History must include warehouse entry for tracked line 2',
);
// 8. Test store.delivery.book on a fresh delivery
console.log('6. Testing store.delivery.book batch booking...');
const delivery2 = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
externalNumber: 'DEL-2',
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Batch Item 1', pu: 1, amount: 4, net: 15, taxrate: 19 },
],
});
const bookedDelivery2 = await store.delivery.book(googleAuthId, { id: delivery2.id, warehouseId: wh1.id });
assert.equal(bookedDelivery2.ledger.isBooked, true);
assert.equal(bookedDelivery2.ledger.completelyOnLedgerPositions, 1);
// Double booking delivery2 must reject
await assert.rejects(
async () => store.delivery.book(googleAuthId, { id: delivery2.id }),
/Delivery line(s)? already (accepted|booked) on ledger/,
'Should reject booking already booked delivery',
);
// 9. Test shipment booking
console.log('7. Testing shipment booking (SHIPMENT + WAREHOUSE dual entries)...');
const customer = await store.entity.create(googleAuthId, {
name: 'Customer Y',
isCustomer: true,
customergroupId: company.rootCategoryId,
});
const order2 = await store.order.create(googleAuthId, {
number: 'O-2',
entityId: customer.id,
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Ordered Item', pu: 1, amount: 3, net: 25, taxrate: 19 },
],
});
const testCarrier = await store.carrier.create(googleAuthId, { name: 'Test Carrier', hasPackages: true });
const testShipping = { carrierId: testCarrier.id, type: 'parcel', state: 'draft' };
const shipment = await store.shipment.create(googleAuthId, {
orderId: order2.id,
shipping: testShipping,
lines: [{ sku: 'SKU-ABC', amount: 3 }],
});
const shipmentLineId = shipment.lines[0].id;
const shipRow = await store.ledger.add(googleAuthId, {
manifestarticle_id: shipmentLineId,
warehouse_id: wh1.id,
transaction_type: 'SHIPMENT',
amount: 3,
transaction_text: `Lieferschein ${shipment.number}`,
});
assert.equal(shipRow.transaction_type, 'SHIPMENT');
assert.equal(shipRow.warehouse_id, null);
assert.equal(shipRow.amount, -3);
const shipEntries = await store.ledger.listByManifestarticleId(googleAuthId, shipmentLineId);
assert.equal(shipEntries.length, 2, 'Shipment line must have 2 entries (SHIPMENT + WAREHOUSE)');
assert.equal(shipEntries[0].transaction_type, 'SHIPMENT');
assert.equal(shipEntries[0].warehouse_id, null);
assert.equal(shipEntries[0].amount, -3);
assert.equal(shipEntries[1].transaction_type, 'WAREHOUSE');
assert.equal(shipEntries[1].warehouse_id, wh1.id);
assert.equal(shipEntries[1].amount, -3);
// Stock should now be: 10 (line 2) + 4 (delivery2) - 3 (shipment) = 11
const artDetailAfterShip = await store.article.getBySku(googleAuthId, article.sku);
const whStockAfterShip = artDetailAfterShip.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(whStockAfterShip, 11, 'Warehouse stock must be 11 (10 + 4 - 3)');
// Verify shipment history
const historyAfterShip = await store.article.getHistoryBySku(googleAuthId, article.sku);
const shipHistoryTexts = historyAfterShip.map((h) => h.text);
assert(
shipHistoryTexts.some((t) => t.includes(`Lieferschein ${shipment.number}`) && t.includes('Menge: 3')),
'Article history should record outgoing shipment',
);
// 8. Test store.stock.accept with deliverymanifestitem
console.log('8. Testing store.stock.accept (positive acceptance & negative return)...');
const delivery3 = await store.delivery.create(googleAuthId, {
supplierId: supplier.id,
externalNumber: 'DEL-3',
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Positive Delivery Line', pu: 1, amount: 6, net: 10, taxrate: 19 },
{ sku: 'SKU-ABC', name: 'Negative Delivery Line (Return)', pu: 1, amount: -2, net: 10, taxrate: 19 },
],
});
const posDeliveryLineId = delivery3.lines[0].id;
const negDeliveryLineId = delivery3.lines[1].id;
const acceptPosResult = await store.stock.accept(googleAuthId, {
manifestarticleId: posDeliveryLineId,
warehouseId: wh1.id,
});
assert.equal(acceptPosResult.document_ledger.transaction_type, 'ACCEPTANCE');
assert.equal(acceptPosResult.document_ledger.amount, 6);
assert.equal(acceptPosResult.document_ledger.warehouse_id, null);
assert.equal(acceptPosResult.adjust_ledger.amount, 6);
assert.equal(acceptPosResult.adjust_ledger.warehouse_id, wh1.id);
// Negative delivery line: return of a delivery (outgoing)
const acceptNegResult = await store.stock.accept(googleAuthId, {
manifestarticleId: negDeliveryLineId,
warehouseId: wh1.id,
});
assert.equal(acceptNegResult.document_ledger.transaction_type, 'RETURN');
assert.equal(acceptNegResult.document_ledger.amount, -2);
assert.equal(acceptNegResult.document_ledger.warehouse_id, null);
assert.equal(acceptNegResult.adjust_ledger.amount, -2);
assert.equal(acceptNegResult.adjust_ledger.warehouse_id, wh1.id);
// 9. Test store.stock.ship with shipmentmanifestitem
console.log('9. Testing store.stock.ship (positive shipment & rejection of negative line)...');
const order3 = await store.order.create(googleAuthId, {
number: 'O-3',
entityId: customer.id,
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Shipment Item Normal', pu: 1, amount: 5, net: 10, taxrate: 19 },
],
});
const shipment3 = await store.shipment.create(googleAuthId, {
orderId: order3.id,
shipping: testShipping,
lines: [
{ sku: 'SKU-ABC', amount: 4 },
{ sku: 'SKU-ABC', amount: -1 },
],
});
const posShipLineId = shipment3.lines[0].id;
const negShipLineId = shipment3.lines[1].id;
const shipPosResult = await store.stock.ship(googleAuthId, {
manifestarticleId: posShipLineId,
warehouseId: wh1.id,
});
assert.equal(shipPosResult.document_ledger.transaction_type, 'SHIPMENT');
assert.equal(shipPosResult.document_ledger.amount, -4);
assert.equal(shipPosResult.adjust_ledger.amount, -4);
// Negative shipment line: shipping is rejected
await assert.rejects(
store.stock.ship(googleAuthId, {
manifestarticleId: negShipLineId,
warehouseId: wh1.id,
}),
/Amount must be positive/,
);
// 10. Test store.stock.adjust
console.log('10. Testing store.stock.adjust failure on untracked article...');
const untrackedArt = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Pure Untracked Article',
track_stock: false,
});
await assert.rejects(
async () => store.stock.adjust(googleAuthId, {
sku: untrackedArt.sku,
warehouseId: wh1.id,
amount: 5,
}),
/Stock tracking is disabled for article/,
'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,
shipping: testShipping,
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);
const stockBeforeDisable = artBeforeDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock;
console.log('Current stock before disabling tracking:', stockBeforeDisable);
assert(stockBeforeDisable > 0, 'Article must have non-zero stock before disabling');
// Disable stock tracking
await store.article.setTrackStockBySku(googleAuthId, article.sku, false);
const artAfterDisable = await store.article.getBySku(googleAuthId, article.sku);
assert.equal(artAfterDisable.article.track_stock, false, 'Article track_stock must now be false');
const stockAfterDisable = artAfterDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(stockAfterDisable, 0, 'Warehouse stock must be adjusted to 0 on the ledger');
// Calling adjust now must fail
await assert.rejects(
async () => store.stock.adjust(googleAuthId, {
sku: article.sku,
warehouseId: wh1.id,
amount: 1,
}),
/Stock tracking is disabled for article/,
'Subsequent adjust must fail once tracking is disabled',
);
console.log('✓ All ledger tests passed successfully!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});