This commit is contained in:
seb
2026-10-16 22:09:12 +02:00
parent 796f6a3272
commit 7635821dad
38 changed files with 6191 additions and 3350 deletions

View File

@@ -574,7 +574,7 @@ const article = await store.article.setPricesBySku(42, 'SKU-1', [{ gross: 11.9,
*Source: `src/store/article/setTrackStockBySku.js`* *Source: `src/store/article/setTrackStockBySku.js`*
Sets the track_stock flag of an article by its SKU. Sets the track_stock flag of an article by its SKU. If stock tracking is being disabled (1 -> 0), all non-zero warehouse stocks are adjusted to 0 on the ledger before disabling tracking.
**Parameters** **Parameters**
@@ -593,13 +593,6 @@ Sets the track_stock flag of an article by its SKU.
`Error` — If trackStock is invalid, article not found, or no warehouse exists when enabling. `Error` — If trackStock is invalid, article not found, or no warehouse exists when enabling.
**Example**
```js
const article = await store.article.setTrackStockBySku(42, 'SKU-1', true);
// => { id, sku, name, track_stock, ... }
```
## `article.setWeightBySku` ## `article.setWeightBySku`
*Source: `src/store/article/setWeightBySku.js`* *Source: `src/store/article/setWeightBySku.js`*

View File

@@ -7,3 +7,61 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/delivery/attachments.js`* *Source: `src/store/delivery/attachments.js`*
Loads attachment metadata (without binary content) for a manifest. Loads attachment metadata (without binary content) for a manifest.
## `delivery.book`
*Source: `src/store/delivery/book.js`*
Books all positions of an incoming delivery manifest to the ledger by delegating to store.stock.accept.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `object` | Storage provider instance. |
**Returns**
`Function` — `(googleAuthId, input, tx) => Promise<object>`
## `delivery.list`
*Source: `src/store/delivery/list.js`*
Lists the deliveries 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 deliveries to skip. |
| `[opts.limit=50]` | `number` | Number of deliveries to return. |
| `[opts.search]` | `string` | Free-text search matching delivery number, external number, or supplier name. |
| `[opts.onlyUnbooked=false]` | `boolean` | When true, returns only deliveries with unbooked stock items. |
| `[opts.supplierId]` | `number` | Filter by supplier entity id. |
| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{deliveries: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
## `delivery.rebuildSummaries`
*Source: `src/store/delivery/rebuildSummaries.js`*
Rebuilds the `deliverysummary` table for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `import('../../providers/provider.js').DatabaseProvider` | |
## `delivery.syncSummary`
*Source: `src/store/delivery/syncSummary.js`*
Centralized sync and rebuild logic for the materialized `deliverysummary` table. Keeps `deliverysummary` in sync with manifest (delivery), manifestarticle, article (track_stock), and ledger (DELIVERY transactions).

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/ledger/add.js`* *Source: `src/store/ledger/add.js`*
Appends a new append-only entry to the ledger table. Appends new append-only entry/entries to the ledger table according to manifest rules: - quote / order: no bookings possible (throws error) - delivery: acceptance entry (warehouse_id=null) + warehouse entry (if tracked) in one transaction - shipment: shipment entry (warehouse_id=null, negative amount) + warehouse entry (if tracked) in one transaction - adjustment: single warehouse inventory movement
**Parameters** **Parameters**
@@ -23,23 +23,7 @@ Appends a new append-only entry to the ledger table.
**Returns** **Returns**
`Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>` — The created ledger row. `Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>` — The primary ledger row.
**Throws**
`Error` — If user not authorized, company not selected, manifest article not found, or input invalid.
**Example**
```js
const entry = await store.ledger.add(googleAuthId, {
manifestarticle_id: 1,
warehouse_id: 1,
transaction_type: 'DELIVERY',
amount: 100.5,
transaction_text: 'Invoice paid',
});
```
## `ledger.listByManifestarticleId` ## `ledger.listByManifestarticleId`

6
API.md
View File

@@ -174,6 +174,10 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}, tx?: object): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, price: {net: number, taxId: number, taxRate?: number}|null}>` - `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}, tx?: object): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, price: {net: number, taxId: number, taxRate?: number}|null}>`
- [delivery](API.delivery.md) - [delivery](API.delivery.md)
- `attachments(): void` - `attachments(): void`
- `book(provider: object): Function`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number}, tx?: object): Promise<{deliveries: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`
- `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void`
- `syncSummary(): void`
- [quote](API.quote.md) - [quote](API.quote.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}>}>` - `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}>}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
@@ -223,7 +227,9 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `order(googleAuthId: number, input: {warehouseIds: Array<number>}, tx?: object): Promise<{warehouseIds: Array<number>}>` - `order(googleAuthId: number, input: {warehouseIds: Array<number>}, tx?: object): Promise<{warehouseIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>` - `update(googleAuthId: number, id: number, input: {name?: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- [stock](API.stock.md) - [stock](API.stock.md)
- `accept(provider: object): Function`
- `adjust(googleAuthId: number, input: {sku?: string, warehouseId?: number, amount?: number, text?: string, externalNumber?: string, date?: string|Date, lines?: Array<object>}, tx?: object): Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>` - `adjust(googleAuthId: number, input: {sku?: string, warehouseId?: number, amount?: number, text?: string, externalNumber?: string, date?: string|Date, lines?: Array<object>}, tx?: object): Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>`
- `ship(provider: object): Function`
- [posterminal](API.posterminal.md) - [posterminal](API.posterminal.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknowntombstoneid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>` - `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknowntombstoneid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>`

View File

@@ -2,6 +2,22 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `stock.accept`
*Source: `src/store/stock/accept.js`*
Accepts a delivery manifest item (or multiple delivery items). Requirements: - Every accepted delivery manifest item receives a document ledger entry: - If amount >= 0: transaction_type = 'ACCEPTANCE', amount = +amount, warehouse_id = null. - If amount < 0: transaction_type = 'RETURN', amount = amount (negative), warehouse_id = null. - Always records article history with delivery number, supplier, EK brutto, and quantity. - If the connected article has stock tracking (track_stock == 1): Calls adjust with the item's amount to adjust physical inventory in the target warehouse. - If the connected article does not have stock tracking: Only document acceptance is booked; no warehouse adjust is performed. - Double acceptance of an already accepted line is strictly prevented.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `object` | The database provider. |
**Returns**
`Function` — Async function accepting delivery items.
## `stock.adjust` ## `stock.adjust`
*Source: `src/store/stock/adjust.js`* *Source: `src/store/stock/adjust.js`*
@@ -26,3 +42,19 @@ Adjusts article stock manually by creating an implicit manifest of type "adjustm
**Returns** **Returns**
`Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>` `Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>`
## `stock.ship`
*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.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `provider` | `object` | The database provider. |
**Returns**
`Function` — Async function shipping shipment items.

View File

@@ -323,13 +323,31 @@ Company-scoped goods receipts on `manifest` / `manifestarticle` with fixed `type
| Method | Signature | Returns | | Method | Signature | Returns |
| --- | --- | --- | | --- | --- | --- |
| `list` | `(googleAuthId)` | `DeliverySummary[]` | | `list` | `(googleAuthId, opts?: DeliveryListOpts)` | `DeliverySummary[] | WindowedDeliveries` |
| `getById` | `(googleAuthId, id)` | `Delivery` | | `getById` | `(googleAuthId, id)` | `Delivery` |
| `create` | `(googleAuthId, { date, supplierId?, externalNumber?, lines })` | `Delivery` | | `create` | `(googleAuthId, { date, supplierId?, externalNumber?, lines })` | `Delivery` |
| `update` | `(googleAuthId, id, { number?, date?, supplierId?, externalNumber?, lines? })` | `Delivery` | | `update` | `(googleAuthId, id, { number?, date?, supplierId?, externalNumber?, lines? })` | `Delivery` |
| `delete` | `(googleAuthId, id)` | `void` | | `delete` | `(googleAuthId, id)` | `void` |
| `rebuildSummaries` | `(googleAuthId)` | `{ ok: boolean, companyId: number }` |
```ts ```ts
type DeliveryListOpts = {
offset?: number,
limit?: number,
search?: string,
onlyUnbooked?: boolean,
supplierId?: number,
seekMonths?: number,
}
type WindowedDeliveries = {
deliveries: DeliverySummary[],
total: number,
unfilteredTotal: number,
offset: number,
limit: number,
}
type DeliverySummary = { type DeliverySummary = {
id: number, id: number,
number: string, number: string,
@@ -337,6 +355,31 @@ type DeliverySummary = {
supplierId: number | null, // manifest.entity_id supplierId: number | null, // manifest.entity_id
externalNumber: string | null, // manifest.external_number externalNumber: string | null, // manifest.external_number
date: Date, date: Date,
createdAt?: string,
supplierName?: string | null,
summary?: {
positions: number,
grossTotal: number,
netTotal: number,
totalQuantity: number,
stockPositions: number,
bookedPositions: number,
unbookedPositions: number,
stockQuantity: number,
bookedQuantity: number,
unbookedQuantity: number,
isBooked: boolean,
},
ledger?: {
totalPositions: number,
completelyOnLedgerPositions: number,
bookedPositions: number,
unbookedPositions: number,
totalQuantity: number,
bookedQuantity: number,
unbookedQuantity: number,
isBooked: boolean,
},
} }
type DeliveryLine = { type DeliveryLine = {
@@ -779,7 +822,8 @@ Company-scoped number sequences (`numbers` table). Auth via selected company (`g
| `PATCH /api/article/:sku/supplier/:id` | `articlesupplier.update(googleAuthId, sku, id, patch)` | | `PATCH /api/article/:sku/supplier/:id` | `articlesupplier.update(googleAuthId, sku, id, patch)` |
| `PUT /api/article/:sku/supplier/order` | `articlesupplier.order(googleAuthId, sku, { ids })` | | `PUT /api/article/:sku/supplier/order` | `articlesupplier.order(googleAuthId, sku, { ids })` |
| `DELETE /api/article/:sku/supplier/:id` | `articlesupplier.delete(googleAuthId, sku, id)` | | `DELETE /api/article/:sku/supplier/:id` | `articlesupplier.delete(googleAuthId, sku, id)` |
| `GET /api/delivery` | `delivery.list(googleAuthId)` | | `GET /api/delivery` | `delivery.list(googleAuthId, opts)` |
| `POST /api/delivery/rebuild-summaries` | `delivery.rebuildSummaries(googleAuthId)` |
| `GET /api/delivery/:id` | `delivery.getById(googleAuthId, id)` | | `GET /api/delivery/:id` | `delivery.getById(googleAuthId, id)` |
| `POST /api/delivery` | `delivery.create(googleAuthId, { number, date, supplierId?, lines })` | | `POST /api/delivery` | `delivery.create(googleAuthId, { number, date, supplierId?, lines })` |
| `PATCH /api/delivery/:id` | `delivery.update(googleAuthId, id, { number?, date?, supplierId?, lines? })` | | `PATCH /api/delivery/:id` | `delivery.update(googleAuthId, id, { number?, date?, supplierId?, lines? })` |

View File

