This commit is contained in:
seb
2026-10-12 19:48:07 +02:00
parent 64d1772842
commit 28e62093b3
25 changed files with 979 additions and 125 deletions

15
API.md
View File

@@ -186,6 +186,16 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `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}>}>` - `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}, 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, offset: number, limit: number}|Array<object>>` - `list(googleAuthId: number, opts?: {offset?: number, limit?: 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, offset: number, limit: number}|Array<object>>`
- `update(googleAuthId: number, id: number, patch: {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}>}, 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}>}>` - `update(googleAuthId: number, id: number, patch: {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}>}, 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}>}>`
- [shipment](API.shipment.md)
- `create(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `delete(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `getById(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `listByOrderId(provider: import('../../providers/provider.js').DatabaseProvider): void`
- [shipping](API.shipping.md)
- `create(googleAuthId: number, input: {manifestId: number, type: string, state: string, trackingCode?: string, carrier?: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, manifestId: number, number: string, type: string, state: string}>>`
- `update(provider: import('../../providers/provider.js').DatabaseProvider): void`
- [manifestattachment](API.manifestattachment.md) - [manifestattachment](API.manifestattachment.md)
- `getById(googleAuthId: number, manifestId: number, id: number, tx?: object): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` - `getById(googleAuthId: number, manifestId: number, id: number, tx?: object): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>`
- `listByManifestId(): void` - `listByManifestId(): void`
@@ -325,8 +335,3 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, accountId: number, manifestId: number, type: string, amount: number}>>` - `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, accountId: number, manifestId: number, type: string, amount: number}>>`
- [runner.js](API.runner.js.md) - [runner.js](API.runner.js.md)
- `runner(provider: import('../providers/provider.js').DatabaseProvider, tx?: object): ((sql: string, params?: object) => Promise<import('../providers/provider.js').QueryResult>) & { query: Function, dialect?: string }` - `runner(provider: import('../providers/provider.js').DatabaseProvider, tx?: object): ((sql: string, params?: object) => Promise<import('../providers/provider.js').QueryResult>) & { query: Function, dialect?: string }`
- [shipping](API.shipping.md)
- `create(googleAuthId: number, input: {manifestId: number, type: string, state: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, type: string, state: string}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, manifestId: number, number: string, type: string, state: string}>>`
- `update(provider: import('../../providers/provider.js').DatabaseProvider): void`

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/numbers/list.js`* *Source: `src/store/numbers/list.js`*
Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery') exist. Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
**Parameters** **Parameters**

51
API.shipment.md Normal file
View File

@@ -0,0 +1,51 @@
# shipment
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `shipment.create`
*Source: `src/store/shipment/create.js`*
Creates a new shipment manifest for an order. Validates that all article positions belong to the order and do not exceed remaining ordered quantities.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.delete`
*Source: `src/store/shipment/delete.js`*
Deletes a shipment manifest of the selected company. Shipped shipments (with packages in 'shipped' state) cannot be deleted.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.getById`
*Source: `src/store/shipment/getById.js`*
Retrieves a shipment manifest by ID for the selected company, including lines and packages.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.listByOrderId`
*Source: `src/store/shipment/listByOrderId.js`*
Lists all shipment manifests belonging to an order.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/shipping/create.js`* *Source: `src/store/shipping/create.js`*
Creates a shipping for a manifest (order). The number is allocated from the `shipping` numbers sequence (own prefix + counter). If state is 'shipped', adjusts stock by appending negative ledger movements for tracked order lines, writes article history for all items, and ensures the order was not already shipped. Creates a shipping (package) for a shipment manifest. The number is allocated from the `shipping` numbers sequence (own prefix + counter). If state is 'shipped', adjusts stock by appending negative ledger movements for tracked shipment lines, writes article history for all items, and ensures stock is deducted once.
**Parameters** **Parameters**
@@ -14,24 +14,26 @@ Creates a shipping for a manifest (order). The number is allocated from the `shi
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Shipping input. | | `input` | `object` | Shipping input. |
| `input.manifestId` | `number` | The manifest (order) id. | | `input.manifestId` | `number` | The shipment manifest id. |
| `input.type` | `string` | Shipping type. | | `input.type` | `string` | Shipping type. |
| `input.state` | `string` | Shipping state ('shipped' or other). | | `input.state` | `string` | Shipping state ('shipped' or other). |
| `[input.trackingCode]` | `string` | Carrier tracking code. |
| `[input.carrier]` | `string` | Carrier name. |
| `[input.warehouseId]` | `number` | Optional warehouse ID for stock deduction (defaults to company's first warehouse). | | `[input.warehouseId]` | `number` | Optional warehouse ID for stock deduction (defaults to company's first warehouse). |
| `[tx]` | `object` | Optional transaction to run within. | | `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<{id: number, manifestId: number, number: string, type: string, state: string}>` `Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>`
**Throws** **Throws**
`Error` — If manifest is not found, order already shipped, or input is invalid. `Error` — If manifest is not found, manifest is not a shipment, or input is invalid.
**Example** **Example**
```js ```js
await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped' }); await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped', trackingCode: '1Z999', carrier: 'UPS' });
``` ```
## `shipping.delete` ## `shipping.delete`

View File

