This commit is contained in:
seb
2026-10-15 20:14:51 +02:00
parent 7c8c24eb4d
commit 2179b2e241
23 changed files with 1242 additions and 105 deletions

6
API.md
View File

@@ -185,6 +185,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}>}, 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}>}>`
- `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}>}, 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<object>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `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<object>>`
@@ -195,7 +196,11 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `create(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `delete(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `getById(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, state?: 'new'|'delivered'|'returned', seekMonths?: number, orderId?: number}, tx?: object): Promise<{shipments: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
- `listByOrderId(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `rebuildSummaries(googleAuthId: number, tx?: object): Promise<void>`
- `syncSummary(): void`
- `update(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>`
@@ -261,6 +266,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `getSmallThumbByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- `getThumbByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- [numbers](API.numbers.md)
- `ensureType(runner: object, companyId: number, type: string): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>`
- `update(googleAuthId: number, id: number, input: {prefix?: string, padding?: number, next?: number}, tx?: object): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>`
- [propertyname](API.propertyname.md)

View File

@@ -2,11 +2,29 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `numbers.ensureType`
*Source: `src/store/numbers/ensureType.js`*
Ensures a number sequence row exists for a company and type, inferring the company's prefix style, padding, and next counter if not yet created or unconfigured.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `runner` | `object` | The database runner. |
| `companyId` | `number` | The id of the company. |
| `type` | `string` | The number type (e.g. 'order', 'quote', 'sku'). |
**Returns**
`Promise<{id: number, type: string, prefix: string, padding: number, next: number}>` — The sequence row.
## `numbers.list`
*Source: `src/store/numbers/list.js`*
Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
Lists the number sequences of the user's selected company. Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment', 'quote', 'order') exist.
**Parameters**

View File

@@ -40,7 +40,7 @@ const order = await store.order.create(42, { number: 'B-2026-001', date: '2026-0
*Source: `src/store/order/createFromQuote.js`*
Creates a new sales order from an existing quote for the user's selected company. Copies lines, entity, and external number from the quote unless explicitly overridden. If order number is omitted, it is auto-allocated from the 'order' number sequence.
Creates a new sales order from an existing quote for the user's selected company. Copies lines and customer entity from the quote. Sets external reference to the quote number (quote.number) unless explicitly overridden. If order number is omitted, it is auto-allocated from the 'order' number sequence.
**Parameters**
@@ -53,7 +53,7 @@ Creates a new sales order from an existing quote for the user's selected company
| `[body.date]` | `string` | The order date; defaults to current date if omitted. |
| `[body.createdAt]` | `string` | Creation timestamp; defaults to current time if omitted. |
| `[body.entityId]` | `number` | Customer entity id override; defaults to quote's entityId. |
| `[body.externalNumber]` | `string` | Customer reference override; defaults to quote's externalNumber. |
| `[body.externalNumber]` | `string` | Customer reference override; defaults to quote's number. |
| `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | Lines override; defaults to quote's lines. |
| `[tx]` | `object` | Optional transaction to run within. |
@@ -63,7 +63,7 @@ Creates a new sales order from an existing quote for the user's selected company
**Throws**
`Error` — If the quote is not found, or the order is invalid.
`Error` — If the quote is not found, no entity is selected, or the order is invalid.
**Example**
@@ -72,6 +72,34 @@ const order = await store.order.createFromQuote(42, 10);
// => { id, number, type: 'order', entityId, externalNumber, date, lines: [...] }
```
## `order.createShipment`
*Source: `src/store/order/createShipment.js`*
Creates a new shipment manifest for an order. Can contain one, some, or all items from the parent order. If lines is omitted or empty, all remaining unshipped items are included.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `orderId` | `number` | The parent order manifest id. |
| `[input]` | `object` | Optional overrides. |
| `[input.lines]` | `Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>` | Positions (defaults to all remaining items). |
| `[input.date]` | `string|Date` | Optional shipment date. |
| `[input.externalNumber]` | `string` | Optional external delivery reference. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The created shipment.
**Example**
```js
const shipment = await store.order.createShipment(googleAuthId, 10);
```
## `order.delete`
*Source: `src/store/order/delete.js`*

View File

@@ -38,6 +38,30 @@ Retrieves a shipment manifest by ID for the selected company, including lines an
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.list`
*Source: `src/store/shipment/list.js`*
Lists the shipments of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional pagination and filter options. |
| `[opts.offset=0]` | `number` | Number of shipments to skip. |
| `[opts.limit=50]` | `number` | Number of shipments to return. |
| `[opts.search]` | `string` | Free-text search matching shipment number, order number, external number, tracking code, or customer name. |
| `[opts.state]` | `'new'|'delivered'|'returned'` | Filter by delivery state. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[opts.orderId]` | `number` | Optional filter for parent order manifest id. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{shipments: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
## `shipment.listByOrderId`
*Source: `src/store/shipment/listByOrderId.js`*
@@ -49,3 +73,38 @@ Lists all shipment manifests belonging to an order.
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `shipment.rebuildSummaries`
*Source: `src/store/shipment/rebuildSummaries.js`*
Rebuilds the `shipmentsummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<void>`
## `shipment.syncSummary`
*Source: `src/store/shipment/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `shipmentsummary` table.
## `shipment.update`
*Source: `src/store/shipment/update.js`*
Updates a shipment's state or metadata.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |

View File

@@ -63,8 +63,8 @@ async function run() {
assert.ok(order1.id, 'Order must have an id');
assert.equal(order1.type, 'order');
assert.equal(order1.entityId, customer.id);
assert.equal(order1.externalNumber, 'EXT-QUOTE-123');
assert.ok(order1.number, 'Order must have auto-allocated number');
assert.equal(order1.externalNumber, quote.number);
assert.equal(order1.number, 'AUF-000001');
assert.equal(order1.lines.length, 1);
assert.equal(order1.lines[0].sku, art.sku);
assert.equal(order1.lines[0].amount, 5);
@@ -106,6 +106,35 @@ async function run() {
{ message: 'Quote not found' }
);
// 6. Quote without entity should throw 'No entity selected' if no entity passed
const quoteWithoutEntity = await store.quote.create(googleAuthId, {
number: 'Q-NO-ENT',
date: '2026-09-01T10:00:00Z',
lines: [{ sku: art.sku, name: 'Gadget', amount: 1, net: 10, taxrate: 19 }],
});
assert.equal(quoteWithoutEntity.entityId, null);
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, quoteWithoutEntity.id);
},
{ message: 'No entity selected' }
);
// 7. Explicitly overriding with entityId: null should also throw 'No entity selected'
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, quote.id, { entityId: null });
},
{ message: 'No entity selected' }
);
// 8. Passing entityId for quote without entity should succeed
const orderFromNoEnt = await store.order.createFromQuote(googleAuthId, quoteWithoutEntity.id, {
entityId: customer.id,
});
assert.equal(orderFromNoEnt.entityId, customer.id);
console.log('PASS: All order.createFromQuote tests passed successfully!');
} finally {
try {

View File

@@ -214,7 +214,76 @@ async function run() {
assert.equal(shipmentsAfterDel.length, 1);
console.log('PASS: Unshipped shipment 2 successfully deleted');
// 9. Verify peek next number works for entity and other types without error
// 9. Test order2: One item, some items, all items, and automatic ordersummary updates
const order2 = await store.order.create(googleAuthId, {
number: 'AU-1002',
date: '2026-10-15',
entityId: customer.id,
lines: [
{ sku: 'ITEM-A', amount: 10, net: 100, taxrate: 19 },
{ sku: 'ITEM-B', amount: 5, net: 50, taxrate: 19 },
],
});
// Check initial ordersummary for order2
let sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id);
assert.equal(sum2.ordered_quantity, 15);
assert.equal(sum2.shipped_quantity, 0);
assert.equal(sum2.is_shipped, 0);
// 9a. Ship ONE item (3x ITEM-A) via store.order.createShipment
const shOne = await store.order.createShipment(googleAuthId, order2.id, {
lines: [{ sku: 'ITEM-A', amount: 3 }],
});
assert.equal(shOne.number, 'AU-1002-L-1');
assert.equal(shOne.lines.length, 1);
assert.equal(shOne.lines[0].sku, 'ITEM-A');
assert.equal(shOne.lines[0].amount, 3);
// Verify ordersummary updated: shipped_quantity = 3, is_shipped = 0
sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id);
assert.equal(sum2.shipped_quantity, 3);
assert.equal(sum2.is_shipped, 0);
console.log('PASS: 9a. Shipped ONE item; ordersummary updated with shipped_quantity=3, is_shipped=0');
// 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, {
lines: [{ sku: 'ITEM-A' }],
});
assert.equal(shSome.number, 'AU-1002-L-2');
assert.equal(shSome.lines.length, 1);
assert.equal(shSome.lines[0].sku, 'ITEM-A');
assert.equal(shSome.lines[0].amount, 7);
// Verify ordersummary updated: shipped_quantity = 10 (3 + 7), is_shipped = 0
sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id);
assert.equal(sum2.shipped_quantity, 10);
assert.equal(sum2.is_shipped, 0);
console.log('PASS: 9b. Shipped SOME items with omitted amount; ordersummary updated with shipped_quantity=10, is_shipped=0');
// 9c. Ship ALL remaining items by omitting lines entirely ({})
const shAll = await store.order.createShipment(googleAuthId, order2.id, {});
assert.equal(shAll.number, 'AU-1002-L-3');
assert.equal(shAll.lines.length, 1);
assert.equal(shAll.lines[0].sku, 'ITEM-B');
assert.equal(shAll.lines[0].amount, 5);
// Verify ordersummary updated: shipped_quantity = 15, is_shipped = 1 (order is fully shipped!)
sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id);
assert.equal(sum2.shipped_quantity, 15);
assert.equal(sum2.is_shipped, 1);
console.log('PASS: 9c. Shipped ALL remaining items with omitted lines; ordersummary updated with shipped_quantity=15, is_shipped=1');
// 9d. Calling createShipment when all items already shipped throws 'All items already shipped'
await assert.rejects(
async () => {
await store.order.createShipment(googleAuthId, order2.id);
},
{ message: 'All items already shipped' }
);
console.log('PASS: 9d. Re-shipping fully shipped order rejected with "All items already shipped"');
// 10. Verify peek next number works for entity and other types without error
const nextEntity = await store.entity.peekNextNumber(googleAuthId);
assert.ok(typeof nextEntity === 'string' && nextEntity.length > 0);
console.log('PASS: store.entity.peekNextNumber succeeded:', nextEntity);
@@ -223,8 +292,41 @@ async function run() {
assert.ok(Array.isArray(numList) && numList.length > 0);
const shipmentSeq = numList.find((n) => n.type === 'shipment');
assert.ok(shipmentSeq, 'Expected shipment sequence in numbers.list');
assert.equal(shipmentSeq.prefix, '-L-');
console.log('PASS: store.numbers.list includes shipment with prefix -L-');
// 11. Test store.shipment.list, store.shipment.update, and rebuildSummaries
const listAll = await store.shipment.list(googleAuthId, { offset: 0, limit: 10 });
assert.ok(listAll.total >= 3, `Expected at least 3 shipments, got ${listAll.total}`);
assert.equal(listAll.limit, 10);
assert.equal(listAll.offset, 0);
const s1 = listAll.shipments.find((s) => s.id === shOne.id);
assert.ok(s1, 'Expected shOne in shipment list');
assert.equal(s1.orderNumber, 'AU-1002');
assert.equal(s1.orderId, order2.id);
assert.equal(s1.state, 'new');
assert.equal(s1.positions, 1);
assert.equal(s1.totalQuantity, 3);
console.log('PASS: store.shipment.list returned shipments with order info and state');
// 11a. Search by order number
const listSearchOrder = await store.shipment.list(googleAuthId, { search: 'AU-1002', offset: 0, limit: 10 });
assert.equal(listSearchOrder.total, 3);
console.log('PASS: store.shipment.list filtered by orderNumber search');
// 11b. Update shipment state to 'delivered'
const updatedSh = await store.shipment.update(googleAuthId, shOne.id, { state: 'delivered' });
assert.equal(updatedSh.state, 'delivered');
// 11c. Filter by state = 'delivered'
const listDelivered = await store.shipment.list(googleAuthId, { state: 'delivered', offset: 0, limit: 10 });
assert.equal(listDelivered.total, 1);
assert.equal(listDelivered.shipments[0].id, shOne.id);
console.log('PASS: store.shipment.update set state="delivered" and filtered correctly');
// 11d. Rebuild shipment summaries
await store.shipment.rebuildSummaries(googleAuthId);
const listAfterRebuild = await store.shipment.list(googleAuthId, { state: 'delivered', offset: 0, limit: 10 });
assert.equal(listAfterRebuild.total, 1);
console.log('PASS: store.shipment.rebuildSummaries preserved delivered state and rebuilt correctly');
console.log('--- ALL TESTS PASSED SUCCESSFULLY! ---');
} finally {

View File

@@ -100,6 +100,7 @@ import orderList from './store/order/list.js';
import orderGetById from './store/order/getById.js';
import orderCreate from './store/order/create.js';
import orderCreateFromQuote from './store/order/createFromQuote.js';
import orderCreateShipment from './store/order/createShipment.js';
import orderUpdate from './store/order/update.js';
import orderDelete from './store/order/delete.js';
import orderRebuildSummaries from './store/order/rebuildSummaries.js';
@@ -192,8 +193,11 @@ import shippingListByManifestId from './store/shipping/listByManifestId.js';
import shippingDelete from './store/shipping/delete.js';
import shipmentCreate from './store/shipment/create.js';
import shipmentGetById from './store/shipment/getById.js';
import shipmentList from './store/shipment/list.js';
import shipmentListByOrderId from './store/shipment/listByOrderId.js';
import shipmentUpdate from './store/shipment/update.js';
import shipmentDelete from './store/shipment/delete.js';
import shipmentRebuildSummaries from './store/shipment/rebuildSummaries.js';
import packageCreate from './store/package/create.js';
import packageListByShippingId from './store/package/listByShippingId.js';
import packageDelete from './store/package/delete.js';
@@ -434,6 +438,7 @@ export default class LibDashStore {
getById: orderGetById(provider),
create: orderCreate(provider),
createFromQuote: orderCreateFromQuote(provider),
createShipment: orderCreateShipment(provider),
update: orderUpdate(provider),
delete: orderDelete(provider),
rebuildSummaries: orderRebuildSummaries(provider),
@@ -486,8 +491,11 @@ export default class LibDashStore {
this.shipment = {
create: shipmentCreate(provider),
getById: shipmentGetById(provider),
list: shipmentList(provider),
listByOrderId: shipmentListByOrderId(provider),
update: shipmentUpdate(provider),
delete: shipmentDelete(provider),
rebuildSummaries: shipmentRebuildSummaries(provider),
};
this.package = {

View File

@@ -1,5 +1,6 @@
/** Drop order = reverse of schema create dependency order. */
export const TABLES = [
'shipmentsummary',
'quotesummary',
'ordersummary',
'package',

View File

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

View File

@@ -1243,6 +1243,41 @@ BEGIN
END
`;
export const INIT_SHIPMENTSUMMARY = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'shipmentsummary')
BEGIN
CREATE TABLE shipmentsummary (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
manifest_id INT NOT NULL CONSTRAINT UQ_shipmentsummary_manifest UNIQUE,
shipment_number NVARCHAR(100) NOT NULL,
shipment_date DATETIME2 NOT NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_shipmentsummary_created_at DEFAULT SYSUTCDATETIME(),
order_id INT NULL,
order_number NVARCHAR(100) NULL,
entity_id INT NULL,
customer_name NVARCHAR(255) NULL,
positions INT NOT NULL CONSTRAINT DF_shipmentsummary_positions DEFAULT 0,
total_quantity FLOAT NOT NULL CONSTRAINT DF_shipmentsummary_total_quantity DEFAULT 0,
package_count INT NOT NULL CONSTRAINT DF_shipmentsummary_package_count DEFAULT 0,
carrier NVARCHAR(100) NULL,
tracking_code NVARCHAR(255) NULL,
state NVARCHAR(50) NOT NULL CONSTRAINT DF_shipmentsummary_state DEFAULT 'new',
CONSTRAINT FK_shipmentsummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
CONSTRAINT FK_shipmentsummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
CONSTRAINT FK_shipmentsummary_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
);
CREATE INDEX IX_shipmentsummary_company_date
ON shipmentsummary (company_id, shipment_date DESC, manifest_id DESC);
CREATE INDEX IX_shipmentsummary_company_number
ON shipmentsummary (company_id, shipment_number);
CREATE INDEX IX_shipmentsummary_company_state
ON shipmentsummary (company_id, state);
CREATE INDEX IX_shipmentsummary_company_order
ON shipmentsummary (company_id, order_id);
END
`;
export const INIT_MCP_CLIENT = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
BEGIN
@@ -1367,6 +1402,7 @@ export const SCHEMA_SCRIPTS = [
INIT_PACKAGE,
INIT_ORDERSUMMARY,
INIT_QUOTESUMMARY,
INIT_SHIPMENTSUMMARY,
INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN,

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 ENSURE_TYPE = `
INSERT INTO numbers (company_id, "type", prefix)
VALUES (@companyId, @type, COALESCE(@prefix, ''))
INSERT INTO numbers (company_id, "type", prefix, padding, "next")
VALUES (@companyId, @type, COALESCE(@prefix, ''), COALESCE(@padding, 0), COALESCE(@next, 1))
ON CONFLICT (company_id, "type") DO NOTHING
`;

View File

@@ -899,6 +899,38 @@ CREATE INDEX IF NOT EXISTS IX_quotesummary_company_number
ON quotesummary (company_id, quote_number);
`;
export const INIT_SHIPMENTSUMMARY = `
CREATE TABLE IF NOT EXISTS shipmentsummary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
manifest_id INTEGER NOT NULL UNIQUE,
shipment_number TEXT NOT NULL,
shipment_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
order_id INTEGER NULL,
order_number TEXT NULL,
entity_id INTEGER NULL,
customer_name TEXT NULL,
positions INTEGER NOT NULL DEFAULT 0,
total_quantity REAL NOT NULL DEFAULT 0,
package_count INTEGER NOT NULL DEFAULT 0,
carrier TEXT NULL,
tracking_code TEXT NULL,
state TEXT NOT NULL DEFAULT 'new',
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_date
ON shipmentsummary (company_id, shipment_date DESC, manifest_id DESC);
CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_number
ON shipmentsummary (company_id, shipment_number);
CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_state
ON shipmentsummary (company_id, state);
CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_order
ON shipmentsummary (company_id, order_id);
`;
export const INIT_MCP_CLIENT = `
CREATE TABLE IF NOT EXISTS mcp_client (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1009,6 +1041,7 @@ export const SCHEMA_SCRIPTS = [
INIT_PACKAGE,
INIT_ORDERSUMMARY,
INIT_QUOTESUMMARY,
INIT_SHIPMENTSUMMARY,
INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN,

View File

@@ -0,0 +1,123 @@
import {
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE,
UPDATE_BY_COMPANY_AND_ID as MSSQL_UPDATE_BY_COMPANY_AND_ID,
} from '../../providers/mssql/queries/numbers.js';
import {
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE,
UPDATE_BY_COMPANY_AND_ID as SQLITE_UPDATE_BY_COMPANY_AND_ID,
} from '../../providers/sqlite3/queries/numbers.js';
/**
* Ensures a number sequence row exists for a company and type,
* inferring the company's prefix style, padding, and next counter if not yet created or unconfigured.
*
* @param {object} runner - The database runner.
* @param {number} companyId - The id of the company.
* @param {string} type - The number type (e.g. 'order', 'quote', 'sku').
* @returns {Promise<{id: number, type: string, prefix: string, padding: number, next: number}>} The sequence row.
*/
export async function ensureNumberType(runner, companyId, type) {
const isSqlite = runner.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE;
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
const updateSql = isSqlite ? SQLITE_UPDATE_BY_COMPANY_AND_ID : MSSQL_UPDATE_BY_COMPANY_AND_ID;
let res = await runner.query(getSql, { companyId, type });
let row = res.rows[0];
// If already exists and is configured (has prefix or padding > 0, or not order/quote), return row
if (row && (row.prefix || row.padding > 0 || (type !== 'order' && type !== 'quote'))) {
return row;
}
// Find reference sequence for company to match prefix and padding style
const listSql = isSqlite
? 'SELECT "type", prefix, padding, "next" FROM numbers WHERE company_id = @companyId'
: 'SELECT [type], prefix, padding, [next] FROM numbers WHERE company_id = @companyId';
const existingSeqs = (await runner.query(listSql, { companyId })).rows;
const skuSeq = existingSeqs.find((r) => r.type === 'sku');
const deliverySeq = existingSeqs.find((r) => r.type === 'delivery');
const entitySeq = existingSeqs.find((r) => r.type === 'entity');
const refSeq = skuSeq || deliverySeq || entitySeq;
const pad = refSeq ? Math.max(0, Number(refSeq.padding) || 0) : 6;
const prefixStyle = refSeq?.prefix ?? '';
let prefix = '';
if (type === 'shipment') {
prefix = '-L-';
} else if (type === 'quote') {
if (prefixStyle.startsWith('SKU-') || prefixStyle.startsWith('DEL-') || prefixStyle.startsWith('C-')) {
prefix = 'Q-';
} else if (prefixStyle.startsWith('A-') || prefixStyle.startsWith('L-') || prefixStyle.startsWith('K-')) {
prefix = 'AG-';
} else {
prefix = 'ANG-';
}
} else if (type === 'order') {
if (prefixStyle.startsWith('SKU-') || prefixStyle.startsWith('DEL-') || prefixStyle.startsWith('C-')) {
prefix = 'ORD-';
} else if (prefixStyle.startsWith('A-') || prefixStyle.startsWith('L-') || prefixStyle.startsWith('K-')) {
prefix = 'AU-';
} else {
prefix = 'AUF-';
}
} else if (type === 'adjustment') {
prefix = prefixStyle.endsWith('-') ? 'KORR-' : '';
}
const padding = (type === 'order' || type === 'quote') ? (pad || 6) : (type === 'adjustment' ? 5 : (refSeq?.padding ?? 0));
// Determine next counter by scanning manifest for existing documents
let next = 1;
if (type === 'order' || type === 'quote') {
const manifestSql = isSqlite
? 'SELECT "number" FROM manifest WHERE company_id = @companyId AND "type" = @type'
: 'SELECT [number] FROM manifest WHERE company_id = @companyId AND [type] = @type';
const manifests = (await runner.query(manifestSql, { companyId, type })).rows;
let maxFound = 0;
for (const m of manifests) {
const numStr = String(m.number ?? '').trim();
if (prefix && numStr.startsWith(prefix)) {
const val = parseInt(numStr.slice(prefix.length), 10);
if (!Number.isNaN(val) && val > maxFound) maxFound = val;
} else if (!prefix) {
const val = parseInt(numStr, 10);
if (!Number.isNaN(val) && val > maxFound) maxFound = val;
}
}
if (maxFound > 0) {
next = maxFound + 1;
}
}
if (row) {
// Row existed but was empty unconfigured row (e.g. prefix: '', padding: 0)
const effectiveNext = Math.max(next, row.next ?? 1);
await runner.query(updateSql, {
id: row.id,
companyId,
prefix,
padding,
next: effectiveNext,
});
row.prefix = prefix;
row.padding = padding;
row.next = effectiveNext;
} else {
await runner.query(ensureSql, {
companyId,
type,
prefix,
padding,
next,
});
res = await runner.query(getSql, { companyId, type });
row = res.rows[0];
}
return row;
}

View File

@@ -1,20 +1,21 @@
import {
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
} from '../../providers/mssql/queries/numbers.js';
import {
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY,
} from '../../providers/sqlite3/queries/numbers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { ensureNumberType } from './ensureType.js';
import makeRunner from '../runner.js';
const DEFAULT_TYPES = [
{ type: 'sku', prefix: '' },
{ type: 'entity', prefix: '' },
{ type: 'delivery', prefix: '' },
{ type: 'adjustment', prefix: '' },
{ type: 'shipment', prefix: '-L-' },
'sku',
'entity',
'delivery',
'adjustment',
'shipment',
'quote',
'order',
];
function shape(row) {
@@ -30,13 +31,12 @@ function shape(row) {
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
const sql =
isSqlite ? SQLITE_LIST_BY_COMPANY : MSSQL_LIST_BY_COMPANY;
/**
* Lists the number sequences of the user's selected company.
* Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
* Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment', 'quote', 'order') exist.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
@@ -52,8 +52,7 @@ export default function list(provider) {
for (const item of DEFAULT_TYPES) {
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 });
await ensureNumberType(runner, companyId, type);
}
const result = await runner.query(sql, { companyId });

View File

@@ -1,13 +1,10 @@
import {
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE,
INCREMENT as MSSQL_INCREMENT,
} from '../../providers/mssql/queries/numbers.js';
import {
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE,
INCREMENT as SQLITE_INCREMENT,
} from '../../providers/sqlite3/queries/numbers.js';
import { ensureNumberType } from './ensureType.js';
import makeRunner from '../runner.js';
function formatNumber(prefix, padding, num) {
@@ -19,8 +16,6 @@ function formatNumber(prefix, padding, num) {
export default function nextNumber(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE;
const incSql = isSqlite ? SQLITE_INCREMENT : MSSQL_INCREMENT;
/**
@@ -37,10 +32,7 @@ export default function nextNumber(provider) {
*/
return async (companyId, type, tx) => {
const doAlloc = async (runner) => {
const prefix = type === 'shipment' ? '-L-' : '';
await runner.query(ensureSql, { companyId, type, prefix });
const res = await runner.query(getSql, { companyId, type });
const row = res.rows[0];
const row = await ensureNumberType(runner, companyId, type);
if (!row) {
throw new Error('Number type not found for company');
}

View File

@@ -1,11 +1,4 @@
import {
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE,
} from '../../providers/mssql/queries/numbers.js';
import {
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE,
} from '../../providers/sqlite3/queries/numbers.js';
import { ensureNumberType } from './ensureType.js';
import makeRunner from '../runner.js';
function formatNumber(prefix, padding, num) {
@@ -16,10 +9,6 @@ function formatNumber(prefix, padding, num) {
}
export default function peekNumber(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE;
/**
* Returns the next number for a company and type without consuming it.
*
@@ -34,10 +23,7 @@ export default function peekNumber(provider) {
*/
return async (companyId, type, tx) => {
const doPeek = async (runner) => {
const prefix = type === 'shipment' ? '-L-' : '';
await runner.query(ensureSql, { companyId, type, prefix });
const res = await runner.query(getSql, { companyId, type });
const row = res.rows[0];
const row = await ensureNumberType(runner, companyId, type);
if (!row) {
throw new Error('Number type not found for company');
}

View File

@@ -36,7 +36,8 @@ export default function createFromQuote(provider) {
/**
* Creates a new sales order from an existing quote for the user's selected company.
* Copies lines, entity, and external number from the quote unless explicitly overridden.
* Copies lines and customer entity from the quote.
* Sets external reference to the quote number (quote.number) unless explicitly overridden.
* If order number is omitted, it is auto-allocated from the 'order' number sequence.
*
* @param {number} googleAuthId - The id of the Google auth user.
@@ -46,11 +47,11 @@ export default function createFromQuote(provider) {
* @param {string} [body.date] - The order date; defaults to current date if omitted.
* @param {string} [body.createdAt] - Creation timestamp; defaults to current time if omitted.
* @param {number} [body.entityId] - Customer entity id override; defaults to quote's entityId.
* @param {string} [body.externalNumber] - Customer reference override; defaults to quote's externalNumber.
* @param {string} [body.externalNumber] - Customer reference override; defaults to quote's number.
* @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - Lines override; defaults to quote's lines.
* @param {object} [tx] - Optional transaction to run within.
* @returns {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}>}>} The created order.
* @throws {Error} If the quote is not found, or the order is invalid.
* @throws {Error} If the quote is not found, no entity is selected, or the order is invalid.
*
* @example
* const order = await store.order.createFromQuote(42, 10);
@@ -90,15 +91,19 @@ export default function createFromQuote(provider) {
}
const targetEntityId = body.entityId !== undefined ? body.entityId : quoteDoc.entityId;
if (targetEntityId == null) {
throw new Error('No entity selected');
}
const entityId = await assertCustomerEntity(
runner,
companyId,
targetEntityId ?? null,
targetEntityId,
);
const externalNumber = body.externalNumber !== undefined
? (body.externalNumber?.trim?.() ? body.externalNumber.trim() : null)
: (quoteDoc.externalNumber ?? null);
: (quoteDoc.number ?? null);
const rawLines = body.lines !== undefined ? body.lines : (quoteDoc.lines ?? []);
const lines = normalizeLines(rawLines);

View File

@@ -0,0 +1,26 @@
import shipmentCreate from '../shipment/create.js';
export default function createShipment(provider) {
const create = shipmentCreate(provider);
/**
* Creates a new shipment manifest for an order.
* Can contain one, some, or all items from the parent order.
* If lines is omitted or empty, all remaining unshipped items are included.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} orderId - The parent order manifest id.
* @param {object} [input] - Optional overrides.
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - Positions (defaults to all remaining items).
* @param {string|Date} [input.date] - Optional shipment date.
* @param {string} [input.externalNumber] - Optional external delivery reference.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created shipment.
*
* @example
* const shipment = await store.order.createShipment(googleAuthId, 10);
*/
return async (googleAuthId, orderId, input = {}, tx) => {
return create(googleAuthId, { ...input, orderId }, tx);
};
}

View File

@@ -4,6 +4,7 @@ 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);
@@ -25,35 +26,37 @@ export default function create(provider) {
const q = queriesFor(provider);
/**
* Creates a shipment manifest for an order.
* 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 {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 {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<object>} 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, 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 (!Array.isArray(input.lines) || input.lines.length === 0) {
if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
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);
@@ -68,11 +71,19 @@ export default function create(provider) {
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();
@@ -85,27 +96,11 @@ export default function create(provider) {
}
}
// 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
// 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-';
// 5. Retrieve already shipped amounts across existing shipment manifests of this order
// 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,
@@ -118,16 +113,84 @@ export default function create(provider) {
shippedAmountBySku.set(sku, Number(row.shippedAmount || 0));
}
// 6. Quantity ceiling check
for (const [sku, proposedAmount] of proposedBySku.entries()) {
// 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 ordered = orderAmountBySku.get(sku) || 0;
if (alreadyShipped + proposedAmount > ordered) {
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
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);
}
}
// 7. Resolve next index <y>
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 (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 <y>
const existingShipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
companyId,
pattern,
@@ -148,7 +211,7 @@ export default function create(provider) {
const nextY = Math.max(existingShipments.length + 1, maxY + 1);
const shipmentNumber = `${order.number}${shipmentPrefix}${nextY}`;
// 8. Create shipment manifest header
// 7. Create shipment manifest header
const createdRes = await runner.query(q.CREATE_MANIFEST, {
companyId,
number: shipmentNumber,
@@ -160,29 +223,22 @@ export default function create(provider) {
});
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),
};
});
// 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 });
await syncShipmentSummary(runner, {
companyId,
manifestId,
orderId,
orderNumber: order.number,
state: 'new',
});
return loadShipment(runner, q, companyId, manifestId);
};

281
src/store/shipment/list.js Normal file
View File

@@ -0,0 +1,281 @@
import {
COUNT_MANIFESTS as MSSQL_COUNT_MANIFESTS,
GET_MANIFEST_LINES_BY_MANIFEST_IDS as MSSQL_LINES_BY_MANIFEST_IDS,
} from '../../providers/mssql/queries/manifestDoc.js';
import {
COUNT_MANIFESTS as SQLITE_COUNT_MANIFESTS,
GET_MANIFEST_LINES_BY_MANIFEST_IDS as SQLITE_LINES_BY_MANIFEST_IDS,
} from '../../providers/sqlite3/queries/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from '../manifestDoc/helpers.js';
import { shapeLine } from '../manifestLines.js';
import makeRunner from '../runner.js';
const SHIPMENT_TYPE = 'shipment';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const sqlLinesByManifestIds = isSqlite
? SQLITE_LINES_BY_MANIFEST_IDS
: MSSQL_LINES_BY_MANIFEST_IDS;
const sqlCountManifests = isSqlite
? SQLITE_COUNT_MANIFESTS
: MSSQL_COUNT_MANIFESTS;
/**
* Lists the shipments of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [opts] - Optional pagination and filter options.
* @param {number} [opts.offset=0] - Number of shipments to skip.
* @param {number} [opts.limit=50] - Number of shipments to return.
* @param {string} [opts.search] - Free-text search matching shipment number, order number, external number, tracking code, or customer name.
* @param {'new'|'delivered'|'returned'} [opts.state] - Filter by delivery state.
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
* @param {number} [opts.orderId] - Optional filter for parent order manifest id.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{shipments: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>}
*/
return async (googleAuthId, opts, tx) => {
let offset = null;
let limit = null;
let isPaged = false;
let resolvedTx = tx;
const hasFilter = opts != null && typeof opts === 'object' && !opts._isTx && (
opts.offset !== undefined ||
opts.limit !== undefined ||
(typeof opts.search === 'string' && opts.search.trim() !== '') ||
(typeof opts.state === 'string' && opts.state.trim() !== '') ||
opts.seekMonths !== undefined ||
opts.orderId !== undefined
);
if (opts != null && typeof opts === 'object' && !opts._isTx) {
if (hasFilter || opts.offset !== undefined || opts.limit !== undefined) {
isPaged = true;
const parsedOffset = Number(opts.offset ?? 0);
const parsedLimit = Number(opts.limit ?? 50);
if (!Number.isInteger(parsedOffset) || parsedOffset < 0) {
throw new Error('Invalid offset');
}
if (!Number.isInteger(parsedLimit) || parsedLimit < 1) {
throw new Error('Invalid limit');
}
offset = parsedOffset;
limit = parsedLimit;
}
} else if (opts != null) {
resolvedTx = opts;
}
const runner = makeRunner(provider, resolvedTx);
const { companyId } = await access(googleAuthId, runner);
const conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`];
const params = {
companyId,
type: SHIPMENT_TYPE,
};
// Date cutoff via seekMonths (strictly integer)
if (opts?.seekMonths !== undefined) {
if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) {
throw new Error('Invalid seekMonths');
}
const d = new Date();
d.setMonth(d.getMonth() - Math.abs(opts.seekMonths));
params.dateTo = d.toISOString();
conditions.push(`m.${isSqlite ? '"date"' : '[date]'} <= @dateTo`);
}
// Parent order id filter
if (opts?.orderId !== undefined && opts?.orderId !== null) {
const parsedOrderId = Number(opts.orderId);
if (Number.isInteger(parsedOrderId) && parsedOrderId > 0) {
params.filterOrderId = parsedOrderId;
conditions.push('ss.order_id = @filterOrderId');
}
}
// Delivery state filter ('new' | 'delivered' | 'returned')
if (typeof opts?.state === 'string' && opts.state.trim() !== '') {
params.filterState = opts.state.trim().toLowerCase();
conditions.push("COALESCE(ss.state, 'new') = @filterState");
}
// Free-text search
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
params.searchPattern = `%${opts.search.trim()}%`;
if (isSqlite) {
conditions.push(`(
m."number" LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR ss.order_number LIKE @searchPattern
OR ss.tracking_code LIKE @searchPattern
OR ss.carrier LIKE @searchPattern
OR e.name LIKE @searchPattern
OR e.first_name LIKE @searchPattern
OR (IFNULL(e.first_name, '') || ' ' || IFNULL(e.name, '')) LIKE @searchPattern
OR (IFNULL(e.name, '') || ' ' || IFNULL(e.first_name, '')) LIKE @searchPattern
)`);
} else {
conditions.push(`(
m.[number] LIKE @searchPattern
OR m.external_number LIKE @searchPattern
OR ss.order_number LIKE @searchPattern
OR ss.tracking_code LIKE @searchPattern
OR ss.carrier LIKE @searchPattern
OR e.name LIKE @searchPattern
OR e.first_name LIKE @searchPattern
OR (ISNULL(e.first_name, '') + ' ' + ISNULL(e.name, '')) LIKE @searchPattern
OR (ISNULL(e.name, '') + ' ' + ISNULL(e.first_name, '')) LIKE @searchPattern
)`);
}
}
const whereClause = conditions.join('\n AND ');
const sqlCount = `
SELECT COUNT(*) AS total
FROM manifest m
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
`;
let sqlList;
if (isSqlite) {
sqlList = `
SELECT
m.id,
m."number",
m."type",
m.entity_id AS entityId,
m.external_number AS externalNumber,
m."date",
m.created_at AS createdAt,
ss.order_id AS orderId,
ss.order_number AS orderNumber,
ss.customer_name AS customerName,
COALESCE(ss.positions, 0) AS positions,
COALESCE(ss.total_quantity, 0) AS totalQuantity,
COALESCE(ss.package_count, 0) AS packageCount,
ss.carrier,
ss.tracking_code AS trackingCode,
COALESCE(ss.state, 'new') AS state
FROM manifest m
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
ORDER BY m."date" DESC, m.id DESC
${isPaged ? 'LIMIT @limit OFFSET @offset' : ''}
`;
} else {
sqlList = `
SELECT
m.id,
m.[number],
m.[type],
m.entity_id AS entityId,
m.external_number AS externalNumber,
m.[date],
m.created_at AS createdAt,
ss.order_id AS orderId,
ss.order_number AS orderNumber,
ss.customer_name AS customerName,
COALESCE(ss.positions, 0) AS positions,
COALESCE(ss.total_quantity, 0) AS totalQuantity,
COALESCE(ss.package_count, 0) AS packageCount,
ss.carrier,
ss.tracking_code AS trackingCode,
COALESCE(ss.state, 'new') AS state
FROM manifest m
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
LEFT JOIN entity e ON e.id = m.entity_id
WHERE ${whereClause}
ORDER BY m.[date] DESC, m.id DESC
${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''}
`;
}
const hasUserFilters = (
opts?.seekMonths !== undefined ||
(typeof opts?.search === 'string' && opts.search.trim() !== '') ||
(typeof opts?.state === 'string' && opts.state.trim() !== '') ||
opts?.orderId !== undefined
);
let manifestRows;
let total = 0;
let unfilteredTotal = 0;
if (isPaged) {
params.offset = offset;
params.limit = limit;
const [countResult, listResult, unfilteredCountResult] = await Promise.all([
runner.query(sqlCount, params),
runner.query(sqlList, params),
hasUserFilters ? runner.query(sqlCountManifests, { companyId, type: SHIPMENT_TYPE }) : null,
]);
total = Number(countResult.rows[0]?.total ?? 0);
unfilteredTotal = hasUserFilters
? Number(unfilteredCountResult?.rows[0]?.total ?? 0)
: total;
manifestRows = listResult.rows;
} else {
const result = await runner.query(sqlList, params);
manifestRows = result.rows;
total = manifestRows.length;
if (hasUserFilters) {
const countRes = await runner.query(sqlCountManifests, { companyId, type: SHIPMENT_TYPE });
unfilteredTotal = Number(countRes.rows[0]?.total ?? 0);
} else {
unfilteredTotal = total;
}
}
const manifestIds = manifestRows.map((r) => r.id);
const linesByManifestId = new Map();
if (manifestIds.length > 0) {
const linesResult = await runner.query(sqlLinesByManifestIds, {
manifestIdsJson: JSON.stringify(manifestIds),
});
for (const row of linesResult.rows) {
let lines = linesByManifestId.get(row.manifestId);
if (!lines) {
lines = [];
linesByManifestId.set(row.manifestId, lines);
}
lines.push(shapeLine(row));
}
}
const shipments = manifestRows.map((row) => ({
...shapeSummary(SHIPMENT_TYPE, row),
lines: linesByManifestId.get(row.id) ?? [],
orderId: row.orderId ?? null,
orderNumber: row.orderNumber ?? null,
customerName: row.customerName ?? undefined,
positions: Number(row.positions || 0),
totalQuantity: Number(row.totalQuantity || 0),
packageCount: Number(row.packageCount || 0),
carrier: row.carrier ?? null,
trackingCode: row.trackingCode ?? null,
state: row.state || 'new',
}));
if (isPaged) {
return {
shipments,
total,
unfilteredTotal,
offset,
limit,
};
}
return shipments;
};
}

