Files
dashlibstorage/src/store/ledger/add.js
2026-10-12 13:52:16 +02:00

261 lines
9.4 KiB
JavaScript

import { createHash } from 'node:crypto';
import * as mssqlQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export const INIT_HASH = 'TODO_TSE_BASED_INITHASH';
export function shape(row) {
return {
id: row.id,
manifestarticle_id: row.manifestarticle_id,
warehouse_id: row.warehouse_id ?? null,
transaction_type: row.transaction_type,
amount: row.amount,
transaction_text: row.transaction_text,
hash_version: row.hash_version,
prev_hash: row.prev_hash,
created_at:
row.created_at instanceof Date
? row.created_at.toISOString()
: new Date(row.created_at).toISOString(),
};
}
// Computes the SHA-512 hex hash of a previous ledger entry row
// containing all database columns in canonical JSON format.
export function computePrevHash(row) {
if (!row) {
return INIT_HASH;
}
const createdAt =
row.created_at instanceof Date
? row.created_at.toISOString()
: new Date(row.created_at).toISOString();
const payload = JSON.stringify({
id: row.id,
manifestarticle_id: row.manifestarticle_id,
warehouse_id: row.warehouse_id ?? null,
transaction_type: row.transaction_type,
amount: row.amount,
transaction_text: row.transaction_text,
hash_version: row.hash_version,
prev_hash: row.prev_hash,
created_at: createdAt,
});
return createHash('sha512').update(payload, 'utf8').digest('hex');
}
/**
* Appends a new append-only entry to the ledger table.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Ledger entry payload.
* @param {number} input.manifestarticle_id - ID of the referenced manifest line (can also pass manifestarticleId).
* @param {number|null} [input.warehouse_id] - Optional warehouse ID (can also pass warehouseId).
* @param {string} input.transaction_type - Transaction type (can also pass transactionType).
* @param {number} input.amount - Movement amount.
* @param {string} input.transaction_text - Transaction memo/details (can also pass transactionText).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>} The created ledger row.
* @throws {Error} If user not authorized, company not selected, manifest article not found, or input invalid.
*
* @example
* const entry = await store.ledger.add(googleAuthId, {
* manifestarticle_id: 1,
* warehouse_id: 1,
* transaction_type: 'DELIVERY',
* amount: 100.5,
* transaction_text: 'Invoice paid',
* });
*/
export default function add(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId, input = {}, tx) => {
const rawManifestarticleId =
input.manifestarticle_id ?? input.manifestarticleId;
const rawWarehouseId =
input.warehouse_id ?? input.warehouseId;
const rawTransactionType =
input.transaction_type ?? input.transactionType;
const rawAmount = input.amount;
const rawTransactionText =
input.transaction_text ?? input.transactionText;
const manifestarticleId = Number(rawManifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid ledger entry');
}
let warehouseId = null;
if (rawWarehouseId !== undefined && rawWarehouseId !== null) {
warehouseId = Number(rawWarehouseId);
if (!Number.isInteger(warehouseId) || warehouseId <= 0) {
throw new Error('Invalid ledger entry');
}
}
const transactionType =
typeof rawTransactionType === 'string'
? rawTransactionType.trim()
: '';
if (!transactionType || transactionType.length > 100) {
throw new Error('Invalid ledger entry');
}
const amount = Number(rawAmount);
if (!Number.isFinite(amount)) {
throw new Error('Invalid ledger entry');
}
if (rawTransactionText == null) {
throw new Error('Invalid ledger entry');
}
const transactionText = String(rawTransactionText);
const doAdd = async (runner) => {
const { companyId, companyuserId, rootCategoryId } = await access(googleAuthId, runner);
const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, {
manifestarticleId,
companyId,
});
const manifestArticle = check.rows[0];
if (!manifestArticle) {
throw new Error('Manifest article not found');
}
// Stock guard: warehouse-scoped ledger entries affect article stock
// (stock is SUM(ledger.amount) GROUP BY warehouse). Reject bookings for
// articles with stock tracking disabled before anything is written.
if (warehouseId != null) {
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
if (sku) {
const trackRes = await runner.query(
`SELECT a.sku, a.track_stock FROM article a
WHERE a.company_id = @companyId
AND (a.sku = @sku
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))`,
{ companyId, sku },
);
const disabled = (trackRes.rows ?? []).find((r) => Number(r.track_stock ?? 1) === 0);
if (disabled) {
throw new Error(`Stock tracking is disabled for article (${sku})`);
}
}
}
let warehouseName = null;
if (warehouseId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseName = whCheck.rows[0].name;
}
const latest = await runner.query(queries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
const inserted = await runner.query(queries.INSERT_ROW, {
manifestarticleId,
warehouseId,
transactionType,
amount,
transactionText,
hashVersion: 1,
prevHash,
});
const row = inserted.rows[0];
if (!row) {
throw new Error('Failed to insert ledger entry');
}
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
if (sku) {
const articleMatches = await runner.query(queries.FIND_ARTICLES_FOR_HISTORY, {
sku,
companyId,
});
const upperType = String(transactionType).toUpperCase();
const isDelivery =
upperType === 'DELIVERY' ||
upperType === 'GOODS_RECEIPT' ||
(manifestArticle.manifestType === 'delivery' &&
!upperType.includes('ADJUST') &&
!upperType.includes('LOSS') &&
!upperType.includes('WRITE_OFF'));
const isShipping =
upperType === 'SHIPPING' ||
upperType === 'ORDER_SHIPPED' ||
(manifestArticle.manifestType === 'order' &&
!upperType.includes('ADJUST') &&
!upperType.includes('LOSS') &&
!upperType.includes('WRITE_OFF'));
let historyText = transactionText;
if (isDelivery) {
const details = [];
if (manifestArticle.supplierName) {
details.push(`Lieferant: "${manifestArticle.supplierName}"`);
}
if (warehouseName) {
details.push(`Lager: "${warehouseName}"`);
}
if (manifestArticle.net != null && Number.isFinite(Number(manifestArticle.net))) {
const net = Number(manifestArticle.net);
const rebate = Number(manifestArticle.rebate ?? 0);
const taxrate = Number(manifestArticle.taxrate ?? 0);
const netAfterRebate = net * (1 - rebate / 100);
const brutto = netAfterRebate * (1 + taxrate / 100);
const ekBrutto = (Math.round(brutto * 100) / 100).toFixed(2);
details.push(`EK brutto: ${ekBrutto}`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base =
transactionText?.trim() ||
(manifestArticle.manifestNumber ? `Lieferung ${manifestArticle.manifestNumber}` : 'Lieferung');
historyText = `${base}${suffix}`;
} else if (warehouseName && !transactionText.includes(warehouseName)) {
historyText = `${transactionText} (Lager: "${warehouseName}")`;
}
if (!isShipping) {
for (const match of articleMatches.rows) {
await runner.query(queries.INSERT_ARTICLE_HISTORY, {
articleId: match.articleId,
companyuserId,
text: historyText,
});
}
}
}
return shape(row);
};
if (tx) {
return doAdd(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(doAdd);
}
return doAdd(makeRunner(provider));
};
}