import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, parseDate } from '../manifestLines.js'; import makeRunner from '../runner.js'; import { loadShipment, queriesFor } from './helpers.js'; import { syncOrderSummary } from '../order/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 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. * 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 {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} The created shipment manifest. * @throws {Error} If order not found, invalid lines, all items already shipped, 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)) { throw new Error('Invalid lines'); } 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'); } const orderAmountBySku = new Map(); const orderLineBySku = new Map(); for (const ol of orderLines) { const sku = String(ol.sku ?? '').trim(); if (!sku) continue; orderAmountBySku.set(sku, (orderAmountBySku.get(sku) || 0) + Number(ol.amount || 0)); if (!orderLineBySku.has(sku)) { orderLineBySku.set(sku, 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 sku = String(row.sku ?? '').trim(); 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; 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); } } 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'); } } 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) { 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), }); } } // 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}`; // 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, }); // 9. Synchronize order summary row and shipment summary row await syncOrderSummary(runner, { companyId, manifestId: orderId }); const validState = input.state && ['new', 'delivered', 'returned'].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)); }; }