Files
dashlibstorage/src/store/shipment/create.js
2026-10-26 19:22:04 +01:00

327 lines
13 KiB
JavaScript

import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, parseDate } from '../manifestLines.js';
import { assertUniqueManifestNumber } from '../manifestDoc/helpers.js';
import makeRunner from '../runner.js';
import shippingCreate from '../shipping/create.js';
import { loadShipment, queriesFor } from './helpers.js';
import { syncOrderSummary } from '../order/syncSummary.js';
import { syncRetoureSummary } from '../retoure/syncSummary.js';
import { syncShipmentSummary } from './syncSummary.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Creates a new shipment manifest for an order.
* Validates that all article positions belong to the order and do not exceed remaining ordered quantities.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(provider);
const createShipping = shippingCreate(provider);
const q = queriesFor(provider);
/**
* 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, orderLineId?: number, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions. Order lines with a SKU are referenced by `sku`; SKU-less order lines by `orderLineId` (matched across shipments by name).
* @param {string|Date} [input.date] - Optional shipment date (defaults to current date).
* @param {'new'|'delivered'} [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 {object} input.shipping - Required shipping (package) created atomically with the shipment, so a shipment never exists without a carrier.
* @param {number} input.shipping.carrierId - The carrier id.
* @param {string} input.shipping.type - Shipping type.
* @param {string} input.shipping.state - Shipping state ('shipped' or other).
* @param {string} [input.shipping.trackingCode] - Carrier tracking code.
* @param {number} [input.shipping.warehouseId] - Optional warehouse ID for stock deduction when state is 'shipped'.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created shipment manifest.
* @throws {Error} If order not found, invalid lines, shipping/carrier missing or invalid, or article ceiling invariant exceeded.
*
* @example
* const shipment = await store.shipment.create(googleAuthId, {
* orderId: 10,
* lines: [{ sku: 'ITEM-A', amount: 5 }],
* shipping: { carrierId: 1, type: 'parcel', state: 'draft' }
* });
*/
return async (googleAuthId, input = {}, tx) => {
const orderId = requireId(input.orderId, 'Order');
if (!Array.isArray(input.lines) || input.lines.length === 0) {
throw new Error('Invalid lines');
}
if (!input.shipping || typeof input.shipping !== 'object') {
throw new Error('Shipping is required');
}
requireId(input.shipping.carrierId, 'Carrier');
const shipmentDate = parseDate(input.date) || new Date();
const run = async (runner, innerTx) => {
const { companyId } = await access(googleAuthId, runner);
// 1. Retrieve parent order header
const orderRes = await runner.query(q.GET_ORDER_HEADER, {
orderId,
companyId,
});
const order = orderRes.rows[0];
if (!order) {
throw new Error('Order not found');
}
const externalNumber = input.externalNumber !== undefined
? (input.externalNumber?.trim?.() ? input.externalNumber.trim() : null)
: (order.externalNumber ?? order.number ?? null);
// 2. Retrieve parent order lines
const orderLinesRes = await runner.query(q.GET_ORDER_LINES, {
orderId,
});
const orderLines = orderLinesRes.rows ?? [];
if (orderLines.length === 0) {
throw new Error('Order has no lines');
}
// Lines are keyed by SKU; SKU-less lines are keyed by name (shipment lines copy the name).
const lineKey = (sku, name) => {
const s = String(sku ?? '').trim();
return s || `\u0000name:${String(name ?? '').trim()}`;
};
const orderAmountBySku = new Map();
const orderLineBySku = new Map();
const orderLineById = new Map();
for (const ol of orderLines) {
const key = lineKey(ol.sku, ol.name);
orderLineById.set(Number(ol.id), ol);
orderAmountBySku.set(key, (orderAmountBySku.get(key) || 0) + Number(ol.amount || 0));
if (!orderLineBySku.has(key)) {
orderLineBySku.set(key, ol);
}
}
// 3. Retrieve shipment prefix from numbers table
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
// 4. Retrieve already shipped amounts across existing shipment manifests of this order
const pattern = `${order.number}${shipmentPrefix}%`;
const shippedRes = await runner.query(q.GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN, {
companyId,
pattern,
excludeShipmentId: null,
});
const shippedAmountBySku = new Map();
for (const row of shippedRes.rows ?? []) {
const key = lineKey(row.sku, row.name);
shippedAmountBySku.set(key, (shippedAmountBySku.get(key) || 0) + Number(row.shippedAmount || 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()) {
if (sku.startsWith('\u0000name:')) continue; // SKU-less lines carry no stock
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();
for (const line of input.lines) {
const rawSku = String(line?.sku ?? '').trim();
let sku = rawSku;
let shipSku = rawSku;
let label = rawSku;
if (!rawSku) {
// SKU-less line: referenced by order line id
const target = orderLineById.get(Number(line?.orderLineId));
if (!target || String(target.sku ?? '').trim()) {
throw new Error(`Article (${line?.orderLineId ?? ''}) is not part of order (${order.number})`);
}
sku = lineKey(null, target.name);
shipSku = null;
label = String(target.name ?? '').trim();
}
if (!orderAmountBySku.has(sku)) {
throw new Error(`Article (${label}) is not part of order (${order.number})`);
}
if (Number(orderLineBySku.get(sku)?.shippable) === 0) {
throw new Error(`Article (${label}) is not shippable`);
}
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 (${label})`);
}
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 (${label})`);
}
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: shipSku || null,
name: shipSku ? (line.name ?? ol?.name ?? shipSku) : ol.name,
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),
shippable: true,
});
}
// 6. Resolve next index <y>
const existingShipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
companyId,
pattern,
});
const existingShipments = existingShipmentsRes.rows ?? [];
let maxY = 0;
const prefixExact = `${order.number}${shipmentPrefix}`;
for (const s of existingShipments) {
const num = String(s.number || '');
if (num.startsWith(prefixExact)) {
const suffix = parseInt(num.slice(prefixExact.length), 10);
if (Number.isInteger(suffix) && suffix > maxY) {
maxY = suffix;
}
}
}
const nextY = Math.max(existingShipments.length + 1, maxY + 1);
const shipmentNumber = `${order.number}${shipmentPrefix}${nextY}`;
await assertUniqueManifestNumber(runner, {
companyId,
type: 'shipment',
number: shipmentNumber,
errorMessage: 'Shipment number already exists',
});
// 7. Create shipment manifest header
const createdRes = await runner.query(q.CREATE_MANIFEST, {
companyId,
number: shipmentNumber,
type: 'shipment',
entityId: order.entityId ?? null,
externalNumber,
date: shipmentDate,
createdAt: new Date(),
});
const manifestId = createdRes.rows[0]?.id ?? createdRes.insertId;
// 8. Insert lines
await insertLines(runner, {
manifestId,
lines: linesToInsert,
ensureTax,
});
// 8b. Create the shipping (carrier) atomically with the shipment. Must run before the
// summary state ('delivered') is written, as that would block shipping creation.
await createShipping(googleAuthId, { ...input.shipping, manifestId }, innerTx);
// 9. Synchronize order/retoure summary row and shipment summary row
if (order.type === 'retoure') {
await syncRetoureSummary(runner, { companyId, manifestId: orderId });
} else {
await syncOrderSummary(runner, { companyId, manifestId: orderId });
}
const validState = input.state && ['new', 'delivered'].includes(String(input.state).trim().toLowerCase())
? String(input.state).trim().toLowerCase()
: 'new';
await syncShipmentSummary(runner, {
companyId,
manifestId,
orderId,
orderNumber: order.number,
state: validState,
});
return loadShipment(runner, q, companyId, manifestId);
};
if (tx) {
return run(makeRunner(provider, tx), tx);
}
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, innerTx) => run(runner, innerTx));
}
return run(makeRunner(provider));
};
}