View File

@@ -0,0 +1,20 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { rebuildShipmentSummaries } from './syncSummary.js';
export default function rebuildSummaries(provider) {
const access = resolveSelectedCompanyAccess(provider);
/**
* Rebuilds the `shipmentsummary` table for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
*/
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
await rebuildShipmentSummaries(runner, { companyId });
};
}

View File

@@ -0,0 +1,258 @@
/**
* Centralized sync and rebuild logic for the materialized `shipmentsummary` table.
*/
const SQL_GET_SHIPMENT_PREFIX = `
SELECT prefix FROM numbers WHERE company_id = @companyId AND [type] = 'shipment'
`;
const SQL_GET_SHIPMENT_BY_ID_SQLITE = `
SELECT m.id, m.company_id, m."number", m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'shipment'
`;
const SQL_GET_SHIPMENT_BY_ID_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'shipment'
`;
const SQL_LINE_STATS = `
SELECT
COUNT(*) AS positions,
COALESCE(SUM(amount), 0) AS total_quantity
FROM manifestarticle
WHERE manifest_id = @manifestId
`;
const SQL_SHIPPING_STATS_SQLITE = `
SELECT
COUNT(id) AS package_count,
(SELECT carrier FROM shipping WHERE manifest_id = @manifestId AND carrier IS NOT NULL AND TRIM(carrier) != '' ORDER BY id ASC LIMIT 1) AS carrier,
(SELECT tracking_code FROM shipping WHERE manifest_id = @manifestId AND tracking_code IS NOT NULL AND TRIM(tracking_code) != '' ORDER BY id ASC LIMIT 1) AS tracking_code
FROM shipping
WHERE manifest_id = @manifestId
`;
const SQL_SHIPPING_STATS_MSSQL = `
SELECT
COUNT(id) AS package_count,
(SELECT TOP 1 carrier FROM shipping WHERE manifest_id = @manifestId AND carrier IS NOT NULL AND LTRIM(RTRIM(carrier)) != '' ORDER BY id ASC) AS carrier,
(SELECT TOP 1 tracking_code FROM shipping WHERE manifest_id = @manifestId AND tracking_code IS NOT NULL AND LTRIM(RTRIM(tracking_code)) != '' ORDER BY id ASC) AS tracking_code
FROM shipping
WHERE manifest_id = @manifestId
`;
const SQL_ORDER_BY_NUMBER_SQLITE = `
SELECT id, "number"
FROM manifest
WHERE company_id = @companyId AND "type" = 'order' AND "number" = @orderNumber
LIMIT 1
`;
const SQL_ORDER_BY_NUMBER_MSSQL = `
SELECT TOP 1 id, [number]
FROM manifest
WHERE company_id = @companyId AND [type] = 'order' AND [number] = @orderNumber
`;
const SQL_CUSTOMER_NAME_SQLITE = `
SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS customer_name
FROM entity
WHERE id = @entityId
`;
const SQL_CUSTOMER_NAME_MSSQL = `
SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS customer_name
FROM entity
WHERE id = @entityId
`;
const SQL_UPSERT_SUMMARY_SQLITE = `
INSERT INTO shipmentsummary (
company_id, manifest_id, shipment_number, shipment_date, created_at,
order_id, order_number, entity_id, customer_name, positions,
total_quantity, package_count, carrier, tracking_code, state
) VALUES (
@companyId, @manifestId, @shipmentNumber, @shipmentDate, @createdAt,
@orderId, @orderNumber, @entityId, @customerName, @positions,
@totalQuantity, @packageCount, @carrier, @trackingCode, @state
)
ON CONFLICT(manifest_id) DO UPDATE SET
shipment_number = excluded.shipment_number,
shipment_date = excluded.shipment_date,
created_at = excluded.created_at,
order_id = excluded.order_id,
order_number = excluded.order_number,
entity_id = excluded.entity_id,
customer_name = excluded.customer_name,
positions = excluded.positions,
total_quantity = excluded.total_quantity,
package_count = excluded.package_count,
carrier = excluded.carrier,
tracking_code = excluded.tracking_code,
state = excluded.state
`;
const SQL_UPSERT_SUMMARY_MSSQL = `
MERGE INTO shipmentsummary AS target
USING (SELECT @manifestId AS manifest_id) AS source
ON (target.manifest_id = source.manifest_id)
WHEN MATCHED THEN
UPDATE SET
shipment_number = @shipmentNumber,
shipment_date = @shipmentDate,
created_at = @createdAt,
order_id = @orderId,
order_number = @orderNumber,
entity_id = @entityId,
customer_name = @customerName,
positions = @positions,
total_quantity = @totalQuantity,
package_count = @packageCount,
carrier = @carrier,
tracking_code = @trackingCode,
state = @state
WHEN NOT MATCHED THEN
INSERT (
company_id, manifest_id, shipment_number, shipment_date, created_at,
order_id, order_number, entity_id, customer_name, positions,
total_quantity, package_count, carrier, tracking_code, state
) VALUES (
@companyId, @manifestId, @shipmentNumber, @shipmentDate, @createdAt,
@orderId, @orderNumber, @entityId, @customerName, @positions,
@totalQuantity, @packageCount, @carrier, @trackingCode, @state
);
`;
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
/**
* Recalculates and upserts a single shipment summary into `shipmentsummary`.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} opts.manifestId - Shipment manifest id.
* @param {number} [opts.orderId] - Optional parent order manifest id.
* @param {string} [opts.orderNumber] - Optional parent order number.
* @param {string} [opts.state] - Optional new state ('new' | 'delivered' | 'returned').
*/
export async function syncShipmentSummary(runner, { companyId, manifestId, orderId, orderNumber, state }) {
const isSqlite = runner.dialect === 'sqlite3';
const res = await runner.query(
isSqlite ? SQL_GET_SHIPMENT_BY_ID_SQLITE : SQL_GET_SHIPMENT_BY_ID_MSSQL,
{ manifestId, companyId },
);
const shipment = res.rows[0];
if (!shipment) {
await runner.query('DELETE FROM shipmentsummary WHERE manifest_id = @manifestId', { manifestId });
return;
}
// 1. Line stats
const lineRes = await runner.query(SQL_LINE_STATS, { manifestId });
const positions = Number(lineRes.rows[0]?.positions || 0);
const totalQuantity = Math.round(Number(lineRes.rows[0]?.total_quantity || 0) * 1000) / 1000;
// 2. Shipping stats (packages, carrier, tracking_code)
const shippingRes = await runner.query(
isSqlite ? SQL_SHIPPING_STATS_SQLITE : SQL_SHIPPING_STATS_MSSQL,
{ manifestId },
);
const packageCount = Number(shippingRes.rows[0]?.package_count || 0);
const carrier = shippingRes.rows[0]?.carrier ? String(shippingRes.rows[0].carrier).trim() : null;
const trackingCode = shippingRes.rows[0]?.tracking_code ? String(shippingRes.rows[0].tracking_code).trim() : null;
// 3. Resolve orderId and orderNumber
let resolvedOrderId = orderId ?? null;
let resolvedOrderNumber = orderNumber ?? null;
if (resolvedOrderId == null || !resolvedOrderNumber) {
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const num = String(shipment.number || '');
if (num.includes(shipmentPrefix)) {
const parsedOrderNum = num.split(shipmentPrefix)[0];
if (parsedOrderNum) {
const ordRes = await runner.query(
isSqlite ? SQL_ORDER_BY_NUMBER_SQLITE : SQL_ORDER_BY_NUMBER_MSSQL,
{ companyId, orderNumber: parsedOrderNum },
);
if (ordRes.rows[0]) {
resolvedOrderId = ordRes.rows[0].id;
resolvedOrderNumber = ordRes.rows[0].number;
}
}
}
}
// 4. Customer name
let customerName = null;
if (shipment.entity_id) {
const custRes = await runner.query(
isSqlite ? SQL_CUSTOMER_NAME_SQLITE : SQL_CUSTOMER_NAME_MSSQL,
{ entityId: shipment.entity_id },
);
customerName = custRes.rows[0]?.customer_name || null;
}
// 5. State ('new' | 'delivered' | 'returned')
let resolvedState = 'new';
if (state && VALID_STATES.has(state)) {
resolvedState = state;
} else {
// Preserve existing state if present
const existingRes = await runner.query(
'SELECT state FROM shipmentsummary WHERE manifest_id = @manifestId',
{ manifestId },
);
const existingState = existingRes.rows[0]?.state;
if (existingState && VALID_STATES.has(existingState)) {
resolvedState = existingState;
}
}
// 6. Upsert into shipmentsummary
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
companyId,
manifestId,
shipmentNumber: shipment.number,
shipmentDate: shipment.date,
createdAt: shipment.created_at,
orderId: resolvedOrderId,
orderNumber: resolvedOrderNumber,
entityId: shipment.entity_id ?? null,
customerName,
positions,
totalQuantity,
packageCount,
carrier,
trackingCode,
state: resolvedState,
});
}
/**
* Rebuilds the `shipmentsummary` table for a company.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
*/
export async function rebuildShipmentSummaries(runner, { companyId }) {
const isSqlite = runner.dialect === 'sqlite3';
const sqlList = `
SELECT id FROM manifest
WHERE company_id = @companyId AND ${isSqlite ? '"type"' : '[type]'} = 'shipment'
ORDER BY id ASC
`;
const listRes = await runner.query(sqlList, { companyId });
for (const row of listRes.rows) {
await syncShipmentSummary(runner, { companyId, manifestId: row.id });
}
}

View File

@@ -0,0 +1,71 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { loadShipment, queriesFor } from './helpers.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;
}
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
/**
* Updates a shipment's state or metadata.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const q = queriesFor(provider);
/**
* Updates a shipment manifest.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The shipment manifest ID.
* @param {object} patch - Fields to update.
* @param {'new'|'delivered'|'returned'} [patch.state] - Delivery state.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated shipment manifest.
* @throws {Error} If shipment not found or invalid state.
*/
return async (googleAuthId, id, patch = {}, 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');
}
if (patch.state !== undefined) {
const stateStr = String(patch.state || '').trim().toLowerCase();
if (!VALID_STATES.has(stateStr)) {
throw new Error('Invalid state');
}
await syncShipmentSummary(runner, {
companyId,
manifestId: shipmentId,
state: stateStr,
});
}
const shipment = await loadShipment(runner, q, companyId, shipmentId);
const sumRes = await runner.query(
'SELECT state FROM shipmentsummary WHERE manifest_id = @manifestId',
{ manifestId: shipmentId },
);
return {
...shipment,
state: sumRes.rows[0]?.state || 'new',
};
};
}