@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,324 @@
1
+ const {HttpError} = require('../errors');
2
+ const {uuid} = require('../support/ids');
3
+ const {nowSql} = require('../support/time');
4
+ const {toBool} = require('../support/num');
5
+ const {castRow} = require('../support/db');
6
+ const {phpTruthy} = require('../support/receipts');
7
+
8
+ /**
9
+ * Порт App\Http\Controllers\Api\Platform\PrinterController: настройка принтеров терминала
10
+ * (update / list / delete / get / syncAddress). Печать — отдельно, в PrintService.
11
+ *
12
+ * Без облака касса должна уметь назначить принтер цеху, сменить принтер счёта или подхватить
13
+ * новый IP принтера: иначе замена принтера посреди смены останавливает кухню до возвращения
14
+ * интернета. Локальные правки помечаются грязными триггерами 010 и уезжают в облако push'ем
15
+ * (sync/push.js), облачные приходят снапшотом.
16
+ *
17
+ * Семантика запросов — как у Eloquent:
18
+ * - Printer::query() не видит soft-deleted — у printers deleted_at есть в обеих схемах;
19
+ * - у product_workshops и terminals в локальном снапшоте deleted_at нет (удалённое туда не
20
+ * приходит), поэтому условие на них — только под Postgres;
21
+ * - where([... => null]) — это IS NULL;
22
+ * - Builder::update() и SoftDeletes ставят updated_at.
23
+ */
24
+
25
+ // FILTER_VALIDATE_IP + FILTER_FLAG_IPV4: четыре октета 0..255 без ведущих нулей.
26
+ const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
27
+ const IPV4 = new RegExp(`^${OCTET}(?:\\.${OCTET}){3}$`);
28
+
29
+ // Printer::normalizeMac: разделитель один на весь адрес, октет из 1–2 знаков.
30
+ const MAC = /^\s*([0-9a-f]{1,2})([:-])([0-9a-f]{1,2})(?:\2([0-9a-f]{1,2})){4}\s*$/i;
31
+
32
+ const normalizeMac = (value) => {
33
+ if (typeof value !== 'string' || !MAC.test(value)) {
34
+ return null;
35
+ }
36
+ const mac = value.trim().split(/[:-]/).map((octet) => octet.toLowerCase().padStart(2, '0')).join(':');
37
+ return mac === '00:00:00:00:00:00' || mac === 'ff:ff:ff:ff:ff:ff' ? null : mac;
38
+ };
39
+
40
+ // $request->boolean(): FILTER_VALIDATE_BOOLEAN — true только для 1/'1'/'true'/'on'/'yes'.
41
+ const requestBoolean = (value) => {
42
+ if (typeof value === 'boolean') {
43
+ return value;
44
+ }
45
+ if (typeof value === 'number') {
46
+ return value === 1;
47
+ }
48
+ if (typeof value === 'string') {
49
+ return ['1', 'true', 'on', 'yes'].includes(value.trim().toLowerCase());
50
+ }
51
+ return false;
52
+ };
53
+
54
+ // Идентификаторы USB приходят из скана числами, а лежат строками. Postgres не сравнит varchar с
55
+ // integer-параметром, поэтому в запрос уходит строка.
56
+ const asText = (value) => (value === null || value === undefined ? null : String(value));
57
+
58
+ // where(column, value) у Eloquent: null → IS NULL, иначе обычное равенство — с индексом в Postgres
59
+ // (IS NOT DISTINCT FROM индекс не использует).
60
+ const eq = (column, value) => (value === null || value === undefined
61
+ ? {sql: ` AND ${column} IS NULL`, params: []}
62
+ : {sql: ` AND ${column} = ?`, params: [value]});
63
+
64
+ const where = (...parts) => ({
65
+ sql: parts.map((part) => part.sql).join(''),
66
+ params: parts.flatMap((part) => part.params),
67
+ });
68
+
69
+ // Сравнение значения из запроса с колонкой «по смыслу» — как isDirty у Eloquent для флагов
70
+ // (true и 1 — одно и то же, иначе каждое сохранение без изменений писало бы строку и гнало push).
71
+ const sameValue = (column, current, next) => {
72
+ if (['bill', 'bell', 'cashdraw'].includes(column)) {
73
+ return current !== null && current !== undefined && next !== null && next !== undefined
74
+ ? toBool(current) === toBool(next)
75
+ : current === next;
76
+ }
77
+ return (current ?? null) === (next ?? null);
78
+ };
79
+
80
+ class PrinterAdminService {
81
+ constructor({db, context}) {
82
+ this.db = db;
83
+ this.context = context;
84
+ }
85
+
86
+ notDeleted(alias = null) {
87
+ return this.db.dialect === 'pgsql' ? ` AND ${alias ? `${alias}.` : ''}deleted_at IS NULL` : '';
88
+ }
89
+
90
+ /** DB::table('product_workshops')->where(team_id, deleted_at = null)->get(). */
91
+ async workshops() {
92
+ const rows = await this.db.all(
93
+ `SELECT * FROM product_workshops WHERE team_id = ?${this.notDeleted()}`,
94
+ [this.context.teamId]
95
+ );
96
+ return rows.map((row) => {
97
+ const {synced_at: ignored, ...rest} = row;
98
+ return rest;
99
+ });
100
+ }
101
+
102
+ /** pluck('print_ticket', 'product_workshop_id'): объект, пустой → [] (пустая коллекция в JSON). */
103
+ async workshopSettings(printerId, db = this.db) {
104
+ const rows = await db.all(
105
+ 'SELECT product_workshop_id, print_ticket FROM printer_workshop WHERE printer_id = ?',
106
+ [printerId]
107
+ );
108
+ if (!rows.length) {
109
+ return [];
110
+ }
111
+ const settings = {};
112
+ for (const row of rows) {
113
+ settings[row.product_workshop_id] = toBool(row.print_ticket);
114
+ }
115
+ return settings;
116
+ }
117
+
118
+ /**
119
+ * Условие поиска сетевого принтера: с MAC — строка с тем же MAC или строка без MAC на этом
120
+ * IP (записанная до привязки), MAC-строка первой; без MAC — по IP.
121
+ */
122
+ lanMatch(ip, mac) {
123
+ if (!mac) {
124
+ return {...eq('ip_address', asText(ip)), order: ''};
125
+ }
126
+ const byIp = eq('ip_address', asText(ip));
127
+ return {
128
+ sql: ` AND (mac_address = ? OR (mac_address IS NULL${byIp.sql}))`,
129
+ params: [mac, ...byIp.params],
130
+ order: ' ORDER BY (mac_address IS NULL)',
131
+ };
132
+ }
133
+
134
+ async update(params) {
135
+ const terminalId = this.context.terminalId;
136
+ const type = params.type ?? null;
137
+ const productId = params.product_id ?? null;
138
+ const vendorId = params.vendor_id ?? null;
139
+ const ip = params.ip_address ?? null;
140
+ const mac = type === 'LAN' ? normalizeMac(params.mac_address) : null;
141
+ const cashdraw = requestBoolean(params.cashdraw);
142
+
143
+ return this.db.transaction(async (db) => {
144
+ let filter = eq('terminal_id', terminalId);
145
+ let order = '';
146
+ if (type === 'USB') {
147
+ filter = where(filter, eq('product_id', asText(productId)), eq('vendor_id', asText(vendorId)));
148
+ } else if (type === 'LAN') {
149
+ const match = this.lanMatch(ip, mac);
150
+ filter = where(filter, match);
151
+ order = match.order;
152
+ }
153
+ const existing = await db.get(
154
+ `SELECT * FROM printers WHERE deleted_at IS NULL${filter.sql}${order} LIMIT 1`,
155
+ filter.params
156
+ );
157
+
158
+ const changes = {
159
+ name: params.name ?? null,
160
+ // Laravel пишет bill/bell как пришли. null в NOT NULL-колонку уронил бы запрос,
161
+ // поэтому непереданный флаг оставляем прежним (у новой строки — DEFAULT).
162
+ ...(params.bill === undefined || params.bill === null ? {} : {bill: params.bill}),
163
+ ...(params.bell === undefined || params.bell === null ? {} : {bell: params.bell}),
164
+ cashdraw,
165
+ ...(mac ? {ip_address: asText(ip), mac_address: mac} : {}),
166
+ };
167
+
168
+ const now = nowSql();
169
+ let printerId;
170
+ if (existing) {
171
+ printerId = existing.id;
172
+ const dirty = {};
173
+ for (const [column, value] of Object.entries(changes)) {
174
+ if (!sameValue(column, existing[column], value)) {
175
+ dirty[column] = value;
176
+ }
177
+ }
178
+ if (Object.keys(dirty).length) {
179
+ await db.update('printers', printerId, castRow({...dirty, updated_at: now}));
180
+ }
181
+ } else {
182
+ printerId = uuid();
183
+ await db.insert('printers', castRow({
184
+ id: printerId,
185
+ ip_address: asText(ip),
186
+ mac_address: mac,
187
+ type,
188
+ vendor_id: asText(vendorId),
189
+ product_id: asText(productId),
190
+ terminal_id: terminalId,
191
+ team_id: this.context.teamId,
192
+ company_id: this.context.companyId,
193
+ ...changes,
194
+ created_at: now,
195
+ updated_at: now,
196
+ }));
197
+ }
198
+
199
+ // BelongsToMany::sync со списком строк: ключи списка — 0..n, поэтому текущие связи
200
+ // уходят все, а строки вставляются заново. То есть полная замена цехов принтера.
201
+ const settings = params.workshop_settings;
202
+ if (phpTruthy(settings) && typeof settings === 'object') {
203
+ await db.run('DELETE FROM printer_workshop WHERE printer_id = ?', [printerId]);
204
+ for (const [workshopId, printTicket] of Object.entries(settings)) {
205
+ await db.run(
206
+ 'INSERT INTO printer_workshop (printer_id, product_workshop_id, print_ticket) VALUES (?, ?, ?)',
207
+ [printerId, workshopId, castRow({print_ticket: printTicket}).print_ticket]
208
+ );
209
+ }
210
+ }
211
+ return true;
212
+ });
213
+ }
214
+
215
+ async list() {
216
+ const workshops = await this.workshops();
217
+ const filter = eq('terminal_id', this.context.terminalId);
218
+ const rows = await this.db.all(`SELECT * FROM printers WHERE deleted_at IS NULL${filter.sql}`, filter.params);
219
+ const printers = [];
220
+ for (const printer of rows) {
221
+ printers.push({
222
+ id: printer.id,
223
+ type: printer.type,
224
+ ip_address: printer.ip_address,
225
+ mac_address: printer.mac_address ?? null,
226
+ vendor_id: printer.vendor_id,
227
+ product_id: printer.product_id,
228
+ name: printer.name,
229
+ bill: toBool(printer.bill),
230
+ bell: toBool(printer.bell),
231
+ cashdraw: toBool(printer.cashdraw),
232
+ workshop_settings: await this.workshopSettings(printer.id),
233
+ });
234
+ }
235
+ return {workshops, printers};
236
+ }
237
+
238
+ async delete(params) {
239
+ return this.db.transaction(async (db) => {
240
+ const filter = where(eq('terminal_id', this.context.terminalId), eq('id', asText(params.id)));
241
+ const printer = await db.get(`SELECT id FROM printers WHERE deleted_at IS NULL${filter.sql} LIMIT 1`, filter.params);
242
+ if (printer) {
243
+ await db.run('DELETE FROM printer_workshop WHERE printer_id = ?', [printer.id]);
244
+ const now = nowSql();
245
+ await db.update('printers', printer.id, {deleted_at: now, updated_at: now});
246
+ }
247
+ return true;
248
+ });
249
+ }
250
+
251
+ async syncAddress(params) {
252
+ const mac = normalizeMac(params.mac_address);
253
+ const ip = params.ip_address;
254
+ if (!mac || typeof ip !== 'string' || !IPV4.test(ip)) {
255
+ throw new HttpError(422, {success: false, message: 'Invalid printer address'});
256
+ }
257
+ const now = nowSql();
258
+
259
+ const own = eq('terminal_id', this.context.terminalId);
260
+ const bound = await this.db.run(
261
+ `UPDATE printers SET mac_address = ?, updated_at = ?
262
+ WHERE deleted_at IS NULL${own.sql} AND type = 'LAN' AND mac_address IS NULL AND ip_address = ?`,
263
+ [mac, now, ...own.params, ip]
264
+ );
265
+
266
+ const company = eq('company_id', this.context.companyId);
267
+ const moved = await this.db.run(
268
+ `UPDATE printers SET ip_address = ?, updated_at = ?
269
+ WHERE deleted_at IS NULL
270
+ AND terminal_id IN (SELECT id FROM terminals WHERE 1 = 1${company.sql}${this.notDeleted()})
271
+ AND type = 'LAN' AND mac_address = ? AND ip_address != ?`,
272
+ [ip, now, ...company.params, mac, ip]
273
+ );
274
+
275
+ return {success: true, bound: bound?.changes ?? 0, moved: moved?.changes ?? 0};
276
+ }
277
+
278
+ /**
279
+ * Скан локального Node (params.printers) + сохранённые настройки: по каждому найденному
280
+ * принтеру — цеха, счёт, звонок и имя из БД. Не найден — все цеха выключены.
281
+ */
282
+ async get(params) {
283
+ const scanned = Array.isArray(params.printers) ? params.printers
284
+ : (params.printers && typeof params.printers === 'object' ? Object.values(params.printers) : []);
285
+ const workshops = await this.workshops();
286
+ const printers = [];
287
+ for (const item of scanned) {
288
+ const printerData = {...(item || {})};
289
+ const mac = printerData.type === 'LAN' ? normalizeMac(printerData.mac_address) : null;
290
+
291
+ const match = this.lanMatch(printerData.ip_address, mac);
292
+ const filter = where(
293
+ eq('product_id', asText(printerData.product_id)),
294
+ eq('vendor_id', asText(printerData.vendor_id)),
295
+ eq('terminal_id', this.context.terminalId),
296
+ eq('type', asText(printerData.type)),
297
+ match
298
+ );
299
+ const printer = await this.db.get(
300
+ `SELECT * FROM printers WHERE deleted_at IS NULL${filter.sql}${match.order} LIMIT 1`,
301
+ filter.params
302
+ );
303
+
304
+ const workshopSettings = {};
305
+ for (const workshop of workshops) {
306
+ workshopSettings[workshop.id] = false;
307
+ }
308
+ printerData.workshop_settings = workshops.length ? workshopSettings : [];
309
+ printerData.bill = false;
310
+ printerData.bell = false;
311
+ if (printer) {
312
+ printerData.bill = toBool(printer.bill);
313
+ printerData.bell = toBool(printer.bell);
314
+ printerData.name = printer.name;
315
+ printerData.workshop_settings = await this.workshopSettings(printer.id);
316
+ }
317
+ printers.push(printerData);
318
+ }
319
+ return {workshops, printers};
320
+ }
321
+ }
322
+
323
+ module.exports = PrinterAdminService;
324
+ module.exports.normalizeMac = normalizeMac;