diff --git a/API.md b/API.md index a2305e2..5afa6a3 100644 --- a/API.md +++ b/API.md @@ -209,7 +209,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [order](API.order.md) - `create(googleAuthId: number, body: {number: string, date: string, entityId: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null, shippable?: boolean}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - `createFromQuote(googleAuthId: number, quoteId: number, body?: {number?: string, date?: string, createdAt?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null, shippable?: boolean}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - - `createShipment(googleAuthId: number, orderId: number, input: {lines: Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>, date?: string|Date, externalNumber?: string}, tx?: object): Promise` + - `createShipment(googleAuthId: number, orderId: number, input: {lines: Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>, date?: string|Date, externalNumber?: string, shipping: object}, tx?: object): Promise` - `delete(googleAuthId: number, id: number, tx?: object): Promise` - `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number}, tx?: object): Promise<{orders: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` diff --git a/API.order.md b/API.order.md index 21a93b6..3cda805 100644 --- a/API.order.md +++ b/API.order.md @@ -88,6 +88,7 @@ Creates a new shipment manifest for an order. Can contain specified items from t | `input.lines` | `Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>` | Positions to ship. | | `[input.date]` | `string|Date` | Optional shipment date. | | `[input.externalNumber]` | `string` | Optional external delivery reference. | +| `input.shipping` | `object` | Required shipping ({carrierId, type, state, trackingCode?, warehouseId?}); see shipment.create. | | `[tx]` | `object` | Optional transaction. | **Returns** @@ -97,7 +98,7 @@ Creates a new shipment manifest for an order. Can contain specified items from t **Example** ```js -const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] }); +const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }], shipping: { carrierId: 1, type: 'parcel', state: 'draft' } }); ``` ## `order.delete` diff --git a/scripts/test-ledger.js b/scripts/test-ledger.js index 4a38c3c..c0a43a2 100644 --- a/scripts/test-ledger.js +++ b/scripts/test-ledger.js @@ -269,8 +269,12 @@ async function run() { ], }); + const testCarrier = await store.carrier.create(googleAuthId, { name: 'Test Carrier', hasPackages: true }); + const testShipping = { carrierId: testCarrier.id, type: 'parcel', state: 'draft' }; + const shipment = await store.shipment.create(googleAuthId, { orderId: order2.id, + shipping: testShipping, lines: [{ sku: 'SKU-ABC', amount: 3 }], }); @@ -358,6 +362,7 @@ async function run() { const shipment3 = await store.shipment.create(googleAuthId, { orderId: order3.id, + shipping: testShipping, lines: [ { sku: 'SKU-ABC', amount: 4 }, { sku: 'SKU-ABC', amount: -1 }, @@ -419,6 +424,7 @@ async function run() { await assert.rejects( async () => store.shipment.create(googleAuthId, { orderId: guardOrder.id, + shipping: testShipping, warehouseId: wh1.id, lines: [{ sku: article.sku, amount: curStock + 10 }], }), diff --git a/scripts/test-order-filters.js b/scripts/test-order-filters.js index 365d44b..648489c 100644 --- a/scripts/test-order-filters.js +++ b/scripts/test-order-filters.js @@ -95,14 +95,11 @@ async function run() { entityId: customerBob.id, lines: [{ sku: art.sku, name: 'Widget', amount: 2, net: 100, taxrate: 19 }], }); + const testCarrier = await store.carrier.create(googleAuthId, { name: 'Test Carrier', hasPackages: true }); const shipment2 = await store.shipment.create(googleAuthId, { orderId: order2.id, lines: [{ sku: art.sku, amount: 2 }], - }); - await store.shipping.create(googleAuthId, { - manifestId: shipment2.id, - type: 'parcel', - state: 'shipped', + shipping: { carrierId: testCarrier.id, type: 'parcel', state: 'shipped' }, }); // Order 3: Alice, O-1003, €59.50, Date: today (2026-10-13) @@ -128,14 +125,10 @@ async function run() { type: 'bank_transfer', amount: 150, // Overpaid! }); - const shipment4 = await store.shipment.create(googleAuthId, { + await store.shipment.create(googleAuthId, { orderId: order4.id, lines: [{ sku: art.sku, amount: 1 }], - }); - await store.shipping.create(googleAuthId, { - manifestId: shipment4.id, - type: 'parcel', - state: 'shipped', + shipping: { carrierId: testCarrier.id, type: 'parcel', state: 'shipped' }, }); console.log('Orders created successfully.'); diff --git a/scripts/test-order-shipping-ledger.js b/scripts/test-order-shipping-ledger.js index 1a7af12..9d7326e 100644 --- a/scripts/test-order-shipping-ledger.js +++ b/scripts/test-order-shipping-ledger.js @@ -118,13 +118,9 @@ async function run() { { sku: trackedArt.sku, amount: 3 }, { sku: untrackedArt.sku, amount: 2 }, ], + shipping: { carrierId: carrier.id, type: 'parcel', state: 'open' }, }); - const shippingOpen = await store.shipping.create(googleAuthId, { - manifestId: shipment.id, - carrierId: carrier.id, - type: 'parcel', - state: 'open', - }); + const shippingOpen = shipment.shippings[0]; assert.equal(shippingOpen.state, 'open'); // Assert stock is still 10 @@ -191,6 +187,7 @@ async function run() { number: 'O-HIST-001', date: historicalDate, createdAt: historicalDate, + entityId: customer.id, lines: [{ sku: trackedArt.sku, amount: 1, net: 10, taxrate: 19 }], }); const histWithOld = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku); @@ -253,14 +250,9 @@ async function run() { const shipment2 = await store.shipment.create(googleAuthId, { orderId: order2.id, lines: [{ sku: trackedArt.sku, amount: 2 }], + shipping: { carrierId: carrier.id, type: 'parcel', state: 'shipped' }, }); - - const directShipped = await store.shipping.create(googleAuthId, { - manifestId: shipment2.id, - carrierId: carrier.id, - type: 'parcel', - state: 'shipped', - }); + const directShipped = shipment2.shippings[0]; assert.equal(directShipped.state, 'shipped'); const artFinal = await store.article.getBySku(googleAuthId, trackedArt.sku); diff --git a/scripts/test-shipment.js b/scripts/test-shipment.js index f150341..e066add 100644 --- a/scripts/test-shipment.js +++ b/scripts/test-shipment.js @@ -84,8 +84,26 @@ async function run() { assert.equal(order.number, 'AU-1001'); console.log('Order created:', order.number); + const carrier = await store.carrier.create(googleAuthId, { + name: 'DHL', + hasPackages: true, + isReverse: false, + }); + assert.equal(carrier.name, 'DHL'); + assert.equal(carrier.hasPackages, true); + assert.equal(carrier.isReverse, false); + const shipping = { carrierId: carrier.id, type: 'parcel', state: 'draft' }; + + // 0. Shipment without shipping is rejected + await assert.rejects( + () => store.shipment.create(googleAuthId, { orderId: order.id, lines: [{ sku: 'ITEM-A', amount: 1 }] }), + { message: 'Shipping is required' }, + ); + console.log('PASS: Shipment without shipping rejected'); + // 1. Create Shipment 1: 6x Item A, 3x Item B -> AU-1001-L-1 const shipment1 = await store.shipment.create(googleAuthId, { + shipping, orderId: order.id, lines: [ { sku: 'ITEM-A', amount: 6 }, @@ -100,6 +118,7 @@ async function run() { let ceilingErrorThrown = false; try { await store.shipment.create(googleAuthId, { + shipping, orderId: order.id, lines: [{ sku: 'ITEM-A', amount: 5 }], }); @@ -114,6 +133,7 @@ async function run() { let foreignArticleErrorThrown = false; try { await store.shipment.create(googleAuthId, { + shipping, orderId: order.id, lines: [{ sku: 'ITEM-UNKNOWN', amount: 1 }], }); @@ -126,6 +146,7 @@ async function run() { // 3. Create Shipment 2: 4x Item A, 2x Item B -> AU-1001-L-2 const shipment2 = await store.shipment.create(googleAuthId, { + shipping, orderId: order.id, lines: [ { sku: 'ITEM-A', amount: 4 }, @@ -140,6 +161,7 @@ async function run() { let exhaustedErrorThrown = false; try { await store.shipment.create(googleAuthId, { + shipping, orderId: order.id, lines: [{ sku: 'ITEM-A', amount: 1 }], }); @@ -150,16 +172,7 @@ async function run() { } assert.ok(exhaustedErrorThrown, 'Expected exhausted error to be thrown'); - // 5. Add packages to Shipment 1 with tracking codes and carrier - const carrier = await store.carrier.create(googleAuthId, { - name: 'DHL', - hasPackages: true, - isReverse: false, - }); - assert.equal(carrier.name, 'DHL'); - assert.equal(carrier.hasPackages, true); - assert.equal(carrier.isReverse, false); - + // 5. Add further packages to Shipment 1 with tracking codes const pkg1 = await store.shipping.create(googleAuthId, { manifestId: shipment1.id, carrierId: carrier.id, @@ -194,13 +207,13 @@ async function run() { // Check listByOrderId const orderShipments = await store.shipment.listByOrderId(googleAuthId, order.id); assert.equal(orderShipments.length, 2); - assert.equal(orderShipments[0].shippings.length, 2); + assert.equal(orderShipments[0].shippings.length, 3); console.log('PASS: store.shipment.listByOrderId returned both shipments'); // Check getById const fetchedShipment1 = await store.shipment.getById(googleAuthId, shipment1.id); assert.equal(fetchedShipment1.id, shipment1.id); - assert.equal(fetchedShipment1.shippings.length, 2); + assert.equal(fetchedShipment1.shippings.length, 3); console.log('PASS: store.shipment.getById returned shipment with shippings'); // 6. Transition Package 1 to shipped (should deduct ledger stock for Shipment 1 lines: 6 of Item A, 3 of Item B) @@ -253,6 +266,7 @@ async function run() { // 9a. Ship ONE item (3x ITEM-A) via store.order.createShipment const shOne = await store.order.createShipment(googleAuthId, order2.id, { + shipping, lines: [{ sku: 'ITEM-A', amount: 3 }], }); assert.equal(shOne.number, 'AU-1002-L-1'); @@ -268,6 +282,7 @@ async function run() { // 9b. Ship SOME items with omitted amount (e.g. ITEM-A with no amount -> defaults to remaining 7) const shSome = await store.order.createShipment(googleAuthId, order2.id, { + shipping, lines: [{ sku: 'ITEM-A' }], }); assert.equal(shSome.number, 'AU-1002-L-2'); @@ -282,7 +297,7 @@ async function run() { console.log('PASS: 9b. Shipped SOME items with omitted amount; ordersummary updated with shipped_quantity=10, is_shipped=0'); // 9c. Ship remaining items with explicit lines - const shAll = await store.order.createShipment(googleAuthId, order2.id, { lines: [{ sku: 'ITEM-B', amount: 5 }] }); + const shAll = await store.order.createShipment(googleAuthId, order2.id, { shipping, lines: [{ sku: 'ITEM-B', amount: 5 }] }); assert.equal(shAll.number, 'AU-1002-L-3'); assert.equal(shAll.lines.length, 1); assert.equal(shAll.lines[0].sku, 'ITEM-B'); diff --git a/src/store/order/createShipment.js b/src/store/order/createShipment.js index 569809f..e3d0dab 100644 --- a/src/store/order/createShipment.js +++ b/src/store/order/createShipment.js @@ -13,11 +13,12 @@ export default function createShipment(provider) { * @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} input.shipping - Required shipping ({carrierId, type, state, trackingCode?, warehouseId?}); see shipment.create. * @param {object} [tx] - Optional transaction. * @returns {Promise} The created shipment. * * @example - * const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] }); + * const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }], shipping: { carrierId: 1, type: 'parcel', state: 'draft' } }); */ return async (googleAuthId, orderId, input = {}, tx) => { return create(googleAuthId, { ...input, orderId }, tx); diff --git a/src/store/shipment/create.js b/src/store/shipment/create.js index 6ec5704..7ba60fb 100644 --- a/src/store/shipment/create.js +++ b/src/store/shipment/create.js @@ -3,6 +3,7 @@ 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 { syncShipmentSummary } from './syncSummary.js'; @@ -24,6 +25,7 @@ function requireId(val, name) { export default function create(provider) { const access = resolveSelectedCompanyAccess(provider); const ensureTax = taxEnsure(provider); + const createShipping = shippingCreate(provider); const q = queriesFor(provider); /** @@ -37,14 +39,21 @@ export default function create(provider) { * @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 {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, or article ceiling invariant exceeded. + * @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 }] + * lines: [{ sku: 'ITEM-A', amount: 5 }], + * shipping: { carrierId: 1, type: 'parcel', state: 'draft' } * }); */ return async (googleAuthId, input = {}, tx) => { @@ -54,6 +63,11 @@ export default function create(provider) { 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) => { @@ -289,6 +303,9 @@ export default function create(provider) { state: validState, }); + // 10. Create the shipping (carrier) atomically with the shipment + await createShipping(googleAuthId, { ...input.shipping, manifestId }, innerTx); + return loadShipment(runner, q, companyId, manifestId); };