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} 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 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)); }; }