@@ -374,6 +374,10 @@ const CLUSTERS = [
'manifest', 'manifest',
'manifestarticle', 'manifestarticle',
'manifestattachment', 'manifestattachment',
'deliverysummary',
'ordersummary',
'quotesummary',
'shipmentsummary',
'ledger', 'ledger',
'warehouse', 'warehouse',
'shipping', 'shipping',

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Delivery Summary, Ledger Sync & Filtering in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-delivery-summary-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('delivery-summary-test@example.com');
const company = await store.company.create({
name: 'Delivery Summary Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const warehouse = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
await store.unit.upsert(googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
});
const supplierAlpha = await store.supplier.create(googleAuthId, {
name: 'Alpha Supplier GmbH',
});
const supplierBeta = await store.supplier.create(googleAuthId, {
name: 'Beta Logistics Inc',
});
// 1. Create articles:
// Art1: track_stock = 1 (Stock item)
const artStock1 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Stock Item 1',
track_stock: 1,
});
// Art2: track_stock = 1 (Stock item)
const artStock2 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Stock Item 2',
track_stock: 1,
});
// Art3: track_stock = 0 (Service / Non-stock item)
const artService = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Shipping Freight Fee',
track_stock: 0,
});
// 2. Create Delivery 1:
// Has 2 lines: artStock1 (qty 10), artService (qty 1)
const delivery1 = await store.delivery.create(googleAuthId, {
date: '2026-08-01T10:00:00Z',
externalNumber: 'DEL-ALPHA-1001',
supplierId: supplierAlpha.id,
lines: [
{ sku: artStock1.sku, name: 'Stock Item 1', amount: 10, net: 50, taxrate: 19 },
{ sku: artService.sku, name: 'Shipping Freight Fee', amount: 1, net: 10, taxrate: 19 },
],
});
console.log('Delivery 1 created with id:', delivery1.id);
// Verify deliverysummary row for delivery 1:
// positions = 2, total_quantity = 11, stock_positions = 1, booked_positions = 0, unbooked_positions = 1, is_booked = 0
let sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert(sum1, 'deliverysummary row for delivery 1 must exist');
assert.equal(sum1.positions, 2, 'Total positions should be 2');
assert.equal(sum1.stock_positions, 1, 'Stock positions should be 1');
assert.equal(sum1.booked_positions, 0, 'Booked positions should initially be 0');
assert.equal(sum1.unbooked_positions, 1, 'Unbooked positions should initially be 1');
assert.equal(sum1.stock_quantity, 10, 'Stock quantity should be 10');
assert.equal(sum1.booked_quantity, 0, 'Booked quantity should initially be 0');
assert.equal(sum1.unbooked_quantity, 10, 'Unbooked quantity should initially be 10');
assert.equal(sum1.is_booked, 0, 'is_booked should be 0');
assert.equal(sum1.supplier_name, 'Alpha Supplier GmbH');
// 3. Create Delivery 2:
// Has 1 line: artStock2 (qty 5), Supplier: Beta
const delivery2 = await store.delivery.create(googleAuthId, {
date: '2026-10-01T10:00:00Z',
externalNumber: 'DEL-BETA-2002',
supplierId: supplierBeta.id,
lines: [
{ sku: artStock2.sku, name: 'Stock Item 2', amount: 5, net: 20, taxrate: 19 },
],
});
let sum2 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery2.id);
assert(sum2, 'deliverysummary row for delivery 2 must exist');
assert.equal(sum2.stock_positions, 1);
assert.equal(sum2.unbooked_positions, 1);
assert.equal(sum2.is_booked, 0);
// 4. Book Delivery 1 partially:
// Book 4 units of artStock1 in warehouse
const lineStock1 = delivery1.lines.find((l) => l.sku === artStock1.sku);
assert(lineStock1, 'lineStock1 must exist');
await store.ledger.add(googleAuthId, {
manifestarticle_id: lineStock1.id,
warehouse_id: warehouse.id,
transaction_type: 'DELIVERY',
amount: 4,
transaction_text: 'Partial goods receipt',
});
sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(sum1.booked_positions, 0, 'Booked positions should still be 0 since 4 < 10');
assert.equal(sum1.unbooked_positions, 1);
assert.equal(sum1.booked_quantity, 4);
assert.equal(sum1.unbooked_quantity, 6);
assert.equal(sum1.is_booked, 0);
console.log('✓ Partial booking properly updated deliverysummary');
// 5. Book remaining 6 units of artStock1
await store.ledger.add(googleAuthId, {
manifestarticle_id: lineStock1.id,
warehouse_id: warehouse.id,
transaction_type: 'DELIVERY',
amount: 6,
transaction_text: 'Final goods receipt',
});
sum1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(sum1.booked_positions, 1, 'Booked positions should now be 1');
assert.equal(sum1.unbooked_positions, 0, 'Unbooked positions should now be 0');
assert.equal(sum1.booked_quantity, 10);
assert.equal(sum1.unbooked_quantity, 0);
assert.equal(sum1.is_booked, 1, 'Delivery 1 should now be fully booked (is_booked = 1)');
console.log('✓ Full booking properly updated deliverysummary (is_booked = 1)');
// 6. Test windowed / paged list:
// Both deliveries present
const pagedAll = await store.delivery.list(googleAuthId, { offset: 0, limit: 10 });
assert.equal(pagedAll.total, 2);
assert.equal(pagedAll.unfilteredTotal, 2);
assert.equal(pagedAll.deliveries.length, 2);
assert.equal(pagedAll.offset, 0);
assert.equal(pagedAll.limit, 10);
// Filter onlyUnbooked (Delivery 1 is fully booked, Delivery 2 is unbooked)
const pagedUnbooked = await store.delivery.list(googleAuthId, { onlyUnbooked: true });
assert.equal(pagedUnbooked.total, 1, 'Only 1 delivery is unbooked');
assert.equal(pagedUnbooked.unfilteredTotal, 2, 'Unfiltered total remains 2');
assert.equal(pagedUnbooked.deliveries[0].id, delivery2.id);
console.log('✓ onlyUnbooked filter returned only unbooked deliveries');
// Search filter: 'Beta'
const searchBeta = await store.delivery.list(googleAuthId, { search: 'Beta' });
assert.equal(searchBeta.total, 1);
assert.equal(searchBeta.unfilteredTotal, 2);
assert.equal(searchBeta.deliveries[0].id, delivery2.id);
// Search filter: 'ALPHA-1001'
const searchNum = await store.delivery.list(googleAuthId, { search: 'ALPHA-1001' });
assert.equal(searchNum.total, 1);
assert.equal(searchNum.deliveries[0].id, delivery1.id);
// Test unpaged backwards compatibility
const unpaged = await store.delivery.list(googleAuthId);
assert(Array.isArray(unpaged), 'Unpaged list should return Array');
assert.equal(unpaged.length, 2);
const d1 = unpaged.find((d) => d.id === delivery1.id);
assert(d1.ledger, 'd1 should contain ledger summary object');
assert.equal(d1.ledger.totalPositions, 1);
assert.equal(d1.ledger.bookedPositions, 1);
assert.equal(d1.ledger.unbookedPositions, 0);
assert.equal(d1.ledger.isBooked, true);
const d2 = unpaged.find((d) => d.id === delivery2.id);
assert.equal(d2.ledger.unbookedPositions, 1);
assert.equal(d2.ledger.isBooked, false);
console.log('✓ Unpaged list returns enriched deliveries with ledger summary');
// 7. Test rebuildSummaries
provider.db.prepare('DELETE FROM deliverysummary').run();
const countEmpty = provider.db.prepare('SELECT COUNT(*) AS cnt FROM deliverysummary').get().cnt;
assert.equal(countEmpty, 0);
await store.delivery.rebuildSummaries(googleAuthId);
const countRebuilt = provider.db.prepare('SELECT COUNT(*) AS cnt FROM deliverysummary').get().cnt;
assert.equal(countRebuilt, 2, 'Rebuilt deliverysummary should have 2 rows');
const rebuiltD1 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery1.id);
assert.equal(rebuiltD1.is_booked, 1);
assert.equal(rebuiltD1.booked_quantity, 10);
assert.equal(rebuiltD1.unbooked_quantity, 0);
const rebuiltD2 = provider.db.prepare('SELECT * FROM deliverysummary WHERE manifest_id = ?').get(delivery2.id);
assert.equal(rebuiltD2.is_booked, 0);
assert.equal(rebuiltD2.unbooked_positions, 1);
console.log('✓ rebuildSummaries restored all deliverysummary rows accurately');
console.log('--- ALL DELIVERY SUMMARY & FILTER TESTS PASSED ---');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -5,12 +5,10 @@ import { join } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import LibDashStore from '../src/index.js'; import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { INIT_HASH, computePrevHash } from '../src/store/ledger/add.js'; import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js';
async function run() { async function run() {
console.log('--- Testing store.ledger (add, created_at, list queries) ---'); const tempDir = mkdtempSync(join(tmpdir(), 'dash-ledger-test-'));
const tempDir = mkdtempSync(join(tmpdir(), 'test-ledger-'));
const dbFile = join(tempDir, 'test.db'); const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage'); const storageDir = join(tempDir, 'storage');
@@ -18,50 +16,85 @@ async function run() {
filename: dbFile, filename: dbFile,
storageDir, storageDir,
}); });
await provider.initialize(); await provider.initialize();
const store = await new LibDashStore(provider); const store = await new LibDashStore(provider);
try { try {
// 1. Setup user and company console.log('--- Testing store.ledger (acceptance, warehouse, shipment, guards) ---');
const googleAuthId = await store.google_auth.add('ledger-test@example.com'); const googleAuthId = await store.google_auth.add('ledger-tester@example.com');
const company = await store.company.create({ const company = await store.company.create({
name: 'Ledger Test Corp', name: 'Ledger Test GmbH',
iso639: 'de', iso639: 'de',
taxrate: 19, taxrate: 19,
reducedtaxrate: 7, reducedtaxrate: 7,
googleAuthId, googleAuthId,
}); });
await store.google_auth.setSelectedCompany(googleAuthId, company.id); await store.google_auth.setSelectedCompany(googleAuthId, company.id);
await store.unit.upsert(googleAuthId, { const supplier = await store.supplier.create(googleAuthId, {
si: 'stk', name: 'Supplier X',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
}); });
const supplier = await store.supplier.create(googleAuthId, { name: 'Supplier X' });
// Create an article with track_stock = false initially
const article = await store.article.createUnderParent({ const article = await store.article.createUnderParent({
googleAuthId, googleAuthId,
parentId: company.rootCategoryId, parentId: company.rootCategoryId,
name: 'Ledger Linked Article', name: 'Ledger Linked Article',
track_stock: false,
}); });
assert.equal(article.track_stock, false, 'Newly created article must have track_stock disabled'); assert.equal(article.track_stock, false, 'Newly created article must have track_stock disabled');
await assert.rejects(
async () => store.article.setTrackStockBySku(googleAuthId, article.sku, true),
/At least one warehouse is required to enable stock tracking/,
'Should reject enabling stock tracking when no warehouse exists',
);
await store.articlesupplier.create(googleAuthId, article.sku, { await store.articlesupplier.create(googleAuthId, article.sku, {
entityId: supplier.id, entityId: supplier.id,
sku: 'SKU-ABC', sku: 'SKU-ABC',
pu: 1, pu: 1,
}); });
// Create a delivery manifest with lines const customer1 = await store.entity.create(googleAuthId, {
name: 'Customer Quote Test',
isCustomer: true,
customergroupId: company.rootCategoryId,
});
// Create a quote and verify ledger bookings are rejected
const quote = await store.quote.create(googleAuthId, {
number: 'Q-1',
entityId: customer1.id,
date: new Date().toISOString(),
lines: [{ sku: 'SKU-ABC', name: 'Quote item', pu: 1, amount: 2, net: 10, taxrate: 19 }],
});
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: quote.lines[0].id,
transaction_type: 'ACCEPTANCE',
amount: 2,
transaction_text: 'Should fail for quote',
}),
/Cannot book ledger entries for quote/,
'Should reject booking ledger entries for quote manifest',
);
// Create an order and verify ledger bookings are rejected
const order = await store.order.create(googleAuthId, {
number: 'O-1',
entityId: customer1.id,
date: new Date().toISOString(),
lines: [{ sku: 'SKU-ABC', name: 'Order item', pu: 1, amount: 2, net: 10, taxrate: 19 }],
});
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: order.lines[0].id,
transaction_type: 'ACCEPTANCE',
amount: 2,
transaction_text: 'Should fail for order',
}),
/Cannot book ledger entries for order/,
'Should reject booking ledger entries for order manifest',
);
// 1. Create a delivery manifest with lines
const delivery = await store.delivery.create(googleAuthId, { const delivery = await store.delivery.create(googleAuthId, {
supplierId: supplier.id, supplierId: supplier.id,
externalNumber: 'DEL-1', externalNumber: 'DEL-1',
@@ -78,7 +111,7 @@ async function run() {
}, },
{ {
sku: 'SKU-ABC', sku: 'SKU-ABC',
name: 'Ledger Item 1 Batch 2', name: 'Ledger Item 2',
pu: 1, pu: 1,
amount: 10, amount: 10,
net: 20, net: 20,
@@ -90,278 +123,311 @@ async function run() {
const manifestarticleId1 = delivery.lines[0].id; const manifestarticleId1 = delivery.lines[0].id;
const manifestarticleId2 = delivery.lines[1].id; const manifestarticleId2 = delivery.lines[1].id;
assert(manifestarticleId1, 'manifestarticle line 1 should have an id');
assert(manifestarticleId2, 'manifestarticle line 2 should have an id');
// 2. Test first row (genesis row) // 2. Book line 1 for untracked article (acceptance only, warehouse_id is null)
console.log('1. Inserting genesis ledger row...'); console.log('1. Booking acceptance for untracked article line 1...');
const row1 = await store.ledger.add(googleAuthId, { const row1 = await store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1, manifestarticle_id: manifestarticleId1,
transaction_type: 'GOODS_RECEIPT', transaction_type: 'ACCEPTANCE',
amount: 5, amount: 5,
transaction_text: 'Initial delivery movement', transaction_text: `Lieferung ${delivery.number}`,
}); });
assert.equal(row1.id, 1); assert.equal(row1.id, 1);
assert.equal(row1.manifestarticle_id, manifestarticleId1); assert.equal(row1.manifestarticle_id, manifestarticleId1);
assert.equal(row1.transaction_type, 'GOODS_RECEIPT'); assert.equal(row1.warehouse_id, null);
assert.equal(row1.transaction_type, 'ACCEPTANCE');
assert.equal(row1.amount, 5); assert.equal(row1.amount, 5);
assert.equal(row1.transaction_text, 'Initial delivery movement');
assert.equal(row1.hash_version, 1); assert.equal(row1.hash_version, 1);
assert.equal(row1.prev_hash, INIT_HASH); assert.equal(row1.prev_hash, INIT_HASH);
assert(row1.created_at, 'created_at should exist'); assert(row1.created_at, 'created_at should exist');
assert(!Number.isNaN(new Date(row1.created_at).getTime()), 'created_at should be valid date string');
console.log('✓ Row 1 created_at:', row1.created_at);
// Verify manual hash computation of row 1 (including created_at) // Attempting to book line 1 again must be rejected
const expectedHash2 = computePrevHash(row1); await assert.rejects(
const manualHash2 = createHash('sha512') async () => store.ledger.add(googleAuthId, {
.update(
JSON.stringify({
id: 1,
manifestarticle_id: manifestarticleId1, manifestarticle_id: manifestarticleId1,
warehouse_id: null, transaction_type: 'ACCEPTANCE',
transaction_type: 'GOODS_RECEIPT',
amount: 5, amount: 5,
transaction_text: 'Initial delivery movement', transaction_text: 'Double booking attempt',
hash_version: 1,
prev_hash: INIT_HASH,
created_at: new Date(row1.created_at).toISOString(),
}), }),
'utf8', /Delivery line already booked on ledger/,
) 'Should reject booking an already booked delivery line',
.digest('hex'); );
assert.equal(expectedHash2, manualHash2);
assert.equal(manualHash2.length, 128);
// 3. Test second row (line 1, different transaction type) // 3. Create warehouse and enable tracking on the article
console.log('2. Inserting second ledger row...'); console.log('2. Creating warehouse and enabling stock tracking...');
const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
await store.article.setTrackStockBySku(googleAuthId, article.sku, true);
// Even though tracking is now enabled, line 1 is already accepted so it cannot be booked again
await assert.rejects(
async () => store.ledger.add(googleAuthId, {
manifestarticle_id: manifestarticleId1,
warehouse_id: wh1.id,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: 'Late booking attempt after tracking enabled',
}),
/Delivery line already booked on ledger/,
'Cannot book a delivery line later once stock tracking is enabled if already accepted',
);
// 4. Book line 2 (now tracked): books ACCEPTANCE (row 2) + WAREHOUSE (row 3) in one transaction
console.log('3. Booking line 2 with tracking enabled (dual entries: ACCEPTANCE + WAREHOUSE)...');
const expectedHash2 = computePrevHash(row1);
const row2 = await store.ledger.add(googleAuthId, { const row2 = await store.ledger.add(googleAuthId, {
manifestarticleId: manifestarticleId1, manifestarticle_id: manifestarticleId2,
transactionType: 'INVENTORY_ADJUST', warehouse_id: wh1.id,
amount: -1, transaction_type: 'ACCEPTANCE',
transactionText: 'Damaged item written off', amount: 10,
transaction_text: `Lieferung ${delivery.number}`,
}); });
assert.equal(row2.id, 2); assert.equal(row2.id, 2);
assert.equal(row2.hash_version, 1); assert.equal(row2.manifestarticle_id, manifestarticleId2);
assert.equal(row2.warehouse_id, null);
assert.equal(row2.transaction_type, 'ACCEPTANCE');
assert.equal(row2.amount, 10);
assert.equal(row2.prev_hash, expectedHash2); assert.equal(row2.prev_hash, expectedHash2);
assert(row2.created_at, 'row 2 created_at should exist');
console.log('✓ Row 2 prev_hash matches SHA-512 of Row 1 with created_at');
// 4. Test third row (line 2, same SKU SKU-ABC) // Query ledger lines for manifestarticle 2 to verify row 3 (WAREHOUSE entry)
console.log('3. Inserting third ledger row on line 2...'); const line2Entries = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId2);
const expectedHash3 = computePrevHash(row2); assert.equal(line2Entries.length, 2, 'Line 2 must have 2 ledger entries (ACCEPTANCE + WAREHOUSE)');
const row3 = await store.ledger.add(googleAuthId, { assert.equal(line2Entries[0].transaction_type, 'ACCEPTANCE');
manifestarticle_id: manifestarticleId2, assert.equal(line2Entries[0].warehouse_id, null);
transaction_type: 'GOODS_RECEIPT', assert.equal(line2Entries[1].transaction_type, 'WAREHOUSE');
amount: 10, assert.equal(line2Entries[1].warehouse_id, wh1.id);
transaction_text: 'Second line goods receipt', assert.equal(line2Entries[1].amount, 10);
}); assert.equal(line2Entries[1].id, 3);
assert.equal(line2Entries[1].prev_hash, computePrevHash(line2Entries[0]));
assert.equal(row3.id, 3); // 5. Check warehouse stock for article
assert.equal(row3.hash_version, 1); console.log('4. Checking warehouse stock calculation...');
assert.equal(row3.prev_hash, expectedHash3); const artDetail = await store.article.getBySku(googleAuthId, article.sku);
const whStock = artDetail.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(whStock, 10, 'Warehouse stock must be 10 (from line 2 WAREHOUSE ledger entry only)');
// 5. Test query by manifestarticle_id // 6. Check deliverysummary is_booked
console.log('4. Testing listByManifestarticleId...'); const updatedDelivery = await store.delivery.getById(googleAuthId, delivery.id);
const listLine1All = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId1); assert.equal(updatedDelivery.ledger.isBooked, true, 'Delivery should be fully booked');
assert.equal(listLine1All.length, 2); assert.equal(updatedDelivery.ledger.completelyOnLedgerPositions, 2);
assert.equal(listLine1All[0].id, 1);
assert.equal(listLine1All[1].id, 2);
assert.equal(listLine1All[0].sku, 'SKU-ABC');
const listLine1GoodsReceipt = await store.ledger.listByManifestarticleId( // 7. Check article history
googleAuthId, console.log('5. Verifying article history...');
manifestarticleId1,
'GOODS_RECEIPT',
);
assert.equal(listLine1GoodsReceipt.length, 1);
assert.equal(listLine1GoodsReceipt[0].id, 1);
const listLine1Adjust = await store.ledger.listByManifestarticleId(
googleAuthId,
manifestarticleId1,
{ transaction_type: 'INVENTORY_ADJUST' },
);
assert.equal(listLine1Adjust.length, 1);
assert.equal(listLine1Adjust[0].id, 2);
const listLine1None = await store.ledger.listByManifestarticleId(
googleAuthId,
manifestarticleId1,
'NON_EXISTENT',
);
assert.equal(listLine1None.length, 0);
// 6. Test query by SKU
console.log('5. Testing listBySku...');
const listSkuAll = await store.ledger.listBySku(googleAuthId, 'SKU-ABC');
assert.equal(listSkuAll.length, 3);
assert.equal(listSkuAll[0].id, 1);
assert.equal(listSkuAll[1].id, 2);
assert.equal(listSkuAll[2].id, 3);
const listSkuGoodsReceipt = await store.ledger.listBySku(
googleAuthId,
'SKU-ABC',
'GOODS_RECEIPT',
);
assert.equal(listSkuGoodsReceipt.length, 2);
assert.equal(listSkuGoodsReceipt[0].id, 1);
assert.equal(listSkuGoodsReceipt[1].id, 3);
const listSkuAdjust = await store.ledger.listBySku(
googleAuthId,
'SKU-ABC',
'INVENTORY_ADJUST',
);
assert.equal(listSkuAdjust.length, 1);
assert.equal(listSkuAdjust[0].id, 2);
const listUnknownSku = await store.ledger.listBySku(googleAuthId, 'UNKNOWN-SKU');
assert.equal(listUnknownSku.length, 0);
// 8. Test article history created from ledger
console.log('7. Testing article history entries from ledger rows...');
const history = await store.article.getHistoryBySku(googleAuthId, article.sku); const history = await store.article.getHistoryBySku(googleAuthId, article.sku);
const historyTexts = history.map((h) => h.text); const historyTexts = history.map((h) => h.text);
console.log('Article history texts:', historyTexts); console.log('History entries:', historyTexts);
assert( assert(
historyTexts.some((t) => t.includes('Initial delivery movement') && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')), historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')),
'History should include initial delivery movement with supplier, EK brutto, and amount', 'History must include acceptance of line 1 with Lieferung number, supplier, EK brutto, amount',
); );
assert(historyTexts.includes('Damaged item written off'), 'History should include undamaged adjustment memo as is');
assert( assert(
historyTexts.some((t) => t.includes('Second line goods receipt') && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')), historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')),
'History should include second line goods receipt with details', 'History must include acceptance of line 2',
);
assert(
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lager: "Hauptlager"') && t.includes('Menge: 10')),
'History must include warehouse entry for tracked line 2',
); );
// 9. Test warehouse CRUD, sort, reorder, and linking with ledger // 8. Test store.delivery.book on a fresh delivery
console.log('8. Testing warehouse CRUD, sort, reorder, and ledger association...'); console.log('6. Testing store.delivery.book batch booking...');
const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' }); const delivery2 = await store.delivery.create(googleAuthId, {
assert.equal(wh1.name, 'Hauptlager'); supplierId: supplier.id,
assert.equal(wh1.sort, 0); externalNumber: 'DEL-2',
assert(wh1.id > 0); date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Batch Item 1', pu: 1, amount: 4, net: 15, taxrate: 19 },
],
});
const bookedDelivery2 = await store.delivery.book(googleAuthId, { id: delivery2.id, warehouseId: wh1.id });
assert.equal(bookedDelivery2.ledger.isBooked, true);
assert.equal(bookedDelivery2.ledger.completelyOnLedgerPositions, 1);
const wh2 = await store.warehouse.create(googleAuthId, { name: 'Filiale Nord' }); // Double booking delivery2 must reject
assert.equal(wh2.name, 'Filiale Nord');
assert.equal(wh2.sort, 1);
const whList = await store.warehouse.list(googleAuthId);
assert.equal(whList.length, 2);
assert.equal(whList[0].id, wh1.id);
assert.equal(whList[0].sort, 0);
assert.equal(whList[1].id, wh2.id);
assert.equal(whList[1].sort, 1);
const whGet = await store.warehouse.getById(googleAuthId, wh1.id);
assert.equal(whGet.name, 'Hauptlager');
assert.equal(whGet.sort, 0);
const whUpdated = await store.warehouse.update(googleAuthId, wh2.id, { name: 'Filiale Süd', sort: 5 });
assert.equal(whUpdated.name, 'Filiale Süd');
assert.equal(whUpdated.sort, 5);
// Test warehouse order
await store.warehouse.order(googleAuthId, { warehouseIds: [wh2.id, wh1.id] });
const whListReordered = await store.warehouse.list(googleAuthId);
assert.equal(whListReordered[0].id, wh2.id);
assert.equal(whListReordered[0].sort, 0);
assert.equal(whListReordered[1].id, wh1.id);
assert.equal(whListReordered[1].sort, 1);
// Test invalid warehouse order
await assert.rejects( await assert.rejects(
async () => store.warehouse.order(googleAuthId, { warehouseIds: [wh1.id] }), async () => store.delivery.book(googleAuthId, { id: delivery2.id }),
/Invalid warehouse order/, /Delivery line(s)? already (accepted|booked) on ledger/,
'Should reject booking already booked delivery',
); );
// Verify article detail includes warehouses with sort // 9. Test shipment booking
const articleDetail = await store.article.getBySku(googleAuthId, article.sku); console.log('7. Testing shipment booking (SHIPMENT + WAREHOUSE dual entries)...');
assert(Array.isArray(articleDetail.article.warehouses), 'Article should have warehouses array'); const customer = await store.entity.create(googleAuthId, {
assert.equal(articleDetail.article.warehouses.length, 2); name: 'Customer Y',
assert.equal(articleDetail.article.warehouses[0].id, wh2.id); isCustomer: true,
assert.equal(articleDetail.article.warehouses[0].sort, 0); customergroupId: company.rootCategoryId,
assert.equal(articleDetail.article.warehouses[1].id, wh1.id); });
assert.equal(articleDetail.article.warehouses[1].sort, 1);
// Enable stock tracking now that warehouses exist const order2 = await store.order.create(googleAuthId, {
await store.article.setTrackStockBySku(googleAuthId, article.sku, true); number: 'O-2',
entityId: customer.id,
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Ordered Item', pu: 1, amount: 3, net: 25, taxrate: 19 },
],
});
// Add ledger row with warehouse const shipment = await store.shipment.create(googleAuthId, {
const rowWithWh = await store.ledger.add(googleAuthId, { orderId: order2.id,
manifestarticle_id: manifestarticleId1, lines: [{ sku: 'SKU-ABC', amount: 3 }],
});
const shipmentLineId = shipment.lines[0].id;
const shipRow = await store.ledger.add(googleAuthId, {
manifestarticle_id: shipmentLineId,
warehouse_id: wh1.id, warehouse_id: wh1.id,
transaction_type: 'DELIVERY', transaction_type: 'SHIPMENT',
amount: 25, amount: 3,
transaction_text: 'Delivered directly to Hauptlager', transaction_text: `Lieferschein ${shipment.number}`,
}); });
assert.equal(rowWithWh.warehouse_id, wh1.id);
assert.equal(rowWithWh.amount, 25);
// Filter by warehouse in listBySku and listByManifestarticleId assert.equal(shipRow.transaction_type, 'SHIPMENT');
const whRowsBySku = await store.ledger.listBySku(googleAuthId, 'SKU-ABC', { warehouse_id: wh1.id }); assert.equal(shipRow.warehouse_id, null);
assert.equal(whRowsBySku.length, 1); assert.equal(shipRow.amount, -3);
assert.equal(whRowsBySku[0].id, rowWithWh.id);
assert.equal(whRowsBySku[0].warehouse_id, wh1.id);
const whRowsByLine = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId1, { warehouse_id: wh1.id }); const shipEntries = await store.ledger.listByManifestarticleId(googleAuthId, shipmentLineId);
assert.equal(whRowsByLine.length, 1); assert.equal(shipEntries.length, 2, 'Shipment line must have 2 entries (SHIPMENT + WAREHOUSE)');
assert.equal(whRowsByLine[0].id, rowWithWh.id); assert.equal(shipEntries[0].transaction_type, 'SHIPMENT');
assert.equal(shipEntries[0].warehouse_id, null);
assert.equal(shipEntries[0].amount, -3);
assert.equal(shipEntries[1].transaction_type, 'WAREHOUSE');
assert.equal(shipEntries[1].warehouse_id, wh1.id);
assert.equal(shipEntries[1].amount, -3);
// Verify warehouse is recorded in article history // Stock should now be: 10 (line 2) + 4 (delivery2) - 3 (shipment) = 11
const historyWithWh = await store.article.getHistoryBySku(googleAuthId, article.sku); const artDetailAfterShip = await store.article.getBySku(googleAuthId, article.sku);
const whStockAfterShip = artDetailAfterShip.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(whStockAfterShip, 11, 'Warehouse stock must be 11 (10 + 4 - 3)');
// Verify shipment history
const historyAfterShip = await store.article.getHistoryBySku(googleAuthId, article.sku);
const shipHistoryTexts = historyAfterShip.map((h) => h.text);
assert( assert(
historyWithWh.some((h) => h.text.includes('Lager: "Hauptlager"') && h.text.includes('Lieferant: "Supplier X"')), shipHistoryTexts.some((t) => t.includes(`Lieferschein ${shipment.number}`) && t.includes('Menge: 3')),
'History should include warehouse name (Lager: "Hauptlager")', 'Article history should record outgoing shipment',
); );
// Trying to delete wh1 (used in ledger) must fail // 8. Test store.stock.accept with deliverymanifestitem
await assert.rejects( console.log('8. Testing store.stock.accept (positive acceptance & negative return)...');
async () => store.warehouse.delete(googleAuthId, wh1.id), const delivery3 = await store.delivery.create(googleAuthId, {
/Warehouse in use in ledger/, supplierId: supplier.id,
'Should reject deleting warehouse referenced in ledger', externalNumber: 'DEL-3',
); date: new Date().toISOString(),
lines: [
// Deleting wh2 (not used in ledger) must succeed { sku: 'SKU-ABC', name: 'Positive Delivery Line', pu: 1, amount: 6, net: 10, taxrate: 19 },
await store.warehouse.delete(googleAuthId, wh2.id); { sku: 'SKU-ABC', name: 'Negative Delivery Line (Return)', pu: 1, amount: -2, net: 10, taxrate: 19 },
const whListAfterDelete = await store.warehouse.list(googleAuthId); ],
assert.equal(whListAfterDelete.length, 1);
assert.equal(whListAfterDelete[0].id, wh1.id);
// Test last warehouse guard with track_stock articles
const freshCompany = await store.company.create({
name: 'Warehouse Test Co',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
}); });
await store.google_auth.setSelectedCompany(googleAuthId, freshCompany.id);
const testWh = await store.warehouse.create(googleAuthId, { name: 'Only Warehouse' }); const posDeliveryLineId = delivery3.lines[0].id;
const testArt = await store.article.createUnderParent({ const negDeliveryLineId = delivery3.lines[1].id;
googleAuthId,
parentId: freshCompany.rootCategoryId, const acceptPosResult = await store.stock.accept(googleAuthId, {
name: 'Tracked Article', manifestarticleId: posDeliveryLineId,
track_stock: true, warehouseId: wh1.id,
}); });
assert.equal(testArt.track_stock, true); assert.equal(acceptPosResult.document_ledger.transaction_type, 'ACCEPTANCE');
assert.equal(acceptPosResult.document_ledger.amount, 6);
assert.equal(acceptPosResult.document_ledger.warehouse_id, null);
assert.equal(acceptPosResult.adjust_ledger.amount, 6);
assert.equal(acceptPosResult.adjust_ledger.warehouse_id, wh1.id);
// Negative delivery line: return of a delivery (outgoing)
const acceptNegResult = await store.stock.accept(googleAuthId, {
manifestarticleId: negDeliveryLineId,
warehouseId: wh1.id,
});
assert.equal(acceptNegResult.document_ledger.transaction_type, 'RETURN');
assert.equal(acceptNegResult.document_ledger.amount, -2);
assert.equal(acceptNegResult.document_ledger.warehouse_id, null);
assert.equal(acceptNegResult.adjust_ledger.amount, -2);
assert.equal(acceptNegResult.adjust_ledger.warehouse_id, wh1.id);
// 9. Test store.stock.ship with shipmentmanifestitem
console.log('9. Testing store.stock.ship (positive shipment & negative retoure)...');
const order3 = await store.order.create(googleAuthId, {
number: 'O-3',
entityId: customer.id,
date: new Date().toISOString(),
lines: [
{ sku: 'SKU-ABC', name: 'Shipment Item Normal', pu: 1, amount: 5, net: 10, taxrate: 19 },
],
});
const shipment3 = await store.shipment.create(googleAuthId, {
orderId: order3.id,
lines: [
{ sku: 'SKU-ABC', amount: 4 },
{ sku: 'SKU-ABC', amount: -1 },
],
});
const posShipLineId = shipment3.lines[0].id;
const negShipLineId = shipment3.lines[1].id;
const shipPosResult = await store.stock.ship(googleAuthId, {
manifestarticleId: posShipLineId,
warehouseId: wh1.id,
});
assert.equal(shipPosResult.document_ledger.transaction_type, 'SHIPMENT');
assert.equal(shipPosResult.document_ledger.amount, -4);
assert.equal(shipPosResult.adjust_ledger.amount, -4);
// Negative shipment line: retoure of a shipment (incoming)
const shipNegResult = await store.stock.ship(googleAuthId, {
manifestarticleId: negShipLineId,
warehouseId: wh1.id,
});
assert.equal(shipNegResult.document_ledger.transaction_type, 'RETOURE');
assert.equal(shipNegResult.document_ledger.amount, 1);
assert.equal(shipNegResult.adjust_ledger.amount, 1);
// 10. Test store.stock.adjust
console.log('10. Testing store.stock.adjust failure on untracked article...');
const untrackedArt = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Pure Untracked Article',
track_stock: false,
});
await assert.rejects( await assert.rejects(
async () => store.warehouse.delete(googleAuthId, testWh.id), async () => store.stock.adjust(googleAuthId, {
/Cannot delete the last warehouse while articles have stock tracking enabled/, sku: untrackedArt.sku,
'Should reject deleting last warehouse when articles have track_stock enabled', warehouseId: wh1.id,
amount: 5,
}),
/Stock tracking is disabled for article/,
'Adjust must fail when article has no stock tracking',
); );
// Disable track_stock on the article, then deleting the last warehouse must succeed
await store.article.setTrackStockBySku(googleAuthId, testArt.sku, false);
await store.warehouse.delete(googleAuthId, testWh.id);
const remainingWh = await store.warehouse.list(googleAuthId);
assert.equal(remainingWh.length, 0);
// Switch back to original company // 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized
await store.google_auth.setSelectedCompany(googleAuthId, company.id); console.log('11. Testing zero-adjustment before stock tracking is disabled...');
const artBeforeDisable = await store.article.getBySku(googleAuthId, article.sku);
const stockBeforeDisable = artBeforeDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock;
console.log('Current stock before disabling tracking:', stockBeforeDisable);
assert(stockBeforeDisable > 0, 'Article must have non-zero stock before disabling');
console.log('✓ Warehouse CRUD and ledger enforcement passed'); // Disable stock tracking
await store.article.setTrackStockBySku(googleAuthId, article.sku, false);
const artAfterDisable = await store.article.getBySku(googleAuthId, article.sku);
assert.equal(artAfterDisable.article.track_stock, false, 'Article track_stock must now be false');
const stockAfterDisable = artAfterDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock;
assert.equal(stockAfterDisable, 0, 'Warehouse stock must be adjusted to 0 on the ledger');
// Calling adjust now must fail
await assert.rejects(
async () => store.stock.adjust(googleAuthId, {
sku: article.sku,
warehouseId: wh1.id,
amount: 1,
}),
/Stock tracking is disabled for article/,
'Subsequent adjust must fail once tracking is disabled',
);
console.log('✓ All ledger tests passed successfully!'); console.log('✓ All ledger tests passed successfully!');
} finally { } finally {

View File

@@ -140,16 +140,22 @@ async function run() {
// Assert ledger entries // Assert ledger entries
const ledgerRows = await store.ledger.listBySku(googleAuthId, trackedArt.sku); const ledgerRows = await store.ledger.listBySku(googleAuthId, trackedArt.sku);
assert.equal(ledgerRows.length, 2, 'Should have 2 ledger entries for tracked article (adjust + shipping)'); assert.equal(ledgerRows.length, 3, 'Should have 3 ledger entries for tracked article (adjust + shipment doc + warehouse)');
const shippingLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPPING'); const shipmentDocLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPMENT' && l.warehouse_id == null);
assert(shippingLedger, 'Shipping ledger entry should exist'); assert(shipmentDocLedger, 'Shipment document ledger entry should exist with warehouse_id null');
assert.equal(shippingLedger.amount, -3, 'Shipping ledger entry should be -3'); assert.equal(shipmentDocLedger.amount, -3, 'Shipment document ledger entry should be -3');
const shipmentWhLedger = ledgerRows.find((l) => l.transaction_type === 'WAREHOUSE' && l.warehouse_id === warehouse.id);
assert(shipmentWhLedger, 'Shipment warehouse ledger entry should exist with warehouse_id set');
assert.equal(shipmentWhLedger.amount, -3, 'Shipment warehouse ledger entry should be -3');
// Assert untracked article has 0 warehouse ledger entries // Assert untracked article has 1 document ledger entry and 0 warehouse ledger entries
const untrackedLedger = await store.ledger.listBySku(googleAuthId, untrackedArt.sku); const untrackedLedger = await store.ledger.listBySku(googleAuthId, untrackedArt.sku);
assert.equal(untrackedLedger.length, 0, 'Untracked article should not have warehouse ledger entries'); assert.equal(untrackedLedger.length, 1, 'Untracked article should have 1 shipment document ledger entry');
assert.equal(untrackedLedger[0].transaction_type, 'SHIPMENT');
assert.equal(untrackedLedger[0].warehouse_id, null, 'Untracked article should not have warehouse ledger entry');
assert.equal(untrackedLedger[0].amount, -2);
// Assert article history: order goes into history, shipment does NOT // Assert article history: order goes into history, and shipment produces history
const history = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku); const history = await store.article.getHistoryBySku(googleAuthId, trackedArt.sku);
console.log('Tracked article history:', history.map((h) => h.text)); console.log('Tracked article history:', history.map((h) => h.text));
const orderHistory = history.find((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 3, € 59,50)'); const orderHistory = history.find((h) => h.text === 'Auftrag O-2026-001 (Kunde: "Max Mustermann", Menge: 3, € 59,50)');
@@ -159,10 +165,10 @@ async function run() {
assert.equal(parsedMeta.type, 'order'); assert.equal(parsedMeta.type, 'order');
assert.equal(parsedMeta.orderId, order.id); assert.equal(parsedMeta.orderId, order.id);
assert.equal(parsedMeta.gross, 59.5); assert.equal(parsedMeta.gross, 59.5);
const shippingHistory = history.find((h) => h.text.includes('Versand Auftrag')); const shipmentHistory = history.find((h) => h.text.startsWith(`Lieferschein ${shipment.number}`) && h.text.includes('Kunde: "Max Mustermann"'));
assert(!shippingHistory, 'Article history should NOT report the shipment'); assert(shipmentHistory, 'Article history should report the shipment document');
// Untracked article also has the order in history, but not the shipment // Untracked article also has the order in history and shipment in history
const untrackedHistory = await store.article.getHistoryBySku(googleAuthId, untrackedArt.sku); const untrackedHistory = await store.article.getHistoryBySku(googleAuthId, untrackedArt.sku);
console.log('Untracked article history:', untrackedHistory.map((h) => h.text)); console.log('Untracked article history:', untrackedHistory.map((h) => h.text));
assert( assert(
@@ -170,8 +176,8 @@ async function run() {
'Untracked article history should contain order creation entry with formatted gross price', 'Untracked article history should contain order creation entry with formatted gross price',
); );
assert( assert(
!untrackedHistory.some((h) => h.text.includes('Versand Auftrag')), untrackedHistory.some((h) => h.text.startsWith(`Lieferschein ${shipment.number}`)),
'Untracked article history should NOT report the shipment', 'Untracked article history should report the shipment',
); );
// Test order import with historical date preserves date in article history // Test order import with historical date preserves date in article history

View File

@@ -90,6 +90,8 @@ import deliveryGetById from './store/delivery/getById.js';
import deliveryCreate from './store/delivery/create.js'; import deliveryCreate from './store/delivery/create.js';
import deliveryUpdate from './store/delivery/update.js'; import deliveryUpdate from './store/delivery/update.js';
import deliveryDelete from './store/delivery/delete.js'; import deliveryDelete from './store/delivery/delete.js';
import deliveryBook from './store/delivery/book.js';
import deliveryRebuildSummaries from './store/delivery/rebuildSummaries.js';
import quoteList from './store/quote/list.js'; import quoteList from './store/quote/list.js';
import quoteGetById from './store/quote/getById.js'; import quoteGetById from './store/quote/getById.js';
import quoteCreate from './store/quote/create.js'; import quoteCreate from './store/quote/create.js';
@@ -208,6 +210,8 @@ import labeltemplateList from './store/labeltemplate/list.js';
import labeltemplateUpdate from './store/labeltemplate/update.js'; import labeltemplateUpdate from './store/labeltemplate/update.js';
import labeltemplateDelete from './store/labeltemplate/delete.js'; import labeltemplateDelete from './store/labeltemplate/delete.js';
import stockAdjust from './store/stock/adjust.js'; import stockAdjust from './store/stock/adjust.js';
import stockAccept from './store/stock/accept.js';
import stockShip from './store/stock/ship.js';
import propertynameList from './store/propertyname/list.js'; import propertynameList from './store/propertyname/list.js';
import propertynameCreate from './store/propertyname/create.js'; import propertynameCreate from './store/propertyname/create.js';
import propertynameUpdate from './store/propertyname/update.js'; import propertynameUpdate from './store/propertyname/update.js';
@@ -422,6 +426,8 @@ export default class LibDashStore {
create: deliveryCreate(provider), create: deliveryCreate(provider),
update: deliveryUpdate(provider), update: deliveryUpdate(provider),
delete: deliveryDelete(provider), delete: deliveryDelete(provider),
book: deliveryBook(provider),
rebuildSummaries: deliveryRebuildSummaries(provider),
}; };
this.quote = { this.quote = {
@@ -588,6 +594,8 @@ export default class LibDashStore {
this.stock = { this.stock = {
adjust: stockAdjust(provider), adjust: stockAdjust(provider),
accept: stockAccept(provider),
ship: stockShip(provider),
}; };
this.adjustment = this.stock; this.adjustment = this.stock;

View File

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

View File

@@ -658,3 +658,21 @@ SELECT TOP 1 id
FROM warehouse FROM warehouse
WHERE company_id = @companyId; WHERE company_id = @companyId;
`; `;
export const CHECK_ARTICLE_WAREHOUSE_STOCKS = `
SELECT w.id AS warehouseId, w.name AS warehouseName, COALESCE(stock_calc.stock, 0) AS stock
FROM warehouse w
LEFT JOIN (
SELECT l.warehouse_id, SUM(l.amount) 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 m.company_id = @companyId
AND (
ma.sku = @sku
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
)
GROUP BY l.warehouse_id
) stock_calc ON stock_calc.warehouse_id = w.id
WHERE w.company_id = @companyId;
`;

View File

@@ -23,6 +23,20 @@ FROM warehouse
WHERE id = @warehouseId AND company_id = @companyId; WHERE id = @warehouseId AND company_id = @companyId;
`; `;
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 CHECK_MANIFESTARTICLE_BOOKED = `
SELECT COALESCE(SUM(amount), 0) AS bookedAmount
FROM ledger
WHERE manifestarticle_id = @manifestarticleId
AND warehouse_id IS NULL;
`;
export const FIND_ARTICLES_FOR_HISTORY = ` export const FIND_ARTICLES_FOR_HISTORY = `
SELECT DISTINCT asup.article_id AS articleId SELECT DISTINCT asup.article_id AS articleId
FROM articlesupplier asup FROM articlesupplier asup

View File

@@ -1278,6 +1278,49 @@ BEGIN
END END
`; `;
export const INIT_DELIVERYSUMMARY = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'deliverysummary')
BEGIN
CREATE TABLE deliverysummary (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
manifest_id INT NOT NULL CONSTRAINT UQ_deliverysummary_manifest UNIQUE,
delivery_number NVARCHAR(100) NOT NULL,
external_number NVARCHAR(100) NULL,
delivery_date DATETIME2 NOT NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_deliverysummary_created_at DEFAULT SYSUTCDATETIME(),
supplier_id INT NULL,
supplier_name NVARCHAR(255) NULL,
positions INT NOT NULL CONSTRAINT DF_deliverysummary_positions DEFAULT 0,
gross_total FLOAT NOT NULL CONSTRAINT DF_deliverysummary_gross_total DEFAULT 0,
net_total FLOAT NOT NULL CONSTRAINT DF_deliverysummary_net_total DEFAULT 0,
total_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_total_quantity DEFAULT 0,
stock_positions INT NOT NULL CONSTRAINT DF_deliverysummary_stock_positions DEFAULT 0,
booked_positions INT NOT NULL CONSTRAINT DF_deliverysummary_booked_positions DEFAULT 0,
unbooked_positions INT NOT NULL CONSTRAINT DF_deliverysummary_unbooked_positions DEFAULT 0,
stock_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_stock_quantity DEFAULT 0,
booked_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_booked_quantity DEFAULT 0,
unbooked_quantity FLOAT NOT NULL CONSTRAINT DF_deliverysummary_unbooked_quantity DEFAULT 0,
is_booked TINYINT NOT NULL CONSTRAINT DF_deliverysummary_is_booked DEFAULT 0,
CONSTRAINT FK_deliverysummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
CONSTRAINT FK_deliverysummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
CONSTRAINT FK_deliverysummary_supplier FOREIGN KEY (supplier_id) REFERENCES entity(id) ON DELETE SET NULL
);
CREATE INDEX IX_deliverysummary_company_date
ON deliverysummary (company_id, delivery_date DESC, manifest_id DESC);
CREATE INDEX IX_deliverysummary_company_number
ON deliverysummary (company_id, delivery_number);
CREATE INDEX IX_deliverysummary_company_booked
ON deliverysummary (company_id, is_booked);
CREATE INDEX IX_deliverysummary_company_supplier
ON deliverysummary (company_id, supplier_id);
END
`;
export const INIT_MCP_CLIENT = ` export const INIT_MCP_CLIENT = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
BEGIN BEGIN
@@ -1403,6 +1446,7 @@ export const SCHEMA_SCRIPTS = [
INIT_ORDERSUMMARY, INIT_ORDERSUMMARY,
INIT_QUOTESUMMARY, INIT_QUOTESUMMARY,
INIT_SHIPMENTSUMMARY, INIT_SHIPMENTSUMMARY,
INIT_DELIVERYSUMMARY,
INIT_MCP_CLIENT, INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE, INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN, INIT_MCP_TOKEN,

View File

@@ -3,7 +3,7 @@ import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'article'); const queries = translateQueries(source, 'article');
export const { export const {
ADD_CATEGORY, ADD_EAN, ARTICLE_LIST_COLUMNS, CHECK_HAS_WAREHOUSE, CREATE, CREATE_CHILD, ADD_CATEGORY, ADD_EAN, ARTICLE_LIST_COLUMNS, CHECK_ARTICLE_WAREHOUSE_STOCKS, CHECK_HAS_WAREHOUSE, CREATE, CREATE_CHILD,
DELETE_CATEGORIES_BY_ARTICLE, DELETE_EANS_BY_ARTICLE, EAN_EXISTS, EAN_EXISTS_FOR_OTHER_ARTICLE, DELETE_CATEGORIES_BY_ARTICLE, DELETE_EANS_BY_ARTICLE, EAN_EXISTS, EAN_EXISTS_FOR_OTHER_ARTICLE,
GET_BRAND_BY_COMPANY_AND_ID, GET_BRAND_BY_COMPANY_AND_ID,
GET_CATEGORY_ID_BY_SKU, GET_CATEGORY_TREE_FLAT, GET_ID_BY_SKU, GET_CATEGORY_ID_BY_SKU, GET_CATEGORY_TREE_FLAT, GET_ID_BY_SKU,

View File

@@ -23,6 +23,21 @@ FROM warehouse
WHERE id = @warehouseId AND company_id = @companyId; WHERE id = @warehouseId AND company_id = @companyId;
`; `;
export const GET_DEFAULT_WAREHOUSE = `
SELECT id, name
FROM warehouse
WHERE company_id = @companyId
ORDER BY sort ASC, name ASC, id ASC
LIMIT 1;
`;
export const CHECK_MANIFESTARTICLE_BOOKED = `
SELECT COALESCE(SUM(amount), 0) AS bookedAmount
FROM ledger
WHERE manifestarticle_id = @manifestarticleId
AND warehouse_id IS NULL;
`;
export const FIND_ARTICLES_FOR_HISTORY = ` export const FIND_ARTICLES_FOR_HISTORY = `
SELECT DISTINCT asup.article_id AS articleId SELECT DISTINCT asup.article_id AS articleId
FROM articlesupplier asup FROM articlesupplier asup

View File

@@ -931,6 +931,42 @@ CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_order
ON shipmentsummary (company_id, order_id); ON shipmentsummary (company_id, order_id);
`; `;
export const INIT_DELIVERYSUMMARY = `
CREATE TABLE IF NOT EXISTS deliverysummary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
manifest_id INTEGER NOT NULL UNIQUE,
delivery_number TEXT NOT NULL,
external_number TEXT,
delivery_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
supplier_id INTEGER NULL,
supplier_name TEXT NULL,
positions INTEGER NOT NULL DEFAULT 0,
gross_total REAL NOT NULL DEFAULT 0,
net_total REAL NOT NULL DEFAULT 0,
total_quantity REAL NOT NULL DEFAULT 0,
stock_positions INTEGER NOT NULL DEFAULT 0,
booked_positions INTEGER NOT NULL DEFAULT 0,
unbooked_positions INTEGER NOT NULL DEFAULT 0,
stock_quantity REAL NOT NULL DEFAULT 0,
booked_quantity REAL NOT NULL DEFAULT 0,
unbooked_quantity REAL NOT NULL DEFAULT 0,
is_booked INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES entity(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_date
ON deliverysummary (company_id, delivery_date DESC, manifest_id DESC);
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_number
ON deliverysummary (company_id, delivery_number);
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_booked
ON deliverysummary (company_id, is_booked);
CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_supplier
ON deliverysummary (company_id, supplier_id);
`;
export const INIT_MCP_CLIENT = ` export const INIT_MCP_CLIENT = `
CREATE TABLE IF NOT EXISTS mcp_client ( CREATE TABLE IF NOT EXISTS mcp_client (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1042,6 +1078,7 @@ export const SCHEMA_SCRIPTS = [
INIT_ORDERSUMMARY, INIT_ORDERSUMMARY,
INIT_QUOTESUMMARY, INIT_QUOTESUMMARY,
INIT_SHIPMENTSUMMARY, INIT_SHIPMENTSUMMARY,
INIT_DELIVERYSUMMARY,
INIT_MCP_CLIENT, INIT_MCP_CLIENT,
INIT_MCP_AUTH_CODE, INIT_MCP_AUTH_CODE,
INIT_MCP_TOKEN, INIT_MCP_TOKEN,

View File

@@ -1,25 +1,32 @@
import { import {
UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK, UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK,
CHECK_HAS_WAREHOUSE as MSSQL_CHECK_HAS_WAREHOUSE, CHECK_HAS_WAREHOUSE as MSSQL_CHECK_HAS_WAREHOUSE,
CHECK_ARTICLE_WAREHOUSE_STOCKS as MSSQL_CHECK_ARTICLE_WAREHOUSE_STOCKS,
} from '../../providers/mssql/queries/article.js'; } from '../../providers/mssql/queries/article.js';
import { import {
UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK, UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK,
CHECK_HAS_WAREHOUSE as SQLITE_CHECK_HAS_WAREHOUSE, CHECK_HAS_WAREHOUSE as SQLITE_CHECK_HAS_WAREHOUSE,
CHECK_ARTICLE_WAREHOUSE_STOCKS as SQLITE_CHECK_ARTICLE_WAREHOUSE_STOCKS,
} from '../../providers/sqlite3/queries/article.js'; } from '../../providers/sqlite3/queries/article.js';
import queryBySku from './queryBySku.js'; import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import { recordTrackStockChangeHistory } from '../articlehistory/record.js'; import { recordTrackStockChangeHistory } from '../articlehistory/record.js';
import stockAdjust from '../stock/adjust.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
export default function setTrackStockBySku(provider) { export default function setTrackStockBySku(provider) {
const resolve = resolveArticleBySku(provider); const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider); const load = queryBySku(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_UPDATE_TRACK_STOCK : MSSQL_UPDATE_TRACK_STOCK; const sql = isSqlite ? SQLITE_UPDATE_TRACK_STOCK : MSSQL_UPDATE_TRACK_STOCK;
const checkHasWarehouseSql = isSqlite ? SQLITE_CHECK_HAS_WAREHOUSE : MSSQL_CHECK_HAS_WAREHOUSE; const checkHasWarehouseSql = isSqlite ? SQLITE_CHECK_HAS_WAREHOUSE : MSSQL_CHECK_HAS_WAREHOUSE;
const checkStocksSql = isSqlite ? SQLITE_CHECK_ARTICLE_WAREHOUSE_STOCKS : MSSQL_CHECK_ARTICLE_WAREHOUSE_STOCKS;
/** /**
* Sets the track_stock flag of an article by its SKU. * Sets the track_stock flag of an article by its SKU.
* If stock tracking is being disabled (1 -> 0), all non-zero warehouse stocks
* are adjusted to 0 on the ledger before disabling tracking.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
@@ -27,17 +34,13 @@ export default function setTrackStockBySku(provider) {
* @param {object} [tx] - Optional transaction. * @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated article. * @returns {Promise<object>} The updated article.
* @throws {Error} If trackStock is invalid, article not found, or no warehouse exists when enabling. * @throws {Error} If trackStock is invalid, article not found, or no warehouse exists when enabling.
*
* @example
* const article = await store.article.setTrackStockBySku(42, 'SKU-1', true);
* // => { id, sku, name, track_stock, ... }
*/ */
return async (googleAuthId, sku, trackStock, tx) => { return async (googleAuthId, sku, trackStock, tx) => {
if (typeof trackStock !== 'boolean' && trackStock !== 0 && trackStock !== 1) { if (typeof trackStock !== 'boolean' && trackStock !== 0 && trackStock !== 1) {
throw new Error('Invalid track_stock'); throw new Error('Invalid track_stock');
} }
const runner = makeRunner(provider, tx); const run = async (runner, activeTx) => {
const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner); const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
const newTrack = Boolean(trackStock); const newTrack = Boolean(trackStock);
@@ -55,6 +58,31 @@ export default function setTrackStockBySku(provider) {
); );
const oldTrack = Boolean(oldRes.rows[0]?.track_stock ?? 0); const oldTrack = Boolean(oldRes.rows[0]?.track_stock ?? 0);
// "before stock tracking is disabled, adjust to 0 has to be ledgerized."
if (oldTrack && !newTrack) {
const stocksRes = await runner.query(checkStocksSql, {
companyId,
sku,
articleId,
});
const rows = stocksRes.rows ?? [];
for (const row of rows) {
const currentStock = Number(row.stock || 0);
if (currentStock !== 0) {
await adjust(
googleAuthId,
{
sku,
warehouseId: row.warehouseId,
amount: -currentStock,
text: `Bestandskorrektur auf 0 vor Deaktivierung der Bestandsführung (Lager: "${row.warehouseName}")`,
},
activeTx,
);
}
}
}
const result = await runner.query(sql, { const result = await runner.query(sql, {
articleId, articleId,
trackStock: trackStock ? 1 : 0, trackStock: trackStock ? 1 : 0,
@@ -65,12 +93,14 @@ export default function setTrackStockBySku(provider) {
} }
// Write history if track_stock changed // Write history if track_stock changed
if (oldTrack !== newTrack) {
await recordTrackStockChangeHistory(runner, { await recordTrackStockChangeHistory(runner, {
articleId, articleId,
companyuserId, companyuserId,
oldTrack, oldTrack,
newTrack, newTrack,
}); });
}
const article = await load(articleId, languageId, runner); const article = await load(articleId, languageId, runner);
if (!article) { if (!article) {
@@ -79,4 +109,11 @@ export default function setTrackStockBySku(provider) {
return article; return article;
}; };
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));
};
} }

View File

@@ -8,6 +8,7 @@ import { GET_FULL_BY_SI as MSSQL_GET_UNIT } from '../../providers/mssql/queries/
import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queries/unit.js'; import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queries/unit.js';
import queryBySku from './queryBySku.js'; import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import setTrackStockBySku from './setTrackStockBySku.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js'; import resolveUniqueSlug from './resolveUniqueSlug.js';
import { recordArticleUpdateHistory } from '../articlehistory/record.js'; import { recordArticleUpdateHistory } from '../articlehistory/record.js';
@@ -24,6 +25,7 @@ export default function updateBySku(provider) {
const resolve = resolveArticleBySku(provider); const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider); const load = queryBySku(provider);
const resolveSlug = resolveUniqueSlug(provider); const resolveSlug = resolveUniqueSlug(provider);
const setTrackStock = setTrackStockBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
@@ -219,10 +221,7 @@ export default function updateBySku(provider) {
} }
if (hasTrackStock) { if (hasTrackStock) {
await runner.query(articleQueries.UPDATE_TRACK_STOCK, { await setTrackStock(googleAuthId, sku, Boolean(rawTrackStock), activeTx);
articleId,
trackStock: Boolean(rawTrackStock) ? 1 : 0,
});
} }
if (historyLines.length) { if (historyLines.length) {

View File

@@ -149,7 +149,158 @@ export async function recordOrderHistory(runner, {
} }
/** /**
* Records a delivery / warehouse ledger entry in article history. * Records an incoming delivery acceptance ledger entry in article history.
* Format: "Lieferung {manifestNumber} (Lieferant: "{supplierName}", EK brutto: {ekBrutto}, Menge: {amount})"
*/
export async function recordAcceptanceHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
supplierName,
net,
rebate,
taxrate,
amount,
transactionText,
}) {
const details = [];
if (supplierName) {
details.push(`Lieferant: "${supplierName}"`);
}
let ekBrutto = null;
if (net != null && Number.isFinite(Number(net))) {
const netNum = Number(net);
const rebateNum = Number(rebate ?? 0);
const taxrateNum = Number(taxrate ?? 0);
const netAfterRebate = netNum * (1 - rebateNum / 100);
const brutto = netAfterRebate * (1 + taxrateNum / 100);
ekBrutto = (Math.round(brutto * 100) / 100).toFixed(2);
details.push(`EK brutto: ${ekBrutto}`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base = manifestNumber ? `Lieferung ${manifestNumber}` : (transactionText?.trim() || 'Lieferung');
const historyText = `${base}${suffix}`;
const meta = {
type: 'acceptance',
manifestNumber: manifestNumber ?? null,
deliveryNumber: manifestNumber ?? null,
supplierName: supplierName ?? null,
amount: amount != null ? Number(amount) : null,
net: net != null ? Number(net) : null,
rebate: rebate != null ? Number(rebate) : 0,
taxrate: taxrate != null ? Number(taxrate) : 0,
ekBrutto: ekBrutto != null ? Number(ekBrutto) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Records an outgoing shipment ledger entry in article history.
* Format: "Lieferschein {manifestNumber} (Kunde: "{customerName}", Menge: {amount})"
*/
export async function recordShipmentHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
customerName,
amount,
transactionText,
}) {
const details = [];
if (customerName) {
details.push(`Kunde: "${customerName}"`);
}
if (amount != null && Number.isFinite(Number(amount))) {
details.push(`Menge: ${amount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base = manifestNumber ? `Lieferschein ${manifestNumber}` : (transactionText?.trim() || 'Lieferschein');
const historyText = `${base}${suffix}`;
const meta = {
type: 'shipment',
manifestNumber: manifestNumber ?? null,
shipmentNumber: manifestNumber ?? null,
customerName: customerName ?? null,
amount: amount != null ? Number(amount) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Records a warehouse inventory ledger entry in article history.
* Format:
* Inbound: "Lieferung {manifestNumber} (Lager: "{warehouseName}", Menge: {amount})"
* Outbound: "Lieferschein {manifestNumber} (Lager: "{warehouseName}", Menge: {amount})"
*/
export async function recordWarehouseHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
manifestType,
warehouseName,
amount,
transactionText,
}) {
const isShipment = manifestType === 'shipment' || (amount != null && Number(amount) < 0);
const prefix = isShipment ? 'Lieferschein' : 'Lieferung';
const displayAmount = amount != null ? Math.abs(Number(amount)) : null;
const details = [];
if (warehouseName) {
details.push(`Lager: "${warehouseName}"`);
}
if (displayAmount != null && Number.isFinite(displayAmount)) {
details.push(`Menge: ${displayAmount}`);
}
const suffix = details.length ? ` (${details.join(', ')})` : '';
const base = manifestNumber ? `${prefix} ${manifestNumber}` : (transactionText?.trim() || prefix);
const historyText = `${base}${suffix}`;
const meta = {
type: isShipment ? 'warehouse_out' : 'warehouse_in',
manifestNumber: manifestNumber ?? null,
deliveryNumber: !isShipment ? (manifestNumber ?? null) : null,
shipmentNumber: isShipment ? (manifestNumber ?? null) : null,
warehouseName: warehouseName ?? null,
amount: amount != null ? Number(amount) : null,
transactionText: transactionText ?? null,
};
return recordArticleHistory(runner, {
articleId,
companyuserId,
date,
text: historyText,
meta,
});
}
/**
* Backward-compatible helper that records delivery / warehouse ledger entry in article history.
*/ */
export async function recordDeliveryHistory(runner, { export async function recordDeliveryHistory(runner, {
articleId, articleId,
@@ -165,6 +316,21 @@ export async function recordDeliveryHistory(runner, {
transactionText, transactionText,
isDelivery = true, isDelivery = true,
}) { }) {
if (isDelivery && !warehouseName) {
return recordAcceptanceHistory(runner, {
articleId,
companyuserId,
date,
manifestNumber,
supplierName,
net,
rebate,
taxrate,
amount,
transactionText,
});
}
let historyText = transactionText; let historyText = transactionText;
let ekBrutto = null; let ekBrutto = null;
@@ -189,9 +355,7 @@ export async function recordDeliveryHistory(runner, {
details.push(`Menge: ${amount}`); details.push(`Menge: ${amount}`);
} }
const suffix = details.length ? ` (${details.join(', ')})` : ''; const suffix = details.length ? ` (${details.join(', ')})` : '';
const base = const base = manifestNumber ? `Lieferung ${manifestNumber}` : (transactionText?.trim() || 'Lieferung');
transactionText?.trim() ||
(manifestNumber ? `Lieferung ${manifestNumber}` : 'Lieferung');
historyText = `${base}${suffix}`; historyText = `${base}${suffix}`;
} else if (warehouseName && transactionText && !transactionText.includes(warehouseName)) { } else if (warehouseName && transactionText && !transactionText.includes(warehouseName)) {
historyText = `${transactionText} (Lager: "${warehouseName}")`; historyText = `${transactionText} (Lager: "${warehouseName}")`;
@@ -200,6 +364,7 @@ export async function recordDeliveryHistory(runner, {
const meta = { const meta = {
type: isDelivery ? 'delivery' : 'movement', type: isDelivery ? 'delivery' : 'movement',
manifestNumber: manifestNumber ?? null, manifestNumber: manifestNumber ?? null,
deliveryNumber: manifestNumber ?? null,
supplierName: supplierName ?? null, supplierName: supplierName ?? null,
warehouseName: warehouseName ?? null, warehouseName: warehouseName ?? null,
amount: amount != null ? Number(amount) : null, amount: amount != null ? Number(amount) : null,

View File

@@ -0,0 +1,48 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import stockAccept from '../stock/accept.js';
import { loadDelivery } from './helpers.js';
import makeRunner from '../runner.js';
/**
* Books all positions of an incoming delivery manifest to the ledger
* by delegating to store.stock.accept.
*
* @param {object} provider - Storage provider instance.
* @returns {Function} `(googleAuthId, input, tx) => Promise<object>`
*/
export default function book(provider) {
const access = resolveSelectedCompanyAccess(provider);
const accept = stockAccept(provider);
return async (googleAuthId, input = {}, tx) => {
const rawId = typeof input === 'number' ? input : (input?.id ?? input?.deliveryId);
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Delivery not found');
}
const warehouseId = input?.warehouseId ?? input?.warehouse_id ?? null;
const doBook = async (runner, activeTx) => {
const { companyId } = await access(googleAuthId, runner);
const delivery = await loadDelivery(provider, activeTx, companyId, id);
if (!delivery) {
throw new Error('Delivery not found');
}
if (!delivery.lines || delivery.lines.length === 0) {
throw new Error('Delivery has no lines');
}
await accept(googleAuthId, { deliveryId: id, warehouseId }, activeTx);
return loadDelivery(provider, activeTx, companyId, id);
};
if (tx) {
return doBook(makeRunner(provider, tx), tx);
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(doBook);
}
return doBook(makeRunner(provider));
};
}

View File

@@ -1,3 +1,4 @@
import { syncDeliverySummary } from './syncSummary.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
@@ -73,6 +74,7 @@ export default function create(provider) {
body.files, body.files,
); );
await syncDeliverySummary(runner, { companyId, manifestId });
return loadDelivery(provider, tx, companyId, manifestId); return loadDelivery(provider, tx, companyId, manifestId);
}; };

View File

@@ -18,6 +18,7 @@ export default function del(provider) {
const runner = makeRunner(provider); const runner = makeRunner(provider);
try { try {
await runner.query('DELETE FROM deliverysummary WHERE manifest_id = @id AND company_id = @companyId', { id, companyId });
const result = await runner(queries.DELETE_MANIFEST, { const result = await runner(queries.DELETE_MANIFEST, {
id, id,
companyId, companyId,

View File

@@ -17,12 +17,20 @@ export function shapeSummary(row) {
createdAt: row.createdAt ?? row.created_at ?? null, createdAt: row.createdAt ?? row.created_at ?? null,
}; };
if (row.totalPositions !== undefined || row.completelyOnLedgerPositions !== undefined) { const totalPositions = Number(row.stockPositions ?? row.totalPositions ?? row.positions ?? 0);
const bookedPositions = Number(row.completelyOnLedgerPositions ?? row.bookedPositions ?? 0);
const unbookedPositions = Number(row.unbookedPositions ?? Math.max(0, totalPositions - bookedPositions));
summary.ledger = { summary.ledger = {
totalPositions: Number(row.totalPositions ?? 0), totalPositions,
completelyOnLedgerPositions: Number(row.completelyOnLedgerPositions ?? 0), completelyOnLedgerPositions: bookedPositions,
bookedPositions,
unbookedPositions,
totalQuantity: Number(row.stockQuantity ?? row.totalQuantity ?? 0),
bookedQuantity: Number(row.bookedQuantity ?? 0),
unbookedQuantity: Number(row.unbookedQuantity ?? 0),
isBooked: row.isBooked !== undefined ? Boolean(row.isBooked) : (totalPositions > 0 && bookedPositions >= totalPositions),
}; };
}
return summary; return summary;
} }

View File

@@ -10,30 +10,317 @@ export default function list(provider) {
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries; const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId) => { const sqlCountManifests = isSqlite
const { companyId } = await access(googleAuthId); ? 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND "type" = @type'
const runner = makeRunner(provider); : 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND [type] = @type';
const result = await runner(queries.LIST_DELIVERIES, { /**
* Lists the deliveries 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 deliveries to skip.
* @param {number} [opts.limit=50] - Number of deliveries to return.
* @param {string} [opts.search] - Free-text search matching delivery number, external number, or supplier name.
* @param {boolean} [opts.onlyUnbooked=false] - When true, returns only deliveries with unbooked stock items.
* @param {number} [opts.supplierId] - Filter by supplier entity id.
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{deliveries: 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() !== '') ||
opts.onlyUnbooked === true ||
opts.supplierId !== undefined ||
opts.seekMonths !== 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, companyId,
type: queries.DELIVERY_TYPE, type: queries.DELIVERY_TYPE,
}); };
const filesResult = await runner(queries.LIST_DELIVERY_FILES, { // Date cutoff via seekMonths (strictly integer)
companyId, if (opts?.seekMonths !== undefined) {
type: queries.DELIVERY_TYPE, 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`);
}
// Filter by supplierId
if (opts?.supplierId !== undefined && opts.supplierId !== null) {
const supplierId = Number(opts.supplierId);
if (!Number.isInteger(supplierId) || supplierId <= 0) {
throw new Error('Invalid supplierId');
}
params.supplierId = supplierId;
conditions.push('m.entity_id = @supplierId');
}
// 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 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
OR ds.delivery_number LIKE @searchPattern
OR ds.supplier_name LIKE @searchPattern
)`);
} else {
conditions.push(`(
m.[number] LIKE @searchPattern
OR m.external_number 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
OR ds.delivery_number LIKE @searchPattern
OR ds.supplier_name LIKE @searchPattern
)`);
}
}
// Only unbooked deliveries (has stock positions that are not yet booked)
if (opts?.onlyUnbooked === true) {
conditions.push('(COALESCE(ds.unbooked_positions, 0) > 0 OR (COALESCE(ds.is_booked, 0) = 0 AND COALESCE(ds.stock_positions, 0) > 0))');
}
const whereClause = conditions.join('\n AND ');
const sqlCount = `
SELECT COUNT(*) AS total
FROM manifest m
LEFT JOIN deliverysummary ds ON ds.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 supplierId,
m.external_number AS externalNumber,
m."date",
m.created_at AS createdAt,
ds.positions,
ds.gross_total AS grossTotal,
ds.net_total AS netTotal,
ds.total_quantity AS totalQuantity,
ds.stock_positions AS stockPositions,
ds.booked_positions AS bookedPositions,
ds.unbooked_positions AS unbookedPositions,
ds.stock_quantity AS stockQuantity,
ds.booked_quantity AS bookedQuantity,
ds.unbooked_quantity AS unbookedQuantity,
ds.is_booked AS isBooked,
ds.supplier_name AS supplierName
FROM manifest m
LEFT JOIN deliverysummary ds ON ds.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 supplierId,
m.external_number AS externalNumber,
m.[date],
m.created_at AS createdAt,
ds.positions,
ds.gross_total AS grossTotal,
ds.net_total AS netTotal,
ds.total_quantity AS totalQuantity,
ds.stock_positions AS stockPositions,
ds.booked_positions AS bookedPositions,
ds.unbooked_positions AS unbookedPositions,
ds.stock_quantity AS stockQuantity,
ds.booked_quantity AS bookedQuantity,
ds.unbooked_quantity AS unbookedQuantity,
ds.is_booked AS isBooked,
ds.supplier_name AS supplierName
FROM manifest m
LEFT JOIN deliverysummary ds ON ds.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() !== '') ||
opts?.onlyUnbooked === true ||
opts?.supplierId !== 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: queries.DELIVERY_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: queries.DELIVERY_TYPE });
unfilteredTotal = Number(countRes.rows[0]?.total ?? 0);
} else {
unfilteredTotal = total;
}
}
const manifestIds = manifestRows.map((r) => r.id);
const filesByManifest = new Map(); const filesByManifest = new Map();
if (manifestIds.length > 0) {
const sqlFiles = isSqlite
? `SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN "file" f ON f.id = ma.file_id
WHERE ma.manifest_id IN (SELECT value FROM json_each(@manifestIdsJson))
ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC`
: `SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbavif,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN [file] f ON f.id = ma.file_id
WHERE ma.manifest_id IN (SELECT CAST(value AS INT) FROM OPENJSON(@manifestIdsJson))
ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC`;
const filesResult = await runner.query(sqlFiles, {
manifestIdsJson: JSON.stringify(manifestIds),
});
for (const row of filesResult.rows) { for (const row of filesResult.rows) {
const list = filesByManifest.get(row.manifestId) ?? []; const list = filesByManifest.get(row.manifestId) ?? [];
list.push(shapeAttachmentMeta(row)); list.push(shapeAttachmentMeta(row));
filesByManifest.set(row.manifestId, list); filesByManifest.set(row.manifestId, list);
} }
}
return result.rows.map((row) => ({ const deliveries = manifestRows.map((row) => ({
...shapeSummary(row), ...shapeSummary(row),
positions: row.positions !== undefined && row.positions !== null ? Number(row.positions) : 0,
grossTotal: row.grossTotal !== undefined && row.grossTotal !== null ? Number(row.grossTotal) : 0,
netTotal: row.netTotal !== undefined && row.netTotal !== null ? Number(row.netTotal) : 0,
totalQuantity: row.totalQuantity !== undefined && row.totalQuantity !== null ? Number(row.totalQuantity) : 0,
stockPositions: row.stockPositions !== undefined && row.stockPositions !== null ? Number(row.stockPositions) : 0,
bookedPositions: row.bookedPositions !== undefined && row.bookedPositions !== null ? Number(row.bookedPositions) : 0,
unbookedPositions: row.unbookedPositions !== undefined && row.unbookedPositions !== null ? Number(row.unbookedPositions) : 0,
stockQuantity: row.stockQuantity !== undefined && row.stockQuantity !== null ? Number(row.stockQuantity) : 0,
bookedQuantity: row.bookedQuantity !== undefined && row.bookedQuantity !== null ? Number(row.bookedQuantity) : 0,
unbookedQuantity: row.unbookedQuantity !== undefined && row.unbookedQuantity !== null ? Number(row.unbookedQuantity) : 0,
isBooked: row.isBooked !== undefined && row.isBooked !== null ? Boolean(row.isBooked) : false,
supplierName: row.supplierName ?? undefined,
ledger: {
totalPositions: Number(row.stockPositions ?? row.totalPositions ?? 0),
completelyOnLedgerPositions: Number(row.bookedPositions ?? row.completelyOnLedgerPositions ?? 0),
bookedPositions: Number(row.bookedPositions ?? 0),
unbookedPositions: Number(row.unbookedPositions ?? 0),
totalQuantity: Number(row.stockQuantity ?? 0),
bookedQuantity: Number(row.bookedQuantity ?? 0),
unbookedQuantity: Number(row.unbookedQuantity ?? 0),
isBooked: Boolean(row.isBooked),
},
files: filesByManifest.get(row.id) ?? [], files: filesByManifest.get(row.id) ?? [],
})); }));
if (isPaged) {
return {
deliveries,
total,
unfilteredTotal,
offset,
limit,
};
}
return deliveries;
}; };
} }

View File

@@ -0,0 +1,19 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { rebuildDeliverySummaries } from './syncSummary.js';
/**
* Rebuilds the `deliverysummary` table for the user's selected company.
*
* @param {import('../../providers/provider.js').DatabaseProvider} provider
*/
export default function rebuildSummaries(provider) {
const access = resolveSelectedCompanyAccess(provider);
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
await rebuildDeliverySummaries(runner, companyId);
return { ok: true, companyId };
};
}

View File

@@ -0,0 +1,445 @@
/**
* Centralized sync and rebuild logic for the materialized `deliverysummary` table.
*
* Keeps `deliverysummary` in sync with manifest (delivery), manifestarticle,
* article (track_stock), and ledger (DELIVERY transactions).
*/
const SQL_GET_DELIVERY_BY_ID_SQLITE = `
SELECT m.id, m.company_id, m."number", m.external_number, m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'delivery'
`;
const SQL_GET_DELIVERY_BY_ID_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.external_number, m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'delivery'
`;
const SQL_GET_DELIVERY_BY_NUMBER_SQLITE = `
SELECT m.id, m.company_id, m."number", m.external_number, m."date", m.created_at, m.entity_id
FROM manifest m
WHERE m."number" = @number AND m.company_id = @companyId AND m."type" = 'delivery'
`;
const SQL_GET_DELIVERY_BY_NUMBER_MSSQL = `
SELECT m.id, m.company_id, m.[number], m.external_number, m.[date], m.created_at, m.entity_id
FROM manifest m
WHERE m.[number] = @number AND m.company_id = @companyId AND m.[type] = 'delivery'
`;
const SQL_SUPPLIER_NAME_SQLITE = `
SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS supplier_name
FROM entity
WHERE id = @entityId
`;
const SQL_SUPPLIER_NAME_MSSQL = `
SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS supplier_name
FROM entity
WHERE id = @entityId
`;
const SQL_DELIVERY_TOTALS_SQLITE = `
WITH line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
LEFT JOIN article a ON a.company_id = @companyId AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE ma.manifest_id = @manifestId
)
SELECT
COUNT(*) AS positions,
COALESCE(SUM(line_gross), 0) AS gross_total,
COALESCE(SUM(line_net), 0) AS net_total,
COALESCE(SUM(amount), 0) AS total_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END), 0) AS stock_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END), 0) AS booked_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END), 0) AS stock_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END), 0) AS booked_quantity
FROM line_data
`;
const SQL_DELIVERY_TOTALS_MSSQL = `
WITH line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
LEFT JOIN article a ON a.company_id = @companyId AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE ma.manifest_id = @manifestId
)
SELECT
COUNT(*) AS positions,
COALESCE(SUM(line_gross), 0) AS gross_total,
COALESCE(SUM(line_net), 0) AS net_total,
COALESCE(SUM(amount), 0) AS total_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END), 0) AS stock_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END), 0) AS booked_positions,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END), 0) AS stock_quantity,
COALESCE(SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END), 0) AS booked_quantity
FROM line_data
`;
const SQL_UPSERT_SUMMARY_SQLITE = `
INSERT INTO deliverysummary (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
) VALUES (
@companyId, @manifestId, @deliveryNumber, @externalNumber, @deliveryDate, @createdAt,
@supplierId, @supplierName, @positions, @grossTotal, @netTotal, @totalQuantity,
@stockPositions, @bookedPositions, @unbookedPositions,
@stockQuantity, @bookedQuantity, @unbookedQuantity, @isBooked
)
ON CONFLICT(manifest_id) DO UPDATE SET
delivery_number = excluded.delivery_number,
external_number = excluded.external_number,
delivery_date = excluded.delivery_date,
created_at = excluded.created_at,
supplier_id = excluded.supplier_id,
supplier_name = excluded.supplier_name,
positions = excluded.positions,
gross_total = excluded.gross_total,
net_total = excluded.net_total,
total_quantity = excluded.total_quantity,
stock_positions = excluded.stock_positions,
booked_positions = excluded.booked_positions,
unbooked_positions = excluded.unbooked_positions,
stock_quantity = excluded.stock_quantity,
booked_quantity = excluded.booked_quantity,
unbooked_quantity = excluded.unbooked_quantity,
is_booked = excluded.is_booked
`;
const SQL_UPSERT_SUMMARY_MSSQL = `
MERGE INTO deliverysummary AS target
USING (SELECT @manifestId AS manifest_id) AS source
ON (target.manifest_id = source.manifest_id)
WHEN MATCHED THEN
UPDATE SET
delivery_number = @deliveryNumber,
external_number = @externalNumber,
delivery_date = @deliveryDate,
created_at = @createdAt,
supplier_id = @supplierId,
supplier_name = @supplierName,
positions = @positions,
gross_total = @grossTotal,
net_total = @netTotal,
total_quantity = @totalQuantity,
stock_positions = @stockPositions,
booked_positions = @bookedPositions,
unbooked_positions = @unbookedPositions,
stock_quantity = @stockQuantity,
booked_quantity = @bookedQuantity,
unbooked_quantity = @unbookedQuantity,
is_booked = @isBooked
WHEN NOT MATCHED THEN
INSERT (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
) VALUES (
@companyId, @manifestId, @deliveryNumber, @externalNumber, @deliveryDate, @createdAt,
@supplierId, @supplierName, @positions, @grossTotal, @netTotal, @totalQuantity,
@stockPositions, @bookedPositions, @unbookedPositions,
@stockQuantity, @bookedQuantity, @unbookedQuantity, @isBooked
);
`;
/**
* Recalculates and upserts a single delivery summary into `deliverysummary`.
*
* @param {object} runner - Database runner.
* @param {object} opts
* @param {number} opts.companyId
* @param {number} [opts.manifestId] - Delivery manifest id.
* @param {string} [opts.deliveryNumber] - Delivery number (if manifestId not given).
*/
export async function syncDeliverySummary(runner, { companyId, manifestId, deliveryNumber }) {
const isSqlite = runner.dialect === 'sqlite3';
let deliveryRow;
if (manifestId) {
const res = await runner.query(
isSqlite ? SQL_GET_DELIVERY_BY_ID_SQLITE : SQL_GET_DELIVERY_BY_ID_MSSQL,
{ manifestId, companyId },
);
deliveryRow = res.rows[0];
} else if (deliveryNumber) {
const res = await runner.query(
isSqlite ? SQL_GET_DELIVERY_BY_NUMBER_SQLITE : SQL_GET_DELIVERY_BY_NUMBER_MSSQL,
{ number: deliveryNumber, companyId },
);
deliveryRow = res.rows[0];
}
if (!deliveryRow) {
if (manifestId) {
await runner.query('DELETE FROM deliverysummary WHERE manifest_id = @manifestId', { manifestId });
}
return;
}
const deliveryId = deliveryRow.id;
const num = deliveryRow.number;
const totalsRes = await runner.query(
isSqlite ? SQL_DELIVERY_TOTALS_SQLITE : SQL_DELIVERY_TOTALS_MSSQL,
{ manifestId: deliveryId, companyId },
);
const totals = totalsRes.rows[0] || {};
let supplierName = null;
if (deliveryRow.entity_id) {
const supRes = await runner.query(
isSqlite ? SQL_SUPPLIER_NAME_SQLITE : SQL_SUPPLIER_NAME_MSSQL,
{ entityId: deliveryRow.entity_id },
);
supplierName = supRes.rows[0]?.supplier_name || null;
}
const positions = Number(totals.positions || 0);
const grossTotal = Math.round(Number(totals.gross_total || 0) * 100) / 100;
const netTotal = Math.round(Number(totals.net_total || 0) * 100) / 100;
const totalQuantity = Math.round(Number(totals.total_quantity || 0) * 1000) / 1000;
const stockPositions = Number(totals.stock_positions || 0);
const bookedPositions = Number(totals.booked_positions || 0);
const unbookedPositions = Math.max(0, stockPositions - bookedPositions);
const stockQuantity = Math.round(Number(totals.stock_quantity || 0) * 1000) / 1000;
const bookedQuantity = Math.round(Number(totals.booked_quantity || 0) * 1000) / 1000;
const unbookedQuantity = Math.round(Math.max(0, stockQuantity - bookedQuantity) * 1000) / 1000;
const isBooked = stockPositions > 0 && bookedPositions >= stockPositions ? 1 : 0;
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
companyId,
manifestId: deliveryId,
deliveryNumber: num,
externalNumber: deliveryRow.external_number ?? null,
deliveryDate: deliveryRow.date,
createdAt: deliveryRow.created_at,
supplierId: deliveryRow.entity_id ?? null,
supplierName,
positions,
grossTotal,
netTotal,
totalQuantity,
stockPositions,
bookedPositions,
unbookedPositions,
stockQuantity,
bookedQuantity,
unbookedQuantity,
isBooked,
});
}
/**
* Full rebuild of all deliveries in `deliverysummary` for a given company.
*
* @param {object} runner
* @param {number} companyId
*/
export async function rebuildDeliverySummaries(runner, companyId) {
const isSqlite = runner.dialect === 'sqlite3';
if (isSqlite) {
const sqlRebuildSqlite = `
WITH
line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.manifest_id,
ma.id AS line_id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.company_id = m.company_id AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE m.company_id = @companyId AND m."type" = 'delivery'
),
delivery_agg AS (
SELECT
manifest_id,
COUNT(*) AS positions,
SUM(line_gross) AS gross_total,
SUM(line_net) AS net_total,
SUM(amount) AS total_quantity,
SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END) AS stock_positions,
SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END) AS booked_positions,
SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END) AS stock_quantity,
SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END) AS booked_quantity
FROM line_data
GROUP BY manifest_id
)
INSERT INTO deliverysummary (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
)
SELECT
m.company_id,
m.id AS manifest_id,
m."number" AS delivery_number,
m.external_number,
m."date" AS delivery_date,
m.created_at,
m.entity_id AS supplier_id,
TRIM(COALESCE(e.first_name || ' ', '') || COALESCE(e.name, '')) AS supplier_name,
COALESCE(da.positions, 0) AS positions,
ROUND(COALESCE(da.gross_total, 0), 2) AS gross_total,
ROUND(COALESCE(da.net_total, 0), 2) AS net_total,
ROUND(COALESCE(da.total_quantity, 0), 3) AS total_quantity,
COALESCE(da.stock_positions, 0) AS stock_positions,
COALESCE(da.booked_positions, 0) AS booked_positions,
MAX(0, COALESCE(da.stock_positions, 0) - COALESCE(da.booked_positions, 0)) AS unbooked_positions,
ROUND(COALESCE(da.stock_quantity, 0), 3) AS stock_quantity,
ROUND(COALESCE(da.booked_quantity, 0), 3) AS booked_quantity,
ROUND(MAX(0, COALESCE(da.stock_quantity, 0) - COALESCE(da.booked_quantity, 0)), 3) AS unbooked_quantity,
CASE WHEN COALESCE(da.stock_positions, 0) > 0 AND COALESCE(da.booked_positions, 0) >= COALESCE(da.stock_positions, 0) THEN 1 ELSE 0 END AS is_booked
FROM manifest m
LEFT JOIN entity e ON e.id = m.entity_id
LEFT JOIN delivery_agg da ON da.manifest_id = m.id
WHERE m.company_id = @companyId AND m."type" = 'delivery'
ON CONFLICT(manifest_id) DO UPDATE SET
delivery_number = excluded.delivery_number,
external_number = excluded.external_number,
delivery_date = excluded.delivery_date,
created_at = excluded.created_at,
supplier_id = excluded.supplier_id,
supplier_name = excluded.supplier_name,
positions = excluded.positions,
gross_total = excluded.gross_total,
net_total = excluded.net_total,
total_quantity = excluded.total_quantity,
stock_positions = excluded.stock_positions,
booked_positions = excluded.booked_positions,
unbooked_positions = excluded.unbooked_positions,
stock_quantity = excluded.stock_quantity,
booked_quantity = excluded.booked_quantity,
unbooked_quantity = excluded.unbooked_quantity,
is_booked = excluded.is_booked;
`;
await runner.query(sqlRebuildSqlite, { companyId });
} else {
await runner.query('DELETE FROM deliverysummary WHERE company_id = @companyId', { companyId });
const sqlRebuildMssql = `
WITH
line_ledger AS (
SELECT
manifestarticle_id,
SUM(amount) AS booked_amount
FROM ledger
WHERE warehouse_id IS NULL
AND UPPER(transaction_type) IN ('ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT')
GROUP BY manifestarticle_id
),
line_data AS (
SELECT
ma.manifest_id,
ma.id AS line_id,
ma.amount,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross,
ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net,
CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock,
COALESCE(ll.booked_amount, 0) AS booked_amount
FROM manifestarticle ma
JOIN manifest m ON m.id = ma.manifest_id
LEFT JOIN article a ON a.company_id = m.company_id AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku))
LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id
WHERE m.company_id = @companyId AND m.[type] = 'delivery'
),
delivery_agg AS (
SELECT
manifest_id,
COUNT(*) AS positions,
SUM(line_gross) AS gross_total,
SUM(line_net) AS net_total,
SUM(amount) AS total_quantity,
SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END) AS stock_positions,
SUM(CASE WHEN track_stock = 1 AND (CASE WHEN amount > 0 THEN booked_amount >= amount ELSE booked_amount > 0 END) THEN 1 ELSE 0 END) AS booked_positions,
SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END) AS stock_quantity,
SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END) AS booked_quantity
FROM line_data
GROUP BY manifest_id
)
INSERT INTO deliverysummary (
company_id, manifest_id, delivery_number, external_number, delivery_date, created_at,
supplier_id, supplier_name, positions, gross_total, net_total, total_quantity,
stock_positions, booked_positions, unbooked_positions,
stock_quantity, booked_quantity, unbooked_quantity, is_booked
)
SELECT
m.company_id,
m.id AS manifest_id,
m.[number] AS delivery_number,
m.external_number,
m.[date] AS delivery_date,
m.created_at,
m.entity_id AS supplier_id,
LTRIM(RTRIM(ISNULL(e.first_name + ' ', '') + ISNULL(e.name, ''))) AS supplier_name,
COALESCE(da.positions, 0) AS positions,
ROUND(COALESCE(da.gross_total, 0), 2) AS gross_total,
ROUND(COALESCE(da.net_total, 0), 2) AS net_total,
ROUND(COALESCE(da.total_quantity, 0), 3) AS total_quantity,
COALESCE(da.stock_positions, 0) AS stock_positions,
COALESCE(da.booked_positions, 0) AS booked_positions,
CASE WHEN COALESCE(da.stock_positions, 0) > COALESCE(da.booked_positions, 0) THEN COALESCE(da.stock_positions, 0) - COALESCE(da.booked_positions, 0) ELSE 0 END AS unbooked_positions,
ROUND(COALESCE(da.stock_quantity, 0), 3) AS stock_quantity,
ROUND(COALESCE(da.booked_quantity, 0), 3) AS booked_quantity,
ROUND(CASE WHEN COALESCE(da.stock_quantity, 0) > COALESCE(da.booked_quantity, 0) THEN COALESCE(da.stock_quantity, 0) - COALESCE(da.booked_quantity, 0) ELSE 0 END, 3) AS unbooked_quantity,
CASE WHEN COALESCE(da.stock_positions, 0) > 0 AND COALESCE(da.booked_positions, 0) >= COALESCE(da.stock_positions, 0) THEN 1 ELSE 0 END AS is_booked
FROM manifest m
LEFT JOIN entity e ON e.id = m.entity_id
LEFT JOIN delivery_agg da ON da.manifest_id = m.id
WHERE m.company_id = @companyId AND m.[type] = 'delivery';
`;
await runner.query(sqlRebuildMssql, { companyId });
}
}

View File

@@ -1,3 +1,4 @@
import { syncDeliverySummary } from './syncSummary.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js'; import { isForeignKeyConstraintError } from '../../providers/provider.js';
@@ -141,6 +142,7 @@ export default function update(provider) {
); );
} }
await syncDeliverySummary(runner, { companyId, manifestId: id });
return loadDelivery(provider, tx, companyId, id); return loadDelivery(provider, tx, companyId, id);
}; };

View File

@@ -1,8 +1,14 @@
import { syncDeliverySummary } from '../delivery/syncSummary.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import * as mssqlQueries from '../../providers/mssql/queries/ledger.js'; import * as mssqlQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { recordDeliveryHistory } from '../articlehistory/record.js'; import {
recordAcceptanceHistory,
recordShipmentHistory,
recordWarehouseHistory,
} from '../articlehistory/record.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
export const INIT_HASH = 'TODO_TSE_BASED_INITHASH'; export const INIT_HASH = 'TODO_TSE_BASED_INITHASH';
@@ -50,7 +56,11 @@ export function computePrevHash(row) {
} }
/** /**
* Appends a new append-only entry to the ledger table. * Appends new append-only entry/entries to the ledger table according to manifest rules:
* - quote / order: no bookings possible (throws error)
* - delivery: acceptance entry (warehouse_id=null) + warehouse entry (if tracked) in one transaction
* - shipment: shipment entry (warehouse_id=null, negative amount) + warehouse entry (if tracked) in one transaction
* - adjustment: single warehouse inventory movement
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Ledger entry payload. * @param {object} input - Ledger entry payload.
@@ -60,17 +70,7 @@ export function computePrevHash(row) {
* @param {number} input.amount - Movement amount. * @param {number} input.amount - Movement amount.
* @param {string} input.transaction_text - Transaction memo/details (can also pass transactionText). * @param {string} input.transaction_text - Transaction memo/details (can also pass transactionText).
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>} The created ledger row. * @returns {Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>} The primary ledger row.
* @throws {Error} If user not authorized, company not selected, manifest article not found, or input invalid.
*
* @example
* const entry = await store.ledger.add(googleAuthId, {
* manifestarticle_id: 1,
* warehouse_id: 1,
* transaction_type: 'DELIVERY',
* amount: 100.5,
* transaction_text: 'Invoice paid',
* });
*/ */
export default function add(provider) { export default function add(provider) {
const access = resolveSelectedCompanyAccess(provider); const access = resolveSelectedCompanyAccess(provider);
@@ -120,7 +120,7 @@ export default function add(provider) {
const transactionText = String(rawTransactionText); const transactionText = String(rawTransactionText);
const doAdd = async (runner) => { const doAdd = async (runner) => {
const { companyId, companyuserId, rootCategoryId } = await access(googleAuthId, runner); const { companyId, companyuserId } = await access(googleAuthId, runner);
const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, { const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, {
manifestarticleId, manifestarticleId,
@@ -132,48 +132,25 @@ export default function add(provider) {
throw new Error('Manifest article not found'); throw new Error('Manifest article not found');
} }
// Stock guard: warehouse-scoped ledger entries affect article stock const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
// (stock is SUM(ledger.amount) GROUP BY warehouse). Reject bookings for if (manifestType === 'quote') {
// articles with stock tracking disabled before anything is written. throw new Error('Cannot book ledger entries for quote');
if (warehouseId != null) {
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
if (sku) {
const trackRes = await runner.query(
`SELECT 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))`,
{ companyId, sku },
);
const disabled = (trackRes.rows ?? []).find((r) => Number(r.track_stock ?? 1) === 0);
if (disabled) {
throw new Error(`Stock tracking is disabled for article (${sku})`);
}
} }
if (manifestType === 'order') {
throw new Error('Cannot book ledger entries for order');
} }
let warehouseName = null; const insertLedgerRow = async ({ whId, type, rowAmount, memo }) => {
if (warehouseId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseName = whCheck.rows[0].name;
}
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);
const inserted = await runner.query(queries.INSERT_ROW, { const inserted = await runner.query(queries.INSERT_ROW, {
manifestarticleId, manifestarticleId,
warehouseId, warehouseId: whId,
transactionType, transactionType: type,
amount, amount: rowAmount,
transactionText, transactionText: memo,
hashVersion: 1, hashVersion: 1,
prevHash, prevHash,
}); });
@@ -182,49 +159,204 @@ export default function add(provider) {
if (!row) { if (!row) {
throw new Error('Failed to insert ledger entry'); throw new Error('Failed to insert ledger entry');
} }
return row;
};
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : ''; const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
let isTracked = true;
let articleMatches = [];
if (sku) { if (sku) {
const articleMatches = await runner.query(queries.FIND_ARTICLES_FOR_HISTORY, { const trackRes = await runner.query(
sku, `SELECT a.id, a.sku, a.track_stock FROM article a
companyId, 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 },
);
articleMatches = trackRes.rows ?? [];
const disabled = articleMatches.find((r) => Number(r.track_stock ?? 1) === 0);
if (disabled) {
isTracked = false;
}
}
if (manifestType === 'delivery') {
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Number(bookedCheck.rows?.[0]?.bookedAmount || 0);
const lineAmount = Number(manifestArticle.lineAmount || 0);
if (lineAmount > 0 && bookedAmount >= lineAmount) {
throw new Error('Delivery line already booked on ledger');
}
const deliveryNumber = manifestArticle.manifestNumber;
const text = transactionText || (deliveryNumber ? `Lieferung ${deliveryNumber}` : 'Lieferung');
// Entry 1: ACCEPTANCE (warehouse_id = null)
const row1 = await insertLedgerRow({
whId: null,
type: 'ACCEPTANCE',
rowAmount: Math.abs(amount),
memo: text,
}); });
const upperType = String(transactionType).toUpperCase(); // Record history for acceptance
const isDelivery = for (const match of articleMatches) {
upperType === 'DELIVERY' || await recordAcceptanceHistory(runner, {
upperType === 'GOODS_RECEIPT' || articleId: match.id,
(manifestArticle.manifestType === 'delivery' &&
!upperType.includes('ADJUST') &&
!upperType.includes('LOSS') &&
!upperType.includes('WRITE_OFF'));
const isShipping =
upperType === 'SHIPPING' ||
upperType === 'ORDER_SHIPPED' ||
(manifestArticle.manifestType === 'order' &&
!upperType.includes('ADJUST') &&
!upperType.includes('LOSS') &&
!upperType.includes('WRITE_OFF'));
if (!isShipping) {
for (const match of articleMatches.rows) {
await recordDeliveryHistory(runner, {
articleId: match.articleId,
companyuserId, companyuserId,
manifestNumber: manifestArticle.manifestNumber, manifestNumber: deliveryNumber,
supplierName: manifestArticle.supplierName, supplierName: manifestArticle.supplierName,
warehouseName,
net: manifestArticle.net, net: manifestArticle.net,
rebate: manifestArticle.rebate, rebate: manifestArticle.rebate,
taxrate: manifestArticle.taxrate, taxrate: manifestArticle.taxrate,
amount, amount: Math.abs(amount),
transactionText, transactionText: text,
isDelivery, });
}
// Entry 2: WAREHOUSE (only if tracked)
if (isTracked) {
let effectiveWhId = warehouseId;
let warehouseName = null;
if (effectiveWhId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId: effectiveWhId,
companyId,
});
if (!whCheck.rows[0]) throw new Error('Warehouse not found');
warehouseName = whCheck.rows[0].name;
} else {
const defWh = await runner.query(queries.GET_DEFAULT_WAREHOUSE, { companyId });
if (!defWh.rows[0]) throw new Error('Warehouse not found');
effectiveWhId = defWh.rows[0].id;
warehouseName = defWh.rows[0].name;
}
await insertLedgerRow({
whId: effectiveWhId,
type: 'WAREHOUSE',
rowAmount: Math.abs(amount),
memo: text,
});
for (const match of articleMatches) {
await recordWarehouseHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: deliveryNumber,
manifestType: 'delivery',
warehouseName,
amount: Math.abs(amount),
transactionText: text,
}); });
} }
} }
if (manifestArticle.manifestId) {
await syncDeliverySummary(runner, { companyId, manifestId: manifestArticle.manifestId });
} }
return shape(row1);
}
if (manifestType === 'shipment') {
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Math.abs(Number(bookedCheck.rows?.[0]?.bookedAmount || 0));
const lineAmount = Number(manifestArticle.lineAmount || 0);
if (lineAmount > 0 && bookedAmount >= lineAmount) {
throw new Error('Shipment line already booked on ledger');
}
const shipmentNumber = manifestArticle.manifestNumber;
const text = transactionText || (shipmentNumber ? `Lieferschein ${shipmentNumber}` : 'Lieferschein');
const outboundAmount = -Math.abs(amount);
// Entry 1: SHIPMENT (warehouse_id = null)
const row1 = await insertLedgerRow({
whId: null,
type: 'SHIPMENT',
rowAmount: outboundAmount,
memo: text,
});
for (const match of articleMatches) {
await recordShipmentHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
customerName: manifestArticle.supplierName,
amount: outboundAmount,
transactionText: text,
});
}
// Entry 2: WAREHOUSE (only if tracked)
if (isTracked) {
let effectiveWhId = warehouseId;
let warehouseName = null;
if (effectiveWhId != null) {
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId: effectiveWhId,
companyId,
});
if (!whCheck.rows[0]) throw new Error('Warehouse not found');
warehouseName = whCheck.rows[0].name;
} else {
const defWh = await runner.query(queries.GET_DEFAULT_WAREHOUSE, { companyId });
if (!defWh.rows[0]) throw new Error('Warehouse not found');
effectiveWhId = defWh.rows[0].id;
warehouseName = defWh.rows[0].name;
}
await insertLedgerRow({
whId: effectiveWhId,
type: 'WAREHOUSE',
rowAmount: outboundAmount,
memo: text,
});
for (const match of articleMatches) {
await recordWarehouseHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
manifestType: 'shipment',
warehouseName,
amount: outboundAmount,
transactionText: text,
});
}
}
if (manifestArticle.manifestId) {
await syncShipmentSummary(runner, { companyId, manifestId: manifestArticle.manifestId });
}
return shape(row1);
}
// Fallback for other manifest types (e.g. adjustment)
if (!isTracked) {
throw new Error(`Stock tracking is disabled for article (${sku})`);
}
if (warehouseId == null) {
throw new Error('Warehouse not found');
}
const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, {
warehouseId,
companyId,
});
if (!whCheck.rows[0]) {
throw new Error('Warehouse not found');
}
const row = await insertLedgerRow({
whId: warehouseId,
type: transactionType,
rowAmount: amount,
memo: transactionText,
});
return shape(row); return shape(row);
}; };

