@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,196 @@
1
+ const moment = require('moment');
2
+
3
+ const {HttpError} = require('./errors');
4
+ const {nowSql, SQL_FORMAT, now} = require('./support/time');
5
+
6
+ /**
7
+ * Exactly-once для мутаций ядра — порт App\Http\Middleware\IdempotencyMiddleware (БАГ A).
8
+ *
9
+ * Зачем дубль логики на JS: в облаке событие, забранное ядром, до Laravel не доходит, а
10
+ * значит и до его middleware. Без этой проверки один жест кассы (сокет + HTTP-fallback,
11
+ * реплей буфера при reconnect, ретрай оператора) снова добавлял бы позицию дважды.
12
+ *
13
+ * Таблица та же — `idempotency_keys`, и формат хранимого ответа тот же (JSON тела ответа).
14
+ * Поэтому событие можно возить туда-сюда между Laravel и Node на одном ключе: кто первый
15
+ * захватил, тот и выполнил, второй канал получит сохранённый ответ независимо от того,
16
+ * какая из двух реализаций его записала.
17
+ *
18
+ * Модель захвата — как в PHP: лёгкий atomic INSERT без удержания транзакции на время
19
+ * обработчика (иначе конкурентный дубль блокировался бы на всё время внешнего I/O).
20
+ */
21
+ const flag = (name, fallback) => (process.env[name] ?? fallback);
22
+
23
+ const isEnabled = () => flag('CORE_IDEMPOTENCY', 'true') === 'true';
24
+
25
+ const config = () => ({
26
+ ttlHours: Number(flag('IDEMPOTENCY_TTL_HOURS', 24)),
27
+ waitMs: Number(flag('IDEMPOTENCY_WAIT_MS', 2500)),
28
+ waitStepMs: Math.max(50, Number(flag('IDEMPOTENCY_WAIT_STEP_MS', 150))),
29
+ stalePendingSeconds: Number(flag('IDEMPOTENCY_STALE_PENDING_SECONDS', 190)),
30
+ });
31
+
32
+ const MAX_KEY_LENGTH = 64;
33
+
34
+ /** Ключ жеста: заголовок Idempotency-Key либо operation_id в теле — как у Laravel. */
35
+ const resolveKey = (value) => {
36
+ if (typeof value !== 'string') {
37
+ return null;
38
+ }
39
+ const key = value.trim();
40
+ if (!key || key.length > MAX_KEY_LENGTH) {
41
+ return null;
42
+ }
43
+ return key;
44
+ };
45
+
46
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
47
+
48
+ class Idempotency {
49
+ constructor(db) {
50
+ this.db = db;
51
+ this.config = config();
52
+ }
53
+
54
+ /**
55
+ * Захват ключа. true — мы владельцы и обязаны выполнить операцию ровно один раз.
56
+ * false — ключ уже занят: операция выполняется или выполнена другим каналом.
57
+ *
58
+ * Вместо ловли ошибки уникальности используем ON CONFLICT/OR IGNORE: разбор кодов
59
+ * ошибок у pg и node:sqlite разный, а результат нужен один — вставили или нет.
60
+ */
61
+ async claim(key, {companyId = null, event = null} = {}) {
62
+ if (await this.insertPending(key, companyId, event)) {
63
+ return true;
64
+ }
65
+
66
+ // Занято. Если pending остался от умершего процесса — перезахватываем.
67
+ const row = await this.find(key);
68
+ if (row && row.status === 'pending' && this.isStale(row)) {
69
+ return this.reclaimStale(key, companyId, event);
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * Дубль: отдаём сохранённый ответ, если первый уже закончил, иначе коротко ждём его.
76
+ * Ждать долго нельзя (печать держит операцию до двух минут) → 409 с маркером
77
+ * in_progress: фронт по нему не шумит ошибкой и повторит с тем же ключом.
78
+ */
79
+ async replay(key) {
80
+ const row = (await this.find(key)) || null;
81
+ const done = row && row.status === 'done' ? row : await this.pollDone(key);
82
+
83
+ if (done) {
84
+ return {replayed: true, result: this.decode(done.response)};
85
+ }
86
+
87
+ throw new HttpError(409, {message: 'Operation in progress', in_progress: true});
88
+ }
89
+
90
+ async markDone(key, result) {
91
+ await this.db.run(
92
+ `UPDATE idempotency_keys
93
+ SET status = 'done', http_status = 200, response = ?, updated_at = ?
94
+ WHERE operation_id = ?`,
95
+ [JSON.stringify(result === undefined ? null : result), nowSql(), key]
96
+ );
97
+ }
98
+
99
+ /** Ошибка не кэшируется: честный повтор жеста должен выполниться заново. */
100
+ async release(key) {
101
+ await this.db.run('DELETE FROM idempotency_keys WHERE operation_id = ?', [key]);
102
+ }
103
+
104
+ /** Локальному мастеру чистить некому — в облаке это делает команда Laravel. */
105
+ async cleanup(limit = 1000) {
106
+ const result = await this.db.run(
107
+ `DELETE FROM idempotency_keys
108
+ WHERE operation_id IN (
109
+ SELECT operation_id FROM idempotency_keys WHERE expires_at < ? LIMIT ?
110
+ )`,
111
+ [nowSql(), limit]
112
+ );
113
+ return result?.changes ?? 0;
114
+ }
115
+
116
+ /* ------------------------------------------------------------------ */
117
+
118
+ async insertPending(key, companyId, event) {
119
+ const timestamp = nowSql();
120
+ const expires = moment.utc(now()).add(this.config.ttlHours, 'hours').format(SQL_FORMAT);
121
+ const columns = '(operation_id, company_id, event, status, created_at, updated_at, expires_at)';
122
+ const values = [key, companyId, event, 'pending', timestamp, timestamp, expires];
123
+
124
+ const sql = this.db.dialect === 'sqlite'
125
+ ? `INSERT OR IGNORE INTO idempotency_keys ${columns} VALUES (?, ?, ?, ?, ?, ?, ?)`
126
+ : `INSERT INTO idempotency_keys ${columns} VALUES (?, ?, ?, ?, ?, ?, ?)
127
+ ON CONFLICT (operation_id) DO NOTHING`;
128
+
129
+ const result = await this.db.run(sql, values);
130
+ return (result?.changes ?? 0) > 0;
131
+ }
132
+
133
+ /** Удалить брошенный pending и вставить свой — владеет только победитель гонки. */
134
+ async reclaimStale(key, companyId, event) {
135
+ const staleBefore = moment.utc(now())
136
+ .subtract(this.config.stalePendingSeconds, 'seconds')
137
+ .format(SQL_FORMAT);
138
+
139
+ return this.db.transaction(async (tx) => {
140
+ const deleted = await tx.run(
141
+ `DELETE FROM idempotency_keys
142
+ WHERE operation_id = ? AND status = 'pending' AND created_at < ?`,
143
+ [key, staleBefore]
144
+ );
145
+ if ((deleted?.changes ?? 0) === 0) {
146
+ return false; // кто-то опередил с перезахватом или уже завершил
147
+ }
148
+ const scoped = new Idempotency(tx);
149
+ return scoped.insertPending(key, companyId, event);
150
+ });
151
+ }
152
+
153
+ async pollDone(key) {
154
+ let waited = 0;
155
+ while (waited < this.config.waitMs) {
156
+ await sleep(this.config.waitStepMs);
157
+ waited += this.config.waitStepMs;
158
+
159
+ const row = await this.find(key);
160
+ if (!row) {
161
+ return null; // первый освободил ключ из-за ошибки → повтор выполнит заново
162
+ }
163
+ if (row.status === 'done') {
164
+ return row;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ find(key) {
171
+ return this.db.get('SELECT * FROM idempotency_keys WHERE operation_id = ?', [key]);
172
+ }
173
+
174
+ isStale(row) {
175
+ if (!row.created_at) {
176
+ return false;
177
+ }
178
+ const created = moment.utc(row.created_at, [SQL_FORMAT, moment.ISO_8601]);
179
+ return created.isValid()
180
+ && created.valueOf() < now().getTime() - this.config.stalePendingSeconds * 1000;
181
+ }
182
+
183
+ decode(response) {
184
+ if (response === null || response === undefined || response === '') {
185
+ return null;
186
+ }
187
+ try {
188
+ return JSON.parse(response);
189
+ } catch (e) {
190
+ // Ответ записан Laravel и почему-то не JSON — вернуть как есть честнее, чем упасть.
191
+ return response;
192
+ }
193
+ }
194
+ }
195
+
196
+ module.exports = {Idempotency, isEnabled, resolveKey};
@@ -0,0 +1,374 @@
1
+ const {getDatabase, Database} = require('../db/Database');
2
+ const Context = require('./Context');
3
+ const SocketEmitter = require('./emitter');
4
+ const OrderService = require('./services/OrderService');
5
+ const OrderProductService = require('./services/OrderProductService');
6
+ const OutboxRepository = require('./repositories/OutboxRepository');
7
+ const {Idempotency, isEnabled: idempotencyEnabled, resolveKey} = require('./idempotency');
8
+ const {HttpError} = require('./errors');
9
+ const syncConfig = require('../sync/config');
10
+ const auth = require('./auth');
11
+
12
+ /**
13
+ * Точка входа ядра — POS-API, перенесённый с PHP на JS.
14
+ *
15
+ * Один и тот же код работает в двух контурах (см. app/db/driver.js):
16
+ * CORE_MODE=cloud — облако, общая с Laravel Postgres-база. Laravel остаётся рядом как откат,
17
+ * Node забирает события постепенно (список CORE_EVENTS).
18
+ * CORE_MODE=local — локальный мастер: одна касса держит SQLite, остальные терминалы ходят
19
+ * к ней; мутации дублируются в outbox для досылки наверх.
20
+ * По умолчанию режим выводится из диалекта БД, отдельно его задавать не нужно.
21
+ *
22
+ * Флаг OFFLINE_CORE по умолчанию ВЫКЛЮЧЕН: тогда роут делает next(), запрос доходит до прокси
23
+ * и уезжает в Laravel — сервер ведёт себя ровно как сегодня.
24
+ *
25
+ * Регистрация событий живёт в routes/common/api/*.js — здесь только реестр и исполнение,
26
+ * общие для двух транспортов: HTTP (express) и socket.io (`api:request`, горячий путь фронта).
27
+ */
28
+ const isEnabled = () => (process.env.OFFLINE_CORE ?? 'false') === 'true';
29
+
30
+ const mode = () => {
31
+ const value = (process.env.CORE_MODE || '').toLowerCase();
32
+ if (value === 'cloud' || value === 'local') {
33
+ return value;
34
+ }
35
+ return Database.defaultDialect() === 'sqlite' ? 'local' : 'cloud';
36
+ };
37
+
38
+ /**
39
+ * Поэтапный перевод: CORE_EVENTS — список событий, которые ядру разрешено обслуживать
40
+ * («orders.getById,orders.archive»). Пусто = все зарегистрированные. Всё остальное уходит
41
+ * в Laravel как раньше, поэтому откат любого события — это правка одной переменной.
42
+ */
43
+ let allowCache = {raw: null, set: null};
44
+ const allowed = (event) => {
45
+ const raw = process.env.CORE_EVENTS || '';
46
+ if (!raw.trim()) {
47
+ return true;
48
+ }
49
+ if (allowCache.raw !== raw) {
50
+ allowCache = {
51
+ raw,
52
+ set: new Set(raw.split(',').map((item) => item.trim()).filter(Boolean)),
53
+ };
54
+ }
55
+ return allowCache.set.has(event);
56
+ };
57
+
58
+ /**
59
+ * event → {mutation, localOnly, followerLocal, cloudOnly, isPublic, action}. Заполняется при
60
+ * загрузке роутов. isPublic — событие без проверки токена (вход по логину: у Laravel это отдельный
61
+ * роут `app.auth` без middleware).
62
+ */
63
+ const registry = new Map();
64
+
65
+ /**
66
+ * Пути, которые ядро обслуживает локально. Нужны server.js: только у локальных роутов тело
67
+ * можно парсить (см. комментарий к LOCAL_BODY_ROUTES) — проксируемые обязаны идти сырым потоком.
68
+ */
69
+ const registeredPaths = new Map(); // path → event
70
+
71
+ /**
72
+ * Модель F (OFFLINE_SYNC, см. app/sync): облако — основная система, ядро включается только
73
+ * на время обрыва связи. В режиме cloud событие уходит в прокси (в облако) как раньше, в
74
+ * offline/recovering его обслуживает локальная БД.
75
+ *
76
+ * localOnly — обработчики, нужные только офлайн (app.init, вход по PIN, смены): без синка
77
+ * их всегда обслуживает Laravel, иначе ядро перехватило бы то, что сегодня умеет только он.
78
+ */
79
+ const syncGate = (entry) => {
80
+ if (!syncConfig.isSyncEnabled()) {
81
+ return !entry.localOnly;
82
+ }
83
+ // Лениво: app/sync сам зовёт ядро (эмит патчей), прямой require замкнул бы цикл.
84
+ return require('../sync').mode() !== 'cloud';
85
+ };
86
+
87
+ /**
88
+ * cloudOnly — события облачного контура (auth.check): на кассе их по-прежнему обслуживает
89
+ * облако через прокси, у кассы нет ни токенов, ни данных для ответа.
90
+ */
91
+ const contourGate = (entry) => !entry.cloudOnly || mode() === 'cloud';
92
+
93
+ const canHandle = (event) => {
94
+ if (!isEnabled()) {
95
+ return false;
96
+ }
97
+ const entry = registry.get(event);
98
+ return Boolean(entry) && allowed(event) && contourGate(entry) && syncGate(entry);
99
+ };
100
+
101
+ const isOfflineRoute = (path) => {
102
+ if (!isEnabled()) {
103
+ return false;
104
+ }
105
+ const event = registeredPaths.get(path);
106
+ return Boolean(event) && canHandle(event);
107
+ };
108
+
109
+ /** Заголовки, которые нужны апстриму при досылке операции из outbox. */
110
+ const FORWARD_HEADERS = [
111
+ 'team-id', 'company-id', 'terminal-id', 'employee-id',
112
+ 'timestamp', 'ip-address', 'authorization', 'idempotency-key',
113
+ ];
114
+
115
+ /** Заголовки приходят и в нижнем регистре (express), и в исходном (socket-payload фронта). */
116
+ const header = (headers, name) => {
117
+ if (!headers) {
118
+ return null;
119
+ }
120
+ if (headers[name] !== undefined) {
121
+ return headers[name];
122
+ }
123
+ const found = Object.keys(headers).find((key) => key.toLowerCase() === name);
124
+ return found ? headers[found] : null;
125
+ };
126
+
127
+ const forwardHeaders = (headers) => {
128
+ const result = {};
129
+ for (const name of FORWARD_HEADERS) {
130
+ const value = header(headers, name);
131
+ if (value) {
132
+ result[name] = value;
133
+ }
134
+ }
135
+ return result;
136
+ };
137
+
138
+ // Печать у мастера ждёт принтер (цеха параллельно, до 120с) — запас как у сокет-прокси.
139
+ const SLOW_EVENTS = new Set(['orders.sendToWorkshops', 'orders.printBillReceipt']);
140
+
141
+ /** Событие ведомого → мастер зала. Ответ мастера отдаётся как есть, ошибка — HttpError. */
142
+ const forwardToMaster = async (event, {headers = {}, params = {}}) => {
143
+ const axios = require('axios');
144
+ const {lanHeaders} = require('../sync/lan');
145
+ const masterUrl = syncConfig.masterUrl();
146
+ const unavailable = () => new HttpError(503, {success: false, message: 'Master terminal is unavailable', error_code: 'master_unavailable'});
147
+ // OFFLINE_MASTER_URL=auto, а мастер в сети ещё не найден
148
+ if (!masterUrl) {
149
+ throw unavailable();
150
+ }
151
+ let response;
152
+ try {
153
+ response = await axios.post(`${masterUrl}/api/${event}`, params, {
154
+ headers: {
155
+ ...forwardHeaders(headers),
156
+ Accept: 'application/json',
157
+ 'Content-Type': 'application/json',
158
+ ...lanHeaders(),
159
+ },
160
+ timeout: SLOW_EVENTS.has(event) ? 150000 : syncConfig.timeouts().master,
161
+ validateStatus: () => true,
162
+ });
163
+ } catch (e) {
164
+ throw unavailable();
165
+ }
166
+ if (response.status >= 400) {
167
+ throw new HttpError(response.status, response.data || {success: false, message: `HTTP ${response.status}`});
168
+ }
169
+ return response.data === '' || response.data === undefined ? null : response.data;
170
+ };
171
+
172
+ /** Адрес клиента для логов scope. За Traefik — первый адрес X-Forwarded-For. */
173
+ const clientIp = (req) => {
174
+ const forwarded = req.headers && req.headers['x-forwarded-for'];
175
+ if (typeof forwarded === 'string' && forwarded.trim()) {
176
+ return forwarded.split(',')[0].trim();
177
+ }
178
+ return req.ip || (req.socket && req.socket.remoteAddress) || null;
179
+ };
180
+
181
+ /** Сервисы на запрос — прямой аналог резолва PlatformService + сервисов из контейнера Laravel. */
182
+ const makeServices = async ({headers = {}, params = {}, team = undefined, locale = undefined, db = null} = {}) => {
183
+ db = db || await getDatabase();
184
+ const context = new Context({
185
+ team,
186
+ locale,
187
+ teamId: header(headers, 'team-id'),
188
+ companyId: header(headers, 'company-id'),
189
+ terminalId: header(headers, 'terminal-id'),
190
+ employeeId: header(headers, 'employee-id'),
191
+ timestamp: header(headers, 'timestamp'),
192
+ ipAddress: header(headers, 'ip-address'),
193
+ operationId: header(headers, 'idempotency-key') || params.operation_id || null,
194
+ db,
195
+ });
196
+ // Один раз читаем team/company/terminal/employee, дальше геттеры контекста синхронны.
197
+ await context.load();
198
+
199
+ const emitter = new SocketEmitter({
200
+ db,
201
+ context,
202
+ // Отдельный экземпляр без эмиттера: нужен только для чтения списка открытых чеков.
203
+ buildOpenOrders: (companyId) => new OrderService({db, context}).getOpenOrders(companyId),
204
+ });
205
+
206
+ return {
207
+ db,
208
+ context,
209
+ emitter,
210
+ orders: new OrderService({db, context, emitter}),
211
+ orderProducts: new OrderProductService({db, context, emitter}),
212
+ outbox: new OutboxRepository(db),
213
+ };
214
+ };
215
+
216
+ /**
217
+ * Выполнение события ядром. Бросает HttpError, если сервис вернул ошибку с кодом.
218
+ *
219
+ * @param {string} event имя события Laravel («orders.create»)
220
+ * @param {{headers: object, params: object, ip?: string}} request
221
+ */
222
+ const runEvent = async (event, {headers = {}, params = {}, ip = null} = {}) => {
223
+ const handler = registry.get(event);
224
+ if (!handler) {
225
+ throw new HttpError(404, {success: false, message: `Unknown event ${event}`});
226
+ }
227
+
228
+ // Облако: токен, лицензия, scope и режим заведения — до всего остального, как цепочка
229
+ // middleware Laravel. Заведение в `local` — событие выполнил мастер, отдаём его ответ.
230
+ let authorized = null;
231
+ if (!handler.isPublic && mode() === 'cloud' && auth.isEnabled()) {
232
+ const db = await getDatabase();
233
+ authorized = await auth.authorize(db, {event, headers, params, ip});
234
+ if (authorized.relayed) {
235
+ const {status, body} = authorized.relayed;
236
+ if (status >= 400) {
237
+ throw new HttpError(status, body ?? {});
238
+ }
239
+ return body === undefined ? null : body;
240
+ }
241
+ }
242
+
243
+ // Ведомый терминал зала своей БД не ведёт: чеки живут у мастера, туда и уходит событие
244
+ // (вместе с ключом идемпотентности — дедуп делает мастер).
245
+ if (syncConfig.isSyncEnabled() && syncConfig.role() === 'follower' && !handler.followerLocal) {
246
+ return forwardToMaster(event, {headers, params});
247
+ }
248
+
249
+ // Модель M, to_online: касса досылает очередь и вот-вот отдаст заведение облаку. Новая
250
+ // правка сейчас продлила бы очередь и могла разойтись с облаком — просим подождать.
251
+ if (handler.mutation && syncConfig.isSyncEnabled() && require('../sync').mode() === 'draining') {
252
+ throw new HttpError(409, {
253
+ success: false,
254
+ error_code: 'mode_switching',
255
+ message: 'Rejim dəyişdirilir, bir az gözləyin',
256
+ });
257
+ }
258
+
259
+ const services = await makeServices({
260
+ headers,
261
+ params,
262
+ team: authorized ? authorized.team : undefined,
263
+ locale: authorized ? authorized.locale : undefined,
264
+ });
265
+
266
+ // Дедуп жеста. Забранное ядром событие до middleware Laravel не доходит — проверку
267
+ // exactly-once делаем здесь, на той же таблице idempotency_keys и по тому же ключу.
268
+ // Чтения не дедупим: у них нет побочных эффектов, а ключ на них фронт и не шлёт.
269
+ const key = handler.mutation && idempotencyEnabled()
270
+ ? resolveKey(services.context.operationId)
271
+ : null;
272
+ const guard = key ? new Idempotency(services.db) : null;
273
+ const owned = guard
274
+ ? await guard.claim(key, {companyId: services.context.companyId, event})
275
+ : false;
276
+
277
+ if (guard && !owned) {
278
+ // Тот же жест уже выполняется/выполнен: отдаём сохранённый ответ либо 409 in_progress.
279
+ return (await guard.replay(key)).result;
280
+ }
281
+
282
+ let result;
283
+ try {
284
+ // Третий аргумент нужен редким обработчикам, которым мало контекста (ведомый app.init
285
+ // спрашивает у мастера открытые чеки с исходными заголовками терминала).
286
+ result = await handler.action(services, params, {event, headers, ip, auth: authorized});
287
+ } catch (e) {
288
+ if (owned) {
289
+ await guard.release(key);
290
+ }
291
+ throw e;
292
+ }
293
+
294
+ if (owned) {
295
+ await guard.markDone(key, result === undefined ? null : result);
296
+ }
297
+
298
+ // Outbox — только у локального мастера: в облаке запись уже в целевой базе,
299
+ // досылать её некуда, да и таблицы outbox там нет.
300
+ // С синком (модель F) outbox не нужен: наверх уходит агрегат чека по пометкам sync_dirty.
301
+ if (handler.mutation && mode() === 'local' && !syncConfig.isSyncEnabled()) {
302
+ // Пишем ПОСЛЕ успешной обработки: провалившаяся операция не уедет наверх.
303
+ try {
304
+ await services.outbox.push({
305
+ event,
306
+ payload: params,
307
+ headers: forwardHeaders(headers),
308
+ operationId: services.context.operationId,
309
+ });
310
+ } catch (e) {
311
+ console.error(`[core] outbox push failed for ${event}:`, e.message);
312
+ }
313
+ }
314
+
315
+ return result === undefined ? null : result;
316
+ };
317
+
318
+ /** Транспорт socket.io: тот же реестр, но результат/ошибка отдаются вызывающему. */
319
+ const dispatch = (event, request) => runEvent(event, request);
320
+
321
+ /**
322
+ * Регистрирует карту обработчиков на роутере: ключ — имя события, значение — [options, action].
323
+ * Метод не фиксируем (router.all): Laravel принимает событие через Route::any, фронт ходит
324
+ * и POST'ом (сокет-фолбэк), и GET'ом (например orders.archive).
325
+ */
326
+ const registerOfflineRoutes = (router, prefix, handlers) => {
327
+ for (const [event, [options, action]] of Object.entries(handlers)) {
328
+ registry.set(event, {
329
+ mutation: Boolean(options.mutation),
330
+ localOnly: Boolean(options.localOnly),
331
+ followerLocal: Boolean(options.followerLocal),
332
+ cloudOnly: Boolean(options.cloudOnly),
333
+ isPublic: Boolean(options.public),
334
+ action,
335
+ });
336
+ registeredPaths.set(`${prefix}/${event}`, event);
337
+
338
+ router.all(`/${event}`, async (req, res, next) => {
339
+ // Решение «локально или в прокси» принимается один раз — там же, где решается,
340
+ // парсить ли тело (server.js). Режим синка может смениться между этими точками, а
341
+ // распарсенное тело нельзя отдать прокси, как и нераспарсенное — ядру.
342
+ const local = typeof req.coreLocal === 'boolean' ? req.coreLocal : canHandle(event);
343
+ if (!local) {
344
+ return next();
345
+ }
346
+ try {
347
+ // Laravel $request->get() читает и query, и тело — повторяем порядок.
348
+ const params = {...(req.query || {}), ...(req.body || {})};
349
+ return res.json(await runEvent(event, {headers: req.headers, params, ip: clientIp(req)}));
350
+ } catch (e) {
351
+ if (e instanceof HttpError) {
352
+ if (e.headers) {
353
+ res.set(e.headers);
354
+ }
355
+ return res.status(e.status).json(e.body);
356
+ }
357
+ console.error(`[core] ${event} failed:`, e);
358
+ return res.status(500).json({success: false, message: e.message});
359
+ }
360
+ });
361
+ }
362
+ return router;
363
+ };
364
+
365
+ module.exports = {
366
+ isEnabled,
367
+ mode,
368
+ canHandle,
369
+ dispatch,
370
+ isOfflineRoute,
371
+ makeServices,
372
+ forwardToMaster,
373
+ registerOfflineRoutes,
374
+ };