This commit is contained in:
seb
2026-10-15 15:54:59 +02:00
parent f38d1f11a6
commit 7c8c24eb4d
5 changed files with 338 additions and 0 deletions

1
API.md
View File

@@ -184,6 +184,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `update(googleAuthId: number, id: number, patch: {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}>}>`
- [order](API.order.md)
- `create(googleAuthId: number, body: {number: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `createFromQuote(googleAuthId: number, quoteId: number, body?: {number?: string, date?: string, createdAt?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number}, tx?: object): Promise<{orders: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>`

View File

@@ -36,6 +36,42 @@ const order = await store.order.create(42, { number: 'B-2026-001', date: '2026-0
// => { id, number, type, entityId, externalNumber, date, lines: [...] }
```
## `order.createFromQuote`
*Source: `src/store/order/createFromQuote.js`*
Creates a new sales order from an existing quote for the user's selected company. Copies lines, entity, and external number from the quote unless explicitly overridden. If order number is omitted, it is auto-allocated from the 'order' number sequence.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `quoteId` | `number` | The id of the source quote. |
| `[body]` | `object` | Optional overrides for the order. |
| `[body.number]` | `string` | The order number; auto-allocated from sequence if omitted. |
| `[body.date]` | `string` | The order date; defaults to current date if omitted. |
| `[body.createdAt]` | `string` | Creation timestamp; defaults to current time if omitted. |
| `[body.entityId]` | `number` | Customer entity id override; defaults to quote's entityId. |
| `[body.externalNumber]` | `string` | Customer reference override; defaults to quote's externalNumber. |
| `[body.lines]` | `Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>` | Lines override; defaults to quote's lines. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` — The created order.
**Throws**
`Error` — If the quote is not found, or the order is invalid.
**Example**
```js
const order = await store.order.createFromQuote(42, 10);
// => { id, number, type: 'order', entityId, externalNumber, date, lines: [...] }
```
## `order.delete`
*Source: `src/store/order/delete.js`*

View File

@@ -0,0 +1,122 @@
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 store.order.createFromQuote in dashdbschema ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-order-create-from-quote-'));
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('order-from-quote-test@example.com');
const company = await store.company.create({
name: 'Order Quote Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const groups = await store.customergroup.list(googleAuthId);
const customer = await store.entity.create(googleAuthId, {
customergroupId: groups[0].id,
firstName: 'Alice',
name: 'Wonderland',
});
const art = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Gadget',
track_stock: 0,
});
// 1. Create a quote
const quote = await store.quote.create(googleAuthId, {
number: 'Q-2026-001',
date: '2026-09-01T10:00:00Z',
entityId: customer.id,
externalNumber: 'EXT-QUOTE-123',
lines: [
{ sku: art.sku, name: 'Gadget', pu: 1, amount: 5, net: 20, rebate: 10, taxrate: 19, comment: 'test item' },
],
});
assert.equal(quote.number, 'Q-2026-001');
assert.equal(quote.lines.length, 1);
// 2. Create order from quote with default auto-allocated number & inherited fields
const order1 = await store.order.createFromQuote(googleAuthId, quote.id);
assert.ok(order1.id, 'Order must have an id');
assert.equal(order1.type, 'order');
assert.equal(order1.entityId, customer.id);
assert.equal(order1.externalNumber, 'EXT-QUOTE-123');
assert.ok(order1.number, 'Order must have auto-allocated number');
assert.equal(order1.lines.length, 1);
assert.equal(order1.lines[0].sku, art.sku);
assert.equal(order1.lines[0].amount, 5);
assert.equal(order1.lines[0].net, 20);
assert.equal(order1.lines[0].rebate, 10);
assert.equal(order1.lines[0].taxrate, 19);
assert.equal(order1.lines[0].comment, 'test item');
// Verify ordersummary row
const summaryRows = provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').all(order1.id);
assert.equal(summaryRows.length, 1);
assert.equal(summaryRows[0].customer_name, 'Alice Wonderland');
assert.equal(summaryRows[0].positions, 1);
assert.equal(summaryRows[0].ordered_quantity, 5);
// 3. Create order from quote with custom overrides (number, externalNumber, date)
const order2 = await store.order.createFromQuote(googleAuthId, quote.id, {
number: 'ORD-CUSTOM-999',
date: '2026-09-15T12:00:00Z',
externalNumber: 'CUSTOM-EXT',
});
assert.equal(order2.number, 'ORD-CUSTOM-999');
assert.equal(order2.externalNumber, 'CUSTOM-EXT');
assert.equal(order2.lines.length, 1);
// 4. Non-existent quote should throw 'Quote not found'
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, 999999);
},
{ message: 'Quote not found' }
);
// 5. Invalid quoteId should throw 'Quote not found'
await assert.rejects(
async () => {
await store.order.createFromQuote(googleAuthId, 'invalid');
},
{ message: 'Quote not found' }
);
console.log('PASS: All order.createFromQuote tests passed successfully!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
run().catch((err) => {
console.error('FAIL: Test failed:', err);
process.exit(1);
});

View File

@@ -99,6 +99,7 @@ import quoteRebuildSummaries from './store/quote/rebuildSummaries.js';
import orderList from './store/order/list.js';
import orderGetById from './store/order/getById.js';
import orderCreate from './store/order/create.js';
import orderCreateFromQuote from './store/order/createFromQuote.js';
import orderUpdate from './store/order/update.js';
import orderDelete from './store/order/delete.js';
import orderRebuildSummaries from './store/order/rebuildSummaries.js';
@@ -432,6 +433,7 @@ export default class LibDashStore {
list: orderList(provider),
getById: orderGetById(provider),
create: orderCreate(provider),
createFromQuote: orderCreateFromQuote(provider),
update: orderUpdate(provider),
delete: orderDelete(provider),
rebuildSummaries: orderRebuildSummaries(provider),

View File

@@ -0,0 +1,177 @@
import {
CREATE_MANIFEST as MSSQL_CREATE_MANIFEST,
ORDER_TYPE as MSSQL_ORDER_TYPE,
QUOTE_TYPE as MSSQL_QUOTE_TYPE,
} from '../../providers/mssql/queries/manifestDoc.js';
import {
CREATE_MANIFEST as SQLITE_CREATE_MANIFEST,
ORDER_TYPE as SQLITE_ORDER_TYPE,
QUOTE_TYPE as SQLITE_QUOTE_TYPE,
} from '../../providers/sqlite3/queries/manifestDoc.js';
import {
FIND_ARTICLES_FOR_HISTORY as MSSQL_FIND_ARTICLES,
} from '../../providers/mssql/queries/ledger.js';
import {
FIND_ARTICLES_FOR_HISTORY as SQLITE_FIND_ARTICLES,
} from '../../providers/sqlite3/queries/ledger.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import nextNumber from '../numbers/nextNumber.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js';
import { recordOrderHistory } from '../articlehistory/record.js';
import { syncOrderSummary } from './syncSummary.js';
import makeRunner from '../runner.js';
export default function createFromQuote(provider) {
const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(provider);
const allocateNumber = nextNumber(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const createManifestSql = isSqlite ? SQLITE_CREATE_MANIFEST : MSSQL_CREATE_MANIFEST;
const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE;
const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE;
const findArticlesSql = isSqlite ? SQLITE_FIND_ARTICLES : MSSQL_FIND_ARTICLES;
/**
* Creates a new sales order from an existing quote for the user's selected company.
* Copies lines, entity, and external number from the quote unless explicitly overridden.
* If order number is omitted, it is auto-allocated from the 'order' number sequence.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} quoteId - The id of the source quote.
* @param {object} [body] - Optional overrides for the order.
* @param {string} [body.number] - The order number; auto-allocated from sequence if omitted.
* @param {string} [body.date] - The order date; defaults to current date if omitted.
* @param {string} [body.createdAt] - Creation timestamp; defaults to current time if omitted.
* @param {number} [body.entityId] - Customer entity id override; defaults to quote's entityId.
* @param {string} [body.externalNumber] - Customer reference override; defaults to quote's externalNumber.
* @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - Lines override; defaults to quote's lines.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The created order.
* @throws {Error} If the quote is not found, or the order is invalid.
*
* @example
* const order = await store.order.createFromQuote(42, 10);
* // => { id, number, type: 'order', entityId, externalNumber, date, lines: [...] }
*/
return async (googleAuthId, quoteId, body = {}, tx) => {
if (!Number.isFinite(quoteId)) {
throw new Error('Quote not found');
}
const runCreate = async (runner) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
const quoteDoc = await loadDoc(runner, {
type: quoteType,
companyId,
id: quoteId,
});
if (!quoteDoc) {
throw new Error('Quote not found');
}
const date = body.date !== undefined ? parseDate(body.date) : new Date();
if (!date) {
throw new Error('Invalid order');
}
const createdAt = body.createdAt !== undefined ? parseDate(body.createdAt) : new Date();
if (!createdAt) {
throw new Error('Invalid order');
}
let number = body.number?.trim?.() ? body.number.trim() : null;
if (!number) {
number = await allocateNumber(companyId, orderType, runner);
}
const targetEntityId = body.entityId !== undefined ? body.entityId : quoteDoc.entityId;
const entityId = await assertCustomerEntity(
runner,
companyId,
targetEntityId ?? null,
);
const externalNumber = body.externalNumber !== undefined
? (body.externalNumber?.trim?.() ? body.externalNumber.trim() : null)
: (quoteDoc.externalNumber ?? null);
const rawLines = body.lines !== undefined ? body.lines : (quoteDoc.lines ?? []);
const lines = normalizeLines(rawLines);
const created = await runner.query(createManifestSql, {
companyId,
number,
type: orderType,
entityId,
externalNumber,
date,
createdAt,
});
const manifestId = created.rows[0]?.id ?? created.insertId;
await insertLines(runner, {
manifestId,
lines,
ensureTax,
});
// Record article history for all order items
let customerName = null;
if (entityId) {
const entRes = await runner.query('SELECT name FROM entity WHERE id = @entityId', { entityId });
customerName = entRes.rows[0]?.name ?? null;
}
const historyDate = body.createdAt ? createdAt : date;
for (const line of lines) {
const sku = line.sku ? String(line.sku).trim() : '';
if (!sku) continue;
const articleMatches = await runner.query(findArticlesSql, {
sku,
companyId,
});
for (const match of articleMatches.rows) {
await recordOrderHistory(runner, {
articleId: match.articleId,
companyuserId,
date: historyDate,
orderId: manifestId,
number,
customerName,
amount: line.amount,
net: line.net,
rebate: line.rebate,
taxrate: line.taxrate,
});
}
}
const doc = await loadDoc(runner, {
type: orderType,
companyId,
id: manifestId,
});
await syncOrderSummary(runner, { companyId, manifestId });
return doc;
};
if (tx) {
return runCreate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
};
}