This commit is contained in:
seb
2026-10-20 05:33:11 +02:00
parent 5cb38874ca
commit bcefd81af7
23 changed files with 514 additions and 150 deletions

View File

@@ -664,8 +664,11 @@ SELECT
SELECT SUM(ma_s.amount)
FROM manifest m_s
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = m.company_id
AND m_s.[type] = 'shipment'
AND ISNULL(ss.state, 'new') != 'cancelled'
AND m_s.[number] NOT LIKE '%#cancelled#%'
AND ma_s.sku = a.sku
AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%'
), 0), 3) AS amount
@@ -682,8 +685,11 @@ SELECT
SELECT SUM(ma_s.amount)
FROM manifest m_s
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = m.company_id
AND m_s.[type] = 'shipment'
AND ISNULL(ss.state, 'new') != 'cancelled'
AND m_s.[number] NOT LIKE '%#cancelled#%'
AND ma_s.sku = a.sku
AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%'
), 0)) > 0

View File

@@ -145,3 +145,26 @@ WHERE ma.sku = @sku
AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId)
ORDER BY l.id ASC;
`;
export const GET_ARTICLE_TRACK_STOCK = `
SELECT TOP 1 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)
);
`;
export const GET_ARTICLE_WAREHOUSE_STOCK = `
SELECT COALESCE(SUM(l.amount), 0) 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 l.warehouse_id = @warehouseId
AND m.company_id = @companyId
AND (
ma.sku = @sku
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
);
`;

View File

@@ -22,9 +22,11 @@ WHERE company_id = @companyId AND [type] = 'shipment';
export const LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN = `
SELECT m.id, m.[number], m.[type], m.entity_id AS entityId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt
FROM manifest m
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND m.[number] LIKE @pattern
AND (ss.state IS NULL OR ss.state != 'cancelled')
ORDER BY m.id ASC;
`;
@@ -32,10 +34,12 @@ export const GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN = `
SELECT ma.sku, SUM(ma.amount) AS shippedAmount
FROM manifest m
INNER JOIN manifestarticle ma ON ma.manifest_id = m.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND m.[number] LIKE @pattern
AND (@excludeShipmentId IS NULL OR m.id != @excludeShipmentId)
AND (ss.state IS NULL OR ss.state != 'cancelled')
GROUP BY ma.sku;
`;
@@ -57,6 +61,7 @@ FROM manifest m
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id AND ss.company_id = @companyId
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND (ss.state IS NULL OR ss.state != 'cancelled')
AND (
m.[number] LIKE @pattern
OR ss.order_id = @orderId
@@ -69,3 +74,33 @@ DELETE FROM manifest
WHERE id = @id AND company_id = @companyId AND [type] = 'shipment';
`;
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 GET_ARTICLE_TRACK_STOCK = `
SELECT TOP 1 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)
);
`;
export const GET_ARTICLE_WAREHOUSE_STOCK = `
SELECT COALESCE(SUM(l.amount), 0) 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 l.warehouse_id = @warehouseId
AND m.company_id = @companyId
AND (
ma.sku = @sku
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
);
`;

View File

@@ -307,8 +307,11 @@ SELECT auth.*,
SELECT SUM(ma_s.amount)
FROM manifest m_s
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = m.company_id
AND m_s."type" = 'shipment'
AND COALESCE(ss.state, 'new') != 'cancelled'
AND m_s."number" NOT LIKE '%#cancelled#%'
AND ma_s.sku = a.sku
AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%'
), 0), 3) AS amount
@@ -321,7 +324,18 @@ SELECT auth.*,
AND ma.sku = a.sku
AND COALESCE(os.is_shipped, 0) = 0
GROUP BY m.id, m."number", m."date", os.customer_name, e.first_name, e.name
HAVING amount > 0
HAVING (SUM(ma.amount) - COALESCE((
SELECT SUM(ma_s.amount)
FROM manifest m_s
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = m.company_id
AND m_s."type" = 'shipment'
AND COALESCE(ss.state, 'new') != 'cancelled'
AND m_s."number" NOT LIKE '%#cancelled#%'
AND ma_s.sku = a.sku
AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%'
), 0)) > 0
ORDER BY m."date" ASC, m.id ASC
) uo
))

View File

