From a27f507138cddb4e7753fd8808799b9b4703b235 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 22 Oct 2026 12:30:04 +0200 Subject: [PATCH] u --- docs/api/README.md | 2 + docs/api/routes-all.md | 8 +- docs/api/routes.md | 11 +- docs/api/routes/v1/deletedentity.md | 38 +++ docs/api/routes/v1/order.md | 52 ++++ scripts/wipeCompany.js | 1 - src/db/posterminal.js | 5 + src/v1/index.js | 6 + src/v1/routes/deletedentity.js | 76 ++++++ src/v1/routes/order.js | 400 ++++++++++++++++++++++++++++ 10 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 docs/api/routes/v1/deletedentity.md create mode 100644 docs/api/routes/v1/order.md create mode 100644 src/v1/routes/deletedentity.js create mode 100644 src/v1/routes/order.js diff --git a/docs/api/README.md b/docs/api/README.md index 4e58f4e..9392b18 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -278,7 +278,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` | | [`routes/v1/customer.md`](routes/v1/customer.md) | `GET /v1/customer` | | [`routes/v1/customergroup.md`](routes/v1/customergroup.md) | `GET /v1/customergroup` | +| [`routes/v1/deletedentity.md`](routes/v1/deletedentity.md) | `GET /v1/deletedentity` | | [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` | +| [`routes/v1/order.md`](routes/v1/order.md) | `POST /v1/order` | | [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` | | [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` | | [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 5b1fd45..c17ba4f 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 266 API endpoints across all modules. +Overview of all 268 API endpoints across all modules. ### Notation @@ -933,9 +933,15 @@ _none_ #### JTL-POS customer group synchronization — `GET /v1/customergroup` - Params: `query: lastChangedCustomerGroup?: str, limit?: int` - Returns: `200 { customerGroupId: str, name: str, standard: str, discountPercent: str, lastChanged: str }[]` +#### JTL-POS deleted entity synchronization — `GET /v1/deletedentity` +- Params: `query: lastChangedDeletedEntity?: str, limit?: int` +- Returns: `200 { entityId: str, entityType: int, lastChanged: int }[]` #### POS sync initialization counts — `GET /v1/init` - Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int` - Returns: `200 { version: str, product_count: str, category_count: str, customer_count: str, customerGroup_count: str, compositeProduct_count: str, configurationGroup_count: str, configurationItem_count: str, deletedEntity_count: str, max_orderId_count: str }` +#### JTL-POS order upload — `POST /v1/order` +- Params: `query: mandantId?: str, body: { orders?: {}[] }` +- Returns: `200 { status: str, externalOrderId: str, message: str }[]` #### POS product image delivery — `GET /v1/pimage` - Params: `query: path: str, w?: int, h?: int, format?: str` - Returns: `200 jpeg` diff --git a/docs/api/routes.md b/docs/api/routes.md index 21c483c..bf33855 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 187 API endpoints across all modules. +Overview of all 189 API endpoints across all modules. ### Notation @@ -662,6 +662,15 @@ _none_ - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Returns: `200 { unit: T19 }` +### Section: /v1 + +#### JTL-POS deleted entity synchronization — `GET /v1/deletedentity` +- Params: `query: lastChangedDeletedEntity?: str, limit?: int` +- Returns: `200 { entityId: str, entityType: int, lastChanged: int }[]` +#### JTL-POS order upload — `POST /v1/order` +- Params: `query: mandantId?: str, body: { orders?: {}[] }` +- Returns: `200 { status: str, externalOrderId: str, message: str }[]` + ### Section: /warehouse #### List warehouses — `GET /warehouse` diff --git a/docs/api/routes/v1/deletedentity.md b/docs/api/routes/v1/deletedentity.md new file mode 100644 index 0000000..f97c557 --- /dev/null +++ b/docs/api/routes/v1/deletedentity.md @@ -0,0 +1,38 @@ +# `GET /v1/deletedentity` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +JTL-POS deleted entity synchronization + +Returns deleted entities after lastChangedDeletedEntity cursor for the authenticated POS register. + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `lastChangedDeletedEntity` | no | string | Sequential ID cursor of last processed tombstone | +| `limit` | no | integer | Maximum number of deleted entities to return | + +## Response `200` + +Array of deleted entity objects for JTL-POS + +```json +[ + { + "entityId": "123", + "entityType": 1, + "lastChanged": 45 + } +] +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 401 | Unauthorized | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/v1/order.md b/docs/api/routes/v1/order.md new file mode 100644 index 0000000..30552c7 --- /dev/null +++ b/docs/api/routes/v1/order.md @@ -0,0 +1,52 @@ +# `POST /v1/order` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +JTL-POS order upload + +Imports orders from JTL-POS, creating order manifests, payments, and shipments with stock deductions. + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `mandantId` | no | string | | + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `orders` | no | object[] | | + +```json +{ + "orders": [ + {} + ] +} +``` + +## Response `200` + +Array of sync results per order + +```json +[ + { + "status": "OK", + "externalOrderId": "2", + "message": "" + } +] +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/wipeCompany.js b/scripts/wipeCompany.js index 94a760a..c2d3075 100644 --- a/scripts/wipeCompany.js +++ b/scripts/wipeCompany.js @@ -144,7 +144,6 @@ async function wipeCompany(companyIdentifier, autoConfirm = false) { await tx.query('DELETE FROM companysetting WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM companyhistory WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM numbers WHERE company_id = @companyId', { companyId }); - await tx.query('DELETE FROM articletombstone WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM modelvfs WHERE company_id = @companyId', { companyId }); await tx.query(`DELETE FROM modelconversation WHERE companyuser_id IN (SELECT id FROM companyuser WHERE company_id = @companyId)`, { companyId }); diff --git a/src/db/posterminal.js b/src/db/posterminal.js index 7d90d84..d114c95 100644 --- a/src/db/posterminal.js +++ b/src/db/posterminal.js @@ -95,6 +95,11 @@ export async function getDeletedEntityCount(params) { return store.posterminal.getDeletedEntityCount(params); } +export async function getDeletedEntityList(params) { + const store = await getStore(); + return store.posterminal.getDeletedEntityList(params); +} + export async function getMaxOrderIdCount(params) { const store = await getStore(); return store.posterminal.getMaxOrderIdCount(params); diff --git a/src/v1/index.js b/src/v1/index.js index cc62df1..6ec6e45 100644 --- a/src/v1/index.js +++ b/src/v1/index.js @@ -6,7 +6,9 @@ import { handleCategory } from './routes/category.js'; import { handleCustomerGroup } from './routes/customergroup.js'; import { handleCustomer } from './routes/customer.js'; import { handleProduct } from './routes/product.js'; +import { handleDeletedEntity } from './routes/deletedentity.js'; import { handlePimage } from './routes/pimage.js'; +import { handleOrder } from './routes/order.js'; const router = Router(); @@ -21,6 +23,10 @@ router.get('/category', posAuthMiddleware, handleCategory); router.get('/customergroup', posAuthMiddleware, handleCustomerGroup); router.get('/customer', posAuthMiddleware, handleCustomer); router.get('/product', posAuthMiddleware, handleProduct); +router.get('/deletedentity', posAuthMiddleware, handleDeletedEntity); + +// POS order upload +router.post('/order', posAuthMiddleware, handleOrder); // POS image delivery router.get('/pimage', handlePimage); diff --git a/src/v1/routes/deletedentity.js b/src/v1/routes/deletedentity.js new file mode 100644 index 0000000..0cf253a --- /dev/null +++ b/src/v1/routes/deletedentity.js @@ -0,0 +1,76 @@ +import { getDeletedEntityList } from '../../db/posterminal.js'; + +const TYPE_MAP = { + article: 1, + category: 2, + customer: 4, + customergroup: 6, +}; + +/** + * @openapi + * /v1/deletedentity: + * get: + * summary: JTL-POS deleted entity synchronization + * description: Returns deleted entities after lastChangedDeletedEntity cursor for the authenticated POS register. + * tags: + * - POS Sync + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: lastChangedDeletedEntity + * schema: + * type: string + * description: Sequential ID cursor of last processed tombstone + * - in: query + * name: limit + * schema: + * type: integer + * default: 200 + * description: Maximum number of deleted entities to return + * responses: + * 200: + * description: Array of deleted entity objects for JTL-POS + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * entityId: + * type: string + * example: "123" + * entityType: + * type: integer + * example: 1 + * lastChanged: + * type: integer + * example: 45 + * 401: + * description: Unauthorized + */ +export async function handleDeletedEntity(req, res, next) { + try { + const posterminal = req.posterminal; + const cursor = Number(req.query.lastChangedDeletedEntity) || 0; + const limit = Number(req.query.limit) || 200; + + const tombstones = await getDeletedEntityList({ + posterminalId: posterminal.id, + cursor, + limit, + }); + + res.json( + tombstones.map((t) => ({ + entityId: String(t.entityId), + entityType: TYPE_MAP[t.entityType?.toLowerCase()] ?? 0, + lastChanged: t.id, + })) + ); + } catch (err) { + next(err); + } +} diff --git a/src/v1/routes/order.js b/src/v1/routes/order.js new file mode 100644 index 0000000..8c79d3f --- /dev/null +++ b/src/v1/routes/order.js @@ -0,0 +1,400 @@ +import { getStore, getProvider } from '../../db/store.js'; +import { emit } from '../../api/sse.js'; +import { createLogger } from '../../util/logger.js'; + +const log = createLogger('pos-order'); + +async function allocateOrderNumber(provider, companyId) { + const isSqlite = provider?.dialect === 'sqlite3'; + const doAlloc = async (runner) => { + const selectSql = isSqlite + ? `SELECT id, prefix, padding, [next] FROM numbers WHERE company_id = @companyId AND [type] = 'order'` + : `SELECT id, prefix, padding, [next] FROM numbers WITH (UPDLOCK, ROWLOCK) WHERE company_id = @companyId AND [type] = 'order'`; + const res = await runner.query(selectSql, { companyId }); + let row = res.rows[0]; + if (!row) { + await runner.query( + `INSERT INTO numbers (company_id, [type], prefix, padding, [next]) VALUES (@companyId, 'order', 'AUF-', 6, 1)`, + { companyId } + ); + const fetchRes = await runner.query(selectSql, { companyId }); + row = fetchRes.rows[0]; + } + const val = row.next; + const p = row.prefix || ''; + const nStr = String(val); + const padded = row.padding > 0 ? nStr.padStart(row.padding, '0') : nStr; + const formatted = `${p}${padded}`; + await runner.query(`UPDATE numbers SET [next] = [next] + 1 WHERE id = @id`, { id: row.id }); + return formatted; + }; + + if (typeof provider.transaction === 'function') { + return provider.transaction(doAlloc); + } + return doAlloc(provider); +} + +async function resolveGoogleAuthId(provider, store, companyId) { + const userRes = await provider.query( + `SELECT cu.google_auth_id AS googleAuthId, ga.selected_company AS selectedCompany + FROM companyuser cu + JOIN google_auth ga ON ga.id = cu.google_auth_id + WHERE cu.company_id = @companyId + ORDER BY CASE WHEN ga.selected_company = @companyId THEN 0 ELSE 1 END, cu.id ASC`, + { companyId } + ); + const googleAuthId = userRes.rows[0]?.googleAuthId; + if (!googleAuthId) { + throw new Error(`No user found for company ${companyId}`); + } + if (userRes.rows[0]?.selectedCompany !== companyId) { + await store.company.setSelectedCompany(googleAuthId, companyId); + } + return googleAuthId; +} + +/** + * @openapi + * /v1/order: + * post: + * summary: JTL-POS order upload + * description: Imports orders from JTL-POS, creating order manifests, payments, and shipments with stock deductions. + * tags: + * - POS Sync + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: mandantId + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * orders: + * type: array + * items: + * type: object + * responses: + * 200: + * description: Array of sync results per order + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * status: + * type: string + * example: "OK" + * externalOrderId: + * type: string + * example: "2" + * message: + * type: string + * example: "" + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +export async function handleOrder(req, res, next) { + try { + const posterminal = req.posterminal; + const companyId = posterminal.companyId; + const provider = await getProvider(); + const store = await getStore(); + const googleAuthId = await resolveGoogleAuthId(provider, store, companyId); + + const orders = Array.isArray(req.body?.orders) ? req.body.orders : []; + const results = []; + + for (const order of orders) { + const externalOrderId = String(order?.externalId ?? ''); + const externalOrderNumber = (order?.externalOrderNumber || '').trim(); + const extCheck = externalOrderNumber || externalOrderId; + + try { + // 1. Deduplication check + if (extCheck) { + const existing = await provider.query( + `SELECT id, number, external_number FROM manifest WHERE company_id = @companyId AND (external_number = @extCheck OR number = @extCheck) AND type = 'order'`, + { companyId, extCheck } + ); + if (existing.rows.length > 0) { + const row = existing.rows[0]; + const msg = `order already mapped, skipped save (kAuftrag=${row.id}, ${row.number})`; + log.info(`order externalId=${externalOrderId} skipped: ${msg}`); + results.push({ + status: 'OK', + externalOrderId, + message: '', + }); + continue; + } + } + + // 2. Resolve Customer Entity + let entityId = null; + const billing = order.billingAddress || {}; + const shipping = order.shippingAddress || {}; + const rawLastName = (billing.lastName || shipping.lastName || '').trim(); + const rawFirstName = (billing.firstName || shipping.firstName || '').trim(); + const rawCustomerNr = (order.customerNumber || billing.debtorNumber || '').trim(); + const isWalkIn = !rawLastName || /^(walk-in customer|laufkunde|kunde)$/i.test(rawLastName); + + const entities = await store.entity.list(googleAuthId).catch(() => []); + if (isWalkIn) { + const laufkunde = entities.find((e) => /^(laufkunde|walk-in customer)$/i.test(e.name?.trim())); + if (laufkunde) entityId = laufkunde.id; + } + + if (!entityId && rawCustomerNr) { + const found = entities.find((e) => String(e.number || '').trim().toLowerCase() === rawCustomerNr.toLowerCase()); + if (found) entityId = found.id; + } + + if (!entityId && rawLastName && !isWalkIn) { + const found = entities.find((e) => (e.name || '').trim().toLowerCase() === rawLastName.toLowerCase()); + if (found) entityId = found.id; + } + + if (!entityId) { + const groups = await store.customergroup.list(googleAuthId).catch(() => []); + const posGroup = groups.find((g) => g.name === 'POS' || g.name?.toUpperCase() === 'POS'); + const defaultCustomerGroupId = posGroup?.id || groups.find((g) => g.name !== 'Lieferanten')?.id || groups[0]?.id; + + try { + const newEntity = await store.entity.create(googleAuthId, { + customergroupId: defaultCustomerGroupId, + name: isWalkIn ? 'Laufkunde' : rawLastName, + firstName: rawFirstName || undefined, + number: rawCustomerNr || undefined, + }); + entityId = newEntity.id; + } catch (entErr) { + entityId = entities[0]?.id || null; + } + } + + // 3. Lines + const rawItems = Array.isArray(order.orderItems) ? order.orderItems : []; + const lines = rawItems.map((it) => { + const sku = it.sku ? String(it.sku).trim() : null; + const name = it.name ? String(it.name).trim() : (sku ? `Artikel ${sku}` : 'Artikel'); + let amount = Number(it.quantity) || 1; + if (String(it.isReturn) === '1' && amount > 0) { + amount = -amount; + } + const net = Number(it.priceNet) || 0; + const taxrate = Number(it.vat) || 19; + const rebate = Number(it.discountPercent) || 0; + const comment = it.note ? String(it.note).trim() : null; + let shippable = true; + if (/^(summe glätten|shippingcost|versandkosten)$/i.test(name)) { + shippable = false; + } + return { + sku, + name, + amount, + net, + taxrate, + rebate, + comment, + shippable, + }; + }); + + // 4. Date and Numbers + const parsedDate = order.creationDate ? new Date(order.creationDate) : new Date(); + const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString(); + const orderNumber = await allocateOrderNumber(provider, companyId); + const externalNumber = externalOrderNumber || (externalOrderId ? String(externalOrderId) : null); + + // 5. Create Order + let createdOrder = null; + try { + createdOrder = await store.order.create(googleAuthId, { + number: orderNumber, + externalNumber, + date: validDate, + createdAt: validDate, + entityId: entityId || undefined, + lines, + }); + } catch (createErr) { + const parentMatch = createErr.message?.match(/A parent article cannot be booked on documents \(([^)]+)\)/); + if (parentMatch) { + const offendingSku = parentMatch[1]; + for (const l of lines) { + if (l.sku === offendingSku) l.sku = null; + } + createdOrder = await store.order.create(googleAuthId, { + number: orderNumber, + externalNumber, + date: validDate, + createdAt: validDate, + entityId: entityId || undefined, + lines, + }); + } else { + throw createErr; + } + } + + // 6. Payments + const payments = Array.isArray(order.payments) ? order.payments : []; + if (payments.length > 0) { + const accounts = await store.account.list(googleAuthId).catch(() => []); + const accountMap = new Map(); + for (const acc of accounts) { + if (acc.name) accountMap.set(acc.name.trim().toLowerCase(), acc.id); + if (acc.type) accountMap.set(acc.type.trim().toLowerCase(), acc.id); + } + + const resolveAccountId = async (methodName) => { + const raw = (methodName || 'Bar').trim().toLowerCase(); + if (raw === 'cash' || raw === 'bar') { + if (accountMap.has('bar')) return accountMap.get('bar'); + if (accountMap.has('cash')) return accountMap.get('cash'); + } else if (raw === 'card' || raw === 'karte') { + if (accountMap.has('karte')) return accountMap.get('karte'); + if (accountMap.has('card')) return accountMap.get('card'); + } + if (accountMap.has(raw)) return accountMap.get(raw); + try { + const accName = methodName || 'Bar'; + const created = await store.account.create(googleAuthId, { + type: accName, + name: accName, + }); + accountMap.set(created.name.trim().toLowerCase(), created.id); + return created.id; + } catch { + return accounts[0]?.id || null; + } + }; + + for (const p of payments) { + const amount = Number(p.amount) || 0; + if (amount <= 0) continue; + const type = p.paymentMethodName || 'CASH'; + const accountId = await resolveAccountId(type); + if (accountId) { + await store.payment.create(googleAuthId, { + accountId, + manifestId: createdOrder.id, + type, + amount, + }); + } + } + } + + // 7. Deliver / Shipment + const shouldDeliver = String(order.settings?.deliver ?? '').trim() === '1' || order.deliver === true || order.shipped === true; + if (shouldDeliver) { + const warehouses = await store.warehouse.list(googleAuthId).catch(() => []); + const defaultWarehouseId = warehouses[0]?.id || 1; + + const shippableLines = (createdOrder.lines || lines) + .filter((l) => l.shippable !== false) + .map((l) => { + if (l.sku) return { sku: l.sku, amount: l.amount }; + if (l.id) return { orderLineId: l.id, amount: l.amount }; + return null; + }) + .filter(Boolean); + + if (shippableLines.length > 0) { + const neededBySku = new Map(); + for (const l of shippableLines) { + if (l.sku && l.amount > 0) { + neededBySku.set(l.sku, (neededBySku.get(l.sku) || 0) + Number(l.amount)); + } + } + for (const [sku, needed] of neededBySku) { + const detail = await store.article.getBySku(googleAuthId, sku).catch(() => null); + const art = detail?.article; + if (!art?.track_stock) continue; + const available = art.warehouses?.find((w) => w.id === defaultWarehouseId)?.stock ?? 0; + const shortfall = needed - available; + if (shortfall > 0) { + await store.stock.adjust(googleAuthId, { + sku, + warehouseId: defaultWarehouseId, + amount: shortfall, + text: `POS ${externalOrderNumber || orderNumber}`, + }); + } + } + + const carriers = await store.carrier.list(googleAuthId).catch(() => []); + let carrier = carriers.find((c) => c.name?.toLowerCase() === 'selbstabholer') || carriers[0]; + let carrierId = carrier?.id; + if (!carrierId) { + const createdCarrier = await store.carrier.create(googleAuthId, { + name: 'Selbstabholer', + hasPackages: false, + }); + carrierId = createdCarrier.id; + } + + await store.shipment.create(googleAuthId, { + orderId: createdOrder.id, + date: validDate, + state: 'delivered', + warehouseId: defaultWarehouseId, + lines: shippableLines, + shipping: { + carrierId, + type: 'selbstabholer', + state: 'shipped', + warehouseId: defaultWarehouseId, + }, + }); + } + } + + // 8. Real-time updates + emit('order', { companyId }); + emit('stock', { companyId }); + emit('ledger', { companyId }); + emit('articles', { companyId }); + emit('shipping', { companyId }); + for (const l of lines) { + if (l.sku) { + emit(`article-${l.sku}`, { companyId }); + } + } + + log.info(`order ${orderNumber} created for externalId=${externalOrderId} (${externalOrderNumber})`); + results.push({ + status: 'OK', + externalOrderId, + message: '', + }); + } catch (err) { + log.error(`order externalId=${externalOrderId} failed:`, err.message); + results.push({ + status: 'ERROR', + externalOrderId, + message: err.message, + }); + } + } + + const failed = results.filter((r) => r.status === 'ERROR').length; + const httpStatus = failed > 0 ? 500 : 200; + res.status(httpStatus).json(results); + } catch (err) { + next(err); + } +}