327 lines
13 KiB
JavaScript
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));
|
|
};
|
|
}
|