@alizadefaig/pos-server 1.2.26 → 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +85 -0
  27. package/app/core/resources/casts.js +55 -0
  28. package/app/core/services/AuthService.js +468 -0
  29. package/app/core/services/InitService.js +277 -0
  30. package/app/core/services/KdsService.js +639 -0
  31. package/app/core/services/OrderProductService.js +309 -0
  32. package/app/core/services/OrderService.js +903 -0
  33. package/app/core/services/PrintService.js +715 -0
  34. package/app/core/services/PrinterAdminService.js +324 -0
  35. package/app/core/services/ReportService.js +581 -0
  36. package/app/core/services/ShiftService.js +199 -0
  37. package/app/core/services/StorageService.js +425 -0
  38. package/app/core/services/TelegramService.js +162 -0
  39. package/app/core/support/db.js +48 -0
  40. package/app/core/support/ids.js +9 -0
  41. package/app/core/support/imageData.js +135 -0
  42. package/app/core/support/images.js +92 -0
  43. package/app/core/support/lanHandoff.js +105 -0
  44. package/app/core/support/lang.js +98 -0
  45. package/app/core/support/lock.js +63 -0
  46. package/app/core/support/num.js +51 -0
  47. package/app/core/support/php.js +125 -0
  48. package/app/core/support/pin.js +34 -0
  49. package/app/core/support/receipts.js +278 -0
  50. package/app/core/support/rows.js +38 -0
  51. package/app/core/support/str.js +31 -0
  52. package/app/core/support/teamSettings.js +77 -0
  53. package/app/core/support/telegram.js +253 -0
  54. package/app/core/support/telegramReport.js +143 -0
  55. package/app/core/support/time.js +58 -0
  56. package/app/core/support/tz.js +80 -0
  57. package/app/core/support/unlock.js +113 -0
  58. package/app/core/timers.js +151 -0
  59. package/app/db/Database.js +197 -1
  60. package/app/db/Migrator.js +45 -0
  61. package/app/db/driver.js +248 -0
  62. package/app/db/migrations/001_snapshot.sql +214 -0
  63. package/app/db/migrations/002_orders.sql +190 -0
  64. package/app/db/migrations/003_idempotency.sql +18 -0
  65. package/app/db/migrations/004_shifts.sql +21 -0
  66. package/app/db/migrations/005_printers.sql +43 -0
  67. package/app/db/migrations/006_sync.sql +136 -0
  68. package/app/db/migrations/007_printer_mac.sql +4 -0
  69. package/app/db/migrations/008_kds.sql +94 -0
  70. package/app/db/migrations/009_product_categories.sql +20 -0
  71. package/app/db/migrations/010_printers_sync.sql +43 -0
  72. package/app/db/migrations/011_team_logos.sql +13 -0
  73. package/app/db/migrations/012_category_workshop.sql +4 -0
  74. package/app/monitor.js +185 -0
  75. package/app/printers/address.js +348 -0
  76. package/app/proxy.js +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,63 @@