@@ -147,3 +147,27 @@ WHERE ma.sku = @sku
AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId)
ORDER BY l.id ASC;
`;
export const GET_ARTICLE_TRACK_STOCK = `
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)
)
LIMIT 1;
`;
export const GET_ARTICLE_WAREHOUSE_STOCK = `
SELECT COALESCE(SUM(l.amount), 0) 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 l.warehouse_id = @warehouseId
AND m.company_id = @companyId
AND (
ma.sku = @sku
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
);
`;

View File

@@ -13,5 +13,8 @@ export const {
CHECK_SHIPMENT_PACKAGES_SHIPPED,
CHECK_ORDER_HAS_SHIPMENTS,
DELETE_SHIPMENT,
GET_DEFAULT_WAREHOUSE,
GET_ARTICLE_TRACK_STOCK,
GET_ARTICLE_WAREHOUSE_STOCK,
} = queries;

View File

@@ -315,6 +315,17 @@ export default function add(provider) {
warehouseName = defWh.rows[0].name;
}
const stockRes = await runner.query(queries.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId: effectiveWhId,
companyId,
sku,
articleId: articleMatches[0]?.id ?? null,
});
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
if (currentStock + outboundAmount < 0) {
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(outboundAmount)}`);
}
await insertLedgerRow({
whId: effectiveWhId,
type: 'WAREHOUSE',
@@ -363,6 +374,19 @@ export default function add(provider) {
throw new Error('Warehouse not found');
}
if (amount < 0) {
const stockRes = await runner.query(queries.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId,
companyId,
sku,
articleId: articleMatches[0]?.id ?? null,
});
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
if (currentStock + amount < 0) {
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`);
}
}
const row = await insertLedgerRow({
whId: warehouseId,
type: transactionType,

View File

@@ -5,20 +5,19 @@ export default function createShipment(provider) {
/**
* Creates a new shipment manifest for an order.
* Can contain one, some, or all items from the parent order.
* If lines is omitted or empty, all remaining unshipped items are included.
* Can contain specified items from the parent order.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} orderId - The parent order manifest id.
* @param {object} [input] - Optional overrides.
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - Positions (defaults to all remaining items).
* @param {object} input - Shipment input.
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - Positions to ship.
* @param {string|Date} [input.date] - Optional shipment date.
* @param {string} [input.externalNumber] - Optional external delivery reference.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created shipment.
*
* @example
* const shipment = await store.order.createShipment(googleAuthId, 10);
* const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] });
*/
return async (googleAuthId, orderId, input = {}, tx) => {
return create(googleAuthId, { ...input, orderId }, tx);

View File

@@ -49,8 +49,10 @@ const SQL_SHIPPED_QTY_SQLITE = `
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s."type" = 'shipment'
AND COALESCE(ss.state, 'new') != 'cancelled'
AND m_s."number" >= (@orderNumber || @shipmentPrefix)
AND m_s."number" < (@orderNumber || @shipmentPrefix || char(127))
`;
@@ -59,8 +61,10 @@ const SQL_SHIPPED_QTY_MSSQL = `
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s.[type] = 'shipment'
AND ISNULL(ss.state, 'new') != 'cancelled'
AND m_s.[number] >= (@orderNumber + @shipmentPrefix)
AND m_s.[number] < (@orderNumber + @shipmentPrefix + NCHAR(127))
`;
@@ -255,8 +259,10 @@ export async function rebuildOrderSummaries(runner, companyId) {
SUM(ma_s.amount) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s."type" = 'shipment'
AND COALESCE(ss.state, 'new') != 'cancelled'
AND INSTR(m_s."number", @shipmentPrefix) > 0
GROUP BY SUBSTR(m_s."number", 1, INSTR(m_s."number", @shipmentPrefix) - 1)
)
@@ -327,8 +333,10 @@ export async function rebuildOrderSummaries(runner, companyId) {
SUM(ma_s.amount) AS shipped_quantity
FROM manifest m_s
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
WHERE m_s.company_id = @companyId
AND m_s.[type] = 'shipment'
AND ISNULL(ss.state, 'new') != 'cancelled'
AND CHARINDEX(@shipmentPrefix, m_s.[number]) > 0
GROUP BY SUBSTRING(m_s.[number], 1, CHARINDEX(@shipmentPrefix, m_s.[number]) - 1)
)

View File

@@ -27,34 +27,30 @@ export default function create(provider) {
const q = queriesFor(provider);
/**
* Creates a shipment manifest for an order containing one, some, or all items from the order.
* If input.lines is omitted or empty, all remaining unshipped items from the order are shipped.
* Creates a shipment manifest for an order containing specified items from the order.
* On individual lines, amount is optional and defaults to the remaining unshipped quantity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The shipment input.
* @param {number} input.orderId - The parent order manifest id.
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions.
* @param {string|Date} [input.date] - Optional shipment date (defaults to current date).
* @param {'new'|'delivered'|'returned'} [input.state='new'] - Optional delivery state (defaults to 'new').
* @param {string} [input.externalNumber] - Optional external delivery note number (defaults to order.externalNumber or order.number).
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - The shipment positions (optional).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created shipment manifest.
* @throws {Error} If order not found, invalid lines, all items already shipped, or article ceiling invariant exceeded.
* @throws {Error} If order not found, invalid lines, or article ceiling invariant exceeded.
*
* @example
* const shipment = await store.shipment.create(googleAuthId, {
* orderId: 10,
* lines: [{ sku: 'ITEM-A', amount: 5 }]
* });
*
* // Ship all remaining items from the order:
* const fullShipment = await store.shipment.create(googleAuthId, { orderId: 10 });
*/
return async (googleAuthId, input = {}, tx) => {
const orderId = requireId(input.orderId, 'Order');
if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
if (!Array.isArray(input.lines) || input.lines.length === 0) {
throw new Error('Invalid lines');
}
@@ -115,81 +111,97 @@ export default function create(provider) {
shippedAmountBySku.set(sku, Number(row.shippedAmount || 0));
}
// 5. Determine lines to ship (one, some, or all items)
const rawLines = input.lines;
const isAutoAll = !Array.isArray(rawLines) || rawLines.length === 0;
// 5. Determine warehouse and load stock for tracked articles
let targetWarehouseId = input.warehouseId ?? input.warehouse_id;
if (targetWarehouseId != null) {
targetWarehouseId = Number(targetWarehouseId);
} else {
const defWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { companyId });
targetWarehouseId = defWh.rows[0]?.id ?? null;
}
const articleInfoBySku = new Map();
const remainingStockBySku = new Map();
for (const sku of orderAmountBySku.keys()) {
const artRes = await runner.query(q.GET_ARTICLE_TRACK_STOCK, { companyId, sku });
const art = artRes.rows[0];
const isTracked = Number(art?.track_stock ?? 0) === 1;
let availableStock = Infinity;
if (isTracked && targetWarehouseId != null) {
const stockRes = await runner.query(q.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId: targetWarehouseId,
companyId,
sku,
articleId: art?.id ?? null,
});
availableStock = Number(stockRes.rows[0]?.stock ?? 0);
}
articleInfoBySku.set(sku, {
articleId: art?.id,
isTracked,
availableStock,
});
remainingStockBySku.set(sku, availableStock);
}
// 6. Determine lines to ship
const linesToInsert = [];
const proposedBySku = new Map();
if (isAutoAll) {
// Automatically ship all remaining unshipped items from the order
for (const [sku, ordered] of orderAmountBySku.entries()) {
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000;
if (remaining > 0) {
const ol = orderLineBySku.get(sku);
linesToInsert.push({
sku,
name: ol?.name ?? sku,
pu: ol?.pu ?? 1,
amount: remaining,
net: Number(ol?.net ?? 0),
rebate: Number(ol?.rebate ?? 0),
taxrate: Number(ol?.taxrate ?? 0),
comment: ol?.comment ?? null,
});
proposedBySku.set(sku, remaining);
}
for (const line of input.lines) {
const sku = String(line?.sku ?? '').trim();
if (!sku || !orderAmountBySku.has(sku)) {
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
}
if (linesToInsert.length === 0) {
throw new Error('All items already shipped');
}
} else {
// Specific lines requested (one, some, or all items)
for (const line of rawLines) {
const sku = String(line?.sku ?? '').trim();
if (!sku || !orderAmountBySku.has(sku)) {
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
const ordered = orderAmountBySku.get(sku) || 0;
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000;
let amount;
if (line.amount !== undefined && line.amount !== null) {
amount = Number(line.amount);
if (!Number.isFinite(amount) || amount === 0) {
throw new Error('Invalid lines');
}
const ordered = orderAmountBySku.get(sku) || 0;
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000;
let amount;
if (line.amount !== undefined && line.amount !== null) {
amount = Number(line.amount);
if (!Number.isFinite(amount) || amount === 0) {
throw new Error('Invalid lines');
}
} else {
// Default to remaining quantity
if (remaining <= 0) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
}
amount = remaining;
}
const currentProposed = proposedBySku.get(sku) || 0;
if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) {
} else {
// Default to remaining quantity
if (remaining <= 0) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
}
proposedBySku.set(sku, currentProposed + amount);
const ol = orderLineBySku.get(sku);
linesToInsert.push({
sku,
name: line.name ?? ol?.name ?? sku,
pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1),
amount,
net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0),
rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0),
taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0),
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
});
amount = remaining;
}
const currentProposed = proposedBySku.get(sku) || 0;
if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
}
const info = articleInfoBySku.get(sku);
if (amount > 0 && info?.isTracked) {
const available = remainingStockBySku.get(sku) ?? 0;
if (amount > available) {
throw new Error(`Insufficient stock for article (${sku}): available ${available}, requested ${amount}`);
}
remainingStockBySku.set(sku, available - amount);
}
proposedBySku.set(sku, currentProposed + amount);
const ol = orderLineBySku.get(sku);
linesToInsert.push({
sku,
name: line.name ?? ol?.name ?? sku,
pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1),
amount,
net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0),
rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0),
taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0),
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
});
}
// 6. Resolve next index <y>

View File

@@ -2,6 +2,12 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
import makeRunner from '../runner.js';
import { queriesFor } from './helpers.js';
import { syncOrderSummary } from '../order/syncSummary.js';
import { syncShipmentSummary } from './syncSummary.js';
import stockAdjust from '../stock/adjust.js';
import { computePrevHash } from '../ledger/add.js';
import { recordShipmentHistory } from '../articlehistory/record.js';
import * as mssqlLedgerQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteLedgerQueries from '../../providers/sqlite3/queries/ledger.js';
function requireId(val, name) {
const n = Number(val);
@@ -12,60 +18,205 @@ function requireId(val, name) {
}
/**
* Deletes a shipment manifest of the selected company.
* Shipped shipments (with packages in 'shipped' state) cannot be deleted.
* Deletes or cancels a shipment manifest of the selected company.
* - If no ledger rows exist: physically deletes the shipment manifest.
* - If ledger rows exist: reverses the ledger entries (storno document entry + warehouse stock adjustment),
* marks the shipment as 'cancelled' in shipmentsummary, and updates the parent order's summary.
* - Shipped shipments (with packages in 'shipped' state) cannot be deleted.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const ledgerQueries = isSqlite ? sqliteLedgerQueries : mssqlLedgerQueries;
/**
* Deletes a shipment manifest.
* Deletes or cancels a shipment manifest.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The shipment manifest ID.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If shipment not found or already has shipped packages.
* @returns {Promise<object>} Result indicating whether shipment was deleted or cancelled.
* @throws {Error} If shipment not found, already cancelled, or already has shipped packages.
*
* @example
* await store.shipment.delete(googleAuthId, 42);
*/
return async (googleAuthId, id, tx) => {
const shipmentId = requireId(id, 'Shipment');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
id: shipmentId,
companyId,
});
if (!check.rows[0]) {
throw new Error('Shipment not found');
}
const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, {
id: shipmentId,
});
if (shippedCheck.rows[0]) {
throw new Error('Shipped shipment cannot be deleted');
}
const shipmentNumber = String(check.rows[0].number || '');
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const pIdx = shipmentNumber.indexOf(shipmentPrefix);
const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null;
await runner.query(q.DELETE_SHIPMENT, {
id: shipmentId,
companyId,
});
if (orderNumber) {
await syncOrderSummary(runner, { companyId, orderNumber });
const run = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
id: shipmentId,
companyId,
});
if (!check.rows[0]) {
throw new Error('Shipment not found');
}
const sumRes = await runner.query(
'SELECT state, customer_name FROM shipmentsummary WHERE manifest_id = @manifestId',
{ manifestId: shipmentId },
);
if (sumRes.rows[0]?.state === 'cancelled') {
throw new Error('Shipment already cancelled');
}
const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, {
id: shipmentId,
});
if (shippedCheck.rows[0]) {
throw new Error('Shipped shipment cannot be deleted');
}
const shipmentNumber = String(check.rows[0].number || '');
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const pIdx = shipmentNumber.indexOf(shipmentPrefix);
const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null;
// Find all ledger rows booked for lines of this shipment
const ledgerRowsRes = await runner.query(
`SELECT l.id, l.manifestarticle_id, l.warehouse_id, l.transaction_type, l.amount, l.transaction_text,
ma.sku, ma.name AS article_name, ma.amount AS line_amount
FROM ledger l
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
WHERE ma.manifest_id = @shipmentId
ORDER BY l.id ASC`,
{ shipmentId },
);
const ledgerRows = ledgerRowsRes.rows ?? [];
if (ledgerRows.length === 0) {
await runner.query(q.DELETE_SHIPMENT, {
id: shipmentId,
companyId,
});
await runner.query('DELETE FROM shipmentsummary WHERE manifest_id = @id', { id: shipmentId });
if (orderNumber) {
await syncOrderSummary(runner, { companyId, orderNumber });
}
return { deleted: true, shipmentId };
}
// Ledger entries exist -> Storno / Compensating entries
const customerName = sumRes.rows[0]?.customer_name || null;
const stornoDocText = shipmentNumber ? `Storno Lieferschein ${shipmentNumber}` : 'Storno Lieferschein';
// Group ledger entries by manifestarticle_id
const byLine = new Map();
for (const row of ledgerRows) {
const lineId = row.manifestarticle_id;
if (!byLine.has(lineId)) {
byLine.set(lineId, {
sku: row.sku,
articleName: row.article_name,
docAmount: 0,
warehouseAmounts: new Map(),
});
}
const lineData = byLine.get(lineId);
if (row.warehouse_id == null) {
lineData.docAmount += Number(row.amount || 0);
} else {
const whId = Number(row.warehouse_id);
lineData.warehouseAmounts.set(
whId,
(lineData.warehouseAmounts.get(whId) || 0) + Number(row.amount || 0),
);
}
}
for (const [lineId, data] of byLine.entries()) {
// Revert document ledger entry if net balance is not 0
if (data.docAmount !== 0) {
const compensatingDocAmount = -data.docAmount;
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
await runner.query(ledgerQueries.INSERT_ROW, {
manifestarticleId: lineId,
warehouseId: null,
transactionType: 'STORNO',
amount: compensatingDocAmount,
transactionText: stornoDocText,
hashVersion: 1,
prevHash,
});
// Write article history for storno
if (data.sku) {
const trackRes = await runner.query(
`SELECT a.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: data.sku },
);
for (const match of trackRes.rows ?? []) {
await recordShipmentHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
customerName,
amount: compensatingDocAmount,
transactionText: stornoDocText,
});
}
}
}
// Revert warehouse ledger entries
for (const [whId, whAmount] of data.warehouseAmounts.entries()) {
if (whAmount !== 0) {
const compensatingWhAmount = -whAmount;
await adjust(
googleAuthId,
{
manifestarticleId: lineId,
warehouseId: whId,
amount: compensatingWhAmount,
text: stornoDocText,
},
activeTx,
);
}
}
}
// Free the shipment number in manifest so subsequent shipments can reuse the number
const freedManifestNumber = `${shipmentNumber}#cancelled#${shipmentId}`;
await runner.query(
isSqlite
? 'UPDATE manifest SET "number" = @freedManifestNumber WHERE id = @shipmentId'
: 'UPDATE manifest SET [number] = @freedManifestNumber WHERE id = @shipmentId',
{ freedManifestNumber, shipmentId },
);
// Mark shipment as cancelled in shipmentsummary
await syncShipmentSummary(runner, {
companyId,
manifestId: shipmentId,
state: 'cancelled',
});
// Resync order summary to reopen positions on parent order
if (orderNumber) {
await syncOrderSummary(runner, { companyId, orderNumber });
}
return { cancelled: true, shipmentId, restoredStock: true };
};
if (tx) return run(makeRunner(provider, tx), tx);
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, activeTx) => run(runner, activeTx));
}
return run(makeRunner(provider));
};
}

