This commit is contained in:
seb
2026-10-16 22:09:12 +02:00
parent 796f6a3272
commit 7635821dad
38 changed files with 6191 additions and 3350 deletions

View File

@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Delivery Summary, Ledger Sync & Filtering in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-delivery-summary-'));
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 {
const googleAuthId = await store.google_auth.add('delivery-summary-test@example.com');
const company = await store.company.create({
name: 'Delivery Summary Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const warehouse = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
await store.unit.upsert(googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
});
const supplierAlpha = await store.supplier.create(googleAuthId, {
name: 'Alpha Supplier GmbH',
});
const supplierBeta = await store.supplier.create(googleAuthId, {
name: 'Beta Logistics Inc',
});
// 1. Create articles:
// Art1: track_stock = 1 (Stock item)
const artStock1 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Stock Item 1',
track_stock: 1,
});
// Art2: track_stock = 1 (Stock item)
const artStock2 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Stock Item 2',
track_stock: 1,
});
// Art3: track_stock = 0 (Service / Non-stock item)
const artService = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Shipping Freight Fee',
track_stock: 0,
});
// 2. Create Delivery 1:
// Has 2 lines: artStock1 (qty 10), artService (qty 1)
const delivery1 = await store.delivery.create(googleAuthId, {
date: '2026-08-01T10:00:00Z',
externalNumber: 'DEL-ALPHA-1001',
supplierId: supplierAlpha.id,
lines: [
{ sku: artStock1.sku, name: 'Stock Item 1', amount: 10, net: 50, taxrate: 19 },
{ sku: artService.sku, name: 'Shipping Freight Fee', amount: 1, net: 10, taxrate: 19 },
],
});
console.log('Delivery 1 created with id:', delivery1.id);
// Verify deliverysummary row for delivery 1:
// positions = 2, total_quantity = 11, stock_positions = 1, booked_positions = 0, unbooked_positions = 1, is_booked = 0
let sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert(sum1, 'deliverysummary row for delivery 1 must exist');
assert.equal(sum1.positions, 2, 'Total positions should be 2');
assert.equal(sum1.stock_positions, 1, 'Stock positions should be 1');
assert.equal(sum1.booked_positions, 0, 'Booked positions should initially be 0');
assert.equal(sum1.unbooked_positions, 1, 'Unbooked positions should initially be 1');
assert.equal(sum1.stock_quantity, 10, 'Stock quantity should be 10');
assert.equal(sum1.booked_quantity, 0, 'Booked quantity should initially be 0');
assert.equal(sum1.unbooked_quantity, 10, 'Unbooked quantity should initially be 10');
assert.equal(sum1.is_booked, 0, 'is_booked should be 0');
assert.equal(sum1.supplier_name, 'Alpha Supplier GmbH');
// 3. Create Delivery 2:
// Has 1 line: artStock2 (qty 5), Supplier: Beta
const delivery2 = await store.delivery.create(googleAuthId, {
date: '2026-10-01T10:00:00Z',
externalNumber: 'DEL-BETA-2002',
supplierId: supplierBeta.id,
lines: [
{ sku: artStock2.sku, name: 'Stock Item 2', amount: 5, net: 20, taxrate: 19 },
],
});
let sum2 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery2.id);
assert(sum2, 'deliverysummary row for delivery 2 must exist');
assert.equal(sum2.stock_positions, 1);
assert.equal(sum2.unbooked_positions, 1);
assert.equal(sum2.is_booked, 0);
// 4. Book Delivery 1 partially:
// Book 4 units of artStock1 in warehouse
const lineStock1 = delivery1.lines.find((l) => l.sku === artStock1.sku);
assert(lineStock1, 'lineStock1 must exist');
await store.ledger.add(googleAuthId, {
manifestarticle_id: lineStock1.id,
warehouse_id: warehouse.id,
transaction_type: 'DELIVERY',
amount: 4,
transaction_text: 'Partial goods receipt',
});
sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(sum1.booked_positions, 0, 'Booked positions should still be 0 since 4 < 10');
assert.equal(sum1.unbooked_positions, 1);
assert.equal(sum1.booked_quantity, 4);
assert.equal(sum1.unbooked_quantity, 6);
assert.equal(sum1.is_booked, 0);
console.log('✓ Partial booking properly updated deliverysummary');
// 5. Book remaining 6 units of artStock1
await store.ledger.add(googleAuthId, {
manifestarticle_id: lineStock1.id,
warehouse_id: warehouse.id,
transaction_type: 'DELIVERY',
amount: 6,
transaction_text: 'Final goods receipt',
});
sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(sum1.booked_positions, 1, 'Booked positions should now be 1');
assert.equal(sum1.unbooked_positions, 0, 'Unbooked positions should now be 0');
assert.equal(sum1.booked_quantity, 10);
assert.equal(sum1.unbooked_quantity, 0);
assert.equal(sum1.is_booked, 1, 'Delivery 1 should now be fully booked (is_booked = 1)');
console.log('✓ Full booking properly updated deliverysummary (is_booked = 1)');
// 6. Test windowed / paged list:
// Both deliveries present
const pagedAll = await store.delivery.list(googleAuthId, { offset: 0, limit: 10 });
assert.equal(pagedAll.total, 2);
assert.equal(pagedAll.unfilteredTotal, 2);
assert.equal(pagedAll.deliveries.length, 2);
assert.equal(pagedAll.offset, 0);
assert.equal(pagedAll.limit, 10);
// Filter onlyUnbooked (Delivery 1 is fully booked, Delivery 2 is unbooked)
const pagedUnbooked = await store.delivery.list(googleAuthId, { onlyUnbooked: true });
assert.equal(pagedUnbooked.total, 1, 'Only 1 delivery is unbooked');
assert.equal(pagedUnbooked.unfilteredTotal, 2, 'Unfiltered total remains 2');
assert.equal(pagedUnbooked.deliveries[0].id, delivery2.id);
console.log('✓ onlyUnbooked filter returned only unbooked deliveries');
// Search filter: 'Beta'
const searchBeta = await store.delivery.list(googleAuthId, { search: 'Beta' });
assert.equal(searchBeta.total, 1);
assert.equal(searchBeta.unfilteredTotal, 2);
assert.equal(searchBeta.deliveries[0].id, delivery2.id);
// Search filter: 'ALPHA-1001'
const searchNum = await store.delivery.list(googleAuthId, { search: 'ALPHA-1001' });
assert.equal(searchNum.total, 1);
assert.equal(searchNum.deliveries[0].id, delivery1.id);
// Test unpaged backwards compatibility
const unpaged = await store.delivery.list(googleAuthId);
assert(Array.isArray(unpaged), 'Unpaged list should return Array');
assert.equal(unpaged.length, 2);
const d1 = unpaged.find((d) => d.id === delivery1.id);
assert(d1.ledger, 'd1 should contain ledger summary object');
assert.equal(d1.ledger.totalPositions, 1);
assert.equal(d1.ledger.bookedPositions, 1);
assert.equal(d1.ledger.unbookedPositions, 0);
assert.equal(d1.ledger.isBooked, true);
const d2 = unpaged.find((d) => d.id === delivery2.id);
assert.equal(d2.ledger.unbookedPositions, 1);
assert.equal(d2.ledger.isBooked, false);
console.log('✓ Unpaged list returns enriched deliveries with ledger summary');
// 7. Test rebuildSummaries
provider.db.prepare('DELETE FROM deliverysummary').run();
const countEmpty = provider.db.prepare('SELECT COUNT(*) AS cnt FROM deliverysummary').get().cnt;
assert.equal(countEmpty, 0);
await store.delivery.rebuildSummaries(googleAuthId);
const countRebuilt = provider.db.prepare('SELECT COUNT(*) AS cnt FROM deliverysummary').get().cnt;
assert.equal(countRebuilt, 2, 'Rebuilt deliverysummary should have 2 rows');
const rebuiltD1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(rebuiltD1.is_booked, 1);
assert.equal(rebuiltD1.booked_quantity, 10);
assert.equal(rebuiltD1.unbooked_quantity, 0);
const rebuiltD2 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery2.id);
assert.equal(rebuiltD2.is_booked, 0);
assert.equal(rebuiltD2.unbooked_positions, 1);
console.log('✓ rebuildSummaries restored all deliverysummary rows accurately');
console.log('--- ALL DELIVERY SUMMARY & FILTER TESTS PASSED ---');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -5,12 +5,10 @@ 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 { INIT_HASH, computePrevHash } from '../src/store/ledger/add.js';
import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js';
async function run() {
console.log('--- Testing store.ledger (add, created_at, list queries) ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-ledger-'));
const tempDir = mkdtempSync(join(tmpdir(), 'dash-ledger-test-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
@@ -18,50 +16,85 @@ async function run() {
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
// 1. Setup user and company
const googleAuthId = await store.google_auth.add('ledger-test@example.com');
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 Corp',
name: 'Ledger Test GmbH',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
await store.unit.upsert(googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
const supplier = await store.supplier.create(googleAuthId, {
name: 'Supplier X',
});
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 assert.rejects(
async () => store.article.setTrackStockBySku(googleAuthId, article.sku, true),
/At least one warehouse is required to enable stock tracking/,
'Should reject enabling stock tracking when no warehouse exists',
);
await store.articlesupplier.create(googleAuthId, article.sku, {
entityId: supplier.id,
sku: 'SKU-ABC',
pu: 1,
});
// Create a delivery manifest with lines
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',
@@ -78,7 +111,7 @@ async function run() {
},
{
sku: 'SKU-ABC',
name: 'Ledger Item 1 Batch 2',
name: 'Ledger Item 2',
pu: 1,
amount: 10,
net: 20,
@@ -90,278 +123,311 @@ async function run() {
const manifestarticleId1 = delivery.lines[0].id;
const manifestarticleId2 = delivery.lines[1].id;
assert(manifestarticleId1, 'manifestarticle line 1 should have an id');
assert(manifestarticleId2, 'manifestarticle line 2 should have an id');
// 2. Test first row (genesis row)
console.log('1. Inserting genesis ledger row...');
// 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: 'GOODS_RECEIPT',
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: 'Initial delivery movement',
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(row1.id, 1);
assert.equal(row1.manifestarticle_id, manifestarticleId1);
assert.equal(row1.transaction_type, 'GOODS_RECEIPT');
assert.equal(row1.warehouse_id, null);
assert.equal(row1.transaction_type, 'ACCEPTANCE');
assert.equal(row1.amount, 5);
assert.equal(row1.transaction_text, 'Initial delivery movement');
assert.equal(row1.hash_version, 1);
assert.equal(row1.prev_hash, INIT_HASH);
assert(row1.created_at, 'created_at should exist');
assert(!Number.isNaN(new Date(row1.created_at).getTime()), 'created_at should be valid date string');
console.log('✓ Row 1 created_at:', row1.created_at);
// Verify manual hash computation of row 1 (including created_at)
// 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 manualHash2 = createHash('sha512')
.update(
JSON.stringify({
id: 1,
manifestarticle_id: manifestarticleId1,
warehouse_id: null,
transaction_type: 'GOODS_RECEIPT',
amount: 5,
transaction_text: 'Initial delivery movement',
hash_version: 1,
prev_hash: INIT_HASH,
created_at: new Date(row1.created_at).toISOString(),
}),
'utf8',
)
.digest('hex');
assert.equal(expectedHash2, manualHash2);
assert.equal(manualHash2.length, 128);
// 3. Test second row (line 1, different transaction type)
console.log('2. Inserting second ledger row...');
const row2 = await store.ledger.add(googleAuthId, {
manifestarticleId: manifestarticleId1,
transactionType: 'INVENTORY_ADJUST',
amount: -1,
transactionText: 'Damaged item written off',
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.hash_version, 1);
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);
assert(row2.created_at, 'row 2 created_at should exist');
console.log('✓ Row 2 prev_hash matches SHA-512 of Row 1 with created_at');
// 4. Test third row (line 2, same SKU SKU-ABC)
console.log('3. Inserting third ledger row on line 2...');
const expectedHash3 = computePrevHash(row2);
const row3 = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId2,
transaction_type: 'GOODS_RECEIPT',
amount: 10,
transaction_text: 'Second line goods receipt',
});
// 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]));
assert.equal(row3.id, 3);
assert.equal(row3.hash_version, 1);
assert.equal(row3.prev_hash, expectedHash3);
// 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)');
// 5. Test query by manifestarticle_id
console.log('4. Testing listByManifestarticleId...');
const listLine1All = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId1);
assert.equal(listLine1All.length, 2);
assert.equal(listLine1All[0].id, 1);
assert.equal(listLine1All[1].id, 2);
assert.equal(listLine1All[0].sku, 'SKU-ABC');
// 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);
const listLine1GoodsReceipt = await store.ledger.listByManifestarticleId(
googleAuthId,
manifestarticleId1,
'GOODS_RECEIPT',
);
assert.equal(listLine1GoodsReceipt.length, 1);
assert.equal(listLine1GoodsReceipt[0].id, 1);
const listLine1Adjust = await store.ledger.listByManifestarticleId(
googleAuthId,
manifestarticleId1,
{ transaction_type: 'INVENTORY_ADJUST' },
);
assert.equal(listLine1Adjust.length, 1);
assert.equal(listLine1Adjust[0].id, 2);
const listLine1None = await store.ledger.listByManifestarticleId(
googleAuthId,
manifestarticleId1,
'NON_EXISTENT',
);
assert.equal(listLine1None.length, 0);
// 6. Test query by SKU
console.log('5. Testing listBySku...');
const listSkuAll = await store.ledger.listBySku(googleAuthId, 'SKU-ABC');
assert.equal(listSkuAll.length, 3);
assert.equal(listSkuAll[0].id, 1);
assert.equal(listSkuAll[1].id, 2);
assert.equal(listSkuAll[2].id, 3);
const listSkuGoodsReceipt = await store.ledger.listBySku(
googleAuthId,
'SKU-ABC',
'GOODS_RECEIPT',
);
assert.equal(listSkuGoodsReceipt.length, 2);
assert.equal(listSkuGoodsReceipt[0].id, 1);
assert.equal(listSkuGoodsReceipt[1].id, 3);
const listSkuAdjust = await store.ledger.listBySku(
googleAuthId,
'SKU-ABC',
'INVENTORY_ADJUST',
);
assert.equal(listSkuAdjust.length, 1);
assert.equal(listSkuAdjust[0].id, 2);
const listUnknownSku = await store.ledger.listBySku(googleAuthId, 'UNKNOWN-SKU');
assert.equal(listUnknownSku.length, 0);
// 8. Test article history created from ledger
console.log('7. Testing article history entries from ledger rows...');
// 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('Article history texts:', historyTexts);
console.log('History entries:', historyTexts);
assert(
historyTexts.some((t) => t.includes('Initial delivery movement') && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')),
'History should include initial delivery movement with supplier, EK brutto, and amount',
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.includes('Damaged item written off'), 'History should include undamaged adjustment memo as is');
assert(
historyTexts.some((t) => t.includes('Second line goods receipt') && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')),
'History should include second line goods receipt with details',
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',
);
// 9. Test warehouse CRUD, sort, reorder, and linking with ledger
console.log('8. Testing warehouse CRUD, sort, reorder, and ledger association...');
const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
assert.equal(wh1.name, 'Hauptlager');
assert.equal(wh1.sort, 0);
assert(wh1.id > 0);
// 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);
const wh2 = await store.warehouse.create(googleAuthId, { name: 'Filiale Nord' });
assert.equal(wh2.name, 'Filiale Nord');
assert.equal(wh2.sort, 1);
const whList = await store.warehouse.list(googleAuthId);
assert.equal(whList.length, 2);
assert.equal(whList[0].id, wh1.id);
assert.equal(whList[0].sort, 0);
assert.equal(whList[1].id, wh2.id);
assert.equal(whList[1].sort, 1);
const whGet = await store.warehouse.getById(googleAuthId, wh1.id);
assert.equal(whGet.name, 'Hauptlager');
assert.equal(whGet.sort, 0);
const whUpdated = await store.warehouse.update(googleAuthId, wh2.id, { name: 'Filiale Süd', sort: 5 });
assert.equal(whUpdated.name, 'Filiale Süd');
assert.equal(whUpdated.sort, 5);
// Test warehouse order
await store.warehouse.order(googleAuthId, { warehouseIds: [wh2.id, wh1.id] });
const whListReordered = await store.warehouse.list(googleAuthId);
assert.equal(whListReordered[0].id, wh2.id);
assert.equal(whListReordered[0].sort, 0);
assert.equal(whListReordered[1].id, wh1.id);
assert.equal(whListReordered[1].sort, 1);
// Test invalid warehouse order
// Double booking delivery2 must reject
await assert.rejects(
async () => store.warehouse.order(googleAuthId, { warehouseIds: [wh1.id] }),
/Invalid warehouse order/,
async () => store.delivery.book(googleAuthId, { id: delivery2.id }),
/Delivery line(s)? already (accepted|booked) on ledger/,
'Should reject booking already booked delivery',
);
// Verify article detail includes warehouses with sort
const articleDetail = await store.article.getBySku(googleAuthId, article.sku);
assert(Array.isArray(articleDetail.article.warehouses), 'Article should have warehouses array');
assert.equal(articleDetail.article.warehouses.length, 2);
assert.equal(articleDetail.article.warehouses[0].id, wh2.id);
assert.equal(articleDetail.article.warehouses[0].sort, 0);
assert.equal(articleDetail.article.warehouses[1].id, wh1.id);
assert.equal(articleDetail.article.warehouses[1].sort, 1);
// 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,
});
// Enable stock tracking now that warehouses exist
await store.article.setTrackStockBySku(googleAuthId, article.sku, true);
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 },
],
});
// Add ledger row with warehouse
const rowWithWh = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
const shipment = await store.shipment.create(googleAuthId, {
orderId: order2.id,
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: 'DELIVERY',
amount: 25,
transaction_text: 'Delivered directly to Hauptlager',
transaction_type: 'SHIPMENT',
amount: 3,
transaction_text: `Lieferschein ${shipment.number}`,
});
assert.equal(rowWithWh.warehouse_id, wh1.id);
assert.equal(rowWithWh.amount, 25);
// Filter by warehouse in listBySku and listByManifestarticleId
const whRowsBySku = await store.ledger.listBySku(googleAuthId, 'SKU-ABC', { warehouse_id: wh1.id });
assert.equal(whRowsBySku.length, 1);
assert.equal(whRowsBySku[0].id, rowWithWh.id);
assert.equal(whRowsBySku[0].warehouse_id, wh1.id);
assert.equal(shipRow.transaction_type, 'SHIPMENT');
assert.equal(shipRow.warehouse_id, null);
assert.equal(shipRow.amount, -3);
const whRowsByLine = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId1, { warehouse_id: wh1.id });
assert.equal(whRowsByLine.length, 1);
assert.equal(whRowsByLine[0].id, rowWithWh.id);
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);
// Verify warehouse is recorded in article history
const historyWithWh = await store.article.getHistoryBySku(googleAuthId, article.sku);
// 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(
historyWithWh.some((h) => h.text.includes('Lager: "Hauptlager"') && h.text.includes('Lieferant: "Supplier X"')),
'History should include warehouse name (Lager: "Hauptlager")',
shipHistoryTexts.some((t) => t.includes(`Lieferschein ${shipment.number}`) && t.includes('Menge: 3')),
'Article history should record outgoing shipment',
);
// Trying to delete wh1 (used in ledger) must fail
await assert.rejects(
async () => store.warehouse.delete(googleAuthId, wh1.id),
/Warehouse in use in ledger/,
'Should reject deleting warehouse referenced in ledger',
);
// Deleting wh2 (not used in ledger) must succeed
await store.warehouse.delete(googleAuthId, wh2.id);
const whListAfterDelete = await store.warehouse.list(googleAuthId);
assert.equal(whListAfterDelete.length, 1);
assert.equal(whListAfterDelete[0].id, wh1.id);
// Test last warehouse guard with track_stock articles
const freshCompany = await store.company.create({
name: 'Warehouse Test Co',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
// 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 },
],
});
await store.google_auth.setSelectedCompany(googleAuthId, freshCompany.id);
const testWh = await store.warehouse.create(googleAuthId, { name: 'Only Warehouse' });
const testArt = await store.article.createUnderParent({
googleAuthId,
parentId: freshCompany.rootCategoryId,
name: 'Tracked Article',
track_stock: true,
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(testArt.track_stock, true);
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 & negative retoure)...');
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,
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: retoure of a shipment (incoming)
const shipNegResult = await store.stock.ship(googleAuthId, {
manifestarticleId: negShipLineId,
warehouseId: wh1.id,
});
assert.equal(shipNegResult.document_ledger.transaction_type, 'RETOURE');
assert.equal(shipNegResult.document_ledger.amount, 1);
assert.equal(shipNegResult.adjust_ledger.amount, 1);
// 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.warehouse.delete(googleAuthId, testWh.id),
/Cannot delete the last warehouse while articles have stock tracking enabled/,
'Should reject deleting last warehouse when articles have track_stock enabled',
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',
);
// Disable track_stock on the article, then deleting the last warehouse must succeed
await store.article.setTrackStockBySku(googleAuthId, testArt.sku, false);
await store.warehouse.delete(googleAuthId, testWh.id);
const remainingWh = await store.warehouse.list(googleAuthId);
assert.equal(remainingWh.length, 0);
// Switch back to original company
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// 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');
console.log('✓ Warehouse CRUD and ledger enforcement passed');
// 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 {

