This commit is contained in:
seb
2026-09-30 09:47:59 +02:00
parent 1de802fbaa
commit 82cc138ba6
15 changed files with 624 additions and 11 deletions

View File

@@ -184,6 +184,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` | | [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` |
| [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` | | [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` |
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` | | [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` | | [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` | | [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` | | [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |

View File

@@ -8804,7 +8804,7 @@
"Category" "Category"
], ],
"summary": "Articles in category", "summary": "Articles in category",
"description": "Returns articles in a category the user is authorized for.", "description": "Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles).",
"security": [ "security": [
{ {
"cookieAuth": [] "cookieAuth": []
@@ -8834,6 +8834,62 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"properties": {
"id": {
"type": "integer"
},
"sku": {
"type": "string"
},
"name": {
"type": "string"
},
"slug": {
"type": "string"
},
"net": {
"type": "number",
"nullable": true
},
"taxRate": {
"type": "number",
"nullable": true
},
"track_stock": {
"type": "boolean"
},
"optionsCount": {
"type": "integer"
},
"articleListCount": {
"type": "integer"
},
"childCount": {
"type": "integer",
"description": "Number of child articles belonging to this article"
},
"fullHash": {
"type": "string",
"nullable": true
},
"thumbW": {
"type": "integer",
"nullable": true
},
"thumbH": {
"type": "integer",
"nullable": true
},
"thumbHash": {
"type": "string",
"nullable": true
},
"brand": {
"type": "object",
"nullable": true,
"additionalProperties": true
}
},
"additionalProperties": true "additionalProperties": true
} }
} }
@@ -13016,7 +13072,7 @@
}, },
"put": { "put": {
"summary": "Set Stückliste (bundle items) for an article", "summary": "Set Stückliste (bundle items) for an article",
"description": "Configures the article as a Stückliste covering a list of count * article. An article that is a father or has options cannot be a Stückliste. Pass an empty array to remove the Stückliste.", "description": "Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.",
"tags": [ "tags": [
"Article" "Article"
], ],
@@ -13172,7 +13228,7 @@
}, },
"put": { "put": {
"summary": "Set options list for an article", "summary": "Set options list for an article",
"description": "Configures options for an article. Some options can be mandated (come with the article in any case). An article that is a father or a Stückliste cannot have options. Pass an empty array to remove options.", "description": "Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.",
"tags": [ "tags": [
"Article" "Article"
], ],
@@ -13520,6 +13576,164 @@
} }
} }
}, },
"/v1/init": {
"get": {
"tags": [
"POS"
],
"summary": "POS sync initialization counts",
"description": "Returns entity change counts since the provided `lastChanged*` unix timestamp cursors\nfor the authenticated POS terminal (`Authorization: Bearer <authToken>`).\n",
"x-markdown": "pos/init.md",
"parameters": [
{
"in": "header",
"name": "Authorization",
"required": true,
"schema": {
"type": "string"
},
"description": "POS device authToken obtained during pairing (`Bearer <token>` or `<token>`)",
"example": "Bearer c30099b66ec1fcb9ccd05795423c94e8"
},
{
"in": "query",
"name": "lastChangedProduct",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Unix timestamp of last synced product update",
"example": 0
},
{
"in": "query",
"name": "lastChangedCategory",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Unix timestamp of last synced category update",
"example": 0
},
{
"in": "query",
"name": "lastChangedCustomer",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Unix timestamp of last synced customer update",
"example": 0
},
{
"in": "query",
"name": "lastChangedCustomerGroup",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Unix timestamp of last synced customer group update",
"example": 0
},
{
"in": "query",
"name": "lastChangedCompositeProduct",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Unix timestamp of last synced composite product update",
"example": 0
},
{
"in": "query",
"name": "lastChangedDeletedEntity",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Unix timestamp of last synced deleted entity",
"example": 0
}
],
"responses": {
"200": {
"description": "Sync initialization counts",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"version": {
"type": "string",
"example": "1.10.12.0"
},
"product_count": {
"type": "string",
"example": "19"
},
"category_count": {
"type": "string",
"example": "4"
},
"customer_count": {
"type": "string",
"example": "0"
},
"customerGroup_count": {
"type": "string",
"example": "1"
},
"compositeProduct_count": {
"type": "string",
"example": "0"
},
"configurationGroup_count": {
"type": "string",
"example": "0"
},
"configurationItem_count": {
"type": "string",
"example": "0"
},
"deletedEntity_count": {
"type": "string",
"example": "0"
},
"max_orderId_count": {
"type": "string",
"example": "0"
}
}
}
}
}
},
"401": {
"description": "Missing or invalid POS Authorization token",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"Message": {
"type": "string",
"example": "Authorization has been denied for this request."
}
}
}
}
}
}
}
}
},
"/v1/client": { "/v1/client": {
"get": { "get": {
"tags": [ "tags": [

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Set Stückliste (bundle items) for an article Set Stückliste (bundle items) for an article
Configures the article as a Stückliste covering a list of count * article. An article that is a father or has options cannot be a Stückliste. Pass an empty array to remove the Stückliste. Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
## Path parameters ## Path parameters

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Set options list for an article Set options list for an article
Configures options for an article. Some options can be mandated (come with the article in any case). An article that is a father or a Stückliste cannot have options. Pass an empty array to remove options. Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
## Path parameters ## Path parameters

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Articles in category Articles in category
Returns articles in a category the user is authorized for. Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles).
## Path parameters ## Path parameters
@@ -19,7 +19,23 @@ Articles list
```json ```json
{ {
"articles": [ "articles": [
{} {
"id": 1,
"sku": "…",
"name": "…",
"slug": "…",
"net": 1,
"taxRate": 1,
"track_stock": true,
"optionsCount": 1,
"articleListCount": 1,
"childCount": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"brand": {}
}
] ]
} }
``` ```

View File

@@ -0,0 +1,49 @@
# `GET /v1/init`
Protected. Requires a valid session cookie (`credentials: 'include'`).
POS sync initialization counts
Returns entity change counts since the provided `lastChanged*` unix timestamp cursors
for the authenticated POS terminal (`Authorization: Bearer <authToken>`).
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `lastChangedProduct` | no | integer | Unix timestamp of last synced product update |
| `lastChangedCategory` | no | integer | Unix timestamp of last synced category update |
| `lastChangedCustomer` | no | integer | Unix timestamp of last synced customer update |
| `lastChangedCustomerGroup` | no | integer | Unix timestamp of last synced customer group update |
| `lastChangedCompositeProduct` | no | integer | Unix timestamp of last synced composite product update |
| `lastChangedDeletedEntity` | no | integer | Unix timestamp of last synced deleted entity |
## Response `200`
Sync initialization counts
```json
{
"version": "1.10.12.0",
"product_count": "19",
"category_count": "4",
"customer_count": "0",
"customerGroup_count": "1",
"compositeProduct_count": "0",
"configurationGroup_count": "0",
"configurationItem_count": "0",
"deletedEntity_count": "0",
"max_orderId_count": "0"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 401 | Missing or invalid POS Authorization token |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

1
quixpos2jtl Submodule

Submodule quixpos2jtl added at a0a2e8d87f

View File

@@ -55,6 +55,9 @@ export const loggerMiddleware = (req, res, next) => {
if (req.headers['content-type']) { if (req.headers['content-type']) {
console.log(chalk.gray(' content-type:'), chalk.cyan(req.headers['content-type'])); console.log(chalk.gray(' content-type:'), chalk.cyan(req.headers['content-type']));
} }
if (req.headers.authorization) {
console.log(chalk.gray(' authorization:'), chalk.cyan(req.headers.authorization));
}
logField('body', req.body ?? {}, { skipEmpty: true }); logField('body', req.body ?? {}, { skipEmpty: true });
logField('response', getBody()); logField('response', getBody());

View File

@@ -1997,7 +1997,7 @@ export function register(app, basePath = '') {
* /article/{sku}/articlelist: * /article/{sku}/articlelist:
* put: * put:
* summary: Set Stückliste (bundle items) for an article * summary: Set Stückliste (bundle items) for an article
* description: Configures the article as a Stückliste covering a list of count * article. An article that is a father or has options cannot be a Stückliste. Pass an empty array to remove the Stückliste. * description: Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
* tags: * tags:
* - Article * - Article
* security: * security:
@@ -2135,7 +2135,7 @@ export function register(app, basePath = '') {
* /article/{sku}/optionslist: * /article/{sku}/optionslist:
* put: * put:
* summary: Set options list for an article * summary: Set options list for an article
* description: Configures options for an article. Some options can be mandated (come with the article in any case). An article that is a father or a Stückliste cannot have options. Pass an empty array to remove options. * description: Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
* tags: * tags:
* - Article * - Article
* security: * security:

View File

@@ -292,7 +292,7 @@ export function register(app, basePath = '') {
* get: * get:
* tags: [Category] * tags: [Category]
* summary: Articles in category * summary: Articles in category
* description: Returns articles in a category the user is authorized for. * description: Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles).
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: category/articles.md * x-markdown: category/articles.md
@@ -315,6 +315,46 @@ export function register(app, basePath = '') {
* type: array * type: array
* items: * items:
* type: object * type: object
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* slug:
* type: string
* net:
* type: number
* nullable: true
* taxRate:
* type: number
* nullable: true
* track_stock:
* type: boolean
* optionsCount:
* type: integer
* articleListCount:
* type: integer
* childCount:
* type: integer
* description: Number of child articles belonging to this article
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* brand:
* type: object
* nullable: true
* additionalProperties: true
* additionalProperties: true * additionalProperties: true
* 400: * 400:
* description: Invalid id * description: Invalid id
@@ -342,7 +382,13 @@ export function register(app, basePath = '') {
} }
try { try {
const articles = await db.getCategoryArticles(req.user.id, parsed.id); const allArticles = await db.getCategoryArticles(req.user.id, parsed.id);
const articles = allArticles
.filter((a) => a.fatherArticleId == null)
.map((a) => ({
...a,
childCount: Number(a.childCount ?? 0),
}));
res.status(200).json({ articles }); res.status(200).json({ articles });
} catch (error) { } catch (error) {
const status = categoryErrorStatus(error); const status = categoryErrorStatus(error);

View File

@@ -40,7 +40,48 @@ export async function findByPairingpin(pairingpin) {
return store.posterminal.findByPairingpin(pairingpin); return store.posterminal.findByPairingpin(pairingpin);
} }
export async function findByToken(token) {
const store = await getStore();
return store.posterminal.findByToken(token);
}
export async function pair(id, data) { export async function pair(id, data) {
const store = await getStore(); const store = await getStore();
return store.posterminal.pair(id, data); return store.posterminal.pair(id, data);
} }
export async function getProductCount(params) {
const store = await getStore();
return store.posterminal.getProductCount(params);
}
export async function getCategoryCount(params) {
const store = await getStore();
return store.posterminal.getCategoryCount(params);
}
export async function getCustomerCount(params) {
const store = await getStore();
return store.posterminal.getCustomerCount(params);
}
export async function getCustomerGroupCount(params) {
const store = await getStore();
return store.posterminal.getCustomerGroupCount(params);
}
export async function getCompositeProductCount(params) {
const store = await getStore();
return store.posterminal.getCompositeProductCount(params);
}
export async function getDeletedEntityCount(params) {
const store = await getStore();
return store.posterminal.getDeletedEntityCount(params);
}
export async function getMaxOrderIdCount(params) {
const store = await getStore();
return store.posterminal.getMaxOrderIdCount(params);
}

View File

@@ -1,9 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import { posAuthMiddleware } from './middleware/posAuth.js';
import { handleClient } from './routes/client.js'; import { handleClient } from './routes/client.js';
import { handleInit } from './routes/init.js';
const router = Router(); const router = Router();
// POS discovery and pairing // POS discovery and pairing
router.get('/client', handleClient); router.get('/client', handleClient);
// POS sync initialization counts
router.get('/init', posAuthMiddleware, handleInit);
export default router; export default router;

View File

@@ -0,0 +1,66 @@
import chalk from 'chalk';
import * as db from '../../db/posterminal.js';
export function extractPosToken(req) {
const authHeader = req.headers.authorization;
if (typeof authHeader === 'string' && authHeader.trim()) {
const trimmed = authHeader.trim();
if (/^Bearer\s+/i.test(trimmed)) {
return trimmed.replace(/^Bearer\s+/i, '').trim();
}
if (/^Basic\s+/i.test(trimmed)) {
const b64 = trimmed.replace(/^Basic\s+/i, '').trim();
try {
const decoded = Buffer.from(b64, 'base64').toString('utf8');
const parts = decoded.split(':');
return (parts[1] || parts[0] || '').trim();
} catch {
return b64;
}
}
return trimmed;
}
for (const key of ['x-auth-token', 'authtoken', 'auth-token', 'token', 'x-token']) {
const val = req.headers[key];
if (typeof val === 'string' && val.trim()) {
return val.trim();
}
}
const queryToken = req.query?.authToken || req.query?.token;
if (typeof queryToken === 'string' && queryToken.trim()) {
return queryToken.trim();
}
return '';
}
/**
* Verifies the POS terminal token from the Authorization header and attaches
* `req.posterminal`.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export async function posAuthMiddleware(req, res, next) {
const token = extractPosToken(req);
if (!token) {
console.log(chalk.yellow(' [posAuth] No token found in headers:'), req.headers);
return res.status(401).json({ Message: 'Authorization has been denied for this request.' });
}
try {
const terminal = await db.findByToken(token);
if (!terminal) {
console.log(chalk.yellow(` [posAuth] Unknown token "${token}", headers:`), req.headers);
return res.status(401).json({ Message: 'Authorization has been denied for this request.' });
}
req.posterminal = terminal;
next();
} catch (err) {
console.error('POS auth error:', err);
return res.status(500).json({ Message: 'Internal server error' });
}
}

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import * as db from '../../db/posterminal.js'; import * as db from '../../db/posterminal.js';
import { getCompanyById } from '../../db/company.js'; import { getCompanyById } from '../../db/company.js';
import { getCertMetadata } from '../../util/cert.js'; import { getCertMetadata } from '../../util/cert.js';
import { emit } from '../../api/sse.js';
export function serverTimestamp() { export function serverTimestamp() {
const d = new Date(); const d = new Date();
@@ -166,6 +167,10 @@ export async function handleClient(req, res) {
pairedAt, pairedAt,
}); });
if (terminal.companyId) {
emit('posterminal', { companyId: terminal.companyId });
}
let mandantName = process.env.MANDANT_NAME || 'eB-Standard'; let mandantName = process.env.MANDANT_NAME || 'eB-Standard';
let mandantDatabase = process.env.MANDANT_DATABASE || 'eazybusiness'; let mandantDatabase = process.env.MANDANT_DATABASE || 'eazybusiness';
let mandantId = String(terminal.companyId || process.env.MANDANT_ID || '1'); let mandantId = String(terminal.companyId || process.env.MANDANT_ID || '1');

165
src/v1/routes/init.js Normal file
View File

@@ -0,0 +1,165 @@
import * as db from '../../db/posterminal.js';
/**
* @openapi
* /v1/init:
* get:
* tags: [POS]
* summary: POS sync initialization counts
* description: |
* Returns entity change counts since the provided `lastChanged*` unix timestamp cursors
* for the authenticated POS terminal (`Authorization: Bearer <authToken>`).
* x-markdown: pos/init.md
* parameters:
* - in: header
* name: Authorization
* required: true
* schema:
* type: string
* description: POS device authToken obtained during pairing (`Bearer <token>` or `<token>`)
* example: "Bearer c30099b66ec1fcb9ccd05795423c94e8"
* - in: query
* name: lastChangedProduct
* required: false
* schema:
* type: integer
* default: 0
* description: Unix timestamp of last synced product update
* example: 0
* - in: query
* name: lastChangedCategory
* required: false
* schema:
* type: integer
* default: 0
* description: Unix timestamp of last synced category update
* example: 0
* - in: query
* name: lastChangedCustomer
* required: false
* schema:
* type: integer
* default: 0
* description: Unix timestamp of last synced customer update
* example: 0
* - in: query
* name: lastChangedCustomerGroup
* required: false
* schema:
* type: integer
* default: 0
* description: Unix timestamp of last synced customer group update
* example: 0
* - in: query
* name: lastChangedCompositeProduct
* required: false
* schema:
* type: integer
* default: 0
* description: Unix timestamp of last synced composite product update
* example: 0
* - in: query
* name: lastChangedDeletedEntity
* required: false
* schema:
* type: integer
* default: 0
* description: Unix timestamp of last synced deleted entity
* example: 0
* responses:
* 200:
* description: Sync initialization counts
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* example: "1.10.12.0"
* product_count:
* type: string
* example: "19"
* category_count:
* type: string
* example: "4"
* customer_count:
* type: string
* example: "0"
* customerGroup_count:
* type: string
* example: "1"
* compositeProduct_count:
* type: string
* example: "0"
* configurationGroup_count:
* type: string
* example: "0"
* configurationItem_count:
* type: string
* example: "0"
* deletedEntity_count:
* type: string
* example: "0"
* max_orderId_count:
* type: string
* example: "0"
* 401:
* description: Missing or invalid POS Authorization token
* content:
* application/json:
* schema:
* type: object
* properties:
* Message:
* type: string
* example: "Authorization has been denied for this request."
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
export async function handleInit(req, res) {
const posterminalId = req.posterminal.id;
const productCursor = Number(req.query.lastChangedProduct) || 0;
const categoryCursor = Number(req.query.lastChangedCategory) || 0;
const customerCursor = Number(req.query.lastChangedCustomer) || 0;
const customerGroupCursor = Number(req.query.lastChangedCustomerGroup) || 0;
const compositeProductCursor = Number(req.query.lastChangedCompositeProduct) || 0;
const deletedEntityCursor = Number(req.query.lastChangedDeletedEntity) || 0;
try {
const [
productCount,
categoryCount,
customerCount,
customerGroupCount,
compositeProductCount,
deletedEntityCount,
maxOrderIdCount,
] = await Promise.all([
db.getProductCount({ posterminalId, cursor: productCursor }),
db.getCategoryCount({ posterminalId, cursor: categoryCursor }),
db.getCustomerCount({ posterminalId, cursor: customerCursor }),
db.getCustomerGroupCount({ posterminalId, cursor: customerGroupCursor }),
db.getCompositeProductCount({ posterminalId, cursor: compositeProductCursor }),
db.getDeletedEntityCount({ posterminalId, cursor: deletedEntityCursor }),
db.getMaxOrderIdCount({ posterminalId }),
]);
return res.status(200).json({
version: '1.10.12.0',
product_count: String(productCount),
category_count: String(categoryCount),
customer_count: String(customerCount),
customerGroup_count: String(customerGroupCount),
compositeProduct_count: String(compositeProductCount),
configurationGroup_count: '0',
configurationItem_count: '0',
deletedEntity_count: String(deletedEntityCount),
max_orderId_count: String(maxOrderIdCount),
});
} catch (err) {
console.error('POS /v1/init error:', err);
return res.status(500).json({ Message: 'Internal server error' });
}
}