This commit is contained in:
seb
2026-10-16 22:09:12 +02:00
parent 796f6a3272
commit 7635821dad
38 changed files with 6191 additions and 3350 deletions

View File

@@ -1,25 +1,32 @@
import {
UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK,
CHECK_HAS_WAREHOUSE as MSSQL_CHECK_HAS_WAREHOUSE,
CHECK_ARTICLE_WAREHOUSE_STOCKS as MSSQL_CHECK_ARTICLE_WAREHOUSE_STOCKS,
} from '../../providers/mssql/queries/article.js';
import {
UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK,
CHECK_HAS_WAREHOUSE as SQLITE_CHECK_HAS_WAREHOUSE,
CHECK_ARTICLE_WAREHOUSE_STOCKS as SQLITE_CHECK_ARTICLE_WAREHOUSE_STOCKS,
} from '../../providers/sqlite3/queries/article.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordTrackStockChangeHistory } from '../articlehistory/record.js';
import stockAdjust from '../stock/adjust.js';
import makeRunner from '../runner.js';
export default function setTrackStockBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_UPDATE_TRACK_STOCK : MSSQL_UPDATE_TRACK_STOCK;
const checkHasWarehouseSql = isSqlite ? SQLITE_CHECK_HAS_WAREHOUSE : MSSQL_CHECK_HAS_WAREHOUSE;
const checkStocksSql = isSqlite ? SQLITE_CHECK_ARTICLE_WAREHOUSE_STOCKS : MSSQL_CHECK_ARTICLE_WAREHOUSE_STOCKS;
/**
* Sets the track_stock flag of an article by its SKU.
* If stock tracking is being disabled (1 -> 0), all non-zero warehouse stocks
* are adjusted to 0 on the ledger before disabling tracking.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
@@ -27,56 +34,86 @@ export default function setTrackStockBySku(provider) {
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated article.
* @throws {Error} If trackStock is invalid, article not found, or no warehouse exists when enabling.
*
* @example
* const article = await store.article.setTrackStockBySku(42, 'SKU-1', true);
* // => { id, sku, name, track_stock, ... }
*/
return async (googleAuthId, sku, trackStock, tx) => {
if (typeof trackStock !== 'boolean' && trackStock !== 0 && trackStock !== 1) {
throw new Error('Invalid track_stock');
}
const runner = makeRunner(provider, tx);
const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
const run = async (runner, activeTx) => {
const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
const newTrack = Boolean(trackStock);
if (newTrack) {
const whRes = await runner.query(checkHasWarehouseSql, { companyId });
if (!whRes.rows || whRes.rows.length === 0) {
throw new Error('At least one warehouse is required to enable stock tracking');
const newTrack = Boolean(trackStock);
if (newTrack) {
const whRes = await runner.query(checkHasWarehouseSql, { companyId });
if (!whRes.rows || whRes.rows.length === 0) {
throw new Error('At least one warehouse is required to enable stock tracking');
}
}
// Read old track_stock for history
const oldRes = await runner.query(
'SELECT track_stock FROM article WHERE id = @articleId',
{ articleId },
);
const oldTrack = Boolean(oldRes.rows[0]?.track_stock ?? 0);
// "before stock tracking is disabled, adjust to 0 has to be ledgerized."
if (oldTrack && !newTrack) {
const stocksRes = await runner.query(checkStocksSql, {
companyId,
sku,
articleId,
});
const rows = stocksRes.rows ?? [];
for (const row of rows) {
const currentStock = Number(row.stock || 0);
if (currentStock !== 0) {
await adjust(
googleAuthId,
{
sku,
warehouseId: row.warehouseId,
amount: -currentStock,
text: `Bestandskorrektur auf 0 vor Deaktivierung der Bestandsführung (Lager: "${row.warehouseName}")`,
},
activeTx,
);
}
}
}
const result = await runner.query(sql, {
articleId,
trackStock: trackStock ? 1 : 0,
});
if (!result.rowsAffected) {
throw new Error('Article not found');
}
// Write history if track_stock changed
if (oldTrack !== newTrack) {
await recordTrackStockChangeHistory(runner, {
articleId,
companyuserId,
oldTrack,
newTrack,
});
}
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
return article;
};
if (tx) return run(makeRunner(provider, tx), tx);
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, activeTx) => run(runner, activeTx));
}
// Read old track_stock for history
const oldRes = await runner.query(
'SELECT track_stock FROM article WHERE id = @articleId',
{ articleId },
);
const oldTrack = Boolean(oldRes.rows[0]?.track_stock ?? 0);
const result = await runner.query(sql, {
articleId,
trackStock: trackStock ? 1 : 0,
});
if (!result.rowsAffected) {
throw new Error('Article not found');
}
// Write history if track_stock changed
await recordTrackStockChangeHistory(runner, {
articleId,
companyuserId,
oldTrack,
newTrack,
});
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
return article;
return run(makeRunner(provider));
};
}

View File

