@alizadefaig/pos-server 1.2.26 → 1.2.28

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 (117) 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 +117 -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 +279 -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 +911 -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 +11 -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 +29 -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 +72 -3
  115. package/utils.js +6 -0
  116. package/tests/usbDiagnose.js +0 -59
  117. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,1644 @@
1
+ const axios = require('axios');
2
+ const kv = require('./kv');
3
+ const config = require('./config');
4
+ const {getStore} = require('./store');
5
+ const {CloudClient, CloudRequestError, normalizeApiUrl} = require('./cloud');
6
+ const {CloudSignal} = require('./signal');
7
+ const {MasterBridge} = require('./bridge');
8
+ const {lanHeaders} = require('./lan');
9
+ const discovery = require('./discovery');
10
+ const lanDevices = require('./lanDevices');
11
+ const lanFront = require('./lanFront');
12
+ const {getConfig} = require('../config');
13
+ const mirror = require('./mirror');
14
+ const snapshot = require('./snapshot');
15
+ const {pushBatch, pendingCount, foreignPending} = require('./push');
16
+ const reconcile = require('./reconcile');
17
+ const monitor = require('../monitor');
18
+ const {HttpError} = require('../core/errors');
19
+ const {unlockThrottle, findUnlockUser, throttleKey} = require('../core/support/unlock');
20
+ const {version: PACKAGE_VERSION} = require('../../package.json');
21
+
22
+ /**
23
+ * Оркестратор синка локального Node с облаком (OFFLINE-FIRST-PLAN §11, модель F).
24
+ *
25
+ * Облако — основная система. Локальный Node держит зеркало (справочники + чеки) и решает,
26
+ * куда ходить фронту: фронт сам сеть не проверяет, а читает `mode` из GET /local/status.
27
+ *
28
+ * cloud — фронт работает с облаком как сегодня; Node в фоне тянет зеркало;
29
+ * offline — облака нет: фронт переключён на локальный Node, правки копятся в sync_dirty;
30
+ * recovering — связь вернулась, офлайн-правки ещё не досланы: фронт остаётся на локальном
31
+ * Node, пока push не опустошит очередь, — иначе он увидел бы в облаке чек без
32
+ * своих офлайн-позиций.
33
+ *
34
+ * Связь меряется гистерезисом: failuresToOffline провалов подряд → offline,
35
+ * successesToOnline успехов подряд → online. Одиночный таймаут режим не дёргает.
36
+ *
37
+ * Всё выключено, пока нет OFFLINE_CORE + OFFLINE_SYNC + CORE_DB=sqlite (config.isSyncEnabled):
38
+ * mode() тогда null, и ядро ведёт себя как раньше.
39
+ *
40
+ * Модель M (§12) — поверх: режим заведения ведёт облако (companies.pos_mode, суперадминка), касса
41
+ * узнаёт его опросом sync.mode (заодно heartbeat) и сразу по сокет-событию pos:mode.
42
+ *
43
+ * local — эта касса — мастер заведения: истина чеков у неё, интернет не нужен. Клиенты облака
44
+ * ходят к ней через облачный Node (master:request), выгрузка в облако авторитетна;
45
+ * draining — заведение возвращается в онлайн: мастер досылает очередь, правок не принимает,
46
+ * затем releaseMaster.
47
+ * Переход в local: заведение в to_local → касса выгружает недосланное, забирает облачную копию
48
+ * целиком и только после этого claimMaster. Пока идёт переход, облако правок не принимает
49
+ * (409 mode_switching), поэтому забранная копия полная.
50
+ */
51
+
52
+ const state = {
53
+ started: false,
54
+ starting: null,
55
+ store: null,
56
+ session: null,
57
+ cloud: null,
58
+ signal: null,
59
+ bridge: null,
60
+ // автопоиск мастера в зале: finder — у ведомого с OFFLINE_MASTER_URL=auto, responder — у мастера
61
+ finder: null,
62
+ responder: null,
63
+ timer: null,
64
+ wakeTimer: null,
65
+ running: false,
66
+ rerun: false,
67
+
68
+ mode: 'cloud',
69
+ online: null,
70
+ fails: 0,
71
+ oks: 0,
72
+ lastOkAt: null,
73
+ lastError: null,
74
+
75
+ pending: 0,
76
+ foreignPending: [],
77
+ needSnapshot: true,
78
+ lastSnapshotTry: 0,
79
+ // снапшот по сигналу облака — не раньше этого момента (SNAPSHOT_SIGNAL_MIN_MS)
80
+ snapshotNotBefore: 0,
81
+ lastSnapshotOk: 0,
82
+ lastPullAt: 0,
83
+ pullRequested: false,
84
+ lastPushAt: null,
85
+ lastPushError: null,
86
+ batchSize: null,
87
+
88
+ // 'entity:id' → {version, attempts, reason}: запись, которую облако раз за разом не принимает
89
+ poison: new Map(),
90
+ conflicts: [],
91
+
92
+ master: {reachable: null, mode: null, checkedAt: null, error: null},
93
+
94
+ // модель M: режим заведения из sync.mode {company_id, state, master_terminal_id, epoch, enabled}
95
+ company: null,
96
+ modeRequested: true,
97
+ lastModePoll: 0,
98
+ lastModeError: null,
99
+ handover: {stage: null, epoch: null, error: null, at: null, lastTry: 0},
100
+ // чеки, которые облако просит перечитать (sync:pull)
101
+ pullIds: new Set(),
102
+
103
+ // выход терминала (logout): с какого момента облако на связи, а очередь ещё не пуста;
104
+ // идёт отзыв токена — возврат на кассу (resume) в это время невозможен
105
+ revokeWaitFrom: null,
106
+ revoking: false,
107
+
108
+ // сверка касса ↔ облако (reconcile.js): последний итог, расхождений подряд, сколько вылечено
109
+ reconcile: {
110
+ nextAt: 0, at: null, ok: null, streak: 0, local: null, cloud: null,
111
+ diff: null, truncated: false, healed: 0, error: null,
112
+ },
113
+ };
114
+
115
+ const SNAPSHOT_RETRY_MS = 60000;
116
+ // Снапшот по сигналу облака (sync:snapshot) — не чаще: серия правок в бэк-офисе даёт один снапшот.
117
+ const SNAPSHOT_SIGNAL_MIN_MS = 10000;
118
+ const MAX_PUSH_ROUNDS = 20;
119
+ const MAX_CONFLICTS_KEPT = 20;
120
+ const HANDOVER_RETRY_MS = 10000;
121
+ const MAX_HANDOVER_PULL_ROUNDS = 50;
122
+ const PULL_IDS_CHUNK = 200;
123
+ // выход терминала: отзыв токена не дольше этого (фронт ждёт ответ на /local/logout)
124
+ const REVOKE_TIMEOUT_MS = 3000;
125
+ // токенов прошлых сессий в очереди на отзыв
126
+ const MAX_LOGOUT_QUEUE = 20;
127
+
128
+ const COMPANY_STATES = new Set(['online', 'to_local', 'local', 'to_online']);
129
+ const MASTER_STATES = new Set(['to_local', 'local', 'to_online']);
130
+ // Режимы, в которых фронт работает с локальным Node (а не с облаком).
131
+ const LOCAL_MODES = new Set(['offline', 'recovering', 'local', 'draining']);
132
+ // Эти события облако кассе не ретранслирует (LocalModeMiddleware их пропускает), а если
133
+ // пришли — не исполняем: вход по PIN и init терминала облака не должны зависеть от кассы.
134
+ const RELAY_DENY = /^(app|auth|sync)\./;
135
+
136
+ const nowIso = () => new Date().toISOString();
137
+
138
+ const log = (...args) => console.log('[sync]', ...args);
139
+
140
+ const isFollower = () => config.role() === 'follower';
141
+
142
+ // ---------------------------------------------------------------- связь (гистерезис)
143
+
144
+ const onLink = (ok, error = null) => {
145
+ const {failuresToOffline, successesToOnline} = config.limits();
146
+ if (ok) {
147
+ state.oks += 1;
148
+ state.fails = 0;
149
+ state.lastOkAt = nowIso();
150
+ if (state.online !== true && (state.online === null || state.oks >= successesToOnline)) {
151
+ if (state.online === false) {
152
+ log('cloud reachable again');
153
+ }
154
+ state.online = true;
155
+ }
156
+ return;
157
+ }
158
+ state.fails += 1;
159
+ state.oks = 0;
160
+ state.lastError = error;
161
+ if (state.online !== false && (state.online === null || state.fails >= failuresToOffline)) {
162
+ log(`cloud unreachable: ${error}`);
163
+ state.online = false;
164
+ }
165
+ };
166
+
167
+ // ---------------------------------------------------------------- режим
168
+
169
+ const excludedKeys = () => {
170
+ const limit = config.limits().poisonAttempts;
171
+ const keys = new Set();
172
+ for (const [key, entry] of state.poison) {
173
+ if (entry.attempts >= limit) {
174
+ keys.add(key);
175
+ }
176
+ }
177
+ return keys;
178
+ };
179
+
180
+ const hasInit = () => Boolean(state.store && kv.get(state.store, 'snapshot_at'));
181
+
182
+ /** Заведение сессии: push и счётчик очереди работают только с его записями (см. push.js). */
183
+ const sessionCompanyId = () => (state.session ? state.session.company_id : null);
184
+
185
+ const refreshPending = () => {
186
+ if (!state.store || isFollower()) {
187
+ state.pending = 0;
188
+ state.foreignPending = [];
189
+ return 0;
190
+ }
191
+ try {
192
+ state.pending = pendingCount(state.store, excludedKeys(), sessionCompanyId());
193
+ state.foreignPending = foreignPending(state.store, sessionCompanyId());
194
+ } catch (e) {
195
+ console.error('[sync] pending count failed:', e.message);
196
+ }
197
+ return state.pending;
198
+ };
199
+
200
+ const computeFollowerMode = () => {
201
+ const master = state.master;
202
+ if (master.reachable) {
203
+ // Мастер работает локально — идём за ним, даже если своя связь есть: иначе один чек
204
+ // правили бы в двух местах сразу.
205
+ if (LOCAL_MODES.has(master.mode)) {
206
+ return master.mode;
207
+ }
208
+ return state.online === false ? 'offline' : 'cloud';
209
+ }
210
+ // Мастера не видно: облако доступно — работаем с ним; нет и облака — работать не с чем,
211
+ // остаёмся на локальном Node, чтобы фронт показал внятную ошибку, а не висел.
212
+ return state.online === false ? 'offline' : 'cloud';
213
+ };
214
+
215
+ /** Эта касса — мастер заведения (модель M): облако назначило её, эпоха своя. */
216
+ const isMasterNow = () => {
217
+ const company = state.company;
218
+ const session = state.session;
219
+ return Boolean(
220
+ company && session && company.enabled
221
+ && company.company_id === session.company_id
222
+ && MASTER_STATES.has(company.state)
223
+ && company.master_terminal_id === session.terminal_id
224
+ );
225
+ };
226
+
227
+ const computeMode = () => {
228
+ if (!config.isSyncEnabled() || !state.session || !hasInit()) {
229
+ return 'cloud';
230
+ }
231
+ if (isFollower()) {
232
+ return computeFollowerMode();
233
+ }
234
+ const pending = refreshPending();
235
+ if (isMasterNow()) {
236
+ // Истина у кассы — связь с облаком для работы не нужна.
237
+ if (state.company.state === 'local') {
238
+ return 'local';
239
+ }
240
+ if (state.company.state === 'to_online') {
241
+ return 'draining';
242
+ }
243
+ // to_local: облако ещё истина, пока касса его не забрала — дальше как модель F.
244
+ }
245
+ if (state.online === false) {
246
+ return 'offline';
247
+ }
248
+ if (state.online === null) {
249
+ // Старт до первой проверки: недосланные правки есть — фронт сразу на локальный Node.
250
+ return pending > 0 ? 'recovering' : 'cloud';
251
+ }
252
+ // Бывший мастер (сменили мастера, force_online) досылает свою очередь как после обрыва:
253
+ // фронт остаётся на локальном Node, пока правки не уедут.
254
+ if (LOCAL_MODES.has(state.mode) && pending > 0) {
255
+ return 'recovering';
256
+ }
257
+ // В облачном режиме грязные записи тоже бывают (запрос, пришедший на локальный Node
258
+ // в момент переключения) — push их дошлёт, фронт с облака не уводим.
259
+ return 'cloud';
260
+ };
261
+
262
+ const updateMode = () => {
263
+ const next = computeMode();
264
+ if (next !== state.mode) {
265
+ log(`mode ${state.mode} → ${next}${state.pending ? ` (pending ${state.pending})` : ''}`);
266
+ if (next === 'draining') {
267
+ // Перед возвратом в онлайн — последний шанс записям, которые облако отвергало.
268
+ state.poison.clear();
269
+ }
270
+ state.mode = next;
271
+ }
272
+ return state.mode;
273
+ };
274
+
275
+ // ---------------------------------------------------------------- шаги цикла
276
+
277
+ const snapshotDue = () => {
278
+ const now = Date.now();
279
+ if (state.needSnapshot) {
280
+ if (now < state.snapshotNotBefore) {
281
+ return false;
282
+ }
283
+ return now - state.lastSnapshotTry >= (state.lastSnapshotTry ? SNAPSHOT_RETRY_MS : 0);
284
+ }
285
+ return now - state.lastSnapshotOk >= config.intervals().snapshot;
286
+ };
287
+
288
+ const runSnapshot = async () => {
289
+ state.lastSnapshotTry = Date.now();
290
+ const pullFn = isFollower() ? snapshot.pullInitOnly : snapshot.pullSnapshot;
291
+ const result = await pullFn({store: state.store, cloud: state.cloud});
292
+ state.needSnapshot = false;
293
+ state.lastSnapshotOk = Date.now();
294
+ log(`snapshot applied: ${Object.entries(result.applied).map(([t, n]) => `${t}=${n}`).join(' ')}`);
295
+
296
+ if (!isFollower()) {
297
+ const removed = await state.store.block((s) => mirror.cleanup(s));
298
+ if (removed) {
299
+ log(`cleanup: removed ${removed} old closed orders`);
300
+ }
301
+ }
302
+ };
303
+
304
+ const trackFailure = (key, version, reason) => {
305
+ const entry = state.poison.get(key);
306
+ if (entry && entry.version === version) {
307
+ entry.attempts += 1;
308
+ entry.reason = reason;
309
+ if (entry.attempts === config.limits().poisonAttempts) {
310
+ console.error(`[sync] ${key} rejected ${entry.attempts} times, skipped until it changes: ${reason}`);
311
+ }
312
+ } else {
313
+ state.poison.set(key, {version, attempts: 1, reason});
314
+ }
315
+ };
316
+
317
+ /** Отравленная запись, которую касса с тех пор правила (или которой больше нет), — снова в работе. */
318
+ const reconcilePoison = () => {
319
+ for (const [key, entry] of state.poison) {
320
+ const separator = key.indexOf(':');
321
+ const row = state.store.get(
322
+ 'SELECT version FROM sync_dirty WHERE entity = ? AND id = ?',
323
+ [key.slice(0, separator), key.slice(separator + 1)]
324
+ );
325
+ if (!row || Number(row.version) !== Number(entry.version)) {
326
+ state.poison.delete(key);
327
+ }
328
+ }
329
+ };
330
+
331
+ const emitPatches = async (orderIds) => {
332
+ if (!orderIds.length || state.mode === 'cloud') {
333
+ return;
334
+ }
335
+ // Фронт в cloud-режиме слушает облако; локальный сокет нужен только offline/recovering.
336
+ const {makeServices} = require('../core');
337
+ const services = await makeServices({
338
+ headers: {'team-id': state.session.team_id, 'company-id': state.session.company_id},
339
+ });
340
+ for (const id of orderIds) {
341
+ try {
342
+ await services.orders.emitPatch(id);
343
+ } catch (e) {
344
+ console.error(`[sync] patch emit failed for ${id}:`, e.message);
345
+ }
346
+ }
347
+ };
348
+
349
+ const runPull = async (ids = null, {full = false} = {}) => {
350
+ let result = {applied: [], skipped: 0, pages: 0, hasMore: false};
351
+ if (ids) {
352
+ // Облако отдаёт по ids не больше 200 чеков за раз.
353
+ for (let i = 0; i < ids.length; i += PULL_IDS_CHUNK) {
354
+ const part = await mirror.pull({
355
+ store: state.store, cloud: state.cloud, ids: ids.slice(i, i + PULL_IDS_CHUNK), companyId: sessionCompanyId(),
356
+ });
357
+ result = {...part, applied: [...result.applied, ...part.applied], skipped: result.skipped + part.skipped};
358
+ }
359
+ } else {
360
+ result = await mirror.pull({store: state.store, cloud: state.cloud, full, companyId: sessionCompanyId()});
361
+ state.lastPullAt = Date.now();
362
+ }
363
+ await emitPatches(result.applied);
364
+ return result;
365
+ };
366
+
367
+ /** Чеки, которые облако попросило перечитать (sync:pull), — одним заходом. */
368
+ const runRequestedPull = async () => {
369
+ const ids = [...state.pullIds];
370
+ state.pullIds.clear();
371
+ try {
372
+ await runPull(ids);
373
+ } catch (e) {
374
+ // Не дошло — вернём в очередь, следующий цикл повторит.
375
+ for (const id of ids) {
376
+ state.pullIds.add(id);
377
+ }
378
+ throw e;
379
+ }
380
+ };
381
+
382
+ /** Эпоха для авторитетной выгрузки: только мастер в local/to_online. */
383
+ const authoritativeEpoch = () => (
384
+ isMasterNow() && (state.company.state === 'local' || state.company.state === 'to_online')
385
+ ? state.company.epoch
386
+ : null
387
+ );
388
+
389
+ const runPush = async () => {
390
+ let rounds = 0;
391
+ reconcilePoison();
392
+ while (refreshPending() > 0 && rounds < MAX_PUSH_ROUNDS && state.online) {
393
+ rounds += 1;
394
+ const epoch = authoritativeEpoch();
395
+ let result;
396
+ try {
397
+ result = await pushBatch({
398
+ store: state.store,
399
+ cloud: state.cloud,
400
+ excluded: excludedKeys(),
401
+ onFailure: trackFailure,
402
+ batchSize: state.batchSize,
403
+ epoch,
404
+ companyId: sessionCompanyId(),
405
+ });
406
+ } catch (e) {
407
+ state.lastPushError = e.message;
408
+ if (e.name === 'CloudRequestError') {
409
+ // Пачку отвергли целиком — дальше по одной, чтобы найти виновную запись.
410
+ state.batchSize = 1;
411
+ }
412
+ throw e;
413
+ }
414
+
415
+ state.lastPushAt = nowIso();
416
+ if (epoch !== null && result.sent > 0 && !result.authoritative) {
417
+ // Облако не признало нас мастером этой эпохи — режим сменился, перечитываем его.
418
+ requestModePoll();
419
+ }
420
+ if (result.sent === 0) {
421
+ break;
422
+ }
423
+ if (result.failed.length) {
424
+ state.lastPushError = result.failed.map((item) => `${item.key}: ${item.reason}`).join('; ');
425
+ } else {
426
+ state.lastPushError = null;
427
+ }
428
+
429
+ if (result.conflicts.length) {
430
+ state.conflicts = [
431
+ ...result.conflicts.map((item) => ({...item, at: nowIso()})),
432
+ ...state.conflicts,
433
+ ].slice(0, MAX_CONFLICTS_KEPT);
434
+ log(`push conflicts: ${result.conflicts.map((item) => item.id).join(', ')}`);
435
+ // Облако оставило свою версию — забираем её, локальная копия не должна расходиться.
436
+ await runPull(result.conflicts.map((item) => item.id));
437
+ }
438
+
439
+ // Облако оставило свою версию принтера (удалён в облаке) — справочник перечитывается снапшотом.
440
+ if (result.results.some((item) => item.entity === 'printer' && item.status === 'conflict')) {
441
+ requestSnapshot();
442
+ }
443
+
444
+ const applied = result.results
445
+ .filter((item) => item.entity === 'order' && item.status === 'applied')
446
+ .map((item) => item.id);
447
+ if (applied.length) {
448
+ log(`pushed ${applied.length} order(s)`);
449
+ }
450
+
451
+ if (result.failed.length) {
452
+ // Непринятые останутся в очереди; следующая попытка — в следующем цикле.
453
+ break;
454
+ }
455
+ }
456
+ if (refreshPending() === 0) {
457
+ state.batchSize = null;
458
+ }
459
+ };
460
+
461
+ const pullDue = () => {
462
+ if (state.mode === 'local' || state.mode === 'draining') {
463
+ // Истина у этой кассы: облачная копия — её же выгрузка. Расхождения облако присылает
464
+ // адресно (sync:pull), общий pull по курсору только затирал бы локальные версии.
465
+ return false;
466
+ }
467
+ if (state.pullRequested || state.mode === 'recovering') {
468
+ return true;
469
+ }
470
+ const {pull, pullWithoutSignal} = config.intervals();
471
+ const every = state.signal && state.signal.connected ? pull : pullWithoutSignal;
472
+ return Date.now() - state.lastPullAt >= every;
473
+ };
474
+
475
+ // ---------------------------------------------------------------- режим заведения (модель M)
476
+
477
+ const requestModePoll = () => {
478
+ state.modeRequested = true;
479
+ };
480
+
481
+ const modePollDue = () => state.modeRequested || Date.now() - state.lastModePoll >= config.intervals().modePoll;
482
+
483
+ const countOpenOrders = () => {
484
+ try {
485
+ // Heartbeat уходит в облако заведения сессии — чеки других заведений кассы не в счёт.
486
+ const companyId = sessionCompanyId();
487
+ return Number(state.store.get(
488
+ `SELECT COUNT(*) AS count FROM orders WHERE closed_at IS NULL AND deleted_at IS NULL${companyId ? ' AND company_id = ?' : ''}`,
489
+ companyId ? [String(companyId)] : []
490
+ ).count) || 0;
491
+ } catch (e) {
492
+ return null;
493
+ }
494
+ };
495
+
496
+ const dbFile = () => (state.store && state.store.database ? state.store.database.file : null);
497
+
498
+ /** Итог сверки для суперадминки и /local/status. null — сверок ещё не было. */
499
+ const reconcileStatus = () => {
500
+ const r = state.reconcile;
501
+ if (!r.at) {
502
+ return null;
503
+ }
504
+ return {
505
+ at: r.at,
506
+ ok: r.ok,
507
+ streak: r.streak,
508
+ alarm: r.streak >= config.reconcile().alarmAfter,
509
+ local: r.local,
510
+ cloud: r.cloud,
511
+ diff: r.diff,
512
+ truncated: r.truncated,
513
+ healed: r.healed,
514
+ error: r.error,
515
+ };
516
+ };
517
+
518
+ /** Heartbeat кассы для суперадминки (PosModeService::heartbeat берёт только эти поля). */
519
+ const heartbeat = () => ({
520
+ mode: state.mode,
521
+ role: config.role(),
522
+ version: PACKAGE_VERSION,
523
+ pending: state.pending,
524
+ poisoned: excludedKeys().size,
525
+ last_push_at: state.lastPushAt,
526
+ last_push_error: state.lastPushError,
527
+ last_pull_at: state.lastPullAt ? new Date(state.lastPullAt).toISOString() : null,
528
+ snapshot_at: state.store ? kv.get(state.store, 'snapshot_at') : null,
529
+ cloud_reachable: state.online,
530
+ totals: {open: countOpenOrders()},
531
+ epoch: state.company ? state.company.epoch : null,
532
+ monitor: monitor.snapshot({dbFile: dbFile()}),
533
+ reconcile: reconcileStatus(),
534
+ // Адрес мастера для LAN-перехода браузера (LanHandoffService::masterFor).
535
+ ...(config.isLanFrontEnabled() ? {lan: lanFront.heartbeat(getConfig().port)} : {}),
536
+ });
537
+
538
+ const sameCompanyMode = (a, b) => Boolean(a && b)
539
+ && a.company_id === b.company_id
540
+ && a.state === b.state
541
+ && a.master_terminal_id === b.master_terminal_id
542
+ && a.epoch === b.epoch
543
+ && a.enabled === b.enabled;
544
+
545
+ /**
546
+ * Режим заведения из ответа облака (sync.mode целиком или state из claim/release).
547
+ * Хранится в kv: мастер, перезапущенный без интернета, обязан помнить, что он мастер.
548
+ */
549
+ const applyCompanyMode = async (data) => {
550
+ if (!data || typeof data !== 'object' || !state.session) {
551
+ return;
552
+ }
553
+ const previous = state.company && state.company.company_id === state.session.company_id ? state.company : null;
554
+ const next = {
555
+ company_id: state.session.company_id,
556
+ state: COMPANY_STATES.has(data.state) ? data.state : 'online',
557
+ master_terminal_id: data.master_terminal_id ? String(data.master_terminal_id) : null,
558
+ epoch: Number(data.epoch) || 0,
559
+ // claim/release отвечают без enabled — переходы возможны только при включённом режиме
560
+ enabled: data.enabled !== undefined ? Boolean(data.enabled) : Boolean(previous && previous.enabled),
561
+ };
562
+ if (sameCompanyMode(previous, next)) {
563
+ return;
564
+ }
565
+ const role = next.master_terminal_id === state.session.terminal_id ? 'master' : 'not master';
566
+ log(`company mode ${previous ? previous.state : '?'} → ${next.state} (epoch ${next.epoch}, ${role}${next.enabled ? '' : ', disabled'})`);
567
+ state.company = {...next, at: nowIso()};
568
+ await state.store.block((s) => kv.set(s, 'company_mode', state.company));
569
+ };
570
+
571
+ const pollMode = async () => {
572
+ state.lastModePoll = Date.now();
573
+ state.modeRequested = false;
574
+ let response;
575
+ try {
576
+ response = await state.cloud.call('sync.mode', {status: heartbeat()});
577
+ } catch (e) {
578
+ state.lastModeError = e.message;
579
+ if (e.name === 'CloudRequestError' && e.status === 404) {
580
+ // Старый Laravel или Sync API выключен: модели M нет — заведение в онлайне.
581
+ await applyCompanyMode({state: 'online', master_terminal_id: null, epoch: 0, enabled: false});
582
+ return;
583
+ }
584
+ throw e;
585
+ }
586
+ state.lastModeError = null;
587
+ await applyCompanyMode(response);
588
+ };
589
+
590
+ /** Ответ claim/release (успех или 409 с state) — режим, который облако считает верным. */
591
+ const applyHandoverResponse = async (body) => {
592
+ if (body && body.state && typeof body.state === 'object') {
593
+ await applyCompanyMode(body.state);
594
+ }
595
+ };
596
+
597
+ const handoverDue = () => isMasterNow()
598
+ && state.company.state === 'to_local'
599
+ && Date.now() - state.handover.lastTry >= HANDOVER_RETRY_MS;
600
+
601
+ /**
602
+ * to_local → local. Облако уже не принимает правок (409 mode_switching), поэтому копия,
603
+ * которую касса забирает, полная. Любой сбой — повтор через HANDOVER_RETRY_MS с начала:
604
+ * каждый шаг идемпотентен.
605
+ */
606
+ const runHandover = async () => {
607
+ const epoch = state.company.epoch;
608
+ const stage = (name) => {
609
+ state.handover = {...state.handover, stage: name, epoch, at: nowIso()};
610
+ };
611
+ state.handover.lastTry = Date.now();
612
+ state.handover.error = null;
613
+ try {
614
+ // 1. Своё недосланное — наверх: забранная копия должна его уже содержать.
615
+ stage('push');
616
+ await runPush();
617
+ if (refreshPending() > 0) {
618
+ throw new Error(`${state.pending} unsent record(s)`);
619
+ }
620
+
621
+ // 2. Облачная копия чеков: открытые + последние 36 ч, дальше по курсору до конца.
622
+ stage('pull');
623
+ let result = await runPull(null, {full: true});
624
+ let rounds = 0;
625
+ while (result.hasMore && rounds < MAX_HANDOVER_PULL_ROUNDS) {
626
+ rounds += 1;
627
+ result = await runPull();
628
+ }
629
+ if (result.hasMore) {
630
+ throw new Error('orders pull did not finish');
631
+ }
632
+
633
+ // 3. Чеки, открытые локально, но не попавшие в выборку (закрыты/удалены в облаке давно):
634
+ // облачная версия важнее — перечитываем по ids.
635
+ const openIds = state.store
636
+ .all('SELECT id FROM orders WHERE closed_at IS NULL AND deleted_at IS NULL')
637
+ .map((row) => row.id);
638
+ if (openIds.length) {
639
+ await runPull(openIds);
640
+ }
641
+
642
+ // 4. Справочники — свежие: мастер будет работать без облака.
643
+ stage('snapshot');
644
+ await runSnapshot();
645
+
646
+ // 5. Режим мог смениться, пока шла загрузка (отмена, другой мастер).
647
+ await pollMode();
648
+ if (!isMasterNow() || state.company.state !== 'to_local' || state.company.epoch !== epoch) {
649
+ stage(null);
650
+ return;
651
+ }
652
+ if (refreshPending() > 0) {
653
+ throw new Error(`${state.pending} record(s) changed during handover`);
654
+ }
655
+
656
+ stage('claim');
657
+ let response;
658
+ try {
659
+ response = await state.cloud.call('sync.claimMaster', {epoch});
660
+ } catch (e) {
661
+ if (e.name === 'CloudRequestError') {
662
+ await applyHandoverResponse(e.body);
663
+ requestModePoll();
664
+ }
665
+ throw e;
666
+ }
667
+ await applyHandoverResponse(response);
668
+ stage(null);
669
+ log(`claimed company as master (epoch ${epoch})`);
670
+ } catch (e) {
671
+ state.handover.error = e.message;
672
+ throw e;
673
+ }
674
+ };
675
+
676
+ /** to_online → online: очередь пуста — отдаём заведение облаку. */
677
+ const runRelease = async () => {
678
+ if (!isMasterNow() || state.company.state !== 'to_online' || refreshPending() > 0) {
679
+ return;
680
+ }
681
+ const epoch = state.company.epoch;
682
+ let response;
683
+ try {
684
+ response = await state.cloud.call('sync.releaseMaster', {epoch});
685
+ } catch (e) {
686
+ if (e.name === 'CloudRequestError') {
687
+ await applyHandoverResponse(e.body);
688
+ requestModePoll();
689
+ }
690
+ throw e;
691
+ }
692
+ await applyHandoverResponse(response);
693
+ log(`released company to cloud (epoch ${epoch})`);
694
+ };
695
+
696
+ // ---------------------------------------------------------------- ретрансляция (модель M)
697
+
698
+ /** Касса обслуживает заведение как мастер и принимает события клиентов облака. */
699
+ const isServingAsMaster = () => !isFollower() && isMasterNow()
700
+ && (state.mode === 'local' || state.mode === 'draining');
701
+
702
+ const headerValue = (headers, name) => {
703
+ const key = Object.keys(headers).find((item) => item.toLowerCase() === name);
704
+ return key ? headers[key] : null;
705
+ };
706
+
707
+ /**
708
+ * master:request — событие клиента облака, ретранслированное Laravel → облачный Node → сюда.
709
+ * Ответ {relayed:true, status, body} уходит клиенту как есть; relayed:false — «мастера нет».
710
+ */
711
+ const handleMasterRequest = async (payload) => {
712
+ const session = state.session;
713
+ const epoch = Number(payload.epoch);
714
+ if (!session || !isServingAsMaster()
715
+ || payload.company_id !== session.company_id
716
+ || payload.master_terminal_id !== session.terminal_id
717
+ || epoch !== state.company.epoch) {
718
+ // Облако считает мастером нас, а мы — нет (или эпоха другая): сверяемся с ним.
719
+ requestModePoll();
720
+ wake();
721
+ return {relayed: false, error_code: 'not_master'};
722
+ }
723
+
724
+ const event = String(payload.event || '');
725
+ const headers = payload.headers && typeof payload.headers === 'object' ? {...payload.headers} : {};
726
+ const params = payload.params && typeof payload.params === 'object' ? payload.params : {};
727
+ const core = require('../core');
728
+ if (RELAY_DENY.test(event) || !core.canHandle(event)) {
729
+ return {
730
+ relayed: true,
731
+ status: 409,
732
+ body: {success: false, error_code: 'not_supported_in_local_mode', message: 'Bu əməliyyat lokal rejimdə mümkün deyil'},
733
+ };
734
+ }
735
+
736
+ // Заведение — только своё: заголовок компании облако проверило токеном, но ядро кассы
737
+ // не должно даже пытаться работать с чужой.
738
+ const companyHeader = headerValue(headers, 'company-id');
739
+ if (companyHeader && companyHeader !== session.company_id) {
740
+ return {relayed: true, status: 403, body: {success: false, message: 'Company mismatch'}};
741
+ }
742
+ headers['company-id'] = session.company_id;
743
+ headers['team-id'] = session.team_id;
744
+
745
+ try {
746
+ const body = await core.dispatch(event, {headers, params});
747
+ return {relayed: true, status: 200, body};
748
+ } catch (e) {
749
+ if (e instanceof HttpError) {
750
+ return {relayed: true, status: e.status, body: e.body};
751
+ }
752
+ console.error(`[sync] relayed ${event} failed:`, e);
753
+ return {relayed: true, status: 500, body: {success: false, message: e.message}};
754
+ }
755
+ };
756
+
757
+ /** Облако просит перечитать чеки (правка мимо мастера принята по правилам модели F). */
758
+ const requestPull = (ids) => {
759
+ for (const id of ids) {
760
+ if (id) {
761
+ state.pullIds.add(String(id));
762
+ }
763
+ }
764
+ kick();
765
+ };
766
+
767
+ /** Бродкаст облака (KDS, клиентские события терминалов облака) → локальные клиенты мастера. */
768
+ const forwardCloudBroadcast = (eventName, args) => {
769
+ if (!isServingAsMaster() || !require('../socket/server')()) {
770
+ return;
771
+ }
772
+ try {
773
+ require('../socket/server')().to(String(state.session.company_id)).emit(eventName, ...args);
774
+ } catch (e) {
775
+ console.error('[sync] cloud broadcast forward failed:', e.message);
776
+ }
777
+ };
778
+
779
+ /**
780
+ * Бродкаст ядра/клиентов мастера → клиенты заведения, подключённые к облаку.
781
+ * No-op, пока касса не мастер: в остальных режимах облако рассылает само.
782
+ */
783
+ const relayBroadcast = (eventName, args) => {
784
+ if (!isServingAsMaster() || !state.signal) {
785
+ return false;
786
+ }
787
+ return state.signal.broadcast(eventName, args);
788
+ };
789
+
790
+ /**
791
+ * Запрос к терминалу, которого нет в локальном сокете (печать на терминал облака).
792
+ * null — не применимо (касса не мастер или канала нет).
793
+ */
794
+ const terminalRequest = (options) => {
795
+ if (!isServingAsMaster() || !state.signal) {
796
+ return null;
797
+ }
798
+ return state.signal.terminalRequest(options);
799
+ };
800
+
801
+ // ---------------------------------------------------------------- сверка с облаком
802
+
803
+ const reconcileDue = () => config.isReconcileEnabled()
804
+ && !isFollower()
805
+ && Boolean(state.session)
806
+ && hasInit()
807
+ // local — касса мастер, облако получает её копию; cloud — обычный режим. Переходы,
808
+ // офлайн и досылка пропускаются: расхождение там ожидаемо.
809
+ && (state.mode === 'cloud' || state.mode === 'local')
810
+ && !(isMasterNow() && state.company.state === 'to_local')
811
+ && state.pullIds.size === 0
812
+ && refreshPending() === 0
813
+ && Date.now() >= state.reconcile.nextAt;
814
+
815
+ const runReconcile = async () => {
816
+ const settings = config.reconcile();
817
+ const previous = state.reconcile;
818
+ previous.nextAt = Date.now() + settings.interval;
819
+ let result;
820
+ try {
821
+ result = await reconcile.run({
822
+ store: state.store,
823
+ cloud: state.cloud,
824
+ companyId: state.session.company_id,
825
+ authoritative: authoritativeEpoch() !== null,
826
+ skip: excludedKeys(),
827
+ });
828
+ } catch (e) {
829
+ previous.at = nowIso();
830
+ if (e.name === 'CloudRequestError' && e.status === 404) {
831
+ // Laravel без sync.reconcile (или Sync API выключен) — спросим снова через час.
832
+ previous.error = 'unsupported';
833
+ previous.nextAt = Date.now() + 3600000;
834
+ return;
835
+ }
836
+ previous.error = e.message;
837
+ throw e;
838
+ }
839
+
840
+ const size = reconcile.diffSize(result.diff);
841
+ // Сводки разошлись, а построчно всё сходится (копейки округления) — это не расхождение.
842
+ const mismatched = !result.ok && (result.truncated || size > 0);
843
+ const healed = result.push.length + result.pull.length;
844
+ const counts = result.diff ? Object.fromEntries(
845
+ Object.entries(result.diff).map(([kind, ids]) => [kind, ids.length])
846
+ ) : null;
847
+ state.reconcile = {
848
+ ...previous,
849
+ at: nowIso(),
850
+ ok: !mismatched,
851
+ streak: mismatched ? previous.streak + 1 : 0,
852
+ local: result.local,
853
+ cloud: result.cloud,
854
+ diff: mismatched ? counts : null,
855
+ truncated: result.truncated,
856
+ healed: previous.healed + healed,
857
+ error: null,
858
+ nextAt: mismatched ? Date.now() + settings.retry : previous.nextAt,
859
+ };
860
+ if (!mismatched) {
861
+ return;
862
+ }
863
+
864
+ const message = `reconcile mismatch #${state.reconcile.streak}: local ${result.local.count}/${result.local.total}`
865
+ + ` cloud ${result.cloud ? `${result.cloud.count}/${result.cloud.total}` : '?'}`
866
+ + (counts ? ` ${JSON.stringify(counts)}` : ' (cloud list truncated)')
867
+ + `; push ${result.push.length}, pull ${result.pull.length}`;
868
+ if (state.reconcile.streak >= settings.alarmAfter) {
869
+ console.error(`[sync] ${message}`);
870
+ } else {
871
+ log(message);
872
+ }
873
+ for (const id of result.pull) {
874
+ state.pullIds.add(id);
875
+ }
876
+ if (result.push.length) {
877
+ refreshPending();
878
+ }
879
+ if (healed) {
880
+ kick();
881
+ }
882
+ };
883
+
884
+ const checkMaster = async () => {
885
+ const url = config.masterUrl();
886
+ if (!url) {
887
+ state.master = {reachable: false, mode: null, checkedAt: nowIso(), error: 'master not found'};
888
+ return;
889
+ }
890
+ try {
891
+ const response = await axios.get(`${url}/local/status`, {
892
+ headers: lanHeaders(),
893
+ timeout: config.timeouts().health,
894
+ validateStatus: () => true,
895
+ });
896
+ const body = response.data;
897
+ if (response.status !== 200 || !body || typeof body.mode !== 'string') {
898
+ throw new Error(`HTTP ${response.status}`);
899
+ }
900
+ state.master = {reachable: true, mode: body.enabled ? body.mode : 'cloud', checkedAt: nowIso(), error: null};
901
+ } catch (e) {
902
+ state.master = {reachable: false, mode: null, checkedAt: nowIso(), error: e.message};
903
+ // Мастер мог сменить адрес (DHCP) — искать сразу, не дожидаясь интервала поиска.
904
+ if (state.finder) {
905
+ state.finder.kick();
906
+ }
907
+ }
908
+ };
909
+
910
+ const step = async (name, fn) => {
911
+ try {
912
+ await fn();
913
+ } catch (e) {
914
+ // CloudUnavailable уже учтён в гистерезисе через onLink; 4xx и прочее — просто в статус.
915
+ if (e.name !== 'CloudUnavailable') {
916
+ console.error(`[sync] ${name} failed:`, e.message);
917
+ }
918
+ if (name === 'snapshot') {
919
+ state.lastError = `${name}: ${e.message}`;
920
+ }
921
+ }
922
+ };
923
+
924
+ const cycle = async () => {
925
+ if (!state.session || !state.cloud) {
926
+ updateMode();
927
+ return;
928
+ }
929
+ await state.cloud.health();
930
+
931
+ if (isFollower()) {
932
+ await checkMaster();
933
+ if (state.online && snapshotDue()) {
934
+ await step('snapshot', runSnapshot);
935
+ }
936
+ await runLogout();
937
+ updateMode();
938
+ return;
939
+ }
940
+
941
+ if (state.online && modePollDue()) {
942
+ await step('mode', pollMode);
943
+ }
944
+ updateMode();
945
+ if (state.online) {
946
+ if (snapshotDue()) {
947
+ await step('snapshot', runSnapshot);
948
+ }
949
+ if (config.isLanFrontEnabled()) {
950
+ // Реестр телефонов нужен до обрыва: без облака взять его неоткуда.
951
+ if (lanDevices.due()) {
952
+ await step('lan devices', () => lanDevices.refresh(state.cloud, state.store));
953
+ }
954
+ // Загрузка фронта может длиться минуты — цикл её не ждёт.
955
+ if (lanFront.due()) {
956
+ lanFront.kick();
957
+ }
958
+ }
959
+ if (hasInit()) {
960
+ if (handoverDue()) {
961
+ await step('handover', runHandover);
962
+ updateMode();
963
+ }
964
+ await step('push', runPush);
965
+ updateMode();
966
+ if (state.mode === 'draining') {
967
+ await step('release', runRelease);
968
+ updateMode();
969
+ }
970
+ if (state.online && state.pullIds.size) {
971
+ await step('pull ids', runRequestedPull);
972
+ }
973
+ if (state.online && pullDue()) {
974
+ state.pullRequested = false;
975
+ await step('pull', () => runPull());
976
+ }
977
+ if (state.online && reconcileDue()) {
978
+ await step('reconcile', runReconcile);
979
+ }
980
+ }
981
+ }
982
+ await runLogout();
983
+ updateMode();
984
+ };
985
+
986
+ // ---------------------------------------------------------------- цикл
987
+
988
+ const schedule = (delay = config.intervals().tick) => {
989
+ clearTimeout(state.timer);
990
+ if (!state.started) {
991
+ return;
992
+ }
993
+ state.timer = setTimeout(runLoop, delay);
994
+ if (state.timer.unref) {
995
+ state.timer.unref();
996
+ }
997
+ };
998
+
999
+ const runLoop = async () => {
1000
+ if (state.running) {
1001
+ state.rerun = true;
1002
+ return;
1003
+ }
1004
+ state.running = true;
1005
+ try {
1006
+ await cycle();
1007
+ } catch (e) {
1008
+ console.error('[sync] cycle failed:', e);
1009
+ } finally {
1010
+ state.running = false;
1011
+ }
1012
+ if (state.rerun) {
1013
+ state.rerun = false;
1014
+ schedule(0);
1015
+ } else {
1016
+ schedule();
1017
+ }
1018
+ };
1019
+
1020
+ /** Облако поменяло справочник, который касса правит и сама (sync:snapshot). */
1021
+ const requestSnapshot = () => {
1022
+ state.needSnapshot = true;
1023
+ state.lastSnapshotTry = 0;
1024
+ state.snapshotNotBefore = state.lastSnapshotOk + SNAPSHOT_SIGNAL_MIN_MS;
1025
+ kick();
1026
+ };
1027
+
1028
+ /** Внеочередной цикл (сигнал облака «что-то поменялось»), с дебаунсом. */
1029
+ const wake = () => {
1030
+ state.pullRequested = true;
1031
+ kick();
1032
+ };
1033
+
1034
+ /** Внеочередной цикл без pull по курсору (режим заведения, pull по ids). */
1035
+ const kick = () => {
1036
+ clearTimeout(state.wakeTimer);
1037
+ state.wakeTimer = setTimeout(() => {
1038
+ if (state.running) {
1039
+ state.rerun = true;
1040
+ } else {
1041
+ schedule(0);
1042
+ }
1043
+ }, config.intervals().signalDebounce);
1044
+ if (state.wakeTimer.unref) {
1045
+ state.wakeTimer.unref();
1046
+ }
1047
+ };
1048
+
1049
+ // ---------------------------------------------------------------- сессия
1050
+
1051
+ const SESSION_FIELDS = ['token', 'team_id', 'company_id', 'terminal_id', 'employee_id', 'api_url'];
1052
+ const IDENTITY_FIELDS = ['token', 'team_id', 'company_id', 'terminal_id', 'api_url'];
1053
+
1054
+ const sanitizeSession = (input = {}) => {
1055
+ const session = {};
1056
+ for (const field of SESSION_FIELDS) {
1057
+ const value = input[field];
1058
+ session[field] = value === undefined || value === null || value === '' ? null : String(value);
1059
+ }
1060
+ if (session.api_url) {
1061
+ session.api_url = normalizeApiUrl(session.api_url);
1062
+ }
1063
+ return session;
1064
+ };
1065
+
1066
+ const stopComponents = () => {
1067
+ if (state.signal) {
1068
+ state.signal.stop();
1069
+ state.signal = null;
1070
+ }
1071
+ if (state.bridge) {
1072
+ state.bridge.stop();
1073
+ state.bridge = null;
1074
+ }
1075
+ state.cloud = null;
1076
+ };
1077
+
1078
+ /** Мост ведомого к мастеру: при auto — только когда мастер найден, заново — при смене адреса. */
1079
+ const startBridge = () => {
1080
+ if (state.bridge) {
1081
+ state.bridge.stop();
1082
+ state.bridge = null;
1083
+ }
1084
+ const masterUrl = config.masterUrl();
1085
+ if (!state.session || !masterUrl) {
1086
+ return;
1087
+ }
1088
+ state.bridge = new MasterBridge({
1089
+ masterUrl,
1090
+ session: state.session,
1091
+ isRelaying: () => state.mode !== 'cloud',
1092
+ }).start();
1093
+ };
1094
+
1095
+ const onMasterFound = () => {
1096
+ state.master = {reachable: null, mode: null, checkedAt: null, error: null};
1097
+ if (isFollower()) {
1098
+ startBridge();
1099
+ }
1100
+ kick();
1101
+ };
1102
+
1103
+ const startDiscovery = () => {
1104
+ stopDiscovery();
1105
+ if (isFollower() && config.isMasterAuto()) {
1106
+ state.finder = new discovery.Finder({
1107
+ companyId: () => (state.session ? state.session.company_id : null),
1108
+ isReachable: () => state.master.reachable,
1109
+ onChange: onMasterFound,
1110
+ // адрес мастера переживает перезапуск ведомого: первым проверяется он
1111
+ remembered: () => (state.store ? kv.get(state.store, 'discovery_master') : null),
1112
+ remember: (url) => {
1113
+ if (state.store) {
1114
+ state.store.block((s) => kv.set(s, 'discovery_master', url))
1115
+ .catch((e) => console.warn('[discovery] remember failed:', e.message));
1116
+ }
1117
+ },
1118
+ }).start();
1119
+ } else if (config.role() === 'master' && config.lanKey()) {
1120
+ state.responder = new discovery.Responder({
1121
+ httpPort: () => getConfig().port,
1122
+ identity: () => state.session,
1123
+ }).start();
1124
+ }
1125
+ };
1126
+
1127
+ /** Ответ мастера на GET /local/whoami (ступени поиска ведомого). null — 404. */
1128
+ const whoami = (nonce) => (config.role() === 'master' && state.started
1129
+ ? discovery.whoami({nonce, httpPort: getConfig().port, identity: state.session})
1130
+ : null);
1131
+
1132
+ /**
1133
+ * Ответ мастера на GET /local/lan — опрос телефона, ушедшего на мастера по LAN: пора ли назад в
1134
+ * облако. Назад можно, когда облако доступно самому мастеру и заведение не держит истину на кассе
1135
+ * в переходе (draining/recovering досылают очередь). null — 404.
1136
+ */
1137
+ const lanInfo = () => {
1138
+ if (!config.isLanFrontEnabled() || !state.started) {
1139
+ return null;
1140
+ }
1141
+ return {
1142
+ lan: true,
1143
+ mode: mode(),
1144
+ cloud_reachable: state.online === true,
1145
+ front_version: lanFront.status().version,
1146
+ };
1147
+ };
1148
+
1149
+ const stopDiscovery = () => {
1150
+ if (state.finder) {
1151
+ state.finder.stop();
1152
+ state.finder = null;
1153
+ }
1154
+ if (state.responder) {
1155
+ state.responder.stop();
1156
+ state.responder = null;
1157
+ }
1158
+ };
1159
+
1160
+ const startComponents = () => {
1161
+ stopComponents();
1162
+ const session = state.session;
1163
+ if (!session) {
1164
+ return;
1165
+ }
1166
+ state.cloud = new CloudClient(session, {onLink});
1167
+ if (isFollower()) {
1168
+ startBridge();
1169
+ } else {
1170
+ state.signal = new CloudSignal({
1171
+ session,
1172
+ onChange: wake,
1173
+ onSnapshot: requestSnapshot,
1174
+ master: {
1175
+ isLocal: isServingAsMaster,
1176
+ onRequest: handleMasterRequest,
1177
+ onPull: requestPull,
1178
+ onMode: () => {
1179
+ requestModePoll();
1180
+ kick();
1181
+ },
1182
+ onBroadcast: forwardCloudBroadcast,
1183
+ },
1184
+ }).start();
1185
+ }
1186
+ };
1187
+
1188
+ /**
1189
+ * Сессия терминала от фронта (POST /local/session): токен и заголовки, с которыми синк ходит
1190
+ * в облако. Смена личности (токен/компания/терминал/адрес) перезапускает клиентов и снапшот;
1191
+ * смена сотрудника — просто обновляет заголовок.
1192
+ */
1193
+ const setSession = async (input) => {
1194
+ if (!config.isSyncEnabled()) {
1195
+ return {success: false, enabled: false};
1196
+ }
1197
+ await start();
1198
+ const next = sanitizeSession(input);
1199
+ if (!next.token || !next.company_id || !next.team_id || !next.api_url) {
1200
+ return {success: false, message: 'token, team_id, company_id and api_url are required'};
1201
+ }
1202
+
1203
+ const previous = state.session;
1204
+ const identityChanged = !previous || IDENTITY_FIELDS.some((field) => previous[field] !== next[field]);
1205
+ // Терминал вышел, пока токен ещё не отозван, а вошли уже с другим — старый отзовёт очередь.
1206
+ const orphan = previous && previous.parked && previous.token !== next.token ? previous : null;
1207
+
1208
+ await state.store.block((s) => {
1209
+ kv.set(s, 'session', next);
1210
+ if (orphan) {
1211
+ queueLogout(s, orphan);
1212
+ }
1213
+ // С этого момента каждый чек заведения проходит через эту кассу (сама создала или
1214
+ // получит pull'ом). Первый pull сдвинет начало назад на окно облака.
1215
+ mirror.extendHistory(s, next.company_id, new Date());
1216
+ });
1217
+
1218
+ if (identityChanged) {
1219
+ if (previous && previous.company_id !== next.company_id && refreshPending() > 0) {
1220
+ // Не потеряются: уйдут, когда касса снова войдёт в то заведение (push берёт записи
1221
+ // только заведения сессии, снапшот и уборка помеченное не трогают).
1222
+ console.warn(`[sync] company changed: ${state.pending} unsent record(s) of the previous company wait for its next login`);
1223
+ }
1224
+ if (previous && previous.company_id !== next.company_id) {
1225
+ // Токены чужого заведения больше не годятся.
1226
+ await lanDevices.reset(state.store);
1227
+ }
1228
+ state.session = next;
1229
+ state.needSnapshot = true;
1230
+ state.lastSnapshotTry = 0;
1231
+ state.snapshotNotBefore = 0;
1232
+ state.handover.lastTry = 0;
1233
+ state.pullIds.clear();
1234
+ // Другая касса/компания — прошлые итоги сверки к ней не относятся; первая сверка —
1235
+ // после того, как зеркало догонит облако.
1236
+ state.reconcile = {...state.reconcile, nextAt: Date.now() + config.reconcile().retry,
1237
+ at: null, ok: null, streak: 0, local: null, cloud: null, diff: null, truncated: false, error: null};
1238
+ requestModePoll();
1239
+ startComponents();
1240
+ schedule(0);
1241
+ } else if (state.session) {
1242
+ // Тот же объект держат облачный клиент и сокеты — меняем поля на месте.
1243
+ Object.assign(state.session, next);
1244
+ // Фронт снова прислал сессию — значит, терминал вошёл обратно (resume).
1245
+ delete state.session.parked;
1246
+ }
1247
+ state.revokeWaitFrom = null;
1248
+ return {success: true, mode: state.mode};
1249
+ };
1250
+
1251
+ /** Выход терминала: токен забываем. Недосланные правки остаются и уйдут со следующей сессией. */
1252
+ const clearSession = async () => {
1253
+ if (!config.isSyncEnabled()) {
1254
+ return {success: false, enabled: false};
1255
+ }
1256
+ await start();
1257
+ if (isServingAsMaster()) {
1258
+ // Без сессии касса перестаёт обслуживать заведение: клиенты облака получат 503.
1259
+ console.warn('[sync] session cleared on the master terminal — company is not served until the next login');
1260
+ }
1261
+ stopComponents();
1262
+ state.session = null;
1263
+ state.revokeWaitFrom = null;
1264
+ await state.store.block((s) => kv.del(s, 'session'));
1265
+ updateMode();
1266
+ return {success: true};
1267
+ };
1268
+
1269
+ // ---------------------------------------------------------------- выход терминала
1270
+
1271
+ /*
1272
+ * Выход терминала (logout) без облака. Раньше фронт сам отзывал токен в облаке
1273
+ * (auth.logoutApp) и стирал сессию синка: без интернета выход падал, а недосланное
1274
+ * ждало следующего входа, которого без интернета не бывает (app.auth — только облако).
1275
+ *
1276
+ * Теперь выход «паркует» сессию: фронт свою авторизацию стирает, а синк токен держит, пока
1277
+ * он нужен, — досылает очередь, а мастер модели M продолжает обслуживать зал. Токен
1278
+ * отзывается, когда облако на связи, очередь пуста и касса не держит заведение
1279
+ * (очередь не опустела за OFFLINE_LOGOUT_GRACE_MS — отзыв всё равно, недосланное уйдёт со
1280
+ * следующим входом в это заведение). До отзыва на кассу можно вернуться по PIN сотрудника
1281
+ * (resume) — это единственный вход без облака.
1282
+ */
1283
+
1284
+ const initNames = () => {
1285
+ const blob = state.store ? kv.get(state.store, 'init') : null;
1286
+ const name = (value) => (value && value.name ? String(value.name) : null);
1287
+ return {
1288
+ terminal: name(blob && blob.terminal),
1289
+ company: blob && blob.terminal && blob.terminal.company_name ? String(blob.terminal.company_name) : null,
1290
+ team: name(blob && blob.team),
1291
+ };
1292
+ };
1293
+
1294
+ /** Токен прошлой сессии — в очередь на отзыв (внутри store.block). */
1295
+ const queueLogout = (s, session) => {
1296
+ const queue = (kv.get(s, 'logout_queue') || []).filter((entry) => entry.token !== session.token);
1297
+ queue.push({
1298
+ token: session.token,
1299
+ team_id: session.team_id,
1300
+ company_id: session.company_id,
1301
+ terminal_id: session.terminal_id,
1302
+ api_url: session.api_url,
1303
+ at: nowIso(),
1304
+ });
1305
+ kv.set(s, 'logout_queue', queue.slice(-MAX_LOGOUT_QUEUE));
1306
+ };
1307
+
1308
+ /**
1309
+ * Отзыв токена в облаке. true — токена больше нет (отозван сейчас или уже был мёртв: любой 4xx),
1310
+ * false — облако недоступно, повторить позже.
1311
+ */
1312
+ const revokeToken = async (client, data = {}, timeout = config.timeouts().api) => {
1313
+ try {
1314
+ await client.call('auth.logoutApp', data, {timeout});
1315
+ return true;
1316
+ } catch (e) {
1317
+ if (e instanceof CloudRequestError) {
1318
+ return true;
1319
+ }
1320
+ if (e.name !== 'CloudUnavailable') {
1321
+ console.error('[sync] logout failed:', e.message);
1322
+ }
1323
+ return false;
1324
+ }
1325
+ };
1326
+
1327
+ const canRevokeParked = () => Boolean(state.session && state.session.parked && state.cloud)
1328
+ && state.online === true && !state.revoking && !isServingAsMaster();
1329
+
1330
+ /** Отзыв токена запаркованной сессии. Вернулись на кассу, пока шёл запрос, — сессию не трогаем. */
1331
+ const revokeParked = async (timeout) => {
1332
+ const session = state.session;
1333
+ state.revoking = true;
1334
+ let revoked;
1335
+ try {
1336
+ revoked = await revokeToken(state.cloud, {}, timeout);
1337
+ } finally {
1338
+ state.revoking = false;
1339
+ }
1340
+ if (!revoked || state.session !== session) {
1341
+ return false;
1342
+ }
1343
+ log('terminal logged out: token revoked');
1344
+ await clearSession();
1345
+ return true;
1346
+ };
1347
+
1348
+ const runRevoke = async () => {
1349
+ if (!canRevokeParked()) {
1350
+ state.revokeWaitFrom = null;
1351
+ return;
1352
+ }
1353
+ if (refreshPending() > 0) {
1354
+ state.revokeWaitFrom = state.revokeWaitFrom || Date.now();
1355
+ if (Date.now() - state.revokeWaitFrom < config.intervals().logoutGrace) {
1356
+ return;
1357
+ }
1358
+ console.warn(`[sync] logout: ${state.pending} unsent record(s) still queued after the grace period — `
1359
+ + 'revoking the token anyway, they wait for the next login to this company');
1360
+ }
1361
+ await revokeParked();
1362
+ };
1363
+
1364
+ /** Токены прошлых сессий: у каждого свой клиент — сессия кассы тут уже другая. */
1365
+ const runLogoutQueue = async () => {
1366
+ const queue = kv.get(state.store, 'logout_queue') || [];
1367
+ for (const entry of queue) {
1368
+ // Тот же терминал уже вошёл с новым токеном — отметку «вошёл» облаку не сбрасывать.
1369
+ const sameTerminal = Boolean(state.session && entry.terminal_id && entry.terminal_id === state.session.terminal_id);
1370
+ if (!await revokeToken(new CloudClient(entry), sameTerminal ? {keep_terminal: true} : {})) {
1371
+ return;
1372
+ }
1373
+ await state.store.block((s) => kv.set(s, 'logout_queue',
1374
+ (kv.get(s, 'logout_queue') || []).filter((item) => item.token !== entry.token)));
1375
+ }
1376
+ };
1377
+
1378
+ const runLogout = async () => {
1379
+ if (state.online === true && state.store && (kv.get(state.store, 'logout_queue') || []).length) {
1380
+ await step('logout queue', runLogoutQueue);
1381
+ }
1382
+ await step('revoke', runRevoke);
1383
+ };
1384
+
1385
+ /**
1386
+ * POST /local/logout от фронта. Сессия не та (фронт вышел с другим токеном) — handled: false,
1387
+ * фронт выходит по-старому. Иначе сессия паркуется и, если можно, токен отзывается сразу.
1388
+ */
1389
+ const logout = async ({token} = {}) => {
1390
+ if (!config.isSyncEnabled()) {
1391
+ return {success: false, enabled: false};
1392
+ }
1393
+ await start();
1394
+ const session = state.session;
1395
+ if (!session || !token || session.token !== String(token)) {
1396
+ return {success: true, handled: false};
1397
+ }
1398
+ if (!session.parked) {
1399
+ session.parked = {at: nowIso(), ...initNames()};
1400
+ await state.store.block((s) => kv.set(s, 'session', {...session}));
1401
+ }
1402
+ if (canRevokeParked() && refreshPending() === 0 && await revokeParked(REVOKE_TIMEOUT_MS)) {
1403
+ return {success: true, handled: true, revoked: true};
1404
+ }
1405
+ if (isServingAsMaster()) {
1406
+ console.warn('[sync] terminal logged out on the master — the company is still served, token kept');
1407
+ }
1408
+ schedule(0);
1409
+ return {success: true, handled: true, parked: true, pending: state.pending, master: isServingAsMaster()};
1410
+ };
1411
+
1412
+ /**
1413
+ * POST /local/resume — вход обратно на запаркованную сессию по PIN сотрудника. PIN проверяется,
1414
+ * как в офлайн-auth.unlock (тот же список users и тот же счётчик неудач). Отдаёт то, что фронт
1415
+ * кладёт в auth-user / auth-team / auth-terminal.
1416
+ */
1417
+ const resume = async ({code} = {}) => {
1418
+ if (!config.isSyncEnabled()) {
1419
+ return {success: false, enabled: false};
1420
+ }
1421
+ await start();
1422
+ const session = state.session;
1423
+ if (!session || !session.parked) {
1424
+ throw new HttpError(409, {success: false, error_code: 'not_parked', message: 'Terminal is not logged out'});
1425
+ }
1426
+ const busy = () => new HttpError(409, {
1427
+ success: false,
1428
+ error_code: 'logout_in_progress',
1429
+ message: 'Terminaldan çıxış tamamlanır, yenidən daxil olun',
1430
+ });
1431
+ if (state.revoking) {
1432
+ throw busy();
1433
+ }
1434
+ const blob = kv.get(state.store, 'init');
1435
+ if (!blob || typeof blob !== 'object') {
1436
+ throw new HttpError(503, {success: false, error_code: 'offline_not_ready', message: 'Offline data is not ready yet'});
1437
+ }
1438
+ const key = throttleKey(session.team_id, session.terminal_id);
1439
+ unlockThrottle.assertAllowed(key);
1440
+ const user = code
1441
+ ? await findUnlockUser(state.store, Array.isArray(blob.users) ? blob.users : [], code, blob.pin_salt)
1442
+ : null;
1443
+ if (!user) {
1444
+ unlockThrottle.fail(key);
1445
+ throw new HttpError(422, {success: false, error_code: 'invalid_code', message: 'PIN kod yanlışdır'});
1446
+ }
1447
+ unlockThrottle.reset(key);
1448
+ // Пока проверялся PIN, мог начаться отзыв или прийти другая сессия.
1449
+ if (state.session !== session || !session.parked || state.revoking) {
1450
+ throw busy();
1451
+ }
1452
+ delete session.parked;
1453
+ state.revokeWaitFrom = null;
1454
+ await state.store.block((s) => kv.set(s, 'session', {...session}));
1455
+ log('terminal logged back in by PIN');
1456
+ return {
1457
+ success: true,
1458
+ user: {...(blob.user && typeof blob.user === 'object' ? blob.user : {}), token: session.token},
1459
+ team: blob.team || null,
1460
+ terminal: blob.terminal || null,
1461
+ };
1462
+ };
1463
+
1464
+ // ---------------------------------------------------------------- старт / статус
1465
+
1466
+ const boot = async () => {
1467
+ const store = await getStore();
1468
+ // Строка подавления переживает только крах посреди блока — снимаем её на всякий случай,
1469
+ // иначе триггеры молчали бы и офлайн-правки не попали бы в очередь.
1470
+ await store.block((s) => s.run('DELETE FROM sync_suppress'));
1471
+ state.store = store;
1472
+ state.session = kv.get(store, 'session');
1473
+ // Мастер, перезапущенный без интернета, продолжает работать по сохранённому режиму.
1474
+ state.company = kv.get(store, 'company_mode');
1475
+ state.started = true;
1476
+ state.reconcile.nextAt = Date.now() + config.reconcile().retry;
1477
+ // Копии с диска: мастер, перезапущенный без интернета, должен узнать телефоны и отдать фронт.
1478
+ lanDevices.load(store);
1479
+ lanFront.load();
1480
+ monitor.start();
1481
+ startDiscovery();
1482
+ startComponents();
1483
+ updateMode();
1484
+ log(`started: role=${config.role()} mode=${state.mode}${state.session ? '' : ' (no session yet)'}`);
1485
+ schedule(0);
1486
+ };
1487
+
1488
+ const start = () => {
1489
+ if (!config.isSyncEnabled()) {
1490
+ return Promise.resolve(false);
1491
+ }
1492
+ if (!state.starting) {
1493
+ state.starting = boot().then(() => true).catch((e) => {
1494
+ console.error('[sync] start failed:', e);
1495
+ state.starting = null;
1496
+ return false;
1497
+ });
1498
+ }
1499
+ return state.starting;
1500
+ };
1501
+
1502
+ const stop = () => {
1503
+ state.started = false;
1504
+ clearTimeout(state.timer);
1505
+ clearTimeout(state.wakeTimer);
1506
+ stopComponents();
1507
+ stopDiscovery();
1508
+ monitor.stop();
1509
+ state.starting = null;
1510
+ };
1511
+
1512
+ /** Текущий режим для ядра и фронта. null — синк выключен (поведение как до модели F). */
1513
+ const mode = () => (config.isSyncEnabled() ? state.mode : null);
1514
+
1515
+ /** Фронту и ведомым: куда ходить и что с синком. */
1516
+ const status = () => {
1517
+ const enabled = config.isSyncEnabled();
1518
+ const store = state.store;
1519
+ const read = (key) => (store ? kv.get(store, key) : null);
1520
+ const excluded = [...state.poison.entries()]
1521
+ .filter(([, entry]) => entry.attempts >= config.limits().poisonAttempts)
1522
+ .map(([key, entry]) => ({key, attempts: entry.attempts, reason: entry.reason}));
1523
+
1524
+ const result = {
1525
+ enabled,
1526
+ mode: enabled ? state.mode : 'cloud',
1527
+ role: config.role(),
1528
+ session: Boolean(state.session),
1529
+ // терминал вышел, токен ещё не отозван: на кассу можно вернуться по PIN (resume)
1530
+ parked: state.session && state.session.parked
1531
+ ? {...state.session.parked, revoking: state.revoking}
1532
+ : null,
1533
+ cloud: {
1534
+ reachable: state.online,
1535
+ last_ok_at: state.lastOkAt,
1536
+ last_error: state.lastError,
1537
+ },
1538
+ sync: {
1539
+ snapshot_at: read('snapshot_at'),
1540
+ orders_cursor: read(mirror.cursorKey(sessionCompanyId())),
1541
+ pending_push: state.pending,
1542
+ // Недосланное других заведений этой кассы: [{company_id, count}], уйдёт при входе в них.
1543
+ other_companies: state.foreignPending || [],
1544
+ last_push_at: state.lastPushAt,
1545
+ last_push_error: state.lastPushError,
1546
+ last_pull_at: state.lastPullAt ? new Date(state.lastPullAt).toISOString() : null,
1547
+ poisoned: excluded,
1548
+ conflicts: state.conflicts,
1549
+ reconcile: reconcileStatus(),
1550
+ },
1551
+ monitor: monitor.snapshot({dbFile: dbFile(), reset: false}),
1552
+ };
1553
+ if (isFollower()) {
1554
+ result.master = {url: config.masterUrl(), ...state.master,
1555
+ discovery: state.finder ? state.finder.status() : null};
1556
+ } else {
1557
+ const company = state.company;
1558
+ result.company = company ? {
1559
+ state: company.state,
1560
+ master_terminal_id: company.master_terminal_id,
1561
+ epoch: company.epoch,
1562
+ enabled: company.enabled,
1563
+ is_master: isMasterNow(),
1564
+ polled_at: state.lastModePoll ? new Date(state.lastModePoll).toISOString() : null,
1565
+ error: state.lastModeError,
1566
+ } : null;
1567
+ result.handover = {
1568
+ stage: state.handover.stage,
1569
+ error: state.handover.error,
1570
+ at: state.handover.at,
1571
+ };
1572
+ result.relay = {
1573
+ signal: Boolean(state.signal && state.signal.connected),
1574
+ ready: Boolean(state.signal && state.signal.relayReady),
1575
+ };
1576
+ result.discovery = state.responder
1577
+ ? {port: discovery.settings().port, answering: Boolean(state.responder.socket), answered: state.responder.answered}
1578
+ : null;
1579
+ result.lan = config.isLanFrontEnabled()
1580
+ ? {urls: lanFront.urls(getConfig().port), front: lanFront.status(), devices: lanDevices.status()}
1581
+ : null;
1582
+ }
1583
+ return result;
1584
+ };
1585
+
1586
+ /**
1587
+ * Для отчёта кассы (ReportService): с какого момента у кассы есть все чеки заведения и можно ли
1588
+ * за более ранним периодом сходить в облако.
1589
+ * from — позже из двух: начало истории (mirror.historyFrom; неизвестно — «сейчас») и граница
1590
+ * уборки закрытых чеков (keepClosedDays);
1591
+ * cloud — облачный клиент, только если облако на связи и в нём уже всё, что есть у кассы:
1592
+ * то же заведение, очередь push пуста. Иначе облачный отчёт был бы неполнее локального.
1593
+ * Без синка — null: облачный контур ядра (pgsql) читает боевую базу, история там вся.
1594
+ */
1595
+ const history = (companyId) => {
1596
+ if (!config.isSyncEnabled() || !state.store || !companyId) {
1597
+ return null;
1598
+ }
1599
+ const nowMs = Date.now();
1600
+ const known = mirror.historyFrom(state.store, companyId);
1601
+ const keepFrom = nowMs - config.limits().keepClosedDays * 86400000;
1602
+ const from = new Date(Math.max(known ? known.getTime() : nowMs, keepFrom));
1603
+ const complete = Boolean(state.cloud) && state.online === true && !isFollower()
1604
+ && sessionCompanyId() === companyId && refreshPending() === 0;
1605
+ return {from, cloud: complete ? state.cloud : null, mode: state.mode};
1606
+ };
1607
+
1608
+ /** Сессия (для ядра: offline-обработчики app.init/ведомый). */
1609
+ const session = () => state.session;
1610
+
1611
+ const bridge = () => state.bridge;
1612
+
1613
+ const store = () => state.store;
1614
+
1615
+ module.exports = {
1616
+ start,
1617
+ stop,
1618
+ mode,
1619
+ status,
1620
+ whoami,
1621
+ lanInfo,
1622
+ session,
1623
+ history,
1624
+ bridge,
1625
+ store,
1626
+ setSession,
1627
+ clearSession,
1628
+ logout,
1629
+ resume,
1630
+ wake,
1631
+ relayBroadcast,
1632
+ terminalRequest,
1633
+ isServingAsMaster,
1634
+ // для тестов
1635
+ _state: state,
1636
+ _cycle: cycle,
1637
+ _onLink: onLink,
1638
+ _handleMasterRequest: handleMasterRequest,
1639
+ _pollMode: pollMode,
1640
+ _runReconcile: runReconcile,
1641
+ _heartbeat: heartbeat,
1642
+ _requestSnapshot: requestSnapshot,
1643
+ _runLogout: runLogout,
1644
+ };