261 lines
9.4 KiB
JavaScript
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));
|
|
};
|
|
}
|