@@ -8,6 +8,7 @@ import { GET_FULL_BY_SI as MSSQL_GET_UNIT } from '../../providers/mssql/queries/
import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queries/unit.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import setTrackStockBySku from './setTrackStockBySku.js';
import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js';
import { recordArticleUpdateHistory } from '../articlehistory/record.js';
@@ -24,6 +25,7 @@ export default function updateBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const resolveSlug = resolveUniqueSlug(provider);
const setTrackStock = setTrackStockBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
@@ -219,10 +221,7 @@ export default function updateBySku(provider) {
}
if (hasTrackStock) {
await runner.query(articleQueries.UPDATE_TRACK_STOCK, {
articleId,
trackStock: Boolean(rawTrackStock) ? 1 : 0,
});
await setTrackStock(googleAuthId, sku, Boolean(rawTrackStock), activeTx);
}
if (historyLines.length) {

View File

@@ -149,7 +149,158 @@ export async function recordOrderHistory(runner, {
}
/**
* Records a delivery / warehouse ledger entry in article history.
* Records an incoming delivery acceptance ledger entry in article history.
* Format: "Lieferung {manifestNumber} (Lieferant: "{supplierName}", EK brutto: {ekBrutto}, Menge: {amount})"
*/
export async function recordAcceptanceHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
supplierName,
net,
rebate,
taxrate,
amount,
transactionText,
}) {
const details = [];
if (supplierName) {
details.push(`Lieferant: "${supplierName}"`);
}
let ekBrutto = null;
if (net != null && Number.isFinite(Number(net))) {
const netNum = Number(net);
const rebateNum = Number(rebate ?? 0);
const taxrateNum = Number(taxrate ?? 0);
const netAfterRebate = netNum * (1 - rebateNum / 100);
const brutto = netAfterRebate * (1 + taxrateNum / 100);
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 = manifestNumber ? `Lieferung ${manifestNumber}` : (transactionText?.trim() || 'Lieferung');
const historyText = `${base}${suffix}`;
const meta = {
type: 'acceptance',
manifestNumber: manifestNumber ?? null,
deliveryNumber: manifestNumber ?? null,
supplierName: supplierName ?? null,
amount: amount != null ? Number(amount) : null,
net: net != null ? Number(net) : null,
rebate: rebate != null ? Number(rebate) : 0,
taxrate: taxrate != null ? Number(taxrate) : 0,
ekBrutto: ekBrutto != null ? Number(ekBrutto) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Records an outgoing shipment ledger entry in article history.
* Format: "Lieferschein {manifestNumber} (Kunde: "{customerName}", Menge: {amount})"
*/
export async function recordShipmentHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
customerName,
amount,
transactionText,
}) {
const details = [];
if (customerName) {
details.push(`Kunde: "${customerName}"`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base = manifestNumber ? `Lieferschein ${manifestNumber}` : (transactionText?.trim() || 'Lieferschein');
const historyText = `${base}${suffix}`;
const meta = {
type: 'shipment',
manifestNumber: manifestNumber ?? null,
shipmentNumber: manifestNumber ?? null,
customerName: customerName ?? null,
amount: amount != null ? Number(amount) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Records a warehouse inventory ledger entry in article history.
* Format:
* Inbound: "Lieferung {manifestNumber} (Lager: "{warehouseName}", Menge: {amount})"
* Outbound: "Lieferschein {manifestNumber} (Lager: "{warehouseName}", Menge: {amount})"
*/
export async function recordWarehouseHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
manifestType,
warehouseName,
amount,
transactionText,
}) {
const isShipment = manifestType === 'shipment' || (amount != null && Number(amount) < 0);
const prefix = isShipment ? 'Lieferschein' : 'Lieferung';
const displayAmount = amount != null ? Math.abs(Number(amount)) : null;
const details = [];
if (warehouseName) {
details.push(`Lager: "${warehouseName}"`);
}
if (displayAmount != null && Number.isFinite(displayAmount)) {
details.push(`Menge: ${displayAmount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base = manifestNumber ? `${prefix} ${manifestNumber}` : (transactionText?.trim() || prefix);
const historyText = `${base}${suffix}`;
const meta = {
type: isShipment ? 'warehouse_out' : 'warehouse_in',
manifestNumber: manifestNumber ?? null,
deliveryNumber: !isShipment ? (manifestNumber ?? null) : null,
shipmentNumber: isShipment ? (manifestNumber ?? null) : null,
warehouseName: warehouseName ?? null,
amount: amount != null ? Number(amount) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Backward-compatible helper that records delivery / warehouse ledger entry in article history.
*/
export async function recordDeliveryHistory(runner, {
articleId,
@@ -165,6 +316,21 @@ export async function recordDeliveryHistory(runner, {
transactionText,
isDelivery = true,
}) {
if (isDelivery && !warehouseName) {
return recordAcceptanceHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
supplierName,
net,
rebate,
taxrate,
amount,
transactionText,
});
}
let historyText = transactionText;
let ekBrutto = null;
@@ -189,9 +355,7 @@ export async function recordDeliveryHistory(runner, {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base =
transactionText?.trim() ||
(manifestNumber ? `Lieferung ${manifestNumber}` : 'Lieferung');
const base = manifestNumber ? `Lieferung ${manifestNumber}` : (transactionText?.trim() || 'Lieferung');
historyText = `${base}${suffix}`;
} else if (warehouseName && transactionText && !transactionText.includes(warehouseName)) {
historyText = `${transactionText} (Lager: "${warehouseName}")`;
@@ -200,6 +364,7 @@ export async function recordDeliveryHistory(runner, {
const meta = {
type: isDelivery ? 'delivery' : 'movement',
manifestNumber: manifestNumber ?? null,
deliveryNumber: manifestNumber ?? null,
supplierName: supplierName ?? null,
warehouseName: warehouseName ?? null,
amount: amount != null ? Number(amount) : null,

View File

@@ -0,0 +1,48 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import stockAccept from '../stock/accept.js';
import { loadDelivery } from './helpers.js';
import makeRunner from '../runner.js';
/**
* Books all positions of an incoming delivery manifest to the ledger
* by delegating to store.stock.accept.
*
* @param {object} provider - Storage provider instance.
* @returns {Function} `(googleAuthId, input, tx) => Promise<object>`
*/
export default function book(provider) {
const access = resolveSelectedCompanyAccess(provider);
const accept = stockAccept(provider);
return async (googleAuthId, input = {}, tx) => {
const rawId = typeof input === 'number' ? input : (input?.id ?? input?.deliveryId);
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Delivery not found');
}
const warehouseId = input?.warehouseId ?? input?.warehouse_id ?? null;
const doBook = async (runner, activeTx) => {
const { companyId } = await access(googleAuthId, runner);
const delivery = await loadDelivery(provider, activeTx, companyId, id);
if (!delivery) {
throw new Error('Delivery not found');
}
if (!delivery.lines || delivery.lines.length === 0) {
throw new Error('Delivery has no lines');
}
await accept(googleAuthId, { deliveryId: id, warehouseId }, activeTx);
return loadDelivery(provider, activeTx, companyId, id);
};
if (tx) {
return doBook(makeRunner(provider, tx), tx);
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(doBook);
}
return doBook(makeRunner(provider));
};
}

View File

@@ -1,3 +1,4 @@
import { syncDeliverySummary } from './syncSummary.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
@@ -73,6 +74,7 @@ export default function create(provider) {
body.files,
);
await syncDeliverySummary(runner, { companyId, manifestId });
return loadDelivery(provider, tx, companyId, manifestId);
};

View File

@@ -18,6 +18,7 @@ export default function del(provider) {
const runner = makeRunner(provider);
try {
await runner.query('DELETE FROM deliverysummary WHERE manifest_id = @id AND company_id = @companyId', { id, companyId });
const result = await runner(queries.DELETE_MANIFEST, {
id,
companyId,

View File

@@ -17,12 +17,20 @@ export function shapeSummary(row) {
createdAt: row.createdAt ?? row.created_at ?? null,
};
if (row.totalPositions !== undefined || row.completelyOnLedgerPositions !== undefined) {
summary.ledger = {
totalPositions: Number(row.totalPositions ?? 0),
completelyOnLedgerPositions: Number(row.completelyOnLedgerPositions ?? 0),
};
}
const totalPositions = Number(row.stockPositions ?? row.totalPositions ?? row.positions ?? 0);
const bookedPositions = Number(row.completelyOnLedgerPositions ?? row.bookedPositions ?? 0);
const unbookedPositions = Number(row.unbookedPositions ?? Math.max(0, totalPositions - bookedPositions));
summary.ledger = {
totalPositions,
completelyOnLedgerPositions: bookedPositions,
bookedPositions,
unbookedPositions,
totalQuantity: Number(row.stockQuantity ?? row.totalQuantity ?? 0),
bookedQuantity: Number(row.bookedQuantity ?? 0),
unbookedQuantity: Number(row.unbookedQuantity ?? 0),
isBooked: row.isBooked !== undefined ? Boolean(row.isBooked) : (totalPositions > 0 && bookedPositions >= totalPositions),
};
return summary;
}

View File

@@ -10,30 +10,317 @@ export default function list(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider);
const sqlCountManifests = isSqlite
? 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND "type" = @type'
: 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND [type] = @type';
const result = await runner(queries.LIST_DELIVERIES, {
companyId,
type: queries.DELIVERY_TYPE,
});
/**
* Lists the deliveries of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [opts] - Optional pagination and filter options.
* @param {number} [opts.offset=0] - Number of deliveries to skip.
* @param {number} [opts.limit=50] - Number of deliveries to return.
* @param {string} [opts.search] - Free-text search matching delivery number, external number, or supplier name.
* @param {boolean} [opts.onlyUnbooked=false] - When true, returns only deliveries with unbooked stock items.
* @param {number} [opts.supplierId] - Filter by supplier entity id.
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{deliveries: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>}
*/
return async (googleAuthId, opts, tx) => {
let offset = null;
let limit = null;
let isPaged = false;
let resolvedTx = tx;
const filesResult = await runner(queries.LIST_DELIVERY_FILES, {
companyId,
type: queries.DELIVERY_TYPE,
});
const hasFilter = opts != null && typeof opts === 'object' && !opts._isTx && (
opts.offset !== undefined ||
opts.limit !== undefined ||
(typeof opts.search === 'string' && opts.search.trim() !== '') ||
opts.onlyUnbooked === true ||
opts.supplierId !== undefined ||
opts.seekMonths !== undefined
);
const filesByManifest = new Map();
for (const row of filesResult.rows) {
const list = filesByManifest.get(row.manifestId) ?? [];
list.push(shapeAttachmentMeta(row));
filesByManifest.set(row.manifestId, list);
if (opts != null && typeof opts === 'object' && !opts._isTx) {
if (hasFilter || opts.offset !== undefined || opts.limit !== undefined) {
isPaged = true;
const parsedOffset = Number(opts.offset ?? 0);
const parsedLimit = Number(opts.limit ?? 50);
if (!Number.isInteger(parsedOffset) || parsedOffset < 0) {
throw new Error('Invalid offset');
}
if (!Number.isInteger(parsedLimit) || parsedLimit < 1) {
throw new Error('Invalid limit');
}
offset = parsedOffset;
limit = parsedLimit;
}
} else if (opts != null) {
resolvedTx = opts;
}
return result.rows.map((row) => ({
const runner = makeRunner(provider, resolvedTx);
const { companyId } = await access(googleAuthId, runner);
const conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`];
const params = {
companyId,
type: queries.DELIVERY_TYPE,
};
// Date cutoff via seekMonths (strictly integer)
if (opts?.seekMonths !== undefined) {
if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) {
throw new Error('Invalid seekMonths');
}
const d = new Date();
d.setMonth(d.getMonth() - Math.abs(opts.seekMonths));
params.dateTo = d.toISOString();
conditions.push(`m.${isSqlite ? '"date"' : '[date]'} <= @dateTo`);
}
// Filter by supplierId
if (opts?.supplierId !== undefined && opts.supplierId !== null) {
const supplierId = Number(opts.supplierId);
if (!Number.isInteger(supplierId) || supplierId <= 0) {
throw new Error('Invalid supplierId');
}
params.supplierId = supplierId;
conditions.push('m.entity_id = @supplierId');
}
// Free-text search
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
params.searchPattern = `%${opts.search.trim()}%`;
if (isSqlite) {
conditions.push(`(
m."number" LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR e.name LIKE @searchPattern
OR e.first_name LIKE @searchPattern
OR (IFNULL(e.first_name, '') || ' ' || IFNULL(e.name, '')) LIKE @searchPattern
OR (IFNULL(e.name, '') || ' ' || IFNULL(e.first_name, '')) LIKE @searchPattern
OR ds.delivery_number LIKE @searchPattern
OR ds.supplier_name LIKE @searchPattern
)`);
} else {
conditions.push(`(
m.[number] LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR e.name LIKE @searchPattern
OR e.first_name LIKE @searchPattern
OR (ISNULL(e.first_name, '') + ' ' + ISNULL(e.name, '')) LIKE @searchPattern
OR (ISNULL(e.name, '') + ' ' + ISNULL(e.first_name, '')) LIKE @searchPattern
OR ds.delivery_number LIKE @searchPattern
OR ds.supplier_name LIKE @searchPattern
)`);
}
}
// Only unbooked deliveries (has stock positions that are not yet booked)
if (opts?.onlyUnbooked === true) {
conditions.push('(COALESCE(ds.unbooked_positions, 0) > 0 OR (COALESCE(ds.is_booked, 0) = 0 AND COALESCE(ds.stock_positions, 0) > 0))');
}
const whereClause = conditions.join('\n AND ');
const sqlCount = `
SELECT COUNT(*) AS total
FROM manifest m
LEFT JOIN deliverysummary ds ON ds.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
`;
let sqlList;
if (isSqlite) {
sqlList = `
SELECT
m.id,
m."number",
m."type",
m.entity_id AS supplierId,
m.external_number AS externalNumber,
m."date",
m.created_at AS createdAt,
ds.positions,
ds.gross_total AS grossTotal,
ds.net_total AS netTotal,
ds.total_quantity AS totalQuantity,
ds.stock_positions AS stockPositions,
ds.booked_positions AS bookedPositions,
ds.unbooked_positions AS unbookedPositions,
ds.stock_quantity AS stockQuantity,
ds.booked_quantity AS bookedQuantity,
ds.unbooked_quantity AS unbookedQuantity,
ds.is_booked AS isBooked,
ds.supplier_name AS supplierName
FROM manifest m
LEFT JOIN deliverysummary ds ON ds.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
ORDER BY m."date" DESC, m.id DESC
${isPaged ? 'LIMIT @limit OFFSET @offset' : ''}
`;
} else {
sqlList = `
SELECT
m.id,
m.[number],
m.[type],
m.entity_id AS supplierId,
m.external_number AS externalNumber,
m.[date],
m.created_at AS createdAt,
ds.positions,
ds.gross_total AS grossTotal,
ds.net_total AS netTotal,
ds.total_quantity AS totalQuantity,
ds.stock_positions AS stockPositions,
ds.booked_positions AS bookedPositions,
ds.unbooked_positions AS unbookedPositions,
ds.stock_quantity AS stockQuantity,
ds.booked_quantity AS bookedQuantity,
ds.unbooked_quantity AS unbookedQuantity,
ds.is_booked AS isBooked,
ds.supplier_name AS supplierName
FROM manifest m
LEFT JOIN deliverysummary ds ON ds.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
ORDER BY m.[date] DESC, m.id DESC
${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''}
`;
}
const hasUserFilters = (
opts?.seekMonths !== undefined ||
(typeof opts?.search === 'string' && opts.search.trim() !== '') ||
opts?.onlyUnbooked === true ||
opts?.supplierId !== undefined
);
let manifestRows;
let total = 0;
let unfilteredTotal = 0;
if (isPaged) {
params.offset = offset;
params.limit = limit;
const [countResult, listResult, unfilteredCountResult] = await Promise.all([
runner.query(sqlCount, params),
runner.query(sqlList, params),
hasUserFilters ? runner.query(sqlCountManifests, { companyId, type: queries.DELIVERY_TYPE }) : null,
]);
total = Number(countResult.rows[0]?.total ?? 0);
unfilteredTotal = hasUserFilters
? Number(unfilteredCountResult?.rows[0]?.total ?? 0)
: total;
manifestRows = listResult.rows;
} else {
const result = await runner.query(sqlList, params);
manifestRows = result.rows;
total = manifestRows.length;
if (hasUserFilters) {
const countRes = await runner.query(sqlCountManifests, { companyId, type: queries.DELIVERY_TYPE });
unfilteredTotal = Number(countRes.rows[0]?.total ?? 0);
} else {
unfilteredTotal = total;
}
}
const manifestIds = manifestRows.map((r) => r.id);
const filesByManifest = new Map();
if (manifestIds.length > 0) {
const sqlFiles = isSqlite
? `SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN "file" f ON f.id = ma.file_id
WHERE ma.manifest_id IN (SELECT value FROM json_each(@manifestIdsJson))
ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC`
: `SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbavif,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN [file] f ON f.id = ma.file_id
WHERE ma.manifest_id IN (SELECT CAST(value AS INT) FROM OPENJSON(@manifestIdsJson))
ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC`;
const filesResult = await runner.query(sqlFiles, {
manifestIdsJson: JSON.stringify(manifestIds),
});
for (const row of filesResult.rows) {
const list = filesByManifest.get(row.manifestId) ?? [];
list.push(shapeAttachmentMeta(row));
filesByManifest.set(row.manifestId, list);
}
}
const deliveries = manifestRows.map((row) => ({
...shapeSummary(row),
positions: row.positions !== undefined && row.positions !== null ? Number(row.positions) : 0,
grossTotal: row.grossTotal !== undefined && row.grossTotal !== null ? Number(row.grossTotal) : 0,
netTotal: row.netTotal !== undefined && row.netTotal !== null ? Number(row.netTotal) : 0,
totalQuantity: row.totalQuantity !== undefined && row.totalQuantity !== null ? Number(row.totalQuantity) : 0,
stockPositions: row.stockPositions !== undefined && row.stockPositions !== null ? Number(row.stockPositions) : 0,
bookedPositions: row.bookedPositions !== undefined && row.bookedPositions !== null ? Number(row.bookedPositions) : 0,
unbookedPositions: row.unbookedPositions !== undefined && row.unbookedPositions !== null ? Number(row.unbookedPositions) : 0,
stockQuantity: row.stockQuantity !== undefined && row.stockQuantity !== null ? Number(row.stockQuantity) : 0,
bookedQuantity: row.bookedQuantity !== undefined && row.bookedQuantity !== null ? Number(row.bookedQuantity) : 0,
unbookedQuantity: row.unbookedQuantity !== undefined && row.unbookedQuantity !== null ? Number(row.unbookedQuantity) : 0,
isBooked: row.isBooked !== undefined && row.isBooked !== null ? Boolean(row.isBooked) : false,
supplierName: row.supplierName ?? undefined,
ledger: {
totalPositions: Number(row.stockPositions ?? row.totalPositions ?? 0),
completelyOnLedgerPositions: Number(row.bookedPositions ?? row.completelyOnLedgerPositions ?? 0),
bookedPositions: Number(row.bookedPositions ?? 0),
unbookedPositions: Number(row.unbookedPositions ?? 0),
totalQuantity: Number(row.stockQuantity ?? 0),
bookedQuantity: Number(row.bookedQuantity ?? 0),
unbookedQuantity: Number(row.unbookedQuantity ?? 0),
isBooked: Boolean(row.isBooked),
},
files: filesByManifest.get(row.id) ?? [],
}));
if (isPaged) {
return {
deliveries,
total,
unfilteredTotal,
offset,
limit,
};
}
return deliveries;
};
}

View File

@@ -0,0 +1,19 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { rebuildDeliverySummaries } from './syncSummary.js';
/**
* Rebuilds the `deliverysummary` table for the user's selected company.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function rebuildSummaries(provider) {
const access = resolveSelectedCompanyAccess(provider);
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
await rebuildDeliverySummaries(runner, companyId);
return { ok: true, companyId };
};
}

View File

@@ -0,0 +1,445 @@
/**
* Centralized sync and rebuild logic for the materialized `deliverysummary` table.
*
* Keeps `deliverysummary` in sync with manifest (delivery), manifestarticle,
* article (track_stock), and ledger (DELIVERY transactions).
*/
const SQL_GET_DELIVERY_BY_ID_SQLITE = `
SELECT m.id, m.company_id, m."number", m.external_number, m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'delivery'
`;
const SQL_GET_DELIVERY_BY_ID_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.external_number, m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'delivery'
`;
const SQL_GET_DELIVERY_BY_NUMBER_SQLITE = `
SELECT m.id, m.company_id, m."number", m.external_number, m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m."number" = @number AND m.company_id = @companyId AND m."type" = 'delivery'
`;
const SQL_GET_DELIVERY_BY_NUMBER_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.external_number, m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.[number] = @number AND m.company_id = @companyId AND m.[type] = 'delivery'
`;
const SQL_SUPPLIER_NAME_SQLITE = `
SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS supplier_name
FROM entity
WHERE id = @entityId
`;
const SQL_SUPPLIER_NAME_MSSQL = `
SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS supplier_name
FROM entity
WHERE id = @entityId
`;
const SQL_DELIVERY_TOTALS_SQLITE = `
WITH line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
LEFT JOIN article a ON a.company_id = @companyId AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE ma.manifest_id = @manifestId
)
SELECT
COUNT(*) AS positions,
COALESCE(SUM(line_gross), 0) AS gross_total,
COALESCE(SUM(line_net), 0) AS net_total,
COALESCE(SUM(amount), 0) AS total_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END), 0) AS stock_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END), 0) AS booked_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END), 0) AS stock_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END), 0) AS booked_quantity
FROM line_data
`;
const SQL_DELIVERY_TOTALS_MSSQL = `
WITH line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
LEFT JOIN article a ON a.company_id = @companyId AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE ma.manifest_id = @manifestId
)
SELECT
COUNT(*) AS positions,
COALESCE(SUM(line_gross), 0) AS gross_total,
COALESCE(SUM(line_net), 0) AS net_total,
COALESCE(SUM(amount), 0) AS total_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END), 0) AS stock_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END), 0) AS booked_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END), 0) AS stock_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END), 0) AS booked_quantity
FROM line_data
`;
const SQL_UPSERT_SUMMARY_SQLITE = `
INSERT INTO deliverysummary (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
) VALUES (
@companyId, @manifestId, @deliveryNumber, @externalNumber, @deliveryDate, @createdAt,
@supplierId, @supplierName, @positions, @grossTotal, @netTotal, @totalQuantity,
@stockPositions, @bookedPositions, @unbookedPositions,
@stockQuantity, @bookedQuantity, @unbookedQuantity, @isBooked
)
ON CONFLICT(manifest_id) DO UPDATE SET
delivery_number = excluded.delivery_number,
external_number = excluded.external_number,
delivery_date = excluded.delivery_date,
created_at = excluded.created_at,
supplier_id = excluded.supplier_id,
supplier_name = excluded.supplier_name,
positions = excluded.positions,
gross_total = excluded.gross_total,
net_total = excluded.net_total,
total_quantity = excluded.total_quantity,
stock_positions = excluded.stock_positions,
booked_positions = excluded.booked_positions,
unbooked_positions = excluded.unbooked_positions,
stock_quantity = excluded.stock_quantity,
booked_quantity = excluded.booked_quantity,
unbooked_quantity = excluded.unbooked_quantity,
is_booked = excluded.is_booked
`;
const SQL_UPSERT_SUMMARY_MSSQL = `
MERGE INTO deliverysummary AS target
USING (SELECT @manifestId AS manifest_id) AS source
ON (target.manifest_id = source.manifest_id)
WHEN MATCHED THEN
UPDATE SET
delivery_number = @deliveryNumber,
external_number = @externalNumber,
delivery_date = @deliveryDate,
created_at = @createdAt,
supplier_id = @supplierId,
supplier_name = @supplierName,
positions = @positions,
gross_total = @grossTotal,
net_total = @netTotal,
total_quantity = @totalQuantity,
stock_positions = @stockPositions,
booked_positions = @bookedPositions,
unbooked_positions = @unbookedPositions,
stock_quantity = @stockQuantity,
booked_quantity = @bookedQuantity,
unbooked_quantity = @unbookedQuantity,
is_booked = @isBooked
WHEN NOT MATCHED THEN
INSERT (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
) VALUES (
@companyId, @manifestId, @deliveryNumber, @externalNumber, @deliveryDate, @createdAt,
@supplierId, @supplierName, @positions, @grossTotal, @netTotal, @totalQuantity,
@stockPositions, @bookedPositions, @unbookedPositions,
@stockQuantity, @bookedQuantity, @unbookedQuantity, @isBooked
);
`;
/**
* Recalculates and upserts a single delivery summary into `deliverysummary`.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} [opts.manifestId] - Delivery manifest id.
* @param {string} [opts.deliveryNumber] - Delivery number (if manifestId not given).
*/
export async function syncDeliverySummary(runner, { companyId, manifestId, deliveryNumber }) {
const isSqlite = runner.dialect === 'sqlite3';
let deliveryRow;
if (manifestId) {
const res = await runner.query(
isSqlite ? SQL_GET_DELIVERY_BY_ID_SQLITE : SQL_GET_DELIVERY_BY_ID_MSSQL,
{ manifestId, companyId },
);
deliveryRow = res.rows[0];
} else if (deliveryNumber) {
const res = await runner.query(
isSqlite ? SQL_GET_DELIVERY_BY_NUMBER_SQLITE : SQL_GET_DELIVERY_BY_NUMBER_MSSQL,
{ number: deliveryNumber, companyId },
);
deliveryRow = res.rows[0];
}
if (!deliveryRow) {
if (manifestId) {
await runner.query('DELETE FROM deliverysummary WHERE manifest_id = @manifestId', { manifestId });
}
return;
}
const deliveryId = deliveryRow.id;
const num = deliveryRow.number;
const totalsRes = await runner.query(
isSqlite ? SQL_DELIVERY_TOTALS_SQLITE : SQL_DELIVERY_TOTALS_MSSQL,
{ manifestId: deliveryId, companyId },
);
const totals = totalsRes.rows[0] || {};
let supplierName = null;
if (deliveryRow.entity_id) {
const supRes = await runner.query(
isSqlite ? SQL_SUPPLIER_NAME_SQLITE : SQL_SUPPLIER_NAME_MSSQL,
{ entityId: deliveryRow.entity_id },
);
supplierName = supRes.rows[0]?.supplier_name || null;
}
const positions = Number(totals.positions || 0);
const grossTotal = Math.round(Number(totals.gross_total || 0) * 100) / 100;
const netTotal = Math.round(Number(totals.net_total || 0) * 100) / 100;
const totalQuantity = Math.round(Number(totals.total_quantity || 0) * 1000) / 1000;
const stockPositions = Number(totals.stock_positions || 0);
const bookedPositions = Number(totals.booked_positions || 0);
const unbookedPositions = Math.max(0, stockPositions - bookedPositions);
const stockQuantity = Math.round(Number(totals.stock_quantity || 0) * 1000) / 1000;
const bookedQuantity = Math.round(Number(totals.booked_quantity || 0) * 1000) / 1000;
const unbookedQuantity = Math.round(Math.max(0, stockQuantity - bookedQuantity) * 1000) / 1000;
const isBooked = stockPositions > 0 && bookedPositions >= stockPositions ? 1 : 0;
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
companyId,
manifestId: deliveryId,
deliveryNumber: num,
externalNumber: deliveryRow.external_number ?? null,
deliveryDate: deliveryRow.date,
createdAt: deliveryRow.created_at,
supplierId: deliveryRow.entity_id ?? null,
supplierName,
positions,
grossTotal,
netTotal,
totalQuantity,
stockPositions,
bookedPositions,
unbookedPositions,
stockQuantity,
bookedQuantity,
unbookedQuantity,
isBooked,
});
}
/**
* Full rebuild of all deliveries in `deliverysummary` for a given company.
*
* @param {object} runner
* @param {number} companyId
*/
export async function rebuildDeliverySummaries(runner, companyId) {
const isSqlite = runner.dialect === 'sqlite3';
if (isSqlite) {
const sqlRebuildSqlite = `
WITH
line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.manifest_id,
ma.id AS line_id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.company_id = m.company_id AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE m.company_id = @companyId AND m."type" = 'delivery'
),
delivery_agg AS (
SELECT
manifest_id,
COUNT(*) AS positions,
SUM(line_gross) AS gross_total,
SUM(line_net) AS net_total,
SUM(amount) AS total_quantity,
SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END) AS stock_positions,
SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END) AS booked_positions,
SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END) AS stock_quantity,
SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END) AS booked_quantity
FROM line_data
GROUP BY manifest_id
)
INSERT INTO deliverysummary (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
)
SELECT
m.company_id,
m.id AS manifest_id,
m."number" AS delivery_number,
m.external_number,
m."date" AS delivery_date,
m.created_at,
m.entity_id AS supplier_id,
TRIM(COALESCE(e.first_name || ' ', '') || COALESCE(e.name, '')) AS supplier_name,
COALESCE(da.positions, 0) AS positions,
ROUND(COALESCE(da.gross_total, 0), 2) AS gross_total,
ROUND(COALESCE(da.net_total, 0), 2) AS net_total,
ROUND(COALESCE(da.total_quantity, 0), 3) AS total_quantity,
COALESCE(da.stock_positions, 0) AS stock_positions,
COALESCE(da.booked_positions, 0) AS booked_positions,
MAX(0, COALESCE(da.stock_positions, 0) - COALESCE(da.booked_positions, 0)) AS unbooked_positions,
ROUND(COALESCE(da.stock_quantity, 0), 3) AS stock_quantity,
ROUND(COALESCE(da.booked_quantity, 0), 3) AS booked_quantity,
ROUND(MAX(0, COALESCE(da.stock_quantity, 0) - COALESCE(da.booked_quantity, 0)), 3) AS unbooked_quantity,
CASE WHEN COALESCE(da.stock_positions, 0) > 0 AND COALESCE(da.booked_positions, 0) >= COALESCE(da.stock_positions, 0) THEN 1 ELSE 0 END AS is_booked
FROM manifest m
LEFT JOIN entity e ON e.id = m.entity_id
LEFT JOIN delivery_agg da ON da.manifest_id = m.id
WHERE m.company_id = @companyId AND m."type" = 'delivery'
ON CONFLICT(manifest_id) DO UPDATE SET
delivery_number = excluded.delivery_number,
external_number = excluded.external_number,
delivery_date = excluded.delivery_date,
created_at = excluded.created_at,
supplier_id = excluded.supplier_id,
supplier_name = excluded.supplier_name,
positions = excluded.positions,
gross_total = excluded.gross_total,
net_total = excluded.net_total,
total_quantity = excluded.total_quantity,
stock_positions = excluded.stock_positions,
booked_positions = excluded.booked_positions,
unbooked_positions = excluded.unbooked_positions,
stock_quantity = excluded.stock_quantity,
booked_quantity = excluded.booked_quantity,
unbooked_quantity = excluded.unbooked_quantity,
is_booked = excluded.is_booked;
`;
await runner.query(sqlRebuildSqlite, { companyId });
} else {
await runner.query('DELETE FROM deliverysummary WHERE company_id = @companyId', { companyId });
const sqlRebuildMssql = `
WITH
line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.manifest_id,
ma.id AS line_id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.company_id = m.company_id AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE m.company_id = @companyId AND m.[type] = 'delivery'
),
delivery_agg AS (
SELECT
manifest_id,
COUNT(*) AS positions,
SUM(line_gross) AS gross_total,
SUM(line_net) AS net_total,
SUM(amount) AS total_quantity,
SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END) AS stock_positions,
SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END) AS booked_positions,
SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END) AS stock_quantity,
SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END) AS booked_quantity
FROM line_data
GROUP BY manifest_id
)
INSERT INTO deliverysummary (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
)
SELECT
m.company_id,
m.id AS manifest_id,
m.[number] AS delivery_number,
m.external_number,
m.[date] AS delivery_date,
m.created_at,
m.entity_id AS supplier_id,
LTRIM(RTRIM(ISNULL(e.first_name + ' ', '') + ISNULL(e.name, ''))) AS supplier_name,
COALESCE(da.positions, 0) AS positions,
ROUND(COALESCE(da.gross_total, 0), 2) AS gross_total,
ROUND(COALESCE(da.net_total, 0), 2) AS net_total,
ROUND(COALESCE(da.total_quantity, 0), 3) AS total_quantity,
COALESCE(da.stock_positions, 0) AS stock_positions,
COALESCE(da.booked_positions, 0) AS booked_positions,
CASE WHEN COALESCE(da.stock_positions, 0) > COALESCE(da.booked_positions, 0) THEN COALESCE(da.stock_positions, 0) - COALESCE(da.booked_positions, 0) ELSE 0 END AS unbooked_positions,
ROUND(COALESCE(da.stock_quantity, 0), 3) AS stock_quantity,
ROUND(COALESCE(da.booked_quantity, 0), 3) AS booked_quantity,
ROUND(CASE WHEN COALESCE(da.stock_quantity, 0) > COALESCE(da.booked_quantity, 0) THEN COALESCE(da.stock_quantity, 0) - COALESCE(da.booked_quantity, 0) ELSE 0 END, 3) AS unbooked_quantity,
CASE WHEN COALESCE(da.stock_positions, 0) > 0 AND COALESCE(da.booked_positions, 0) >= COALESCE(da.stock_positions, 0) THEN 1 ELSE 0 END AS is_booked
FROM manifest m
LEFT JOIN entity e ON e.id = m.entity_id
LEFT JOIN delivery_agg da ON da.manifest_id = m.id
WHERE m.company_id = @companyId AND m.[type] = 'delivery';
`;
await runner.query(sqlRebuildMssql, { companyId });
}
}

