u
This commit is contained in:
@@ -574,7 +574,7 @@ const article = await store.article.setPricesBySku(42, 'SKU-1', [{ gross: 11.9,
|
||||
|
||||
*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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
const article = await store.article.setTrackStockBySku(42, 'SKU-1', true);
|
||||
// => { id, sku, name, track_stock, ... }
|
||||
```
|
||||
|
||||
## `article.setWeightBySku`
|
||||
|
||||
*Source: `src/store/article/setWeightBySku.js`*
|
||||
|
||||
@@ -7,3 +7,61 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
|
||||
*Source: `src/store/delivery/attachments.js`*
|
||||
|
||||
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).
|
||||
|
||||
@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
|
||||
|
||||
*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**
|
||||
|
||||
@@ -23,23 +23,7 @@ Appends a new append-only entry to the ledger table.
|
||||
|
||||
**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.
|
||||
|
||||
**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',
|
||||
});
|
||||
```
|
||||
`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.
|
||||
|
||||
## `ledger.listByManifestarticleId`
|
||||
|
||||
|
||||
6
API.md
6
API.md
@@ -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}>`
|
||||
- [delivery](API.delivery.md)
|
||||
- `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)
|
||||
- `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>`
|
||||
@@ -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>}>`
|
||||
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>`
|
||||
- [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>}>`
|
||||
- `ship(provider: object): Function`
|
||||
- [posterminal](API.posterminal.md)
|
||||
- `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>`
|
||||
|
||||
32
API.stock.md
32
API.stock.md
@@ -2,6 +2,22 @@
|
||||
|
||||
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`
|
||||
|
||||
*Source: `src/store/stock/adjust.js`*
|
||||
@@ -26,3 +42,19 @@ Adjusts article stock manually by creating an implicit manifest of type "adjustm
|
||||
**Returns**
|
||||
|
||||
`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.
|
||||
|
||||
48
README.md
48
README.md
@@ -323,13 +323,31 @@ Company-scoped goods receipts on `manifest` / `manifestarticle` with fixed `type
|
||||
|
||||
| Method | Signature | Returns |
|
||||
| --- | --- | --- |
|
||||
| `list` | `(googleAuthId)` | `DeliverySummary[]` |
|
||||
| `list` | `(googleAuthId, opts?: DeliveryListOpts)` | `DeliverySummary[] | WindowedDeliveries` |
|
||||
| `getById` | `(googleAuthId, id)` | `Delivery` |
|
||||
| `create` | `(googleAuthId, { date, supplierId?, externalNumber?, lines })` | `Delivery` |
|
||||
| `update` | `(googleAuthId, id, { number?, date?, supplierId?, externalNumber?, lines? })` | `Delivery` |
|
||||
| `delete` | `(googleAuthId, id)` | `void` |
|
||||
| `rebuildSummaries` | `(googleAuthId)` | `{ ok: boolean, companyId: number }` |
|
||||
|
||||
```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 = {
|
||||
id: number,
|
||||
number: string,
|
||||
@@ -337,6 +355,31 @@ type DeliverySummary = {
|
||||
supplierId: number | null, // manifest.entity_id
|
||||
externalNumber: string | null, // manifest.external_number
|
||||
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 = {
|
||||
@@ -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)` |
|
||||
| `PUT /api/article/:sku/supplier/order` | `articlesupplier.order(googleAuthId, sku, { ids })` |
|
||||
| `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)` |
|
||||
| `POST /api/delivery` | `delivery.create(googleAuthId, { number, date, supplierId?, lines })` |
|
||||
| `PATCH /api/delivery/:id` | `delivery.update(googleAuthId, id, { number?, date?, supplierId?, lines? })` |
|
||||
|
||||
@@ -374,6 +374,10 @@ const CLUSTERS = [
|
||||
'manifest',
|
||||
'manifestarticle',
|
||||
'manifestattachment',
|
||||
'deliverysummary',
|
||||
'ordersummary',
|
||||
'quotesummary',
|
||||
'shipmentsummary',
|
||||
'ledger',
|
||||
'warehouse',
|
||||
'shipping',
|
||||
|
||||
269
schema-graph.dot
269
schema-graph.dot
File diff suppressed because one or more lines are too long
5874
schema-graph.svg
5874
schema-graph.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 386 KiB |
231
scripts/test-delivery-summary.js
Normal file
231
scripts/test-delivery-summary.js
Normal 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);
|
||||
});
|
||||
@@ -5,12 +5,10 @@ import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import LibDashStore from '../src/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() {
|
||||
console.log('--- Testing store.ledger (add, created_at, list queries) ---');
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'test-ledger-'));
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dash-ledger-test-'));
|
||||
const dbFile = join(tempDir, 'test.db');
|
||||
const storageDir = join(tempDir, 'storage');
|
||||
|
||||
@@ -18,50 +16,85 @@ async function run() {
|
||||
filename: dbFile,
|
||||
storageDir,
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
const store = await new LibDashStore(provider);
|
||||
|
||||
try {
|
||||
// 1. Setup user and company
|
||||
const googleAuthId = await store.google_auth.add('ledger-test@example.com');
|
||||
console.log('--- Testing store.ledger (acceptance, warehouse, shipment, guards) ---');
|
||||
const googleAuthId = await store.google_auth.add('ledger-tester@example.com');
|
||||
|
||||
const company = await store.company.create({
|
||||
name: 'Ledger Test Corp',
|
||||
name: 'Ledger Test GmbH',
|
||||
iso639: 'de',
|
||||
taxrate: 19,
|
||||
reducedtaxrate: 7,
|
||||
googleAuthId,
|
||||
});
|
||||
|
||||
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
|
||||
|
||||
await store.unit.upsert(googleAuthId, {
|
||||
si: 'stk',
|
||||
basesi: 'stk',
|
||||
basesifactor: 1,
|
||||
symbol: 'Stk',
|
||||
name: 'Stück',
|
||||
const supplier = await store.supplier.create(googleAuthId, {
|
||||
name: 'Supplier X',
|
||||
});
|
||||
const supplier = await store.supplier.create(googleAuthId, { name: 'Supplier X' });
|
||||
|
||||
// Create an article with track_stock = false initially
|
||||
const article = await store.article.createUnderParent({
|
||||
googleAuthId,
|
||||
parentId: company.rootCategoryId,
|
||||
name: 'Ledger Linked Article',
|
||||
track_stock: false,
|
||||
});
|
||||
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, {
|
||||
entityId: supplier.id,
|
||||
sku: 'SKU-ABC',
|
||||
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, {
|
||||
supplierId: supplier.id,
|
||||
externalNumber: 'DEL-1',
|
||||
@@ -78,7 +111,7 @@ async function run() {
|
||||
},
|
||||
{
|
||||
sku: 'SKU-ABC',
|
||||
name: 'Ledger Item 1 Batch 2',
|
||||
name: 'Ledger Item 2',
|
||||
pu: 1,
|
||||
amount: 10,
|
||||
net: 20,
|
||||
@@ -90,278 +123,311 @@ async function run() {
|
||||
|
||||
const manifestarticleId1 = delivery.lines[0].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)
|
||||
console.log('1. Inserting genesis ledger row...');
|
||||
// 2. Book line 1 for untracked article (acceptance only, warehouse_id is null)
|
||||
console.log('1. Booking acceptance for untracked article line 1...');
|
||||
const row1 = await store.ledger.add(googleAuthId, {
|
||||
manifestarticle_id: manifestarticleId1,
|
||||
transaction_type: 'GOODS_RECEIPT',
|
||||
transaction_type: 'ACCEPTANCE',
|
||||
amount: 5,
|
||||
transaction_text: 'Initial delivery movement',
|
||||
transaction_text: `Lieferung ${delivery.number}`,
|
||||
});
|
||||
|
||||
assert.equal(row1.id, 1);
|
||||
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.transaction_text, 'Initial delivery movement');
|
||||
assert.equal(row1.hash_version, 1);
|
||||
assert.equal(row1.prev_hash, INIT_HASH);
|
||||
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)
|
||||
const expectedHash2 = computePrevHash(row1);
|
||||
const manualHash2 = createHash('sha512')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
// Attempting to book line 1 again must be rejected
|
||||
await assert.rejects(
|
||||
async () => store.ledger.add(googleAuthId, {
|
||||
manifestarticle_id: manifestarticleId1,
|
||||
warehouse_id: null,
|
||||
transaction_type: 'GOODS_RECEIPT',
|
||||
transaction_type: 'ACCEPTANCE',
|
||||
amount: 5,
|
||||
transaction_text: 'Initial delivery movement',
|
||||
hash_version: 1,
|
||||
prev_hash: INIT_HASH,
|
||||
created_at: new Date(row1.created_at).toISOString(),
|
||||
transaction_text: 'Double booking attempt',
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
.digest('hex');
|
||||
assert.equal(expectedHash2, manualHash2);
|
||||
assert.equal(manualHash2.length, 128);
|
||||
/Delivery line already booked on ledger/,
|
||||
'Should reject booking an already booked delivery line',
|
||||
);
|
||||
|
||||
// 3. Test second row (line 1, different transaction type)
|
||||
console.log('2. Inserting second ledger row...');
|
||||
// 3. Create warehouse and enable tracking on the article
|
||||
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, {
|
||||
manifestarticleId: manifestarticleId1,
|
||||
transactionType: 'INVENTORY_ADJUST',
|
||||
amount: -1,
|
||||
transactionText: 'Damaged item written off',
|
||||
manifestarticle_id: manifestarticleId2,
|
||||
warehouse_id: wh1.id,
|
||||
transaction_type: 'ACCEPTANCE',
|
||||
amount: 10,
|
||||
transaction_text: `Lieferung ${delivery.number}`,
|
||||
});
|
||||
|
||||
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(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)
|
||||
console.log('3. Inserting third ledger row on line 2...');
|
||||
const expectedHash3 = computePrevHash(row2);
|
||||
const row3 = await store.ledger.add(googleAuthId, {
|
||||
manifestarticle_id: manifestarticleId2,
|
||||
transaction_type: 'GOODS_RECEIPT',
|
||||
amount: 10,
|
||||
transaction_text: 'Second line goods receipt',
|
||||
});
|
||||
// Query ledger lines for manifestarticle 2 to verify row 3 (WAREHOUSE entry)
|
||||
const line2Entries = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId2);
|
||||
assert.equal(line2Entries.length, 2, 'Line 2 must have 2 ledger entries (ACCEPTANCE + WAREHOUSE)');
|
||||
assert.equal(line2Entries[0].transaction_type, 'ACCEPTANCE');
|
||||
assert.equal(line2Entries[0].warehouse_id, null);
|
||||
assert.equal(line2Entries[1].transaction_type, 'WAREHOUSE');
|
||||
assert.equal(line2Entries[1].warehouse_id, wh1.id);
|
||||
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);
|
||||
assert.equal(row3.hash_version, 1);
|
||||
assert.equal(row3.prev_hash, expectedHash3);
|
||||
// 5. Check warehouse stock for article
|
||||
console.log('4. Checking warehouse stock calculation...');
|
||||
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
|
||||
console.log('4. Testing listByManifestarticleId...');
|
||||
const listLine1All = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId1);
|
||||
assert.equal(listLine1All.length, 2);
|
||||
assert.equal(listLine1All[0].id, 1);
|
||||
assert.equal(listLine1All[1].id, 2);
|
||||
assert.equal(listLine1All[0].sku, 'SKU-ABC');
|
||||
// 6. Check deliverysummary is_booked
|
||||
const updatedDelivery = await store.delivery.getById(googleAuthId, delivery.id);
|
||||
assert.equal(updatedDelivery.ledger.isBooked, true, 'Delivery should be fully booked');
|
||||
assert.equal(updatedDelivery.ledger.completelyOnLedgerPositions, 2);
|
||||
|
||||
const listLine1GoodsReceipt = await store.ledger.listByManifestarticleId(
|
||||
googleAuthId,
|
||||
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...');
|
||||
// 7. Check article history
|
||||
console.log('5. Verifying article history...');
|
||||
const history = await store.article.getHistoryBySku(googleAuthId, article.sku);
|
||||
const historyTexts = history.map((h) => h.text);
|
||||
console.log('Article history texts:', historyTexts);
|
||||
console.log('History entries:', historyTexts);
|
||||
|
||||
assert(
|
||||
historyTexts.some((t) => t.includes('Initial delivery movement') && 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',
|
||||
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')),
|
||||
'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(
|
||||
historyTexts.some((t) => t.includes('Second line goods receipt') && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')),
|
||||
'History should include second line goods receipt with details',
|
||||
historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')),
|
||||
'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
|
||||
console.log('8. Testing warehouse CRUD, sort, reorder, and ledger association...');
|
||||
const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' });
|
||||
assert.equal(wh1.name, 'Hauptlager');
|
||||
assert.equal(wh1.sort, 0);
|
||||
assert(wh1.id > 0);
|
||||
// 8. Test store.delivery.book on a fresh delivery
|
||||
console.log('6. Testing store.delivery.book batch booking...');
|
||||
const delivery2 = await store.delivery.create(googleAuthId, {
|
||||
supplierId: supplier.id,
|
||||
externalNumber: 'DEL-2',
|
||||
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' });
|
||||
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
|
||||
// Double booking delivery2 must reject
|
||||
await assert.rejects(
|
||||
async () => store.warehouse.order(googleAuthId, { warehouseIds: [wh1.id] }),
|
||||
/Invalid warehouse order/,
|
||||
async () => store.delivery.book(googleAuthId, { id: delivery2.id }),
|
||||
/Delivery line(s)? already (accepted|booked) on ledger/,
|
||||
'Should reject booking already booked delivery',
|
||||
);
|
||||
|
||||
// Verify article detail includes warehouses with sort
|
||||
const articleDetail = await store.article.getBySku(googleAuthId, article.sku);
|
||||
assert(Array.isArray(articleDetail.article.warehouses), 'Article should have warehouses array');
|
||||
assert.equal(articleDetail.article.warehouses.length, 2);
|
||||
assert.equal(articleDetail.article.warehouses[0].id, wh2.id);
|
||||
assert.equal(articleDetail.article.warehouses[0].sort, 0);
|
||||
assert.equal(articleDetail.article.warehouses[1].id, wh1.id);
|
||||
assert.equal(articleDetail.article.warehouses[1].sort, 1);
|
||||
// 9. Test shipment booking
|
||||
console.log('7. Testing shipment booking (SHIPMENT + WAREHOUSE dual entries)...');
|
||||
const customer = await store.entity.create(googleAuthId, {
|
||||
name: 'Customer Y',
|
||||
isCustomer: true,
|
||||
customergroupId: company.rootCategoryId,
|
||||
});
|
||||
|
||||
// Enable stock tracking now that warehouses exist
|
||||
await store.article.setTrackStockBySku(googleAuthId, article.sku, true);
|
||||
const order2 = await store.order.create(googleAuthId, {
|
||||
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 rowWithWh = await store.ledger.add(googleAuthId, {
|
||||
manifestarticle_id: manifestarticleId1,
|
||||
const shipment = await store.shipment.create(googleAuthId, {
|
||||
orderId: order2.id,
|
||||
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,
|
||||
transaction_type: 'DELIVERY',
|
||||
amount: 25,
|
||||
transaction_text: 'Delivered directly to Hauptlager',
|
||||
transaction_type: 'SHIPMENT',
|
||||
amount: 3,
|
||||
transaction_text: `Lieferschein ${shipment.number}`,
|
||||
});
|
||||
assert.equal(rowWithWh.warehouse_id, wh1.id);
|
||||
assert.equal(rowWithWh.amount, 25);
|
||||
|
||||
// Filter by warehouse in listBySku and listByManifestarticleId
|
||||
const whRowsBySku = await store.ledger.listBySku(googleAuthId, 'SKU-ABC', { warehouse_id: wh1.id });
|
||||
assert.equal(whRowsBySku.length, 1);
|
||||
assert.equal(whRowsBySku[0].id, rowWithWh.id);
|
||||
assert.equal(whRowsBySku[0].warehouse_id, wh1.id);
|
||||
assert.equal(shipRow.transaction_type, 'SHIPMENT');
|
||||
assert.equal(shipRow.warehouse_id, null);
|
||||
assert.equal(shipRow.amount, -3);
|
||||
|
||||
const whRowsByLine = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId1, { warehouse_id: wh1.id });
|
||||
assert.equal(whRowsByLine.length, 1);
|
||||
assert.equal(whRowsByLine[0].id, rowWithWh.id);
|
||||
const shipEntries = await store.ledger.listByManifestarticleId(googleAuthId, shipmentLineId);
|
||||
assert.equal(shipEntries.length, 2, 'Shipment line must have 2 entries (SHIPMENT + WAREHOUSE)');
|
||||
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
|
||||
const historyWithWh = await store.article.getHistoryBySku(googleAuthId, article.sku);
|
||||
// Stock should now be: 10 (line 2) + 4 (delivery2) - 3 (shipment) = 11
|
||||
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(
|
||||
historyWithWh.some((h) => h.text.includes('Lager: "Hauptlager"') && h.text.includes('Lieferant: "Supplier X"')),
|
||||
'History should include warehouse name (Lager: "Hauptlager")',
|
||||
shipHistoryTexts.some((t) => t.includes(`Lieferschein ${shipment.number}`) && t.includes('Menge: 3')),
|
||||
'Article history should record outgoing shipment',
|
||||
);
|
||||
|
||||
// Trying to delete wh1 (used in ledger) must fail
|
||||
await assert.rejects(
|
||||
async () => store.warehouse.delete(googleAuthId, wh1.id),
|
||||
/Warehouse in use in ledger/,
|
||||
'Should reject deleting warehouse referenced in ledger',
|
||||
);
|
||||
|
||||
// Deleting wh2 (not used in ledger) must succeed
|
||||
await store.warehouse.delete(googleAuthId, wh2.id);
|
||||
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,
|
||||
// 8. Test store.stock.accept with deliverymanifestitem
|
||||
console.log('8. Testing store.stock.accept (positive acceptance & negative return)...');
|
||||
const delivery3 = await store.delivery.create(googleAuthId, {
|
||||
supplierId: supplier.id,
|
||||
externalNumber: 'DEL-3',
|
||||
date: new Date().toISOString(),
|
||||
lines: [
|
||||
{ sku: 'SKU-ABC', name: 'Positive Delivery Line', pu: 1, amount: 6, net: 10, taxrate: 19 },
|
||||
{ sku: 'SKU-ABC', name: 'Negative Delivery Line (Return)', pu: 1, amount: -2, net: 10, taxrate: 19 },
|
||||
],
|
||||
});
|
||||
await store.google_auth.setSelectedCompany(googleAuthId, freshCompany.id);
|
||||
const testWh = await store.warehouse.create(googleAuthId, { name: 'Only Warehouse' });
|
||||
const testArt = await store.article.createUnderParent({
|
||||
googleAuthId,
|
||||
parentId: freshCompany.rootCategoryId,
|
||||
name: 'Tracked Article',
|
||||
track_stock: true,
|
||||
|
||||
const posDeliveryLineId = delivery3.lines[0].id;
|
||||
const negDeliveryLineId = delivery3.lines[1].id;
|
||||
|
||||
const acceptPosResult = await store.stock.accept(googleAuthId, {
|
||||
manifestarticleId: posDeliveryLineId,
|
||||
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(
|
||||
async () => store.warehouse.delete(googleAuthId, testWh.id),
|
||||
/Cannot delete the last warehouse while articles have stock tracking enabled/,
|
||||
'Should reject deleting last warehouse when articles have track_stock enabled',
|
||||
async () => store.stock.adjust(googleAuthId, {
|
||||
sku: untrackedArt.sku,
|
||||
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
|
||||
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
|
||||
// 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized
|
||||
console.log('11. Testing zero-adjustment before stock tracking is disabled...');
|
||||
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!');
|
||||
} finally {
|
||||
|
||||
@@ -140,16 +140,22 @@ async function run() {
|
||||
|
||||
// Assert ledger entries
|
||||
const ledgerRows = await store.ledger.listBySku(googleAuthId, trackedArt.sku);
|
||||
assert.equal(ledgerRows.length, 2, 'Should have 2 ledger entries for tracked article (adjust + shipping)');
|
||||
const shippingLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPPING');
|
||||
assert(shippingLedger, 'Shipping ledger entry should exist');
|
||||
assert.equal(shippingLedger.amount, -3, 'Shipping ledger entry should be -3');
|
||||
assert.equal(ledgerRows.length, 3, 'Should have 3 ledger entries for tracked article (adjust + shipment doc + warehouse)');
|
||||
const shipmentDocLedger = ledgerRows.find((l) => l.transaction_type === 'SHIPMENT' && l.warehouse_id == null);
|
||||
assert(shipmentDocLedger, 'Shipment document ledger entry should exist with warehouse_id null');
|
||||
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);
|
||||
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);
|
||||
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)');
|
||||
@@ -159,10 +165,10 @@ async function run() {
|
||||
assert.equal(parsedMeta.type, 'order');
|
||||
assert.equal(parsedMeta.orderId, order.id);
|
||||
assert.equal(parsedMeta.gross, 59.5);
|
||||
const shippingHistory = history.find((h) => h.text.includes('Versand Auftrag'));
|
||||
assert(!shippingHistory, 'Article history should NOT report the shipment');
|
||||
const shipmentHistory = history.find((h) => h.text.startsWith(`Lieferschein ${shipment.number}`) && h.text.includes('Kunde: "Max Mustermann"'));
|
||||
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);
|
||||
console.log('Untracked article history:', untrackedHistory.map((h) => h.text));
|
||||
assert(
|
||||
@@ -170,8 +176,8 @@ async function run() {
|
||||
'Untracked article history should contain order creation entry with formatted gross price',
|
||||
);
|
||||
assert(
|
||||
!untrackedHistory.some((h) => h.text.includes('Versand Auftrag')),
|
||||
'Untracked article history should NOT report the shipment',
|
||||
untrackedHistory.some((h) => h.text.startsWith(`Lieferschein ${shipment.number}`)),
|
||||
'Untracked article history should report the shipment',
|
||||
);
|
||||
|
||||
// Test order import with historical date preserves date in article history
|
||||
|
||||
@@ -90,6 +90,8 @@ import deliveryGetById from './store/delivery/getById.js';
|
||||
import deliveryCreate from './store/delivery/create.js';
|
||||
import deliveryUpdate from './store/delivery/update.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 quoteGetById from './store/quote/getById.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 labeltemplateDelete from './store/labeltemplate/delete.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 propertynameCreate from './store/propertyname/create.js';
|
||||
import propertynameUpdate from './store/propertyname/update.js';
|
||||
@@ -422,6 +426,8 @@ export default class LibDashStore {
|
||||
create: deliveryCreate(provider),
|
||||
update: deliveryUpdate(provider),
|
||||
delete: deliveryDelete(provider),
|
||||
book: deliveryBook(provider),
|
||||
rebuildSummaries: deliveryRebuildSummaries(provider),
|
||||
};
|
||||
|
||||
this.quote = {
|
||||
@@ -588,6 +594,8 @@ export default class LibDashStore {
|
||||
|
||||
this.stock = {
|
||||
adjust: stockAdjust(provider),
|
||||
accept: stockAccept(provider),
|
||||
ship: stockShip(provider),
|
||||
};
|
||||
this.adjustment = this.stock;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Drop order = reverse of schema create dependency order. */
|
||||
export const TABLES = [
|
||||
|
||||
'deliverysummary',
|
||||
'shipmentsummary',
|
||||
'quotesummary',
|
||||
'ordersummary',
|
||||
|
||||
@@ -658,3 +658,21 @@ SELECT TOP 1 id
|
||||
FROM warehouse
|
||||
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;
|
||||
`;
|
||||
|
||||
@@ -23,6 +23,20 @@ FROM warehouse
|
||||
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 = `
|
||||
SELECT DISTINCT asup.article_id AS articleId
|
||||
FROM articlesupplier asup
|
||||
|
||||
@@ -1278,6 +1278,49 @@ BEGIN
|
||||
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 = `
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
|
||||
BEGIN
|
||||
@@ -1403,6 +1446,7 @@ export const SCHEMA_SCRIPTS = [
|
||||
INIT_ORDERSUMMARY,
|
||||
INIT_QUOTESUMMARY,
|
||||
INIT_SHIPMENTSUMMARY,
|
||||
INIT_DELIVERYSUMMARY,
|
||||
INIT_MCP_CLIENT,
|
||||
INIT_MCP_AUTH_CODE,
|
||||
INIT_MCP_TOKEN,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { translateQueries } from './translate.js';
|
||||
|
||||
const queries = translateQueries(source, 'article');
|
||||
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,
|
||||
GET_BRAND_BY_COMPANY_AND_ID,
|
||||
GET_CATEGORY_ID_BY_SKU, GET_CATEGORY_TREE_FLAT, GET_ID_BY_SKU,
|
||||
|
||||
@@ -23,6 +23,21 @@ FROM warehouse
|
||||
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 = `
|
||||
SELECT DISTINCT asup.article_id AS articleId
|
||||
FROM articlesupplier asup
|
||||
|
||||
@@ -931,6 +931,42 @@ CREATE INDEX IF NOT EXISTS IX_shipmentsummary_company_order
|
||||
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 = `
|
||||
CREATE TABLE IF NOT EXISTS mcp_client (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1042,6 +1078,7 @@ export const SCHEMA_SCRIPTS = [
|
||||
INIT_ORDERSUMMARY,
|
||||
INIT_QUOTESUMMARY,
|
||||
INIT_SHIPMENTSUMMARY,
|
||||
INIT_DELIVERYSUMMARY,
|
||||
INIT_MCP_CLIENT,
|
||||
INIT_MCP_AUTH_CODE,
|
||||
INIT_MCP_TOKEN,
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import {
|
||||
UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK,
|
||||
CHECK_HAS_WAREHOUSE as MSSQL_CHECK_HAS_WAREHOUSE,
|
||||
CHECK_ARTICLE_WAREHOUSE_STOCKS as MSSQL_CHECK_ARTICLE_WAREHOUSE_STOCKS,
|
||||
} from '../../providers/mssql/queries/article.js';
|
||||
import {
|
||||
UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK,
|
||||
CHECK_HAS_WAREHOUSE as SQLITE_CHECK_HAS_WAREHOUSE,
|
||||
CHECK_ARTICLE_WAREHOUSE_STOCKS as SQLITE_CHECK_ARTICLE_WAREHOUSE_STOCKS,
|
||||
} from '../../providers/sqlite3/queries/article.js';
|
||||
import queryBySku from './queryBySku.js';
|
||||
import resolveArticleBySku from './resolveArticleBySku.js';
|
||||
import { recordTrackStockChangeHistory } from '../articlehistory/record.js';
|
||||
import stockAdjust from '../stock/adjust.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
export default function setTrackStockBySku(provider) {
|
||||
const resolve = resolveArticleBySku(provider);
|
||||
const load = queryBySku(provider);
|
||||
const adjust = stockAdjust(provider);
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const sql = isSqlite ? SQLITE_UPDATE_TRACK_STOCK : MSSQL_UPDATE_TRACK_STOCK;
|
||||
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.
|
||||
* 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 {string} sku - The article SKU.
|
||||
@@ -27,17 +34,13 @@ export default function setTrackStockBySku(provider) {
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<object>} The updated article.
|
||||
* @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) => {
|
||||
if (typeof trackStock !== 'boolean' && trackStock !== 0 && trackStock !== 1) {
|
||||
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 newTrack = Boolean(trackStock);
|
||||
@@ -55,6 +58,31 @@ export default function setTrackStockBySku(provider) {
|
||||
);
|
||||
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, {
|
||||
articleId,
|
||||
trackStock: trackStock ? 1 : 0,
|
||||
@@ -65,12 +93,14 @@ export default function setTrackStockBySku(provider) {
|
||||
}
|
||||
|
||||
// Write history if track_stock changed
|
||||
if (oldTrack !== newTrack) {
|
||||
await recordTrackStockChangeHistory(runner, {
|
||||
articleId,
|
||||
companyuserId,
|
||||
oldTrack,
|
||||
newTrack,
|
||||
});
|
||||
}
|
||||
|
||||
const article = await load(articleId, languageId, runner);
|
||||
if (!article) {
|
||||
@@ -79,4 +109,11 @@ export default function setTrackStockBySku(provider) {
|
||||
|
||||
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));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 queryBySku from './queryBySku.js';
|
||||
import resolveArticleBySku from './resolveArticleBySku.js';
|
||||
import setTrackStockBySku from './setTrackStockBySku.js';
|
||||
import makeRunner from '../runner.js';
|
||||
import resolveUniqueSlug from './resolveUniqueSlug.js';
|
||||
import { recordArticleUpdateHistory } from '../articlehistory/record.js';
|
||||
@@ -24,6 +25,7 @@ export default function updateBySku(provider) {
|
||||
const resolve = resolveArticleBySku(provider);
|
||||
const load = queryBySku(provider);
|
||||
const resolveSlug = resolveUniqueSlug(provider);
|
||||
const setTrackStock = setTrackStockBySku(provider);
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
|
||||
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
|
||||
@@ -219,10 +221,7 @@ export default function updateBySku(provider) {
|
||||
}
|
||||
|
||||
if (hasTrackStock) {
|
||||
await runner.query(articleQueries.UPDATE_TRACK_STOCK, {
|
||||
articleId,
|
||||
trackStock: Boolean(rawTrackStock) ? 1 : 0,
|
||||
});
|
||||
await setTrackStock(googleAuthId, sku, Boolean(rawTrackStock), activeTx);
|
||||
}
|
||||
|
||||
if (historyLines.length) {
|
||||
|
||||
@@ -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, {
|
||||
articleId,
|
||||
@@ -165,6 +316,21 @@ export async function recordDeliveryHistory(runner, {
|
||||
transactionText,
|
||||
isDelivery = true,
|
||||
}) {
|
||||
if (isDelivery && !warehouseName) {
|
||||
return recordAcceptanceHistory(runner, {
|
||||
articleId,
|
||||
companyuserId,
|
||||
date,
|
||||
manifestNumber,
|
||||
supplierName,
|
||||
net,
|
||||
rebate,
|
||||
taxrate,
|
||||
amount,
|
||||
transactionText,
|
||||
});
|
||||
}
|
||||
|
||||
let historyText = transactionText;
|
||||
let ekBrutto = null;
|
||||
|
||||
@@ -189,9 +355,7 @@ export async function recordDeliveryHistory(runner, {
|
||||
details.push(`Menge: ${amount}`);
|
||||
}
|
||||
const suffix = details.length ? ` (${details.join(', ')})` : '';
|
||||
const base =
|
||||
transactionText?.trim() ||
|
||||
(manifestNumber ? `Lieferung ${manifestNumber}` : 'Lieferung');
|
||||
const base = manifestNumber ? `Lieferung ${manifestNumber}` : (transactionText?.trim() || 'Lieferung');
|
||||
historyText = `${base}${suffix}`;
|
||||
} else if (warehouseName && transactionText && !transactionText.includes(warehouseName)) {
|
||||
historyText = `${transactionText} (Lager: "${warehouseName}")`;
|
||||
@@ -200,6 +364,7 @@ export async function recordDeliveryHistory(runner, {
|
||||
const meta = {
|
||||
type: isDelivery ? 'delivery' : 'movement',
|
||||
manifestNumber: manifestNumber ?? null,
|
||||
deliveryNumber: manifestNumber ?? null,
|
||||
supplierName: supplierName ?? null,
|
||||
warehouseName: warehouseName ?? null,
|
||||
amount: amount != null ? Number(amount) : null,
|
||||
|
||||
48
src/store/delivery/book.js
Normal file
48
src/store/delivery/book.js
Normal 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));
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { syncDeliverySummary } from './syncSummary.js';
|
||||
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
|
||||
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
|
||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
||||
@@ -73,6 +74,7 @@ export default function create(provider) {
|
||||
body.files,
|
||||
);
|
||||
|
||||
await syncDeliverySummary(runner, { companyId, manifestId });
|
||||
return loadDelivery(provider, tx, companyId, manifestId);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function del(provider) {
|
||||
const runner = makeRunner(provider);
|
||||
|
||||
try {
|
||||
await runner.query('DELETE FROM deliverysummary WHERE manifest_id = @id AND company_id = @companyId', { id, companyId });
|
||||
const result = await runner(queries.DELETE_MANIFEST, {
|
||||
id,
|
||||
companyId,
|
||||
|
||||
@@ -17,12 +17,20 @@ export function shapeSummary(row) {
|
||||
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 = {
|
||||
totalPositions: Number(row.totalPositions ?? 0),
|
||||
completelyOnLedgerPositions: Number(row.completelyOnLedgerPositions ?? 0),
|
||||
totalPositions,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,30 +10,317 @@ export default function list(provider) {
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const queries = isSqlite ? sqliteQueries : mssqlQueries;
|
||||
|
||||
return async (googleAuthId) => {
|
||||
const { companyId } = await access(googleAuthId);
|
||||
const runner = makeRunner(provider);
|
||||
const sqlCountManifests = isSqlite
|
||||
? 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND "type" = @type'
|
||||
: '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,
|
||||
type: queries.DELIVERY_TYPE,
|
||||
});
|
||||
};
|
||||
|
||||
const filesResult = await runner(queries.LIST_DELIVERY_FILES, {
|
||||
companyId,
|
||||
type: queries.DELIVERY_TYPE,
|
||||
});
|
||||
// Date cutoff via seekMonths (strictly integer)
|
||||
if (opts?.seekMonths !== undefined) {
|
||||
if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) {
|
||||
throw new Error('Invalid seekMonths');
|
||||
}
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - Math.abs(opts.seekMonths));
|
||||
params.dateTo = d.toISOString();
|
||||
conditions.push(`m.${isSqlite ? '"date"' : '[date]'} <= @dateTo`);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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) {
|
||||
const list = filesByManifest.get(row.manifestId) ?? [];
|
||||
list.push(shapeAttachmentMeta(row));
|
||||
filesByManifest.set(row.manifestId, list);
|
||||
}
|
||||
}
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
const deliveries = manifestRows.map((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) ?? [],
|
||||
}));
|
||||
|
||||
if (isPaged) {
|
||||
return {
|
||||
deliveries,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return deliveries;
|
||||
};
|
||||
}
|
||||
|
||||
19
src/store/delivery/rebuildSummaries.js
Normal file
19
src/store/delivery/rebuildSummaries.js
Normal 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 };
|
||||
};
|
||||
}
|
||||
445
src/store/delivery/syncSummary.js
Normal file
445
src/store/delivery/syncSummary.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { syncDeliverySummary } from './syncSummary.js';
|
||||
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
|
||||
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.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);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { syncDeliverySummary } from '../delivery/syncSummary.js';
|
||||
import { syncShipmentSummary } from '../shipment/syncSummary.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
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 { recordDeliveryHistory } from '../articlehistory/record.js';
|
||||
import {
|
||||
recordAcceptanceHistory,
|
||||
recordShipmentHistory,
|
||||
recordWarehouseHistory,
|
||||
} from '../articlehistory/record.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
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 {object} input - Ledger entry payload.
|
||||
@@ -60,17 +70,7 @@ export function computePrevHash(row) {
|
||||
* @param {number} input.amount - Movement amount.
|
||||
* @param {string} input.transaction_text - Transaction memo/details (can also pass transactionText).
|
||||
* @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.
|
||||
* @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',
|
||||
* });
|
||||
* @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.
|
||||
*/
|
||||
export default function add(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
@@ -120,7 +120,7 @@ export default function add(provider) {
|
||||
const transactionText = String(rawTransactionText);
|
||||
|
||||
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, {
|
||||
manifestarticleId,
|
||||
@@ -132,48 +132,25 @@ export default function add(provider) {
|
||||
throw new Error('Manifest article not found');
|
||||
}
|
||||
|
||||
// Stock guard: warehouse-scoped ledger entries affect article stock
|
||||
// (stock is SUM(ledger.amount) GROUP BY warehouse). Reject bookings for
|
||||
// articles with stock tracking disabled before anything is written.
|
||||
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})`);
|
||||
}
|
||||
const manifestType = String(manifestArticle.manifestType || '').toLowerCase();
|
||||
if (manifestType === 'quote') {
|
||||
throw new Error('Cannot book ledger entries for quote');
|
||||
}
|
||||
if (manifestType === 'order') {
|
||||
throw new Error('Cannot book ledger entries for order');
|
||||
}
|
||||
|
||||
let warehouseName = null;
|
||||
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 insertLedgerRow = async ({ whId, type, rowAmount, memo }) => {
|
||||
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,
|
||||
transactionType,
|
||||
amount,
|
||||
transactionText,
|
||||
warehouseId: whId,
|
||||
transactionType: type,
|
||||
amount: rowAmount,
|
||||
transactionText: memo,
|
||||
hashVersion: 1,
|
||||
prevHash,
|
||||
});
|
||||
@@ -182,49 +159,204 @@ export default function add(provider) {
|
||||
if (!row) {
|
||||
throw new Error('Failed to insert ledger entry');
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : '';
|
||||
let isTracked = true;
|
||||
let articleMatches = [];
|
||||
if (sku) {
|
||||
const articleMatches = await runner.query(queries.FIND_ARTICLES_FOR_HISTORY, {
|
||||
sku,
|
||||
companyId,
|
||||
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 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();
|
||||
const isDelivery =
|
||||
upperType === 'DELIVERY' ||
|
||||
upperType === 'GOODS_RECEIPT' ||
|
||||
(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,
|
||||
// Record history for acceptance
|
||||
for (const match of articleMatches) {
|
||||
await recordAcceptanceHistory(runner, {
|
||||
articleId: match.id,
|
||||
companyuserId,
|
||||
manifestNumber: manifestArticle.manifestNumber,
|
||||
manifestNumber: deliveryNumber,
|
||||
supplierName: manifestArticle.supplierName,
|
||||
warehouseName,
|
||||
net: manifestArticle.net,
|
||||
rebate: manifestArticle.rebate,
|
||||
taxrate: manifestArticle.taxrate,
|
||||
amount,
|
||||
transactionText,
|
||||
isDelivery,
|
||||
amount: Math.abs(amount),
|
||||
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: 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function create(provider) {
|
||||
let amount;
|
||||
if (line.amount !== undefined && line.amount !== null) {
|
||||
amount = Number(line.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
if (!Number.isFinite(amount) || amount === 0) {
|
||||
throw new Error('Invalid lines');
|
||||
}
|
||||
} else {
|
||||
@@ -172,7 +172,7 @@ export default function create(provider) {
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
proposedBySku.set(sku, currentProposed + amount);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queriesFor, requireId, requireText } from '../logistics/helpers.js';
|
||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.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 { syncShipmentSummary } from '../shipment/syncSummary.js';
|
||||
|
||||
@@ -20,7 +20,7 @@ function shape(row) {
|
||||
export default function create(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
const allocateNumber = nextNumber(provider);
|
||||
const addLedger = ledgerAdd(provider);
|
||||
const ship = stockShip(provider);
|
||||
const q = queriesFor(provider);
|
||||
|
||||
/**
|
||||
@@ -104,42 +104,8 @@ export default function create(provider) {
|
||||
});
|
||||
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);
|
||||
|
||||
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;
|
||||
if (input.warehouseId != null) {
|
||||
const requestedWhId = requireId(input.warehouseId, 'Warehouse');
|
||||
@@ -155,27 +121,28 @@ export default function create(provider) {
|
||||
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
|
||||
companyId,
|
||||
});
|
||||
if (!defaultWh.rows[0]) {
|
||||
throw new Error('Warehouse not found');
|
||||
}
|
||||
if (defaultWh.rows[0]) {
|
||||
warehouseId = defaultWh.rows[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of trackedLines) {
|
||||
await addLedger(
|
||||
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;
|
||||
}
|
||||
|
||||
await ship(
|
||||
googleAuthId,
|
||||
{
|
||||
manifestarticle_id: line.id,
|
||||
warehouse_id: warehouseId,
|
||||
transaction_type: 'SHIPPING',
|
||||
amount: -line.amount,
|
||||
transaction_text: `Versand Lieferschein ${manifestNumber}`,
|
||||
manifestarticleId: line.id,
|
||||
warehouseId,
|
||||
},
|
||||
innerTx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await syncShipmentSummary(runner, { companyId, manifestId });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queriesFor, requireId, requireText } from '../logistics/helpers.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 { syncShipmentSummary } from '../shipment/syncSummary.js';
|
||||
|
||||
@@ -25,7 +25,7 @@ function shape(row) {
|
||||
*/
|
||||
export default function update(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
const addLedger = ledgerAdd(provider);
|
||||
const ship = stockShip(provider);
|
||||
const q = queriesFor(provider);
|
||||
|
||||
/**
|
||||
@@ -109,38 +109,8 @@ export default function update(provider) {
|
||||
manifestId,
|
||||
});
|
||||
const lines = linesRes.rows ?? [];
|
||||
const trackedLines = [];
|
||||
const untrackedLines = [];
|
||||
for (const line of lines) {
|
||||
const sku = line.sku ? String(line.sku).trim() : '';
|
||||
const amount = Number(line.amount);
|
||||
if (!sku || !Number.isFinite(amount) || amount <= 0) continue;
|
||||
|
||||
const trackRes = await runner.query(q.CHECK_ARTICLE_TRACK_STOCK, {
|
||||
companyId,
|
||||
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);
|
||||
|
||||
let customerName = null;
|
||||
if (manifest.entityId) {
|
||||
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', {
|
||||
entityId: manifest.entityId,
|
||||
});
|
||||
customerName = entRes.rows[0]?.name ?? null;
|
||||
}
|
||||
|
||||
if (trackedLines.length > 0) {
|
||||
let warehouseId = null;
|
||||
if (patch.warehouseId != null) {
|
||||
const requestedWhId = requireId(patch.warehouseId, 'Warehouse');
|
||||
@@ -156,28 +126,27 @@ export default function update(provider) {
|
||||
const defaultWh = await runner.query(q.GET_DEFAULT_WAREHOUSE, {
|
||||
companyId,
|
||||
});
|
||||
if (!defaultWh.rows[0]) {
|
||||
throw new Error('Warehouse not found');
|
||||
}
|
||||
if (defaultWh.rows[0]) {
|
||||
warehouseId = defaultWh.rows[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of trackedLines) {
|
||||
await addLedger(
|
||||
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;
|
||||
|
||||
await ship(
|
||||
googleAuthId,
|
||||
{
|
||||
manifestarticle_id: line.id,
|
||||
warehouse_id: warehouseId,
|
||||
transaction_type: 'SHIPPING',
|
||||
amount: -line.amount,
|
||||
transaction_text: `Versand Lieferschein ${manifestNumber}`,
|
||||
manifestarticleId: line.id,
|
||||
warehouseId,
|
||||
},
|
||||
innerTx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateRes = await runner.query(q.SHIPPING_UPDATE, {
|
||||
id: shippingId,
|
||||
|
||||
229
src/store/stock/accept.js
Normal file
229
src/store/stock/accept.js
Normal 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));
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as mssqlQueries from '../../providers/mssql/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 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 ledgerAdd from '../ledger/add.js';
|
||||
import makeRunner from '../runner.js';
|
||||
@@ -24,6 +27,7 @@ export default function adjust(provider) {
|
||||
const addLedger = ledgerAdd(provider);
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const queries = isSqlite ? sqliteQueries : mssqlQueries;
|
||||
const ledgerQueries = isSqlite ? sqliteLedgerQueries : mssqlLedgerQueries;
|
||||
|
||||
/**
|
||||
* 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>}>}
|
||||
*/
|
||||
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)
|
||||
? input.lines
|
||||
: [
|
||||
|
||||
229
src/store/stock/ship.js
Normal file
229
src/store/stock/ship.js
Normal 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));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user