View File

@@ -51,7 +51,7 @@ export async function loadShipment(runner, q, companyId, id) {
return {
id: row.id,
number: row.number,
number: String(row.number || '').split('#cancelled#')[0],
type: 'shipment',
entityId: row.entityId ?? null,
externalNumber: row.externalNumber ?? null,

View File

@@ -31,7 +31,8 @@ export default function list(provider) {
* @param {number} [opts.offset=0] - Number of shipments to skip.
* @param {number} [opts.limit=50] - Number of shipments to return.
* @param {string} [opts.search] - Free-text search matching shipment number, order number, external number, tracking code, or customer name.
* @param {'new'|'delivered'|'returned'} [opts.state] - Filter by delivery state.
* @param {'new'|'delivered'|'returned'|'cancelled'|'all'} [opts.state] - Filter by delivery state.
* @param {boolean} [opts.includeCancelled=false] - Whether to include cancelled shipments when state is not set.
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
* @param {number} [opts.orderId] - Optional filter for parent order manifest id.
* @param {object} [tx] - Optional transaction.
@@ -109,10 +110,15 @@ export default function list(provider) {
}
}
// Delivery state filter ('new' | 'delivered' | 'returned')
// Delivery state filter ('new' | 'delivered' | 'returned' | 'cancelled' | 'all')
if (typeof opts?.state === 'string' && opts.state.trim() !== '') {
params.filterState = opts.state.trim().toLowerCase();
conditions.push("COALESCE(ss.state, 'new') = @filterState");
const stateFilter = opts.state.trim().toLowerCase();
if (stateFilter !== 'all') {
params.filterState = stateFilter;
conditions.push("COALESCE(ss.state, 'new') = @filterState");
}
} else if (!opts?.includeCancelled) {
conditions.push("COALESCE(ss.state, 'new') != 'cancelled'");
}
// Free-text search

View File

@@ -126,7 +126,7 @@ const SQL_UPSERT_SUMMARY_MSSQL = `
);
`;
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']);
/**
* Recalculates and upserts a single shipment summary into `shipmentsummary`.
@@ -153,6 +153,8 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
return;
}
const cleanShipmentNumber = String(shipment.number || '').split('#cancelled#')[0];
// 1. Line stats
const lineRes = await runner.query(SQL_LINE_STATS, { manifestId });
const positions = Number(lineRes.rows[0]?.positions || 0);
@@ -175,7 +177,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const num = String(shipment.number || '');
const num = cleanShipmentNumber;
if (num.includes(shipmentPrefix)) {
const parsedOrderNum = num.split(shipmentPrefix)[0];
if (parsedOrderNum) {
@@ -201,7 +203,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
customerName = custRes.rows[0]?.customer_name || null;
}
// 5. State ('new' | 'delivered' | 'returned')
// 5. State ('new' | 'delivered' | 'returned' | 'cancelled')
let resolvedState = 'new';
if (state && VALID_STATES.has(state)) {
resolvedState = state;
@@ -229,7 +231,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
companyId,
manifestId,
shipmentNumber: shipment.number,
shipmentNumber: cleanShipmentNumber,
shipmentDate: shipment.date,
createdAt: shipment.created_at,
orderId: resolvedOrderId,

View File

@@ -11,7 +11,7 @@ function requireId(val, name) {
return n;
}
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']);
/**
* Updates a shipment's state or metadata.

View File

@@ -126,6 +126,19 @@ export default function adjust(provider) {
const text = input.text ?? input.transactionText ?? input.transaction_text ??
(amount > 0 ? `Stock addition (+${amount})` : `Stock deduction (${amount})`);
if (amount < 0) {
const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId,
companyId,
sku,
articleId: art.id,
});
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
if (currentStock + amount < 0) {
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`);
}
}
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
@@ -301,6 +314,19 @@ export default function adjust(provider) {
const createdLedgerEntries = [];
for (const item of resolvedArticles) {
if (item.amount < 0) {
const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, {
warehouseId: item.warehouseId,
companyId,
sku: item.sku,
articleId: item.articleId,
});
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
if (currentStock + item.amount < 0) {
throw new Error(`Insufficient stock for article (${item.sku}): available ${currentStock}, requested ${Math.abs(item.amount)}`);
}
}
// Create manifestarticle row
const lineRes = await runner.query(queries.CREATE_LINE, {
manifestId,