u
This commit is contained in:
4
API.md
4
API.md
@@ -209,7 +209,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
|
|||||||
- [order](API.order.md)
|
- [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, 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}>}>`
|
- `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, 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}>}>`
|
- `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>`
|
- `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>`
|
- `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}>}>`
|
- `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>>`
|
- `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>>`
|
||||||
@@ -220,7 +220,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
|
|||||||
- `create(provider: import('../../providers/provider.js').DatabaseProvider): void`
|
- `create(provider: import('../../providers/provider.js').DatabaseProvider): void`
|
||||||
- `delete(provider: import('../../providers/provider.js').DatabaseProvider): void`
|
- `delete(provider: import('../../providers/provider.js').DatabaseProvider): void`
|
||||||
- `getById(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>>`
|
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, state?: 'new'|'delivered'|'returned'|'cancelled'|'all', includeCancelled?: boolean, 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`
|
- `listByOrderId(provider: import('../../providers/provider.js').DatabaseProvider): void`
|
||||||
- `rebuildSummaries(googleAuthId: number, tx?: object): Promise<void>`
|
- `rebuildSummaries(googleAuthId: number, tx?: object): Promise<void>`
|
||||||
- `syncSummary(): void`
|
- `syncSummary(): void`
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const order = await store.order.createFromQuote(42, 10);
|
|||||||
|
|
||||||
*Source: `src/store/order/createShipment.js`*
|
*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.
|
Creates a new shipment manifest for an order. Can contain specified items from the parent order.
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ Creates a new shipment manifest for an order. Can contain one, some, or all item
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `googleAuthId` | `number` | The id of the Google auth user. |
|
| `googleAuthId` | `number` | The id of the Google auth user. |
|
||||||
| `orderId` | `number` | The parent order manifest id. |
|
| `orderId` | `number` | The parent order manifest id. |
|
||||||
| `[input]` | `object` | Optional overrides. |
|
| `input` | `object` | Shipment input. |
|
||||||
| `[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.lines` | `Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>` | Positions to ship. |
|
||||||
| `[input.date]` | `string|Date` | Optional shipment date. |
|
| `[input.date]` | `string|Date` | Optional shipment date. |
|
||||||
| `[input.externalNumber]` | `string` | Optional external delivery reference. |
|
| `[input.externalNumber]` | `string` | Optional external delivery reference. |
|
||||||
| `[tx]` | `object` | Optional transaction. |
|
| `[tx]` | `object` | Optional transaction. |
|
||||||
@@ -97,7 +97,7 @@ Creates a new shipment manifest for an order. Can contain one, some, or all item
|
|||||||
**Example**
|
**Example**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const shipment = await store.order.createShipment(googleAuthId, 10);
|
const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] });
|
||||||
```
|
```
|
||||||
|
|
||||||
## `order.delete`
|
## `order.delete`
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Creates a new shipment manifest for an order. Validates that all article positio
|
|||||||
|
|
||||||
*Source: `src/store/shipment/delete.js`*
|
*Source: `src/store/shipment/delete.js`*
|
||||||
|
|
||||||
Deletes a shipment manifest of the selected company. Shipped shipments (with packages in 'shipped' state) cannot be deleted.
|
Deletes or cancels a shipment manifest of the selected company. - If no ledger rows exist: physically deletes the shipment manifest. - If ledger rows exist: reverses the ledger entries (storno document entry + warehouse stock adjustment), marks the shipment as 'cancelled' in shipmentsummary, and updates the parent order's summary. - Shipped shipments (with packages in 'shipped' state) cannot be deleted.
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
@@ -53,7 +53,8 @@ Lists the shipments of the user's selected company.
|
|||||||
| `[opts.offset=0]` | `number` | Number of shipments to skip. |
|
| `[opts.offset=0]` | `number` | Number of shipments to skip. |
|
||||||
| `[opts.limit=50]` | `number` | Number of shipments to return. |
|
| `[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.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.state]` | `'new'|'delivered'|'returned'|'cancelled'|'all'` | Filter by delivery state. |
|
||||||
|
| `[opts.includeCancelled=false]` | `boolean` | Whether to include cancelled shipments when state is not set. |
|
||||||
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
|
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
|
||||||
| `[opts.orderId]` | `number` | Optional filter for parent order manifest id. |
|
| `[opts.orderId]` | `number` | Optional filter for parent order manifest id. |
|
||||||
| `[tx]` | `object` | Optional transaction. |
|
| `[tx]` | `object` | Optional transaction. |
|
||||||
|
|||||||
@@ -403,6 +403,40 @@ async function run() {
|
|||||||
'Adjust must fail when article has no stock tracking',
|
'Adjust must fail when article has no stock tracking',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 10b. Test prevention of negative stock on shipment creation and stock adjust
|
||||||
|
console.log('10b. Testing prevention of negative stock on shipment creation and adjust...');
|
||||||
|
const guardOrder = await store.order.create(googleAuthId, {
|
||||||
|
number: 'ORD-GUARD-1',
|
||||||
|
date: '2026-10-20',
|
||||||
|
entityId: customer.id,
|
||||||
|
lines: [{ sku: article.sku, name: 'Order Line', pu: 1, amount: 9999, net: 10, taxrate: 19 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentArt = await store.article.getBySku(googleAuthId, article.sku);
|
||||||
|
const curStock = currentArt.article.warehouses.find((w) => w.id === wh1.id)?.stock ?? 0;
|
||||||
|
|
||||||
|
// Explicit shipment requesting more than available stock must be rejected
|
||||||
|
await assert.rejects(
|
||||||
|
async () => store.shipment.create(googleAuthId, {
|
||||||
|
orderId: guardOrder.id,
|
||||||
|
warehouseId: wh1.id,
|
||||||
|
lines: [{ sku: article.sku, amount: curStock + 10 }],
|
||||||
|
}),
|
||||||
|
/Insufficient stock for article/,
|
||||||
|
'Shipment creation must fail when requested amount exceeds warehouse stock',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manual stock adjust attempting to deduct more than current stock must be rejected
|
||||||
|
await assert.rejects(
|
||||||
|
async () => store.stock.adjust(googleAuthId, {
|
||||||
|
sku: article.sku,
|
||||||
|
warehouseId: wh1.id,
|
||||||
|
amount: -(curStock + 10),
|
||||||
|
}),
|
||||||
|
/Insufficient stock for article/,
|
||||||
|
'Stock adjust must fail when deduction exceeds available stock',
|
||||||
|
);
|
||||||
|
|
||||||
// 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized
|
// 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized
|
||||||
console.log('11. Testing zero-adjustment before stock tracking is disabled...');
|
console.log('11. Testing zero-adjustment before stock tracking is disabled...');
|
||||||
const artBeforeDisable = await store.article.getBySku(googleAuthId, article.sku);
|
const artBeforeDisable = await store.article.getBySku(googleAuthId, article.sku);
|
||||||
|
|||||||
@@ -281,8 +281,8 @@ async function run() {
|
|||||||
assert.equal(sum2.is_shipped, 0);
|
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');
|
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 ({})
|
// 9c. Ship remaining items with explicit lines
|
||||||
const shAll = await store.order.createShipment(googleAuthId, order2.id, {});
|
const shAll = await store.order.createShipment(googleAuthId, order2.id, { lines: [{ sku: 'ITEM-B', amount: 5 }] });
|
||||||
assert.equal(shAll.number, 'AU-1002-L-3');
|
assert.equal(shAll.number, 'AU-1002-L-3');
|
||||||
assert.equal(shAll.lines.length, 1);
|
assert.equal(shAll.lines.length, 1);
|
||||||
assert.equal(shAll.lines[0].sku, 'ITEM-B');
|
assert.equal(shAll.lines[0].sku, 'ITEM-B');
|
||||||
@@ -292,16 +292,16 @@ async function run() {
|
|||||||
sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id);
|
sum2 = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order2.id);
|
||||||
assert.equal(sum2.shipped_quantity, 15);
|
assert.equal(sum2.shipped_quantity, 15);
|
||||||
assert.equal(sum2.is_shipped, 1);
|
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');
|
console.log('PASS: 9c. Shipped remaining items with explicit lines; ordersummary updated with shipped_quantity=15, is_shipped=1');
|
||||||
|
|
||||||
// 9d. Calling createShipment when all items already shipped throws 'All items already shipped'
|
// 9d. Calling createShipment without lines is rejected with "Invalid lines"
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => {
|
async () => {
|
||||||
await store.order.createShipment(googleAuthId, order2.id);
|
await store.order.createShipment(googleAuthId, order2.id);
|
||||||
},
|
},
|
||||||
{ message: 'All items already shipped' }
|
{ message: 'Invalid lines' }
|
||||||
);
|
);
|
||||||
console.log('PASS: 9d. Re-shipping fully shipped order rejected with "All items already shipped"');
|
console.log('PASS: 9d. Creating shipment without lines rejected with "Invalid lines"');
|
||||||
|
|
||||||
// 10. Verify peek next number works for entity and other types without error
|
// 10. Verify peek next number works for entity and other types without error
|
||||||
const nextEntity = await store.entity.peekNextNumber(googleAuthId);
|
const nextEntity = await store.entity.peekNextNumber(googleAuthId);
|
||||||
|
|||||||
@@ -664,8 +664,11 @@ SELECT
|
|||||||
SELECT SUM(ma_s.amount)
|
SELECT SUM(ma_s.amount)
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = m.company_id
|
WHERE m_s.company_id = m.company_id
|
||||||
AND m_s.[type] = 'shipment'
|
AND m_s.[type] = 'shipment'
|
||||||
|
AND ISNULL(ss.state, 'new') != 'cancelled'
|
||||||
|
AND m_s.[number] NOT LIKE '%#cancelled#%'
|
||||||
AND ma_s.sku = a.sku
|
AND ma_s.sku = a.sku
|
||||||
AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%'
|
AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%'
|
||||||
), 0), 3) AS amount
|
), 0), 3) AS amount
|
||||||
@@ -682,8 +685,11 @@ SELECT
|
|||||||
SELECT SUM(ma_s.amount)
|
SELECT SUM(ma_s.amount)
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = m.company_id
|
WHERE m_s.company_id = m.company_id
|
||||||
AND m_s.[type] = 'shipment'
|
AND m_s.[type] = 'shipment'
|
||||||
|
AND ISNULL(ss.state, 'new') != 'cancelled'
|
||||||
|
AND m_s.[number] NOT LIKE '%#cancelled#%'
|
||||||
AND ma_s.sku = a.sku
|
AND ma_s.sku = a.sku
|
||||||
AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%'
|
AND m_s.[number] LIKE m.[number] + COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND [type] = 'shipment'), '-L-') + '%'
|
||||||
), 0)) > 0
|
), 0)) > 0
|
||||||
|
|||||||
@@ -145,3 +145,26 @@ WHERE ma.sku = @sku
|
|||||||
AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId)
|
AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId)
|
||||||
ORDER BY l.id ASC;
|
ORDER BY l.id ASC;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_ARTICLE_TRACK_STOCK = `
|
||||||
|
SELECT TOP 1 a.id, a.sku, a.track_stock
|
||||||
|
FROM article a
|
||||||
|
WHERE a.company_id = @companyId
|
||||||
|
AND (
|
||||||
|
a.sku = @sku
|
||||||
|
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ARTICLE_WAREHOUSE_STOCK = `
|
||||||
|
SELECT COALESCE(SUM(l.amount), 0) AS stock
|
||||||
|
FROM ledger l
|
||||||
|
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
|
||||||
|
INNER JOIN manifest m ON m.id = ma.manifest_id
|
||||||
|
WHERE l.warehouse_id = @warehouseId
|
||||||
|
AND m.company_id = @companyId
|
||||||
|
AND (
|
||||||
|
ma.sku = @sku
|
||||||
|
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ WHERE company_id = @companyId AND [type] = 'shipment';
|
|||||||
export const LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN = `
|
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
|
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
|
FROM manifest m
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
|
||||||
WHERE m.company_id = @companyId
|
WHERE m.company_id = @companyId
|
||||||
AND m.[type] = 'shipment'
|
AND m.[type] = 'shipment'
|
||||||
AND m.[number] LIKE @pattern
|
AND m.[number] LIKE @pattern
|
||||||
|
AND (ss.state IS NULL OR ss.state != 'cancelled')
|
||||||
ORDER BY m.id ASC;
|
ORDER BY m.id ASC;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -32,10 +34,12 @@ export const GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN = `
|
|||||||
SELECT ma.sku, SUM(ma.amount) AS shippedAmount
|
SELECT ma.sku, SUM(ma.amount) AS shippedAmount
|
||||||
FROM manifest m
|
FROM manifest m
|
||||||
INNER JOIN manifestarticle ma ON ma.manifest_id = m.id
|
INNER JOIN manifestarticle ma ON ma.manifest_id = m.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id
|
||||||
WHERE m.company_id = @companyId
|
WHERE m.company_id = @companyId
|
||||||
AND m.[type] = 'shipment'
|
AND m.[type] = 'shipment'
|
||||||
AND m.[number] LIKE @pattern
|
AND m.[number] LIKE @pattern
|
||||||
AND (@excludeShipmentId IS NULL OR m.id != @excludeShipmentId)
|
AND (@excludeShipmentId IS NULL OR m.id != @excludeShipmentId)
|
||||||
|
AND (ss.state IS NULL OR ss.state != 'cancelled')
|
||||||
GROUP BY ma.sku;
|
GROUP BY ma.sku;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -57,6 +61,7 @@ FROM manifest m
|
|||||||
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id AND ss.company_id = @companyId
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id AND ss.company_id = @companyId
|
||||||
WHERE m.company_id = @companyId
|
WHERE m.company_id = @companyId
|
||||||
AND m.[type] = 'shipment'
|
AND m.[type] = 'shipment'
|
||||||
|
AND (ss.state IS NULL OR ss.state != 'cancelled')
|
||||||
AND (
|
AND (
|
||||||
m.[number] LIKE @pattern
|
m.[number] LIKE @pattern
|
||||||
OR ss.order_id = @orderId
|
OR ss.order_id = @orderId
|
||||||
@@ -69,3 +74,33 @@ DELETE FROM manifest
|
|||||||
WHERE id = @id AND company_id = @companyId AND [type] = 'shipment';
|
WHERE id = @id AND company_id = @companyId AND [type] = 'shipment';
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_DEFAULT_WAREHOUSE = `
|
||||||
|
SELECT TOP 1 id, name
|
||||||
|
FROM warehouse
|
||||||
|
WHERE company_id = @companyId
|
||||||
|
ORDER BY sort ASC, name ASC, id ASC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ARTICLE_TRACK_STOCK = `
|
||||||
|
SELECT TOP 1 a.id, a.sku, a.track_stock
|
||||||
|
FROM article a
|
||||||
|
WHERE a.company_id = @companyId
|
||||||
|
AND (
|
||||||
|
a.sku = @sku
|
||||||
|
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ARTICLE_WAREHOUSE_STOCK = `
|
||||||
|
SELECT COALESCE(SUM(l.amount), 0) AS stock
|
||||||
|
FROM ledger l
|
||||||
|
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
|
||||||
|
INNER JOIN manifest m ON m.id = ma.manifest_id
|
||||||
|
WHERE l.warehouse_id = @warehouseId
|
||||||
|
AND m.company_id = @companyId
|
||||||
|
AND (
|
||||||
|
ma.sku = @sku
|
||||||
|
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -307,8 +307,11 @@ SELECT auth.*,
|
|||||||
SELECT SUM(ma_s.amount)
|
SELECT SUM(ma_s.amount)
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = m.company_id
|
WHERE m_s.company_id = m.company_id
|
||||||
AND m_s."type" = 'shipment'
|
AND m_s."type" = 'shipment'
|
||||||
|
AND COALESCE(ss.state, 'new') != 'cancelled'
|
||||||
|
AND m_s."number" NOT LIKE '%#cancelled#%'
|
||||||
AND ma_s.sku = a.sku
|
AND ma_s.sku = a.sku
|
||||||
AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%'
|
AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%'
|
||||||
), 0), 3) AS amount
|
), 0), 3) AS amount
|
||||||
@@ -321,7 +324,18 @@ SELECT auth.*,
|
|||||||
AND ma.sku = a.sku
|
AND ma.sku = a.sku
|
||||||
AND COALESCE(os.is_shipped, 0) = 0
|
AND COALESCE(os.is_shipped, 0) = 0
|
||||||
GROUP BY m.id, m."number", m."date", os.customer_name, e.first_name, e.name
|
GROUP BY m.id, m."number", m."date", os.customer_name, e.first_name, e.name
|
||||||
HAVING amount > 0
|
HAVING (SUM(ma.amount) - COALESCE((
|
||||||
|
SELECT SUM(ma_s.amount)
|
||||||
|
FROM manifest m_s
|
||||||
|
INNER JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
|
WHERE m_s.company_id = m.company_id
|
||||||
|
AND m_s."type" = 'shipment'
|
||||||
|
AND COALESCE(ss.state, 'new') != 'cancelled'
|
||||||
|
AND m_s."number" NOT LIKE '%#cancelled#%'
|
||||||
|
AND ma_s.sku = a.sku
|
||||||
|
AND m_s."number" LIKE m."number" || COALESCE((SELECT prefix FROM numbers WHERE company_id = m.company_id AND "type" = 'shipment'), '-L-') || '%'
|
||||||
|
), 0)) > 0
|
||||||
ORDER BY m."date" ASC, m.id ASC
|
ORDER BY m."date" ASC, m.id ASC
|
||||||
) uo
|
) uo
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -147,3 +147,27 @@ WHERE ma.sku = @sku
|
|||||||
AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId)
|
AND (@warehouseId IS NULL OR l.warehouse_id = @warehouseId)
|
||||||
ORDER BY l.id ASC;
|
ORDER BY l.id ASC;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_ARTICLE_TRACK_STOCK = `
|
||||||
|
SELECT a.id, a.sku, a.track_stock
|
||||||
|
FROM article a
|
||||||
|
WHERE a.company_id = @companyId
|
||||||
|
AND (
|
||||||
|
a.sku = @sku
|
||||||
|
OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku)
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ARTICLE_WAREHOUSE_STOCK = `
|
||||||
|
SELECT COALESCE(SUM(l.amount), 0) AS stock
|
||||||
|
FROM ledger l
|
||||||
|
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
|
||||||
|
INNER JOIN manifest m ON m.id = ma.manifest_id
|
||||||
|
WHERE l.warehouse_id = @warehouseId
|
||||||
|
AND m.company_id = @companyId
|
||||||
|
AND (
|
||||||
|
ma.sku = @sku
|
||||||
|
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|||||||
@@ -13,5 +13,8 @@ export const {
|
|||||||
CHECK_SHIPMENT_PACKAGES_SHIPPED,
|
CHECK_SHIPMENT_PACKAGES_SHIPPED,
|
||||||
CHECK_ORDER_HAS_SHIPMENTS,
|
CHECK_ORDER_HAS_SHIPMENTS,
|
||||||
DELETE_SHIPMENT,
|
DELETE_SHIPMENT,
|
||||||
|
GET_DEFAULT_WAREHOUSE,
|
||||||
|
GET_ARTICLE_TRACK_STOCK,
|
||||||
|
GET_ARTICLE_WAREHOUSE_STOCK,
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,17 @@ export default function add(provider) {
|
|||||||
warehouseName = defWh.rows[0].name;
|
warehouseName = defWh.rows[0].name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stockRes = await runner.query(queries.GET_ARTICLE_WAREHOUSE_STOCK, {
|
||||||
|
warehouseId: effectiveWhId,
|
||||||
|
companyId,
|
||||||
|
sku,
|
||||||
|
articleId: articleMatches[0]?.id ?? null,
|
||||||
|
});
|
||||||
|
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
|
||||||
|
if (currentStock + outboundAmount < 0) {
|
||||||
|
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(outboundAmount)}`);
|
||||||
|
}
|
||||||
|
|
||||||
await insertLedgerRow({
|
await insertLedgerRow({
|
||||||
whId: effectiveWhId,
|
whId: effectiveWhId,
|
||||||
type: 'WAREHOUSE',
|
type: 'WAREHOUSE',
|
||||||
@@ -363,6 +374,19 @@ export default function add(provider) {
|
|||||||
throw new Error('Warehouse not found');
|
throw new Error('Warehouse not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (amount < 0) {
|
||||||
|
const stockRes = await runner.query(queries.GET_ARTICLE_WAREHOUSE_STOCK, {
|
||||||
|
warehouseId,
|
||||||
|
companyId,
|
||||||
|
sku,
|
||||||
|
articleId: articleMatches[0]?.id ?? null,
|
||||||
|
});
|
||||||
|
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
|
||||||
|
if (currentStock + amount < 0) {
|
||||||
|
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const row = await insertLedgerRow({
|
const row = await insertLedgerRow({
|
||||||
whId: warehouseId,
|
whId: warehouseId,
|
||||||
type: transactionType,
|
type: transactionType,
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ export default function createShipment(provider) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new shipment manifest for an order.
|
* Creates a new shipment manifest for an order.
|
||||||
* Can contain one, some, or all items from the parent order.
|
* Can contain specified 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} googleAuthId - The id of the Google auth user.
|
||||||
* @param {number} orderId - The parent order manifest id.
|
* @param {number} orderId - The parent order manifest id.
|
||||||
* @param {object} [input] - Optional overrides.
|
* @param {object} input - Shipment input.
|
||||||
* @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 {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - Positions to ship.
|
||||||
* @param {string|Date} [input.date] - Optional shipment date.
|
* @param {string|Date} [input.date] - Optional shipment date.
|
||||||
* @param {string} [input.externalNumber] - Optional external delivery reference.
|
* @param {string} [input.externalNumber] - Optional external delivery reference.
|
||||||
* @param {object} [tx] - Optional transaction.
|
* @param {object} [tx] - Optional transaction.
|
||||||
* @returns {Promise<object>} The created shipment.
|
* @returns {Promise<object>} The created shipment.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const shipment = await store.order.createShipment(googleAuthId, 10);
|
* const shipment = await store.order.createShipment(googleAuthId, 10, { lines: [{ sku: 'ITEM-A', amount: 5 }] });
|
||||||
*/
|
*/
|
||||||
return async (googleAuthId, orderId, input = {}, tx) => {
|
return async (googleAuthId, orderId, input = {}, tx) => {
|
||||||
return create(googleAuthId, { ...input, orderId }, tx);
|
return create(googleAuthId, { ...input, orderId }, tx);
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ const SQL_SHIPPED_QTY_SQLITE = `
|
|||||||
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
|
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = @companyId
|
WHERE m_s.company_id = @companyId
|
||||||
AND m_s."type" = 'shipment'
|
AND m_s."type" = 'shipment'
|
||||||
|
AND COALESCE(ss.state, 'new') != 'cancelled'
|
||||||
AND m_s."number" >= (@orderNumber || @shipmentPrefix)
|
AND m_s."number" >= (@orderNumber || @shipmentPrefix)
|
||||||
AND m_s."number" < (@orderNumber || @shipmentPrefix || char(127))
|
AND m_s."number" < (@orderNumber || @shipmentPrefix || char(127))
|
||||||
`;
|
`;
|
||||||
@@ -59,8 +61,10 @@ const SQL_SHIPPED_QTY_MSSQL = `
|
|||||||
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
|
SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = @companyId
|
WHERE m_s.company_id = @companyId
|
||||||
AND m_s.[type] = 'shipment'
|
AND m_s.[type] = 'shipment'
|
||||||
|
AND ISNULL(ss.state, 'new') != 'cancelled'
|
||||||
AND m_s.[number] >= (@orderNumber + @shipmentPrefix)
|
AND m_s.[number] >= (@orderNumber + @shipmentPrefix)
|
||||||
AND m_s.[number] < (@orderNumber + @shipmentPrefix + NCHAR(127))
|
AND m_s.[number] < (@orderNumber + @shipmentPrefix + NCHAR(127))
|
||||||
`;
|
`;
|
||||||
@@ -255,8 +259,10 @@ export async function rebuildOrderSummaries(runner, companyId) {
|
|||||||
SUM(ma_s.amount) AS shipped_quantity
|
SUM(ma_s.amount) AS shipped_quantity
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = @companyId
|
WHERE m_s.company_id = @companyId
|
||||||
AND m_s."type" = 'shipment'
|
AND m_s."type" = 'shipment'
|
||||||
|
AND COALESCE(ss.state, 'new') != 'cancelled'
|
||||||
AND INSTR(m_s."number", @shipmentPrefix) > 0
|
AND INSTR(m_s."number", @shipmentPrefix) > 0
|
||||||
GROUP BY SUBSTR(m_s."number", 1, INSTR(m_s."number", @shipmentPrefix) - 1)
|
GROUP BY SUBSTR(m_s."number", 1, INSTR(m_s."number", @shipmentPrefix) - 1)
|
||||||
)
|
)
|
||||||
@@ -327,8 +333,10 @@ export async function rebuildOrderSummaries(runner, companyId) {
|
|||||||
SUM(ma_s.amount) AS shipped_quantity
|
SUM(ma_s.amount) AS shipped_quantity
|
||||||
FROM manifest m_s
|
FROM manifest m_s
|
||||||
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id
|
||||||
|
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id
|
||||||
WHERE m_s.company_id = @companyId
|
WHERE m_s.company_id = @companyId
|
||||||
AND m_s.[type] = 'shipment'
|
AND m_s.[type] = 'shipment'
|
||||||
|
AND ISNULL(ss.state, 'new') != 'cancelled'
|
||||||
AND CHARINDEX(@shipmentPrefix, m_s.[number]) > 0
|
AND CHARINDEX(@shipmentPrefix, m_s.[number]) > 0
|
||||||
GROUP BY SUBSTRING(m_s.[number], 1, CHARINDEX(@shipmentPrefix, m_s.[number]) - 1)
|
GROUP BY SUBSTRING(m_s.[number], 1, CHARINDEX(@shipmentPrefix, m_s.[number]) - 1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,34 +27,30 @@ export default function create(provider) {
|
|||||||
const q = queriesFor(provider);
|
const q = queriesFor(provider);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a shipment manifest for an order containing one, some, or all items from the order.
|
* Creates a shipment manifest for an order containing specified 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.
|
* On individual lines, amount is optional and defaults to the remaining unshipped quantity.
|
||||||
*
|
*
|
||||||
* @param {number} googleAuthId - The id of the Google auth user.
|
* @param {number} googleAuthId - The id of the Google auth user.
|
||||||
* @param {object} input - The shipment input.
|
* @param {object} input - The shipment input.
|
||||||
* @param {number} input.orderId - The parent order manifest id.
|
* @param {number} input.orderId - The parent order manifest id.
|
||||||
|
* @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|Date} [input.date] - Optional shipment date (defaults to current date).
|
* @param {string|Date} [input.date] - Optional shipment date (defaults to current date).
|
||||||
* @param {'new'|'delivered'|'returned'} [input.state='new'] - Optional delivery state (defaults to 'new').
|
* @param {'new'|'delivered'|'returned'} [input.state='new'] - Optional delivery state (defaults to 'new').
|
||||||
* @param {string} [input.externalNumber] - Optional external delivery note number (defaults to order.externalNumber or order.number).
|
* @param {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.
|
* @param {object} [tx] - Optional transaction.
|
||||||
* @returns {Promise<object>} The created shipment manifest.
|
* @returns {Promise<object>} The created shipment manifest.
|
||||||
* @throws {Error} If order not found, invalid lines, all items already shipped, or article ceiling invariant exceeded.
|
* @throws {Error} If order not found, invalid lines, or article ceiling invariant exceeded.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const shipment = await store.shipment.create(googleAuthId, {
|
* const shipment = await store.shipment.create(googleAuthId, {
|
||||||
* orderId: 10,
|
* orderId: 10,
|
||||||
* lines: [{ sku: 'ITEM-A', amount: 5 }]
|
* 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) => {
|
return async (googleAuthId, input = {}, tx) => {
|
||||||
const orderId = requireId(input.orderId, 'Order');
|
const orderId = requireId(input.orderId, 'Order');
|
||||||
|
|
||||||
if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
|
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
||||||
throw new Error('Invalid lines');
|
throw new Error('Invalid lines');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,40 +111,47 @@ export default function create(provider) {
|
|||||||
shippedAmountBySku.set(sku, Number(row.shippedAmount || 0));
|
shippedAmountBySku.set(sku, Number(row.shippedAmount || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Determine lines to ship (one, some, or all items)
|
// 5. Determine warehouse and load stock for tracked articles
|
||||||
const rawLines = input.lines;
|
let targetWarehouseId = input.warehouseId ?? input.warehouse_id;
|
||||||
const isAutoAll = !Array.isArray(rawLines) || rawLines.length === 0;
|
if (targetWarehouseId != null) {
|
||||||
|
targetWarehouseId = Number(targetWarehouseId);
|
||||||
|
} else {
|
||||||
|
const defWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { companyId });
|
||||||
|
targetWarehouseId = defWh.rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleInfoBySku = new Map();
|
||||||
|
const remainingStockBySku = new Map();
|
||||||
|
|
||||||
|
for (const sku of orderAmountBySku.keys()) {
|
||||||
|
const artRes = await runner.query(q.GET_ARTICLE_TRACK_STOCK, { companyId, sku });
|
||||||
|
const art = artRes.rows[0];
|
||||||
|
const isTracked = Number(art?.track_stock ?? 0) === 1;
|
||||||
|
let availableStock = Infinity;
|
||||||
|
|
||||||
|
if (isTracked && targetWarehouseId != null) {
|
||||||
|
const stockRes = await runner.query(q.GET_ARTICLE_WAREHOUSE_STOCK, {
|
||||||
|
warehouseId: targetWarehouseId,
|
||||||
|
companyId,
|
||||||
|
sku,
|
||||||
|
articleId: art?.id ?? null,
|
||||||
|
});
|
||||||
|
availableStock = Number(stockRes.rows[0]?.stock ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
articleInfoBySku.set(sku, {
|
||||||
|
articleId: art?.id,
|
||||||
|
isTracked,
|
||||||
|
availableStock,
|
||||||
|
});
|
||||||
|
remainingStockBySku.set(sku, availableStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Determine lines to ship
|
||||||
const linesToInsert = [];
|
const linesToInsert = [];
|
||||||
const proposedBySku = new Map();
|
const proposedBySku = new Map();
|
||||||
|
|
||||||
if (isAutoAll) {
|
for (const line of input.lines) {
|
||||||
// Automatically ship all remaining unshipped items from the order
|
|
||||||
for (const [sku, ordered] of orderAmountBySku.entries()) {
|
|
||||||
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
|
|
||||||
const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000;
|
|
||||||
if (remaining > 0) {
|
|
||||||
const ol = orderLineBySku.get(sku);
|
|
||||||
linesToInsert.push({
|
|
||||||
sku,
|
|
||||||
name: ol?.name ?? sku,
|
|
||||||
pu: ol?.pu ?? 1,
|
|
||||||
amount: remaining,
|
|
||||||
net: Number(ol?.net ?? 0),
|
|
||||||
rebate: Number(ol?.rebate ?? 0),
|
|
||||||
taxrate: Number(ol?.taxrate ?? 0),
|
|
||||||
comment: ol?.comment ?? null,
|
|
||||||
});
|
|
||||||
proposedBySku.set(sku, remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linesToInsert.length === 0) {
|
|
||||||
throw new Error('All items already shipped');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Specific lines requested (one, some, or all items)
|
|
||||||
for (const line of rawLines) {
|
|
||||||
const sku = String(line?.sku ?? '').trim();
|
const sku = String(line?.sku ?? '').trim();
|
||||||
if (!sku || !orderAmountBySku.has(sku)) {
|
if (!sku || !orderAmountBySku.has(sku)) {
|
||||||
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
|
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
|
||||||
@@ -176,6 +179,16 @@ export default function create(provider) {
|
|||||||
if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) {
|
if (amount > 0 && alreadyShipped + currentProposed + amount > ordered) {
|
||||||
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
|
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const info = articleInfoBySku.get(sku);
|
||||||
|
if (amount > 0 && info?.isTracked) {
|
||||||
|
const available = remainingStockBySku.get(sku) ?? 0;
|
||||||
|
if (amount > available) {
|
||||||
|
throw new Error(`Insufficient stock for article (${sku}): available ${available}, requested ${amount}`);
|
||||||
|
}
|
||||||
|
remainingStockBySku.set(sku, available - amount);
|
||||||
|
}
|
||||||
|
|
||||||
proposedBySku.set(sku, currentProposed + amount);
|
proposedBySku.set(sku, currentProposed + amount);
|
||||||
|
|
||||||
const ol = orderLineBySku.get(sku);
|
const ol = orderLineBySku.get(sku);
|
||||||
@@ -190,7 +203,6 @@ export default function create(provider) {
|
|||||||
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
|
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Resolve next index <y>
|
// 6. Resolve next index <y>
|
||||||
const existingShipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
|
const existingShipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
|
|||||||
import makeRunner from '../runner.js';
|
import makeRunner from '../runner.js';
|
||||||
import { queriesFor } from './helpers.js';
|
import { queriesFor } from './helpers.js';
|
||||||
import { syncOrderSummary } from '../order/syncSummary.js';
|
import { syncOrderSummary } from '../order/syncSummary.js';
|
||||||
|
import { syncShipmentSummary } from './syncSummary.js';
|
||||||
|
import stockAdjust from '../stock/adjust.js';
|
||||||
|
import { computePrevHash } from '../ledger/add.js';
|
||||||
|
import { recordShipmentHistory } from '../articlehistory/record.js';
|
||||||
|
import * as mssqlLedgerQueries from '../../providers/mssql/queries/ledger.js';
|
||||||
|
import * as sqliteLedgerQueries from '../../providers/sqlite3/queries/ledger.js';
|
||||||
|
|
||||||
function requireId(val, name) {
|
function requireId(val, name) {
|
||||||
const n = Number(val);
|
const n = Number(val);
|
||||||
@@ -12,31 +18,38 @@ function requireId(val, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a shipment manifest of the selected company.
|
* Deletes or cancels a shipment manifest of the selected company.
|
||||||
* Shipped shipments (with packages in 'shipped' state) cannot be deleted.
|
* - If no ledger rows exist: physically deletes the shipment manifest.
|
||||||
|
* - If ledger rows exist: reverses the ledger entries (storno document entry + warehouse stock adjustment),
|
||||||
|
* marks the shipment as 'cancelled' in shipmentsummary, and updates the parent order's summary.
|
||||||
|
* - Shipped shipments (with packages in 'shipped' state) cannot be deleted.
|
||||||
*
|
*
|
||||||
* @param {import('../../providers/provider.js').DatabaseProvider} provider
|
* @param {import('../../providers/provider.js').DatabaseProvider} provider
|
||||||
*/
|
*/
|
||||||
export default function del(provider) {
|
export default function del(provider) {
|
||||||
const access = resolveSelectedCompanyAccess(provider);
|
const access = resolveSelectedCompanyAccess(provider);
|
||||||
const q = queriesFor(provider);
|
const q = queriesFor(provider);
|
||||||
|
const adjust = stockAdjust(provider);
|
||||||
|
const isSqlite = provider?.dialect === 'sqlite3';
|
||||||
|
const ledgerQueries = isSqlite ? sqliteLedgerQueries : mssqlLedgerQueries;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a shipment manifest.
|
* Deletes or cancels a shipment manifest.
|
||||||
*
|
*
|
||||||
* @param {number} googleAuthId - The id of the Google auth user.
|
* @param {number} googleAuthId - The id of the Google auth user.
|
||||||
* @param {number} id - The shipment manifest ID.
|
* @param {number} id - The shipment manifest ID.
|
||||||
* @param {object} [tx] - Optional transaction.
|
* @param {object} [tx] - Optional transaction.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<object>} Result indicating whether shipment was deleted or cancelled.
|
||||||
* @throws {Error} If shipment not found or already has shipped packages.
|
* @throws {Error} If shipment not found, already cancelled, or already has shipped packages.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* await store.shipment.delete(googleAuthId, 42);
|
* await store.shipment.delete(googleAuthId, 42);
|
||||||
*/
|
*/
|
||||||
return async (googleAuthId, id, tx) => {
|
return async (googleAuthId, id, tx) => {
|
||||||
const shipmentId = requireId(id, 'Shipment');
|
const shipmentId = requireId(id, 'Shipment');
|
||||||
const runner = makeRunner(provider, tx);
|
|
||||||
const { companyId } = await access(googleAuthId, runner);
|
const run = async (runner, activeTx) => {
|
||||||
|
const { companyId, companyuserId } = await access(googleAuthId, runner);
|
||||||
|
|
||||||
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
|
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
|
||||||
id: shipmentId,
|
id: shipmentId,
|
||||||
@@ -46,6 +59,14 @@ export default function del(provider) {
|
|||||||
throw new Error('Shipment not found');
|
throw new Error('Shipment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sumRes = await runner.query(
|
||||||
|
'SELECT state, customer_name FROM shipmentsummary WHERE manifest_id = @manifestId',
|
||||||
|
{ manifestId: shipmentId },
|
||||||
|
);
|
||||||
|
if (sumRes.rows[0]?.state === 'cancelled') {
|
||||||
|
throw new Error('Shipment already cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, {
|
const shippedCheck = await runner.query(q.CHECK_SHIPMENT_PACKAGES_SHIPPED, {
|
||||||
id: shipmentId,
|
id: shipmentId,
|
||||||
});
|
});
|
||||||
@@ -59,13 +80,143 @@ export default function del(provider) {
|
|||||||
const pIdx = shipmentNumber.indexOf(shipmentPrefix);
|
const pIdx = shipmentNumber.indexOf(shipmentPrefix);
|
||||||
const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null;
|
const orderNumber = pIdx > 0 ? shipmentNumber.slice(0, pIdx) : null;
|
||||||
|
|
||||||
|
// Find all ledger rows booked for lines of this shipment
|
||||||
|
const ledgerRowsRes = await runner.query(
|
||||||
|
`SELECT l.id, l.manifestarticle_id, l.warehouse_id, l.transaction_type, l.amount, l.transaction_text,
|
||||||
|
ma.sku, ma.name AS article_name, ma.amount AS line_amount
|
||||||
|
FROM ledger l
|
||||||
|
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
|
||||||
|
WHERE ma.manifest_id = @shipmentId
|
||||||
|
ORDER BY l.id ASC`,
|
||||||
|
{ shipmentId },
|
||||||
|
);
|
||||||
|
const ledgerRows = ledgerRowsRes.rows ?? [];
|
||||||
|
|
||||||
|
if (ledgerRows.length === 0) {
|
||||||
await runner.query(q.DELETE_SHIPMENT, {
|
await runner.query(q.DELETE_SHIPMENT, {
|
||||||
id: shipmentId,
|
id: shipmentId,
|
||||||
companyId,
|
companyId,
|
||||||
});
|
});
|
||||||
|
await runner.query('DELETE FROM shipmentsummary WHERE manifest_id = @id', { id: shipmentId });
|
||||||
if (orderNumber) {
|
if (orderNumber) {
|
||||||
await syncOrderSummary(runner, { companyId, orderNumber });
|
await syncOrderSummary(runner, { companyId, orderNumber });
|
||||||
}
|
}
|
||||||
|
return { deleted: true, shipmentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ledger entries exist -> Storno / Compensating entries
|
||||||
|
const customerName = sumRes.rows[0]?.customer_name || null;
|
||||||
|
const stornoDocText = shipmentNumber ? `Storno Lieferschein ${shipmentNumber}` : 'Storno Lieferschein';
|
||||||
|
|
||||||
|
// Group ledger entries by manifestarticle_id
|
||||||
|
const byLine = new Map();
|
||||||
|
for (const row of ledgerRows) {
|
||||||
|
const lineId = row.manifestarticle_id;
|
||||||
|
if (!byLine.has(lineId)) {
|
||||||
|
byLine.set(lineId, {
|
||||||
|
sku: row.sku,
|
||||||
|
articleName: row.article_name,
|
||||||
|
docAmount: 0,
|
||||||
|
warehouseAmounts: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const lineData = byLine.get(lineId);
|
||||||
|
if (row.warehouse_id == null) {
|
||||||
|
lineData.docAmount += Number(row.amount || 0);
|
||||||
|
} else {
|
||||||
|
const whId = Number(row.warehouse_id);
|
||||||
|
lineData.warehouseAmounts.set(
|
||||||
|
whId,
|
||||||
|
(lineData.warehouseAmounts.get(whId) || 0) + Number(row.amount || 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [lineId, data] of byLine.entries()) {
|
||||||
|
// Revert document ledger entry if net balance is not 0
|
||||||
|
if (data.docAmount !== 0) {
|
||||||
|
const compensatingDocAmount = -data.docAmount;
|
||||||
|
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
|
||||||
|
const prevRow = latest.rows[0];
|
||||||
|
const prevHash = computePrevHash(prevRow);
|
||||||
|
|
||||||
|
await runner.query(ledgerQueries.INSERT_ROW, {
|
||||||
|
manifestarticleId: lineId,
|
||||||
|
warehouseId: null,
|
||||||
|
transactionType: 'STORNO',
|
||||||
|
amount: compensatingDocAmount,
|
||||||
|
transactionText: stornoDocText,
|
||||||
|
hashVersion: 1,
|
||||||
|
prevHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write article history for storno
|
||||||
|
if (data.sku) {
|
||||||
|
const trackRes = await runner.query(
|
||||||
|
`SELECT a.id FROM article a
|
||||||
|
WHERE a.company_id = @companyId
|
||||||
|
AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))`,
|
||||||
|
{ companyId, sku: data.sku },
|
||||||
|
);
|
||||||
|
for (const match of trackRes.rows ?? []) {
|
||||||
|
await recordShipmentHistory(runner, {
|
||||||
|
articleId: match.id,
|
||||||
|
companyuserId,
|
||||||
|
manifestNumber: shipmentNumber,
|
||||||
|
customerName,
|
||||||
|
amount: compensatingDocAmount,
|
||||||
|
transactionText: stornoDocText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert warehouse ledger entries
|
||||||
|
for (const [whId, whAmount] of data.warehouseAmounts.entries()) {
|
||||||
|
if (whAmount !== 0) {
|
||||||
|
const compensatingWhAmount = -whAmount;
|
||||||
|
await adjust(
|
||||||
|
googleAuthId,
|
||||||
|
{
|
||||||
|
manifestarticleId: lineId,
|
||||||
|
warehouseId: whId,
|
||||||
|
amount: compensatingWhAmount,
|
||||||
|
text: stornoDocText,
|
||||||
|
},
|
||||||
|
activeTx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free the shipment number in manifest so subsequent shipments can reuse the number
|
||||||
|
const freedManifestNumber = `${shipmentNumber}#cancelled#${shipmentId}`;
|
||||||
|
await runner.query(
|
||||||
|
isSqlite
|
||||||
|
? 'UPDATE manifest SET "number" = @freedManifestNumber WHERE id = @shipmentId'
|
||||||
|
: 'UPDATE manifest SET [number] = @freedManifestNumber WHERE id = @shipmentId',
|
||||||
|
{ freedManifestNumber, shipmentId },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark shipment as cancelled in shipmentsummary
|
||||||
|
await syncShipmentSummary(runner, {
|
||||||
|
companyId,
|
||||||
|
manifestId: shipmentId,
|
||||||
|
state: 'cancelled',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resync order summary to reopen positions on parent order
|
||||||
|
if (orderNumber) {
|
||||||
|
await syncOrderSummary(runner, { companyId, orderNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancelled: true, shipmentId, restoredStock: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tx) return run(makeRunner(provider, tx), tx);
|
||||||
|
if (typeof provider?.transaction === 'function') {
|
||||||
|
return provider.transaction((runner, activeTx) => run(runner, activeTx));
|
||||||
|
}
|
||||||
|
return run(makeRunner(provider));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export async function loadShipment(runner, q, companyId, id) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
number: row.number,
|
number: String(row.number || '').split('#cancelled#')[0],
|
||||||
type: 'shipment',
|
type: 'shipment',
|
||||||
entityId: row.entityId ?? null,
|
entityId: row.entityId ?? null,
|
||||||
externalNumber: row.externalNumber ?? null,
|
externalNumber: row.externalNumber ?? null,
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export default function list(provider) {
|
|||||||
* @param {number} [opts.offset=0] - Number of shipments to skip.
|
* @param {number} [opts.offset=0] - Number of shipments to skip.
|
||||||
* @param {number} [opts.limit=50] - Number of shipments to return.
|
* @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 {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 {'new'|'delivered'|'returned'|'cancelled'|'all'} [opts.state] - Filter by delivery state.
|
||||||
|
* @param {boolean} [opts.includeCancelled=false] - Whether to include cancelled shipments when state is not set.
|
||||||
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
|
* @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 {number} [opts.orderId] - Optional filter for parent order manifest id.
|
||||||
* @param {object} [tx] - Optional transaction.
|
* @param {object} [tx] - Optional transaction.
|
||||||
@@ -109,11 +110,16 @@ export default function list(provider) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery state filter ('new' | 'delivered' | 'returned')
|
// Delivery state filter ('new' | 'delivered' | 'returned' | 'cancelled' | 'all')
|
||||||
if (typeof opts?.state === 'string' && opts.state.trim() !== '') {
|
if (typeof opts?.state === 'string' && opts.state.trim() !== '') {
|
||||||
params.filterState = opts.state.trim().toLowerCase();
|
const stateFilter = opts.state.trim().toLowerCase();
|
||||||
|
if (stateFilter !== 'all') {
|
||||||
|
params.filterState = stateFilter;
|
||||||
conditions.push("COALESCE(ss.state, 'new') = @filterState");
|
conditions.push("COALESCE(ss.state, 'new') = @filterState");
|
||||||
}
|
}
|
||||||
|
} else if (!opts?.includeCancelled) {
|
||||||
|
conditions.push("COALESCE(ss.state, 'new') != 'cancelled'");
|
||||||
|
}
|
||||||
|
|
||||||
// Free-text search
|
// Free-text search
|
||||||
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
|
if (typeof opts?.search === 'string' && opts.search.trim() !== '') {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const SQL_UPSERT_SUMMARY_MSSQL = `
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
|
const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recalculates and upserts a single shipment summary into `shipmentsummary`.
|
* Recalculates and upserts a single shipment summary into `shipmentsummary`.
|
||||||
@@ -153,6 +153,8 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanShipmentNumber = String(shipment.number || '').split('#cancelled#')[0];
|
||||||
|
|
||||||
// 1. Line stats
|
// 1. Line stats
|
||||||
const lineRes = await runner.query(SQL_LINE_STATS, { manifestId });
|
const lineRes = await runner.query(SQL_LINE_STATS, { manifestId });
|
||||||
const positions = Number(lineRes.rows[0]?.positions || 0);
|
const positions = Number(lineRes.rows[0]?.positions || 0);
|
||||||
@@ -175,7 +177,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
|
|||||||
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
|
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
|
||||||
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
|
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
|
||||||
|
|
||||||
const num = String(shipment.number || '');
|
const num = cleanShipmentNumber;
|
||||||
if (num.includes(shipmentPrefix)) {
|
if (num.includes(shipmentPrefix)) {
|
||||||
const parsedOrderNum = num.split(shipmentPrefix)[0];
|
const parsedOrderNum = num.split(shipmentPrefix)[0];
|
||||||
if (parsedOrderNum) {
|
if (parsedOrderNum) {
|
||||||
@@ -201,7 +203,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
|
|||||||
customerName = custRes.rows[0]?.customer_name || null;
|
customerName = custRes.rows[0]?.customer_name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. State ('new' | 'delivered' | 'returned')
|
// 5. State ('new' | 'delivered' | 'returned' | 'cancelled')
|
||||||
let resolvedState = 'new';
|
let resolvedState = 'new';
|
||||||
if (state && VALID_STATES.has(state)) {
|
if (state && VALID_STATES.has(state)) {
|
||||||
resolvedState = state;
|
resolvedState = state;
|
||||||
@@ -229,7 +231,7 @@ export async function syncShipmentSummary(runner, { companyId, manifestId, order
|
|||||||
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
|
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
|
||||||
companyId,
|
companyId,
|
||||||
manifestId,
|
manifestId,
|
||||||
shipmentNumber: shipment.number,
|
shipmentNumber: cleanShipmentNumber,
|
||||||
shipmentDate: shipment.date,
|
shipmentDate: shipment.date,
|
||||||
createdAt: shipment.created_at,
|
createdAt: shipment.created_at,
|
||||||
orderId: resolvedOrderId,
|
orderId: resolvedOrderId,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function requireId(val, name) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
|
const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a shipment's state or metadata.
|
* Updates a shipment's state or metadata.
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ export default function adjust(provider) {
|
|||||||
const text = input.text ?? input.transactionText ?? input.transaction_text ??
|
const text = input.text ?? input.transactionText ?? input.transaction_text ??
|
||||||
(amount > 0 ? `Stock addition (+${amount})` : `Stock deduction (${amount})`);
|
(amount > 0 ? `Stock addition (+${amount})` : `Stock deduction (${amount})`);
|
||||||
|
|
||||||
|
if (amount < 0) {
|
||||||
|
const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, {
|
||||||
|
warehouseId,
|
||||||
|
companyId,
|
||||||
|
sku,
|
||||||
|
articleId: art.id,
|
||||||
|
});
|
||||||
|
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
|
||||||
|
if (currentStock + amount < 0) {
|
||||||
|
throw new Error(`Insufficient stock for article (${sku}): available ${currentStock}, requested ${Math.abs(amount)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
|
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
|
||||||
const prevRow = latest.rows[0];
|
const prevRow = latest.rows[0];
|
||||||
const prevHash = computePrevHash(prevRow);
|
const prevHash = computePrevHash(prevRow);
|
||||||
@@ -301,6 +314,19 @@ export default function adjust(provider) {
|
|||||||
const createdLedgerEntries = [];
|
const createdLedgerEntries = [];
|
||||||
|
|
||||||
for (const item of resolvedArticles) {
|
for (const item of resolvedArticles) {
|
||||||
|
if (item.amount < 0) {
|
||||||
|
const stockRes = await runner.query(ledgerQueries.GET_ARTICLE_WAREHOUSE_STOCK, {
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
companyId,
|
||||||
|
sku: item.sku,
|
||||||
|
articleId: item.articleId,
|
||||||
|
});
|
||||||
|
const currentStock = Number(stockRes.rows[0]?.stock ?? 0);
|
||||||
|
if (currentStock + item.amount < 0) {
|
||||||
|
throw new Error(`Insufficient stock for article (${item.sku}): available ${currentStock}, requested ${Math.abs(item.amount)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create manifestarticle row
|
// Create manifestarticle row
|
||||||
const lineRes = await runner.query(queries.CREATE_LINE, {
|
const lineRes = await runner.query(queries.CREATE_LINE, {
|
||||||
manifestId,
|
manifestId,
|
||||||
|
|||||||
@@ -753,6 +753,7 @@ describe('store.article', () => {
|
|||||||
|
|
||||||
await store.shipment.create(gid, {
|
await store.shipment.create(gid, {
|
||||||
orderId: o2.id,
|
orderId: o2.id,
|
||||||
|
lines: [{ sku: art.sku, amount: 5 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
res = await store.article.getBySku(gid, art.sku);
|
res = await store.article.getBySku(gid, art.sku);
|
||||||
|
|||||||
@@ -69,15 +69,10 @@ describe('store.shipment', () => {
|
|||||||
assert.equal(rest.lines[0].amount, 7);
|
assert.equal(rest.lines[0].amount, 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ships everything remaining when lines are omitted', async () => {
|
it('rejects when lines are omitted or empty', async () => {
|
||||||
const order = await makeOrder();
|
const order = await makeOrder();
|
||||||
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
|
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'Invalid lines' });
|
||||||
const rest = await store.shipment.create(gid, { orderId: order.id });
|
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id, lines: [] }), { message: 'Invalid lines' });
|
||||||
assert.deepEqual(
|
|
||||||
rest.lines.map((l) => [l.sku, l.amount]).sort(),
|
|
||||||
[['SH-A', 6], ['SH-B', 5]],
|
|
||||||
);
|
|
||||||
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'All items already shipped' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates the order summary', async () => {
|
it('updates the order summary', async () => {
|
||||||
@@ -88,7 +83,7 @@ describe('store.shipment', () => {
|
|||||||
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
|
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
|
||||||
assert.equal(summary().shipped_quantity, 10);
|
assert.equal(summary().shipped_quantity, 10);
|
||||||
assert.equal(summary().is_shipped, 0);
|
assert.equal(summary().is_shipped, 0);
|
||||||
await store.shipment.create(gid, { orderId: order.id });
|
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-B', amount: 5 }] });
|
||||||
assert.equal(summary().shipped_quantity, 15);
|
assert.equal(summary().shipped_quantity, 15);
|
||||||
assert.equal(summary().is_shipped, 1);
|
assert.equal(summary().is_shipped, 1);
|
||||||
});
|
});
|
||||||
@@ -120,18 +115,18 @@ describe('store.shipment', () => {
|
|||||||
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 0 }] }),
|
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 0 }] }),
|
||||||
{ message: 'Invalid lines' },
|
{ message: 'Invalid lines' },
|
||||||
);
|
);
|
||||||
await assert.rejects(() => store.shipment.create(gid, { orderId: 999999 }), { message: 'Order not found' });
|
await assert.rejects(() => store.shipment.create(gid, { orderId: 999999, lines: [{ sku: 'SH-A', amount: 1 }] }), { message: 'Order not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects orders without lines', async () => {
|
it('rejects orders without lines', async () => {
|
||||||
const empty = await makeOrder([]);
|
const empty = await makeOrder([]);
|
||||||
await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id }), { message: 'Order has no lines' });
|
await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id, lines: [{ sku: 'SH-A', amount: 1 }] }), { message: 'Order has no lines' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cannot ship orders of another company', async () => {
|
it('cannot ship orders of another company', async () => {
|
||||||
const order = await makeOrder();
|
const order = await makeOrder();
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => store.shipment.create(other.googleAuthId, { orderId: order.id }),
|
() => store.shipment.create(other.googleAuthId, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }),
|
||||||
{ message: 'Order not found' },
|
{ message: 'Order not found' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user