View File

@@ -1,3 +1,4 @@
import { syncDeliverySummary } from './syncSummary.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js';
@@ -141,6 +142,7 @@ export default function update(provider) {
);
}
await syncDeliverySummary(runner, { companyId, manifestId: id });
return loadDelivery(provider, tx, companyId, id);
};

View File

@@ -1,8 +1,14 @@
import { syncDeliverySummary } from '../delivery/syncSummary.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js';
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 { recordDeliveryHistory } from '../articlehistory/record.js';
import {
recordAcceptanceHistory,
recordShipmentHistory,
recordWarehouseHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export const INIT_HASH = 'TODO_TSE_BASED_INITHASH';
@@ -50,7 +56,11 @@ export function computePrevHash(row) {
}
/**
* Appends a new append-only entry to the ledger table.
* Appends new append-only entry/entries to the ledger table according to manifest rules:
* - quote / order: no bookings possible (throws error)
* - delivery: acceptance entry (warehouse_id=null) + warehouse entry (if tracked) in one transaction
* - shipment: shipment entry (warehouse_id=null, negative amount) + warehouse entry (if tracked) in one transaction
* - adjustment: single warehouse inventory movement
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Ledger entry payload.
@@ -60,17 +70,7 @@ export function computePrevHash(row) {
* @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',
* });
* @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 primary ledger row.
*/
export default function add(provider) {
const access = resolveSelectedCompanyAccess(provider);
@@ -120,7 +120,7 @@ export default function add(provider) {
const transactionText = String(rawTransactionText);
const doAdd = async (runner) => {
const { companyId, companyuserId, rootCategoryId } = await access(googleAuthId, runner);
const { companyId, companyuserId } = await access(googleAuthId, runner);
const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, {
manifestarticleId,
@@ -132,99 +132,231 @@ export default function add(provider) {
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})`);
}
}
const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
if (manifestType === 'quote') {
throw new Error('Cannot book ledger entries for quote');
}
if (manifestType === 'order') {
throw new Error('Cannot book ledger entries for order');
}
let warehouseName = null;
if (warehouseId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId,
companyId,
const insertLedgerRow = async ({ whId, type, rowAmount, memo }) => {
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: whId,
transactionType: type,
amount: rowAmount,
transactionText: memo,
hashVersion: 1,
prevHash,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
const row = inserted.rows[0];
if (!row) {
throw new Error('Failed to insert ledger entry');
}
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');
}
return row;
};
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
let isTracked = true;
let articleMatches = [];
if (sku) {
const articleMatches = await runner.query(queries.FIND_ARTICLES_FOR_HISTORY, {
sku,
companyId,
const trackRes = await runner.query(
`SELECT a.id, 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 },
);
articleMatches = trackRes.rows ?? [];
const disabled = articleMatches.find((r) => Number(r.track_stock ?? 1) === 0);
if (disabled) {
isTracked = false;
}
}
if (manifestType === 'delivery') {
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Number(bookedCheck.rows?.[0]?.bookedAmount || 0);
const lineAmount = Number(manifestArticle.lineAmount || 0);
if (lineAmount > 0 && bookedAmount >= lineAmount) {
throw new Error('Delivery line already booked on ledger');
}
const deliveryNumber = manifestArticle.manifestNumber;
const text = transactionText || (deliveryNumber ? `Lieferung ${deliveryNumber}` : 'Lieferung');
// Entry 1: ACCEPTANCE (warehouse_id = null)
const row1 = await insertLedgerRow({
whId: null,
type: 'ACCEPTANCE',
rowAmount: Math.abs(amount),
memo: text,
});
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'));
// Record history for acceptance
for (const match of articleMatches) {
await recordAcceptanceHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: deliveryNumber,
supplierName: manifestArticle.supplierName,
net: manifestArticle.net,
rebate: manifestArticle.rebate,
taxrate: manifestArticle.taxrate,
amount: Math.abs(amount),
transactionText: text,
});
}
if (!isShipping) {
for (const match of articleMatches.rows) {
await recordDeliveryHistory(runner, {
articleId: match.articleId,
// Entry 2: WAREHOUSE (only if tracked)
if (isTracked) {
let effectiveWhId = warehouseId;
let warehouseName = null;
if (effectiveWhId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId: effectiveWhId,
companyId,
});
if (!whCheck.rows[0]) throw new Error('Warehouse not found');
warehouseName = whCheck.rows[0].name;
} else {
const defWh = await runner.query(queries.GET_DEFAULT_WAREHOUSE, { companyId });
if (!defWh.rows[0]) throw new Error('Warehouse not found');
effectiveWhId = defWh.rows[0].id;
warehouseName = defWh.rows[0].name;
}
await insertLedgerRow({
whId: effectiveWhId,
type: 'WAREHOUSE',
rowAmount: Math.abs(amount),
memo: text,
});
for (const match of articleMatches) {
await recordWarehouseHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: manifestArticle.manifestNumber,
supplierName: manifestArticle.supplierName,
manifestNumber: deliveryNumber,
manifestType: 'delivery',
warehouseName,
net: manifestArticle.net,
rebate: manifestArticle.rebate,
taxrate: manifestArticle.taxrate,
amount,
transactionText,
isDelivery,
amount: Math.abs(amount),
transactionText: text,
});
}
}
if (manifestArticle.manifestId) {
await syncDeliverySummary(runner, { companyId, manifestId: manifestArticle.manifestId });
}
return shape(row1);
}
if (manifestType === 'shipment') {
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Math.abs(Number(bookedCheck.rows?.[0]?.bookedAmount || 0));
const lineAmount = Number(manifestArticle.lineAmount || 0);
if (lineAmount > 0 && bookedAmount >= lineAmount) {
throw new Error('Shipment line already booked on ledger');
}
const shipmentNumber = manifestArticle.manifestNumber;
const text = transactionText || (shipmentNumber ? `Lieferschein ${shipmentNumber}` : 'Lieferschein');
const outboundAmount = -Math.abs(amount);
// Entry 1: SHIPMENT (warehouse_id = null)
const row1 = await insertLedgerRow({
whId: null,
type: 'SHIPMENT',
rowAmount: outboundAmount,
memo: text,
});
for (const match of articleMatches) {
await recordShipmentHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
customerName: manifestArticle.supplierName,
amount: outboundAmount,
transactionText: text,
});
}
// Entry 2: WAREHOUSE (only if tracked)
if (isTracked) {
let effectiveWhId = warehouseId;
let warehouseName = null;
if (effectiveWhId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId: effectiveWhId,
companyId,
});
if (!whCheck.rows[0]) throw new Error('Warehouse not found');
warehouseName = whCheck.rows[0].name;
} else {
const defWh = await runner.query(queries.GET_DEFAULT_WAREHOUSE, { companyId });
if (!defWh.rows[0]) throw new Error('Warehouse not found');
effectiveWhId = defWh.rows[0].id;
warehouseName = defWh.rows[0].name;
}
await insertLedgerRow({
whId: effectiveWhId,
type: 'WAREHOUSE',
rowAmount: outboundAmount,
memo: text,
});
for (const match of articleMatches) {
await recordWarehouseHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
manifestType: 'shipment',
warehouseName,
amount: outboundAmount,
transactionText: text,
});
}
}
if (manifestArticle.manifestId) {
await syncShipmentSummary(runner, { companyId, manifestId: manifestArticle.manifestId });
}
return shape(row1);
}
// Fallback for other manifest types (e.g. adjustment)
if (!isTracked) {
throw new Error(`Stock tracking is disabled for article (${sku})`);
}
if (warehouseId == null) {
throw new Error('Warehouse not found');
}
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
const row = await insertLedgerRow({
whId: warehouseId,
type: transactionType,
rowAmount: amount,
memo: transactionText,
});
return shape(row);
};

