@alizadefaig/pos-server 1.2.25 → 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/app/core/Context.js +170 -0
  2. package/app/core/auth/index.js +133 -0
  3. package/app/core/auth/license.js +45 -0
  4. package/app/core/auth/posMode.js +260 -0
  5. package/app/core/auth/sanctum.js +209 -0
  6. package/app/core/auth/scope.js +231 -0
  7. package/app/core/emitter.js +67 -0
  8. package/app/core/errors.js +18 -0
  9. package/app/core/guard.js +54 -0
  10. package/app/core/idempotency.js +196 -0
  11. package/app/core/index.js +374 -0
  12. package/app/core/models/Order.js +176 -0
  13. package/app/core/models/OrderProduct.js +249 -0
  14. package/app/core/repositories/EventRepository.js +51 -0
  15. package/app/core/repositories/OrderProductRepository.js +225 -0
  16. package/app/core/repositories/OrderRepository.js +274 -0
  17. package/app/core/repositories/OutboxRepository.js +52 -0
  18. package/app/core/repositories/PaymentRepository.js +43 -0
  19. package/app/core/repositories/PrinterRepository.js +179 -0
  20. package/app/core/repositories/ProductRelations.js +148 -0
  21. package/app/core/repositories/SnapshotRepository.js +197 -0
  22. package/app/core/resources/HallResource.js +54 -0
  23. package/app/core/resources/OrderResource.js +177 -0
  24. package/app/core/resources/ProductResource.js +186 -0
  25. package/app/core/resources/ShiftResource.js +42 -0
  26. package/app/core/resources/TeamResource.js +85 -0
  27. package/app/core/resources/casts.js +55 -0
  28. package/app/core/services/AuthService.js +468 -0
  29. package/app/core/services/InitService.js +277 -0
  30. package/app/core/services/KdsService.js +639 -0
  31. package/app/core/services/OrderProductService.js +309 -0
  32. package/app/core/services/OrderService.js +903 -0
  33. package/app/core/services/PrintService.js +715 -0
  34. package/app/core/services/PrinterAdminService.js +324 -0
  35. package/app/core/services/ReportService.js +581 -0
  36. package/app/core/services/ShiftService.js +199 -0
  37. package/app/core/services/StorageService.js +425 -0
  38. package/app/core/services/TelegramService.js +162 -0
  39. package/app/core/support/db.js +48 -0
  40. package/app/core/support/ids.js +9 -0
  41. package/app/core/support/imageData.js +135 -0
  42. package/app/core/support/images.js +92 -0
  43. package/app/core/support/lanHandoff.js +105 -0
  44. package/app/core/support/lang.js +98 -0
  45. package/app/core/support/lock.js +63 -0
  46. package/app/core/support/num.js +51 -0
  47. package/app/core/support/php.js +125 -0
  48. package/app/core/support/pin.js +34 -0
  49. package/app/core/support/receipts.js +278 -0
  50. package/app/core/support/rows.js +38 -0
  51. package/app/core/support/str.js +31 -0
  52. package/app/core/support/teamSettings.js +77 -0
  53. package/app/core/support/telegram.js +253 -0
  54. package/app/core/support/telegramReport.js +143 -0
  55. package/app/core/support/time.js +58 -0
  56. package/app/core/support/tz.js +80 -0
  57. package/app/core/support/unlock.js +113 -0
  58. package/app/core/timers.js +151 -0
  59. package/app/db/Database.js +197 -1
  60. package/app/db/Migrator.js +45 -0
  61. package/app/db/driver.js +248 -0
  62. package/app/db/migrations/001_snapshot.sql +214 -0
  63. package/app/db/migrations/002_orders.sql +190 -0
  64. package/app/db/migrations/003_idempotency.sql +18 -0
  65. package/app/db/migrations/004_shifts.sql +21 -0
  66. package/app/db/migrations/005_printers.sql +43 -0
  67. package/app/db/migrations/006_sync.sql +136 -0
  68. package/app/db/migrations/007_printer_mac.sql +4 -0
  69. package/app/db/migrations/008_kds.sql +94 -0
  70. package/app/db/migrations/009_product_categories.sql +20 -0
  71. package/app/db/migrations/010_printers_sync.sql +43 -0
  72. package/app/db/migrations/011_team_logos.sql +13 -0
  73. package/app/db/migrations/012_category_workshop.sql +4 -0
  74. package/app/monitor.js +185 -0
  75. package/app/printers/address.js +348 -0
  76. package/app/proxy.js +20 -3
  77. package/app/relay/masters.js +173 -0
  78. package/app/security.js +218 -0
  79. package/app/socket/auth.js +160 -0
  80. package/app/socket/request.js +87 -0
  81. package/app/socket/server.js +121 -19
  82. package/app/sync/bridge.js +162 -0
  83. package/app/sync/cloud.js +120 -0
  84. package/app/sync/config.js +185 -0
  85. package/app/sync/discovery.js +546 -0
  86. package/app/sync/index.js +1644 -0
  87. package/app/sync/kv.js +26 -0
  88. package/app/sync/lan.js +139 -0
  89. package/app/sync/lanDevices.js +177 -0
  90. package/app/sync/lanFront.js +431 -0
  91. package/app/sync/mirror.js +236 -0
  92. package/app/sync/push.js +324 -0
  93. package/app/sync/reconcile.js +161 -0
  94. package/app/sync/signal.js +202 -0
  95. package/app/sync/snapshot.js +189 -0
  96. package/app/sync/store.js +234 -0
  97. package/app/tcp.js +11 -2
  98. package/package.json +6 -3
  99. package/routes/common/api/auth.js +38 -0
  100. package/routes/common/api/kds.js +37 -0
  101. package/routes/common/api/orderProducts.js +81 -0
  102. package/routes/common/api/orders.js +176 -0
  103. package/routes/common/api/printersCore.js +35 -0
  104. package/routes/common/api/reports.js +29 -0
  105. package/routes/common/socket.js +15 -35
  106. package/routes/common/tax.js +35 -7
  107. package/routes/desktop/api/offline.js +146 -0
  108. package/routes/desktop/api/printers.js +288 -5
  109. package/routes/desktop/local.js +130 -0
  110. package/routes/server/api/login.js +37 -0
  111. package/routes/server/api/platform.js +31 -0
  112. package/routes/server/api/shifts.js +25 -0
  113. package/routes/server/master.js +24 -0
  114. package/server.js +111 -4
  115. package/utils.js +6 -0
  116. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,218 @@
