Files
dashlibstorage/src/store/stock/adjust.js
2026-10-20 05:33:11 +02:00

403 lines
15 KiB
JavaScript

import * as mssqlQueries from '../../providers/mssql/queries/manifestDoc.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/manifestDoc.js';
import * as mssqlLedgerQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteLedgerQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { recordStockAdjustmentHistory, recordWarehouseHistory } from '../articlehistory/record.js';
import { computePrevHash, shape } from '../ledger/add.js';
import nextNumber from '../numbers/nextNumber.js';
import ledgerAdd from '../ledger/add.js';
import makeRunner from '../runner.js';
import { parseDate } from '../manifestLines.js';
import { syncArticleWarehouseSummaryForLedger } from '../articlewarehousesummary/syncSummary.js';
const ADJUSTMENT_TYPE = 'adjustment';
const GET_ARTICLE_NAME_SQL = `
SELECT at.name
FROM article a
LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId
WHERE a.id = @articleId;
`;
export default function adjust(provider) {
const access = resolveSelectedCompanyAccess(provider);
const resolveArticle = resolveArticleBySku(provider);
const allocateNumber = nextNumber(provider);
const addLedger = ledgerAdd(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const ledgerQueries = isSqlite ? sqliteLedgerQueries : mssqlLedgerQueries;
/**
* Adjusts article stock manually by creating an implicit manifest of type "adjustment",
* allocating a number from the "adjustment" number scheme, and appending to the ledger.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Adjustment payload.
* @param {string} [input.sku] - Article SKU (if single-item adjustment).
* @param {number} [input.warehouseId] - Warehouse ID (can also pass warehouse_id).
* @param {number} [input.amount] - Signed adjustment amount (+ to add, - to remove).
* @param {string} [input.text] - Optional adjustment reason/memo.
* @param {string} [input.externalNumber] - Optional external reference.
* @param {string|Date} [input.date] - Optional date (defaults to now).
* @param {Array<object>} [input.lines] - Optional array of items to adjust in one manifest.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>}
*/
return async (googleAuthId, input = {}, tx) => {
const rawManifestarticleId = input.manifestarticleId ?? input.manifestarticle_id;
if (rawManifestarticleId != null) {
const manifestarticleId = Number(rawManifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid manifest article id');
}
const amount = Number(input.amount);
if (!Number.isFinite(amount) || amount === 0) {
throw new Error('Amount must be a non-zero number');
}
const runManifestarticleAdjust = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
const checkRes = await runner.query(
`SELECT ma.id, ma.manifest_id, ma.sku, ma.name, m.company_id, m.type AS manifest_type, m.number AS manifest_number
FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE ma.id = @manifestarticleId AND m.company_id = @companyId`,
{ manifestarticleId, companyId },
);
const manifestArticle = checkRes.rows[0];
if (!manifestArticle) {
throw new Error('Manifest article not found');
}
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
if (!sku) {
throw new Error('Manifest article has no SKU');
}
const trackRes = await runner.query(
`SELECT a.id, a.sku, a.track_stock, a.is_parent, a.articlelist_id 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 art = trackRes.rows[0];
if (!art) {
throw new Error(`Article not found (${sku})`);
}
if (art.is_parent) {
throw new Error(`A parent article cannot have stock (${sku})`);
}
if (art.articlelist_id != null) {
throw new Error(`A Stückliste cannot have manual stock adjustments (${sku})`);
}
if (Number(art.track_stock ?? 1) === 0) {
throw new Error(`Stock tracking is disabled for article (${sku})`);
}
let warehouseId = input.warehouseId ?? input.warehouse_id;
let warehouseName = '';
if (warehouseId != null) {
warehouseId = Number(warehouseId);
if (!Number.isInteger(warehouseId) || warehouseId <= 0) {
throw new Error('Warehouse must be a positive integer');
}
const whCheck = await runner.query(
ledgerQueries.CHECK_WAREHOUSE_ACCESS,
{ warehouseId, companyId },
);
if (!whCheck.rows[0]) throw new Error('Warehouse not found');
warehouseName = whCheck.rows[0].name;
} else {
const defWh = await runner.query(
ledgerQueries.GET_DEFAULT_WAREHOUSE,
{ companyId },
);
if (!defWh.rows[0]) throw new Error('Warehouse not found');
warehouseId = defWh.rows[0].id;
warehouseName = defWh.rows[0].name;
}
const text = input.text ?? input.transactionText ?? input.transaction_text ??
(amount > 0 ? `Stock addition (+${amount})` : `Stock deduction (${amount})`);
if (amount < 0) {
const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId,
companyId,
sku,
articleId: art.id,
});
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
if (currentStock + amount < 0) {
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`);
}
}
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
const inserted = await runner.query(ledgerQueries.INSERT_ROW, {
manifestarticleId,
warehouseId,
transactionType: 'WAREHOUSE',
amount,
transactionText: text,
hashVersion: 1,
prevHash,
});
const ledgerRow = inserted.rows[0];
await syncArticleWarehouseSummaryForLedger(runner, {
companyId,
manifestarticleId,
warehouseId,
});
// Write warehouse history for tracked article
if (manifestArticle.manifest_type === 'delivery') {
await recordWarehouseHistory(runner, {
articleId: art.id,
companyuserId,
manifestNumber: manifestArticle.manifest_number,
manifestType: 'delivery',
warehouseName,
amount,
transactionText: text,
});
} else if (manifestArticle.manifest_type === 'shipment') {
await recordWarehouseHistory(runner, {
articleId: art.id,
companyuserId,
manifestNumber: manifestArticle.manifest_number,
manifestType: 'shipment',
warehouseName,
amount,
transactionText: text,
});
}
return {
manifestarticle_id: manifestarticleId,
warehouse_id: warehouseId,
amount,
text,
ledger: shape(ledgerRow),
};
};
if (tx) return runManifestarticleAdjust(makeRunner(provider, tx), tx);
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, activeTx) => runManifestarticleAdjust(runner, activeTx));
}
return runManifestarticleAdjust(makeRunner(provider));
}
const rawLines = Array.isArray(input.lines)
? input.lines
: [
{
sku: input.sku,
warehouseId: input.warehouseId ?? input.warehouse_id,
amount: input.amount,
text: input.text ?? input.transactionText ?? input.transaction_text,
},
];
if (rawLines.length === 0) {
throw new Error('No items to adjust');
}
const defaultWarehouseId = input.warehouseId ?? input.warehouse_id;
const defaultText = input.text ?? input.transactionText ?? input.transaction_text;
const externalNumber = input.externalNumber?.trim?.() ? input.externalNumber.trim() : null;
const date = input.date ? parseDate(input.date) ?? new Date() : new Date();
const createdAt = new Date();
const isSingle = !Array.isArray(input.lines);
const normalizedLines = rawLines.map((line, idx) => {
const itemLabel = isSingle ? '' : ` for item ${idx + 1}`;
if (line.sku == null) {
throw new Error(`SKU required${itemLabel}`);
}
if (typeof line.sku !== 'string') {
const val = typeof line.sku === 'object' ? JSON.stringify(line.sku) : String(line.sku);
throw new Error(`SKU must be a string${itemLabel} (received ${typeof line.sku}: ${val})`);
}
const sku = line.sku.trim();
if (!sku) {
throw new Error(`SKU cannot be empty${itemLabel}`);
}
const rawWhId = line.warehouseId ?? line.warehouse_id ?? defaultWarehouseId;
if (rawWhId == null) {
throw new Error(`Warehouse required${itemLabel} (${sku})`);
}
if (typeof rawWhId !== 'number' || !Number.isInteger(rawWhId) || rawWhId <= 0) {
throw new Error(`Warehouse must be a positive integer${itemLabel} (${sku})`);
}
const warehouseId = rawWhId;
if (line.amount == null) {
throw new Error(`Amount required${itemLabel} (${sku})`);
}
if (typeof line.amount !== 'number' || !Number.isFinite(line.amount) || line.amount === 0) {
throw new Error(`Invalid amount${itemLabel} (${sku}): must be non-zero number`);
}
const amount = line.amount;
const text = (line.text ?? line.transactionText ?? line.transaction_text ?? defaultText)?.trim?.() ||
(amount > 0 ? `Manual stock addition (+${amount})` : `Manual stock removal (${amount})`);
return {
sku,
warehouseId,
amount,
text,
};
});
const run = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
// Validate all articles exist and belong to the user's company
const resolvedArticles = [];
for (const item of normalizedLines) {
const resolved = await resolveArticle(googleAuthId, item.sku, activeTx);
const artRes = await runner.query('SELECT is_parent, articlelist_id, track_stock FROM article WHERE id = @articleId', {
articleId: resolved.articleId,
});
if (artRes.rows[0]?.is_parent) {
throw new Error(`A parent article cannot have stock (${item.sku})`);
}
if (artRes.rows[0]?.articlelist_id != null) {
throw new Error(`A Stückliste cannot have manual stock adjustments (${item.sku})`);
}
if (Number(artRes.rows[0]?.track_stock ?? 1) === 0) {
throw new Error(`Stock tracking is disabled for article (${item.sku})`);
}
const nameRes = await runner.query(GET_ARTICLE_NAME_SQL, {
articleId: resolved.articleId,
languageId: resolved.languageId,
});
const articleName = nameRes.rows[0]?.name ?? item.sku;
resolvedArticles.push({ ...item, ...resolved, articleName });
}
// Allocate unique adjustment number using the dedicated adjustment number scheme
const number = await allocateNumber(companyId, ADJUSTMENT_TYPE, activeTx);
// Create the implicit adjustment manifest
const manifestRes = await runner.query(queries.CREATE_MANIFEST, {
companyId,
number,
type: ADJUSTMENT_TYPE,
entityId: null,
externalNumber,
date,
createdAt,
});
const manifestId = manifestRes.rows[0]?.id ?? manifestRes.insertId;
const createdLines = [];
const createdLedgerEntries = [];
for (const item of resolvedArticles) {
if (item.amount < 0) {
const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId: item.warehouseId,
companyId,
sku: item.sku,
articleId: item.articleId,
});
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
if (currentStock + item.amount < 0) {
throw new Error(`Insufficient stock for article (${item.sku}): available ${currentStock}, requested ${Math.abs(item.amount)}`);
}
}
// Create manifestarticle row
const lineRes = await runner.query(queries.CREATE_LINE, {
manifestId,
sku: item.sku,
name: item.articleName,
pu: 1,
amount: Math.abs(item.amount),
net: 0,
comment: item.text,
rebate: 0,
taxrate: 0,
});
const manifestarticleId = lineRes.rows[0]?.id ?? lineRes.insertId;
// Append to ledger
const ledgerRow = await addLedger(
googleAuthId,
{
manifestarticle_id: manifestarticleId,
warehouse_id: item.warehouseId,
transaction_type: ADJUSTMENT_TYPE,
amount: item.amount,
transaction_text: item.text,
},
activeTx,
);
createdLines.push({
manifestarticle_id: manifestarticleId,
sku: item.sku,
name: item.articleName,
warehouse_id: item.warehouseId,
amount: item.amount,
text: item.text,
});
createdLedgerEntries.push(ledgerRow);
// Write article history for the adjusted article
if (item.articleId) {
await recordStockAdjustmentHistory(runner, {
articleId: item.articleId,
companyuserId,
amount: item.amount,
reason: item.text,
});
}
}
return {
manifest: {
id: manifestId,
number,
type: ADJUSTMENT_TYPE,
date: date.toISOString(),
createdAt: createdAt.toISOString(),
externalNumber,
},
lines: createdLines,
ledger: createdLedgerEntries,
};
};
if (tx) {
return run(makeRunner(provider, tx), tx);
}
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, activeTx) => run(runner, activeTx));
}
return run(makeRunner(provider));
};
}