View File

@@ -160,7 +160,7 @@ export default function create(provider) {
let amount;
if (line.amount !== undefined && line.amount !== null) {
amount = Number(line.amount);
if (!Number.isFinite(amount) || amount <= 0) {
if (!Number.isFinite(amount) || amount === 0) {
throw new Error('Invalid lines');
}
} else {
@@ -172,7 +172,7 @@ export default function create(provider) {
}
const currentProposed = proposedBySku.get(sku) || 0;
if (alreadyShipped + currentProposed + amount > ordered) {
if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
}
proposedBySku.set(sku, currentProposed + amount);

View File

@@ -1,7 +1,7 @@
import { queriesFor, requireId, requireText } from '../logistics/helpers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import nextNumber from '../numbers/nextNumber.js';
import ledgerAdd from '../ledger/add.js';
import stockShip from '../stock/ship.js';
import makeRunner from '../runner.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js';
@@ -20,7 +20,7 @@ function shape(row) {
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const allocateNumber = nextNumber(provider);
const addLedger = ledgerAdd(provider);
const ship = stockShip(provider);
const q = queriesFor(provider);
/**
@@ -104,76 +104,43 @@ export default function create(provider) {
});
const lines = linesRes.rows ?? [];
// Check which lines need warehouse stock deduction vs untracked history
const trackedLines = [];
const untrackedLines = [];
const manifestNumber = manifest.number || String(manifestId);
let warehouseId = null;
if (input.warehouseId != null) {
const requestedWhId = requireId(input.warehouseId, 'Warehouse');
const whCheck = await runner.query(q.CHECK_WAREHOUSE, {
warehouseId: requestedWhId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = requestedWhId;
} else {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId,
});
if (defaultWh.rows[0]) {
warehouseId = defaultWh.rows[0].id;
}
}
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount <= 0) {
if (!sku || !Number.isFinite(amount) || amount === 0) {
continue;
}
const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
companyId,
sku,
});
const isUntracked = (trackRes.rows ?? []).some(
(r) => Number(r.track_stock ?? 1) === 0
await ship(
googleAuthId,
{
manifestarticleId: line.id,
warehouseId,
},
innerTx,
);
if (isUntracked) {
untrackedLines.push({ ...line, amount });
} else if ((trackRes.rows ?? []).length > 0) {
trackedLines.push({ ...line, amount });
}
}
const manifestNumber = manifest.number || String(manifestId);
let customerName = null;
if (manifest.entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
entityId: manifest.entityId,
});
customerName = entRes.rows[0]?.name ?? null;
}
// Book warehouse ledger movements for tracked lines
if (trackedLines.length > 0) {
let warehouseId = null;
if (input.warehouseId != null) {
const requestedWhId = requireId(input.warehouseId, 'Warehouse');
const whCheck = await runner.query(q.CHECK_WAREHOUSE, {
warehouseId: requestedWhId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = requestedWhId;
} else {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId,
});
if (!defaultWh.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = defaultWh.rows[0].id;
}
for (const line of trackedLines) {
await addLedger(
googleAuthId,
{
manifestarticle_id: line.id,
warehouse_id: warehouseId,
transaction_type: 'SHIPPING',
amount: -line.amount,
transaction_text: `Versand Lieferschein ${manifestNumber}`,
},
innerTx,
);
}
}
}

View File

@@ -1,6 +1,6 @@
import { queriesFor, requireId, requireText } from '../logistics/helpers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import ledgerAdd from '../ledger/add.js';
import stockShip from '../stock/ship.js';
import makeRunner from '../runner.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js';
@@ -25,7 +25,7 @@ function shape(row) {
*/
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const addLedger = ledgerAdd(provider);
const ship = stockShip(provider);
const q = queriesFor(provider);
/**
@@ -109,72 +109,41 @@ export default function update(provider) {
manifestId,
});
const lines = linesRes.rows ?? [];
const trackedLines = [];
const untrackedLines = [];
const manifestNumber = manifest.number || String(manifestId);
let warehouseId = null;
if (patch.warehouseId != null) {
const requestedWhId = requireId(patch.warehouseId, 'Warehouse');
const whCheck = await runner.query(q.CHECK_WAREHOUSE, {
warehouseId: requestedWhId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = requestedWhId;
} else {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId,
});
if (defaultWh.rows[0]) {
warehouseId = defaultWh.rows[0].id;
}
}
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount <= 0) continue;
if (!sku || !Number.isFinite(amount) || amount === 0) continue;
const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
companyId,
sku,
});
const isUntracked = (trackRes.rows ?? []).some(
(r) => Number(r.track_stock ?? 1) === 0
await ship(
googleAuthId,
{
manifestarticleId: line.id,
warehouseId,
},
innerTx,
);
if (isUntracked) {
untrackedLines.push({ ...line, amount });
} else if ((trackRes.rows ?? []).length > 0) {
trackedLines.push({ ...line, amount });
}
}
const manifestNumber = manifest.number || String(manifestId);
let customerName = null;
if (manifest.entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
entityId: manifest.entityId,
});
customerName = entRes.rows[0]?.name ?? null;
}
if (trackedLines.length > 0) {
let warehouseId = null;
if (patch.warehouseId != null) {
const requestedWhId = requireId(patch.warehouseId, 'Warehouse');
const whCheck = await runner.query(q.CHECK_WAREHOUSE, {
warehouseId: requestedWhId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = requestedWhId;
} else {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId,
});
if (!defaultWh.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = defaultWh.rows[0].id;
}
for (const line of trackedLines) {
await addLedger(
googleAuthId,
{
manifestarticle_id: line.id,
warehouse_id: warehouseId,
transaction_type: 'SHIPPING',
amount: -line.amount,
transaction_text: `Versand Lieferschein ${manifestNumber}`,
},
innerTx,
);
}
}
}
}

229
src/store/stock/accept.js Normal file
View File

@@ -0,0 +1,229 @@
import { syncDeliverySummary } from '../delivery/syncSummary.js';
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 { recordAcceptanceHistory } from '../articlehistory/record.js';
import { computePrevHash, shape } from '../ledger/add.js';
import stockAdjust from './adjust.js';
import makeRunner from '../runner.js';
/**
* Accepts a delivery manifest item (or multiple delivery items).
*
* Requirements:
* - Every accepted delivery manifest item receives a document ledger entry:
* - If amount >= 0: transaction_type = 'ACCEPTANCE', amount = +amount, warehouse_id = null.
* - If amount < 0: transaction_type = 'RETURN', amount = amount (negative), warehouse_id = null.
* - Always records article history with delivery number, supplier, EK brutto, and quantity.
* - If the connected article has stock tracking (track_stock == 1):
* Calls adjust with the item's amount to adjust physical inventory in the target warehouse.
* - If the connected article does not have stock tracking:
* Only document acceptance is booked; no warehouse adjust is performed.
* - Double acceptance of an already accepted line is strictly prevented.
*
* @param {object} provider - The database provider.
* @returns {Function} Async function accepting delivery items.
*/
export default function accept(provider) {
const access = resolveSelectedCompanyAccess(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId, input = {}, tx) => {
let items = [];
if (Array.isArray(input.items)) {
items = input.items;
} else if (input.manifestarticleId != null || input.manifestarticle_id != null) {
items = [
{
manifestarticleId: input.manifestarticleId ?? input.manifestarticle_id,
warehouseId: input.warehouseId ?? input.warehouse_id,
text: input.text ?? input.transactionText ?? input.transaction_text,
},
];
} else if (input.deliveryId != null || input.delivery_id != null || input.id != null) {
const deliveryId = Number(input.deliveryId ?? input.delivery_id ?? input.id);
if (!Number.isInteger(deliveryId) || deliveryId <= 0) {
throw new Error('Invalid delivery ID');
}
const warehouseId = input.warehouseId ?? input.warehouse_id;
// We will resolve all lines for this delivery within the transaction
items = [{ deliveryId, warehouseId }];
} else {
throw new Error('manifestarticleId or items required');
}
if (items.length === 0) {
throw new Error('No items to accept');
}
const run = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
// If a deliveryId was provided, expand to all lines of that delivery
let resolvedItems = [];
for (const item of items) {
if (item.deliveryId) {
const linesRes = await runner.query(
`SELECT ma.id FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.id = @deliveryId AND m.company_id = @companyId AND m.type = 'delivery'
ORDER BY ma.id ASC`,
{ deliveryId: item.deliveryId, companyId },
);
if (linesRes.rows.length === 0) {
throw new Error('Delivery has no lines or delivery not found');
}
for (const line of linesRes.rows) {
resolvedItems.push({
manifestarticleId: line.id,
warehouseId: item.warehouseId,
text: item.text,
});
}
} else {
resolvedItems.push(item);
}
}
const results = [];
const touchedManifestIds = new Set();
for (const item of resolvedItems) {
const manifestarticleId = Number(item.manifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid manifest article id');
}
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');
}
const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
if (manifestType !== 'delivery') {
throw new Error('Manifest article does not belong to a delivery');
}
// Prevent double booking on ledger
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Number(bookedCheck.rows?.[0]?.bookedAmount || 0);
if (bookedAmount !== 0) {
throw new Error('Delivery line already accepted on ledger');
}
const lineAmount = Number(manifestArticle.lineAmount || 0);
const deliveryNumber = manifestArticle.manifestNumber;
const isReturn = lineAmount < 0;
const docType = isReturn ? 'RETURN' : 'ACCEPTANCE';
const docAmount = lineAmount; // negative if return, positive if acceptance
const defaultDocText = isReturn
? (deliveryNumber ? `Lieferantenrücksendung ${deliveryNumber}` : 'Lieferantenrücksendung')
: (deliveryNumber ? `Lieferung ${deliveryNumber}` : 'Lieferung');
const docText = item.text?.trim?.() || defaultDocText;
// Insert document acceptance/return entry (warehouse_id = null)
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: null,
transactionType: docType,
amount: docAmount,
transactionText: docText,
hashVersion: 1,
prevHash,
});
const docRow = inserted.rows[0];
if (!docRow) {
throw new Error('Failed to insert ledger entry');
}
// Resolve connected articles by SKU
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
let articleMatches = [];
let isTracked = false;
if (sku) {
const trackRes = await runner.query(
`SELECT a.id, 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 },
);
articleMatches = trackRes.rows ?? [];
const trackedMatch = articleMatches.find((r) => Number(r.track_stock ?? 0) === 1);
if (trackedMatch) {
isTracked = true;
}
}
// Write article history for acceptance / return
for (const match of articleMatches) {
await recordAcceptanceHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: deliveryNumber,
supplierName: manifestArticle.supplierName,
net: manifestArticle.net,
rebate: manifestArticle.rebate,
taxrate: manifestArticle.taxrate,
amount: docAmount,
transactionText: docText,
});
}
// "accept also does adjust when the deliverymanifestitem'S connected article has stock tracking."
let adjustResult = null;
if (isTracked) {
adjustResult = await adjust(
googleAuthId,
{
manifestarticleId,
warehouseId: item.warehouseId,
amount: docAmount,
text: docText,
},
activeTx,
);
}
if (manifestArticle.manifestId) {
touchedManifestIds.add(manifestArticle.manifestId);
}
results.push({
manifestarticle_id: manifestarticleId,
document_ledger: shape(docRow),
adjust_ledger: adjustResult?.ledger ?? null,
is_tracked: isTracked,
});
}
// Sync deliverysummary for all affected manifests
for (const manifestId of touchedManifestIds) {
await syncDeliverySummary(runner, { companyId, manifestId });
}
return Array.isArray(input.items) || input.deliveryId != null || input.delivery_id != null || input.id != null
? { accepted: results }
: results[0];
};
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));
};
}

View File

@@ -1,8 +1,11 @@
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 } from '../articlehistory/record.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';
@@ -24,6 +27,7 @@ export default function adjust(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",
@@ -42,6 +46,139 @@ export default function adjust(provider) {
* @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
: [

229
src/store/stock/ship.js Normal file
View File

@@ -0,0 +1,229 @@
import { syncShipmentSummary } from '../shipment/syncSummary.js';
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 { recordShipmentHistory } from '../articlehistory/record.js';
import { computePrevHash, shape } from '../ledger/add.js';
import stockAdjust from './adjust.js';
import makeRunner from '../runner.js';
/**
* Ships a shipment manifest item (or multiple shipment items).
*
* Requirements:
* - Every shipped shipment manifest item receives a document ledger entry:
* - If amount >= 0: transaction_type = 'SHIPMENT', amount = -amount, warehouse_id = null.
* - If amount < 0: transaction_type = 'RETOURE', amount = +|amount|, warehouse_id = null.
* - Always records article history with shipment number, customer, and quantity.
* - If the connected article has stock tracking (track_stock == 1):
* Calls adjust with:
* - If normal shipment (amount >= 0): -amount (outgoing stock from warehouse).
* - If retoure (amount < 0): +|amount| (incoming stock into warehouse).
* - If the connected article does not have stock tracking:
* Only document shipment is booked; no warehouse adjust is performed.
* - Double shipping of an already shipped line is strictly prevented.
*
* @param {object} provider - The database provider.
* @returns {Function} Async function shipping shipment items.
*/
export default function ship(provider) {
const access = resolveSelectedCompanyAccess(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId, input = {}, tx) => {
let items = [];
if (Array.isArray(input.items)) {
items = input.items;
} else if (input.manifestarticleId != null || input.manifestarticle_id != null) {
items = [
{
manifestarticleId: input.manifestarticleId ?? input.manifestarticle_id,
warehouseId: input.warehouseId ?? input.warehouse_id,
text: input.text ?? input.transactionText ?? input.transaction_text,
},
];
} else if (input.shipmentId != null || input.shipment_id != null || input.id != null) {
const shipmentId = Number(input.shipmentId ?? input.shipment_id ?? input.id);
if (!Number.isInteger(shipmentId) || shipmentId <= 0) {
throw new Error('Invalid shipment ID');
}
const warehouseId = input.warehouseId ?? input.warehouse_id;
items = [{ shipmentId, warehouseId }];
} else {
throw new Error('manifestarticleId or items required');
}
if (items.length === 0) {
throw new Error('No items to ship');
}
const run = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
// If a shipmentId was provided, expand to all lines of that shipment
let resolvedItems = [];
for (const item of items) {
if (item.shipmentId) {
const linesRes = await runner.query(
`SELECT ma.id FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.id = @shipmentId AND m.company_id = @companyId AND m.type = 'shipment'
ORDER BY ma.id ASC`,
{ shipmentId: item.shipmentId, companyId },
);
if (linesRes.rows.length === 0) {
throw new Error('Shipment has no lines or shipment not found');
}
for (const line of linesRes.rows) {
resolvedItems.push({
manifestarticleId: line.id,
warehouseId: item.warehouseId,
text: item.text,
});
}
} else {
resolvedItems.push(item);
}
}
const results = [];
const touchedManifestIds = new Set();
for (const item of resolvedItems) {
const manifestarticleId = Number(item.manifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid manifest article id');
}
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');
}
const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
if (manifestType !== 'shipment') {
throw new Error('Manifest article does not belong to a shipment');
}
// Prevent double booking on ledger
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Number(bookedCheck.rows?.[0]?.bookedAmount || 0);
if (bookedAmount !== 0) {
throw new Error('Shipment line already shipped on ledger');
}
const lineAmount = Number(manifestArticle.lineAmount || 0);
const shipmentNumber = manifestArticle.manifestNumber;
const isRetoure = lineAmount < 0;
const docType = isRetoure ? 'RETOURE' : 'SHIPMENT';
// Normal shipment: negative ledger amount (-Math.abs(lineAmount)).
// Retoure (customer return): positive ledger amount (+Math.abs(lineAmount)).
const docAmount = isRetoure ? Math.abs(lineAmount) : -Math.abs(lineAmount);
const defaultDocText = isRetoure
? (shipmentNumber ? `Retoure Lieferschein ${shipmentNumber}` : 'Retoure Lieferschein')
: (shipmentNumber ? `Lieferschein ${shipmentNumber}` : 'Lieferschein');
const docText = item.text?.trim?.() || defaultDocText;
// Insert document shipment/retoure entry (warehouse_id = null)
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: null,
transactionType: docType,
amount: docAmount,
transactionText: docText,
hashVersion: 1,
prevHash,
});
const docRow = inserted.rows[0];
if (!docRow) {
throw new Error('Failed to insert ledger entry');
}
// Resolve connected articles by SKU
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
let articleMatches = [];
let isTracked = false;
if (sku) {
const trackRes = await runner.query(
`SELECT a.id, 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 },
);
articleMatches = trackRes.rows ?? [];
const trackedMatch = articleMatches.find((r) => Number(r.track_stock ?? 0) === 1);
if (trackedMatch) {
isTracked = true;
}
}
// Write article history for shipment / retoure
for (const match of articleMatches) {
await recordShipmentHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
customerName: manifestArticle.supplierName, // in CHECK_MANIFESTARTICLE_ACCESS, e.name is aliased as supplierName / entity name
amount: docAmount,
transactionText: docText,
});
}
// "deliver also does adjust when the shipmentmanifestitem is connected to an article, and this article has stock tracking."
let adjustResult = null;
if (isTracked) {
adjustResult = await adjust(
googleAuthId,
{
manifestarticleId,
warehouseId: item.warehouseId,
amount: docAmount,
text: docText,
},
activeTx,
);
}
if (manifestArticle.manifestId) {
touchedManifestIds.add(manifestArticle.manifestId);
}
results.push({
manifestarticle_id: manifestarticleId,
document_ledger: shape(docRow),
adjust_ledger: adjustResult?.ledger ?? null,
is_tracked: isTracked,
});
}
// Sync shipmentsummary for all affected manifests
for (const manifestId of touchedManifestIds) {
await syncShipmentSummary(runner, { companyId, manifestId });
}
return Array.isArray(input.items) || input.shipmentId != null || input.shipment_id != null || input.id != null
? { shipped: results }
: results[0];
};
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));
};
}