1
+ const {timingSafeEqual} = require('node:crypto');
2
+ const {internalKey, INTERNAL_KEY_HEADER, isSyncEnabled, isLanFrontEnabled} = require('./sync/config');
3
+ const {getConfig} = require('./config');
4
+
5
+ /** Сравнение секретов за постоянное время (длина сравнивается отдельно — это не секрет). */
6
+ const safeEqual = (provided, expected) => {
7
+ if (typeof provided !== 'string' || typeof expected !== 'string' || !provided || !expected) {
8
+ return false;
9
+ }
10
+ const a = Buffer.from(provided);
11
+ const b = Buffer.from(expected);
12
+ return a.length === b.length && timingSafeEqual(a, b);
13
+ };
14
+
15
+ // Служебные пути Laravel → Node: бродкаст в комнату компании, запрос к терминалу (печать),
16
+ // ретрансляция мастер-кассе. Снаружи до них достучаться не должен никто.
17
+ // Без учёта регистра: роутер Express сопоставляет пути так же, и GET /SOCKET?... иначе
18
+ // дошёл бы до router.all('/socket') мимо ключа.
19
+ const INTERNAL_PATHS = [/^\/socket\/?$/i, /^\/socket\/request\/?$/i, /^\/master(\/|$)/i];
20
+
21
+ const isInternalPath = (path) => INTERNAL_PATHS.some((pattern) => pattern.test(path));
22
+
23
+ /**
24
+ * Express-middleware: служебные пути требуют заголовок x-internal-key = INTERNAL_API_KEY.
25
+ * Ключ не задан — no-op (поведение как раньше), поэтому включается одной переменной на
26
+ * облачном Node вместе с тем же ключом в .env Laravel.
27
+ */
28
+ const internalGuard = (req, res, next) => {
29
+ const expected = internalKey();
30
+ if (!expected || !isInternalPath(req.path)) {
31
+ return next();
32
+ }
33
+ if (safeEqual(req.get(INTERNAL_KEY_HEADER), expected)) {
34
+ return next();
35
+ }
36
+ return res.status(403).json({success: false, message: 'Forbidden'});
37
+ };
38
+
39
+ // ---------------------------------------------------------------- Origin (desktop Node)
40
+
41
+ /**
42
+ * Касса слушает localhost, а CORS у неё `*`: любая страница, открытая в любом браузере на
43
+ * этой машине, могла дёрнуть локальный Node — провести чек в офлайн-ядре, подменить сессию
44
+ * синка, отправить команду фискальному аппарату через /tax. Для «простых» запросов (form-POST)
45
+ * браузер даже не спрашивает preflight, поэтому заголовки CORS тут не защита — отказываем сами.
46
+ *
47
+ * Правило: браузер всегда присылает Origin; нет Origin — это не страница (Electron main,
48
+ * локальный процесс, ведомый терминал) — пропускаем. Origin есть и не из списка — 403.
49
+ */
50
+ const DEFAULT_ALLOWED_ORIGINS = [
51
+ 'https://*.cafepos.az',
52
+ 'https://cafepos.az',
53
+ 'http://localhost:*',
54
+ 'http://127.0.0.1:*',
55
+ ];
56
+
57
+ const originPatterns = () => {
58
+ const raw = process.env.CAFEPOS_ALLOWED_ORIGINS;
59
+ const list = raw ? raw.split(',').map((value) => value.trim()).filter(Boolean) : DEFAULT_ALLOWED_ORIGINS;
60
+ return list.map((value) => new RegExp('^' + value
61
+ .toLowerCase()
62
+ .replace(/\/+$/, '')
63
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
64
+ .replace(/:\*$/, '(:\\d+)?')
65
+ .replace(/\*/g, '[a-z0-9-]+(?:\\.[a-z0-9-]+)*') + '$'));
66
+ };
67
+
68
+ const originAllowed = (origin) => {
69
+ if (origin === undefined || origin === null || origin === '') {
70
+ return true;
71
+ }
72
+ const value = String(origin).toLowerCase().replace(/\/+$/, '');
73
+ return originPatterns().some((pattern) => pattern.test(value));
74
+ };
75
+
76
+ /**
77
+ * Мастер, раздающий фронт по LAN (LAN_FRONT): телефон официанта открывает страницу с самого
78
+ * мастера, и её скрипты и запросы приходят с Origin `http://<IP мастера>:<порт>`. Пускаем только
79
+ * собственные IPv4 этой машины и её порт — чужой адрес в сети так не пройдёт. Сессию синка
80
+ * (strictOriginGuard) это не открывает.
81
+ */
82
+ const lanOriginAllowed = (origin) => {
83
+ if (!origin || !isLanFrontEnabled()) {
84
+ return false;
85
+ }
86
+ const match = /^http:\/\/(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})\/?$/.exec(String(origin).toLowerCase());
87
+ if (!match || Number(match[2]) !== Number(getConfig().port)) {
88
+ return false;
89
+ }
90
+ return require('./sync/lanFront').isOwnAddress(match[1]);
91
+ };
92
+
93
+ /**
94
+ * CAFEPOS_ORIGIN_GUARD: true | false; не задан → включён только на кассе с офлайн-контуром
95
+ * (там локальный Node держит данные и сессию). Облачный Node не трогаем: к нему ходят браузеры
96
+ * с разных доменов, его защищают токены.
97
+ */
98
+ const originGuardActive = () => {
99
+ const flag = process.env.CAFEPOS_ORIGIN_GUARD;
100
+ if (flag === 'true') {
101
+ return true;
102
+ }
103
+ if (flag === 'false') {
104
+ return false;
105
+ }
106
+ return !getConfig().isServer && isSyncEnabled();
107
+ };
108
+
109
+ const refuseOrigin = (req, res) => {
110
+ console.warn(`[security] origin refused: ${req.get('origin')} ${req.method} ${req.path}`);
111
+ return res.status(403).json({success: false, message: 'Forbidden origin'});
112
+ };
113
+
114
+ /** Express-middleware для всего desktop Node (no-op, пока guard не активен). */
115
+ const originGuard = (req, res, next) => {
116
+ if (!originGuardActive() || originAllowed(req.get('origin')) || lanOriginAllowed(req.get('origin'))) {
117
+ return next();
118
+ }
119
+ return refuseOrigin(req, res);
120
+ };
121
+
122
+ /** Для путей, которые обязаны быть защищены всегда (сессия синка), — без флага. */
123
+ const strictOriginGuard = (req, res, next) => (originAllowed(req.get('origin')) ? next() : refuseOrigin(req, res));
124
+
125
+ /** socket.io allowRequest: тот же список для WebSocket (CORS на него не распространяется). */
126
+ const socketAllowRequest = (req, callback) => {
127
+ const origin = req.headers && req.headers.origin;
128
+ if (!originGuardActive() || originAllowed(origin) || lanOriginAllowed(origin)) {
129
+ return callback(null, true);
130
+ }
131
+ console.warn(`[security] socket origin refused: ${req.headers.origin}`);
132
+ return callback('Forbidden origin', false);
133
+ };
134
+
135
+ // ---------------------------------------------------------------- /tax
136
+
137
+ /**
138
+ * Прокси к фискальным аппаратам и банковским терминалам. Цель берётся из запроса, поэтому
139
+ * без проверки это открытый SSRF: link-local 169.254.* — метаданные облака, сам Node — петля.
140
+ * Приватные сети (192.168.*, 10.*) и localhost разрешены: аппараты живут именно там.
141
+ * DNS-имена не резолвим (rebinding не закрыт) — это отдельный шаг, если понадобится.
142
+ */
143
+ const BLOCKED_TAX_HOSTS = new Set(['metadata', 'metadata.google.internal', 'instance-data', '0.0.0.0', '::']);
144
+
145
+ const isLoopbackHost = (host) => host === 'localhost' || host === '::1' || /^127\./.test(host);
146
+
147
+ const taxTarget = (raw) => {
148
+ if (typeof raw !== 'string' || !raw) {
149
+ return null;
150
+ }
151
+ let url;
152
+ try {
153
+ url = new URL(raw);
154
+ } catch (e) {
155
+ return null;
156
+ }
157
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
158
+ return null;
159
+ }
160
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
161
+ if (!host || BLOCKED_TAX_HOSTS.has(host) || /^169\.254\./.test(host) || /^fe80:/.test(host)
162
+ || /^::ffff:(169\.254\.|a9fe:)/.test(host)) {
163
+ return null;
164
+ }
165
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
166
+ if (isLoopbackHost(host) && String(getConfig().port) === port) {
167
+ return null;
168
+ }
169
+ return url;
170
+ };
171
+
172
+ /** TAX_PROXY_ENABLED: true | false; не задан → только касса (облаку прокси к аппаратам не нужен). */
173
+ const taxProxyEnabled = () => {
174
+ const flag = process.env.TAX_PROXY_ENABLED;
175
+ if (flag === 'true' || flag === 'false') {
176
+ return flag === 'true';
177
+ }
178
+ return !getConfig().isServer;
179
+ };
180
+
181
+ // ---------------------------------------------------------------- логи
182
+
183
+ const SECRET_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'set-cookie',
184
+ INTERNAL_KEY_HEADER.toLowerCase(), 'x-cafepos-lan-key']);
185
+ const SECRET_FIELDS = /pass|token|secret|^pin$|^code$|ic_card|lan_key/i;
186
+
187
+ const redact = (value, isSecret) => {
188
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
189
+ return value;
190
+ }
191
+ const copy = {};
192
+ for (const [key, item] of Object.entries(value)) {
193
+ copy[key] = isSecret(key) && item !== null && item !== undefined && item !== '' ? '***' : item;
194
+ }
195
+ return copy;
196
+ };
197
+
198
+ /** Копия заголовков без секретов (для логов). */
199
+ const redactHeaders = (headers) => redact(headers, (key) => SECRET_HEADERS.has(String(key).toLowerCase()));
200
+
201
+ /** Копия тела без паролей, токенов и PIN (верхний уровень — для логов этого достаточно). */
202
+ const redactBody = (body) => redact(body, (key) => SECRET_FIELDS.test(key));
203
+
204
+ module.exports = {
205
+ safeEqual,
206
+ internalGuard,
207
+ isInternalPath,
208
+ originAllowed,
209
+ lanOriginAllowed,
210
+ originGuard,
211
+ originGuardActive,
212
+ strictOriginGuard,
213
+ socketAllowRequest,
214
+ taxTarget,
215
+ taxProxyEnabled,
216
+ redactHeaders,
217
+ redactBody,
218
+ };
@@ -0,0 +1,160 @@
1
+ const {createHash} = require('node:crypto');
2
+ const axios = require('axios');
3
+ const {getConfig} = require('../config');
4
+
5
+ /**
6
+ * Облачный Node: кто подключился к сокету. Раньше хватало company_id в query, чтобы сесть в
7
+ * комнату любой компании и слушать её бродкасты (чеки, суммы, сотрудники).
8
+ *
9
+ * SOCKET_AUTH (только облачный Node, isServer):
10
+ * off — как раньше (дефолт);
11
+ * warn — проверяем в фоне и пишем в лог, сокет пускаем всегда (смотреть, кто отвалится);
12
+ * enforce — без подтверждённого токена сокет не подключится (connect_error 'unauthorized').
13
+ *
14
+ * Проверяет событие auth.check (токен + team/company/terminal): облачное ядро, если оно его
15
+ * обслуживает, иначе Laravel. Отказ — только на явный ответ 401/403. Laravel недоступен,
16
+ * таймаут или старый Laravel без auth.check —
17
+ * пропускаем с предупреждением: иначе рестарт PHP выбил бы все кассы из сокета разом, а они
18
+ * без него не получают ни печать, ни чужие изменения чеков.
19
+ */
20
+ const MODES = new Set(['off', 'warn', 'enforce']);
21
+
22
+ const mode = () => {
23
+ const value = String(process.env.SOCKET_AUTH || 'off').toLowerCase();
24
+ return getConfig().isServer && MODES.has(value) ? value : 'off';
25
+ };
26
+
27
+ const okTtl = () => Number(process.env.SOCKET_AUTH_CACHE_MS) || 300000;
28
+ const denyTtl = () => Number(process.env.SOCKET_AUTH_DENY_CACHE_MS) || 30000;
29
+ const timeout = () => Number(process.env.SOCKET_AUTH_TIMEOUT_MS) || 5000;
30
+
31
+ const cache = new Map();
32
+ const CACHE_LIMIT = 10000;
33
+
34
+ const cacheKey = (claims) => createHash('sha256')
35
+ .update([claims.token, claims.team_id, claims.company_id, claims.terminal_id].join('|'))
36
+ .digest('hex');
37
+
38
+ const remember = (key, result) => {
39
+ if (cache.size >= CACHE_LIMIT) {
40
+ cache.delete(cache.keys().next().value);
41
+ }
42
+ cache.set(key, {result, expires: Date.now() + (result.ok ? okTtl() : denyTtl())});
43
+ };
44
+
45
+ /** Что сокет утверждает о себе: токен и команда — из auth, компания/терминал — из query. */
46
+ const claimsOf = (socket) => {
47
+ const handshake = socket.handshake || {};
48
+ const auth = handshake.auth || {};
49
+ const query = handshake.query || {};
50
+ return {
51
+ token: auth.token ? String(auth.token) : null,
52
+ team_id: auth.team_id ? String(auth.team_id) : null,
53
+ company_id: query.company_id ? String(query.company_id) : null,
54
+ terminal_id: query.terminal_id || auth.terminal_id ? String(query.terminal_id || auth.terminal_id) : null,
55
+ };
56
+ };
57
+
58
+ /**
59
+ * auth.check обслуживает облачное ядро (CORE_EVENTS) — спрашиваем его прямо в процессе,
60
+ * иначе, как раньше, Laravel по HTTP. Ответ в обоих случаях {status, data}.
61
+ */
62
+ const verify = async (headers) => {
63
+ const core = require('../core');
64
+ if (core.canHandle('auth.check')) {
65
+ const {HttpError} = require('../core/errors');
66
+ try {
67
+ return {status: 200, data: await core.dispatch('auth.check', {headers, params: {}})};
68
+ } catch (e) {
69
+ if (e instanceof HttpError) {
70
+ return {status: e.status, data: e.body};
71
+ }
72
+ throw e;
73
+ }
74
+ }
75
+ return axios.post(`${getConfig().apiUrl}/api/auth.check`, {}, {
76
+ headers,
77
+ timeout: timeout(),
78
+ validateStatus: () => true,
79
+ });
80
+ };
81
+
82
+ const check = async (claims) => {
83
+ if (!claims.token || !claims.team_id || !claims.company_id) {
84
+ return {ok: false, reason: 'missing_credentials'};
85
+ }
86
+ const key = cacheKey(claims);
87
+ const cached = cache.get(key);
88
+ if (cached && cached.expires > Date.now()) {
89
+ return cached.result;
90
+ }
91
+
92
+ const headers = {
93
+ Accept: 'application/json',
94
+ Authorization: `Bearer ${claims.token}`,
95
+ 'team-id': claims.team_id,
96
+ 'company-id': claims.company_id,
97
+ ...(claims.terminal_id ? {'terminal-id': claims.terminal_id} : {}),
98
+ };
99
+
100
+ let response;
101
+ try {
102
+ response = await verify(headers);
103
+ } catch (e) {
104
+ return {ok: true, degraded: true, reason: 'verify_unavailable'};
105
+ }
106
+
107
+ let result;
108
+ if (response.status === 200 && response.data && response.data.ok === true) {
109
+ result = {ok: true};
110
+ } else if (response.status === 401 || response.status === 403) {
111
+ result = {ok: false, reason: (response.data && response.data.reason) || `verify_failed_${response.status}`};
112
+ } else {
113
+ // 404 (старый Laravel), 402 (лицензия), 5xx — не повод рвать сокет; не кэшируем.
114
+ return {ok: true, degraded: true, reason: `verify_status_${response.status}`};
115
+ }
116
+ remember(key, result);
117
+ return result;
118
+ };
119
+
120
+ const describe = (socket, claims) => `company=${claims.company_id} terminal=${claims.terminal_id} ` +
121
+ `proxy=${Boolean(socket.handshake.query && socket.handshake.query.is_proxy_server)} ` +
122
+ `ip=${socket.handshake.address}`;
123
+
124
+ /** socket.io-middleware (io.use). */
125
+ const socketAuth = (socket, next) => {
126
+ const current = mode();
127
+ if (current === 'off') {
128
+ return next();
129
+ }
130
+ const claims = claimsOf(socket);
131
+
132
+ if (current === 'warn') {
133
+ next();
134
+ check(claims).then((result) => {
135
+ if (!result.ok) {
136
+ console.warn(`[socket-auth] would refuse (${result.reason}) ${describe(socket, claims)}`);
137
+ }
138
+ }).catch(() => {});
139
+ return undefined;
140
+ }
141
+
142
+ check(claims).then((result) => {
143
+ if (result.ok) {
144
+ if (result.degraded) {
145
+ console.warn(`[socket-auth] allowed without verification (${result.reason}) ${describe(socket, claims)}`);
146
+ }
147
+ return next();
148
+ }
149
+ console.warn(`[socket-auth] refused (${result.reason}) ${describe(socket, claims)}`);
150
+ const error = new Error('unauthorized');
151
+ error.data = {reason: result.reason};
152
+ return next(error);
153
+ }).catch((e) => {
154
+ console.error('[socket-auth] check failed:', e.message);
155
+ next();
156
+ });
157
+ return undefined;
158
+ };
159
+
160
+ module.exports = {socketAuth, mode, _cache: cache, _check: check};
@@ -0,0 +1,87 @@
1
+ const {findKeyByValue} = require('../../utils');
2
+ const {v7: uuidv7} = require('uuid');
3
+
4
+ const DEFAULT_TIMEOUT = 10000;
5
+
6
+ /**
7
+ * Запрос к конкретному терминалу через его сокет: `request:<event>:<id>` туда,
8
+ * ответ ждём на `response:<event>:<id>`.
9
+ *
10
+ * Раньше жило только внутри HTTP-роута `/socket/request`, куда Laravel ходит за печатью.
11
+ * Офлайн-ядро печатает, находясь в этом же процессе, — ему нужен тот же канал без HTTP-петли
12
+ * к самому себе, поэтому логика вынесена сюда, а роут стал тонкой обёрткой.
13
+ *
14
+ * Никогда не бросает: и «нет сокета», и таймаут приходят обычным ответом
15
+ * `{success:false, message}` — вызывающий (как и Laravel) проверяет только `success`.
16
+ *
17
+ * companyId — терминал обязан принадлежать этой компании (запрос пришёл от мастер-кассы
18
+ * заведения, а не от Laravel, — адресовать чужие терминалы ей нельзя).
19
+ *
20
+ * @param {{terminalId: string, eventName: string, data: *, requestId?: string, timeout?: number, companyId?: string}} options
21
+ * @returns {Promise<*>} ответ терминала как есть
22
+ */
23
+ const socketRequest = ({terminalId, eventName, data = null, requestId = null, timeout = DEFAULT_TIMEOUT, companyId = null} = {}) => {
24
+ // Сервер берём лениво на каждый вызов: модуль грузится ядром раньше, чем поднимается
25
+ // socket.io, а прямой require на старте замкнул бы цикл server.js → core → socket/server.
26
+ const serverSocket = require('./server')();
27
+ let socket = serverSocket
28
+ ? findKeyByValue(serverSocket.sockets.sockets, 'terminalId', terminalId)
29
+ : null;
30
+ if (socket && companyId && String(socket.companyId) !== String(companyId)) {
31
+ socket = null;
32
+ }
33
+
34
+ if (!socket) {
35
+ // Модель M: касса — мастер заведения, а терминал работает через облако. Запрос уходит
36
+ // туда по сигнальному сокету. Вне этого режима terminalRequest отдаёт null.
37
+ const viaCloud = companyId ? null : cloudTerminalRequest({terminalId, eventName, data, requestId, timeout});
38
+ return viaCloud || Promise.resolve({
39
+ success: false,
40
+ message: 'No socket',
41
+ });
42
+ }
43
+
44
+ // Без request_id имена событий у параллельных запросов совпали бы, и ответ одного
45
+ // принтера засчитался бы другому, — поэтому пустой id заменяем свежим.
46
+ const id = requestId === null || requestId === undefined || requestId === '' ? uuidv7() : requestId;
47
+ const requestEventName = `request:${eventName}:${id}`;
48
+ const responseEventName = `response:${eventName}:${id}`;
49
+ // Значение отдаём setTimeout как есть — ровно как делал роут (строка из тела тоже годится).
50
+ const waitMs = timeout ?? DEFAULT_TIMEOUT;
51
+
52
+ return new Promise((resolve) => {
53
+ let timer = null;
54
+
55
+ const onResponse = (response) => {
56
+ clearTimeout(timer);
57
+ resolve(response);
58
+ };
59
+
60
+ socket.once(responseEventName, onResponse);
61
+ socket.emit(requestEventName, data);
62
+
63
+ timer = setTimeout(() => {
64
+ // Слушатель снимаем, иначе поздний ответ висел бы на сокете до его закрытия.
65
+ socket.off(responseEventName, onResponse);
66
+ resolve({
67
+ success: false,
68
+ message: 'Timeout error',
69
+ });
70
+ }, waitMs);
71
+ });
72
+ };
73
+
74
+ const cloudTerminalRequest = (options) => {
75
+ try {
76
+ const syncConfig = require('../sync/config');
77
+ if (!syncConfig.isSyncEnabled()) {
78
+ return null;
79
+ }
80
+ return require('../sync').terminalRequest({...options, timeout: options.timeout ?? DEFAULT_TIMEOUT});
81
+ } catch (e) {
82
+ console.error('[socket] cloud terminal request failed:', e.message);
83
+ return null;
84
+ }
85
+ };
86
+
87
+ module.exports = {socketRequest, DEFAULT_TIMEOUT};