1
+ /**
2
+ * Очередь по ключу внутри процесса: fn с одним ключом выполняются строго по одной.
3
+ *
4
+ * Нужна там, где между чтением и записью есть await (номер чека: MAX(number) → INSERT).
5
+ * Мастер-касса обслуживает всё заведение, включая события, ретранслированные облаком, —
6
+ * два одновременных «открыть чек» иначе получили бы один и тот же номер.
7
+ * Работает в пределах одного процесса Node; для кассы с SQLite этого достаточно.
8
+ */
9
+ const chains = new Map();
10
+
11
+ /** Очередь не освободилась за waitMs — fn не запускалась. */
12
+ class LockTimeoutError extends Error {
13
+ constructor(key) {
14
+ super(`Lock "${key}" is busy`);
15
+ this.name = 'LockTimeoutError';
16
+ }
17
+ }
18
+
19
+ const settlesWithin = (promise, ms) => new Promise((resolve) => {
20
+ const timer = setTimeout(() => resolve(false), ms);
21
+ promise.then(() => {
22
+ clearTimeout(timer);
23
+ resolve(true);
24
+ });
25
+ });
26
+
27
+ /**
28
+ * @param {number|null} [options.waitMs] сколько ждать очередь; null — без ограничения.
29
+ * Не дождались — LockTimeoutError, fn не вызывается.
30
+ */
31
+ const withLock = async (key, fn, {waitMs = null} = {}) => {
32
+ const previous = chains.get(key) || Promise.resolve();
33
+ let release;
34
+ const current = new Promise((resolve) => {
35
+ release = resolve;
36
+ });
37
+ const tail = previous.then(() => current);
38
+ chains.set(key, tail);
39
+
40
+ if (waitMs !== null && !(await settlesWithin(previous, waitMs))) {
41
+ // Своё место в очереди отдаём сразу, но предыдущий ещё работает: ключ снимается,
42
+ // только когда он закончит, иначе следующий вызов прошёл бы мимо очереди.
43
+ release();
44
+ tail.then(() => {
45
+ if (chains.get(key) === tail) {
46
+ chains.delete(key);
47
+ }
48
+ });
49
+ throw new LockTimeoutError(key);
50
+ }
51
+
52
+ try {
53
+ await previous;
54
+ return await fn();
55
+ } finally {
56
+ release();
57
+ if (chains.get(key) === tail) {
58
+ chains.delete(key);
59
+ }
60
+ }
61
+ };
62
+
63
+ module.exports = {withLock, LockTimeoutError};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Числа денег и количеств.
3
+ *
4
+ * В Laravel и MoneyCast, и UnitCast — это round($value, 3). Повторяем ровно это:
5
+ * ядро и облако считают одни и те же чеки, любое расхождение округления вылезет в выручке.
6
+ * PHP round() округляет «половину от нуля» (0.5 → 1, -0.5 → -1), JS Math.round — к +∞,
7
+ * поэтому знак обрабатываем отдельно.
8
+ */
9
+
10
+ const round = (value, precision = 0) => {
11
+ const num = typeof value === 'number' ? value : parseFloat(value);
12
+ if (!Number.isFinite(num)) {
13
+ return 0;
14
+ }
15
+ const factor = Math.pow(10, precision);
16
+ // toPrecision(15) снимает двоичный «хвост»: 1.005 * 100 === 100.49999999999999
17
+ const scaled = Number((num * factor).toPrecision(15));
18
+ const rounded = scaled < 0 ? -Math.round(-scaled) : Math.round(scaled);
19
+ return rounded / factor;
20
+ };
21
+
22
+ const money = (value) => round(value, 3);
23
+ const qty = (value) => round(value, 3);
24
+ const round2 = (value) => round(value, 2);
25
+
26
+ const toFloat = (value, fallback = 0) => {
27
+ const num = typeof value === 'number' ? value : parseFloat(value);
28
+ return Number.isFinite(num) ? num : fallback;
29
+ };
30
+
31
+ const toInt = (value, fallback = 0) => {
32
+ const num = parseInt(value, 10);
33
+ return Number.isFinite(num) ? num : fallback;
34
+ };
35
+
36
+ const toBool = (value) => {
37
+ if (typeof value === 'boolean') {
38
+ return value;
39
+ }
40
+ if (typeof value === 'number') {
41
+ return value !== 0;
42
+ }
43
+ if (typeof value === 'string') {
44
+ return value !== '' && value !== '0' && value.toLowerCase() !== 'false';
45
+ }
46
+ return Boolean(value);
47
+ };
48
+
49
+ const sum = (items, key) => items.reduce((carry, item) => carry + toFloat(item[key]), 0);
50
+
51
+ module.exports = {round, round2, money, qty, toFloat, toInt, toBool, sum};
@@ -0,0 +1,125 @@
1
+ const {toSql, parse} = require('./time');
2
+ const {zonedParts, DEFAULT_TIMEZONE} = require('./tz');
3
+
4
+ /**
5
+ * Как Laravel сериализует значения в ответах POS-API. Нужны там, где ответ Node обязан
6
+ * совпасть с PHP поле в поле: фронт и касса читают один и тот же JSON из обоих контуров.
7
+ */
8
+
9
+ /** Дата модели в toArray()/JsonResource (serializeDate): '2026-09-24T10:00:00.000000Z'. */
10
+ const isoDate = (value) => {
11
+ const sql = toSql(value);
12
+ return sql ? `${sql.replace(' ', 'T')}.000000Z` : null;
13
+ };
14
+
15
+ /** Carbon::toIso8601String() в UTC: '2026-09-24T10:00:00+00:00'. */
16
+ const iso8601 = (value) => {
17
+ const sql = toSql(value);
18
+ return sql ? `${sql.replace(' ', 'T')}+00:00` : null;
19
+ };
20
+
21
+ const pad = (value) => String(value).padStart(2, '0');
22
+
23
+ /** $date->timezone($tz)->format('Y-m-d H:i:s'). Неизвестная зона — зона по умолчанию. */
24
+ const formatInZone = (value, timeZone) => {
25
+ const date = parse(value);
26
+ if (!date) {
27
+ return '';
28
+ }
29
+ let parts;
30
+ try {
31
+ parts = zonedParts(date, timeZone || DEFAULT_TIMEZONE);
32
+ } catch (e) {
33
+ parts = zonedParts(date, DEFAULT_TIMEZONE);
34
+ }
35
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`;
36
+ };
37
+
38
+ const decode = (value) => {
39
+ if (typeof value !== 'string') {
40
+ return value === undefined ? null : value;
41
+ }
42
+ try {
43
+ return JSON.parse(value);
44
+ } catch (e) {
45
+ return null;
46
+ }
47
+ };
48
+
49
+ /**
50
+ * Каст 'array' → json_encode: JSON-объект стал PHP-массивом. Пустой объект уходит как [],
51
+ * объект с ключами 0..n-1 по порядку — как список. Строка из БД разбирается (невалидная → null).
52
+ * ⚠️ JS ставит целочисленные ключи вперёд, поэтому {"1":…,"0":…} станет списком — PHP оставил бы
53
+ * объект. На реальных настройках таких ключей нет.
54
+ */
55
+ const phpArray = (value) => {
56
+ const decoded = decode(value);
57
+ const walk = (item) => {
58
+ if (Array.isArray(item)) {
59
+ return item.map(walk);
60
+ }
61
+ if (item === null || typeof item !== 'object') {
62
+ return item;
63
+ }
64
+ const keys = Object.keys(item);
65
+ if (keys.every((key, index) => key === String(index))) {
66
+ return keys.map((key) => walk(item[key]));
67
+ }
68
+ const result = {};
69
+ for (const key of keys) {
70
+ result[key] = walk(item[key]);
71
+ }
72
+ return result;
73
+ };
74
+ return walk(decoded);
75
+ };
76
+
77
+ /** Приведение массива '0'/'1' → bool по всему дереву (Table::convertToBoolean). */
78
+ const deepZeroOneToBool = (value) => {
79
+ if (Array.isArray(value)) {
80
+ return value.map(deepZeroOneToBool);
81
+ }
82
+ if (value !== null && typeof value === 'object') {
83
+ const result = {};
84
+ for (const [key, item] of Object.entries(value)) {
85
+ result[key] = deepZeroOneToBool(item);
86
+ }
87
+ return result;
88
+ }
89
+ if (value === '0') {
90
+ return false;
91
+ }
92
+ if (value === '1') {
93
+ return true;
94
+ }
95
+ return value;
96
+ };
97
+
98
+ /** array_map верхнего уровня: '0'/'1' → bool (TeamManageSettingsCast / TeamTerminalSettingsCast). */
99
+ const zeroOneToBool = (value) => {
100
+ const map = (item) => (item === '0' ? false : item === '1' ? true : item);
101
+ if (Array.isArray(value)) {
102
+ return value.map(map);
103
+ }
104
+ const result = {};
105
+ for (const [key, item] of Object.entries(value || {})) {
106
+ result[key] = map(item);
107
+ }
108
+ return result;
109
+ };
110
+
111
+ /** filter_var($value, FILTER_VALIDATE_BOOLEAN). */
112
+ const filterBool = (value) => {
113
+ if (value === true) {
114
+ return true;
115
+ }
116
+ if (typeof value === 'number') {
117
+ return value === 1;
118
+ }
119
+ if (typeof value === 'string') {
120
+ return ['1', 'true', 'on', 'yes'].includes(value.trim().toLowerCase());
121
+ }
122
+ return false;
123
+ };
124
+
125
+ module.exports = {isoDate, iso8601, formatInZone, phpArray, deepZeroOneToBool, zeroOneToBool, filterBool};
@@ -0,0 +1,34 @@
1
+ const {createHmac} = require('node:crypto');
2
+
3
+ /**
4
+ * Порт App\Support\PinExposure: как PIN и IC-карта сотрудников уходят в app.init.
5
+ *
6
+ * USER_PIN_EXPOSURE=plain (дефолт) — открытыми, как раньше. `hash` — вместо значений
7
+ * HMAC-SHA256 с солью команды; соль выводится из APP_KEY так же, как в Laravel, поэтому
8
+ * хеши облака, кассы и Laravel совпадают и снапшоты можно возить между ними.
9
+ */
10
+ const hashed = () => String(process.env.USER_PIN_EXPOSURE || 'plain').trim().toLowerCase() === 'hash';
11
+
12
+ const hmac = (key, value) => createHmac('sha256', String(key)).update(String(value)).digest('hex');
13
+
14
+ const salt = (teamId) => {
15
+ const key = process.env.APP_KEY;
16
+ if (!key) {
17
+ // Соль без ключа совпала бы у всех инсталляций — лучше явный отказ, чем «хеш» без секрета.
18
+ throw new Error('APP_KEY is required for USER_PIN_EXPOSURE=hash');
19
+ }
20
+ // config('app.key') — строка как есть, вместе с префиксом 'base64:'.
21
+ return hmac(key, `pin-salt|${teamId ?? ''}`);
22
+ };
23
+
24
+ const hash = (value, pinSalt) => (value === null || value === undefined || value === '' ? null : hmac(pinSalt, value));
25
+
26
+ const fields = (pin, icCard, teamId) => {
27
+ if (!hashed()) {
28
+ return {pin, ic_card: icCard};
29
+ }
30
+ const pinSalt = salt(teamId);
31
+ return {pin: null, ic_card: null, pin_hash: hash(pin, pinSalt), ic_card_hash: hash(icCard, pinSalt)};
32
+ };
33
+
34
+ module.exports = {hashed, salt, hash, fields};
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Настройки чеков команды — порт App\Casts\TeamReceiptsSettingsCast (+ хелпер convertToBoolean).
3
+ *
4
+ * В Laravel `$team->settings['receipts']` никогда не читается «сырым»: каст подставляет дефолтные
5
+ * конфиги чеков, символ валюты, строку депозита и приводит '0'/'1' к boolean. Конфиг уезжает на
6
+ * терминал внутри сокет-запроса печати и там рендерится как есть, поэтому ядро обязано собрать
7
+ * ТОТ ЖЕ объект, иначе один и тот же чек из облака и из офлайна выглядел бы по-разному.
8
+ */
9
+
10
+ const DEFAULT_SYMBOL = '₼';
11
+
12
+ const lineConfig = () => ({
13
+ type: {
14
+ type: 'select',
15
+ value: 'solid',
16
+ label: 'Xətt tipi',
17
+ options: [
18
+ {value: 'solid', label: 'Düz xətt'},
19
+ {value: 'dashed', label: 'Qırıq xətt'},
20
+ ],
21
+ },
22
+ });
23
+
24
+ // Дефолты — дословно из каста (порядок ключей тоже: терминал обходит rows по порядку).
25
+ const defaultBillReceiptConfig = (symbol) => ({
26
+ container: {
27
+ gutter: 0,
28
+ symbol,
29
+ fontWeight: 500,
30
+ fontFamily: 'monospace',
31
+ },
32
+ rows: [
33
+ {print: true, type: 'logo', label: 'Logo', position: 0},
34
+ {print: true, type: 'print_name', size: 0, label: 'Space/Məkan adı', position: 1},
35
+ {print: true, type: 'order_source_type', size: 0, label: 'Sifariş üsulu', position: 2},
36
+ {print: true, type: 'number', label: 'Çek №', size: 0, position: 3},
37
+ {print: true, type: 'cashier', size: 0, label: 'Kassir', position: 4},
38
+ {print: true, type: 'waiter', size: 0, label: 'Qarson', position: 5},
39
+ {print: true, type: 'opened_at', size: 0, label: 'Açılma tarixi', position: 6},
40
+ {print: true, type: 'closed_at', size: 0, label: 'Bağlanma tarixi', position: 7},
41
+ {print: true, type: 'printed_at', size: 0, label: 'Çap tarixi', position: 8},
42
+ {print: true, type: 'line', config: lineConfig(), label: 'Xətt', position: 9},
43
+ {
44
+ print: true,
45
+ type: 'order_products',
46
+ size: 0,
47
+ config: {
48
+ modifications: {type: 'boolean', label: 'Modifikatorlar', value: false},
49
+ discount: {type: 'boolean', label: 'Endirim', value: false},
50
+ service_charge: {type: 'boolean', label: 'Servis haqqı', value: false},
51
+ },
52
+ label: 'Məhsullar',
53
+ position: 10,
54
+ },
55
+ {print: true, type: 'line', config: lineConfig(), label: 'Xətt', position: 11},
56
+ {print: true, type: 'subtotal', size: 1, label: 'Cəm', position: 12},
57
+ {print: true, type: 'discount', size: 1, label: 'Endirim', position: 13},
58
+ {print: true, type: 'service_charge', size: 1, label: 'Servis haqqı', position: 14},
59
+ {print: true, type: 'total', size: 2, label: 'Yekun', position: 15},
60
+ {print: true, type: 'comment', size: 0, label: 'Şərh', position: 16},
61
+ {
62
+ print: true,
63
+ type: 'payments',
64
+ size: 1,
65
+ config: {
66
+ change: {value: false, label: 'Qalıq', type: 'boolean'},
67
+ },
68
+ label: 'Ödənişlər',
69
+ position: 17,
70
+ },
71
+ {print: true, type: 'id', size: 0, label: 'ID', position: 18},
72
+ {print: true, type: 'delivery_address', size: 0, label: 'Çatdırılma ünvanı', position: 19},
73
+ {print: true, type: 'courier', size: 0, label: 'Kuryer', position: 20},
74
+ {print: true, type: 'powered', draggable: false, size: 0, label: 'Powered by 002 Group', position: 21},
75
+ ],
76
+ });
77
+
78
+ const defaultWorkshopReceiptConfig = () => ({
79
+ container: {
80
+ gutter: 0,
81
+ fontWeight: 500,
82
+ fontFamily: 'monospace',
83
+ },
84
+ rows: [
85
+ {print: true, type: 'number', size: 1, label: 'Çek №', position: 0},
86
+ {print: true, type: 'order_source', size: 1, label: 'Sifariş üsulu/Masa', position: 0},
87
+ {print: true, type: 'printed_at', size: 1, label: 'Çap tarixi', position: 0},
88
+ {print: true, type: 'terminal', size: 1, label: 'Terminal', position: 0},
89
+ {print: true, type: 'comment', size: 1, label: 'Şərh', position: 0},
90
+ {print: true, type: 'guest_count', size: 1, label: 'Qonaq sayı', position: 0},
91
+ {print: true, type: 'waiter', size: 1, label: 'Qarson/Kuryer', position: 0},
92
+ {print: true, type: 'workshop', size: 1, label: 'Şöbə', position: 0},
93
+ {print: true, type: 'line', config: lineConfig(), label: 'Xətt', position: 9},
94
+ {
95
+ print: true,
96
+ type: 'products',
97
+ size: 1,
98
+ config: {
99
+ modifications: {type: 'boolean', label: 'Modifikatorlar', value: true},
100
+ comment: {type: 'boolean', label: 'Şərh', value: true},
101
+ line: {type: 'boolean', label: 'Xətt', value: true},
102
+ },
103
+ label: 'Məhsullar',
104
+ position: 0,
105
+ },
106
+ ],
107
+ });
108
+
109
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
110
+ const isContainer = (value) => value !== null && typeof value === 'object';
111
+
112
+ /**
113
+ * Истинность по правилам PHP (`if ($x)`, `(bool)$x`). Отличается от toBool ядра: строка
114
+ * 'false' в PHP истинна, пустой массив/объект — ложен. Нужна там, где Laravel проверяет
115
+ * значение настройки или параметра запроса «как есть», — иначе ветка печати разойдётся.
116
+ */
117
+ const phpTruthy = (value) => {
118
+ if (value === null || value === undefined || value === false) {
119
+ return false;
120
+ }
121
+ if (typeof value === 'number') {
122
+ return value !== 0;
123
+ }
124
+ if (typeof value === 'string') {
125
+ return value !== '' && value !== '0';
126
+ }
127
+ if (Array.isArray(value)) {
128
+ return value.length > 0;
129
+ }
130
+ if (typeof value === 'object') {
131
+ return Object.keys(value).length > 0;
132
+ }
133
+ return Boolean(value);
134
+ };
135
+
136
+ // is_numeric() PHP 8: число или числовая строка (допускает пробелы по краям и экспоненту).
137
+ const NUMERIC_STRING = /^\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?\s*$/;
138
+ const isNumeric = (value) => (typeof value === 'number' && Number.isFinite(value))
139
+ || (typeof value === 'string' && NUMERIC_STRING.test(value));
140
+
141
+ /**
142
+ * convertToBoolean($array, $booleanKeys, $numberKeys) из bootstrap/helpers.php: рекурсивно,
143
+ * '0'/'1' под булевыми ключами → false/true, числовое под числовыми ключами → (int).
144
+ * (int) в PHP отбрасывает дробную часть — отсюда Math.trunc, а не округление.
145
+ */
146
+ const convertToBoolean = (value, booleanKeys = [], numberKeys = []) => {
147
+ if (!isContainer(value)) {
148
+ return value;
149
+ }
150
+ const result = Array.isArray(value) ? [] : {};
151
+ for (const [key, original] of Object.entries(value)) {
152
+ let item = original;
153
+ if (isContainer(item)) {
154
+ item = convertToBoolean(item, booleanKeys, numberKeys);
155
+ }
156
+ // Индексы списка — целые числа, а in_array(0, ['print']) в PHP 8 ложно: их не трогаем.
157
+ const namedKey = !Array.isArray(value);
158
+ if (namedKey && booleanKeys.includes(key)) {
159
+ if (item === '0') {
160
+ item = false;
161
+ }
162
+ if (item === '1') {
163
+ item = true;
164
+ }
165
+ }
166
+ if (namedKey && numberKeys.includes(key) && isNumeric(item)) {
167
+ item = Math.trunc(Number(item));
168
+ }
169
+ result[Array.isArray(value) ? Number(key) : key] = item;
170
+ }
171
+ return result;
172
+ };
173
+
174
+ const rowType = (row) => (isPlainObject(row) && row.type !== undefined ? row.type : null);
175
+
176
+ /** Строка «Depozit» перед «Yekun», если её ещё нет — бэкфилл старых сохранённых конфигов. */
177
+ const ensureDepositRow = (rows) => {
178
+ if (rows.some((row) => rowType(row) === 'deposit')) {
179
+ return rows;
180
+ }
181
+ const depositRow = {print: true, type: 'deposit', size: 1, label: 'Depozit', position: 14};
182
+ const totalIndex = rows.findIndex((row) => rowType(row) === 'total');
183
+ if (totalIndex === -1) {
184
+ return [...rows, depositRow];
185
+ }
186
+ return [...rows.slice(0, totalIndex), depositRow, ...rows.slice(totalIndex)];
187
+ };
188
+
189
+ /** У строки «Powered by» обязано быть поле size — иначе конструктор чека не даёт его выбрать. */
190
+ const ensurePoweredSize = (rows) => rows.map((row) => {
191
+ if (rowType(row) === 'powered' && !Object.prototype.hasOwnProperty.call(row, 'size')) {
192
+ return {...row, size: 0};
193
+ }
194
+ return row;
195
+ });
196
+
197
+ // rows сохранённого конфига в JSON может оказаться объектом {"0": {...}} — PHP с ним работает
198
+ // как со списком, поэтому и здесь приводим к массиву.
199
+ const asRows = (rows) => {
200
+ if (Array.isArray(rows)) {
201
+ return rows;
202
+ }
203
+ return isPlainObject(rows) ? Object.values(rows) : [];
204
+ };
205
+
206
+ const withContainer = (config) => {
207
+ const next = {...config};
208
+ next.container = isPlainObject(config.container) ? {...config.container} : {};
209
+ return next;
210
+ };
211
+
212
+ const clone = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
213
+
214
+ /**
215
+ * Собирает `settings.receipts` так же, как его отдаёт Laravel после каста.
216
+ *
217
+ * @param {object|null} rawReceipts settings.receipts из БД (строка JSON тоже годится)
218
+ * @param {string|null} currencySymbol символ валюты команды (currencies.symbol). null — валюты в
219
+ * этом контуре нет (локальный снапшот без currencies): берём символ из сохранённого
220
+ * конфига, иначе манат — других валют у действующих заведений нет.
221
+ */
222
+ const normalizeReceipts = (rawReceipts, currencySymbol = null) => {
223
+ let raw = rawReceipts;
224
+ if (typeof raw === 'string') {
225
+ try {
226
+ raw = JSON.parse(raw);
227
+ } catch (e) {
228
+ raw = null;
229
+ }
230
+ }
231
+ raw = isContainer(raw) ? clone(raw) : {};
232
+
233
+ // array_map верхнего уровня: только строки '0'/'1', вложенное не трогается.
234
+ const receipts = Array.isArray(raw) ? {...raw} : raw;
235
+ for (const [key, item] of Object.entries(receipts)) {
236
+ if (item === '0') {
237
+ receipts[key] = false;
238
+ } else if (item === '1') {
239
+ receipts[key] = true;
240
+ }
241
+ }
242
+
243
+ const storedBill = isPlainObject(receipts.bill_receipt_config) ? receipts.bill_receipt_config : null;
244
+ const storedSymbol = storedBill && isPlainObject(storedBill.container) ? storedBill.container.symbol : null;
245
+ const symbol = currencySymbol ?? storedSymbol ?? DEFAULT_SYMBOL;
246
+
247
+ let bill = withContainer(storedBill || defaultBillReceiptConfig(symbol));
248
+ bill.container.symbol = symbol;
249
+ bill.container.fontFamily = bill.container.fontFamily ?? 'monospace';
250
+ bill.container.fontWeight = bill.container.fontWeight ?? 400;
251
+ bill.rows = ensurePoweredSize(ensureDepositRow(asRows(bill.rows)));
252
+
253
+ const storedWorkshop = isPlainObject(receipts.workshop_receipt_config) ? receipts.workshop_receipt_config : null;
254
+ let workshop = withContainer(storedWorkshop || defaultWorkshopReceiptConfig());
255
+ workshop.container.fontFamily = workshop.container.fontFamily ?? 'monospace';
256
+ workshop.container.fontWeight = workshop.container.fontWeight ?? 400;
257
+
258
+ receipts.workshop_receipt_config = convertToBoolean(workshop, ['print', 'value'], ['position', 'size']);
259
+ receipts.bill_receipt_config = convertToBoolean(bill, ['print', 'value'], ['position', 'size']);
260
+ return receipts;
261
+ };
262
+
263
+ /** `$config['rows']->where('type', $type)->first()` */
264
+ const findRow = (config, type) => {
265
+ const rows = config ? asRows(config.rows) : [];
266
+ return rows.find((row) => rowType(row) === type) || null;
267
+ };
268
+
269
+ module.exports = {
270
+ DEFAULT_SYMBOL,
271
+ phpTruthy,
272
+ isNumeric,
273
+ convertToBoolean,
274
+ normalizeReceipts,
275
+ findRow,
276
+ defaultBillReceiptConfig,
277
+ defaultWorkshopReceiptConfig,
278
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Пакетная загрузка связей так, как это делает Eloquent: отдельный `WHERE … IN (…)` на связь,
3
+ * без N+1. Мягко удалённые строки отсекаются только под Postgres: в локальном снапшоте колонки
4
+ * deleted_at у части таблиц нет, как и удалённых строк.
5
+ */
6
+
7
+ const IN_CHUNK = 10000;
8
+
9
+ const unique = (values) => [...new Set(values.filter((value) => value !== null && value !== undefined && value !== '').map(String))];
10
+
11
+ const groupBy = (rows, key) => {
12
+ const map = new Map();
13
+ for (const row of rows) {
14
+ const id = String(row[key]);
15
+ if (!map.has(id)) {
16
+ map.set(id, []);
17
+ }
18
+ map.get(id).push(row);
19
+ }
20
+ return map;
21
+ };
22
+
23
+ const indexBy = (rows, key = 'id') => new Map(rows.map((row) => [String(row[key]), row]));
24
+
25
+ /** ` AND deleted_at IS NULL` под Postgres, пусто в SQLite. */
26
+ const alive = (db, alias = null) => (db.dialect === 'pgsql' ? ` AND ${alias ? `${alias}.` : ''}deleted_at IS NULL` : '');
27
+
28
+ /** `SELECT … WHERE column IN (:ids)` пачками — Postgres держит не больше 65535 параметров. */
29
+ const whereIn = async (db, sql, ids, params = []) => {
30
+ const rows = [];
31
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
32
+ const chunk = ids.slice(i, i + IN_CHUNK);
33
+ rows.push(...await db.all(sql.replace('(:ids)', `(${chunk.map(() => '?').join(', ')})`), [...chunk, ...params]));
34
+ }
35
+ return rows;
36
+ };
37
+
38
+ module.exports = {unique, groupBy, indexBy, alive, whereIn};
@@ -0,0 +1,31 @@
1
+ const {createHash} = require('node:crypto');
2
+
3
+ // Комментарии к чеку и позициям Laravel прогоняет через htmlspecialchars перед сохранением —
4
+ // значение уезжает в чек и в Telegram как есть, поэтому экранируем идентично.
5
+ const htmlspecialchars = (value) => {
6
+ if (value === null || value === undefined) {
7
+ return null;
8
+ }
9
+ return String(value)
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#039;');
15
+ };
16
+
17
+ const md5 = (value) => createHash('md5').update(value).digest('hex');
18
+
19
+ /**
20
+ * json_encode() в поведении PHP по умолчанию: слэши экранируются, не-ASCII уходит в \uXXXX.
21
+ *
22
+ * Нужно именно это, а не голый JSON.stringify: `modifications_sets_hash` = md5(json_encode(...)),
23
+ * по нему ищется существующая позиция чека для слияния. Названия модификаторов
24
+ * азербайджанские — при «родном» stringify хеш ядра и хеш Laravel разойдутся, и один и тот же
25
+ * товар с одними и теми же модификаторами перестанет склеиваться в одну строку чека.
26
+ */
27
+ const phpJsonEncode = (value) => JSON.stringify(value)
28
+ .replace(/\//g, '\\/')
29
+ .replace(/[^\x00-\x7f]/g, (char) => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'));
30
+
31
+ module.exports = {htmlspecialchars, md5, phpJsonEncode};