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'; 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} [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, ledger: Array}>} */ 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})`); 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]; // 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) { // 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)); }; }