@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,431 @@
1
+ const fs = require('node:fs');
2
+ const fsp = require('node:fs/promises');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const {createHash} = require('node:crypto');
6
+ const axios = require('axios');
7
+ const config = require('./config');
8
+ const discovery = require('./discovery');
9
+
10
+ /**
11
+ * Копия фронта на мастере для LAN-перехода (config.isLanFrontEnabled). Официант работает на
12
+ * platform.cafepos.az; пропал интернет — страница уходит на http://<IP мастера>:3005, и мастер
13
+ * отдаёт ей тот же фронт, скачанный заранее.
14
+ *
15
+ * Сборка фронта с VITE_LAN_HANDOFF кладёт рядом с index.html lan-manifest.json:
16
+ * {version, files: [{path, size, sha256}]}. Мастер раз в LAN_FRONT_SYNC_MS сверяет версию и,
17
+ * если она новая, скачивает файлы во временный каталог, проверяет размер и sha256 каждого и
18
+ * только потом переименовывает каталог в <data>/front/<version>. Раздаётся всегда целая
19
+ * версия: полускачанная никогда не становится текущей. Хранятся текущая и предыдущая.
20
+ *
21
+ * Файлы, которые не изменились (хешированные ассеты Vite), берутся из текущей версии без
22
+ * повторной загрузки.
23
+ */
24
+ const MANIFEST = 'lan-manifest.json';
25
+ const CURRENT = 'current.json';
26
+ const LOCAL_MANIFEST = '.manifest.json';
27
+ const SAFE_VERSION = /^[A-Za-z0-9._-]{1,64}$/;
28
+ const SAFE_PATH = /^[A-Za-z0-9._\-/@~+]+$/;
29
+ const SHA256 = /^[a-f0-9]{64}$/;
30
+ const PARALLEL = 4;
31
+ const KEEP_VERSIONS = 2;
32
+
33
+ // Пути, которые Node обслуживает сам: фронт их не перекрывает никогда.
34
+ const RESERVED = /^\/(api|local|socket|socket\.io|master|printers|tax|health|init|up)(\/|$)/i;
35
+
36
+ const state = {
37
+ version: null,
38
+ dir: null,
39
+ files: null,
40
+ at: null,
41
+ error: null,
42
+ checkedAt: null,
43
+ lastTry: 0,
44
+ running: null,
45
+ indexHtml: null,
46
+ };
47
+
48
+ const log = (...args) => console.log('[lan-front]', ...args);
49
+
50
+ const dataDir = () => process.env.CAFEPOS_DATA_DIR || path.join(os.homedir(), '.cafepos');
51
+ const root = () => path.join(dataDir(), 'front');
52
+
53
+ const nowIso = () => new Date().toISOString();
54
+
55
+ /** Путь файла из манифеста: только относительный, без `..`, `.` и пустых сегментов. */
56
+ const safePath = (value) => {
57
+ if (typeof value !== 'string' || !value || value.length > 256 || !SAFE_PATH.test(value)) {
58
+ return false;
59
+ }
60
+ return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
61
+ };
62
+
63
+ const validateManifest = (manifest) => {
64
+ const limits = config.lanFront();
65
+ if (!manifest || typeof manifest !== 'object' || !SAFE_VERSION.test(String(manifest.version || ''))) {
66
+ throw new Error('manifest: bad version');
67
+ }
68
+ if (!Array.isArray(manifest.files) || !manifest.files.length) {
69
+ throw new Error('manifest: no files');
70
+ }
71
+ if (manifest.files.length > limits.maxFiles) {
72
+ throw new Error(`manifest: more than ${limits.maxFiles} files`);
73
+ }
74
+ const seen = new Set();
75
+ let total = 0;
76
+ for (const file of manifest.files) {
77
+ if (!file || !safePath(file.path) || seen.has(file.path)) {
78
+ throw new Error(`manifest: bad path ${file && file.path}`);
79
+ }
80
+ if (!Number.isInteger(file.size) || file.size < 0 || !SHA256.test(String(file.sha256 || ''))) {
81
+ throw new Error(`manifest: bad entry ${file.path}`);
82
+ }
83
+ seen.add(file.path);
84
+ total += file.size;
85
+ }
86
+ if (!seen.has('index.html')) {
87
+ throw new Error('manifest: no index.html');
88
+ }
89
+ if (total > limits.maxBytes) {
90
+ throw new Error(`manifest: ${total} bytes is over the limit`);
91
+ }
92
+ return {version: String(manifest.version), files: manifest.files};
93
+ };
94
+
95
+ const readJson = (file) => {
96
+ try {
97
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
98
+ } catch (e) {
99
+ return null;
100
+ }
101
+ };
102
+
103
+ const writeJsonAtomic = async (file, value) => {
104
+ const tmp = `${file}.${process.pid}.tmp`;
105
+ await fsp.writeFile(tmp, JSON.stringify(value), {mode: 0o600});
106
+ await fsp.rename(tmp, file);
107
+ };
108
+
109
+ const activate = (version, dir, files, at) => {
110
+ state.version = version;
111
+ state.dir = dir;
112
+ state.files = new Set(files.map((file) => file.path));
113
+ state.at = at;
114
+ state.indexHtml = null;
115
+ };
116
+
117
+ /** Текущая версия с диска после перезапуска кассы. */
118
+ const load = () => {
119
+ if (state.version) {
120
+ return;
121
+ }
122
+ const current = readJson(path.join(root(), CURRENT));
123
+ if (!current || !SAFE_VERSION.test(String(current.version || ''))) {
124
+ return;
125
+ }
126
+ const dir = path.join(root(), current.version);
127
+ const manifest = readJson(path.join(dir, LOCAL_MANIFEST));
128
+ if (!manifest || manifest.version !== current.version || !Array.isArray(manifest.files)) {
129
+ return;
130
+ }
131
+ activate(current.version, dir, manifest.files, current.at || null);
132
+ };
133
+
134
+ const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
135
+
136
+ const fetchManifest = async () => {
137
+ const response = await axios.get(`${config.lanFrontUrl()}/${MANIFEST}`, {
138
+ params: {t: Date.now()},
139
+ headers: {'Cache-Control': 'no-cache'},
140
+ timeout: config.lanFront().timeout,
141
+ maxContentLength: 5 * 1024 * 1024,
142
+ responseType: 'json',
143
+ validateStatus: () => true,
144
+ });
145
+ if (response.status !== 200) {
146
+ throw new Error(`manifest: HTTP ${response.status}`);
147
+ }
148
+ return validateManifest(response.data);
149
+ };
150
+
151
+ const fetchFile = async (file) => {
152
+ const response = await axios.get(`${config.lanFrontUrl()}/${file.path}`, {
153
+ params: {v: file.sha256.slice(0, 12)},
154
+ timeout: config.lanFront().timeout,
155
+ responseType: 'arraybuffer',
156
+ maxContentLength: Math.max(1, file.size),
157
+ validateStatus: () => true,
158
+ });
159
+ if (response.status !== 200) {
160
+ throw new Error(`${file.path}: HTTP ${response.status}`);
161
+ }
162
+ return Buffer.from(response.data);
163
+ };
164
+
165
+ /** Файлы текущей версии по sha256 — их не качаем заново. */
166
+ const reusable = () => {
167
+ const result = new Map();
168
+ if (!state.dir) {
169
+ return result;
170
+ }
171
+ const manifest = readJson(path.join(state.dir, LOCAL_MANIFEST));
172
+ for (const file of (manifest && manifest.files) || []) {
173
+ result.set(`${file.sha256}:${file.size}`, path.join(state.dir, file.path));
174
+ }
175
+ return result;
176
+ };
177
+
178
+ const placeFile = async (tmpDir, file, reuse) => {
179
+ const target = path.join(tmpDir, file.path);
180
+ await fsp.mkdir(path.dirname(target), {recursive: true});
181
+ const existing = reuse.get(`${file.sha256}:${file.size}`);
182
+ if (existing) {
183
+ try {
184
+ const buffer = await fsp.readFile(existing);
185
+ if (buffer.length === file.size && sha256(buffer) === file.sha256) {
186
+ await fsp.writeFile(target, buffer);
187
+ return false;
188
+ }
189
+ } catch (e) {
190
+ // файла прошлой версии нет или он битый — качаем заново
191
+ }
192
+ }
193
+ const buffer = await fetchFile(file);
194
+ if (buffer.length !== file.size || sha256(buffer) !== file.sha256) {
195
+ throw new Error(`${file.path}: checksum mismatch`);
196
+ }
197
+ await fsp.writeFile(target, buffer);
198
+ return true;
199
+ };
200
+
201
+ const cleanup = async () => {
202
+ let entries;
203
+ try {
204
+ entries = await fsp.readdir(root(), {withFileTypes: true});
205
+ } catch (e) {
206
+ return;
207
+ }
208
+ const versions = [];
209
+ for (const entry of entries) {
210
+ if (!entry.isDirectory()) {
211
+ continue;
212
+ }
213
+ const dir = path.join(root(), entry.name);
214
+ if (entry.name.startsWith('.tmp-')) {
215
+ if (!state.running || !dir.endsWith(state.tmpName || '\0')) {
216
+ await fsp.rm(dir, {recursive: true, force: true});
217
+ }
218
+ continue;
219
+ }
220
+ if (entry.name === state.version) {
221
+ continue;
222
+ }
223
+ const stat = await fsp.stat(dir).catch(() => null);
224
+ versions.push({dir, mtime: stat ? stat.mtimeMs : 0});
225
+ }
226
+ versions.sort((a, b) => b.mtime - a.mtime);
227
+ for (const old of versions.slice(KEEP_VERSIONS - 1)) {
228
+ await fsp.rm(old.dir, {recursive: true, force: true});
229
+ }
230
+ };
231
+
232
+ const download = async (manifest) => {
233
+ await fsp.mkdir(root(), {recursive: true, mode: 0o700});
234
+ const tmpName = `.tmp-${manifest.version}-${process.pid}-${Date.now()}`;
235
+ const tmpDir = path.join(root(), tmpName);
236
+ state.tmpName = tmpName;
237
+ const reuse = reusable();
238
+ let fetched = 0;
239
+ try {
240
+ await fsp.mkdir(tmpDir, {recursive: true});
241
+ const queue = [...manifest.files];
242
+ const worker = async () => {
243
+ while (queue.length) {
244
+ if (await placeFile(tmpDir, queue.shift(), reuse)) {
245
+ fetched += 1;
246
+ }
247
+ }
248
+ };
249
+ await Promise.all(Array.from({length: PARALLEL}, worker));
250
+ await writeJsonAtomic(path.join(tmpDir, LOCAL_MANIFEST), manifest);
251
+
252
+ const target = path.join(root(), manifest.version);
253
+ await fsp.rm(target, {recursive: true, force: true});
254
+ await fsp.rename(tmpDir, target);
255
+ const at = nowIso();
256
+ await writeJsonAtomic(path.join(root(), CURRENT), {version: manifest.version, at});
257
+ activate(manifest.version, target, manifest.files, at);
258
+ log(`version ${manifest.version}: ${manifest.files.length} files, ${fetched} downloaded`);
259
+ } finally {
260
+ state.tmpName = null;
261
+ await fsp.rm(tmpDir, {recursive: true, force: true}).catch(() => {});
262
+ }
263
+ };
264
+
265
+ const run = async () => {
266
+ state.lastTry = Date.now();
267
+ try {
268
+ const manifest = await fetchManifest();
269
+ state.checkedAt = nowIso();
270
+ if (manifest.version !== state.version) {
271
+ await download(manifest);
272
+ }
273
+ state.error = null;
274
+ await cleanup();
275
+ } catch (e) {
276
+ state.error = e.message;
277
+ console.warn('[lan-front] sync failed:', e.message);
278
+ }
279
+ };
280
+
281
+ const due = () => {
282
+ if (state.running) {
283
+ return false;
284
+ }
285
+ const limits = config.lanFront();
286
+ const interval = state.version && !state.error ? limits.sync : limits.retry;
287
+ return Date.now() - state.lastTry >= interval;
288
+ };
289
+
290
+ /**
291
+ * Сверить версию с облаком в фоне. Загрузка фронта может длиться минуты — цикл синка её
292
+ * не ждёт. Повторный вызов во время загрузки возвращает ту же загрузку.
293
+ */
294
+ const kick = () => {
295
+ if (!state.running) {
296
+ state.running = run().finally(() => {
297
+ state.running = null;
298
+ });
299
+ }
300
+ return state.running;
301
+ };
302
+
303
+ const status = () => ({
304
+ version: state.version,
305
+ at: state.at,
306
+ checked_at: state.checkedAt,
307
+ error: state.error,
308
+ downloading: Boolean(state.running),
309
+ });
310
+
311
+ // ---------------------------------------------------------------- адреса мастера
312
+
313
+ const privateRank = (ip) => {
314
+ const [a, b] = ip.split('.').map(Number);
315
+ if (a === 192 && b === 168) {
316
+ return 0;
317
+ }
318
+ if (a === 10) {
319
+ return 1;
320
+ }
321
+ if (a === 172 && b >= 16 && b <= 31) {
322
+ return 2;
323
+ }
324
+ return -1;
325
+ };
326
+
327
+ /**
328
+ * Куда телефону уходить по LAN: http://<IPv4>:<порт> каждого приватного интерфейса кассы.
329
+ * Облако пропускает в браузер только такие адреса (LanHandoffService::isPrivateUrl) и не
330
+ * больше четырёх; 192.168 первым — это почти всегда Wi-Fi зала, а 10/172 — VPN или Docker.
331
+ */
332
+ const urls = (port) => discovery.interfaces()
333
+ .map((item) => item.address)
334
+ .filter((ip) => privateRank(ip) >= 0)
335
+ .sort((a, b) => privateRank(a) - privateRank(b))
336
+ .slice(0, 4)
337
+ .map((ip) => `http://${ip}:${port}`);
338
+
339
+ /** Блок lan для heartbeat: облако отдаст его телефонам в app.init как lan_master. */
340
+ const heartbeat = (port) => ({
341
+ urls: state.version ? urls(port) : [],
342
+ port,
343
+ front: {version: state.version, at: state.at, error: state.error},
344
+ });
345
+
346
+ /** Адрес этой кассы (для same-origin страницы, открытой по LAN-адресу). */
347
+ const isOwnAddress = (host) => Boolean(host) && discovery.interfaces().some((item) => item.address === host);
348
+
349
+ // ---------------------------------------------------------------- раздача
350
+
351
+ const injected = () => JSON.stringify({v: 1, version: state.version, cloud: config.lanFrontUrl()})
352
+ .replace(/</g, '\\u003c');
353
+
354
+ /**
355
+ * index.html с меткой LAN-страницы: по window.__CAFEPOS_LAN__ фронт понимает, что работает
356
+ * с мастера (API — на этот же адрес, возврат в облако — на cloud).
357
+ */
358
+ const indexHtml = () => {
359
+ if (state.indexHtml && state.indexHtml.version === state.version) {
360
+ return state.indexHtml.body;
361
+ }
362
+ const raw = fs.readFileSync(path.join(state.dir, 'index.html'), 'utf8');
363
+ const tag = `<script>window.__CAFEPOS_LAN__=${injected()};</script>`;
364
+ const at = raw.search(/<script\b/i);
365
+ const body = at >= 0
366
+ ? `${raw.slice(0, at)}${tag}${raw.slice(at)}`
367
+ : raw.replace(/<\/head>/i, `${tag}</head>`);
368
+ state.indexHtml = {version: state.version, body};
369
+ return body;
370
+ };
371
+
372
+ const sendIndex = (req, res) => {
373
+ res.set('Cache-Control', 'no-cache');
374
+ res.type('html');
375
+ if (req.method === 'HEAD') {
376
+ res.end();
377
+ return;
378
+ }
379
+ res.send(indexHtml());
380
+ };
381
+
382
+ const decode = (value) => {
383
+ try {
384
+ return decodeURIComponent(value);
385
+ } catch (e) {
386
+ return null;
387
+ }
388
+ };
389
+
390
+ /**
391
+ * Express-middleware раздачи. Стоит до httpGuard: страницу получает любой телефон зала —
392
+ * фронт и так публичен на platform.cafepos.az, а API за ним пускает только по токену.
393
+ */
394
+ const middleware = () => (req, res, next) => {
395
+ if ((req.method !== 'GET' && req.method !== 'HEAD') || !config.isLanFrontEnabled()) {
396
+ next();
397
+ return;
398
+ }
399
+ load();
400
+ const requestPath = decode(req.path);
401
+ if (!state.version || !requestPath || RESERVED.test(requestPath)) {
402
+ next();
403
+ return;
404
+ }
405
+ const rel = requestPath.replace(/^\/+/, '');
406
+ if (rel === '' || rel === 'index.html') {
407
+ sendIndex(req, res);
408
+ return;
409
+ }
410
+ if (state.files.has(rel) && safePath(rel)) {
411
+ const immutable = rel.startsWith('assets/');
412
+ res.sendFile(path.join(state.dir, rel), {
413
+ dotfiles: 'deny',
414
+ headers: {'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache'},
415
+ }, (error) => {
416
+ if (error && !res.headersSent) {
417
+ next();
418
+ }
419
+ });
420
+ return;
421
+ }
422
+ // Маршрут SPA (/order/…): без расширения и из адресной строки — отдаём index.html.
423
+ const last = rel.split('/').pop();
424
+ if (!last.includes('.') && req.accepts('html')) {
425
+ sendIndex(req, res);
426
+ return;
427
+ }
428
+ next();
429
+ };
430
+
431
+ module.exports = {load, due, kick, status, heartbeat, urls, isOwnAddress, middleware, validateManifest, _state: state, _run: run};
@@ -0,0 +1,236 @@
1
+ const kv = require('./kv');
2
+ const {SyncStore} = require('./store');
3
+ const {limits, timeouts} = require('./config');
4
+
5
+ /**
6
+ * Зеркало чеков: облако → локальная БД.
7
+ *
8
+ * Приходит агрегат целиком (чек + позиции + модификаторы + платежи + тикеты кухни), локально
9
+ * он заменяет свою копию полностью: чего нет в агрегате — удаляется. Чек, который касса правила сама и
10
+ * ещё не отправила (есть в sync_dirty), не трогаем — иначе облачный снимок затёр бы
11
+ * офлайн-правки. Такой чек облако получит через push и вернёт следующим pull.
12
+ */
13
+ const isDirty = (store, id) => Boolean(store.get(
14
+ "SELECT 1 AS dirty FROM sync_dirty WHERE entity = 'order' AND id = ?",
15
+ [id]
16
+ ));
17
+
18
+ const idsOf = (rows) => (rows || []).map((row) => row && row.id).filter(Boolean);
19
+
20
+ /** Удаляет строки таблицы по условию владельца, кроме перечисленных id. */
21
+ const deleteMissing = (store, table, ownerSql, ownerParams, keepIds) => {
22
+ const keep = SyncStore.inList('id', keepIds);
23
+ const notKept = keepIds.length ? ` AND NOT (${keep.sql})` : '';
24
+ return store.run(`DELETE FROM ${table} WHERE ${ownerSql}${notKept}`, [...ownerParams, ...keep.params]);
25
+ };
26
+
27
+ /** Применяет один агрегат. Возвращает true, если чек записан. */
28
+ const applyAggregate = (store, aggregate) => {
29
+ const order = aggregate && aggregate.order;
30
+ if (!order || !order.id) {
31
+ return false;
32
+ }
33
+ if (isDirty(store, order.id)) {
34
+ return false;
35
+ }
36
+
37
+ const products = aggregate.products || [];
38
+ const modifiers = aggregate.modifier_sets || [];
39
+ const payments = aggregate.payments || [];
40
+
41
+ store.upsert('orders', order);
42
+
43
+ // Позиции, которые были у чека локально, — чтобы убрать и их модификаторы.
44
+ const localProductIds = store.all('SELECT id FROM order_products WHERE order_id = ?', [order.id])
45
+ .map((row) => row.id);
46
+ const remoteProductIds = idsOf(products);
47
+
48
+ for (const product of products) {
49
+ store.upsert('order_products', product);
50
+ }
51
+ deleteMissing(store, 'order_products', 'order_id = ?', [order.id], remoteProductIds);
52
+
53
+ const ownerIds = [...new Set([...localProductIds, ...remoteProductIds])];
54
+ for (const modifier of modifiers) {
55
+ store.upsert('order_product_modifier_sets', modifier);
56
+ }
57
+ if (ownerIds.length) {
58
+ const owners = SyncStore.inList('order_product_id', ownerIds);
59
+ deleteMissing(store, 'order_product_modifier_sets', owners.sql, owners.params, idsOf(modifiers));
60
+ }
61
+
62
+ for (const payment of payments) {
63
+ store.upsert('order_payments', payment);
64
+ }
65
+ deleteMissing(store, 'order_payments', 'order_id = ?', [order.id], idsOf(payments));
66
+
67
+ applyKdsTickets(store, order.id, aggregate);
68
+
69
+ return true;
70
+ };
71
+
72
+ /**
73
+ * Тикеты кухни чека. Старое облако ключа kds_tickets не присылает — тогда локальные тикеты
74
+ * не трогаем: пустой список означал бы «удалить все», а это неправда.
75
+ */
76
+ const applyKdsTickets = (store, orderId, aggregate) => {
77
+ if (!Array.isArray(aggregate.kds_tickets)) {
78
+ return;
79
+ }
80
+ const tickets = aggregate.kds_tickets;
81
+ const items = aggregate.kds_ticket_items || [];
82
+
83
+ const localTicketIds = store.all('SELECT id FROM kds_tickets WHERE order_id = ?', [orderId])
84
+ .map((row) => row.id);
85
+ const remoteTicketIds = idsOf(tickets);
86
+
87
+ for (const ticket of tickets) {
88
+ store.upsert('kds_tickets', ticket);
89
+ }
90
+ for (const item of items) {
91
+ store.upsert('kds_ticket_items', item);
92
+ }
93
+
94
+ const ticketIds = [...new Set([...localTicketIds, ...remoteTicketIds])];
95
+ if (ticketIds.length) {
96
+ const owners = SyncStore.inList('kds_ticket_id', ticketIds);
97
+ deleteMissing(store, 'kds_ticket_items', owners.sql, owners.params, idsOf(items));
98
+ }
99
+ deleteMissing(store, 'kds_tickets', 'order_id = ?', [orderId], remoteTicketIds);
100
+ };
101
+
102
+ /**
103
+ * Курсор дельты — свой у каждого заведения. Общий курсор после входа в другое заведение
104
+ * пропускал бы его чеки, изменённые раньше курсора первого (открытый с утра стол не доехал бы
105
+ * до кассы), а по возвращении — правки первого, сделанные, пока касса работала на втором.
106
+ * Старый общий ключ 'orders_cursor' не читается: первый pull заведения идёт без курсора.
107
+ */
108
+ const cursorKey = (companyId) => (companyId ? `orders_cursor:${companyId}` : 'orders_cursor');
109
+
110
+ // Первый pull без курсора облако отдаёт открытые чеки и открытые за это окно
111
+ // (SyncService::PULL_INITIAL_WINDOW_HOURS): всё, что старше, до кассы не доезжало.
112
+ const INITIAL_WINDOW_HOURS = 36;
113
+
114
+ /**
115
+ * С какого момента у кассы есть ВСЕ чеки заведения (ISO-строка в kv). Нужен отчёту кассы
116
+ * (reports.getReport в ядре): период раньше этого момента локально неполон. Ставится на
117
+ * первом pull заведения и на первом входе в него (sync.setSession) и только сдвигается назад.
118
+ * Уборка (cleanup) режет историю отдельно — её границу отчёт учитывает сам.
119
+ */
120
+ const historyKey = (companyId) => `orders_from:${companyId}`;
121
+
122
+ /** Сдвигает начало полной истории заведения назад до `from` (вызывать внутри store.block). */
123
+ const extendHistory = (store, companyId, from) => {
124
+ if (!companyId || !(from instanceof Date) || Number.isNaN(from.getTime())) {
125
+ return;
126
+ }
127
+ const known = Date.parse(kv.get(store, historyKey(companyId)) || '');
128
+ if (Number.isNaN(known) || from.getTime() < known) {
129
+ kv.set(store, historyKey(companyId), from.toISOString());
130
+ }
131
+ };
132
+
133
+ /** Начало полной истории заведения или null, если касса о нём ничего не знает. */
134
+ const historyFrom = (store, companyId) => {
135
+ const value = Date.parse((companyId && kv.get(store, historyKey(companyId))) || '');
136
+ return Number.isNaN(value) ? null : new Date(value);
137
+ };
138
+
139
+ /**
140
+ * Применяет страницу pull одним блоком: либо вся страница и курсор, либо ничего.
141
+ * initial — страница запрошена без курсора (первый pull заведения или полный pull мастера).
142
+ */
143
+ const applyPage = (store, orders, {cursor = undefined, companyId = null, initial = false} = {}) => {
144
+ const applied = [];
145
+ const skipped = [];
146
+ for (const aggregate of orders || []) {
147
+ if (applyAggregate(store, aggregate)) {
148
+ applied.push(aggregate.order.id);
149
+ } else if (aggregate && aggregate.order && aggregate.order.id) {
150
+ skipped.push(aggregate.order.id);
151
+ }
152
+ }
153
+ if (cursor !== undefined && cursor !== null) {
154
+ kv.set(store, cursorKey(companyId), cursor);
155
+ }
156
+ if (initial) {
157
+ extendHistory(store, companyId, new Date(Date.now() - INITIAL_WINDOW_HOURS * 3600000));
158
+ }
159
+ kv.set(store, 'last_pull_at', new Date().toISOString());
160
+ return {applied, skipped};
161
+ };
162
+
163
+ /**
164
+ * Pull с облака. Без ids — дельта от сохранённого курсора (первый раз: открытые чеки плюс
165
+ * закрытые за последние 36 часов), с ids — ровно эти чеки (после конфликта push).
166
+ * full — первая страница без курсора, как в первый раз: мастер-касса перед тем, как забрать
167
+ * заведение (модель M), берёт облачную копию целиком, а не только дельту.
168
+ */
169
+ const pull = async ({store, cloud, ids = null, full = false, companyId = null}) => {
170
+ const applied = [];
171
+ const skipped = [];
172
+ let pages = 0;
173
+ let hasMore = true;
174
+
175
+ while (hasMore && pages < limits().pullPages) {
176
+ pages += 1;
177
+ const cursor = ids || (full && pages === 1) ? null : kv.get(store, cursorKey(companyId));
178
+ const params = ids ? {ids} : {limit: limits().pullLimit, ...(cursor ? {since: cursor} : {})};
179
+ const response = await cloud.call('sync.pullOrders', params, {timeout: timeouts().api}) || {};
180
+
181
+ const result = await store.block(
182
+ (s) => applyPage(s, response.orders, {
183
+ cursor: ids ? undefined : response.cursor,
184
+ companyId,
185
+ initial: !ids && !cursor,
186
+ }),
187
+ {suppress: true}
188
+ );
189
+ applied.push(...result.applied);
190
+ skipped.push(...result.skipped);
191
+
192
+ hasMore = !ids && Boolean(response.has_more) && Boolean(response.cursor) && response.cursor !== cursor;
193
+ }
194
+
195
+ return {applied, skipped, pages, hasMore};
196
+ };
197
+
198
+ /**
199
+ * Уборка: закрытые и удалённые чеки старше keepClosedDays дней, если они уже в облаке
200
+ * (не грязные). Чек с максимальным global_number по компании оставляем — от него считается
201
+ * номер следующего офлайн-чека (OrderRepository::nextNumbers).
202
+ */
203
+ const cleanup = (store) => {
204
+ const days = limits().keepClosedDays;
205
+ const threshold = new Date(Date.now() - days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
206
+ const ids = store.all(
207
+ `SELECT o.id FROM orders o
208
+ WHERE (o.closed_at IS NOT NULL OR o.deleted_at IS NOT NULL)
209
+ AND COALESCE(o.closed_at, o.deleted_at) < ?
210
+ AND NOT EXISTS (SELECT 1 FROM sync_dirty d WHERE d.entity = 'order' AND d.id = o.id)
211
+ AND o.global_number IS NOT (
212
+ SELECT MAX(o2.global_number) FROM orders o2 WHERE o2.company_id = o.company_id
213
+ )`,
214
+ [threshold]
215
+ ).map((row) => row.id);
216
+
217
+ for (const id of ids) {
218
+ store.run(
219
+ 'DELETE FROM kds_ticket_items WHERE kds_ticket_id IN (SELECT id FROM kds_tickets WHERE order_id = ?)',
220
+ [id]
221
+ );
222
+ store.run('DELETE FROM kds_tickets WHERE order_id = ?', [id]);
223
+ store.run(
224
+ `DELETE FROM order_product_modifier_sets
225
+ WHERE order_product_id IN (SELECT id FROM order_products WHERE order_id = ?)`,
226
+ [id]
227
+ );
228
+ store.run('DELETE FROM order_products WHERE order_id = ?', [id]);
229
+ store.run('DELETE FROM order_payments WHERE order_id = ?', [id]);
230
+ store.run('DELETE FROM order_events WHERE order_id = ?', [id]);
231
+ store.run('DELETE FROM orders WHERE id = ?', [id]);
232
+ }
233
+ return ids.length;
234
+ };
235
+
236
+ module.exports = {pull, applyAggregate, applyPage, cleanup, cursorKey, historyKey, extendHistory, historyFrom};