@@ -27,6 +27,8 @@ const MODULE_ORDER = [
'delivery', 'delivery',
'quote', 'quote',
'order', 'order',
'shipment',
'shipping',
'manifestattachment', 'manifestattachment',
'ledger', 'ledger',
'warehouse', 'warehouse',

View File

@@ -106,10 +106,17 @@ async function run() {
}); });
assert.equal(order.lines.length, 2); assert.equal(order.lines.length, 2);
// 4. Create shipping with state 'open' (not shipped yet) // 4. Create shipment and shipping with state 'open' (not shipped yet)
console.log('4. Creating shipping with state "open"...'); console.log('4. Creating shipment and shipping with state "open"...');
const shipment = await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [
{ sku: trackedArt.sku, amount: 3 },
{ sku: untrackedArt.sku, amount: 2 },
],
});
const shippingOpen = await store.shipping.create(googleAuthId, { const shippingOpen = await store.shipping.create(googleAuthId, {
manifestId: order.id, manifestId: shipment.id,
type: 'parcel', type: 'parcel',
state: 'open', state: 'open',
}); });
@@ -200,8 +207,8 @@ async function run() {
{ message: 'Shipped shipping cannot be undone' }, { message: 'Shipped shipping cannot be undone' },
); );
// 8. Test order cannot be shipped again // 8. Test order manifest cannot be used directly for shipping
console.log('8. Testing order cannot be shipped twice...'); console.log('8. Testing order manifest cannot be used directly for shipping...');
await assert.rejects( await assert.rejects(
async () => { async () => {
await store.shipping.create(googleAuthId, { await store.shipping.create(googleAuthId, {
@@ -210,10 +217,10 @@ async function run() {
state: 'shipped', state: 'shipped',
}); });
}, },
{ message: 'Order already shipped' }, { message: 'Manifest is not a shipment' },
); );
// 9. Test direct creation with state 'shipped' // 9. Test direct creation with state 'shipped' on shipment manifest
console.log('9. Testing direct creation with state "shipped"...'); console.log('9. Testing direct creation with state "shipped"...');
const order2 = await store.order.create(googleAuthId, { const order2 = await store.order.create(googleAuthId, {
number: 'O-2026-002', number: 'O-2026-002',
@@ -231,8 +238,13 @@ async function run() {
], ],
}); });
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order2.id,
lines: [{ sku: trackedArt.sku, amount: 2 }],
});
const directShipped = await store.shipping.create(googleAuthId, { const directShipped = await store.shipping.create(googleAuthId, {
manifestId: order2.id, manifestId: shipment2.id,
type: 'parcel', type: 'parcel',
state: 'shipped', state: 'shipped',
}); });

228
scripts/test-shipment.js Normal file
View File

@@ -0,0 +1,228 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Shipment Manifest & Multi-Package Shipping ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-shipment-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('shipment-test@example.com');
const company = await store.company.create({
name: 'Shipment Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Create a warehouse
const wh = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
// Create customer entity
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
name: 'Max Mustermann',
});
// Create Item A and Item B
const itemA = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Item A',
sku: 'ITEM-A',
});
const itemB = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Item B',
sku: 'ITEM-B',
});
// Stock up Item A (20) and Item B (20)
await store.stock.adjust(googleAuthId, {
sku: 'ITEM-A',
warehouseId: wh.id,
amount: 20,
comment: 'Initial Stock',
});
await store.stock.adjust(googleAuthId, {
sku: 'ITEM-B',
warehouseId: wh.id,
amount: 20,
comment: 'Initial Stock',
});
// Create Order with 10x Item A, 5x Item B
const order = await store.order.create(googleAuthId, {
number: 'AU-1001',
date: '2026-10-12',
entityId: customer.id,
lines: [
{ sku: 'ITEM-A', amount: 10, net: 100, taxrate: 19 },
{ sku: 'ITEM-B', amount: 5, net: 50, taxrate: 19 },
],
});
assert.equal(order.number, 'AU-1001');
console.log('Order created:', order.number);
// 1. Create Shipment 1: 6x Item A, 3x Item B -> AU-1001-L-1
const shipment1 = await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [
{ sku: 'ITEM-A', amount: 6 },
{ sku: 'ITEM-B', amount: 3 },
],
});
assert.equal(shipment1.number, 'AU-1001-L-1');
assert.equal(shipment1.lines.length, 2);
console.log('PASS: Shipment 1 created with number:', shipment1.number);
// 2. Create Shipment 2: 5x Item A -> Fails with ceiling violation (6 + 5 > 10)
let ceilingErrorThrown = false;
try {
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'ITEM-A', amount: 5 }],
});
} catch (err) {
ceilingErrorThrown = true;
assert.match(err.message, /Shipment amount exceeds ordered amount for article \(ITEM-A\)/);
console.log('PASS: Ceiling violation caught:', err.message);
}
assert.ok(ceilingErrorThrown, 'Expected ceiling violation error to be thrown');
// Also test foreign article rejection
let foreignArticleErrorThrown = false;
try {
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'ITEM-UNKNOWN', amount: 1 }],
});
} catch (err) {
foreignArticleErrorThrown = true;
assert.match(err.message, /Article \(ITEM-UNKNOWN\) is not part of order/);
console.log('PASS: Foreign article rejected:', err.message);
}
assert.ok(foreignArticleErrorThrown, 'Expected foreign article error to be thrown');
// 3. Create Shipment 2: 4x Item A, 2x Item B -> AU-1001-L-2
const shipment2 = await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [
{ sku: 'ITEM-A', amount: 4 },
{ sku: 'ITEM-B', amount: 2 },
],
});
assert.equal(shipment2.number, 'AU-1001-L-2');
assert.equal(shipment2.lines.length, 2);
console.log('PASS: Shipment 2 created with number:', shipment2.number);
// 4. Try creating another shipment (now 10/10 of Item A and 5/5 of Item B are shipped)
let exhaustedErrorThrown = false;
try {
await store.shipment.create(googleAuthId, {
orderId: order.id,
lines: [{ sku: 'ITEM-A', amount: 1 }],
});
} catch (err) {
exhaustedErrorThrown = true;
assert.match(err.message, /Shipment amount exceeds ordered amount for article \(ITEM-A\)/);
console.log('PASS: Exhausted quantity violation caught:', err.message);
}
assert.ok(exhaustedErrorThrown, 'Expected exhausted error to be thrown');
// 5. Add packages to Shipment 1 with tracking codes
const pkg1 = await store.shipping.create(googleAuthId, {
manifestId: shipment1.id,
type: 'parcel',
state: 'draft',
trackingCode: 'TRACK-111',
carrier: 'DHL',
});
assert.equal(pkg1.trackingCode, 'TRACK-111');
assert.equal(pkg1.carrier, 'DHL');
assert.equal(pkg1.state, 'draft');
console.log('PASS: Package 1 created for Shipment 1:', pkg1.number, pkg1.trackingCode);
const pkg2 = await store.shipping.create(googleAuthId, {
manifestId: shipment1.id,
type: 'parcel',
state: 'draft',
trackingCode: 'TRACK-222',
carrier: 'DHL',
});
assert.equal(pkg2.trackingCode, 'TRACK-222');
console.log('PASS: Package 2 created for Shipment 1:', pkg2.number, pkg2.trackingCode);
// Check listByOrderId
const orderShipments = await store.shipment.listByOrderId(googleAuthId, order.id);
assert.equal(orderShipments.length, 2);
assert.equal(orderShipments[0].shippings.length, 2);
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);
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)
const updatedPkg1 = await store.shipping.update(googleAuthId, pkg1.id, {
state: 'shipped',
});
assert.equal(updatedPkg1.state, 'shipped');
console.log('PASS: Package 1 transitioned to shipped');
// Transition Package 2 to shipped (stock already booked for shipment1, should not double-deduct)
const updatedPkg2 = await store.shipping.update(googleAuthId, pkg2.id, {
state: 'shipped',
});
assert.equal(updatedPkg2.state, 'shipped');
console.log('PASS: Package 2 transitioned to shipped');
// 7. Verify cannot delete shipped shipment
let deleteErrorThrown = false;
try {
await store.shipment.delete(googleAuthId, shipment1.id);
} catch (err) {
deleteErrorThrown = true;
assert.match(err.message, /Shipped shipment cannot be deleted/);
console.log('PASS: Shipped shipment delete rejected:', err.message);
}
assert.ok(deleteErrorThrown, 'Expected shipped shipment delete error');
// 8. Delete unshipped shipment 2
await store.shipment.delete(googleAuthId, shipment2.id);
const shipmentsAfterDel = await store.shipment.listByOrderId(googleAuthId, order.id);
assert.equal(shipmentsAfterDel.length, 1);
console.log('PASS: Unshipped shipment 2 successfully deleted');
console.log('--- ALL TESTS PASSED SUCCESSFULLY! ---');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('TEST FAILED:', err);
process.exit(1);
});

