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/upsert.md`](routes/unit/upsert.md) | `PUT /unit` |
| [`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/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |

View File

@@ -8804,7 +8804,7 @@
"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": [
{
"cookieAuth": []
@@ -8834,6 +8834,62 @@
"type": "array",
"items": {
"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
}
}
@@ -13016,7 +13072,7 @@
},
"put": {
"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": [
"Article"
],
@@ -13172,7 +13228,7 @@
},
"put": {
"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": [
"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": {
"get": {
"tags": [

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
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

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
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

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
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
@@ -19,7 +19,23 @@ Articles list
```json
{
"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']) {
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('response', getBody());

View File

@@ -1997,7 +1997,7 @@ export function register(app, basePath = '') {
* /article/{sku}/articlelist:
* put:
* 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:
* - Article
* security:
@@ -2135,7 +2135,7 @@ export function register(app, basePath = '') {
* /article/{sku}/optionslist:
* put:
* 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:
* - Article
* security:

View File

@@ -292,7 +292,7 @@ export function register(app, basePath = '') {
* get:
* tags: [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:
* - cookieAuth: []
* x-markdown: category/articles.md
@@ -315,6 +315,46 @@ export function register(app, basePath = '') {
* type: array
* items:
* 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
* 400:
* description: Invalid id
@@ -342,7 +382,13 @@ export function register(app, basePath = '') {
}
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 });
} catch (error) {
const status = categoryErrorStatus(error);

View File

@@ -40,7 +40,48 @@ export async function 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) {
const store = await getStore();
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 { posAuthMiddleware } from './middleware/posAuth.js';
import { handleClient } from './routes/client.js';
import { handleInit } from './routes/init.js';
const router = Router();
// POS discovery and pairing
router.get('/client', handleClient);
// POS sync initialization counts
router.get('/init', posAuthMiddleware, handleInit);
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 { getCompanyById } from '../../db/company.js';
import { getCertMetadata } from '../../util/cert.js';
import { emit } from '../../api/sse.js';
export function serverTimestamp() {
const d = new Date();
@@ -166,6 +167,10 @@ export async function handleClient(req, res) {
pairedAt,
});
if (terminal.companyId) {
emit('posterminal', { companyId: terminal.companyId });
}
let mandantName = process.env.MANDANT_NAME || 'eB-Standard';
let mandantDatabase = process.env.MANDANT_DATABASE || 'eazybusiness';
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' });
}
}