370 lines
14 KiB
JavaScript
370 lines
14 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';
|
|
|
|
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})`);
|
|
|
|
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));
|
|
};
|
|
}
|