@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,903 @@
1
+ const OrderRepository = require('../repositories/OrderRepository');
2
+ const OrderProductRepository = require('../repositories/OrderProductRepository');
3
+ const PaymentRepository = require('../repositories/PaymentRepository');
4
+ const EventRepository = require('../repositories/EventRepository');
5
+ const SnapshotRepository = require('../repositories/SnapshotRepository');
6
+ const OrderResource = require('../resources/OrderResource');
7
+ const OrderProduct = require('../models/OrderProduct');
8
+ const StorageService = require('./StorageService');
9
+ const KdsService = require('./KdsService');
10
+ const TelegramService = require('./TelegramService');
11
+ const {HttpError, notFound} = require('../errors');
12
+ const {money, qty, toFloat, toInt, toBool} = require('../support/num');
13
+ const {now, nowSql, fromTimestamp, toSql} = require('../support/time');
14
+ const {businessDay} = require('../support/tz');
15
+ const {uuid, isUuid} = require('../support/ids');
16
+ const {htmlspecialchars, md5, phpJsonEncode} = require('../support/str');
17
+ const {castRow} = require('../support/db');
18
+ const {assertOrderMutable} = require('../guard');
19
+ const {withLock} = require('../support/lock');
20
+
21
+ const TYPE_TIMER = OrderProduct.TYPE_TIMER;
22
+ const TYPE_EXTRA = OrderProduct.TYPE_EXTRA;
23
+
24
+ const DINE_IN = 'dine_in';
25
+ const DELIVERY = 'delivery';
26
+ const PIECE_CODE = 'pcs';
27
+
28
+ const numberFormat = (value, decimals = 2) => toFloat(value).toFixed(decimals);
29
+
30
+ /**
31
+ * Порт App\Services\Platform\OrderService — жизненный цикл чека от открытия до закрытия.
32
+ *
33
+ * Печать живёт в PrintService, кухонный экран — в KdsService (тикеты гасятся здесь, на закрытии
34
+ * и удалении чека). Склад (StorageService) и Telegram (TelegramService) работают только в
35
+ * облачном контуре; на кассе их ведёт облако при приёме выгруженных чеков.
36
+ *
37
+ * Склад двигается по одному правилу — по позиции списано
38
+ * StorageService.held(quantity, workshop_print_quantity): каждое изменение количества или
39
+ * напечатанного идёт через storage.settle(было, стало). Прежние ошибки учёта (блюдо списывало
40
+ * одну порцию, возврат при печати считался от напечатанного) исправлены по этому правилу
41
+ * одновременно здесь и в PHP — событие по-прежнему можно возить через CORE_EVENTS.
42
+ * То же правило у orderProducts.remove, слияния строк в setGuestNumber и removeCustomer (строки
43
+ * гостя удаляются — held возвращается целиком); updateModifiers меняет только модификаторы на
44
+ * held позиции (StorageService.swapModifiers).
45
+ */
46
+ class OrderService {
47
+ constructor({db, context, emitter = null}) {
48
+ this.db = db;
49
+ this.context = context;
50
+ this.emitter = emitter;
51
+
52
+ this.orders = new OrderRepository(db, context);
53
+ this.orderProducts = new OrderProductRepository(db);
54
+ this.payments = new PaymentRepository(db);
55
+ this.events = new EventRepository(db);
56
+ this.snapshot = new SnapshotRepository(db);
57
+ this.storage = new StorageService({db, context});
58
+ this.telegram = new TelegramService({db, context});
59
+ }
60
+
61
+ resource() {
62
+ // Новый экземпляр на каждый ответ: внутри связи товаров, которые не должны переживать
63
+ // изменение справочников между запросами.
64
+ return new OrderResource(this.snapshot, this.context.locale());
65
+ }
66
+
67
+ /** Список чеков через один ресурс: связи товаров всех чеков грузятся одним заходом. */
68
+ async mapResource(orders) {
69
+ const resource = this.resource();
70
+ await resource.preload(orders);
71
+ const result = [];
72
+ for (const order of orders) {
73
+ result.push(await resource.order(order));
74
+ }
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Ответ мутации: бродкаст-патч уже собрал ресурс — переиспользуем его вместо
80
+ * повторного чтения чека. Без эмиттера (тесты, локальный старт) собираем сами.
81
+ */
82
+ async patchOrResource(orderId, options = {}) {
83
+ const payload = await this.emitPatch(orderId);
84
+ if (payload) {
85
+ return payload;
86
+ }
87
+ return this.resource().order(await this.findOrFail(orderId, options));
88
+ }
89
+
90
+ async findOrFail(orderId, options = {}) {
91
+ const order = await this.orders.find(orderId, options);
92
+ if (!order) {
93
+ throw notFound('Order not found');
94
+ }
95
+ return order;
96
+ }
97
+
98
+ async saveEvents(events) {
99
+ await this.events.saveEvents(events, this.context);
100
+ // Только облако (TelegramService.active): на кассе уведомления шлёт облако при приёме чеков.
101
+ await this.telegram.notifyEvents(events);
102
+ }
103
+
104
+ /** Guard закрытого чека с контекстом запроса для журнала. */
105
+ assertMutable(order, action, message = null) {
106
+ assertOrderMutable(order, action, message, this.context);
107
+ }
108
+
109
+ /**
110
+ * Открытая кассовая смена — порт `ShiftService::getCashShift`. Резолв смены не имеет
111
+ * права сорвать закрытие: деньги уже приняты, привязку досоставит syncClosedOrder.
112
+ */
113
+ async resolveCashShiftId() {
114
+ if (!toBool(this.context.setting('manage.use_cash_shift', false))) {
115
+ return null;
116
+ }
117
+ try {
118
+ const shift = await this.snapshot.findOpenCashShift(this.context.teamId, this.context.companyId);
119
+ return shift ? shift.id : null;
120
+ } catch (error) {
121
+ console.warn('[order-close] shift resolve failed', error.message);
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /** Патч одного чека в сокет — аналог SocketService::emitOrderPatch. */
127
+ async emitPatch(orderId) {
128
+ if (!this.emitter) {
129
+ return null;
130
+ }
131
+ const order = await this.orders.find(orderId, {withTrashed: true});
132
+ if (!order) {
133
+ return null;
134
+ }
135
+ const payload = await this.resource().order(order);
136
+ this.emitter.orderPatch(order.company_id, payload);
137
+ return payload;
138
+ }
139
+
140
+ async emitUpdateOrders() {
141
+ if (this.emitter) {
142
+ // Ждём обязательно: updateOrders сам ходит в БД за списком открытых чеков, и
143
+ // брошенный промис = unhandled rejection (в Node 22 это падение процесса).
144
+ // В Laravel эмит тоже блокирующий (HTTP к Node), так что порядок тот же.
145
+ await this.emitter.updateOrders(this.context.companyId);
146
+ }
147
+ }
148
+
149
+ // ---------------------------------------------------------------- чек
150
+
151
+ async create({orderSourceId, tableId, guestCount = 1, hallId = null, servingId = null, id = null, slotIndex = 1}) {
152
+ const context = this.context;
153
+
154
+ let orderSource = await this.snapshot.findOrderSource(context.teamId, orderSourceId);
155
+ if (!orderSource) {
156
+ orderSource = await this.snapshot.findOrderSourceByType(context.teamId, DINE_IN);
157
+ }
158
+ if (!orderSource) {
159
+ throw notFound('Order source not found');
160
+ }
161
+
162
+ if (!servingId) {
163
+ if (orderSource.type === DINE_IN) {
164
+ servingId = context.employeeId;
165
+ }
166
+ if (orderSource.type === DELIVERY) {
167
+ const courierRole = await this.snapshot.findCourierRole(context.teamId);
168
+ const courier = courierRole ? await this.snapshot.findFirstTeamUserByRole(courierRole.id) : null;
169
+ servingId = courier ? courier.id : context.employeeId;
170
+ }
171
+ }
172
+
173
+ let serviceCharge = 0;
174
+ if (context.setting('terminal_settings.apply_service_charge_automatically', false)) {
175
+ const company = context.getCompany();
176
+ serviceCharge = company ? toFloat(company.service_charge) : 0;
177
+ }
178
+
179
+ const team = context.getTeam();
180
+ const resetNumber = toBool(context.setting('receipts.reset_number', false));
181
+ const table = tableId ? await this.snapshot.findTable(tableId) : null;
182
+ const orderId = id || uuid();
183
+
184
+ // Номер и вставка — под одним замком компании: иначе два одновременных create прочли бы
185
+ // один и тот же MAX(number).
186
+ await withLock(`order-number:${context.companyId}`, async () => {
187
+ let numbers;
188
+ if (resetNumber && team) {
189
+ const [dayFrom, dayTo] = businessDay(now(), {
190
+ timezone: team.timezone,
191
+ shiftEndsAt: team.shift_ends_at,
192
+ now: now(),
193
+ });
194
+ numbers = await this.orders.nextNumbers(context.companyId, {
195
+ resetNumber: true,
196
+ dayFrom: toSql(dayFrom),
197
+ dayTo: toSql(dayTo),
198
+ });
199
+ } else {
200
+ numbers = await this.orders.nextNumbers(context.companyId);
201
+ }
202
+
203
+ await this.orders.insert({
204
+ id: orderId,
205
+ team_id: context.teamId,
206
+ company_id: context.companyId,
207
+ terminal_id: context.terminalId,
208
+ order_source_id: orderSource.id,
209
+ opened_by_id: context.employeeId,
210
+ serving_id: servingId,
211
+ table_id: tableId,
212
+ customers: [1],
213
+ slot_index: toInt(slotIndex, 1),
214
+ service_charge: serviceCharge,
215
+ guest_count: Math.max(toInt(guestCount, 1), 1),
216
+ hall_id: hallId || (table ? table.hall_id : null),
217
+ opened_at: fromTimestamp(context.timestamp),
218
+ global_number: numbers.globalNumber,
219
+ number: numbers.number,
220
+ status: 'created',
221
+ });
222
+ });
223
+
224
+ await this.saveEvents([{
225
+ event: 'orders:create',
226
+ time: nowSql(),
227
+ order_id: orderId,
228
+ }]);
229
+
230
+ return await this.patchOrResource(orderId);
231
+ }
232
+
233
+ async addProduct({productId, orderId, guestNumber = 1, quantity = 1, modifierSets = []}) {
234
+ const context = this.context;
235
+ const order = await this.findOrFail(orderId);
236
+ this.assertMutable(order, 'orders.addProduct');
237
+ const product = await this.snapshot.findProduct(productId);
238
+ if (!product) {
239
+ throw notFound('Product not found');
240
+ }
241
+
242
+ const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifierSets);
243
+
244
+ // Повременная позиция уникальна по времени старта — её нельзя доливать в существующую.
245
+ const hasTimerModifier = await this.snapshot.hasTimerModifier(product.id);
246
+ const appendMode = toBool(context.setting('terminal_settings.append_order_product', false));
247
+
248
+ const existing = hasTimerModifier ? null : await this.orderProducts.findMergeCandidate({
249
+ orderId: order.id,
250
+ productId: product.id,
251
+ guestNumber,
252
+ hash,
253
+ appendMode,
254
+ });
255
+
256
+ let orderProductId;
257
+ if (!existing) {
258
+ orderProductId = await this.orderProducts.insert({
259
+ order_id: order.id,
260
+ product_id: product.id,
261
+ guest_number: guestNumber,
262
+ price: money(product.price),
263
+ added_at: fromTimestamp(context.timestamp),
264
+ team_id: context.teamId,
265
+ company_id: context.companyId,
266
+ type: product.type,
267
+ unit_id: product.unit_id,
268
+ quantity: qty(quantity),
269
+ cost: money(product.cost),
270
+ profit: 0,
271
+ modifications_sets_hash: hash,
272
+ workshop_print_quantity: product.type === TYPE_TIMER ? qty(quantity) : 0,
273
+ });
274
+
275
+ await this.orderProducts.insertModifierSets(orderProductId, sets);
276
+ await this.storage.withdraw(orderProductId, quantity);
277
+
278
+ await this.saveEvents([{
279
+ event: 'orders:add_product',
280
+ time: nowSql(),
281
+ order_id: order.id,
282
+ quantity,
283
+ product_id: product.id,
284
+ }]);
285
+ } else {
286
+ await this.saveEvents([{
287
+ event: 'orders:add_product',
288
+ time: nowSql(),
289
+ order_id: order.id,
290
+ product_id: product.id,
291
+ quantity,
292
+ }]);
293
+
294
+ orderProductId = existing.id;
295
+ const newQuantity = qty(existing.quantity + toFloat(quantity));
296
+ await this.orderProducts.update(existing.id, {
297
+ quantity: newQuantity,
298
+ modified_at: nowSql(),
299
+ });
300
+ // Довес в строку, с которой уже сняли напечатанное: held не растёт, пока новое
301
+ // количество не превысит напечатанное, — склад по ней уже списан.
302
+ await this.storage.settle(
303
+ existing,
304
+ StorageService.held(existing.quantity, existing.workshop_print_quantity),
305
+ StorageService.held(newQuantity, existing.workshop_print_quantity),
306
+ 'rollback'
307
+ );
308
+ }
309
+
310
+ await this.recalculate(order.id, {full: true});
311
+ await this.emitPatch(order.id);
312
+
313
+ // Laravel возвращает саму модель позиции (сырые атрибуты), а не ресурс — повторяем,
314
+ // хотя терминал этот ответ и не использует: состояние он берёт из orders:patch.
315
+ return await this.orderProducts.findRow(orderProductId);
316
+ }
317
+
318
+ async addExtra({name, productWorkshopId, price = 1, orderId, quantity = 1, guestNumber = 1}) {
319
+ const context = this.context;
320
+ // Проверяем ДО создания товара-экстры: иначе на закрытом чеке оставался бы мусор в справочнике.
321
+ this.assertMutable(await this.orders.findRow(orderId), 'orders.addExtra');
322
+ let product = isUuid(name) ? await this.snapshot.findProduct(name) : null;
323
+
324
+ if (!product) {
325
+ const unit = await this.snapshot.findUnitByCode(PIECE_CODE);
326
+ const productId = uuid();
327
+ // Переводимое поле: Laravel кладёт строку под текущую локаль команды.
328
+ const translatable = phpJsonEncode({[context.locale()]: name});
329
+
330
+ await this.db.insert('products', castRow({
331
+ id: productId,
332
+ team_id: context.teamId,
333
+ name: translatable,
334
+ full_name: translatable,
335
+ product_workshop_id: productWorkshopId,
336
+ unit_id: unit ? unit.id : null,
337
+ price: money(price),
338
+ cost: money(price),
339
+ type: TYPE_EXTRA,
340
+ is_giftable: 1,
341
+ is_active: 1,
342
+ is_available: 1,
343
+ // synced_at есть только в локальном снапшоте: товар создан кассой и ещё не
344
+ // подтверждён облаком. В общей с Laravel базе такой колонки нет.
345
+ ...(this.db.dialect === 'sqlite' ? {synced_at: null} : {}),
346
+ }));
347
+ product = await this.snapshot.findProduct(productId);
348
+
349
+ await this.saveEvents([{
350
+ event: 'products:create_extra',
351
+ time: nowSql(),
352
+ order_id: orderId,
353
+ product_id: productId,
354
+ quantity,
355
+ }]);
356
+ }
357
+
358
+ return this.addProduct({productId: product.id, orderId, guestNumber, quantity, modifierSets: []});
359
+ }
360
+
361
+ /** Пересчёт и сохранение — калька на `$order->calculate(...)->save()`. */
362
+ async recalculate(orderId, {withProducts = true, full = false} = {}) {
363
+ const order = await this.findOrFail(orderId, {withTrashed: true});
364
+ order.calculate(withProducts, full);
365
+ await this.orders.save(order);
366
+ return order;
367
+ }
368
+
369
+ async closeOrder({orderId, payments = [], reason = null, fiscalId = null, rrn = null}) {
370
+ const context = this.context;
371
+ const order = await this.findOrFail(orderId, {withTrashed: true});
372
+ // Без guard повторное закрытие переписывало платежи «поверх» новыми суммами.
373
+ this.assertMutable(order, 'orders.closeOrder', 'Çek artıq bağlanıb. Dəyişiklik üçün əvvəlcə çeki geri açın.');
374
+ const closedAt = fromTimestamp(context.timestamp);
375
+
376
+ // Недоплата — до первой записи: проверка ниже стоит после syncWorkshopCount, и 422 оставлял
377
+ // чек открытым, но с проведённой печатью и складом. Та же заморозка таймеров в памяти и та
378
+ // же формула; total от workshop_print_quantity не зависит, поэтому совпадает с проверкой ниже.
379
+ if (!reason) {
380
+ const draft = await this.findOrFail(orderId, {withTrashed: true});
381
+ for (const product of draft.products) {
382
+ if (product.type === TYPE_TIMER && !product.is_stopped) {
383
+ product.is_stopped = true;
384
+ product.stopped_at = closedAt;
385
+ }
386
+ }
387
+ draft.calculate(true, true);
388
+ const paid = payments
389
+ .filter((payment) => toFloat(payment.value) > 0)
390
+ .reduce((carry, payment) => carry + toFloat(payment.value), 0);
391
+
392
+ if (paid + 0.01 < draft.total) {
393
+ throw new HttpError(422, {
394
+ success: false,
395
+ message: `Ödəniş çek məbləğindən azdır. Ödənilib: ${numberFormat(paid)} ₼, ödəniləcək: ${numberFormat(draft.total)} ₼`,
396
+ });
397
+ }
398
+ }
399
+
400
+ order.closed_at = closedAt;
401
+ order.closed_by_id = context.employeeId;
402
+ order.fiscal_id = fiscalId;
403
+ order.rrn = rrn;
404
+ order.status = 'closing';
405
+
406
+ const productRows = await this.db.all(
407
+ 'SELECT id FROM order_products WHERE order_id = ? AND deleted_at IS NULL',
408
+ [orderId]
409
+ );
410
+ await this.syncWorkshopCount(productRows.map((row) => row.id), orderId);
411
+
412
+ // Позиции могли измениться в syncWorkshopCount — перечитываем перед заморозкой таймеров.
413
+ const fresh = await this.findOrFail(orderId, {withTrashed: true});
414
+ fresh.closed_at = closedAt;
415
+ fresh.closed_by_id = context.employeeId;
416
+ fresh.fiscal_id = fiscalId;
417
+ fresh.rrn = rrn;
418
+ fresh.status = 'closing';
419
+
420
+ // Таймеры останавливаем В ПАМЯТИ: если оплаты не хватит, ничего не сохранится
421
+ // и позиция не останется ошибочно остановленной.
422
+ for (const product of fresh.products) {
423
+ if (product.type === TYPE_TIMER && !product.is_stopped) {
424
+ product.is_stopped = true;
425
+ product.stopped_at = closedAt;
426
+ product.receipt_print_quantity = 1;
427
+ }
428
+ }
429
+
430
+ if (reason) {
431
+ fresh.is_closed_without_payment = true;
432
+ fresh.without_payment_reason = reason;
433
+ } else {
434
+ // Серверная проверка покрытия от замороженного времени: фронт мог сверять оплату
435
+ // со старым total (с пречека), а в БД лёг бы пересчитанный от живого таймера.
436
+ fresh.calculate(true, true);
437
+ const paid = payments
438
+ .filter((payment) => toFloat(payment.value) > 0)
439
+ .reduce((carry, payment) => carry + toFloat(payment.value), 0);
440
+
441
+ if (paid + 0.01 < fresh.total) {
442
+ throw new HttpError(422, {
443
+ success: false,
444
+ message: `Ödəniş çek məbləğindən azdır. Ödənilib: ${numberFormat(paid)} ₼, ödəniləcək: ${numberFormat(fresh.total)} ₼`,
445
+ });
446
+ }
447
+
448
+ await this.payments.deleteOfOrder(orderId);
449
+ for (const payment of payments) {
450
+ if (toFloat(payment.value) <= 0) {
451
+ continue;
452
+ }
453
+ await this.payments.insert({
454
+ order_id: fresh.id,
455
+ payment_method_id: payment.id,
456
+ amount: money(payment.value),
457
+ change: money(payment.change),
458
+ real_amount: money(payment.real),
459
+ });
460
+ }
461
+ }
462
+
463
+ await this.saveEvents([{
464
+ event: 'orders:close',
465
+ time: nowSql(),
466
+ order_id: fresh.id,
467
+ }]);
468
+
469
+ // Финальный статус и смена — ЗДЕСЬ, а не отдельным запросом клиента (finalizeClose):
470
+ // не доехавший syncClosedOrder оставлял чек в `closing`, вне выручки и смены.
471
+ fresh.status = 'closed';
472
+ if (!fresh.shift_id) {
473
+ fresh.shift_id = await this.resolveCashShiftId();
474
+ }
475
+
476
+ fresh.calculate(true, true);
477
+ await this.orders.save(fresh);
478
+ // KDS: чек оплачен — снять тикеты с экранов кухни и готовности.
479
+ await this.closeKdsTickets(fresh.id, 'close');
480
+
481
+ return await this.patchOrResource(fresh.id, {withTrashed: true});
482
+ }
483
+
484
+ /** Сбой KDS не должен ломать закрытие или удаление чека — как try/catch в Laravel. */
485
+ async closeKdsTickets(orderId, reason) {
486
+ try {
487
+ await new KdsService({db: this.db, context: this.context, emitter: this.emitter}).closeOrderTickets(orderId);
488
+ } catch (e) {
489
+ console.warn(`[core][kds] closeOrderTickets (${reason}) failed`, {order_id: orderId, error: e.message});
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Совместимость со старым циклом закрытия — идемпотентная доводка. shift_id пишется
495
+ * только пустой: повторный вызов с терминала без открытой смены не должен отвязать чек.
496
+ * Рассылается патч одного чека, а не полный список (снимок, доехавший позже свежего
497
+ * create, вычищал у кассиров живые чеки).
498
+ */
499
+ async syncClosedOrder(orderId) {
500
+ const order = await this.orders.findRow(orderId);
501
+ if (!order) {
502
+ return true;
503
+ }
504
+
505
+ const attributes = {};
506
+ if (order.status !== 'closed') {
507
+ attributes.status = 'closed';
508
+ }
509
+ if (!order.shift_id) {
510
+ const shiftId = await this.resolveCashShiftId();
511
+ if (shiftId) {
512
+ attributes.shift_id = shiftId;
513
+ }
514
+ }
515
+ if (Object.keys(attributes).length) {
516
+ await this.orders.updateRow(orderId, attributes);
517
+ }
518
+
519
+ await this.emitPatch(orderId);
520
+ return true;
521
+ }
522
+
523
+ /**
524
+ * Порт syncWorkshopCount: печать «догоняет» количество позиций.
525
+ * Принимает id позиций — как и в Laravel, где джоб получает id из кэша.
526
+ *
527
+ * @param {Map<string, number>|null} printedQuantities снимок, по которому собран напечатанный
528
+ * чек. Позиция ставится напечатанной ровно на это количество: добавленное после
529
+ * снимка уйдёт следующей отправкой. Нет позиции в снимке — её текущее quantity.
530
+ */
531
+ async syncWorkshopCount(orderProductIds, orderId = null, printedQuantities = null) {
532
+ for (const id of orderProductIds) {
533
+ try {
534
+ const orderProduct = await this.orderProducts.find(id);
535
+ if (!orderProduct) {
536
+ continue;
537
+ }
538
+
539
+ const quantity = printedQuantities && printedQuantities.has(id)
540
+ ? printedQuantities.get(id)
541
+ : orderProduct.quantity;
542
+ const newQuantity = Math.max(quantity - orderProduct.workshop_print_quantity, 0);
543
+ const previousPrintQuantity = orderProduct.workshop_print_quantity;
544
+ let workshopPrintQuantity = quantity;
545
+ let totalWorkshopPrintQuantity = orderProduct.total_workshop_print_quantity;
546
+ let modifiedAt = orderProduct.modified_at;
547
+
548
+ if (!orderId) {
549
+ orderId = orderProduct.order_id;
550
+ }
551
+
552
+ // Склад — по held до и после печати: ушла отмена в цех (напечатанное опустилось
553
+ // к количеству в чеке) — возврат. Повторная печать без изменений склад не двигает.
554
+ // До softDelete: движение блюда читает живые модификаторы позиции.
555
+ await this.storage.settle(
556
+ orderProduct,
557
+ StorageService.held(orderProduct.quantity, previousPrintQuantity),
558
+ StorageService.held(orderProduct.quantity, workshopPrintQuantity),
559
+ 'refund'
560
+ );
561
+ if (newQuantity > 0) {
562
+ totalWorkshopPrintQuantity = qty(totalWorkshopPrintQuantity + newQuantity);
563
+ modifiedAt = nowSql();
564
+ }
565
+
566
+ const removeQuantity = totalWorkshopPrintQuantity - workshopPrintQuantity;
567
+ if (removeQuantity > 0 && previousPrintQuantity !== quantity) {
568
+ await this.saveEvents([{
569
+ event: 'orders:remove_product',
570
+ time: nowSql(),
571
+ order_id: orderId,
572
+ product_id: orderProduct.product_id,
573
+ quantity: removeQuantity,
574
+ }]);
575
+ }
576
+
577
+ await this.orderProducts.update(orderProduct.id, {
578
+ workshop_print_quantity: workshopPrintQuantity,
579
+ total_workshop_print_quantity: totalWorkshopPrintQuantity,
580
+ modified_at: modifiedAt,
581
+ });
582
+
583
+ if (orderProduct.quantity == 0 && workshopPrintQuantity == 0) {
584
+ await this.orderProducts.softDelete(orderProduct.id);
585
+ }
586
+ } catch (error) {
587
+ // Сбой по одной позиции не должен прерывать остальные.
588
+ console.error('[core] syncWorkshopCount: item failed, skip', id, error.message);
589
+ }
590
+ }
591
+
592
+ if (orderId) {
593
+ await this.orderProducts.deleteEmpty(orderId);
594
+ }
595
+ return true;
596
+ }
597
+
598
+ // ------------------------------------------------------- атрибуты чека
599
+
600
+ async changeTable({orderId, tableId, hallId}) {
601
+ this.assertMutable(await this.findOrFail(orderId), 'orders.changeTable');
602
+ await this.orders.updateRow(orderId, {table_id: tableId, hall_id: hallId});
603
+ await this.saveEvents([{event: 'orders:change_table', time: nowSql(), order_id: orderId}]);
604
+ return await this.patchOrResource(orderId);
605
+ }
606
+
607
+ async changeWaiter({orderId, waiterId}) {
608
+ this.assertMutable(await this.findOrFail(orderId), 'orders.changeWaiter');
609
+ await this.orders.updateRow(orderId, {serving_id: waiterId});
610
+ await this.saveEvents([{event: 'orders:change_waiter', time: nowSql(), order_id: orderId}]);
611
+ return await this.patchOrResource(orderId);
612
+ }
613
+
614
+ async changeGuestCount({orderId, guestCount = 1}) {
615
+ this.assertMutable(await this.findOrFail(orderId), 'orders.changeGuestCount');
616
+ await this.orders.updateRow(orderId, {guest_count: toInt(guestCount, 1)});
617
+ await this.saveEvents([{event: 'orders:change_guest_count', time: nowSql(), order_id: orderId}]);
618
+ return await this.patchOrResource(orderId);
619
+ }
620
+
621
+ async setComment({orderId, comment = ''}) {
622
+ const order = await this.findOrFail(orderId);
623
+ this.assertMutable(order, 'orders.setComment');
624
+ const escaped = htmlspecialchars(comment);
625
+ if (escaped !== order.comment) {
626
+ await this.orders.updateRow(orderId, {comment: escaped});
627
+ }
628
+ await this.saveEvents([{event: 'orders:comment', time: nowSql(), order_id: orderId}]);
629
+ return await this.patchOrResource(orderId);
630
+ }
631
+
632
+ async applyServiceCharge({orderId, serviceCharge}) {
633
+ const order = await this.findOrFail(orderId);
634
+ this.assertMutable(order, 'orders.applyServiceCharge');
635
+ const value = toFloat(serviceCharge);
636
+ if (!Number.isFinite(value)) {
637
+ return false;
638
+ }
639
+ order.applyServiceCharge(value).calculate();
640
+ await this.orders.save(order);
641
+ await this.saveEvents([{event: 'orders:service_charge', time: nowSql(), order_id: orderId}]);
642
+ return await this.patchOrResource(orderId);
643
+ }
644
+
645
+ async applyDiscount({orderId, discount, type}) {
646
+ return type === 'percent'
647
+ ? this.applyDiscountByPercent(orderId, discount)
648
+ : this.applyDiscountByAmount(orderId, discount);
649
+ }
650
+
651
+ async applyDiscountByAmount(orderId, amount) {
652
+ const order = await this.findOrFail(orderId);
653
+ this.assertMutable(order, 'orders.applyDiscount:amount');
654
+ const value = toFloat(amount);
655
+ if (!Number.isFinite(value)) {
656
+ return false;
657
+ }
658
+ if (order.max_discount_amount < value) {
659
+ throw new HttpError(403, {success: false, error: 'max_discount_amount'});
660
+ }
661
+ order.applyAmountDiscount(value).calculate();
662
+ await this.orders.save(order);
663
+ await this.saveEvents([{event: 'orders:discount:amount', time: nowSql(), order_id: orderId}]);
664
+ return await this.patchOrResource(orderId);
665
+ }
666
+
667
+ async applyDiscountByPercent(orderId, percent) {
668
+ const order = await this.findOrFail(orderId);
669
+ this.assertMutable(order, 'orders.applyDiscount:percent');
670
+ const value = toFloat(percent);
671
+ if (!Number.isFinite(value) || value > 100) {
672
+ return false;
673
+ }
674
+ order.applyPercentDiscount(value).calculate();
675
+ await this.orders.save(order);
676
+ await this.saveEvents([{event: 'orders:discount:percent', time: nowSql(), order_id: orderId}]);
677
+ return await this.patchOrResource(orderId);
678
+ }
679
+
680
+ async applyDeposit({orderId, deposit}) {
681
+ const order = await this.findOrFail(orderId);
682
+ this.assertMutable(order, 'orders.applyDeposit');
683
+ order.deposit_amount = money(Math.max(0, toFloat(deposit)));
684
+ order.calculate();
685
+ await this.orders.save(order);
686
+ await this.saveEvents([{event: 'orders:deposit', time: nowSql(), order_id: orderId}]);
687
+ return await this.patchOrResource(orderId);
688
+ }
689
+
690
+ // ------------------------------------------------------------- гости
691
+
692
+ async addCustomer(orderId) {
693
+ const order = await this.findOrFail(orderId);
694
+ this.assertMutable(order, 'orders.addCustomer');
695
+ const customers = order.customers.slice();
696
+ customers.push(Math.max(...customers) + 1);
697
+ await this.orders.updateRow(orderId, {customers});
698
+ await this.saveEvents([{event: 'orders:add_guest', time: nowSql(), order_id: orderId}]);
699
+ return await this.patchOrResource(orderId);
700
+ }
701
+
702
+ async removeCustomer({orderId, guestNumber}) {
703
+ const order = await this.findOrFail(orderId);
704
+ this.assertMutable(order, 'orders.removeCustomer');
705
+ const customers = order.customers.filter((customer) => customer !== guestNumber);
706
+ await this.orders.updateRow(orderId, {customers});
707
+ // Склад: всё списанное по строкам гостя возвращается сразу — строки удаляются, отмену
708
+ // в цех по ним уже не отправить. До softDelete: движение блюда читает живые модификаторы.
709
+ const guestRows = await this.db.all(
710
+ `SELECT id, quantity, workshop_print_quantity FROM order_products
711
+ WHERE order_id = ? AND guest_number = ? AND deleted_at IS NULL`,
712
+ [orderId, guestNumber]
713
+ );
714
+ for (const row of guestRows) {
715
+ await this.storage.settle(row, StorageService.held(row.quantity, row.workshop_print_quantity), 0, 'rollback');
716
+ }
717
+ await this.orderProducts.removeGuestProducts(orderId, guestNumber);
718
+ await this.saveEvents([{event: 'orders:remove_guest', time: nowSql(), order_id: orderId}]);
719
+ await this.recalculate(orderId);
720
+ return await this.patchOrResource(orderId);
721
+ }
722
+
723
+ // ------------------------------------------------------ чек целиком
724
+
725
+ async removeOrder(orderId) {
726
+ const order = await this.orders.findRow(orderId);
727
+ // Закрытый чек без позиций (закрыт без оплаты) удалять нельзя — пропал бы из отчёта и смены.
728
+ this.assertMutable(order, 'orders.removeOrder');
729
+ if (order && (await this.orders.countProducts(orderId)) === 0) {
730
+ await this.saveEvents([{event: 'orders:removed', time: nowSql(), order_id: orderId}]);
731
+ await this.closeKdsTickets(orderId, 'remove');
732
+ await this.orders.softDelete(orderId);
733
+ await this.emitUpdateOrders();
734
+ return true;
735
+ }
736
+ await this.emitUpdateOrders();
737
+ return false;
738
+ }
739
+
740
+ async rollback(orderId) {
741
+ const order = await this.findOrFail(orderId, {withTrashed: true});
742
+ await this.payments.deleteOfOrder(orderId);
743
+ // closed_at отменённого закрытия — свидетельство для облака (SyncService::reopenAccepted):
744
+ // чек, закрытый ещё онлайн и переоткрытый на кассе без связи, облако примет открытым,
745
+ // только если касса отменила именно то закрытие, что лежит в облаке.
746
+ await this.saveEvents([{
747
+ event: 'orders:rollback',
748
+ time: nowSql(),
749
+ order_id: orderId,
750
+ data: {closed_at: toSql(order.closed_at)},
751
+ }]);
752
+ await this.orders.updateRow(orderId, {
753
+ status: 'opened',
754
+ closed_at: null,
755
+ closed_by_id: null,
756
+ is_closed_without_payment: 0,
757
+ fiscal_id: null,
758
+ rrn: null,
759
+ without_payment_reason: null,
760
+ // Открытый чек ни к какой смене не относится: иначе повторное закрытие осталось
761
+ // бы в СТАРОЙ (возможно уже закрытой) смене — closeOrder резолвит только пустой.
762
+ shift_id: null,
763
+ });
764
+ return await this.patchOrResource(orderId, {withTrashed: true});
765
+ }
766
+
767
+ async merge({sourceId, targetId}) {
768
+ if (!sourceId || !targetId || sourceId === targetId) {
769
+ return null;
770
+ }
771
+
772
+ // Оба чека должны существовать: повторное слияние (дубль socket+HTTP) не должно
773
+ // ничего делать, иначе пересчитывался бы уже удалённый чек.
774
+ const sourceRow = await this.orders.findRow(sourceId);
775
+ const targetRow = await this.orders.findRow(targetId);
776
+ if (!sourceRow || !targetRow) {
777
+ return null;
778
+ }
779
+
780
+ // Слияние двигает позиции и удаляет source — на закрытом это тот же перенос денег.
781
+ this.assertMutable(sourceRow, 'orders.merge:source');
782
+ this.assertMutable(targetRow, 'orders.merge:target');
783
+
784
+ await this.orderProducts.moveToOrder(sourceId, targetId);
785
+
786
+ await this.recalculate(sourceId);
787
+ await this.saveEvents([{
788
+ event: 'orders:merged',
789
+ time: nowSql(),
790
+ order_id: sourceId,
791
+ data: {target_id: targetId},
792
+ }]);
793
+ await this.orders.softDelete(sourceId);
794
+
795
+ await this.recalculate(targetId);
796
+ await this.emitUpdateOrders();
797
+ return true;
798
+ }
799
+
800
+ // ------------------------------------------------------------ чтение
801
+
802
+ async getOpenOrders(companyId = null) {
803
+ const orders = await this.orders.openOrders(companyId || this.context.companyId);
804
+ return this.mapResource(orders);
805
+ }
806
+
807
+ async getById(orderId) {
808
+ return await this.resource().order(await this.findOrFail(orderId, {withTrashed: true}));
809
+ }
810
+
811
+ async archive({companyId = null, date = null} = {}) {
812
+ const team = this.context.getTeam();
813
+ const target = date ? new Date(date) : now();
814
+ // Архив показывает календарные сутки заведения, без сдвига на бизнес-день.
815
+ const [from, to] = businessDay(target, {
816
+ timezone: team ? team.timezone : undefined,
817
+ shiftEndsAt: 0,
818
+ now: now(),
819
+ });
820
+ const orders = await this.orders.archivedOrders(companyId || this.context.companyId, toSql(from), toSql(to));
821
+ return this.mapResource(orders);
822
+ }
823
+
824
+ async isFiscalExists(fiscalId) {
825
+ const row = await this.db.get(
826
+ 'SELECT 1 AS found FROM orders WHERE team_id = ? AND fiscal_id = ? LIMIT 1',
827
+ [this.context.teamId, fiscalId]
828
+ );
829
+ return {success: true, exists: Boolean(row)};
830
+ }
831
+
832
+ /**
833
+ * Тик повременных позиций: пересчёт в памяти + батч-патч по компаниям.
834
+ * Ничего не пишет в БД — цена таймера всегда выводится из времени.
835
+ */
836
+ async tickTimers(filter = {}) {
837
+ const orders = await this.orders.ordersWithRunningTimers(filter);
838
+ const resource = this.resource();
839
+ await resource.preload(orders);
840
+ const byCompany = new Map();
841
+
842
+ for (const order of orders) {
843
+ order.calculate(true, true);
844
+ if (!byCompany.has(order.company_id)) {
845
+ byCompany.set(order.company_id, []);
846
+ }
847
+ byCompany.get(order.company_id).push(await resource.order(order));
848
+ }
849
+
850
+ if (this.emitter && this.emitter.orderPatchBatch) {
851
+ for (const [companyId, patches] of byCompany) {
852
+ this.emitter.orderPatchBatch(companyId, patches);
853
+ }
854
+ }
855
+ return true;
856
+ }
857
+
858
+ /**
859
+ * Порт OrderProductService::collectModifierSets.
860
+ *
861
+ * Порядок ключей в элементе важен: хэш считается от json_encode этого массива и служит
862
+ * ключом склейки строк чека — переставь поля, и одинаковые позиции перестанут склеиваться.
863
+ */
864
+ static async collectModifierSets(snapshot, modifierSets) {
865
+ let totalPrice = 0;
866
+ const data = [];
867
+
868
+ for (const modifier of modifierSets || []) {
869
+ const modifierSet = await snapshot.findModifierSet(modifier.modifier_set_id);
870
+ if (!modifierSet) {
871
+ throw notFound('Modifier set not found');
872
+ }
873
+ const productModifierSet = await snapshot.findProductModifierSetBySet(modifierSet.id);
874
+ if (!productModifierSet) {
875
+ throw notFound('Product modifier set not found');
876
+ }
877
+
878
+ const price = toFloat(modifier.price);
879
+ const quantity = modifier.quantity;
880
+ if (quantity === 0) {
881
+ continue;
882
+ }
883
+ const total = price * quantity;
884
+
885
+ data.push({
886
+ modifier_set_type: modifierSet.type,
887
+ modifier_set_title: productModifierSet.title,
888
+ name: modifier.name,
889
+ total,
890
+ price,
891
+ quantity,
892
+ modifier_set_modifier_id: modifier.modifier_set_modifier_id,
893
+ modifier_set_id: modifierSet.id,
894
+ });
895
+ totalPrice += total;
896
+ }
897
+
898
+ const hash = data.length ? md5(phpJsonEncode(data)) : null;
899
+ return [data, totalPrice, hash];
900
+ }
901
+ }
902
+
903
+ module.exports = OrderService;