View File

@@ -160,7 +160,7 @@ export default function create(provider) {
let amount; let amount;
if (line.amount !== undefined && line.amount !== null) { if (line.amount !== undefined && line.amount !== null) {
amount = Number(line.amount); amount = Number(line.amount);
if (!Number.isFinite(amount) || amount <= 0) { if (!Number.isFinite(amount) || amount === 0) {
throw new Error('Invalid lines'); throw new Error('Invalid lines');
} }
} else { } else {
@@ -172,7 +172,7 @@ export default function create(provider) {
} }
const currentProposed = proposedBySku.get(sku) || 0; const currentProposed = proposedBySku.get(sku) || 0;
if (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})`);
} }
proposedBySku.set(sku, currentProposed + amount); proposedBySku.set(sku, currentProposed + amount);

View File

@@ -1,7 +1,7 @@
import { queriesFor, requireId, requireText } from '../logistics/helpers.js'; import { queriesFor, requireId, requireText } from '../logistics/helpers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import nextNumber from '../numbers/nextNumber.js'; import nextNumber from '../numbers/nextNumber.js';
import ledgerAdd from '../ledger/add.js'; import stockShip from '../stock/ship.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js'; import { syncShipmentSummary } from '../shipment/syncSummary.js';
@@ -20,7 +20,7 @@ function shape(row) {
export default function create(provider) { export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider); const access = resolveSelectedCompanyAccess(provider);
const allocateNumber = nextNumber(provider); const allocateNumber = nextNumber(provider);
const addLedger = ledgerAdd(provider); const ship = stockShip(provider);
const q = queriesFor(provider); const q = queriesFor(provider);
/** /**
@@ -104,42 +104,8 @@ export default function create(provider) {
}); });
const lines = linesRes.rows ?? []; const lines = linesRes.rows ?? [];
// Check which lines need warehouse stock deduction vs untracked history
const trackedLines = [];
const untrackedLines = [];
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount <= 0) {
continue;
}
const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
companyId,
sku,
});
const isUntracked = (trackRes.rows ?? []).some(
(r) => Number(r.track_stock ?? 1) === 0
);
if (isUntracked) {
untrackedLines.push({ ...line, amount });
} else if ((trackRes.rows ?? []).length > 0) {
trackedLines.push({ ...line, amount });
}
}
const manifestNumber = manifest.number || String(manifestId); const manifestNumber = manifest.number || String(manifestId);
let customerName = null;
if (manifest.entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
entityId: manifest.entityId,
});
customerName = entRes.rows[0]?.name ?? null;
}
// Book warehouse ledger movements for tracked lines
if (trackedLines.length > 0) {
let warehouseId = null; let warehouseId = null;
if (input.warehouseId != null) { if (input.warehouseId != null) {
const requestedWhId = requireId(input.warehouseId, 'Warehouse'); const requestedWhId = requireId(input.warehouseId, 'Warehouse');
@@ -155,27 +121,28 @@ export default function create(provider) {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId, companyId,
}); });
if (!defaultWh.rows[0]) { if (defaultWh.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = defaultWh.rows[0].id; warehouseId = defaultWh.rows[0].id;
} }
}
for (const line of trackedLines) { for (const line of lines) {
await addLedger( const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount === 0) {
continue;
}
await ship(
googleAuthId, googleAuthId,
{ {
manifestarticle_id: line.id, manifestarticleId: line.id,
warehouse_id: warehouseId, warehouseId,
transaction_type: 'SHIPPING',
amount: -line.amount,
transaction_text: `Versand Lieferschein ${manifestNumber}`,
}, },
innerTx, innerTx,
); );
} }
} }
}
await syncShipmentSummary(runner, { companyId, manifestId }); await syncShipmentSummary(runner, { companyId, manifestId });

View File

@@ -1,6 +1,6 @@
import { queriesFor, requireId, requireText } from '../logistics/helpers.js'; import { queriesFor, requireId, requireText } from '../logistics/helpers.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import ledgerAdd from '../ledger/add.js'; import stockShip from '../stock/ship.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
import { syncShipmentSummary } from '../shipment/syncSummary.js'; import { syncShipmentSummary } from '../shipment/syncSummary.js';
@@ -25,7 +25,7 @@ function shape(row) {
*/ */
export default function update(provider) { export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider); const access = resolveSelectedCompanyAccess(provider);
const addLedger = ledgerAdd(provider); const ship = stockShip(provider);
const q = queriesFor(provider); const q = queriesFor(provider);
/** /**
@@ -109,38 +109,8 @@ export default function update(provider) {
manifestId, manifestId,
}); });
const lines = linesRes.rows ?? []; const lines = linesRes.rows ?? [];
const trackedLines = [];
const untrackedLines = [];
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount <= 0) continue;
const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
companyId,
sku,
});
const isUntracked = (trackRes.rows ?? []).some(
(r) => Number(r.track_stock ?? 1) === 0
);
if (isUntracked) {
untrackedLines.push({ ...line, amount });
} else if ((trackRes.rows ?? []).length > 0) {
trackedLines.push({ ...line, amount });
}
}
const manifestNumber = manifest.number || String(manifestId); const manifestNumber = manifest.number || String(manifestId);
let customerName = null;
if (manifest.entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
entityId: manifest.entityId,
});
customerName = entRes.rows[0]?.name ?? null;
}
if (trackedLines.length > 0) {
let warehouseId = null; let warehouseId = null;
if (patch.warehouseId != null) { if (patch.warehouseId != null) {
const requestedWhId = requireId(patch.warehouseId, 'Warehouse'); const requestedWhId = requireId(patch.warehouseId, 'Warehouse');
@@ -156,28 +126,27 @@ export default function update(provider) {
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, { const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
companyId, companyId,
}); });
if (!defaultWh.rows[0]) { if (defaultWh.rows[0]) {
throw new Error('Warehouse not found');
}
warehouseId = defaultWh.rows[0].id; warehouseId = defaultWh.rows[0].id;
} }
}
for (const line of trackedLines) { for (const line of lines) {
await addLedger( const sku = line.sku ? String(line.sku).trim() : '';
const amount = Number(line.amount);
if (!sku || !Number.isFinite(amount) || amount === 0) continue;
await ship(
googleAuthId, googleAuthId,
{ {
manifestarticle_id: line.id, manifestarticleId: line.id,
warehouse_id: warehouseId, warehouseId,
transaction_type: 'SHIPPING',
amount: -line.amount,
transaction_text: `Versand Lieferschein ${manifestNumber}`,
}, },
innerTx, innerTx,
); );
} }
} }
} }
}
const updateRes = await runner.query(q.SHIPPING_UPDATE, { const updateRes = await runner.query(q.SHIPPING_UPDATE, {
id: shippingId, id: shippingId,

229
src/store/stock/accept.js Normal file
View File

@@ -0,0 +1,229 @@
import { syncDeliverySummary } from '../delivery/syncSummary.js';
import * as mssqlQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { recordAcceptanceHistory } from '../articlehistory/record.js';
import { computePrevHash, shape } from '../ledger/add.js';
import stockAdjust from './adjust.js';
import makeRunner from '../runner.js';
/**
* Accepts a delivery manifest item (or multiple delivery items).
*
* Requirements:
* - Every accepted delivery manifest item receives a document ledger entry:
* - If amount >= 0: transaction_type = 'ACCEPTANCE', amount = +amount, warehouse_id = null.
* - If amount < 0: transaction_type = 'RETURN', amount = amount (negative), warehouse_id = null.
* - Always records article history with delivery number, supplier, EK brutto, and quantity.
* - If the connected article has stock tracking (track_stock == 1):
* Calls adjust with the item's amount to adjust physical inventory in the target warehouse.
* - If the connected article does not have stock tracking:
* Only document acceptance is booked; no warehouse adjust is performed.
* - Double acceptance of an already accepted line is strictly prevented.
*
* @param {object} provider - The database provider.
* @returns {Function} Async function accepting delivery items.
*/
export default function accept(provider) {
const access = resolveSelectedCompanyAccess(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId, input = {}, tx) => {
let items = [];
if (Array.isArray(input.items)) {
items = input.items;
} else if (input.manifestarticleId != null || input.manifestarticle_id != null) {
items = [
{
manifestarticleId: input.manifestarticleId ?? input.manifestarticle_id,
warehouseId: input.warehouseId ?? input.warehouse_id,
text: input.text ?? input.transactionText ?? input.transaction_text,
},
];
} else if (input.deliveryId != null || input.delivery_id != null || input.id != null) {
const deliveryId = Number(input.deliveryId ?? input.delivery_id ?? input.id);
if (!Number.isInteger(deliveryId) || deliveryId <= 0) {
throw new Error('Invalid delivery ID');
}
const warehouseId = input.warehouseId ?? input.warehouse_id;
// We will resolve all lines for this delivery within the transaction
items = [{ deliveryId, warehouseId }];
} else {
throw new Error('manifestarticleId or items required');
}
if (items.length === 0) {
throw new Error('No items to accept');
}
const run = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
// If a deliveryId was provided, expand to all lines of that delivery
let resolvedItems = [];
for (const item of items) {
if (item.deliveryId) {
const linesRes = await runner.query(
`SELECT ma.id FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.id = @deliveryId AND m.company_id = @companyId AND m.type = 'delivery'
ORDER BY ma.id ASC`,
{ deliveryId: item.deliveryId, companyId },
);
if (linesRes.rows.length === 0) {
throw new Error('Delivery has no lines or delivery not found');
}
for (const line of linesRes.rows) {
resolvedItems.push({
manifestarticleId: line.id,
warehouseId: item.warehouseId,
text: item.text,
});
}
} else {
resolvedItems.push(item);
}
}
const results = [];
const touchedManifestIds = new Set();
for (const item of resolvedItems) {
const manifestarticleId = Number(item.manifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid manifest article id');
}
const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, {
manifestarticleId,
companyId,
});
const manifestArticle = check.rows[0];
if (!manifestArticle) {
throw new Error('Manifest article not found');
}
const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
if (manifestType !== 'delivery') {
throw new Error('Manifest article does not belong to a delivery');
}
// Prevent double booking on ledger
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Number(bookedCheck.rows?.[0]?.bookedAmount || 0);
if (bookedAmount !== 0) {
throw new Error('Delivery line already accepted on ledger');
}
const lineAmount = Number(manifestArticle.lineAmount || 0);
const deliveryNumber = manifestArticle.manifestNumber;
const isReturn = lineAmount < 0;
const docType = isReturn ? 'RETURN' : 'ACCEPTANCE';
const docAmount = lineAmount; // negative if return, positive if acceptance
const defaultDocText = isReturn
? (deliveryNumber ? `Lieferantenrücksendung ${deliveryNumber}` : 'Lieferantenrücksendung')
: (deliveryNumber ? `Lieferung ${deliveryNumber}` : 'Lieferung');
const docText = item.text?.trim?.() || defaultDocText;
// Insert document acceptance/return entry (warehouse_id = null)
const latest = await runner.query(queries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
const inserted = await runner.query(queries.INSERT_ROW, {
manifestarticleId,
warehouseId: null,
transactionType: docType,
amount: docAmount,
transactionText: docText,
hashVersion: 1,
prevHash,
});
const docRow = inserted.rows[0];
if (!docRow) {
throw new Error('Failed to insert ledger entry');
}
// Resolve connected articles by SKU
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
let articleMatches = [];
let isTracked = false;
if (sku) {
const trackRes = await runner.query(
`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))`,
{ companyId, sku },
);
articleMatches = trackRes.rows ?? [];
const trackedMatch = articleMatches.find((r) => Number(r.track_stock ?? 0) === 1);
if (trackedMatch) {
isTracked = true;
}
}
// Write article history for acceptance / return
for (const match of articleMatches) {
await recordAcceptanceHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: deliveryNumber,
supplierName: manifestArticle.supplierName,
net: manifestArticle.net,
rebate: manifestArticle.rebate,
taxrate: manifestArticle.taxrate,
amount: docAmount,
transactionText: docText,
});
}
// "accept also does adjust when the deliverymanifestitem'S connected article has stock tracking."
let adjustResult = null;
if (isTracked) {
adjustResult = await adjust(
googleAuthId,
{
manifestarticleId,
warehouseId: item.warehouseId,
amount: docAmount,
text: docText,
},
activeTx,
);
}
if (manifestArticle.manifestId) {
touchedManifestIds.add(manifestArticle.manifestId);
}
results.push({
manifestarticle_id: manifestarticleId,
document_ledger: shape(docRow),
adjust_ledger: adjustResult?.ledger ?? null,
is_tracked: isTracked,
});
}
// Sync deliverysummary for all affected manifests
for (const manifestId of touchedManifestIds) {
await syncDeliverySummary(runner, { companyId, manifestId });
}
return Array.isArray(input.items) || input.deliveryId != null || input.delivery_id != null || input.id != null
? { accepted: results }
: results[0];
};
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));
};
}

View File

@@ -1,8 +1,11 @@
import * as mssqlQueries from '../../providers/mssql/queries/manifestDoc.js'; import * as mssqlQueries from '../../providers/mssql/queries/manifestDoc.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/manifestDoc.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/manifestDoc.js';
import * as mssqlLedgerQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteLedgerQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { recordStockAdjustmentHistory } from '../articlehistory/record.js'; import { recordStockAdjustmentHistory, recordWarehouseHistory } from '../articlehistory/record.js';
import { computePrevHash, shape } from '../ledger/add.js';
import nextNumber from '../numbers/nextNumber.js'; import nextNumber from '../numbers/nextNumber.js';
import ledgerAdd from '../ledger/add.js'; import ledgerAdd from '../ledger/add.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
@@ -24,6 +27,7 @@ export default function adjust(provider) {
const addLedger = ledgerAdd(provider); const addLedger = ledgerAdd(provider);
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries; const queries = isSqlite ? sqliteQueries : mssqlQueries;
const ledgerQueries = isSqlite ? sqliteLedgerQueries : mssqlLedgerQueries;
/** /**
* Adjusts article stock manually by creating an implicit manifest of type "adjustment", * Adjusts article stock manually by creating an implicit manifest of type "adjustment",
@@ -42,6 +46,139 @@ export default function adjust(provider) {
* @returns {Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>} * @returns {Promise<{manifest: {id: number, number: string, type: string, date: string, createdAt: string}, lines: Array<object>, ledger: Array<object>}>}
*/ */
return async (googleAuthId, input = {}, tx) => { return async (googleAuthId, input = {}, tx) => {
const rawManifestarticleId = input.manifestarticleId ?? input.manifestarticle_id;
if (rawManifestarticleId != null) {
const manifestarticleId = Number(rawManifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid manifest article id');
}
const amount = Number(input.amount);
if (!Number.isFinite(amount) || amount === 0) {
throw new Error('Amount must be a non-zero number');
}
const runManifestarticleAdjust = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
const checkRes = await runner.query(
`SELECT ma.id, ma.manifest_id, ma.sku, ma.name, m.company_id, m.type AS manifest_type, m.number AS manifest_number
FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE ma.id = @manifestarticleId AND m.company_id = @companyId`,
{ manifestarticleId, companyId },
);
const manifestArticle = checkRes.rows[0];
if (!manifestArticle) {
throw new Error('Manifest article not found');
}
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
if (!sku) {
throw new Error('Manifest article has no SKU');
}
const trackRes = await runner.query(
`SELECT a.id, a.sku, a.track_stock, a.is_parent, a.articlelist_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 },
);
const art = trackRes.rows[0];
if (!art) {
throw new Error(`Article not found (${sku})`);
}
if (art.is_parent) {
throw new Error(`A parent article cannot have stock (${sku})`);
}
if (art.articlelist_id != null) {
throw new Error(`A Stückliste cannot have manual stock adjustments (${sku})`);
}
if (Number(art.track_stock ?? 1) === 0) {
throw new Error(`Stock tracking is disabled for article (${sku})`);
}
let warehouseId = input.warehouseId ?? input.warehouse_id;
let warehouseName = '';
if (warehouseId != null) {
warehouseId = Number(warehouseId);
if (!Number.isInteger(warehouseId) || warehouseId <= 0) {
throw new Error('Warehouse must be a positive integer');
}
const whCheck = await runner.query(
ledgerQueries.CHECK_WAREHOUSE_ACCESS,
{ warehouseId, companyId },
);
if (!whCheck.rows[0]) throw new Error('Warehouse not found');
warehouseName = whCheck.rows[0].name;
} else {
const defWh = await runner.query(
ledgerQueries.GET_DEFAULT_WAREHOUSE,
{ companyId },
);
if (!defWh.rows[0]) throw new Error('Warehouse not found');
warehouseId = defWh.rows[0].id;
warehouseName = defWh.rows[0].name;
}
const text = input.text ?? input.transactionText ?? input.transaction_text ??
(amount > 0 ? `Stock addition (+${amount})` : `Stock deduction (${amount})`);
const latest = await runner.query(ledgerQueries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
const inserted = await runner.query(ledgerQueries.INSERT_ROW, {
manifestarticleId,
warehouseId,
transactionType: 'WAREHOUSE',
amount,
transactionText: text,
hashVersion: 1,
prevHash,
});
const ledgerRow = inserted.rows[0];
// Write warehouse history for tracked article
if (manifestArticle.manifest_type === 'delivery') {
await recordWarehouseHistory(runner, {
articleId: art.id,
companyuserId,
manifestNumber: manifestArticle.manifest_number,
manifestType: 'delivery',
warehouseName,
amount,
transactionText: text,
});
} else if (manifestArticle.manifest_type === 'shipment') {
await recordWarehouseHistory(runner, {
articleId: art.id,
companyuserId,
manifestNumber: manifestArticle.manifest_number,
manifestType: 'shipment',
warehouseName,
amount,
transactionText: text,
});
}
return {
manifestarticle_id: manifestarticleId,
warehouse_id: warehouseId,
amount,
text,
ledger: shape(ledgerRow),
};
};
if (tx) return runManifestarticleAdjust(makeRunner(provider, tx), tx);
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, activeTx) => runManifestarticleAdjust(runner, activeTx));
}
return runManifestarticleAdjust(makeRunner(provider));
}
const rawLines = Array.isArray(input.lines) const rawLines = Array.isArray(input.lines)
? input.lines ? input.lines
: [ : [

229
src/store/stock/ship.js Normal file
View File

@@ -0,0 +1,229 @@
import { syncShipmentSummary } from '../shipment/syncSummary.js';
import * as mssqlQueries from '../../providers/mssql/queries/ledger.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { recordShipmentHistory } from '../articlehistory/record.js';
import { computePrevHash, shape } from '../ledger/add.js';
import stockAdjust from './adjust.js';
import makeRunner from '../runner.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.
*
* @param {object} provider - The database provider.
* @returns {Function} Async function shipping shipment items.
*/
export default function ship(provider) {
const access = resolveSelectedCompanyAccess(provider);
const adjust = stockAdjust(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
return async (googleAuthId, input = {}, tx) => {
let items = [];
if (Array.isArray(input.items)) {
items = input.items;
} else if (input.manifestarticleId != null || input.manifestarticle_id != null) {
items = [
{
manifestarticleId: input.manifestarticleId ?? input.manifestarticle_id,
warehouseId: input.warehouseId ?? input.warehouse_id,
text: input.text ?? input.transactionText ?? input.transaction_text,
},
];
} else if (input.shipmentId != null || input.shipment_id != null || input.id != null) {
const shipmentId = Number(input.shipmentId ?? input.shipment_id ?? input.id);
if (!Number.isInteger(shipmentId) || shipmentId <= 0) {
throw new Error('Invalid shipment ID');
}
const warehouseId = input.warehouseId ?? input.warehouse_id;
items = [{ shipmentId, warehouseId }];
} else {
throw new Error('manifestarticleId or items required');
}
if (items.length === 0) {
throw new Error('No items to ship');
}
const run = async (runner, activeTx) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
// If a shipmentId was provided, expand to all lines of that shipment
let resolvedItems = [];
for (const item of items) {
if (item.shipmentId) {
const linesRes = await runner.query(
`SELECT ma.id FROM manifestarticle ma
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.id = @shipmentId AND m.company_id = @companyId AND m.type = 'shipment'
ORDER BY ma.id ASC`,
{ shipmentId: item.shipmentId, companyId },
);
if (linesRes.rows.length === 0) {
throw new Error('Shipment has no lines or shipment not found');
}
for (const line of linesRes.rows) {
resolvedItems.push({
manifestarticleId: line.id,
warehouseId: item.warehouseId,
text: item.text,
});
}
} else {
resolvedItems.push(item);
}
}
const results = [];
const touchedManifestIds = new Set();
for (const item of resolvedItems) {
const manifestarticleId = Number(item.manifestarticleId);
if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) {
throw new Error('Invalid manifest article id');
}
const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, {
manifestarticleId,
companyId,
});
const manifestArticle = check.rows[0];
if (!manifestArticle) {
throw new Error('Manifest article not found');
}
const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
if (manifestType !== 'shipment') {
throw new Error('Manifest article does not belong to a shipment');
}
// Prevent double booking on ledger
const bookedCheck = await runner.query(queries.CHECK_MANIFESTARTICLE_BOOKED, { manifestarticleId });
const bookedAmount = Number(bookedCheck.rows?.[0]?.bookedAmount || 0);
if (bookedAmount !== 0) {
throw new Error('Shipment line already shipped on ledger');
}
const lineAmount = Number(manifestArticle.lineAmount || 0);
const shipmentNumber = manifestArticle.manifestNumber;
const isRetoure = lineAmount < 0;
const docType = isRetoure ? 'RETOURE' : 'SHIPMENT';
// Normal shipment: negative ledger amount (-Math.abs(lineAmount)).
// Retoure (customer return): positive ledger amount (+Math.abs(lineAmount)).
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;
// Insert document shipment/retoure entry (warehouse_id = null)
const latest = await runner.query(queries.GET_LATEST_ROW);
const prevRow = latest.rows[0];
const prevHash = computePrevHash(prevRow);
const inserted = await runner.query(queries.INSERT_ROW, {
manifestarticleId,
warehouseId: null,
transactionType: docType,
amount: docAmount,
transactionText: docText,
hashVersion: 1,
prevHash,
});
const docRow = inserted.rows[0];
if (!docRow) {
throw new Error('Failed to insert ledger entry');
}
// Resolve connected articles by SKU
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
let articleMatches = [];
let isTracked = false;
if (sku) {
const trackRes = await runner.query(
`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))`,
{ companyId, sku },
);
articleMatches = trackRes.rows ?? [];
const trackedMatch = articleMatches.find((r) => Number(r.track_stock ?? 0) === 1);
if (trackedMatch) {
isTracked = true;
}
}
// Write article history for shipment / retoure
for (const match of articleMatches) {
await recordShipmentHistory(runner, {
articleId: match.id,
companyuserId,
manifestNumber: shipmentNumber,
customerName: manifestArticle.supplierName, // in CHECK_MANIFESTARTICLE_ACCESS, e.name is aliased as supplierName / entity name
amount: docAmount,
transactionText: docText,
});
}
// "deliver also does adjust when the shipmentmanifestitem is connected to an article, and this article has stock tracking."
let adjustResult = null;
if (isTracked) {
adjustResult = await adjust(
googleAuthId,
{
manifestarticleId,
warehouseId: item.warehouseId,
amount: docAmount,
text: docText,
},
activeTx,
);
}
if (manifestArticle.manifestId) {
touchedManifestIds.add(manifestArticle.manifestId);
}
results.push({
manifestarticle_id: manifestarticleId,
document_ledger: shape(docRow),
adjust_ledger: adjustResult?.ledger ?? null,
is_tracked: isTracked,
});
}
// Sync shipmentsummary for all affected manifests
for (const manifestId of touchedManifestIds) {
await syncShipmentSummary(runner, { companyId, manifestId });
}
return Array.isArray(input.items) || input.shipmentId != null || input.shipment_id != null || input.id != null
? { shipped: results }
: results[0];
};
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));
};
}