486 lines
19 KiB
JavaScript
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);
|
|
});
|