Genesis
This commit is contained in:
3
lib/index.js
Normal file
3
lib/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = require('./src');
|
||||
15
lib/package.json
Normal file
15
lib/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "jtl-pos-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Reusable Node.js library implementing the JTL-POS pairing and sync protocol",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
92
lib/src/actions.js
Normal file
92
lib/src/actions.js
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
function setPairingCode(code, name = 'JTL-POS') {
|
||||
return { type: 'SET_PAIRING_CODE', payload: { code, name } };
|
||||
}
|
||||
function revokePairingCode(code) {
|
||||
return { type: 'REVOKE_PAIRING_CODE', payload: { code } };
|
||||
}
|
||||
function registerDevice(token, name = 'JTL-POS') {
|
||||
return { type: 'REGISTER_DEVICE', payload: { token, name } };
|
||||
}
|
||||
function revokeDevice(token) {
|
||||
return { type: 'REVOKE_DEVICE', payload: { token } };
|
||||
}
|
||||
|
||||
function createCustomerGroup(customerGroup) {
|
||||
return { type: 'CREATE_CUSTOMER_GROUP', payload: customerGroup };
|
||||
}
|
||||
function updateCustomerGroup(customerGroup) {
|
||||
return { type: 'UPDATE_CUSTOMER_GROUP', payload: customerGroup };
|
||||
}
|
||||
function deleteCustomerGroup(customerGroupId) {
|
||||
return { type: 'DELETE_CUSTOMER_GROUP', payload: { customerGroupId } };
|
||||
}
|
||||
|
||||
function createCategory(category) {
|
||||
return { type: 'CREATE_CATEGORY', payload: category };
|
||||
}
|
||||
function updateCategory(category) {
|
||||
return { type: 'UPDATE_CATEGORY', payload: category };
|
||||
}
|
||||
function deleteCategory(_id) {
|
||||
return { type: 'DELETE_CATEGORY', payload: { _id } };
|
||||
}
|
||||
|
||||
function createProduct(product) {
|
||||
return { type: 'CREATE_PRODUCT', payload: product };
|
||||
}
|
||||
function updateProduct(product) {
|
||||
return { type: 'UPDATE_PRODUCT', payload: product };
|
||||
}
|
||||
function deleteProduct(_id) {
|
||||
return { type: 'DELETE_PRODUCT', payload: { _id } };
|
||||
}
|
||||
function setCompositeComponent(productId, productIdComponent, quantity = '1.00') {
|
||||
return {
|
||||
type: 'SET_COMPOSITE_COMPONENT',
|
||||
payload: { productId, productIdComponent, quantity },
|
||||
};
|
||||
}
|
||||
function removeCompositeComponent(productId, productIdComponent) {
|
||||
return {
|
||||
type: 'REMOVE_COMPOSITE_COMPONENT',
|
||||
payload: { productId, productIdComponent },
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomer(customer) {
|
||||
return { type: 'CREATE_CUSTOMER', payload: customer };
|
||||
}
|
||||
function updateCustomer(customer) {
|
||||
return { type: 'UPDATE_CUSTOMER', payload: customer };
|
||||
}
|
||||
function deleteCustomer(id) {
|
||||
return { type: 'DELETE_CUSTOMER', payload: { id } };
|
||||
}
|
||||
|
||||
function recordDeletedEntity(entityId, entityType) {
|
||||
return { type: 'RECORD_DELETED_ENTITY', payload: { entityId, entityType } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setPairingCode,
|
||||
revokePairingCode,
|
||||
registerDevice,
|
||||
revokeDevice,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
setCompositeComponent,
|
||||
removeCompositeComponent,
|
||||
createCustomer,
|
||||
updateCustomer,
|
||||
deleteCustomer,
|
||||
createCustomerGroup,
|
||||
updateCustomerGroup,
|
||||
deleteCustomerGroup,
|
||||
recordDeletedEntity,
|
||||
};
|
||||
176
lib/src/admin-server.js
Normal file
176
lib/src/admin-server.js
Normal file
@@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const path = require('node:path');
|
||||
const actions = require('./actions');
|
||||
|
||||
function randomSixDigitCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
function createAdminServer(store, options = {}) {
|
||||
const {
|
||||
adminPort = Number(process.env.ADMIN_PORT) || 8087,
|
||||
staticDir = path.join(process.cwd(), 'web', 'dist'),
|
||||
} = options;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// CORS for Vite dev server
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/admin/api/init', (req, res) => {
|
||||
res.json({
|
||||
counts: store.getInitCounts(),
|
||||
state: store.getState(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/admin/api/state', (req, res) => {
|
||||
res.json(store.getState());
|
||||
});
|
||||
|
||||
function crudRoutes(basePath, collectionName, idField, actionCreators) {
|
||||
app.get(basePath, (req, res) => {
|
||||
const items = store.getState()[collectionName];
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
app.post(basePath, (req, res) => {
|
||||
const payload = req.body;
|
||||
store.dispatch(actionCreators.create(payload));
|
||||
const items = store.getState()[collectionName];
|
||||
const created =
|
||||
items.find((i) => i[idField] === String(payload[idField])) ||
|
||||
items[items.length - 1];
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
app.patch(`${basePath}/:id`, (req, res) => {
|
||||
const id = req.params.id;
|
||||
const updates = req.body;
|
||||
store.dispatch(actionCreators.update({ ...updates, [idField]: id }));
|
||||
res.json(store.getState()[collectionName].find((i) => i[idField] === id));
|
||||
});
|
||||
|
||||
app.put(`${basePath}/:id`, (req, res) => {
|
||||
const id = req.params.id;
|
||||
const updates = req.body;
|
||||
store.dispatch(actionCreators.update({ ...updates, [idField]: id }));
|
||||
res.json(store.getState()[collectionName].find((i) => i[idField] === id));
|
||||
});
|
||||
|
||||
app.delete(`${basePath}/:id`, (req, res) => {
|
||||
store.dispatch(actionCreators.delete(req.params.id));
|
||||
res.sendStatus(204);
|
||||
});
|
||||
}
|
||||
|
||||
crudRoutes('/admin/api/customer-groups', 'customerGroups', 'customerGroupId', {
|
||||
create: actions.createCustomerGroup,
|
||||
update: actions.updateCustomerGroup,
|
||||
delete: actions.deleteCustomerGroup,
|
||||
});
|
||||
|
||||
crudRoutes('/admin/api/categories', 'categories', '_id', {
|
||||
create: actions.createCategory,
|
||||
update: actions.updateCategory,
|
||||
delete: actions.deleteCategory,
|
||||
});
|
||||
|
||||
crudRoutes('/admin/api/products', 'products', '_id', {
|
||||
create: actions.createProduct,
|
||||
update: actions.updateProduct,
|
||||
delete: actions.deleteProduct,
|
||||
});
|
||||
|
||||
crudRoutes('/admin/api/customers', 'customers', 'id', {
|
||||
create: actions.createCustomer,
|
||||
update: actions.updateCustomer,
|
||||
delete: actions.deleteCustomer,
|
||||
});
|
||||
|
||||
app.get('/admin/api/product-composites', (req, res) => {
|
||||
res.json(store.getState().productComposites);
|
||||
});
|
||||
|
||||
app.post('/admin/api/product-composites', (req, res) => {
|
||||
const { productId, productIdComponent, quantity } = req.body;
|
||||
store.dispatch(actions.setCompositeComponent(productId, productIdComponent, quantity));
|
||||
res.status(201).json(
|
||||
store.getState().productComposites.find(
|
||||
(c) => c.productId === productId && c.productIdComponent === productIdComponent
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
app.delete('/admin/api/product-composites/:productId/:productIdComponent', (req, res) => {
|
||||
store.dispatch(
|
||||
actions.removeCompositeComponent(req.params.productId, req.params.productIdComponent)
|
||||
);
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
app.get('/admin/api/pairing', (req, res) => {
|
||||
res.json({
|
||||
authCodes: Object.values(store.getState().authCodes),
|
||||
pairedDevices: Object.values(store.getState().pairedDevices),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/api/pairing', (req, res) => {
|
||||
const { name = 'JTL-POS' } = req.body || {};
|
||||
const code = req.body?.code || randomSixDigitCode();
|
||||
store.dispatch(actions.setPairingCode(code, name));
|
||||
res.status(201).json({ code, name });
|
||||
});
|
||||
|
||||
app.post('/admin/api/pairing/revoke', (req, res) => {
|
||||
const { code } = req.body || {};
|
||||
if (code) {
|
||||
store.dispatch(actions.revokePairingCode(code));
|
||||
}
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
app.delete('/admin/api/devices/:token', (req, res) => {
|
||||
store.dispatch(actions.revokeDevice(req.params.token));
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
app.get('/admin/api/deleted', (req, res) => {
|
||||
res.json(store.getState().deletedEntities);
|
||||
});
|
||||
|
||||
app.post('/admin/api/deleted', (req, res) => {
|
||||
const { entityId, entityType } = req.body;
|
||||
store.dispatch(actions.recordDeletedEntity(entityId, entityType));
|
||||
res.status(201).json(store.getState().deletedEntities[store.getState().deletedEntities.length - 1]);
|
||||
});
|
||||
|
||||
// Serve built static files for the admin SPA
|
||||
const expressStaticExists = require('node:fs').existsSync(staticDir);
|
||||
if (expressStaticExists) {
|
||||
app.use(express.static(staticDir));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(staticDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
const server = app.listen(adminPort, '0.0.0.0', () => {
|
||||
console.log(`Admin web API listening on http://0.0.0.0:${adminPort}`);
|
||||
});
|
||||
|
||||
return { app, server };
|
||||
}
|
||||
|
||||
module.exports = { createAdminServer };
|
||||
15
lib/src/index.js
Normal file
15
lib/src/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const { createStore } = require('./store');
|
||||
const { createJtlPosServer } = require('./jtl-server');
|
||||
const { createAdminServer } = require('./admin-server');
|
||||
const actions = require('./actions');
|
||||
const seed = require('./seed');
|
||||
|
||||
module.exports = {
|
||||
createStore,
|
||||
createJtlPosServer,
|
||||
createAdminServer,
|
||||
actions,
|
||||
seed,
|
||||
};
|
||||
141
lib/src/jtl-server.js
Normal file
141
lib/src/jtl-server.js
Normal file
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
const { serverTimestamp } = require('./seed');
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
const responseBody = JSON.stringify(body);
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(responseBody),
|
||||
});
|
||||
res.end(responseBody);
|
||||
}
|
||||
|
||||
function createJtlPosServer(store, config = {}) {
|
||||
const { authToken = process.env.AUTH_TOKEN || '9a2e3036ed9c47e389741d9dbb7590e9' } = config;
|
||||
const {
|
||||
certificateFingerprint = 'BC2114CF407A42724BEEF417960F76DCBF9DE879',
|
||||
certificateSerialNumber = '00BFC8BEACDB981B165210EF111CB9D3',
|
||||
serverFingerprint = '39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
|
||||
mandantId = '1',
|
||||
mandantName = 'eB-Standard',
|
||||
mandantDatabase = 'eazybusiness',
|
||||
} = config;
|
||||
|
||||
function buildClientStep1() {
|
||||
return {
|
||||
authCode: null,
|
||||
authToken,
|
||||
certificateFingerprint,
|
||||
certificateSerialNumber,
|
||||
mandantId,
|
||||
mandantName: null,
|
||||
mandantDatabase: null,
|
||||
serverFingerprint,
|
||||
name: null,
|
||||
serverTimestamp: serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildClientStep2(authCode) {
|
||||
return {
|
||||
authCode,
|
||||
authToken,
|
||||
certificateFingerprint,
|
||||
certificateSerialNumber,
|
||||
mandantId,
|
||||
mandantName,
|
||||
mandantDatabase,
|
||||
serverFingerprint: null,
|
||||
name: null,
|
||||
serverTimestamp: serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const url = new URL(req.url, 'https://localhost');
|
||||
|
||||
const route = (method, pathname) => method === req.method && url.pathname === pathname;
|
||||
|
||||
if (route('GET', '/api/v1/client')) {
|
||||
const authCode = url.searchParams.get('authCode') || '';
|
||||
const name = url.searchParams.get('name') || 'JTL-POS';
|
||||
const state = store.getState();
|
||||
|
||||
// Step 1 — the first 4 digits of a pre-registered 6-digit pairing code.
|
||||
if (authCode.length <= 4 && authCode.length > 0) {
|
||||
return sendJson(res, 200, buildClientStep1());
|
||||
}
|
||||
|
||||
// Step 2 — full 6-digit code must exist in the pending authCodes map.
|
||||
if (authCode.length === 6) {
|
||||
if (state.authCodes[authCode]) {
|
||||
store.dispatch({ type: 'REVOKE_PAIRING_CODE', payload: { code: authCode } });
|
||||
store.dispatch({ type: 'REGISTER_DEVICE', payload: { token: authToken, name } });
|
||||
return sendJson(res, 200, buildClientStep2(authCode));
|
||||
}
|
||||
return sendJson(res, 400, { Message: 'Der Authentifizierungscode ist falsch.' });
|
||||
}
|
||||
|
||||
return sendJson(res, 400, { Message: 'Keinen passenden Authentifizierungscode gefunden.' });
|
||||
}
|
||||
|
||||
if (route('GET', '/api/v1/init')) {
|
||||
const params = {
|
||||
lastChangedCategory: url.searchParams.get('lastChangedCategory') || '0',
|
||||
lastChangedCustomer: url.searchParams.get('lastChangedCustomer') || '0',
|
||||
lastChangedCustomerGroup: url.searchParams.get('lastChangedCustomerGroup') || '0',
|
||||
lastChangedProduct: url.searchParams.get('lastChangedProduct') || '0',
|
||||
lastChangedConfigurationGroup: url.searchParams.get('lastChangedConfigurationGroup') || '0',
|
||||
lastChangedConfigurationItem: url.searchParams.get('lastChangedConfigurationItem') || '0',
|
||||
lastChangedCompositeProduct: url.searchParams.get('lastChangedCompositeProduct') || '0',
|
||||
lastChangedDeletedEntity: url.searchParams.get('lastChangedDeletedEntity') || '0',
|
||||
};
|
||||
return sendJson(res, 200, store.getInitCounts(params));
|
||||
}
|
||||
|
||||
const entityEndpoints = {
|
||||
'/api/v1/customergroup': { collection: 'customerGroups', param: 'lastChangedCustomerGroup', limit: '200' },
|
||||
'/api/v1/deletedentity': { collection: 'deletedEntities', param: 'lastChangedDeletedEntity', limit: '200' },
|
||||
'/api/v1/category': { collection: 'categories', param: 'lastChangedCategory', limit: '20' },
|
||||
'/api/v1/customer': { collection: 'customers', param: 'lastChangedCustomer', limit: '20' },
|
||||
'/api/v1/product': { collection: 'products', param: 'lastChangedProduct', limit: '20' },
|
||||
'/api/v1/productcomposite': { collection: 'productComposites', param: 'lastChangedCompositeProduct', limit: '20' },
|
||||
};
|
||||
|
||||
for (const [pathname, { collection, param, limit: defaultLimit }] of Object.entries(entityEndpoints)) {
|
||||
if (route('GET', pathname)) {
|
||||
const cursor = url.searchParams.get(param) || '0';
|
||||
const limit = url.searchParams.get('limit') || defaultLimit;
|
||||
const response = store.selectFilteredListFromCollection(collection, cursor, limit);
|
||||
return sendJson(res, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
return sendJson(res, 404, {
|
||||
Message: `No HTTP resource was found that matches the request URI '${url}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
return async function requestListener(req, res) {
|
||||
const body = await readBody(req);
|
||||
// Attach raw body for optional logging by the wrapper
|
||||
req.rawBody = body;
|
||||
try {
|
||||
await handle(req, res);
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { Message: err.message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createJtlPosServer };
|
||||
239
lib/src/seed.js
Normal file
239
lib/src/seed.js
Normal file
@@ -0,0 +1,239 @@
|
||||
function serverTimestamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function productFixture(overrides) {
|
||||
return {
|
||||
imghash: null,
|
||||
imgsrc: null,
|
||||
sort: '0',
|
||||
price: '0.00',
|
||||
p_price: '0.00',
|
||||
discountable: '0',
|
||||
deposit: '0',
|
||||
discount: '',
|
||||
d_price: '0.0',
|
||||
tax_rate: '19',
|
||||
tax_rate2: '',
|
||||
use_in_out_tax: '0',
|
||||
barcode: '',
|
||||
use_stock: '0',
|
||||
q_div: '0',
|
||||
unit: null,
|
||||
single_bookable: '0',
|
||||
annotation: '',
|
||||
status: '0',
|
||||
tags: '',
|
||||
categories_id: '1',
|
||||
is_parent: '0',
|
||||
parent: '0',
|
||||
variants: '',
|
||||
print_kitchen_receipt: '0',
|
||||
deposit_name: '',
|
||||
attributes: [],
|
||||
configurationGroups: '',
|
||||
options: null,
|
||||
hasBestBeforeDate: '0',
|
||||
hasLotNumber: '0',
|
||||
hasSerialNumber: '0',
|
||||
PLU: '',
|
||||
short_description: '',
|
||||
minStock: '0',
|
||||
container: [],
|
||||
reservedQuantity: '0.00',
|
||||
deliveryDetails: [],
|
||||
isbn: '',
|
||||
manufacturerName: null,
|
||||
han: '',
|
||||
productType: '0',
|
||||
voucherData: null,
|
||||
inputPrice: '0',
|
||||
inputQuantity: '0',
|
||||
categories: [{ categoryId: '1' }],
|
||||
prices: [
|
||||
{
|
||||
customerGroupId: '1',
|
||||
customerId: '0',
|
||||
price: '0.00',
|
||||
quantity: '0',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSeedState() {
|
||||
return {
|
||||
customerGroups: [
|
||||
{
|
||||
customerGroupId: '1',
|
||||
name: 'Endkunden',
|
||||
standard: '1',
|
||||
discountPercent: '0.00',
|
||||
lastChanged: '7323',
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
_id: '1',
|
||||
imghash: null,
|
||||
imgsrc: null,
|
||||
name: 'Haupt',
|
||||
pid: '0',
|
||||
discounts: [],
|
||||
sort: '0',
|
||||
updated_at: serverTimestamp(),
|
||||
created_at: serverTimestamp(),
|
||||
lastChanged: '10252',
|
||||
},
|
||||
{
|
||||
_id: '2',
|
||||
imghash: null,
|
||||
imgsrc: null,
|
||||
name: 'Getränke',
|
||||
pid: '1',
|
||||
discounts: [],
|
||||
sort: '1',
|
||||
updated_at: serverTimestamp(),
|
||||
created_at: serverTimestamp(),
|
||||
lastChanged: '10300',
|
||||
},
|
||||
],
|
||||
customers: [
|
||||
{
|
||||
id: '1',
|
||||
customerNumber: '0',
|
||||
firstname: '',
|
||||
lastname: 'kjhkjh',
|
||||
title: '',
|
||||
company: 'kjhkjh',
|
||||
address: '',
|
||||
addressSupplement: '',
|
||||
city: 'kjhkjhkjh',
|
||||
postalCode: '',
|
||||
state: '',
|
||||
country: 'Deutschland',
|
||||
phone: '',
|
||||
email: '',
|
||||
customerGroupId: '1',
|
||||
salutation: '',
|
||||
birthday: null,
|
||||
discount: '0.00',
|
||||
taxIdNumber: '',
|
||||
lastChanged: '13246',
|
||||
debtorNumber: '0',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
customerNumber: '1001',
|
||||
firstname: 'Erika',
|
||||
lastname: 'Musterfrau',
|
||||
title: '',
|
||||
company: 'Muster GmbH',
|
||||
address: 'Hauptstraße 1',
|
||||
addressSupplement: '',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
state: '',
|
||||
country: 'Deutschland',
|
||||
phone: '+49 30 123456',
|
||||
email: 'erika@muster.de',
|
||||
customerGroupId: '1',
|
||||
salutation: 'Frau',
|
||||
birthday: null,
|
||||
discount: '0.00',
|
||||
taxIdNumber: '',
|
||||
lastChanged: '13310',
|
||||
debtorNumber: '1001',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
customerNumber: '1002',
|
||||
firstname: 'Max',
|
||||
lastname: 'Mustermann',
|
||||
title: '',
|
||||
company: '',
|
||||
address: 'Nebenweg 5',
|
||||
addressSupplement: '',
|
||||
city: 'Hamburg',
|
||||
postalCode: '20095',
|
||||
state: '',
|
||||
country: 'Deutschland',
|
||||
phone: '',
|
||||
email: 'max@example.de',
|
||||
customerGroupId: '1',
|
||||
salutation: 'Herr',
|
||||
birthday: null,
|
||||
discount: '5.00',
|
||||
taxIdNumber: '',
|
||||
lastChanged: '13320',
|
||||
debtorNumber: '1002',
|
||||
},
|
||||
],
|
||||
products: [
|
||||
productFixture({
|
||||
_id: '1',
|
||||
name: 'a1',
|
||||
sku: '1',
|
||||
quantity: '0',
|
||||
updated_at: serverTimestamp(),
|
||||
created_at: serverTimestamp(),
|
||||
isCompositeProduct: '0',
|
||||
lastChanged: '13111',
|
||||
}),
|
||||
productFixture({
|
||||
_id: '2',
|
||||
name: 'a2',
|
||||
sku: '2',
|
||||
quantity: '0.00',
|
||||
updated_at: serverTimestamp(),
|
||||
created_at: serverTimestamp(),
|
||||
isCompositeProduct: '1',
|
||||
lastChanged: '13228',
|
||||
}),
|
||||
productFixture({
|
||||
_id: '3',
|
||||
name: 'Cola 0,5l',
|
||||
sku: '3',
|
||||
quantity: '24',
|
||||
price: '1.49',
|
||||
p_price: '1.49',
|
||||
updated_at: serverTimestamp(),
|
||||
created_at: serverTimestamp(),
|
||||
isCompositeProduct: '0',
|
||||
categories_id: '2',
|
||||
categories: [{ categoryId: '2' }],
|
||||
lastChanged: '13300',
|
||||
prices: [
|
||||
{
|
||||
customerGroupId: '1',
|
||||
customerId: '0',
|
||||
price: '1.49',
|
||||
quantity: '0',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
productComposites: [
|
||||
{
|
||||
productId: '2',
|
||||
productIdComponent: '1',
|
||||
quantity: '1.00',
|
||||
lastChanged: '13227',
|
||||
},
|
||||
],
|
||||
deletedEntities: [
|
||||
{
|
||||
entityId: '2',
|
||||
entityType: '6',
|
||||
lastChanged: '8281',
|
||||
},
|
||||
],
|
||||
authCodes: {},
|
||||
pairedDevices: {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSeedState, productFixture, serverTimestamp };
|
||||
316
lib/src/store.js
Normal file
316
lib/src/store.js
Normal file
@@ -0,0 +1,316 @@
|
||||
'use strict';
|
||||
|
||||
const { createSeedState } = require('./seed');
|
||||
|
||||
function withLimit(records, limit) {
|
||||
const max = Number(limit);
|
||||
return Number.isFinite(max) && max > 0 ? records.slice(0, max) : records;
|
||||
}
|
||||
|
||||
function filterByCursor(records, cursor) {
|
||||
const n = Number(cursor) || 0;
|
||||
return records.filter((record) => Number(record.lastChanged) > n);
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
function encodeId(n) {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function createStore(options = {}) {
|
||||
const state = options.initialState || createSeedState();
|
||||
const listeners = new Set();
|
||||
|
||||
if (!state.authCodes) state.authCodes = {};
|
||||
if (!state.pairedDevices) state.pairedDevices = {};
|
||||
if (!state.customerGroups) state.customerGroups = [];
|
||||
if (!state.categories) state.categories = [];
|
||||
if (!state.customers) state.customers = [];
|
||||
if (!state.products) state.products = [];
|
||||
if (!state.productComposites) state.productComposites = [];
|
||||
if (!state.deletedEntities) state.deletedEntities = [];
|
||||
|
||||
function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((l) => l(state));
|
||||
}
|
||||
|
||||
function dispatch(action) {
|
||||
const { type, payload } = action;
|
||||
|
||||
switch (type) {
|
||||
// Auth / pairing
|
||||
case 'SET_PAIRING_CODE': {
|
||||
const { code, name = 'JTL-POS' } = payload;
|
||||
state.authCodes[code] = { code, name, createdAt: Date.now() };
|
||||
break;
|
||||
}
|
||||
case 'REVOKE_PAIRING_CODE': {
|
||||
delete state.authCodes[payload.code];
|
||||
break;
|
||||
}
|
||||
case 'REGISTER_DEVICE': {
|
||||
const { token, name, createdAt = Date.now() } = payload;
|
||||
state.pairedDevices[token] = { name, token, createdAt };
|
||||
break;
|
||||
}
|
||||
case 'REVOKE_DEVICE': {
|
||||
delete state.pairedDevices[payload.token];
|
||||
break;
|
||||
}
|
||||
|
||||
// Customer groups
|
||||
case 'CREATE_CUSTOMER_GROUP':
|
||||
case 'UPDATE_CUSTOMER_GROUP': {
|
||||
const group = payload;
|
||||
const id = group.customerGroupId || generateId();
|
||||
const idx = state.customerGroups.findIndex(
|
||||
(g) => g.customerGroupId === id
|
||||
);
|
||||
const next = {
|
||||
...group,
|
||||
customerGroupId: id,
|
||||
standard: String(group.standard ?? (idx >= 0 ? state.customerGroups[idx].standard : '0')),
|
||||
discountPercent: String(group.discountPercent ?? '0.00'),
|
||||
lastChanged: generateId(),
|
||||
};
|
||||
if (idx >= 0) state.customerGroups[idx] = next;
|
||||
else state.customerGroups.push(next);
|
||||
break;
|
||||
}
|
||||
case 'DELETE_CUSTOMER_GROUP': {
|
||||
const id = payload.customerGroupId;
|
||||
state.customerGroups = state.customerGroups.filter(
|
||||
(g) => g.customerGroupId !== id
|
||||
);
|
||||
state.deletedEntities.push({
|
||||
entityId: id,
|
||||
entityType: '6',
|
||||
lastChanged: generateId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Categories
|
||||
case 'CREATE_CATEGORY':
|
||||
case 'UPDATE_CATEGORY': {
|
||||
const cat = payload;
|
||||
const id = cat._id || generateId();
|
||||
const idx = state.categories.findIndex((c) => c._id === id);
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
const next = {
|
||||
imghash: null,
|
||||
imgsrc: null,
|
||||
discounts: [],
|
||||
sort: '0',
|
||||
...cat,
|
||||
_id: id,
|
||||
pid: String(cat.pid ?? '0'),
|
||||
updated_at: now,
|
||||
created_at: idx >= 0 ? state.categories[idx].created_at : now,
|
||||
lastChanged: generateId(),
|
||||
};
|
||||
if (idx >= 0) state.categories[idx] = next;
|
||||
else state.categories.push(next);
|
||||
break;
|
||||
}
|
||||
case 'DELETE_CATEGORY': {
|
||||
const id = payload._id;
|
||||
state.categories = state.categories.filter((c) => c._id !== id);
|
||||
state.deletedEntities.push({
|
||||
entityId: id,
|
||||
entityType: '2',
|
||||
lastChanged: generateId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Products
|
||||
case 'CREATE_PRODUCT':
|
||||
case 'UPDATE_PRODUCT': {
|
||||
const prod = payload;
|
||||
const id = prod._id || generateId();
|
||||
const idx = state.products.findIndex((p) => p._id === id);
|
||||
const defaults = createSeedState().products[0];
|
||||
if (idx >= 0) {
|
||||
state.products[idx] = {
|
||||
...state.products[idx],
|
||||
...prod,
|
||||
_id: id,
|
||||
isCompositeProduct: String(prod.isCompositeProduct ?? state.products[idx].isCompositeProduct ?? '0'),
|
||||
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
lastChanged: generateId(),
|
||||
};
|
||||
} else {
|
||||
state.products.push({
|
||||
...defaults,
|
||||
...prod,
|
||||
_id: id,
|
||||
isCompositeProduct: String(prod.isCompositeProduct ?? '0'),
|
||||
created_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
lastChanged: generateId(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DELETE_PRODUCT': {
|
||||
const id = payload._id;
|
||||
state.products = state.products.filter((p) => p._id !== id);
|
||||
state.deletedEntities.push({
|
||||
entityId: id,
|
||||
entityType: '1',
|
||||
lastChanged: generateId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Product composites
|
||||
case 'SET_COMPOSITE_COMPONENT': {
|
||||
const { productId, productIdComponent, quantity = '1.00' } = payload;
|
||||
const idx = state.productComposites.findIndex(
|
||||
(c) => c.productId === productId && c.productIdComponent === productIdComponent
|
||||
);
|
||||
const next = {
|
||||
productId,
|
||||
productIdComponent,
|
||||
quantity: String(quantity),
|
||||
lastChanged: generateId(),
|
||||
};
|
||||
if (idx >= 0) state.productComposites[idx] = next;
|
||||
else state.productComposites.push(next);
|
||||
// Mark the parent product as composite
|
||||
const parentIdx = state.products.findIndex((p) => p._id === productId);
|
||||
if (parentIdx >= 0 && state.products[parentIdx].isCompositeProduct !== '1') {
|
||||
state.products[parentIdx].isCompositeProduct = '1';
|
||||
state.products[parentIdx].lastChanged = generateId();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_COMPOSITE_COMPONENT': {
|
||||
const { productId, productIdComponent } = payload;
|
||||
state.productComposites = state.productComposites.filter(
|
||||
(c) => !(c.productId === productId && c.productIdComponent === productIdComponent)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Customers
|
||||
case 'CREATE_CUSTOMER':
|
||||
case 'UPDATE_CUSTOMER': {
|
||||
const cust = payload;
|
||||
const id = cust.id || generateId();
|
||||
const idx = state.customers.findIndex((c) => c.id === id);
|
||||
const next = {
|
||||
customerNumber: '0',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
title: '',
|
||||
company: '',
|
||||
address: '',
|
||||
addressSupplement: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
state: '',
|
||||
country: 'Deutschland',
|
||||
phone: '',
|
||||
email: '',
|
||||
customerGroupId: '1',
|
||||
salutation: '',
|
||||
birthday: null,
|
||||
discount: '0.00',
|
||||
taxIdNumber: '',
|
||||
debtorNumber: '0',
|
||||
...cust,
|
||||
id,
|
||||
lastChanged: generateId(),
|
||||
};
|
||||
if (idx >= 0) state.customers[idx] = next;
|
||||
else state.customers.push(next);
|
||||
break;
|
||||
}
|
||||
case 'DELETE_CUSTOMER': {
|
||||
const id = payload.id;
|
||||
state.customers = state.customers.filter((c) => c.id !== id);
|
||||
state.deletedEntities.push({
|
||||
entityId: id,
|
||||
entityType: '3',
|
||||
lastChanged: generateId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'RECORD_DELETED_ENTITY': {
|
||||
const { entityId, entityType } = payload;
|
||||
state.deletedEntities.push({
|
||||
entityId,
|
||||
entityType: String(entityType),
|
||||
lastChanged: generateId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${type}`);
|
||||
}
|
||||
|
||||
emit();
|
||||
return state;
|
||||
}
|
||||
|
||||
function selectFilteredListFromCollection(collectionName, cursor, limit) {
|
||||
return withLimit(filterByCursor(state[collectionName], cursor), limit);
|
||||
}
|
||||
|
||||
function getInitCounts(params = {}) {
|
||||
return {
|
||||
version: '1.10.12.0',
|
||||
product_count: String(
|
||||
selectFilteredListFromCollection('products', params.lastChangedProduct, Infinity).length
|
||||
),
|
||||
category_count: String(
|
||||
selectFilteredListFromCollection('categories', params.lastChangedCategory, Infinity).length
|
||||
),
|
||||
customer_count: String(
|
||||
selectFilteredListFromCollection('customers', params.lastChangedCustomer, Infinity).length
|
||||
),
|
||||
customerGroup_count: String(
|
||||
selectFilteredListFromCollection('customerGroups', params.lastChangedCustomerGroup, Infinity).length
|
||||
),
|
||||
compositeProduct_count: String(
|
||||
selectFilteredListFromCollection(
|
||||
'productComposites',
|
||||
params.lastChangedCompositeProduct,
|
||||
Infinity
|
||||
).length
|
||||
),
|
||||
configurationGroup_count: '0',
|
||||
configurationItem_count: '0',
|
||||
deletedEntity_count: String(
|
||||
selectFilteredListFromCollection('deletedEntities', params.lastChangedDeletedEntity, Infinity).length
|
||||
),
|
||||
max_orderId_count: '0',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
dispatch,
|
||||
subscribe,
|
||||
getInitCounts,
|
||||
selectFilteredListFromCollection,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStore, withLimit, filterByCursor };
|
||||
Reference in New Issue
Block a user