View File

@@ -140,16 +140,22 @@ async function run() {
// Assert ledger entries
const ledgerRows = await store.ledger.listBySku(googleAuthId, trackedArt.sku);
assert.equal(ledgerRows.length, 2, 'Should have 2 ledger entries for tracked article (adjust + shipping)');
const shippingLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPPING');
assert(shippingLedger, 'Shipping ledger entry should exist');
assert.equal(shippingLedger.amount, -3, 'Shipping ledger entry should be -3');
assert.equal(ledgerRows.length, 3, 'Should have 3 ledger entries for tracked article (adjust + shipment doc + warehouse)');
const shipmentDocLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPMENT' && l.warehouse_id == null);
assert(shipmentDocLedger, 'Shipment document ledger entry should exist with warehouse_id null');
assert.equal(shipmentDocLedger.amount, -3, 'Shipment document ledger entry should be -3');
const shipmentWhLedger = ledgerRows.find((l) => l.transaction_type === 'WAREHOUSE' && l.warehouse_id === warehouse.id);
assert(shipmentWhLedger, 'Shipment warehouse ledger entry should exist with warehouse_id set');
assert.equal(shipmentWhLedger.amount, -3, 'Shipment warehouse ledger entry should be -3');
// Assert untracked article has 0 warehouse ledger entries
// Assert untracked article has 1 document ledger entry and 0 warehouse ledger entries
const untrackedLedger = await store.ledger.listBySku(googleAuthId, untrackedArt.sku);
assert.equal(untrackedLedger.length, 0, 'Untracked article should not have warehouse ledger entries');
assert.equal(untrackedLedger.length, 1, 'Untracked article should have 1 shipment document ledger entry');
assert.equal(untrackedLedger[0].transaction_type, 'SHIPMENT');
assert.equal(untrackedLedger[0].warehouse_id, null, 'Untracked article should not have warehouse ledger entry');
assert.equal(untrackedLedger[0].amount, -2);
// Assert article history: order goes into history, shipment does NOT
// Assert article history: order goes into history, and shipment produces history
const history = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku);
console.log('Tracked article history:', history.map((h) => h.text));
const orderHistory = history.find((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 3, € 59,50)');
@@ -159,10 +165,10 @@ async function run() {
assert.equal(parsedMeta.type, 'order');
assert.equal(parsedMeta.orderId, order.id);
assert.equal(parsedMeta.gross, 59.5);
const shippingHistory = history.find((h) => h.text.includes('Versand Auftrag'));
assert(!shippingHistory, 'Article history should NOT report the shipment');
const shipmentHistory = history.find((h) => h.text.startsWith(`Lieferschein ${shipment.number}`) && h.text.includes('Kunde: "Max Mustermann"'));
assert(shipmentHistory, 'Article history should report the shipment document');
// Untracked article also has the order in history, but not the shipment
// Untracked article also has the order in history and shipment in history
const untrackedHistory = await store.article.getHistoryBySku(googleAuthId, untrackedArt.sku);
console.log('Untracked article history:', untrackedHistory.map((h) => h.text));
assert(
@@ -170,8 +176,8 @@ async function run() {
'Untracked article history should contain order creation entry with formatted gross price',
);
assert(
!untrackedHistory.some((h) => h.text.includes('Versand Auftrag')),
'Untracked article history should NOT report the shipment',
untrackedHistory.some((h) => h.text.startsWith(`Lieferschein ${shipment.number}`)),
'Untracked article history should report the shipment',
);
// Test order import with historical date preserves date in article history