@alizadefaig/pos-server 1.2.25 → 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 (116) 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 +20 -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 +35 -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 +111 -4
  115. package/utils.js +6 -0
  116. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,639 @@
1
+ const moment = require('moment');
2
+ const SnapshotRepository = require('../repositories/SnapshotRepository');
3
+ const {qty, toFloat, toBool} = require('../support/num');
4
+ const {nowSql, parse} = require('../support/time');
5
+ const {translated} = require('../support/lang');
6
+ const {uuid} = require('../support/ids');
7
+ const {castRow} = require('../support/db');
8
+ const {phpTruthy} = require('../support/receipts');
9
+ const {notFound} = require('../errors');
10
+
11
+ const STATUS_WAITING = 'waiting';
12
+ const STATUS_COOKING = 'cooking';
13
+ const STATUS_READY = 'ready';
14
+ const STATUSES = [STATUS_WAITING, STATUS_COOKING, STATUS_READY];
15
+
16
+ // Позиции этих типов в цех не уходят — как в Laravel (и как у печати).
17
+ const SKIP_TYPES = ['standalone', 'modifier'];
18
+ const TERMINAL_TYPE_KDS = 'kds';
19
+ const SENT_COLUMN = 'workshop_print_quantity';
20
+
21
+ const alive = (row) => (row && !row.deleted_at ? row : null);
22
+
23
+ // Carbon::toIso8601String() для UTC-времени из БД: '2026-09-26T10:00:00+00:00'.
24
+ const iso = (value) => {
25
+ const date = parse(value);
26
+ return date ? moment.utc(date).format('YYYY-MM-DDTHH:mm:ss+00:00') : null;
27
+ };
28
+
29
+ const normalizeStatus = (status) => (STATUSES.includes(status) ? status : STATUS_WAITING);
30
+
31
+ const isCancelled = (item) => {
32
+ const cancelled = toFloat(item.cancelled_quantity);
33
+ return cancelled > 0 && qty(toFloat(item.quantity) - cancelled) <= 0;
34
+ };
35
+
36
+ /**
37
+ * Порт App\Services\Platform\KdsService: кухонный экран и экран готовности без облака.
38
+ *
39
+ * Тикеты создаются при отправке в цех (PrintService.sendToWorkshops, до печати и независимо
40
+ * от её успеха), статусы ведёт KDS-терминал, закрытие/удаление чека гасит тикеты. Бродкасты
41
+ * (`kds:ticket`, `kds:ticketRemoved`, `readiness:update|remove`) идут через тот же эмиттер, что
42
+ * и заказы: у мастера модели M они сами уходят облачным клиентам и ведомым кассам.
43
+ *
44
+ * Отличия от Laravel:
45
+ * - без транзакции: SQLite ядра работает на одном соединении, а номер тикета защищён локом
46
+ * отправки `print:order:<id>`, внутри которого и зовётся createTicketsForSend;
47
+ * - `kds_print_quantity` пишется точечным UPDATE (в OrderRepository этой колонки нет намеренно).
48
+ */
49
+ class KdsService {
50
+ constructor({db, context, emitter = null}) {
51
+ this.db = db;
52
+ this.context = context;
53
+ this.emitter = emitter;
54
+ this.snapshot = new SnapshotRepository(db);
55
+ }
56
+
57
+ notDeleted(alias = null) {
58
+ // В локальном снапшоте удалённые справочники не лежат вовсе, и колонки deleted_at там нет.
59
+ if (this.db.dialect !== 'pgsql') {
60
+ return '';
61
+ }
62
+ return ` AND ${alias ? `${alias}.` : ''}deleted_at IS NULL`;
63
+ }
64
+
65
+ locale() {
66
+ return this.context ? this.context.locale() : 'az';
67
+ }
68
+
69
+ emit(eventName, companyId, data) {
70
+ if (this.emitter) {
71
+ this.emitter.emit(eventName, companyId, data);
72
+ }
73
+ }
74
+
75
+ // ————— создание тикетов —————
76
+
77
+ async isEnabled(order) {
78
+ const team = order.team_id ? await this.db.get('SELECT settings FROM teams WHERE id = ?', [order.team_id]) : null;
79
+ let settings = team ? team.settings : null;
80
+ if (typeof settings === 'string') {
81
+ try {
82
+ settings = JSON.parse(settings);
83
+ } catch (e) {
84
+ settings = null;
85
+ }
86
+ }
87
+ const kds = settings && typeof settings === 'object' ? settings.kds : null;
88
+ // (bool) data_get($settings, 'kds.enabled', false)
89
+ return phpTruthy(kds && typeof kds === 'object' ? kds.enabled : false);
90
+ }
91
+
92
+ /**
93
+ * Создать тикеты для новой отправки в цех(а).
94
+ *
95
+ * @param {object} order строка/модель заказа (нужны id, team_id, company_id, number)
96
+ * @param {string|null} sentColumn 'workshop_print_quantity' — отправку сделала другая сторона,
97
+ * и видно её только по счётчику печати (так Laravel разбирает выгрузку кассы)
98
+ */
99
+ async createTicketsForSend(order, sentColumn = null) {
100
+ const column = sentColumn === SENT_COLUMN ? SENT_COLUMN : null;
101
+
102
+ // Мастер-выключатель: выключен (по умолчанию) → KDS не делает ничего.
103
+ if (!(await this.isEnabled(order))) {
104
+ return;
105
+ }
106
+
107
+ // Все цеха команды без фильтра print_ticket — KDS работает и без бумажных чеков.
108
+ const workshops = await this.db.all(
109
+ `SELECT id FROM product_workshops WHERE team_id = ?${this.notDeleted()}`,
110
+ [order.team_id]
111
+ );
112
+ if (!workshops.length) {
113
+ return;
114
+ }
115
+ const allWorkshops = new Set(workshops.map((row) => row.id));
116
+
117
+ const orderProducts = column
118
+ ? await this.db.all(
119
+ `SELECT * FROM order_products
120
+ WHERE order_id = ?
121
+ AND ((deleted_at IS NULL AND workshop_print_quantity != kds_print_quantity)
122
+ OR (deleted_at IS NOT NULL AND kds_print_quantity != 0))
123
+ ORDER BY created_at, id`,
124
+ [order.id]
125
+ )
126
+ : await this.db.all(
127
+ `SELECT * FROM order_products
128
+ WHERE order_id = ? AND deleted_at IS NULL AND quantity != kds_print_quantity
129
+ ORDER BY created_at, id`,
130
+ [order.id]
131
+ );
132
+ if (!orderProducts.length) {
133
+ return;
134
+ }
135
+
136
+ // Группировка по цеху — та же, что у печати в sendToWorkshops.
137
+ const groups = new Map();
138
+ const cancellations = [];
139
+ for (const op of orderProducts) {
140
+ const sent = column ? (op.deleted_at ? 0 : toFloat(op[column])) : toFloat(op.quantity);
141
+ const delta = sent - toFloat(op.kds_print_quantity);
142
+
143
+ // Уменьшение уже уехавшего: бумага печатает «минус»-строку, KDS зачёркивает.
144
+ if (delta < 0) {
145
+ cancellations.push({op, quantity: -delta, sent});
146
+ continue;
147
+ }
148
+
149
+ const product = await this.snapshot.findProduct(op.product_id);
150
+ const skip = SKIP_TYPES.includes(op.type) || !product || toBool(product.ignore_workshop);
151
+
152
+ // Цех — через мутатор, как в Laravel: пустой берётся у категории.
153
+ let workshopId = null;
154
+ if (!skip) {
155
+ workshopId = await this.snapshot.productWorkshopId(product);
156
+ if (!workshopId && product.parent_id) {
157
+ workshopId = await this.snapshot.productWorkshopId(await this.snapshot.findProduct(product.parent_id));
158
+ }
159
+ }
160
+
161
+ // Нет цеха / не наш цех / нечего слать → только выровнять счётчик.
162
+ if (skip || !workshopId || !allWorkshops.has(workshopId) || delta <= 0) {
163
+ await this.bump(op, sent);
164
+ continue;
165
+ }
166
+
167
+ if (!groups.has(workshopId)) {
168
+ groups.set(workshopId, []);
169
+ }
170
+ groups.get(workshopId).push({op, product, delta, sent});
171
+ }
172
+
173
+ // Отмены — в уже существующих тикетах, до создания новых.
174
+ const cancelledIds = await this.applyCancellations(cancellations);
175
+
176
+ if (!groups.size) {
177
+ await this.emitTickets(order, cancelledIds);
178
+ return;
179
+ }
180
+
181
+ // Цех без KDS-терминала → тикет сразу «готов» (tracked=false): готовность чека он не держит.
182
+ const kdsWorkshops = await this.kdsWorkshopIds(order.company_id);
183
+
184
+ // Номера не переиспользуются: удалённые тикеты тоже в счёт.
185
+ let number = Number(await this.db.value(
186
+ 'SELECT MAX(number) AS n FROM kds_tickets WHERE order_id = ?',
187
+ [order.id]
188
+ )) || 0;
189
+
190
+ const createdIds = [];
191
+ const timestamp = nowSql();
192
+ for (const [workshopId, rows] of groups) {
193
+ number += 1;
194
+ const tracked = kdsWorkshops.has(workshopId);
195
+ const status = tracked ? STATUS_WAITING : STATUS_READY;
196
+ const readyAt = tracked ? null : timestamp;
197
+
198
+ const ticketId = uuid();
199
+ await this.db.insert('kds_tickets', castRow({
200
+ id: ticketId,
201
+ company_id: order.company_id,
202
+ team_id: order.team_id,
203
+ order_id: order.id,
204
+ product_workshop_id: workshopId,
205
+ number,
206
+ status,
207
+ tracked,
208
+ fired_at: timestamp,
209
+ ready_at: readyAt,
210
+ created_at: timestamp,
211
+ updated_at: timestamp,
212
+ }));
213
+
214
+ for (const {op, product, delta, sent} of rows) {
215
+ await this.db.insert('kds_ticket_items', {
216
+ id: uuid(),
217
+ kds_ticket_id: ticketId,
218
+ order_product_id: op.id,
219
+ product_id: op.product_id,
220
+ name: (product ? translated(product.full_name, this.locale()) : null) ?? '',
221
+ quantity: qty(delta),
222
+ cancelled_quantity: 0,
223
+ comment: op.comment ?? null,
224
+ status,
225
+ ready_at: readyAt,
226
+ created_at: timestamp,
227
+ updated_at: timestamp,
228
+ });
229
+ await this.bump(op, sent);
230
+ }
231
+ createdIds.push(ticketId);
232
+ }
233
+
234
+ await this.emitTickets(order, [...cancelledIds, ...createdIds]);
235
+ }
236
+
237
+ /**
238
+ * Уменьшение уже отправленного: зачёркиваем в тикетах, начиная с самого нового.
239
+ * @returns {Promise<string[]>} id затронутых тикетов
240
+ */
241
+ async applyCancellations(cancellations) {
242
+ const touched = new Set();
243
+ const timestamp = nowSql();
244
+
245
+ for (const {op, quantity, sent} of cancellations) {
246
+ const items = await this.db.all(
247
+ `SELECT i.* FROM kds_ticket_items i
248
+ JOIN kds_tickets t ON t.id = i.kds_ticket_id
249
+ WHERE i.order_product_id = ? AND t.deleted_at IS NULL
250
+ ORDER BY t.number DESC, i.created_at DESC, i.id DESC`,
251
+ [op.id]
252
+ );
253
+
254
+ let left = qty(quantity);
255
+ for (const item of items) {
256
+ const available = qty(toFloat(item.quantity) - toFloat(item.cancelled_quantity));
257
+ if (available <= 0) {
258
+ continue;
259
+ }
260
+ const take = Math.min(available, left);
261
+ await this.db.update('kds_ticket_items', item.id, {
262
+ cancelled_quantity: qty(toFloat(item.cancelled_quantity) + take),
263
+ cancelled_at: timestamp,
264
+ updated_at: timestamp,
265
+ });
266
+ touched.add(item.kds_ticket_id);
267
+ left = qty(left - take);
268
+ if (left <= 0) {
269
+ break;
270
+ }
271
+ }
272
+
273
+ await this.bump(op, sent);
274
+ }
275
+
276
+ // Статус тикета пересчитывается по живым блюдам. Полностью отменённый тикет не трогаем:
277
+ // countedTickets выбросит его сам.
278
+ for (const ticketId of touched) {
279
+ const ticket = await this.db.get('SELECT * FROM kds_tickets WHERE id = ?', [ticketId]);
280
+ if (!ticket) {
281
+ continue;
282
+ }
283
+ const items = await this.itemsOf(ticketId);
284
+ if (items.length && items.every(isCancelled)) {
285
+ continue;
286
+ }
287
+ const status = KdsService.rollupStatus(items);
288
+ await this.db.update('kds_tickets', ticketId, {
289
+ status,
290
+ ready_at: status === STATUS_READY ? (ticket.ready_at ?? timestamp) : null,
291
+ updated_at: timestamp,
292
+ });
293
+ }
294
+
295
+ return [...touched];
296
+ }
297
+
298
+ async kdsWorkshopIds(companyId) {
299
+ const rows = await this.db.all(
300
+ `SELECT tw.product_workshop_id
301
+ FROM terminal_workshop tw
302
+ JOIN terminals t ON t.id = tw.terminal_id
303
+ WHERE t.type = ? AND t.company_id = ?${this.notDeleted('t')}`,
304
+ [TERMINAL_TYPE_KDS, companyId]
305
+ );
306
+ return new Set(rows.map((row) => row.product_workshop_id));
307
+ }
308
+
309
+ /** Точечный UPDATE: saveProduct эту колонку не пишет, и устаревшая модель её не откатит. */
310
+ async bump(op, sent) {
311
+ await this.db.run(
312
+ 'UPDATE order_products SET kds_print_quantity = ?, updated_at = ? WHERE id = ?',
313
+ [qty(sent), nowSql(), op.id]
314
+ );
315
+ }
316
+
317
+ // ————— статусы с KDS —————
318
+
319
+ async setTicketStatus(ticketId, status) {
320
+ const value = normalizeStatus(status);
321
+ const ticket = ticketId
322
+ ? await this.db.get('SELECT * FROM kds_tickets WHERE id = ? AND deleted_at IS NULL', [ticketId])
323
+ : null;
324
+ if (!ticket) {
325
+ throw notFound('Ticket not found');
326
+ }
327
+
328
+ const timestamp = nowSql();
329
+ const ready = value === STATUS_READY;
330
+ for (const item of await this.itemsOf(ticket.id)) {
331
+ await this.db.update('kds_ticket_items', item.id, {
332
+ status: value,
333
+ ready_at: ready ? (item.ready_at ?? timestamp) : null,
334
+ updated_at: timestamp,
335
+ });
336
+ }
337
+ await this.db.update('kds_tickets', ticket.id, {
338
+ status: value,
339
+ ready_at: ready ? (ticket.ready_at ?? timestamp) : null,
340
+ updated_at: timestamp,
341
+ });
342
+
343
+ return this.afterStatusChange(ticket.id);
344
+ }
345
+
346
+ async setItemStatus(itemId, status) {
347
+ const value = normalizeStatus(status);
348
+ const item = itemId ? await this.db.get('SELECT * FROM kds_ticket_items WHERE id = ?', [itemId]) : null;
349
+ if (!item) {
350
+ throw notFound('Ticket item not found');
351
+ }
352
+ // В Laravel тикет удалённого (закрытого) чека тоже не находится — findOrFail по SoftDeletes.
353
+ const ticket = await this.db.get(
354
+ 'SELECT * FROM kds_tickets WHERE id = ? AND deleted_at IS NULL',
355
+ [item.kds_ticket_id]
356
+ );
357
+ if (!ticket) {
358
+ throw notFound('Ticket not found');
359
+ }
360
+
361
+ const timestamp = nowSql();
362
+ await this.db.update('kds_ticket_items', item.id, {
363
+ status: value,
364
+ ready_at: value === STATUS_READY ? timestamp : null,
365
+ updated_at: timestamp,
366
+ });
367
+
368
+ const rolled = KdsService.rollupStatus(await this.itemsOf(ticket.id));
369
+ await this.db.update('kds_tickets', ticket.id, {
370
+ status: rolled,
371
+ ready_at: rolled === STATUS_READY ? (ticket.ready_at ?? timestamp) : null,
372
+ updated_at: timestamp,
373
+ });
374
+
375
+ return this.afterStatusChange(ticket.id);
376
+ }
377
+
378
+ async afterStatusChange(ticketId) {
379
+ const ticket = await this.db.get('SELECT * FROM kds_tickets WHERE id = ?', [ticketId]);
380
+ const resource = await this.ticketResource(ticket);
381
+ this.emit('kds:ticket', ticket.company_id, resource);
382
+
383
+ const order = await this.findOrder(ticket.order_id);
384
+ if (order) {
385
+ await this.emitReadiness(order);
386
+ }
387
+ return resource;
388
+ }
389
+
390
+ // ————— чтения —————
391
+
392
+ /** Активные тикеты по цехам терминала (KDS-экран). */
393
+ async getTicketsForTerminal(terminal) {
394
+ if (!terminal) {
395
+ return [];
396
+ }
397
+ const workshops = await this.db.all(
398
+ `SELECT pw.id
399
+ FROM terminal_workshop tw
400
+ JOIN product_workshops pw ON pw.id = tw.product_workshop_id
401
+ WHERE tw.terminal_id = ?${this.notDeleted('pw')}`,
402
+ [terminal.id]
403
+ );
404
+ if (!workshops.length) {
405
+ return [];
406
+ }
407
+ const ids = workshops.map((row) => row.id);
408
+ const tickets = await this.db.all(
409
+ `SELECT * FROM kds_tickets
410
+ WHERE company_id = ? AND deleted_at IS NULL
411
+ AND product_workshop_id IN (${ids.map(() => '?').join(', ')})
412
+ ORDER BY fired_at, number`,
413
+ [terminal.company_id, ...ids]
414
+ );
415
+ return this.ticketResources(tickets);
416
+ }
417
+
418
+ /** Тикеты одного чека — касса рисует KDS-статусы в списке позиций. */
419
+ async getOrderTickets(companyId, orderId) {
420
+ if (!orderId) {
421
+ return [];
422
+ }
423
+ const tickets = await this.db.all(
424
+ `SELECT * FROM kds_tickets
425
+ WHERE company_id = ? AND order_id = ? AND deleted_at IS NULL
426
+ ORDER BY fired_at, number`,
427
+ [companyId, orderId]
428
+ );
429
+ return this.ticketResources(tickets);
430
+ }
431
+
432
+ /** Экран готовности: агрегат по открытым чекам компании. */
433
+ async getReadinessOrders(companyId) {
434
+ const tickets = await this.db.all(
435
+ `SELECT * FROM kds_tickets
436
+ WHERE company_id = ? AND deleted_at IS NULL
437
+ ORDER BY fired_at, number`,
438
+ [companyId]
439
+ );
440
+ const grouped = new Map();
441
+ for (const ticket of tickets) {
442
+ if (!grouped.has(ticket.order_id)) {
443
+ grouped.set(ticket.order_id, []);
444
+ }
445
+ grouped.get(ticket.order_id).push(ticket);
446
+ }
447
+
448
+ const result = [];
449
+ for (const [orderId, group] of grouped) {
450
+ const counted = await this.countedTickets(group);
451
+ if (!counted.length) {
452
+ continue;
453
+ }
454
+ const order = await this.findOrder(orderId);
455
+ const number = order ? order.number ?? null : null;
456
+ if (number === null) {
457
+ continue;
458
+ }
459
+ result.push({order_id: orderId, number, status: KdsService.aggregateStatus(counted)});
460
+ }
461
+ return result;
462
+ }
463
+
464
+ /** ready — все тикеты готовы, cooking — иначе, null — тикетов нет (убрать с экрана). */
465
+ async computeOrderReadiness(order) {
466
+ const tickets = await this.db.all(
467
+ 'SELECT * FROM kds_tickets WHERE order_id = ? AND deleted_at IS NULL',
468
+ [order.id]
469
+ );
470
+ const counted = await this.countedTickets(tickets);
471
+ if (!counted.length) {
472
+ return null;
473
+ }
474
+ return KdsService.aggregateStatus(counted);
475
+ }
476
+
477
+ // ————— закрытие чека —————
478
+
479
+ /** Закрытие/удаление чека: тикеты в soft delete, убрать с KDS и экрана готовности. */
480
+ async closeOrderTickets(orderId) {
481
+ const tickets = await this.db.all(
482
+ 'SELECT id, company_id FROM kds_tickets WHERE order_id = ? AND deleted_at IS NULL',
483
+ [orderId]
484
+ );
485
+ if (!tickets.length) {
486
+ return;
487
+ }
488
+ const companyId = tickets[0].company_id;
489
+ const ticketIds = tickets.map((ticket) => ticket.id);
490
+ const timestamp = nowSql();
491
+ for (const id of ticketIds) {
492
+ await this.db.update('kds_tickets', id, {deleted_at: timestamp, updated_at: timestamp});
493
+ }
494
+
495
+ this.emit('kds:ticketRemoved', companyId, {order_id: orderId, ticket_ids: ticketIds});
496
+ this.emit('readiness:remove', companyId, {order_id: orderId});
497
+ }
498
+
499
+ // ————— бродкасты и ресурсы —————
500
+
501
+ async emitTickets(order, ticketIds) {
502
+ const unique = [...new Set(ticketIds)];
503
+ if (!unique.length) {
504
+ return;
505
+ }
506
+ for (const id of unique) {
507
+ const ticket = await this.db.get('SELECT * FROM kds_tickets WHERE id = ?', [id]);
508
+ if (ticket) {
509
+ this.emit('kds:ticket', ticket.company_id, await this.ticketResource(ticket));
510
+ }
511
+ }
512
+ await this.emitReadiness(order);
513
+ }
514
+
515
+ async emitReadiness(order) {
516
+ const status = await this.computeOrderReadiness(order);
517
+ if (status === null) {
518
+ this.emit('readiness:remove', order.company_id, {order_id: order.id});
519
+ return;
520
+ }
521
+ this.emit('readiness:update', order.company_id, {order_id: order.id, number: order.number, status});
522
+ }
523
+
524
+ async findOrder(id) {
525
+ if (!id) {
526
+ return null;
527
+ }
528
+ return this.db.get(
529
+ 'SELECT id, number, team_id, company_id, hall_id, table_id, serving_id FROM orders WHERE id = ? AND deleted_at IS NULL',
530
+ [id]
531
+ );
532
+ }
533
+
534
+ async itemsOf(ticketId) {
535
+ return this.db.all(
536
+ 'SELECT * FROM kds_ticket_items WHERE kds_ticket_id = ? ORDER BY created_at, id',
537
+ [ticketId]
538
+ );
539
+ }
540
+
541
+ /** Тикеты без полностью отменённых (их блюда все зачёркнуты — готовность они не держат). */
542
+ async countedTickets(tickets) {
543
+ const result = [];
544
+ for (const ticket of tickets) {
545
+ const items = await this.itemsOf(ticket.id);
546
+ if (items.length && items.every(isCancelled)) {
547
+ continue;
548
+ }
549
+ result.push(ticket);
550
+ }
551
+ return result;
552
+ }
553
+
554
+ async ticketResources(tickets) {
555
+ const result = [];
556
+ for (const ticket of tickets) {
557
+ result.push(await this.ticketResource(ticket));
558
+ }
559
+ return result;
560
+ }
561
+
562
+ /** KdsTicketResource + KdsTicketItemResource. */
563
+ async ticketResource(ticket) {
564
+ const order = await this.findOrder(ticket.order_id);
565
+ let hall = null;
566
+ let table = null;
567
+ let waiter = null;
568
+ if (order) {
569
+ // Связи заказа в Laravel — withTrashed: удалённый зал/стол всё равно подписывает карточку.
570
+ const hallRow = await this.snapshot.findHall(order.hall_id);
571
+ const tableRow = await this.snapshot.findTable(order.table_id);
572
+ const waiterRow = await this.snapshot.findTeamUserWithUser(order.serving_id);
573
+ hall = hallRow ? hallRow.name ?? null : null;
574
+ table = tableRow ? tableRow.label ?? null : null;
575
+ waiter = waiterRow ? waiterRow.user_name ?? null : null;
576
+ }
577
+
578
+ const items = [];
579
+ for (const item of await this.itemsOf(ticket.id)) {
580
+ // items.product — без withTrashed: удалённый товар даёт null.
581
+ const product = alive(await this.snapshot.findProduct(item.product_id));
582
+ items.push({
583
+ id: item.id,
584
+ order_product_id: item.order_product_id,
585
+ product_id: item.product_id,
586
+ name: item.name,
587
+ quantity: toFloat(item.quantity),
588
+ cancelled_quantity: toFloat(item.cancelled_quantity),
589
+ is_weight_item: product ? toBool(product.is_weight_item) : false,
590
+ unit_id: product ? product.unit_id ?? null : null,
591
+ comment: item.comment ?? null,
592
+ status: item.status,
593
+ });
594
+ }
595
+
596
+ return {
597
+ id: ticket.id,
598
+ number: ticket.number === null || ticket.number === undefined ? null : Number(ticket.number),
599
+ status: ticket.status,
600
+ tracked: toBool(ticket.tracked),
601
+ order_id: ticket.order_id,
602
+ order_number: order ? order.number ?? null : null,
603
+ product_workshop_id: ticket.product_workshop_id,
604
+ hall,
605
+ table,
606
+ waiter,
607
+ fired_at: iso(ticket.fired_at),
608
+ ready_at: iso(ticket.ready_at),
609
+ items,
610
+ };
611
+ }
612
+
613
+ // ————— чистые функции статусов —————
614
+
615
+ /** Статус тикета по его блюдам (отменённые не считаются). */
616
+ static rollupStatus(items) {
617
+ const statuses = items.filter((item) => !isCancelled(item)).map((item) => item.status);
618
+ if (!statuses.length) {
619
+ return STATUS_WAITING;
620
+ }
621
+ if (statuses.every((status) => status === STATUS_READY)) {
622
+ return STATUS_READY;
623
+ }
624
+ if (statuses.some((status) => status === STATUS_COOKING || status === STATUS_READY)) {
625
+ return STATUS_COOKING;
626
+ }
627
+ return STATUS_WAITING;
628
+ }
629
+
630
+ /** Экран клиента: ready, если готовы все тикеты, иначе cooking. */
631
+ static aggregateStatus(tickets) {
632
+ return tickets.every((ticket) => ticket.status === STATUS_READY) ? STATUS_READY : STATUS_COOKING;
633
+ }
634
+ }
635
+
636
+ KdsService.STATUSES = STATUSES;
637
+ KdsService.isCancelled = isCancelled;
638
+
639
+ module.exports = KdsService;