u
This commit is contained in:
@@ -90,6 +90,8 @@ import deliveryGetById from './store/delivery/getById.js';
|
||||
import deliveryCreate from './store/delivery/create.js';
|
||||
import deliveryUpdate from './store/delivery/update.js';
|
||||
import deliveryDelete from './store/delivery/delete.js';
|
||||
import deliveryBook from './store/delivery/book.js';
|
||||
import deliveryRebuildSummaries from './store/delivery/rebuildSummaries.js';
|
||||
import quoteList from './store/quote/list.js';
|
||||
import quoteGetById from './store/quote/getById.js';
|
||||
import quoteCreate from './store/quote/create.js';
|
||||
@@ -208,6 +210,8 @@ import labeltemplateList from './store/labeltemplate/list.js';
|
||||
import labeltemplateUpdate from './store/labeltemplate/update.js';
|
||||
import labeltemplateDelete from './store/labeltemplate/delete.js';
|
||||
import stockAdjust from './store/stock/adjust.js';
|
||||
import stockAccept from './store/stock/accept.js';
|
||||
import stockShip from './store/stock/ship.js';
|
||||
import propertynameList from './store/propertyname/list.js';
|
||||
import propertynameCreate from './store/propertyname/create.js';
|
||||
import propertynameUpdate from './store/propertyname/update.js';
|
||||
@@ -422,6 +426,8 @@ export default class LibDashStore {
|
||||
create: deliveryCreate(provider),
|
||||
update: deliveryUpdate(provider),
|
||||
delete: deliveryDelete(provider),
|
||||
book: deliveryBook(provider),
|
||||
rebuildSummaries: deliveryRebuildSummaries(provider),
|
||||
};
|
||||
|
||||
this.quote = {
|
||||
@@ -588,6 +594,8 @@ export default class LibDashStore {
|
||||
|
||||
this.stock = {
|
||||
adjust: stockAdjust(provider),
|
||||
accept: stockAccept(provider),
|
||||
ship: stockShip(provider),
|
||||
};
|
||||
this.adjustment = this.stock;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Drop order = reverse of schema create dependency order. */
|
||||
export const TABLES = [
|
||||
|
||||
'deliverysummary',
|
||||
'shipmentsummary',
|
||||
'quotesummary',
|
||||
'ordersummary',
|
||||
|
||||
@@ -658,3 +658,21 @@ SELECT TOP 1 id
|
||||
FROM warehouse
|
||||
WHERE company_id = @companyId;
|
||||
`;
|
||||
|
||||
export const CHECK_ARTICLE_WAREHOUSE_STOCKS = `
|
||||
SELECT w.id AS warehouseId, w.name AS warehouseName, COALESCE(stock_calc.stock, 0) AS stock
|
||||
FROM warehouse w
|
||||
LEFT JOIN (
|
||||
SELECT l.warehouse_id, SUM(l.amount) AS stock
|
||||
FROM ledger l
|
||||
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
|
||||
INNER JOIN manifest m ON m.id = ma.manifest_id
|
||||
WHERE m.company_id = @companyId
|
||||
AND (
|
||||
ma.sku = @sku
|
||||
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
|
||||
)
|
||||
GROUP BY l.warehouse_id
|
||||
) stock_calc ON stock_calc.warehouse_id = w.id
|
||||
WHERE w.company_id = @companyId;
|
||||
`;
|
||||
|
||||
@@ -23,6 +23,20 @@ FROM warehouse
|
||||
WHERE id = @warehouseId AND company_id = @companyId;
|
||||
`;
|
||||
|
||||
export const GET_DEFAULT_WAREHOUSE = `
|
||||
SELECT TOP 1 id, name
|
||||
FROM warehouse
|
||||
WHERE company_id = @companyId
|
||||
ORDER BY sort ASC, name ASC, id ASC;
|
||||
`;
|
||||
|
||||
export const CHECK_MANIFESTARTICLE_BOOKED = `
|
||||
SELECT COALESCE(SUM(amount), 0) AS bookedAmount
|
||||
FROM ledger
|
||||
WHERE manifestarticle_id = @manifestarticleId
|
||||
AND warehouse_id IS NULL;
|
||||
`;
|
||||
|
||||
export const FIND_ARTICLES_FOR_HISTORY = `
|
||||
SELECT DISTINCT asup.article_id AS articleId
|
||||
FROM articlesupplier asup
|
||||
|
||||
@@ -1278,6 +1278,49 @@ BEGIN
|
||||
END
|
||||
`;
|
||||
|
||||
export const INIT_DELIVERYSUMMARY = `
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'deliverysummary')
|
||||
BEGIN
|
||||
CREATE TABLE deliverysummary (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
company_id INT NOT NULL,
|
||||
manifest_id INT NOT NULL CONSTRAINT UQ_deliverysummary_manifest UNIQUE,
|
||||
delivery_number NVARCHAR(100) NOT NULL,
|
||||
external_number NVARCHAR(100) NULL,
|
||||
delivery_date DATETIME2 NOT NULL,
|
||||
created_at DATETIME2 NOT NULL CONSTRAINT DF_deliverysummary_created_at DEFAULT SYSUTCDATETIME(),
|
||||
supplier_id INT NULL,
|
||||
supplier_name NVARCHAR(255) NULL,
|
||||
positions INT NOT NULL CONSTRAINT DF_deliverysummary_positions DEFAULT 0,
|
||||
gross_total FLOAT NOT NULL CONSTRAINT DF_deliverysummary_gross_total DEFAULT 0,
|
||||
net_total FLOAT NOT NULL CONSTRAINT DF_deliverysummary_net_total DEFAULT 0,
|
||||
total_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_total_quantity DEFAULT 0,
|
||||
stock_positions INT NOT NULL CONSTRAINT DF_deliverysummary_stock_positions DEFAULT 0,
|
||||
booked_positions INT NOT NULL CONSTRAINT DF_deliverysummary_booked_positions DEFAULT 0,
|
||||
unbooked_positions INT NOT NULL CONSTRAINT DF_deliverysummary_unbooked_positions DEFAULT 0,
|
||||
stock_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_stock_quantity DEFAULT 0,
|
||||
booked_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_booked_quantity DEFAULT 0,
|
||||
unbooked_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_unbooked_quantity DEFAULT 0,
|
||||
is_booked TINYINT NOT NULL CONSTRAINT DF_deliverysummary_is_booked DEFAULT 0,
|
||||
CONSTRAINT FK_deliverysummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_deliverysummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_deliverysummary_supplier FOREIGN KEY (supplier_id) REFERENCES entity(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IX_deliverysummary_company_date
|
||||
ON deliverysummary (company_id, delivery_date DESC, manifest_id DESC);
|
||||
|
||||
CREATE INDEX IX_deliverysummary_company_number
|
||||
ON deliverysummary (company_id, delivery_number);
|
||||
|
||||
CREATE INDEX IX_deliverysummary_company_booked
|
||||
ON deliverysummary (company_id, is_booked);
|
||||
|
||||
CREATE INDEX IX_deliverysummary_company_supplier
|
||||
ON deliverysummary (company_id, supplier_id);
|
||||
END
|
||||
`;
|
||||
|
||||
export const INIT_MCP_CLIENT = `
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
|
||||
BEGIN
|
||||
@@ -1403,6 +1446,7 @@ export const SCHEMA_SCRIPTS = [
|
||||
INIT_ORDERSUMMARY,
|
||||
INIT_QUOTESUMMARY,
|
||||
INIT_SHIPMENTSUMMARY,
|
||||
INIT_DELIVERYSUMMARY,
|
||||
INIT_MCP_CLIENT,
|
||||
INIT_MCP_AUTH_CODE,
|
||||
INIT_MCP_TOKEN,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { translateQueries } from './translate.js';
|
||||
|
||||
const queries = translateQueries(source, 'article');
|
||||
export const {
|
||||
ADD_CATEGORY, ADD_EAN, ARTICLE_LIST_COLUMNS, CHECK_HAS_WAREHOUSE, CREATE, CREATE_CHILD,
|
||||
ADD_CATEGORY, ADD_EAN, ARTICLE_LIST_COLUMNS, CHECK_ARTICLE_WAREHOUSE_STOCKS, CHECK_HAS_WAREHOUSE, CREATE, CREATE_CHILD,
|
||||
DELETE_CATEGORIES_BY_ARTICLE, DELETE_EANS_BY_ARTICLE, EAN_EXISTS, EAN_EXISTS_FOR_OTHER_ARTICLE,
|
||||
GET_BRAND_BY_COMPANY_AND_ID,
|
||||
GET_CATEGORY_ID_BY_SKU, GET_CATEGORY_TREE_FLAT, GET_ID_BY_SKU,
|
||||
|
||||
@@ -23,6 +23,21 @@ FROM warehouse
|
||||
WHERE id = @warehouseId AND company_id = @companyId;
|
||||
`;
|
||||
|
||||
export const GET_DEFAULT_WAREHOUSE = `
|
||||
SELECT id, name
|
||||
FROM warehouse
|
||||
WHERE company_id = @companyId
|
||||
ORDER BY sort ASC, name ASC, id ASC
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
export const CHECK_MANIFESTARTICLE_BOOKED = `
|
||||
SELECT COALESCE(SUM(amount), 0) AS bookedAmount
|
||||
FROM ledger
|
||||
WHERE manifestarticle_id = @manifestarticleId
|
||||
AND warehouse_id IS NULL;
|
||||
`;
|
||||
|
||||
export const FIND_ARTICLES_FOR_HISTORY = `
|
||||
SELECT DISTINCT asup.article_id AS articleId
|
||||
FROM articlesupplier asup
|
||||
|
||||
@@ -931,6 +931,42 @@ CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_order
|
||||
ON shipmentsummary (company_id, order_id);
|
||||
`;
|
||||
|
||||
export const INIT_DELIVERYSUMMARY = `
|
||||
CREATE TABLE IF NOT EXISTS deliverysummary (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL,
|
||||
manifest_id INTEGER NOT NULL UNIQUE,
|
||||
delivery_number TEXT NOT NULL,
|
||||
external_number TEXT,
|
||||
delivery_date TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
|
||||
supplier_id INTEGER NULL,
|
||||
supplier_name TEXT NULL,
|
||||
positions INTEGER NOT NULL DEFAULT 0,
|
||||
gross_total REAL NOT NULL DEFAULT 0,
|
||||
net_total REAL NOT NULL DEFAULT 0,
|
||||
total_quantity REAL NOT NULL DEFAULT 0,
|
||||
stock_positions INTEGER NOT NULL DEFAULT 0,
|
||||
booked_positions INTEGER NOT NULL DEFAULT 0,
|
||||
unbooked_positions INTEGER NOT NULL DEFAULT 0,
|
||||
stock_quantity REAL NOT NULL DEFAULT 0,
|
||||
booked_quantity REAL NOT NULL DEFAULT 0,
|
||||
unbooked_quantity REAL NOT NULL DEFAULT 0,
|
||||
is_booked INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
|
||||
FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES entity(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_date
|
||||
ON deliverysummary (company_id, delivery_date DESC, manifest_id DESC);
|
||||
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_number
|
||||
ON deliverysummary (company_id, delivery_number);
|
||||
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_booked
|
||||
ON deliverysummary (company_id, is_booked);
|
||||
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_supplier
|
||||
ON deliverysummary (company_id, supplier_id);
|
||||
`;
|
||||
|
||||
export const INIT_MCP_CLIENT = `
|
||||
CREATE TABLE IF NOT EXISTS mcp_client (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1042,6 +1078,7 @@ export const SCHEMA_SCRIPTS = [
|
||||
INIT_ORDERSUMMARY,
|
||||
INIT_QUOTESUMMARY,
|
||||
INIT_SHIPMENTSUMMARY,
|
||||
INIT_DELIVERYSUMMARY,
|
||||
INIT_MCP_CLIENT,
|
||||
INIT_MCP_AUTH_CODE,
|
||||
INIT_MCP_TOKEN,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
src/store/delivery/book.js
Normal file
48
src/store/delivery/book.js
Normal 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));
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
19
src/store/delivery/rebuildSummaries.js
Normal file
19
src/store/delivery/rebuildSummaries.js
Normal 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 };
|
||||
};
|
||||
}
|
||||
445
src/store/delivery/syncSummary.js
Normal file
445
src/store/delivery/syncSummary.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
229
src/store/stock/accept.js
Normal 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));
|
||||
};
|
||||
}
|
||||
@@ -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
229
src/store/stock/ship.js
Normal 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));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user