@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,162 @@
1
+ const telegram = require('../support/telegram');
2
+ const {round, toFloat} = require('../support/num');
3
+ const {isUuid} = require('../support/ids');
4
+ const {trans: transField} = require('../support/lang');
5
+
6
+ /**
7
+ * Уведомления о событиях чека в Telegram команды — порт OrderService::sendEventTelegram.
8
+ *
9
+ * Работает только в облачном контуре (`CORE_MODE=cloud` на Postgres): там ядро заменяет
10
+ * Laravel, и без этого сервиса уведомления о чеках, которые обслужил Node, пропали бы. На кассе
11
+ * это no-op — события касса досылает наверх, и облако шлёт их само при приёме (SyncService).
12
+ *
13
+ * Текст собирается сразу, тем же подключением к БД (внутри транзакции мутации видно её
14
+ * состояние — как у PHP, где отправка шла прямо в saveEvents). Отправка — в очередь чата
15
+ * (`support/telegram.js`), запрос кассы её не ждёт.
16
+ */
17
+ const moneyCast = (value) => (value === null || value === undefined ? null : round(toFloat(value), 3));
18
+
19
+ /** round($x, 2) по значению, которое в PHP могло быть null (null → 0). */
20
+ const round2 = (value) => round(toFloat(value), 2);
21
+
22
+ class TelegramService {
23
+ constructor({db, context}) {
24
+ this.db = db;
25
+ this.context = context;
26
+ }
27
+
28
+ /** Лениво: ядро (index.js) само требует сервисы заказа, прямой require замкнул бы цикл. */
29
+ active() {
30
+ return require('../index').mode() === 'cloud' && this.db.dialect === 'pgsql';
31
+ }
32
+
33
+ /**
34
+ * Уведомления по пачке событий saveEvents. Никогда не бросает: уведомление не должно
35
+ * срывать мутацию чека (в PHP каждое событие шло в своём try/catch).
36
+ */
37
+ async notifyEvents(events) {
38
+ if (!this.active() || !Array.isArray(events) || !events.length) {
39
+ return;
40
+ }
41
+ let settings;
42
+ try {
43
+ settings = this.context.settings();
44
+ } catch (e) {
45
+ return;
46
+ }
47
+ const chatId = telegram.chatIdOf(settings);
48
+ if (!chatId) {
49
+ return;
50
+ }
51
+ const locale = this.context.locale();
52
+ for (const event of events) {
53
+ try {
54
+ const text = await TelegramService.eventText(this.db, event, settings, this.context.employeeId, locale);
55
+ if (text !== null) {
56
+ telegram.send(chatId, text);
57
+ }
58
+ } catch (e) {
59
+ console.warn(`[telegram] событие ${event && event.event}: текст не собран — ${e.message}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Текст уведомления или null (события нет в lang/az/telegram.php либо это группа ключей).
66
+ * Статический: тот же текст нужен приёму чеков с кассы, где сотрудник — из события.
67
+ */
68
+ static async eventText(db, event, settings, employeeId, locale) {
69
+ const messageKey = `telegram.${String((event && event.event) || '').replace(/:/g, '.')}`;
70
+ if (!telegram.has(messageKey, locale)) {
71
+ return null;
72
+ }
73
+
74
+ const owner = employeeId && isUuid(employeeId)
75
+ ? await db.value(
76
+ `SELECT u.name FROM team_users tu
77
+ JOIN users u ON u.id = tu.user_id AND u.deleted_at IS NULL
78
+ WHERE tu.id = ? AND tu.deleted_at IS NULL`,
79
+ [employeeId]
80
+ )
81
+ : null;
82
+
83
+ const orderId = event.order_id || null;
84
+ // withTrashed чек, связи зала и стола — без удалённых (как with(['hall', 'table'])).
85
+ const order = orderId && isUuid(orderId)
86
+ ? await db.get(
87
+ `SELECT o.subtotal, o.discount_amount, o.service_charge_amount, o.discount,
88
+ o.discount_type, o.total, CAST(o.service_charge AS TEXT) AS service_charge_text,
89
+ h.name AS hall_name, t.label AS table_label
90
+ FROM orders o
91
+ LEFT JOIN halls h ON h.id = o.hall_id AND h.deleted_at IS NULL
92
+ LEFT JOIN tables t ON t.id = o.table_id AND t.deleted_at IS NULL
93
+ WHERE o.id = ?`,
94
+ [orderId]
95
+ )
96
+ : null;
97
+
98
+ const settingsMode = settings && settings.terminal_settings
99
+ ? settings.terminal_settings.service_charge_calculate_mode
100
+ : null;
101
+ const mode = settingsMode || 'after_discount';
102
+ const isServiceCharge = messageKey.includes('service_charge');
103
+
104
+ let productName = null;
105
+ let quantity = 0;
106
+ let amount;
107
+ if (event.product_id !== undefined && event.product_id !== null) {
108
+ const raw = isUuid(event.product_id)
109
+ ? await db.value('SELECT full_name FROM products WHERE id = ?', [event.product_id])
110
+ : null;
111
+ productName = raw === null || raw === undefined
112
+ ? null
113
+ : transField(raw, locale, process.env.APP_FALLBACK_LOCALE || 'en');
114
+ quantity = event.quantity !== undefined && event.quantity !== null ? event.quantity : 0;
115
+ let price = event.price !== undefined ? event.price : null;
116
+ if (price === null) {
117
+ const stored = orderId && isUuid(orderId) && isUuid(event.product_id)
118
+ ? await db.value(
119
+ `SELECT price FROM order_products
120
+ WHERE order_id = ? AND product_id = ? AND deleted_at IS NULL LIMIT 1`,
121
+ [orderId, event.product_id]
122
+ )
123
+ : null;
124
+ price = moneyCast(stored);
125
+ }
126
+ amount = toFloat(quantity) * toFloat(price === null ? 0 : price);
127
+ } else if (order) {
128
+ amount = moneyCast(isServiceCharge ? order.service_charge_amount : order.discount_amount);
129
+ } else {
130
+ amount = null;
131
+ }
132
+
133
+ const discount = order ? moneyCast(order.discount) : null;
134
+ const percent = isServiceCharge
135
+ ? (order ? order.service_charge_text : null)
136
+ : discount;
137
+ const serviceCharge = order ? order.service_charge_text : null;
138
+ const tableLabel = order ? order.table_label : null;
139
+
140
+ const receipt = telegram.trans(`telegram.receipt.${mode}`, {
141
+ subtotal: round2(order && moneyCast(order.subtotal)),
142
+ discount: telegram.phpString(round2(order && moneyCast(order.discount_amount)))
143
+ + ((order ? order.discount_type : null) !== 'amount' ? ` (${telegram.phpString(discount)}%)` : ''),
144
+ service: `(${telegram.phpString(serviceCharge)}%) ${telegram.phpString(round2(order && moneyCast(order.service_charge_amount)))}`,
145
+ total: round2(order && moneyCast(order.total)),
146
+ }, locale);
147
+
148
+ return telegram.trans(messageKey, {
149
+ hall: order ? order.hall_name : null,
150
+ table: tableLabel,
151
+ owner,
152
+ product: productName,
153
+ quantity,
154
+ amount,
155
+ percent,
156
+ locative: telegram.azLocativeSuffix(tableLabel === null || tableLabel === undefined ? '' : tableLabel),
157
+ receipt: receipt === null ? '' : receipt,
158
+ }, locale);
159
+ }
160
+ }
161
+
162
+ module.exports = TelegramService;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Диалектные мелочи записи.
3
+ *
4
+ * SQLite хранит флаги как INTEGER 0/1, Postgres — как настоящий BOOLEAN и отказывается
5
+ * принимать 0/1 («column is of type boolean but expression is of type integer»). Ядро
6
+ * пишет флаги как попало (0/1/true/false), поэтому приводим их к boolean в одном месте —
7
+ * на границе записи. Обратно в 0/1 для SQLite приводит driver.js.
8
+ *
9
+ * Чтение уже нормализовано: модели прогоняют флаги через toBool().
10
+ */
11
+ const BOOLEAN_COLUMNS = new Set([
12
+ // orders
13
+ 'payed_by_third_party_application', 'is_closed_without_payment', 'should_send_to_kitchen',
14
+ // order_products
15
+ 'is_gift', 'is_stopped', 'is_fixed',
16
+ // products (создание «свободного» товара в addExtra)
17
+ 'is_giftable', 'is_available', 'is_active', 'is_hidden', 'is_weight_item', 'is_not_discount',
18
+ 'is_with_modification',
19
+ // kds_tickets
20
+ 'tracked',
21
+ // printers / printer_workshop / product_workshops
22
+ 'bill', 'bell', 'cashdraw', 'print_ticket',
23
+ ]);
24
+
25
+ const toDbBool = (value) => {
26
+ if (value === null || value === undefined) {
27
+ return null;
28
+ }
29
+ if (typeof value === 'string') {
30
+ return value === '1' || value.toLowerCase() === 'true';
31
+ }
32
+ return Boolean(Number(value) || value === true);
33
+ };
34
+
35
+ /** Приводит значение к типу колонки, если та булева; иначе отдаёт как есть. */
36
+ const castColumn = (column, value) => (BOOLEAN_COLUMNS.has(column) ? toDbBool(value) : value);
37
+
38
+ /** Приводит булевы поля во всём payload перед insert/update. */
39
+ const castRow = (payload) => {
40
+ for (const column of Object.keys(payload)) {
41
+ if (BOOLEAN_COLUMNS.has(column)) {
42
+ payload[column] = toDbBool(payload[column]);
43
+ }
44
+ }
45
+ return payload;
46
+ };
47
+
48
+ module.exports = {BOOLEAN_COLUMNS, toDbBool, castColumn, castRow};
@@ -0,0 +1,9 @@
1
+ const {v7: uuidv7, validate: uuidValidate} = require('uuid');
2
+
3
+ // UUID v7 — те же идентификаторы, что генерирует Laravel (HasUuids). Монотонность по времени
4
+ // даёт заодно естественный порядок вставки, на который опирается сортировка позиций чека.
5
+ const uuid = () => uuidv7();
6
+
7
+ const isUuid = (value) => typeof value === 'string' && uuidValidate(value);
8
+
9
+ module.exports = {uuid, isUuid};
@@ -0,0 +1,135 @@
1
+ const axios = require('axios');
2
+ const {diskUrl} = require('./images');
3
+ const {isUuid} = require('./ids');
4
+ const {toInt} = require('./num');
5
+
6
+ /**
7
+ * Картинка из `images` как data URI — порт ImageService::toBase64 для облачного контура
8
+ * (логотип на счёте). Laravel читает файл из своего Storage, Node берёт тот же файл по публичному
9
+ * адресу диска картинки (`images.disk`, CDN для do_spaces).
10
+ *
11
+ * Отличие от PHP: Laravel читает путь с диска ПО УМОЛЧАНИЮ (`Storage::get($path)`), а не с диска
12
+ * картинки. При `FILESYSTEM_DISK=public` логотип с do_spaces он не находит и отдаёт пустой
13
+ * `data:;base64,`. Здесь — диск самой картинки.
14
+ *
15
+ * Кэш — в процессе, по id картинки: новый логотип — новая строка `images`, так что устаревшей
16
+ * копии не бывает, TTL нужен только чтобы память не держала логотипы ушедших команд. Провал
17
+ * запоминается ненадолго: счёт не должен ждать недоступный CDN на каждой печати.
18
+ *
19
+ * Env: TEAM_LOGO_TIMEOUT_MS (5000), TEAM_LOGO_CACHE_MS (3600000), TEAM_LOGO_MAX_BYTES (2 МБ).
20
+ */
21
+ const FAILURE_TTL_MS = 60000;
22
+ const MAX_ENTRIES = 500;
23
+
24
+ const timeoutMs = () => toInt(process.env.TEAM_LOGO_TIMEOUT_MS, 0) || 5000;
25
+ const cacheMs = () => toInt(process.env.TEAM_LOGO_CACHE_MS, 0) || 3600000;
26
+ const maxBytes = () => toInt(process.env.TEAM_LOGO_MAX_BYTES, 0) || 2 * 1024 * 1024;
27
+
28
+ const cache = new Map();
29
+
30
+ /** Тип по сигнатуре файла — как finfo у локального диска Laravel. */
31
+ const sniffMime = (buffer) => {
32
+ const head = buffer.subarray(0, 16);
33
+ if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
34
+ return 'image/png';
35
+ }
36
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
37
+ return 'image/jpeg';
38
+ }
39
+ const ascii = head.toString('latin1');
40
+ if (ascii.startsWith('GIF87a') || ascii.startsWith('GIF89a')) {
41
+ return 'image/gif';
42
+ }
43
+ if (ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP') {
44
+ return 'image/webp';
45
+ }
46
+ if (ascii.startsWith('BM')) {
47
+ return 'image/bmp';
48
+ }
49
+ if (/^\s*(<\?xml|<svg)/i.test(buffer.subarray(0, 256).toString('utf8'))) {
50
+ return 'image/svg+xml';
51
+ }
52
+ return null;
53
+ };
54
+
55
+ /** Content-Type картинки из ответа (как mimeType у S3 — из метаданных объекта), иначе сигнатура. */
56
+ const mimeOf = (contentType, buffer) => {
57
+ const declared = String(contentType || '').split(';')[0].trim().toLowerCase();
58
+ if (declared.startsWith('image/')) {
59
+ return declared;
60
+ }
61
+ return sniffMime(buffer);
62
+ };
63
+
64
+ const load = async (db, imageId) => {
65
+ const image = await db.get('SELECT disk, path FROM images WHERE id = ? AND deleted_at IS NULL', [imageId]);
66
+ if (!image) {
67
+ return null;
68
+ }
69
+ const url = diskUrl(image.disk, image.path);
70
+ if (!url || !/^https?:\/\//i.test(url)) {
71
+ console.warn(`[images] ${imageId}: у диска ${image.disk} нет публичного адреса — логотип пропущен`);
72
+ return null;
73
+ }
74
+ const response = await axios.get(url, {
75
+ responseType: 'arraybuffer',
76
+ timeout: timeoutMs(),
77
+ maxContentLength: maxBytes(),
78
+ maxRedirects: 3,
79
+ validateStatus: () => true,
80
+ });
81
+ if (response.status < 200 || response.status >= 300) {
82
+ console.warn(`[images] ${imageId}: HTTP ${response.status} — логотип пропущен`);
83
+ return null;
84
+ }
85
+ const buffer = Buffer.from(response.data);
86
+ const mime = buffer.length ? mimeOf(response.headers && response.headers['content-type'], buffer) : null;
87
+ if (!mime) {
88
+ // Пустой или не-картинка: PHP в таком случае отдал бы битый data URI, счёт — пустую рамку.
89
+ console.warn(`[images] ${imageId}: файл не похож на картинку — логотип пропущен`);
90
+ return null;
91
+ }
92
+ return `data:${mime};base64,${buffer.toString('base64')}`;
93
+ };
94
+
95
+ const prune = (now) => {
96
+ for (const [key, entry] of cache) {
97
+ if (entry.expires <= now) {
98
+ cache.delete(key);
99
+ }
100
+ }
101
+ while (cache.size > MAX_ENTRIES) {
102
+ cache.delete(cache.keys().next().value);
103
+ }
104
+ };
105
+
106
+ /**
107
+ * data URI картинки или null (нет картинки, нет публичного адреса, CDN не ответил). Никогда не
108
+ * бросает. Одновременные запросы одной картинки ждут одну загрузку.
109
+ */
110
+ const imageDataUri = async (db, imageId) => {
111
+ if (!imageId || !isUuid(String(imageId))) {
112
+ return null;
113
+ }
114
+ const key = String(imageId);
115
+ const now = Date.now();
116
+ const hit = cache.get(key);
117
+ if (hit && hit.expires > now) {
118
+ return hit.promise;
119
+ }
120
+ const promise = load(db, key).catch((e) => {
121
+ console.warn(`[images] ${key}: не загружен — ${e && e.code ? `${e.code} ` : ''}${e && e.message}`);
122
+ return null;
123
+ });
124
+ cache.set(key, {promise, expires: now + cacheMs()});
125
+ const value = await promise;
126
+ if (value === null && cache.get(key)?.promise === promise) {
127
+ cache.set(key, {promise, expires: Date.now() + FAILURE_TTL_MS});
128
+ }
129
+ prune(Date.now());
130
+ return value;
131
+ };
132
+
133
+ const clearImageCache = () => cache.clear();
134
+
135
+ module.exports = {imageDataUri, sniffMime, clearImageCache};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * URL картинки по id из `images` — порт ImageCast + ImageService::getUrl
3
+ * (`Storage::disk($image->disk)->url($image->path)`).
4
+ *
5
+ * Адреса дисков — как в cafepos-admin/config/filesystems.php, те же переменные окружения.
6
+ * Нестандартный диск можно описать в IMAGE_DISK_URLS (JSON `{"disk": "https://base"}`).
7
+ *
8
+ * Выборка пакетная: init отдаёт сотни товаров, по запросу на картинку — это сотни запросов.
9
+ */
10
+ const GSTATIC = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRlGIgmMH0w19G8Lz6KizIhfg1SMZAAJvkxSB9E5BYT9j7hPrardmF4lINneTRdokKNVBw&usqp=CAU';
11
+
12
+ const CHUNK = 500;
13
+
14
+ // Как принимает uuid-колонка Postgres (без проверки версии: у старых строк она любая).
15
+ const UUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
16
+ const isUuid = (value) => UUID.test(value);
17
+
18
+ const join = (base, path) => `${String(base).replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`;
19
+
20
+ let customDisks;
21
+ const customDiskUrls = () => {
22
+ if (customDisks === undefined) {
23
+ customDisks = {};
24
+ try {
25
+ const parsed = JSON.parse(process.env.IMAGE_DISK_URLS || '{}');
26
+ if (parsed && typeof parsed === 'object') {
27
+ customDisks = parsed;
28
+ }
29
+ } catch (e) {
30
+ console.warn('[images] IMAGE_DISK_URLS is not valid JSON, ignored');
31
+ }
32
+ }
33
+ return customDisks;
34
+ };
35
+
36
+ const diskUrl = (disk, path) => {
37
+ if (path === null || path === undefined) {
38
+ return null;
39
+ }
40
+ const custom = customDiskUrls()[disk];
41
+ if (custom) {
42
+ return join(custom, path);
43
+ }
44
+ switch (disk) {
45
+ case 'do_spaces':
46
+ // root 'v2' — префикс пути внутри бакета.
47
+ return join(process.env.DO_SPACES_URL || 'https://cdn.cafepos.az', join('v2', path));
48
+ case 'public':
49
+ return join(`${process.env.APP_URL || ''}/storage`, path);
50
+ case 's3':
51
+ return join(process.env.AWS_URL || '', path);
52
+ default:
53
+ // Диск local без url: FilesystemAdapter::getLocalUrl → '/storage/<path>'.
54
+ return join('/storage', path);
55
+ }
56
+ };
57
+
58
+ /**
59
+ * Map<id, url|null> для набора id. Пустое значение и не-uuid (Postgres упал бы на uuid-колонке)
60
+ * в выборку не идут; не найденная или удалённая картинка → null, как Image::find.
61
+ */
62
+ const imageUrls = async (db, values) => {
63
+ const ids = [...new Set(values.filter((value) => value && isUuid(String(value))).map(String))];
64
+ const urls = new Map();
65
+ for (let i = 0; i < ids.length; i += CHUNK) {
66
+ const chunk = ids.slice(i, i + CHUNK);
67
+ const rows = await db.all(
68
+ `SELECT id, disk, path FROM images WHERE id IN (${chunk.map(() => '?').join(', ')}) AND deleted_at IS NULL`,
69
+ chunk
70
+ );
71
+ for (const row of rows) {
72
+ urls.set(String(row.id), diskUrl(row.disk, row.path));
73
+ }
74
+ }
75
+ return urls;
76
+ };
77
+
78
+ /**
79
+ * Значение каста ImageCast: пустое — как есть, иначе URL или null.
80
+ * `urls` = null — таблицы images нет (снапшот кассы): значение колонки отдаётся как лежит.
81
+ */
82
+ const castImage = (urls, value) => {
83
+ if (!value) {
84
+ return value === undefined ? null : value;
85
+ }
86
+ if (!urls) {
87
+ return value;
88
+ }
89
+ return urls.get(String(value)) ?? null;
90
+ };
91
+
92
+ module.exports = {GSTATIC, diskUrl, imageUrls, castImage};
@@ -0,0 +1,105 @@
1
+ const {now, toSql} = require('./time');
2
+ const {iso8601} = require('./php');
3
+
4
+ /**
5
+ * Порт App\Services\Platform\LanHandoffService (облачная часть LAN-перехода официанта):
6
+ * куда браузеру уйти по LAN, если пропадёт интернет. Флаги — те же, что у Laravel.
7
+ */
8
+ const enabled = () => {
9
+ const value = String(process.env.LAN_HANDOFF_ENABLED || '').trim().toLowerCase();
10
+ // (bool) env(...): Laravel превращает 'false'/'(false)'/'null'/'empty' в false/null/'',
11
+ // остальные непустые строки кроме '0' истинны.
12
+ return !['', '0', 'false', '(false)', 'null', '(null)', 'empty', '(empty)'].includes(value);
13
+ };
14
+
15
+ const freshSeconds = () => Math.max(1, parseInt(process.env.LAN_HANDOFF_MASTER_FRESH_SECONDS, 10) || 600);
16
+
17
+ const PRIVATE_URL = /^http:\/\/(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})\/?$/;
18
+
19
+ /**
20
+ * Только http://<приватный IPv4>:<порт> без пути: адрес уходит в браузер, и подменённый
21
+ * heartbeat не должен увести официанта на чужой сайт.
22
+ */
23
+ const isPrivateUrl = (url) => {
24
+ const match = typeof url === 'string' ? PRIVATE_URL.exec(url) : null;
25
+ if (!match) {
26
+ return false;
27
+ }
28
+ const port = parseInt(match[2], 10);
29
+ if (port < 1 || port > 65535) {
30
+ return false;
31
+ }
32
+ const octets = match[1].split('.').map((octet) => parseInt(octet, 10));
33
+ if (octets.some((octet) => octet > 255)) {
34
+ return false;
35
+ }
36
+ const [a, b] = octets;
37
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
38
+ };
39
+
40
+ const isObject = (value) => value !== null && typeof value === 'object';
41
+
42
+ const parseJson = (value) => {
43
+ if (typeof value !== 'string') {
44
+ return value;
45
+ }
46
+ try {
47
+ return JSON.parse(value);
48
+ } catch (e) {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** Блок lan из heartbeat кассы, если по нему можно уйти. */
54
+ const lanOf = (terminal) => {
55
+ const status = parseJson(terminal.sync_status);
56
+ const lan = isObject(status) && isObject(status.lan) ? status.lan : null;
57
+ if (!lan || status.role !== 'master') {
58
+ return null;
59
+ }
60
+ const version = isObject(lan.front) ? lan.front.version : null;
61
+ if (typeof version !== 'string' || version === '') {
62
+ return null;
63
+ }
64
+ const urls = (isObject(lan.urls) ? Object.values(lan.urls) : [])
65
+ .filter((url) => typeof url === 'string' && isPrivateUrl(url));
66
+ if (!urls.length) {
67
+ return null;
68
+ }
69
+ return {
70
+ terminal_id: terminal.id,
71
+ urls: urls.slice(0, 4),
72
+ front_version: version,
73
+ seen_at: iso8601(terminal.sync_seen_at),
74
+ };
75
+ };
76
+
77
+ /** Назначенный мастер модели M главнее, иначе — самая свежая подходящая касса. */
78
+ const masterFor = async (db, companyId) => {
79
+ if (!companyId) {
80
+ return null;
81
+ }
82
+ const fresh = toSql(new Date(now().getTime() - freshSeconds() * 1000));
83
+ const company = await db.get('SELECT pos_master_terminal_id FROM companies WHERE id = ?', [companyId]);
84
+ const assigned = company ? company.pos_master_terminal_id : null;
85
+ const candidates = await db.all(
86
+ `SELECT id, sync_status, sync_seen_at FROM terminals
87
+ WHERE company_id = ? AND sync_seen_at IS NOT NULL AND sync_seen_at >= ? AND deleted_at IS NULL
88
+ ORDER BY sync_seen_at DESC`,
89
+ [companyId, fresh]
90
+ );
91
+ let best = null;
92
+ for (const terminal of candidates) {
93
+ const lan = lanOf(terminal);
94
+ if (lan === null) {
95
+ continue;
96
+ }
97
+ if (assigned && String(terminal.id) === String(assigned)) {
98
+ return lan;
99
+ }
100
+ best = best || lan;
101
+ }
102
+ return best;
103
+ };
104
+
105
+ module.exports = {enabled, masterFor, isPrivateUrl, lanOf};
@@ -0,0 +1,98 @@
1
+ const FALLBACK_LOCALE = 'en';
2
+
3
+ /**
4
+ * Переводимые поля (spatie/laravel-translatable) лежат в БД как JSON `{"az": "...", "ru": "..."}`.
5
+ * Достаём значение для локали команды; если её нет — fallback, если и его нет — первое непустое.
6
+ * Строка без JSON — это старое непереведённое значение, отдаём как есть.
7
+ */
8
+ const translated = (value, locale = FALLBACK_LOCALE) => {
9
+ if (value === null || value === undefined) {
10
+ return null;
11
+ }
12
+
13
+ let map = value;
14
+ if (typeof value === 'string') {
15
+ const trimmed = value.trim();
16
+ if (!trimmed.startsWith('{')) {
17
+ return value;
18
+ }
19
+ try {
20
+ map = JSON.parse(trimmed);
21
+ } catch (e) {
22
+ return value;
23
+ }
24
+ }
25
+
26
+ if (typeof map !== 'object' || map === null) {
27
+ return String(value);
28
+ }
29
+
30
+ if (map[locale]) {
31
+ return map[locale];
32
+ }
33
+ if (map[FALLBACK_LOCALE]) {
34
+ return map[FALLBACK_LOCALE];
35
+ }
36
+ const first = Object.values(map).find((item) => item !== null && item !== '');
37
+ return first === undefined ? null : first;
38
+ };
39
+
40
+ /**
41
+ * Строгий порт spatie/laravel-translatable v6 (`$model->name`, getAttributeValue) — для ответов,
42
+ * которые обязаны совпасть с Laravel поле в поле (app.init, справочники). В отличие от
43
+ * `translated`, здесь нет «первого непустого» и нет возврата сырой строки:
44
+ * - NULL в БД → null;
45
+ * - не JSON-объект (старое непереведённое значение) → '' (json_decode даёт не массив);
46
+ * - пустые и null-переводы выкидываются, затем локаль → 'en' (app.fallback_locale) → ''.
47
+ */
48
+ const translationsOf = (raw) => {
49
+ let map = raw;
50
+ if (typeof raw === 'string') {
51
+ try {
52
+ map = JSON.parse(raw);
53
+ } catch (e) {
54
+ map = null;
55
+ }
56
+ }
57
+ return map !== null && typeof map === 'object' ? map : {};
58
+ };
59
+
60
+ const trans = (raw, locale = FALLBACK_LOCALE, fallback = FALLBACK_LOCALE) => {
61
+ if (raw === null || raw === undefined) {
62
+ return null;
63
+ }
64
+ const map = translationsOf(raw);
65
+ const present = (key) => Object.prototype.hasOwnProperty.call(map, key) && map[key] !== null && map[key] !== '';
66
+ if (present(locale)) {
67
+ return map[locale];
68
+ }
69
+ if (present(fallback)) {
70
+ return map[fallback];
71
+ }
72
+ return '';
73
+ };
74
+
75
+ /**
76
+ * То же поле в `$model->toArray()` (App\Traits\HasTranslations): spatie кастует переводимое
77
+ * поле в array, и перевод берётся, только если json_decode дал массив. Невалидный JSON → null,
78
+ * JSON-скаляр ("123", "true") отдаётся как есть.
79
+ */
80
+ const transArray = (raw, locale = FALLBACK_LOCALE) => {
81
+ if (raw === null || raw === undefined) {
82
+ return null;
83
+ }
84
+ let decoded = raw;
85
+ if (typeof raw === 'string') {
86
+ try {
87
+ decoded = JSON.parse(raw);
88
+ } catch (e) {
89
+ return null;
90
+ }
91
+ }
92
+ if (decoded !== null && typeof decoded === 'object') {
93
+ return trans(decoded, locale);
94
+ }
95
+ return decoded;
96
+ };
97
+
98
+ module.exports = {translated, trans, transArray, FALLBACK_LOCALE};