@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,77 @@
1
+ const {normalizeReceipts} = require('./receipts');
2
+ const {phpArray, zeroOneToBool, filterBool} = require('./php');
3
+
4
+ /**
5
+ * Порт TeamSettingsCast — `team.settings` в том виде, в каком его отдаёт Laravel (TeamResource,
6
+ * app.init). Фронт терминала читает отсюда всё: настройки чека, терминала, KDS, управления.
7
+ *
8
+ * Порядок ключей как в PHP: существующие остаются на месте, недостающие дописываются в конец в
9
+ * порядке manage → terminal_settings → receipts → kds.
10
+ */
11
+
12
+ const isContainer = (value) => value !== null && typeof value === 'object';
13
+
14
+ const decode = (raw) => {
15
+ if (typeof raw !== 'string') {
16
+ return raw === undefined ? null : raw;
17
+ }
18
+ try {
19
+ return JSON.parse(raw);
20
+ } catch (e) {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const asObject = (value) => (Array.isArray(value) ? {...value} : value);
26
+
27
+ /** Пустой PHP-массив в JSON — `[]`, а не `{}`. */
28
+ const emptyAsList = (value) => (isContainer(value) && !Array.isArray(value) && Object.keys(value).length === 0 ? [] : value);
29
+
30
+ /** TeamManageSettingsCast + convertToBoolean. */
31
+ const manageSettings = (value) => {
32
+ const settings = asObject(zeroOneToBool(isContainer(value) ? value : []));
33
+ if (!Object.prototype.hasOwnProperty.call(settings, 'show_deleted_order_events')) {
34
+ settings.show_deleted_order_events = true;
35
+ }
36
+ return settings;
37
+ };
38
+
39
+ /**
40
+ * TeamTerminalSettingsCast. В PHP array_map по null — TypeError и 500 на весь init; у старых
41
+ * команд без секции отдаём пустой массив.
42
+ */
43
+ const terminalSettings = (value) => emptyAsList(zeroOneToBool(isContainer(value) ? value : []));
44
+
45
+ const kdsSettings = (value) => {
46
+ const kds = isContainer(value) ? asObject(value) : {};
47
+ kds.enabled = filterBool(kds.enabled ?? false);
48
+ return kds;
49
+ };
50
+
51
+ /**
52
+ * @param {string|object|null} raw teams.settings (строка JSON или уже разобранный объект)
53
+ * @param {string|null} currencySymbol символ валюты команды — для конфига счёта
54
+ */
55
+ const teamSettings = (raw, currencySymbol = null) => {
56
+ const decoded = decode(raw);
57
+ // Объекты json_decode(…, true) — PHP-массивы: пустые и «списочные» сериализуются списком.
58
+ const settings = isContainer(decoded) ? asObject(phpArray(decoded)) : {};
59
+
60
+ settings.manage = manageSettings(settings.manage);
61
+ settings.terminal_settings = terminalSettings(settings.terminal_settings);
62
+ // receipts — из исходного JSON: normalizeReceipts тот же, что уходит на терминал в печати.
63
+ settings.receipts = normalizeReceipts(isContainer(decoded) ? decoded.receipts : null, currencySymbol);
64
+ settings.kds = kdsSettings(settings.kds);
65
+ return settings;
66
+ };
67
+
68
+ /** Team::hasTaxIntegration — сырой settings не пуст и в нём есть tax_integration (isset). */
69
+ const hasTaxIntegration = (raw) => {
70
+ const decoded = decode(raw);
71
+ if (!raw || !isContainer(decoded)) {
72
+ return false;
73
+ }
74
+ return decoded.tax_integration !== undefined && decoded.tax_integration !== null;
75
+ };
76
+
77
+ module.exports = {teamSettings, hasTaxIntegration};
@@ -0,0 +1,253 @@
1
+ const axios = require('axios');
2
+ const {toInt} = require('./num');
3
+
4
+ /**
5
+ * Telegram команды: отправка сообщений и то, что нужно, чтобы текст совпал с Laravel
6
+ * (irazasyed/telegram-bot-sdk + Translator + приведение чисел PHP).
7
+ *
8
+ * Отправка не держит запрос кассы: `send` ставит сообщение в очередь своего чата и сразу
9
+ * возвращается. Очередь по чату сохраняет порядок уведомлений («создал» раньше «закрыл»), как
10
+ * у PHP, где отправка шла прямо в запросе.
11
+ *
12
+ * ⚠️ Токен бота в логи не попадает: в предупреждениях только HTTP-статус, описание ошибки
13
+ * Telegram и `e.message` (у axios в нём нет URL запроса).
14
+ *
15
+ * Env: TELEGRAM_BOT_TOKEN (тот же, что у Laravel), TELEGRAM_ENABLED (false — не слать вовсе),
16
+ * TELEGRAM_API_URL (https://api.telegram.org), TELEGRAM_TIMEOUT_MS (15000).
17
+ */
18
+
19
+ const MAX_RETRY_AFTER_S = 30;
20
+ // Очередь одного чата не растёт бесконечно, если Telegram висит: дальше — сброс с предупреждением.
21
+ const MAX_PENDING_PER_CHAT = 200;
22
+
23
+ const apiBase = () => String(process.env.TELEGRAM_API_URL || 'https://api.telegram.org').replace(/\/+$/, '');
24
+ const botToken = () => String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
25
+ const timeoutMs = () => toInt(process.env.TELEGRAM_TIMEOUT_MS, 0) || 15000;
26
+ const enabled = () => String(process.env.TELEGRAM_ENABLED || '').toLowerCase() !== 'false';
27
+
28
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+
30
+ const chains = new Map();
31
+ const pending = new Map();
32
+ let warnedNoToken = false;
33
+
34
+ const post = async (chatId, text, retried = false) => {
35
+ const response = await axios.post(
36
+ `${apiBase()}/bot${botToken()}/sendMessage`,
37
+ {chat_id: chatId, text, parse_mode: 'HTML'},
38
+ {timeout: timeoutMs(), validateStatus: () => true}
39
+ );
40
+ const data = response.data && typeof response.data === 'object' ? response.data : {};
41
+ if (response.status === 429 && !retried) {
42
+ const retryAfter = Math.min(Math.max(toInt(data.parameters && data.parameters.retry_after, 1), 1), MAX_RETRY_AFTER_S);
43
+ await sleep(retryAfter * 1000);
44
+ return post(chatId, text, true);
45
+ }
46
+ if (response.status < 200 || response.status >= 300 || data.ok === false) {
47
+ const description = typeof data.description === 'string' ? ` — ${data.description.slice(0, 200)}` : '';
48
+ console.warn(`[telegram] чат ${chatId}: HTTP ${response.status}${description}`);
49
+ return false;
50
+ }
51
+ return true;
52
+ };
53
+
54
+ /** Telegram::sendMessage({chat_id, text, parse_mode: 'HTML'}) без ожидания ответа. Никогда не отклоняется. */
55
+ const send = (chatId, text) => {
56
+ if (!enabled()) {
57
+ return Promise.resolve(false);
58
+ }
59
+ if (!botToken()) {
60
+ if (!warnedNoToken) {
61
+ warnedNoToken = true;
62
+ console.warn('[telegram] TELEGRAM_BOT_TOKEN не задан — уведомления не отправляются');
63
+ }
64
+ return Promise.resolve(false);
65
+ }
66
+ const key = String(chatId);
67
+ const count = pending.get(key) || 0;
68
+ if (count >= MAX_PENDING_PER_CHAT) {
69
+ console.warn(`[telegram] чат ${key}: очередь переполнена, сообщение сброшено`);
70
+ return Promise.resolve(false);
71
+ }
72
+ pending.set(key, count + 1);
73
+
74
+ const previous = chains.get(key) || Promise.resolve();
75
+ const next = previous
76
+ .then(() => post(chatId, String(text)))
77
+ .catch((e) => {
78
+ console.warn(`[telegram] чат ${key}: не отправлено — ${e && e.code ? `${e.code} ` : ''}${e && e.message}`);
79
+ return false;
80
+ })
81
+ .then((result) => {
82
+ const left = (pending.get(key) || 1) - 1;
83
+ if (left > 0) {
84
+ pending.set(key, left);
85
+ } else {
86
+ pending.delete(key);
87
+ }
88
+ if (chains.get(key) === next) {
89
+ chains.delete(key);
90
+ }
91
+ return result;
92
+ });
93
+ chains.set(key, next);
94
+ return next;
95
+ };
96
+
97
+ /** Дождаться отправки всего поставленного (тесты, остановка процесса). */
98
+ const flush = () => Promise.all([...chains.values()]);
99
+
100
+ // ------------------------------------------------------------------ PHP-совместимость
101
+
102
+ /** `$settings['tg_chat_id']` в PHP-истинности: '', '0', 0, null, false — «нет чата». */
103
+ const chatIdOf = (settings) => {
104
+ const value = settings && typeof settings === 'object' ? settings.tg_chat_id : null;
105
+ if (value === null || value === undefined || value === false || value === '' || value === '0' || value === 0) {
106
+ return null;
107
+ }
108
+ if (Array.isArray(value) || typeof value === 'object') {
109
+ return null;
110
+ }
111
+ return value;
112
+ };
113
+
114
+ /**
115
+ * Значение → строка, как PHP приводит его при подстановке в текст: float по `precision = 14`,
116
+ * без «.0» у целых, null → '', true → '1', false → ''.
117
+ */
118
+ const phpString = (value) => {
119
+ if (value === null || value === undefined || value === false) {
120
+ return '';
121
+ }
122
+ if (value === true) {
123
+ return '1';
124
+ }
125
+ if (typeof value === 'number') {
126
+ if (Object.is(value, -0)) {
127
+ return '-0';
128
+ }
129
+ if (!Number.isFinite(value)) {
130
+ return Number.isNaN(value) ? 'NAN' : (value > 0 ? 'INF' : '-INF');
131
+ }
132
+ return Number.isInteger(value) ? String(value) : String(Number(value.toPrecision(14)));
133
+ }
134
+ return String(value);
135
+ };
136
+
137
+ /** strtr($text, $pairs): на каждой позиции — самое длинное совпадение, подстановки не перечитываются. */
138
+ const strtr = (text, pairs) => {
139
+ const keys = Object.keys(pairs).filter((key) => key !== '').sort((a, b) => b.length - a.length);
140
+ if (!keys.length) {
141
+ return text;
142
+ }
143
+ let result = '';
144
+ let i = 0;
145
+ outer:
146
+ while (i < text.length) {
147
+ for (const key of keys) {
148
+ if (text.startsWith(key, i)) {
149
+ result += pairs[key];
150
+ i += key.length;
151
+ continue outer;
152
+ }
153
+ }
154
+ result += text[i];
155
+ i += 1;
156
+ }
157
+ return result;
158
+ };
159
+
160
+ const ucfirst = (value) => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value);
161
+
162
+ /** Translator::makeReplacements: `:key`, `:Key`, `:KEY`. */
163
+ const makeReplacements = (line, replace) => {
164
+ const pairs = {};
165
+ for (const [key, raw] of Object.entries(replace || {})) {
166
+ const value = phpString(raw);
167
+ pairs[`:${ucfirst(key)}`] = ucfirst(value);
168
+ pairs[`:${key.toUpperCase()}`] = value.toUpperCase();
169
+ pairs[`:${key}`] = value;
170
+ }
171
+ return strtr(line, pairs);
172
+ };
173
+
174
+ /** Хелпер azLocativeSuffix() из bootstrap/helpers.php. */
175
+ const azLocativeSuffix = (value) => {
176
+ const text = phpString(value);
177
+ const digits = text.match(/\d+$/);
178
+ if (digits) {
179
+ return [1, 2, 3, 4, 5, 7, 8].includes(Number(digits[0].slice(-1))) ? '-də' : '-da';
180
+ }
181
+ const last = Array.from(text).slice(-1)[0];
182
+ const lastChar = last ? last.toLowerCase() : '';
183
+ return ['ə', 'e', 'i', 'ö', 'ü'].includes(lastChar) ? '-də' : '-da';
184
+ };
185
+
186
+ // ------------------------------------------------------------------ тексты lang/az/telegram.php
187
+
188
+ const TEXTS = {
189
+ receipt: {
190
+ after_discount: '<b>Hesab</b>:\nToplam: :subtotal AZN\nEndirim: :discount AZN\nServis haqqi: :service AZN\nYekun: :total AZN',
191
+ before_discount: '<b>Hesab</b>:\nToplam: :subtotal AZN\nServis haqqi: :service AZN\nEndirim: :discount AZN\nYekun: :total AZN',
192
+ },
193
+ orders: {
194
+ create: ':owner :hall::table:locative Sifaris yaratdi',
195
+ close: ':owner :hall::table:locative sifarisi bagladi\n:receipt',
196
+ removed: ':owner :hall::table:locative sifarisi sildi\n:receipt',
197
+ rollback: ':owner :hall::table:locative sifarisi yeniden acdi\n:receipt',
198
+ remove_product: ':owner :hall::table:locative :quantity (:amount AZN) eded :product məhsulunu sildi\n:receipt',
199
+ merged: ':owner :hall::table sifarisi :target_hall::target_table birlesdirdi',
200
+ service_charge: ':owner :hall::table:locative :percent% (:amount AZN) servis haqqi tetbiq etdi.\n:receipt',
201
+ discount: {
202
+ percent: ':owner :hall::table:locative :percent% (:amount AZN) endirim etdi.\n:receipt',
203
+ amount: ':owner :hall::table:locative :amount AZN endirim etdi.\n:receipt',
204
+ },
205
+ },
206
+ };
207
+
208
+ /** Файл переводов есть только азербайджанский: Laravel находит его по локали команды или fallback. */
209
+ const TEXTS_LOCALE = 'az';
210
+ const fallbackLocale = () => process.env.APP_FALLBACK_LOCALE || 'en';
211
+
212
+ /** Значение ключа `telegram.*` (строка или вложенный массив) либо undefined. */
213
+ const lookup = (key, locale) => {
214
+ if (locale !== TEXTS_LOCALE && fallbackLocale() !== TEXTS_LOCALE) {
215
+ return undefined;
216
+ }
217
+ const segments = String(key).split('.');
218
+ if (segments.shift() !== 'telegram') {
219
+ return undefined;
220
+ }
221
+ let current = TEXTS;
222
+ for (const segment of segments) {
223
+ if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) {
224
+ return undefined;
225
+ }
226
+ current = current[segment];
227
+ }
228
+ return current;
229
+ };
230
+
231
+ /** Lang::has($key). */
232
+ const has = (key, locale) => lookup(key, locale) !== undefined;
233
+
234
+ /** trans($key, $replace): нет ключа — сам ключ; массив (группа ключей) — null. */
235
+ const trans = (key, replace, locale) => {
236
+ const line = lookup(key, locale);
237
+ if (line === undefined) {
238
+ return makeReplacements(key, replace);
239
+ }
240
+ return typeof line === 'string' ? makeReplacements(line, replace) : null;
241
+ };
242
+
243
+ module.exports = {
244
+ send,
245
+ flush,
246
+ chatIdOf,
247
+ phpString,
248
+ strtr,
249
+ makeReplacements,
250
+ azLocativeSuffix,
251
+ has,
252
+ trans,
253
+ };
@@ -0,0 +1,143 @@
1
+ const {phpString} = require('./telegram');
2
+ const {htmlspecialchars} = require('./str');
3
+
4
+ /**
5
+ * Порт cafepos-admin/resources/views/telegram/report.blade.php — отчёт кассовой смены в Telegram.
6
+ * Строка в строку, включая пробелы Blade: `@php`/`@if`/`@foreach` своей строки не дают, пустые
7
+ * строки между секциями остаются, `{{ }}` экранирует как e() (htmlspecialchars, ENT_QUOTES).
8
+ *
9
+ * `__()` отдаёт ключ как есть: JSON-переводов этих строк в lang/ нет.
10
+ */
11
+ const RULE = '━'.repeat(18);
12
+ const DASHES = '-'.repeat(30);
13
+
14
+ /** {{ $value }}: null → '', числа — как PHP приводит их к строке. */
15
+ const e = (value) => htmlspecialchars(phpString(value));
16
+
17
+ /** mb_substr($value, 0, $length) по символам (кодовым точкам). */
18
+ const mbSubstr = (value, length) => Array.from(phpString(value)).slice(0, length).join('');
19
+
20
+ /** str_pad: длина в БАЙТАХ UTF-8, как у PHP — кириллица и азербайджанские буквы «шире». */
21
+ const strPad = (value, length, left = false) => {
22
+ const text = phpString(value);
23
+ const missing = length - Buffer.byteLength(text, 'utf8');
24
+ if (missing <= 0) {
25
+ return text;
26
+ }
27
+ return left ? ' '.repeat(missing) + text : text + ' '.repeat(missing);
28
+ };
29
+
30
+ /** formatRow() из @php-блока шаблона. Колонки 3 и 4 пропускаются только при === ''. */
31
+ const formatRow = (col1, col2, col3 = '', col4 = '') => {
32
+ const nameWidth = 14;
33
+ const w = 6;
34
+ let res = strPad(mbSubstr(col1, nameWidth), nameWidth);
35
+ res += strPad(col2, w, true);
36
+ if (col3 !== '') {
37
+ res += strPad(col3, w, true);
38
+ }
39
+ if (col4 !== '') {
40
+ res += strPad(col4, w, true);
41
+ }
42
+ return res;
43
+ };
44
+
45
+ /** !empty($value) для списка разбивки: массив или объект по id (removed_products). */
46
+ const rowsOf = (value) => {
47
+ if (Array.isArray(value)) {
48
+ return value;
49
+ }
50
+ if (value && typeof value === 'object') {
51
+ return Object.values(value);
52
+ }
53
+ return [];
54
+ };
55
+
56
+ const labelValue = (item) => `${e(strPad(mbSubstr(item.label, 20), 22))} ${e(item.value)}`;
57
+
58
+ const render = (report) => {
59
+ const data = (report && report.data) || {};
60
+ const lines = [];
61
+
62
+ lines.push(`📊 <b>${e('Hesablama Tarixi')}:</b> ${e(report.date)}`);
63
+ lines.push(`📅 <b>${e('Dövr')}:</b> ${e(report.from)} - ${e(report.to)}`);
64
+ lines.push('');
65
+ // isset(): ключ есть и не null.
66
+ if (report.shift !== undefined && report.shift !== null) {
67
+ lines.push(`🕒 <b>${e('Növbə')}:</b> ${e(report.shift)}`);
68
+ lines.push(`👤 <b>${e('Kassir (Açıb)')}:</b> ${e(report.opened_by)}`);
69
+ lines.push(`👤 <b>${e('Kassir (Bağlayıb)')}:</b> ${e(report.closed_by ?? '-')}`);
70
+ }
71
+ lines.push('');
72
+
73
+ const products = rowsOf(data.products);
74
+ if (products.length) {
75
+ lines.push(RULE, `📦 <b>${e('Məhsullar')}</b>`, '<pre>');
76
+ lines.push(e(formatRow('Ad', 'Miq', 'Qiy', 'Cəm')), DASHES);
77
+ for (const product of products) {
78
+ lines.push(e(formatRow(product.label, product.quantity, product.price, product.value)));
79
+ }
80
+ lines.push('</pre>');
81
+ }
82
+ lines.push('');
83
+
84
+ const removed = rowsOf(data.removed_products);
85
+ if (removed.length) {
86
+ lines.push(RULE, `🗑 <b>${e('Silinən Məhsullar')}</b>`, '<pre>');
87
+ lines.push(e(formatRow('Ad', 'Miq', 'Cəm')), DASHES);
88
+ for (const product of removed) {
89
+ lines.push(e(formatRow(product.label, product.quantity, product.value)));
90
+ }
91
+ lines.push('</pre>');
92
+ }
93
+ lines.push('');
94
+
95
+ const tables = rowsOf(data.tables);
96
+ if (tables.length) {
97
+ lines.push(RULE, `🪑 <b>${e('Masalar')}</b>`, '<pre>');
98
+ lines.push(...tables.map(labelValue));
99
+ lines.push('</pre>');
100
+ }
101
+ lines.push('');
102
+
103
+ const employees = rowsOf(data.employees);
104
+ if (employees.length) {
105
+ lines.push(RULE, `👥 <b>${e('İşçilər')}</b>`, '<pre>');
106
+ lines.push(e(formatRow('Ad', 'Cəm', '%', 'Yekun')), DASHES);
107
+ for (const item of employees) {
108
+ lines.push(e(formatRow(item.label, item.subtotal, item.service_charge_amount, item.value)));
109
+ }
110
+ lines.push('</pre>');
111
+ }
112
+ lines.push('');
113
+
114
+ const categories = rowsOf(data.categories);
115
+ if (categories.length) {
116
+ lines.push(RULE, `📂 <b>${e('Kateqoriyalar')}</b>`, '<pre>');
117
+ lines.push(...categories.map(labelValue));
118
+ lines.push('</pre>');
119
+ }
120
+ lines.push('');
121
+
122
+ const payments = rowsOf(data.payments);
123
+ if (payments.length) {
124
+ lines.push(RULE, `💳 <b>${e('Ödənişlər')}</b>`, '<pre>');
125
+ lines.push(...payments.map(labelValue));
126
+ lines.push('</pre>');
127
+ }
128
+ lines.push('');
129
+
130
+ const footer = report.footer;
131
+ if (footer && typeof footer === 'object' && Object.keys(footer).length) {
132
+ lines.push(RULE, `💰 <b>${e('YEKUN')}</b>`);
133
+ lines.push(`<b>${e('Sifarişlərin toplamı')}:</b> <code>${e(footer.subtotal)}</code>`);
134
+ lines.push(`<b>${e('Servis haqqı')}:</b> <code>${e(footer.service_charge_amount)}</code>`);
135
+ lines.push(`<b>${e('Endirim')}:</b> <code>${e(footer.discount_amount)}</code>`);
136
+ lines.push(`<b>${e('Nəticə')}:</b> <u><b>${e(footer.total)}</b></u>`);
137
+ }
138
+
139
+ // Последняя строка шаблона (`@endif` без перевода строки в конце файла) ничего не добавляет.
140
+ return lines.join('\n') + '\n';
141
+ };
142
+
143
+ module.exports = {render, formatRow};
@@ -0,0 +1,58 @@
1
+ const moment = require('moment');
2
+
3
+ const SQL_FORMAT = 'YYYY-MM-DD HH:mm:ss';
4
+
5
+ /**
6
+ * Часы ядра вынесены в отдельный модуль (OFFLINE-FIRST-PLAN §5.7): офлайн-касса и облако
7
+ * неизбежно разойдутся по времени, и подменить источник времени должно быть можно в одном месте.
8
+ */
9
+ let clock = () => new Date();
10
+
11
+ const setClock = (fn) => {
12
+ clock = fn;
13
+ };
14
+
15
+ const now = () => clock();
16
+
17
+ // Всё время на проводе и в БД — UTC в формате 'Y-m-d H:i:s': именно так его сериализует
18
+ // Laravel и именно так его разбирает терминал (`moment.utc(order.opened_at, ...)`).
19
+ const nowSql = () => moment.utc(now()).format(SQL_FORMAT);
20
+
21
+ /** Carbon::createFromTimestamp — терминал шлёт секунды, но миллисекунды тоже переживаем. */
22
+ const fromTimestamp = (timestamp) => {
23
+ const value = Number(timestamp);
24
+ if (!Number.isFinite(value) || value <= 0) {
25
+ return now();
26
+ }
27
+ return new Date(value > 1e12 ? value : value * 1000);
28
+ };
29
+
30
+ const parse = (value) => {
31
+ if (!value) {
32
+ return null;
33
+ }
34
+ if (value instanceof Date) {
35
+ return value;
36
+ }
37
+ const parsed = moment.utc(value, [SQL_FORMAT, moment.ISO_8601], true);
38
+ return parsed.isValid() ? parsed.toDate() : new Date(value);
39
+ };
40
+
41
+ const toSql = (value) => {
42
+ if (!value) {
43
+ return null;
44
+ }
45
+ const date = parse(value);
46
+ return date ? moment.utc(date).format(SQL_FORMAT) : null;
47
+ };
48
+
49
+ const diffInSeconds = (from, to) => {
50
+ const a = parse(from);
51
+ const b = parse(to);
52
+ if (!a || !b) {
53
+ return 0;
54
+ }
55
+ return Math.floor((b.getTime() - a.getTime()) / 1000);
56
+ };
57
+
58
+ module.exports = {SQL_FORMAT, now, nowSql, fromTimestamp, parse, toSql, diffInSeconds, setClock};
@@ -0,0 +1,80 @@
1
+ const DEFAULT_TIMEZONE = 'Asia/Baku';
2
+
3
+ const formatterCache = new Map();
4
+
5
+ const formatterFor = (timeZone) => {
6
+ if (!formatterCache.has(timeZone)) {
7
+ formatterCache.set(timeZone, new Intl.DateTimeFormat('en-US', {
8
+ timeZone,
9
+ hour12: false,
10
+ year: 'numeric',
11
+ month: '2-digit',
12
+ day: '2-digit',
13
+ hour: '2-digit',
14
+ minute: '2-digit',
15
+ second: '2-digit',
16
+ }));
17
+ }
18
+ return formatterCache.get(timeZone);
19
+ };
20
+
21
+ /** Календарные части момента в заданной зоне. */
22
+ const zonedParts = (date, timeZone = DEFAULT_TIMEZONE) => {
23
+ const parts = {};
24
+ for (const part of formatterFor(timeZone).formatToParts(date)) {
25
+ if (part.type !== 'literal') {
26
+ parts[part.type] = Number(part.value);
27
+ }
28
+ }
29
+ // В en-US полночь приходит как 24 — приводим к 0, иначе сутки уезжают на день вперёд.
30
+ if (parts.hour === 24) {
31
+ parts.hour = 0;
32
+ }
33
+ return parts;
34
+ };
35
+
36
+ const offsetOf = (date, timeZone) => {
37
+ const parts = zonedParts(date, timeZone);
38
+ const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
39
+ return asUtc - date.getTime() + (date.getTime() % 1000);
40
+ };
41
+
42
+ /** Обратное преобразование: стенные часы зоны → момент времени (UTC). */
43
+ const zonedTimeToUtc = ({year, month, day, hour = 0, minute = 0, second = 0}, timeZone = DEFAULT_TIMEZONE) => {
44
+ const guess = Date.UTC(year, month - 1, day, hour, minute, second);
45
+ let offset = offsetOf(new Date(guess), timeZone);
46
+ // Второй проход — на случай перевода часов внутри интервала.
47
+ offset = offsetOf(new Date(guess - offset), timeZone);
48
+ return new Date(guess - offset);
49
+ };
50
+
51
+ /**
52
+ * Границы бизнес-дня — порт Team::calculateDate.
53
+ * shift_ends_at сдвигает сутки: заведение, закрывающееся в 4 утра, до 04:00 всё ещё
54
+ * работает во «вчерашнем» дне, иначе ночная смена ломала бы нумерацию чеков и отчёты.
55
+ */
56
+ const businessDay = (date, {timezone = DEFAULT_TIMEZONE, shiftEndsAt = 0, now = new Date()} = {}) => {
57
+ const zone = timezone || DEFAULT_TIMEZONE;
58
+ const shift = Number(shiftEndsAt) || 0;
59
+
60
+ const parts = zonedParts(date, zone);
61
+ const nowParts = zonedParts(now, zone);
62
+
63
+ const isToday = parts.year === nowParts.year && parts.month === nowParts.month && parts.day === nowParts.day;
64
+ let {year, month, day} = parts;
65
+ if (isToday && shift > 0 && nowParts.hour < shift) {
66
+ const previous = new Date(Date.UTC(year, month - 1, day));
67
+ previous.setUTCDate(previous.getUTCDate() - 1);
68
+ year = previous.getUTCFullYear();
69
+ month = previous.getUTCMonth() + 1;
70
+ day = previous.getUTCDate();
71
+ }
72
+
73
+ const start = zonedTimeToUtc({year, month, day, hour: shift}, zone);
74
+ const end = zonedTimeToUtc({year, month, day, hour: 23, minute: 59, second: 59}, zone);
75
+ end.setTime(end.getTime() + shift * 3600 * 1000);
76
+
77
+ return [start, end];
78
+ };
79
+
80
+ module.exports = {DEFAULT_TIMEZONE, zonedParts, zonedTimeToUtc, businessDay};