View File

@@ -185,6 +185,10 @@ import shippingCreate from './store/shipping/create.js';
import shippingUpdate from './store/shipping/update.js'; import shippingUpdate from './store/shipping/update.js';
import shippingListByManifestId from './store/shipping/listByManifestId.js'; import shippingListByManifestId from './store/shipping/listByManifestId.js';
import shippingDelete from './store/shipping/delete.js'; import shippingDelete from './store/shipping/delete.js';
import shipmentCreate from './store/shipment/create.js';
import shipmentGetById from './store/shipment/getById.js';
import shipmentListByOrderId from './store/shipment/listByOrderId.js';
import shipmentDelete from './store/shipment/delete.js';
import packageCreate from './store/package/create.js'; import packageCreate from './store/package/create.js';
import packageListByShippingId from './store/package/listByShippingId.js'; import packageListByShippingId from './store/package/listByShippingId.js';
import packageDelete from './store/package/delete.js'; import packageDelete from './store/package/delete.js';
@@ -457,6 +461,13 @@ export default class LibDashStore {
delete: shippingDelete(provider), delete: shippingDelete(provider),
}; };
this.shipment = {
create: shipmentCreate(provider),
getById: shipmentGetById(provider),
listByOrderId: shipmentListByOrderId(provider),
delete: shipmentDelete(provider),
};
this.package = { this.package = {
create: packageCreate(provider), create: packageCreate(provider),
listByShippingId: packageListByShippingId(provider), listByShippingId: packageListByShippingId(provider),

View File

@@ -57,13 +57,13 @@ WHERE id = @id
`; `;
export const SHIPPING_CREATE = ` export const SHIPPING_CREATE = `
INSERT INTO shipping (manifest_id, [number], [type], [state]) INSERT INTO shipping (manifest_id, [number], tracking_code, carrier, [type], [state])
OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.[type], INSERTED.[state] OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier, INSERTED.[type], INSERTED.[state]
VALUES (@manifestId, @number, @type, @state) VALUES (@manifestId, @number, @trackingCode, @carrier, @type, @state)
`; `;
export const SHIPPING_LIST_BY_MANIFEST = ` export const SHIPPING_LIST_BY_MANIFEST = `
SELECT id, manifest_id, [number], [type], [state] SELECT id, manifest_id, [number], tracking_code, carrier, [type], [state]
FROM shipping FROM shipping
WHERE manifest_id = @manifestId WHERE manifest_id = @manifestId
ORDER BY id ASC ORDER BY id ASC
@@ -76,7 +76,7 @@ WHERE id = @id
`; `;
export const GET_SHIPPING_BY_ID = ` export const GET_SHIPPING_BY_ID = `
SELECT s.id, s.manifest_id AS manifestId, s.[number], s.[type], s.[state] SELECT s.id, s.manifest_id AS manifestId, s.[number], s.tracking_code, s.carrier, s.[type], s.[state]
FROM shipping s FROM shipping s
INNER JOIN manifest m ON m.id = s.manifest_id INNER JOIN manifest m ON m.id = s.manifest_id
WHERE s.id = @id AND m.company_id = @companyId; WHERE s.id = @id AND m.company_id = @companyId;
@@ -84,8 +84,8 @@ WHERE s.id = @id AND m.company_id = @companyId;
export const SHIPPING_UPDATE = ` export const SHIPPING_UPDATE = `
UPDATE shipping UPDATE shipping
SET [type] = @type, [state] = @state SET [type] = @type, [state] = @state, tracking_code = @trackingCode, carrier = @carrier
OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.[type], INSERTED.[state] OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier, INSERTED.[type], INSERTED.[state]
WHERE id = @id; WHERE id = @id;
`; `;

View File

@@ -5,8 +5,8 @@ IF NOT EXISTS (
WHERE company_id = @companyId AND [type] = @type WHERE company_id = @companyId AND [type] = @type
) )
BEGIN BEGIN
INSERT INTO numbers (company_id, [type]) INSERT INTO numbers (company_id, [type], prefix)
VALUES (@companyId, @type); VALUES (@companyId, @type, ISNULL(@prefix, ''));
END END
`; `;

View File

@@ -0,0 +1,57 @@
export const SHIPMENT_TYPE = 'shipment';
export const GET_ORDER_HEADER = `
SELECT id, company_id AS companyId, [number], [type], entity_id AS entityId, external_number AS externalNumber, [date]
FROM manifest
WHERE id = @orderId AND company_id = @companyId AND [type] = 'order';
`;
export const GET_ORDER_LINES = `
SELECT id, sku, name, pu, amount, net, rebate, taxrate, comment
FROM manifestarticle
WHERE manifest_id = @orderId
ORDER BY id ASC;
`;
export const GET_SHIPMENT_PREFIX = `
SELECT prefix
FROM numbers
WHERE company_id = @companyId AND [type] = 'shipment';
`;
export const LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN = `
SELECT m.id, m.[number], m.[type], m.entity_id AS entityId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt
FROM manifest m
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND m.[number] LIKE @pattern
ORDER BY m.id ASC;
`;
export const GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN = `
SELECT ma.sku, SUM(ma.amount) AS shippedAmount
FROM manifest m
INNER JOIN manifestarticle ma ON ma.manifest_id = m.id
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND m.[number] LIKE @pattern
AND (@excludeShipmentId IS NULL OR m.id != @excludeShipmentId)
GROUP BY ma.sku;
`;
export const GET_SHIPMENT_HEADER = `
SELECT m.id, m.[number], m.[type], m.entity_id AS entityId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt
FROM manifest m
WHERE m.id = @id AND m.company_id = @companyId AND m.[type] = 'shipment';
`;
export const CHECK_SHIPMENT_PACKAGES_SHIPPED = `
SELECT TOP 1 id
FROM shipping
WHERE manifest_id = @id AND LOWER([state]) = 'shipped';
`;
export const DELETE_SHIPMENT = `
DELETE FROM manifest
WHERE id = @id AND company_id = @companyId AND [type] = 'shipment';
`;

View File

@@ -1151,6 +1151,8 @@ BEGIN
id INT IDENTITY(1,1) PRIMARY KEY, id INT IDENTITY(1,1) PRIMARY KEY,
manifest_id INT NOT NULL, manifest_id INT NOT NULL,
[number] NVARCHAR(100) NOT NULL, [number] NVARCHAR(100) NOT NULL,
tracking_code NVARCHAR(255) NULL,
carrier NVARCHAR(100) NULL,
[type] NVARCHAR(100) NOT NULL, [type] NVARCHAR(100) NOT NULL,
[state] NVARCHAR(100) NOT NULL, [state] NVARCHAR(100) NOT NULL,
CONSTRAINT FK_shipping_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE CONSTRAINT FK_shipping_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE

View File

@@ -5,8 +5,8 @@ const queries = translateQueries(source, 'numbers');
export const { GET_BY_COMPANY_AND_ID, LIST_BY_COMPANY, UPDATE_BY_COMPANY_AND_ID } = queries; export const { GET_BY_COMPANY_AND_ID, LIST_BY_COMPANY, UPDATE_BY_COMPANY_AND_ID } = queries;
export const ENSURE_TYPE = ` export const ENSURE_TYPE = `
INSERT INTO numbers (company_id, "type") INSERT INTO numbers (company_id, "type", prefix)
VALUES (@companyId, @type) VALUES (@companyId, @type, COALESCE(@prefix, ''))
ON CONFLICT (company_id, "type") DO NOTHING ON CONFLICT (company_id, "type") DO NOTHING
`; `;

View File

@@ -0,0 +1,15 @@
import * as source from '../../mssql/queries/shipment.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'shipment');
export const {
SHIPMENT_TYPE,
GET_ORDER_HEADER,
GET_ORDER_LINES,
GET_SHIPMENT_PREFIX,
LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN,
GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN,
GET_SHIPMENT_HEADER,
CHECK_SHIPMENT_PACKAGES_SHIPPED,
DELETE_SHIPMENT,
} = queries;

View File

@@ -823,6 +823,8 @@ CREATE TABLE IF NOT EXISTS shipping (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
manifest_id INTEGER NOT NULL, manifest_id INTEGER NOT NULL,
"number" TEXT NOT NULL, "number" TEXT NOT NULL,
tracking_code TEXT NULL,
carrier TEXT NULL,
"type" TEXT NOT NULL, "type" TEXT NOT NULL,
"state" TEXT NOT NULL, "state" TEXT NOT NULL,
FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE

View File

@@ -9,7 +9,13 @@ import {
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
const DEFAULT_TYPES = ['sku', 'entity', 'delivery', 'adjustment']; const DEFAULT_TYPES = [
{ type: 'sku', prefix: '' },
{ type: 'entity', prefix: '' },
{ type: 'delivery', prefix: '' },
{ type: 'adjustment', prefix: '' },
{ type: 'shipment', prefix: '-L-' },
];
function shape(row) { function shape(row) {
return { return {
@@ -30,7 +36,7 @@ export default function list(provider) {
/** /**
* Lists the number sequences of the user's selected company. * Lists the number sequences of the user's selected company.
* Ensures that default sequences ('sku', 'entity', 'delivery') exist. * Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
@@ -44,8 +50,10 @@ export default function list(provider) {
const { companyId } = await access(googleAuthId, tx); const { companyId } = await access(googleAuthId, tx);
const runner = makeRunner(provider, tx); const runner = makeRunner(provider, tx);
for (const type of DEFAULT_TYPES) { for (const item of DEFAULT_TYPES) {
await runner.query(ensureSql, { companyId, type }); const type = typeof item === 'string' ? item : item.type;
const prefix = typeof item === 'string' ? (type === 'shipment' ? '-L-' : '') : item.prefix;
await runner.query(ensureSql, { companyId, type, prefix });
} }
const result = await runner.query(sql, { companyId }); const result = await runner.query(sql, { companyId });

View File

@@ -37,7 +37,8 @@ export default function nextNumber(provider) {
*/ */
return async (companyId, type, tx) => { return async (companyId, type, tx) => {
const doAlloc = async (runner) => { const doAlloc = async (runner) => {
await runner.query(ensureSql, { companyId, type }); const prefix = type === 'shipment' ? '-L-' : '';
await runner.query(ensureSql, { companyId, type, prefix });
const res = await runner.query(getSql, { companyId, type }); const res = await runner.query(getSql, { companyId, type });
const row = res.rows[0]; const row = res.rows[0];
if (!row) { if (!row) {

View File

@@ -0,0 +1,195 @@
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';
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.
*
* @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 {string} [input.externalNumber] - Optional external delivery note number.
* @param {Array<{sku: string, amount: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created shipment manifest.
* @throws {Error} If order not found, invalid lines, or article ceiling invariant exceeded.
*
* @example
* const shipment = await store.shipment.create(googleAuthId, {
* orderId: 10,
* lines: [{ sku: 'ITEM-A', amount: 5 }]
* });
*/
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');
}
const shipmentDate = parseDate(input.date) || new Date();
const externalNumber = input.externalNumber?.trim?.()
? input.externalNumber.trim()
: null;
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');
}
// 2. Retrieve parent order lines
const orderLinesRes = await runner.query(q.GET_ORDER_LINES, {
orderId,
});
const orderLines = orderLinesRes.rows ?? [];
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. Validate proposed lines SKUs and amounts
const proposedBySku = new Map();
for (const line of input.lines) {
const sku = String(line?.sku ?? '').trim();
const amount = Number(line?.amount);
if (!sku || !orderAmountBySku.has(sku)) {
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
}
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error('Invalid lines');
}
proposedBySku.set(sku, (proposedBySku.get(sku) || 0) + amount);
}
// 4. Retrieve shipment prefix from numbers table
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
// 5. 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));
}
// 6. Quantity ceiling check
for (const [sku, proposedAmount] of proposedBySku.entries()) {
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
const ordered = orderAmountBySku.get(sku) || 0;
if (alreadyShipped + proposedAmount > ordered) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
}
}
// 7. 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}`;
// 8. 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;
// 9. Prepare lines copying defaults from order lines
const linesToInsert = input.lines.map((line) => {
const sku = String(line.sku).trim();
const ol = orderLineBySku.get(sku);
return {
sku,
name: line.name ?? ol?.name ?? sku,
pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1),
amount: Number(line.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),
};
});
await insertLines(runner, {
manifestId,
lines: linesToInsert,
ensureTax,
});
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));
};
}

View File

@@ -0,0 +1,60 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { queriesFor } from './helpers.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Deletes a shipment manifest of the selected company.
* Shipped shipments (with packages in 'shipped' state) cannot be deleted.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
/**
* Deletes a shipment manifest.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The shipment manifest ID.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If shipment not found or already has shipped packages.
*
* @example
* await store.shipment.delete(googleAuthId, 42);
*/
return async (googleAuthId, id, tx) => {
const shipmentId = requireId(id, 'Shipment');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
id: shipmentId,
companyId,
});
if (!check.rows[0]) {
throw new Error('Shipment not found');
}
const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, {
id: shipmentId,
});
if (shippedCheck.rows[0]) {
throw new Error('Shipped shipment cannot be deleted');
}
await runner.query(q.DELETE_SHIPMENT, {
id: shipmentId,
companyId,
});
};
}

View File

@@ -0,0 +1,46 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { loadShipment, queriesFor } from './helpers.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Retrieves a shipment manifest by ID for the selected company, including lines and packages.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function getById(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
/**
* Retrieves a shipment manifest by ID.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The shipment manifest ID.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The shipment manifest.
* @throws {Error} If shipment not found.
*
* @example
* const shipment = await store.shipment.getById(googleAuthId, 42);
*/
return async (googleAuthId, id, tx) => {
const shipmentId = requireId(id, 'Shipment');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const shipment = await loadShipment(runner, q, companyId, shipmentId);
if (!shipment) {
throw new Error('Shipment not found');
}
return shipment;
};
}

View File

@@ -0,0 +1,63 @@
import * as mssqlShipmentQueries from '../../providers/mssql/queries/shipment.js';
import * as sqliteShipmentQueries from '../../providers/sqlite3/queries/shipment.js';
import * as mssqlDocQueries from '../../providers/mssql/queries/manifestDoc.js';
import * as sqliteDocQueries from '../../providers/sqlite3/queries/manifestDoc.js';
import * as mssqlLogisticsQueries from '../../providers/mssql/queries/logistics.js';
import * as sqliteLogisticsQueries from '../../providers/sqlite3/queries/logistics.js';
import { shapeLine } from '../manifestLines.js';
export function queriesFor(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
return {
...(isSqlite ? sqliteShipmentQueries : mssqlShipmentQueries),
CREATE_MANIFEST: isSqlite
? sqliteDocQueries.CREATE_MANIFEST
: mssqlDocQueries.CREATE_MANIFEST,
GET_MANIFEST_LINES: isSqlite
? sqliteDocQueries.GET_MANIFEST_LINES
: mssqlDocQueries.GET_MANIFEST_LINES,
SHIPPING_LIST_BY_MANIFEST: isSqlite
? sqliteLogisticsQueries.SHIPPING_LIST_BY_MANIFEST
: mssqlLogisticsQueries.SHIPPING_LIST_BY_MANIFEST,
};
}
export function shapeShipping(row) {
return {
id: row.id,
manifestId: row.manifest_id ?? row.manifestId,
number: row.number,
trackingCode: row.tracking_code ?? row.trackingCode ?? null,
carrier: row.carrier ?? null,
type: row.type,
state: row.state,
};
}
export async function loadShipment(runner, q, companyId, id) {
const headerRes = await runner.query(q.GET_SHIPMENT_HEADER, { id, companyId });
const row = headerRes.rows[0];
if (!row) return null;
const [linesRes, shippingsRes] = await Promise.all([
runner.query(q.GET_MANIFEST_LINES, { manifestId: id }),
runner.query(q.SHIPPING_LIST_BY_MANIFEST, { manifestId: id }),
]);
const lines = linesRes.rows.map(shapeLine);
const shippings = shippingsRes.rows.map(shapeShipping);
return {
id: row.id,
number: row.number,
type: 'shipment',
entityId: row.entityId ?? null,
externalNumber: row.externalNumber ?? null,
date: row.date,
createdAt: row.createdAt ?? null,
lineCount: lines.length,
packageCount: shippings.length,
lines,
shippings,
};
}

View File

@@ -0,0 +1,64 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { loadShipment, queriesFor } from './helpers.js';
function requireId(val, name) {
const n = Number(val);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} is required`);
}
return n;
}
/**
* Lists all shipment manifests belonging to an order.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function listByOrderId(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
/**
* Lists shipments of an order.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} orderId - The parent order manifest id.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<object>>} The shipment manifests with lines and packages.
* @throws {Error} If order not found.
*
* @example
* const shipments = await store.shipment.listByOrderId(googleAuthId, 10);
*/
return async (googleAuthId, orderIdInput, tx) => {
const orderId = requireId(orderIdInput, 'Order');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
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 prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const pattern = `${order.number}${shipmentPrefix}%`;
const shipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
companyId,
pattern,
});
const rows = shipmentsRes.rows ?? [];
const shipments = await Promise.all(
rows.map((row) => loadShipment(runner, q, companyId, row.id))
);
return shipments.filter(Boolean);
};
}

View File

@@ -7,8 +7,10 @@ import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
id: row.id, id: row.id,
manifestId: row.manifest_id, manifestId: row.manifest_id ?? row.manifestId,
number: row.number, number: row.number,
trackingCode: row.tracking_code ?? row.trackingCode ?? null,
carrier: row.carrier ?? null,
type: row.type, type: row.type,
state: row.state, state: row.state,
}; };
@@ -21,34 +23,38 @@ export default function create(provider) {
const q = queriesFor(provider); const q = queriesFor(provider);
/** /**
* Creates a shipping for a manifest (order). The number is allocated from the * Creates a shipping (package) for a shipment manifest. The number is allocated from the
* `shipping` numbers sequence (own prefix + counter). If state is 'shipped', * `shipping` numbers sequence (own prefix + counter). If state is 'shipped',
* adjusts stock by appending negative ledger movements for tracked order lines, * adjusts stock by appending negative ledger movements for tracked shipment lines,
* writes article history for all items, and ensures the order was not already shipped. * writes article history for all items, and ensures stock is deducted once.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Shipping input. * @param {object} input - Shipping input.
* @param {number} input.manifestId - The manifest (order) id. * @param {number} input.manifestId - The shipment manifest id.
* @param {string} input.type - Shipping type. * @param {string} input.type - Shipping type.
* @param {string} input.state - Shipping state ('shipped' or other). * @param {string} input.state - Shipping state ('shipped' or other).
* @param {string} [input.trackingCode] - Carrier tracking code.
* @param {string} [input.carrier] - Carrier name.
* @param {number} [input.warehouseId] - Optional warehouse ID for stock deduction (defaults to company's first warehouse). * @param {number} [input.warehouseId] - Optional warehouse ID for stock deduction (defaults to company's first warehouse).
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, manifestId: number, number: string, type: string, state: string}>} * @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>}
* @throws {Error} If manifest is not found, order already shipped, or input is invalid. * @throws {Error} If manifest is not found, manifest is not a shipment, or input is invalid.
* *
* @example * @example
* await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped' }); * await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped', trackingCode: '1Z999', carrier: 'UPS' });
*/ */
return async (googleAuthId, input = {}, tx) => { return async (googleAuthId, input = {}, tx) => {
const manifestId = requireId(input.manifestId, 'Manifest'); const manifestId = requireId(input.manifestId, 'Manifest');
const type = requireText(input.type, 'Shipping type'); const type = requireText(input.type, 'Shipping type');
const state = requireText(input.state, 'Shipping state'); const state = requireText(input.state, 'Shipping state');
const trackingCode = input.trackingCode ? String(input.trackingCode).trim() : null;
const carrier = input.carrier ? String(input.carrier).trim() : null;
const isShipped = state.trim().toLowerCase() === 'shipped'; const isShipped = state.trim().toLowerCase() === 'shipped';
const run = async (runner, innerTx) => { const run = async (runner, innerTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner); const { companyId, companyuserId } = await access(googleAuthId, runner);
// Verify manifest exists and belongs to company // Verify manifest exists, belongs to company, and is a shipment
const manifestRes = await runner.query(q.GET_MANIFEST_HEADER_BY_ID, { const manifestRes = await runner.query(q.GET_MANIFEST_HEADER_BY_ID, {
manifestId, manifestId,
companyId, companyId,
@@ -57,15 +63,17 @@ export default function create(provider) {
if (!manifest) { if (!manifest) {
throw new Error('Manifest not found'); throw new Error('Manifest not found');
} }
if (manifest.type !== 'shipment') {
throw new Error('Manifest is not a shipment');
}
let alreadyShipped = false;
if (isShipped) { if (isShipped) {
// Check if order was already shipped // Check if shipment was already marked shipped by an existing package
const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, { const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, {
manifestId, manifestId,
}); });
if (shippedCheck.rows[0]) { alreadyShipped = Boolean(shippedCheck.rows[0]);
throw new Error('Order already shipped');
}
} }
// Allocate shipping number and insert shipping record // Allocate shipping number and insert shipping record
@@ -73,6 +81,8 @@ export default function create(provider) {
const result = await runner.query(q.SHIPPING_CREATE, { const result = await runner.query(q.SHIPPING_CREATE, {
manifestId, manifestId,
number, number,
trackingCode,
carrier,
type, type,
state, state,
}); });
@@ -80,12 +90,14 @@ export default function create(provider) {
id: result.insertId, id: result.insertId,
manifest_id: manifestId, manifest_id: manifestId,
number, number,
tracking_code: trackingCode,
carrier,
type, type,
state, state,
}; };
if (isShipped) { if (isShipped && !alreadyShipped) {
// Load order lines // Load shipment manifest lines
const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, { const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, {
manifestId, manifestId,
}); });
@@ -156,7 +168,7 @@ export default function create(provider) {
warehouse_id: warehouseId, warehouse_id: warehouseId,
transaction_type: 'SHIPPING', transaction_type: 'SHIPPING',
amount: -line.amount, amount: -line.amount,
transaction_text: `Versand Auftrag ${manifestNumber}`, transaction_text: `Versand Lieferschein ${manifestNumber}`,
}, },
innerTx, innerTx,
); );

View File

@@ -30,6 +30,8 @@ export default function listByManifestId(provider) {
id: row.id, id: row.id,
manifestId: row.manifest_id, manifestId: row.manifest_id,
number: row.number, number: row.number,
trackingCode: row.tracking_code ?? null,
carrier: row.carrier ?? null,
type: row.type, type: row.type,
state: row.state, state: row.state,
})); }));

View File

@@ -6,8 +6,10 @@ import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
id: row.id, id: row.id,
manifestId: row.manifest_id, manifestId: row.manifest_id ?? row.manifestId,
number: row.number, number: row.number,
trackingCode: row.tracking_code ?? row.trackingCode ?? null,
carrier: row.carrier ?? null,
type: row.type, type: row.type,
state: row.state, state: row.state,
}; };
@@ -33,10 +35,12 @@ export default function update(provider) {
* @param {object} patch - Shipping patch fields. * @param {object} patch - Shipping patch fields.
* @param {string} [patch.type] - Shipping type. * @param {string} [patch.type] - Shipping type.
* @param {string} [patch.state] - Shipping state. * @param {string} [patch.state] - Shipping state.
* @param {string} [patch.trackingCode] - Carrier tracking code.
* @param {string} [patch.carrier] - Carrier name.
* @param {number} [patch.warehouseId] - Warehouse ID if transitioning to 'shipped'. * @param {number} [patch.warehouseId] - Warehouse ID if transitioning to 'shipped'.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, manifestId: number, number: string, type: string, state: string}>} * @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>}
* @throws {Error} If shipping is not found, cannot be undone, or order already shipped. * @throws {Error} If shipping is not found, cannot be undone, or manifest not found.
*/ */
return async (googleAuthId, id, patch = {}, tx) => { return async (googleAuthId, id, patch = {}, tx) => {
const shippingId = requireId(id, 'Shipping'); const shippingId = requireId(id, 'Shipping');
@@ -57,6 +61,8 @@ export default function update(provider) {
String(current.state || '').trim().toLowerCase() === 'shipped'; String(current.state || '').trim().toLowerCase() === 'shipped';
const hasState = Object.prototype.hasOwnProperty.call(patch, 'state'); const hasState = Object.prototype.hasOwnProperty.call(patch, 'state');
const hasType = Object.prototype.hasOwnProperty.call(patch, 'type'); const hasType = Object.prototype.hasOwnProperty.call(patch, 'type');
const hasTrackingCode = Object.prototype.hasOwnProperty.call(patch, 'trackingCode');
const hasCarrier = Object.prototype.hasOwnProperty.call(patch, 'carrier');
if (isCurrentlyShipped) { if (isCurrentlyShipped) {
if ( if (
@@ -73,6 +79,12 @@ export default function update(provider) {
const nextState = hasState const nextState = hasState
? requireText(patch.state, 'Shipping state') ? requireText(patch.state, 'Shipping state')
: current.state; : current.state;
const nextTrackingCode = hasTrackingCode
? (patch.trackingCode ? String(patch.trackingCode).trim() : null)
: (current.tracking_code ?? current.trackingCode ?? null);
const nextCarrier = hasCarrier
? (patch.carrier ? String(patch.carrier).trim() : null)
: (current.carrier ?? null);
const willBeShipped = const willBeShipped =
!isCurrentlyShipped && !isCurrentlyShipped &&
String(nextState).trim().toLowerCase() === 'shipped'; String(nextState).trim().toLowerCase() === 'shipped';
@@ -82,86 +94,86 @@ export default function update(provider) {
const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, { const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, {
manifestId, manifestId,
}); });
if (shippedCheck.rows[0]) { const alreadyShipped = Boolean(shippedCheck.rows[0]);
throw new Error('Order already shipped');
}
const manifestRes = await runner.query(q.GET_MANIFEST_HEADER_BY_ID, { if (!alreadyShipped) {
manifestId, const manifestRes = await runner.query(q.GET_MANIFEST_HEADER_BY_ID, {
companyId, manifestId,
});
const manifest = manifestRes.rows[0] ?? { number: String(manifestId) };
// Load lines & book ledger
const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, {
manifestId,
});
const lines = linesRes.rows ?? [];
const trackedLines = [];
const untrackedLines = [];
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount <= 0) continue;
const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
companyId, companyId,
sku,
}); });
const isUntracked = (trackRes.rows ?? []).some( const manifest = manifestRes.rows[0] ?? { number: String(manifestId) };
(r) => Number(r.track_stock ?? 1) === 0
);
if (isUntracked) {
untrackedLines.push({ ...line, amount });
} else if ((trackRes.rows ?? []).length > 0) {
trackedLines.push({ ...line, amount });
}
}
const manifestNumber = manifest.number || String(manifestId); // Load lines & book ledger
const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, {
let customerName = null; manifestId,
if (manifest.entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
entityId: manifest.entityId,
}); });
customerName = entRes.rows[0]?.name ?? null; const lines = linesRes.rows ?? [];
} const trackedLines = [];
const untrackedLines = [];
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount <= 0) continue;
if (trackedLines.length > 0) { const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
let warehouseId = null;
if (patch.warehouseId != null) {
const requestedWhId = requireId(patch.warehouseId, 'Warehouse');
const whCheck = await runner.query(q.CHECK_WAREHOUSE, {
warehouseId: requestedWhId,
companyId, companyId,
sku,
}); });
if (!whCheck.rows[0]) { const isUntracked = (trackRes.rows ?? []).some(
throw new Error('Warehouse not found'); (r) => Number(r.track_stock ?? 1) === 0
}
warehouseId = requestedWhId;
} else {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId,
});
if (!defaultWh.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = defaultWh.rows[0].id;
}
for (const line of trackedLines) {
await addLedger(
googleAuthId,
{
manifestarticle_id: line.id,
warehouse_id: warehouseId,
transaction_type: 'SHIPPING',
amount: -line.amount,
transaction_text: `Versand Auftrag ${manifestNumber}`,
},
innerTx,
); );
if (isUntracked) {
untrackedLines.push({ ...line, amount });
} else if ((trackRes.rows ?? []).length > 0) {
trackedLines.push({ ...line, amount });
}
}
const manifestNumber = manifest.number || String(manifestId);
let customerName = null;
if (manifest.entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
entityId: manifest.entityId,
});
customerName = entRes.rows[0]?.name ?? null;
}
if (trackedLines.length > 0) {
let warehouseId = null;
if (patch.warehouseId != null) {
const requestedWhId = requireId(patch.warehouseId, 'Warehouse');
const whCheck = await runner.query(q.CHECK_WAREHOUSE, {
warehouseId: requestedWhId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = requestedWhId;
} else {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId,
});
if (!defaultWh.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = defaultWh.rows[0].id;
}
for (const line of trackedLines) {
await addLedger(
googleAuthId,
{
manifestarticle_id: line.id,
warehouse_id: warehouseId,
transaction_type: 'SHIPPING',
amount: -line.amount,
transaction_text: `Versand Lieferschein ${manifestNumber}`,
},
innerTx,
);
}
} }
} }
} }
@@ -170,12 +182,16 @@ export default function update(provider) {
id: shippingId, id: shippingId,
type: nextType, type: nextType,
state: nextState, state: nextState,
trackingCode: nextTrackingCode,
carrier: nextCarrier,
}); });
const updatedRow = updateRes.rows[0] ?? { const updatedRow = updateRes.rows[0] ?? {
id: shippingId, id: shippingId,
manifest_id: current.manifestId, manifest_id: current.manifestId,
number: current.number, number: current.number,
tracking_code: nextTrackingCode,
carrier: nextCarrier,
type: nextType, type: nextType,
state: nextState, state: nextState,
}; };