This commit is contained in:
seb
2026-10-23 20:31:39 +02:00
parent 8002ca56e6
commit 82ab27ef2b
11 changed files with 39 additions and 41 deletions

2
API.md
View File

@@ -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'|'cancelled'|'all', includeCancelled?: boolean, 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'|'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`

View File

@@ -53,7 +53,7 @@ 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'|'cancelled'|'all'` | Filter by delivery state. | | `[opts.state]` | `'new'|'delivered'|'cancelled'|'all'` | Filter by delivery state. |
| `[opts.includeCancelled=false]` | `boolean` | Whether to include cancelled shipments when state is not set. | | `[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. |

View File

@@ -59,7 +59,7 @@ Rebuilds the `articlewarehousesummary` table for the user's selected company.
*Source: `src/store/stock/ship.js`* *Source: `src/store/stock/ship.js`*
Ships a shipment manifest item (or multiple shipment items). Requirements: - Every shipped shipment manifest item receives a document ledger entry: - If amount >= 0: transaction_type = 'SHIPMENT', amount = -amount, warehouse_id = null. - If amount < 0: transaction_type = 'RETOURE', amount = +|amount|, warehouse_id = null. - Always records article history with shipment number, customer, and quantity. - If the connected article has stock tracking (track_stock == 1): Calls adjust with: - If normal shipment (amount >= 0): -amount (outgoing stock from warehouse). - If retoure (amount < 0): +|amount| (incoming stock into warehouse). - If the connected article does not have stock tracking: Only document shipment is booked; no warehouse adjust is performed. - Double shipping of an already shipped line is strictly prevented. Ships a shipment manifest item (or multiple shipment items). Requirements: - Every shipped shipment manifest item receives a document ledger entry: - transaction_type = 'SHIPMENT', amount = -amount, warehouse_id = null. - Line amount must be positive (> 0); negative or zero line amounts are rejected. - Always records article history with shipment number, customer, and quantity. - If the connected article has stock tracking (track_stock == 1): Calls adjust with: - -amount (outgoing stock from warehouse). - If the connected article does not have stock tracking: Only document shipment is booked; no warehouse adjust is performed. - Double shipping of an already shipped line is strictly prevented.
**Parameters** **Parameters**

View File

@@ -350,7 +350,7 @@ async function run() {
assert.equal(acceptNegResult.adjust_ledger.warehouse_id, wh1.id); assert.equal(acceptNegResult.adjust_ledger.warehouse_id, wh1.id);
// 9. Test store.stock.ship with shipmentmanifestitem // 9. Test store.stock.ship with shipmentmanifestitem
console.log('9. Testing store.stock.ship (positive shipment & negative retoure)...'); console.log('9. Testing store.stock.ship (positive shipment & rejection of negative line)...');
const order3 = await store.order.create(googleAuthId, { const order3 = await store.order.create(googleAuthId, {
number: 'O-3', number: 'O-3',
entityId: customer.id, entityId: customer.id,
@@ -380,14 +380,14 @@ async function run() {
assert.equal(shipPosResult.document_ledger.amount, -4); assert.equal(shipPosResult.document_ledger.amount, -4);
assert.equal(shipPosResult.adjust_ledger.amount, -4); assert.equal(shipPosResult.adjust_ledger.amount, -4);
// Negative shipment line: retoure of a shipment (incoming) // Negative shipment line: shipping is rejected
const shipNegResult = await store.stock.ship(googleAuthId, { await assert.rejects(
store.stock.ship(googleAuthId, {
manifestarticleId: negShipLineId, manifestarticleId: negShipLineId,
warehouseId: wh1.id, warehouseId: wh1.id,
}); }),
assert.equal(shipNegResult.document_ledger.transaction_type, 'RETOURE'); /Amount must be positive/,
assert.equal(shipNegResult.document_ledger.amount, 1); );
assert.equal(shipNegResult.adjust_ledger.amount, 1);
// 10. Test store.stock.adjust // 10. Test store.stock.adjust
console.log('10. Testing store.stock.adjust failure on untracked article...'); console.log('10. Testing store.stock.adjust failure on untracked article...');

View File

@@ -108,7 +108,7 @@ WHERE m.id = @manifestId
) )
OR EXISTS ( OR EXISTS (
SELECT 1 FROM shipmentsummary ss SELECT 1 FROM shipmentsummary ss
WHERE ss.manifest_id = m.id AND LOWER(ss.[state]) IN ('delivered', 'returned') WHERE ss.manifest_id = m.id AND LOWER(ss.[state]) = 'delivered'
) )
); );
`; `;

View File

@@ -37,7 +37,7 @@ export default function create(provider) {
* @param {number} input.orderId - The parent order manifest id. * @param {number} input.orderId - The parent order manifest id.
* @param {Array<{sku?: string, orderLineId?: number, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions. Order lines with a SKU are referenced by `sku`; SKU-less order lines by `orderLineId` (matched across shipments by name). * @param {Array<{sku?: string, orderLineId?: number, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions. Order lines with a SKU are referenced by `sku`; SKU-less order lines by `orderLineId` (matched across shipments by name).
* @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'} [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 {object} input.shipping - Required shipping (package) created atomically with the shipment, so a shipment never exists without a carrier. * @param {object} input.shipping - Required shipping (package) created atomically with the shipment, so a shipment never exists without a carrier.
* @param {number} input.shipping.carrierId - The carrier id. * @param {number} input.shipping.carrierId - The carrier id.
@@ -290,12 +290,12 @@ export default function create(provider) {
}); });
// 8b. Create the shipping (carrier) atomically with the shipment. Must run before the // 8b. Create the shipping (carrier) atomically with the shipment. Must run before the
// summary state ('delivered'/'returned') is written, as that would block shipping creation. // summary state ('delivered') is written, as that would block shipping creation.
await createShipping(googleAuthId, { ...input.shipping, manifestId }, innerTx); await createShipping(googleAuthId, { ...input.shipping, manifestId }, innerTx);
// 9. Synchronize order summary row and shipment summary row // 9. Synchronize order summary row and shipment summary row
await syncOrderSummary(runner, { companyId, manifestId: orderId }); await syncOrderSummary(runner, { companyId, manifestId: orderId });
const validState = input.state && ['new', 'delivered', 'returned'].includes(String(input.state).trim().toLowerCase()) const validState = input.state && ['new', 'delivered'].includes(String(input.state).trim().toLowerCase())
? String(input.state).trim().toLowerCase() ? String(input.state).trim().toLowerCase()
: 'new'; : 'new';

View File

@@ -31,7 +31,7 @@ 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'|'cancelled'|'all'} [opts.state] - Filter by delivery state. * @param {'new'|'delivered'|'cancelled'|'all'} [opts.state] - Filter by delivery state.
* @param {boolean} [opts.includeCancelled=false] - Whether to include cancelled shipments when state is not set. * @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.
@@ -110,7 +110,7 @@ export default function list(provider) {
} }
} }
// Delivery state filter ('new' | 'delivered' | 'returned' | 'cancelled' | 'all') // Delivery state filter ('new' | 'delivered' | 'cancelled' | 'all')
if (typeof opts?.state === 'string' && opts.state.trim() !== '') { if (typeof opts?.state === 'string' && opts.state.trim() !== '') {
const stateFilter = opts.state.trim().toLowerCase(); const stateFilter = opts.state.trim().toLowerCase();
if (stateFilter !== 'all') { if (stateFilter !== 'all') {

View File

@@ -126,7 +126,7 @@ const SQL_UPSERT_SUMMARY_MSSQL = `
); );
`; `;
const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']); const VALID_STATES = new Set(['new', 'delivered', 'cancelled']);
/** /**
* Recalculates and upserts a single shipment summary into `shipmentsummary`. * Recalculates and upserts a single shipment summary into `shipmentsummary`.
@@ -137,7 +137,7 @@ const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']);
* @param {number} opts.manifestId - Shipment manifest id. * @param {number} opts.manifestId - Shipment manifest id.
* @param {number} [opts.orderId] - Optional parent order manifest id. * @param {number} [opts.orderId] - Optional parent order manifest id.
* @param {string} [opts.orderNumber] - Optional parent order number. * @param {string} [opts.orderNumber] - Optional parent order number.
* @param {string} [opts.state] - Optional new state ('new' | 'delivered' | 'returned'). * @param {string} [opts.state] - Optional new state ('new' | 'delivered').
*/ */
export async function syncShipmentSummary(runner, { companyId, manifestId, orderId, orderNumber, state }) { export async function syncShipmentSummary(runner, { companyId, manifestId, orderId, orderNumber, state }) {
const isSqlite = runner.dialect === 'sqlite3'; const isSqlite = runner.dialect === 'sqlite3';
@@ -203,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' | 'cancelled') // 5. State ('new' | 'delivered' | 'cancelled')
let resolvedState = 'new'; let resolvedState = 'new';
if (state && VALID_STATES.has(state)) { if (state && VALID_STATES.has(state)) {
resolvedState = state; resolvedState = state;

View File

@@ -11,7 +11,7 @@ function requireId(val, name) {
return n; return n;
} }
const VALID_STATES = new Set(['new', 'delivered', 'returned', 'cancelled']); const VALID_STATES = new Set(['new', 'delivered', 'cancelled']);
/** /**
* Updates a shipment's state or metadata. * Updates a shipment's state or metadata.
@@ -28,7 +28,7 @@ export default function update(provider) {
* @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} patch - Fields to update. * @param {object} patch - Fields to update.
* @param {'new'|'delivered'|'returned'} [patch.state] - Delivery state. * @param {'new'|'delivered'|'cancelled'} [patch.state] - Delivery state.
* @param {object} [tx] - Optional transaction. * @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated shipment manifest. * @returns {Promise<object>} The updated shipment manifest.
* @throws {Error} If shipment not found or invalid state. * @throws {Error} If shipment not found or invalid state.

View File

@@ -12,13 +12,12 @@ import makeRunner from '../runner.js';
* *
* Requirements: * Requirements:
* - Every shipped shipment manifest item receives a document ledger entry: * - Every shipped shipment manifest item receives a document ledger entry:
* - If amount >= 0: transaction_type = 'SHIPMENT', amount = -amount, warehouse_id = null. * - transaction_type = 'SHIPMENT', amount = -amount, warehouse_id = null.
* - If amount < 0: transaction_type = 'RETOURE', amount = +|amount|, warehouse_id = null. * - Line amount must be positive (> 0); negative or zero line amounts are rejected.
* - Always records article history with shipment number, customer, and quantity. * - Always records article history with shipment number, customer, and quantity.
* - If the connected article has stock tracking (track_stock == 1): * - If the connected article has stock tracking (track_stock == 1):
* Calls adjust with: * Calls adjust with:
* - If normal shipment (amount >= 0): -amount (outgoing stock from warehouse). * - -amount (outgoing stock from warehouse).
* - If retoure (amount < 0): +|amount| (incoming stock into warehouse).
* - If the connected article does not have stock tracking: * - If the connected article does not have stock tracking:
* Only document shipment is booked; no warehouse adjust is performed. * Only document shipment is booked; no warehouse adjust is performed.
* - Double shipping of an already shipped line is strictly prevented. * - Double shipping of an already shipped line is strictly prevented.
@@ -120,19 +119,17 @@ export default function ship(provider) {
} }
const lineAmount = Number(manifestArticle.lineAmount || 0); const lineAmount = Number(manifestArticle.lineAmount || 0);
if (lineAmount <= 0) {
throw new Error('Amount must be positive');
}
const shipmentNumber = manifestArticle.manifestNumber; const shipmentNumber = manifestArticle.manifestNumber;
const isRetoure = lineAmount < 0;
const docType = isRetoure ? 'RETOURE' : 'SHIPMENT'; const docType = 'SHIPMENT';
// Normal shipment: negative ledger amount (-Math.abs(lineAmount)). const docAmount = -lineAmount;
// Retoure (customer return): positive ledger amount (+Math.abs(lineAmount)). const defaultDocText = shipmentNumber ? `Lieferschein ${shipmentNumber}` : 'Lieferschein';
const docAmount = isRetoure ? Math.abs(lineAmount) : -Math.abs(lineAmount);
const defaultDocText = isRetoure
? (shipmentNumber ? `Retoure Lieferschein ${shipmentNumber}` : 'Retoure Lieferschein')
: (shipmentNumber ? `Lieferschein ${shipmentNumber}` : 'Lieferschein');
const docText = item.text?.trim?.() || defaultDocText; const docText = item.text?.trim?.() || defaultDocText;
// Insert document shipment/retoure entry (warehouse_id = null) // Insert document shipment entry (warehouse_id = null)
const latest = await runner.query(queries.GET_LATEST_ROW); const latest = await runner.query(queries.GET_LATEST_ROW);
const prevRow = latest.rows[0]; const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow); const prevHash = computePrevHash(prevRow);
@@ -171,7 +168,7 @@ export default function ship(provider) {
} }
} }
// Write article history for shipment / retoure // Write article history for shipment
for (const match of articleMatches) { for (const match of articleMatches) {
await recordShipmentHistory(runner, { await recordShipmentHistory(runner, {
articleId: match.id, articleId: match.id,

View File

@@ -431,10 +431,11 @@ describe('store.ledger', () => {
assert.equal(pos.document_ledger.transaction_type, 'SHIPMENT'); assert.equal(pos.document_ledger.transaction_type, 'SHIPMENT');
assert.equal(pos.document_ledger.amount, -4); assert.equal(pos.document_ledger.amount, -4);
const neg = await store.stock.ship(gid, { manifestarticleId: shipment.lines[1].id, warehouseId: warehouse.id }); await assert.rejects(
assert.equal(neg.document_ledger.transaction_type, 'RETOURE'); store.stock.ship(gid, { manifestarticleId: shipment.lines[1].id, warehouseId: warehouse.id }),
assert.equal(neg.document_ledger.amount, 1); /Amount must be positive/,
assert.equal(await stockOf(sku), 17); );
assert.equal(await stockOf(sku), 16);
}); });
}); });