@alizadefaig/pos-server 1.2.26 → 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/app/core/Context.js +170 -0
  2. package/app/core/auth/index.js +133 -0
  3. package/app/core/auth/license.js +45 -0
  4. package/app/core/auth/posMode.js +260 -0
  5. package/app/core/auth/sanctum.js +209 -0
  6. package/app/core/auth/scope.js +231 -0
  7. package/app/core/emitter.js +67 -0
  8. package/app/core/errors.js +18 -0
  9. package/app/core/guard.js +54 -0
  10. package/app/core/idempotency.js +196 -0
  11. package/app/core/index.js +374 -0
  12. package/app/core/models/Order.js +176 -0
  13. package/app/core/models/OrderProduct.js +249 -0
  14. package/app/core/repositories/EventRepository.js +51 -0
  15. package/app/core/repositories/OrderProductRepository.js +225 -0
  16. package/app/core/repositories/OrderRepository.js +274 -0
  17. package/app/core/repositories/OutboxRepository.js +52 -0
  18. package/app/core/repositories/PaymentRepository.js +43 -0
  19. package/app/core/repositories/PrinterRepository.js +179 -0
  20. package/app/core/repositories/ProductRelations.js +148 -0
  21. package/app/core/repositories/SnapshotRepository.js +197 -0
  22. package/app/core/resources/HallResource.js +54 -0
  23. package/app/core/resources/OrderResource.js +177 -0
  24. package/app/core/resources/ProductResource.js +186 -0
  25. package/app/core/resources/ShiftResource.js +42 -0
  26. package/app/core/resources/TeamResource.js +85 -0
  27. package/app/core/resources/casts.js +55 -0
  28. package/app/core/services/AuthService.js +468 -0
  29. package/app/core/services/InitService.js +277 -0
  30. package/app/core/services/KdsService.js +639 -0
  31. package/app/core/services/OrderProductService.js +309 -0
  32. package/app/core/services/OrderService.js +903 -0
  33. package/app/core/services/PrintService.js +715 -0
  34. package/app/core/services/PrinterAdminService.js +324 -0
  35. package/app/core/services/ReportService.js +581 -0
  36. package/app/core/services/ShiftService.js +199 -0
  37. package/app/core/services/StorageService.js +425 -0
  38. package/app/core/services/TelegramService.js +162 -0
  39. package/app/core/support/db.js +48 -0
  40. package/app/core/support/ids.js +9 -0
  41. package/app/core/support/imageData.js +135 -0
  42. package/app/core/support/images.js +92 -0
  43. package/app/core/support/lanHandoff.js +105 -0
  44. package/app/core/support/lang.js +98 -0
  45. package/app/core/support/lock.js +63 -0
  46. package/app/core/support/num.js +51 -0
  47. package/app/core/support/php.js +125 -0
  48. package/app/core/support/pin.js +34 -0
  49. package/app/core/support/receipts.js +278 -0
  50. package/app/core/support/rows.js +38 -0
  51. package/app/core/support/str.js +31 -0
  52. package/app/core/support/teamSettings.js +77 -0
  53. package/app/core/support/telegram.js +253 -0
  54. package/app/core/support/telegramReport.js +143 -0
  55. package/app/core/support/time.js +58 -0
  56. package/app/core/support/tz.js +80 -0
  57. package/app/core/support/unlock.js +113 -0
  58. package/app/core/timers.js +151 -0
  59. package/app/db/Database.js +197 -1
  60. package/app/db/Migrator.js +45 -0
  61. package/app/db/driver.js +248 -0
  62. package/app/db/migrations/001_snapshot.sql +214 -0
  63. package/app/db/migrations/002_orders.sql +190 -0
  64. package/app/db/migrations/003_idempotency.sql +18 -0
  65. package/app/db/migrations/004_shifts.sql +21 -0
  66. package/app/db/migrations/005_printers.sql +43 -0
  67. package/app/db/migrations/006_sync.sql +136 -0
  68. package/app/db/migrations/007_printer_mac.sql +4 -0
  69. package/app/db/migrations/008_kds.sql +94 -0
  70. package/app/db/migrations/009_product_categories.sql +20 -0
  71. package/app/db/migrations/010_printers_sync.sql +43 -0
  72. package/app/db/migrations/011_team_logos.sql +13 -0
  73. package/app/db/migrations/012_category_workshop.sql +4 -0
  74. package/app/monitor.js +185 -0
  75. package/app/printers/address.js +348 -0
  76. package/app/proxy.js +11 -3
  77. package/app/relay/masters.js +173 -0
  78. package/app/security.js +218 -0
  79. package/app/socket/auth.js +160 -0
  80. package/app/socket/request.js +87 -0
  81. package/app/socket/server.js +121 -19
  82. package/app/sync/bridge.js +162 -0
  83. package/app/sync/cloud.js +120 -0
  84. package/app/sync/config.js +185 -0
  85. package/app/sync/discovery.js +546 -0
  86. package/app/sync/index.js +1644 -0
  87. package/app/sync/kv.js +26 -0
  88. package/app/sync/lan.js +139 -0
  89. package/app/sync/lanDevices.js +177 -0
  90. package/app/sync/lanFront.js +431 -0
  91. package/app/sync/mirror.js +236 -0
  92. package/app/sync/push.js +324 -0
  93. package/app/sync/reconcile.js +161 -0
  94. package/app/sync/signal.js +202 -0
  95. package/app/sync/snapshot.js +189 -0
  96. package/app/sync/store.js +234 -0
  97. package/app/tcp.js +11 -2
  98. package/package.json +6 -3
  99. package/routes/common/api/auth.js +38 -0
  100. package/routes/common/api/kds.js +37 -0
  101. package/routes/common/api/orderProducts.js +81 -0
  102. package/routes/common/api/orders.js +176 -0
  103. package/routes/common/api/printersCore.js +35 -0
  104. package/routes/common/api/reports.js +29 -0
  105. package/routes/common/socket.js +15 -35
  106. package/routes/common/tax.js +29 -7
  107. package/routes/desktop/api/offline.js +146 -0
  108. package/routes/desktop/api/printers.js +288 -5
  109. package/routes/desktop/local.js +130 -0
  110. package/routes/server/api/login.js +37 -0
  111. package/routes/server/api/platform.js +31 -0
  112. package/routes/server/api/shifts.js +25 -0
  113. package/routes/server/master.js +24 -0
  114. package/server.js +72 -3
  115. package/utils.js +6 -0
  116. package/tests/usbDiagnose.js +0 -59
  117. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,581 @@
1
+ const PrinterRepository = require('../repositories/PrinterRepository');
2
+ const {round, toFloat, toBool} = require('../support/num');
3
+ const {now, parse} = require('../support/time');
4
+ const {translated} = require('../support/lang');
5
+ const {DEFAULT_TIMEZONE, zonedParts, zonedTimeToUtc} = require('../support/tz');
6
+
7
+ // Команда, у которой дневной отчёт идёт по закрытию чека, а не по созданию (как в Laravel).
8
+ const CLOSED_AT_TEAMS = ['a0cc12ea-ea58-488b-9e4b-d0dc429aabed'];
9
+ const CLOSED_STATUSES = ['closing', 'closed'];
10
+ const TYPES = ['date', 'cash_shift'];
11
+
12
+ // Order::WITHOUT_PAYMENT_REASONS. trans() их не переводит: ключей нет ни в az.json, ни в en.json.
13
+ const WITHOUT_PAYMENT_REASONS = {
14
+ guest_left: 'Qonaq getdi',
15
+ on_the_house: 'Müəssisənin hesabına',
16
+ employees_wrong: 'Qarson səhvi',
17
+ };
18
+ const NO_REASON = 'Səbəb göstərilməyib';
19
+ const DELETED_USER = 'Silinmiş istifadəçi';
20
+ const DELETED_PRODUCT = 'Silinən məhsul';
21
+
22
+ // Отчёт за длинный период облако считает дольше обычного запроса.
23
+ const CLOUD_REPORT_TIMEOUT_MS = 60000;
24
+
25
+ const pad = (value) => String(value).padStart(2, '0');
26
+
27
+ const sqlUtc = (date) => date.toISOString().slice(0, 19).replace('T', ' ');
28
+
29
+ const formatZoned = (date, timeZone) => {
30
+ let parts;
31
+ try {
32
+ parts = zonedParts(date, timeZone || DEFAULT_TIMEZONE);
33
+ } catch (e) {
34
+ parts = zonedParts(date, DEFAULT_TIMEZONE);
35
+ }
36
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`;
37
+ };
38
+
39
+ const LOCAL_DATE = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2})(?:\.\d+)?)?)?$/;
40
+ const ZONED_DATE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
41
+
42
+ const daysInMonth = (year, month) => new Date(Date.UTC(year, month, 0)).getUTCDate();
43
+
44
+ /**
45
+ * Carbon::parse($value, $tz): стенные часы без зоны — в зоне команды, строка с зоной — как есть.
46
+ * Фронт шлёт 'YYYY-MM-DD HH:mm:ss' и 'YYYY-MM-DD'. Непонятное — null (правило 'date' Laravel).
47
+ */
48
+ const parseDate = (value, timeZone) => {
49
+ if (typeof value !== 'string') {
50
+ return null;
51
+ }
52
+ const text = value.trim();
53
+ const local = LOCAL_DATE.exec(text);
54
+ if (local) {
55
+ const [year, month, day, hour = 0, minute = 0, second = 0] = local.slice(1).map((part) => Number(part || 0));
56
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)
57
+ || hour > 23 || minute > 59 || second > 59) {
58
+ return null;
59
+ }
60
+ return zonedTimeToUtc({year, month, day, hour, minute, second}, timeZone || DEFAULT_TIMEZONE);
61
+ }
62
+ if (ZONED_DATE.test(text)) {
63
+ const date = new Date(text.replace(' ', 'T'));
64
+ return Number.isNaN(date.getTime()) ? null : date;
65
+ }
66
+ return null;
67
+ };
68
+
69
+ // Collection::sum по полю — float в порядке коллекции, как в PHP.
70
+ const sumOf = (rows, key) => rows.reduce((carry, row) => carry + toFloat(row[key]), 0);
71
+
72
+ /**
73
+ * Порт App\Http\Controllers\Api\Platform\ReportController (reports.getReport): X/Z-отчёт кассы
74
+ * без облака. Форма ответа совпадает с Laravel поле в поле — её печатает ReportReceipt.vue.
75
+ *
76
+ * Отличия:
77
+ * - by_product группирует позиции по точной цене. Laravel groupBy('price') по float обрезал
78
+ * ключ до целого и сливал, например, 2.5 и 2.9 в одну строку; исправлено и там.
79
+ * - Касса хранит не всю историю (mirror.historyFrom + уборка закрытых чеков). Период раньше
80
+ * этой границы при доступном и полном облаке считает облако, иначе отчёт считается по
81
+ * локальным данным и помечается `report.partial_from` — начало полных данных в зоне команды.
82
+ * Облако недоступно — `report.offline`: чеки других касс без мастера сюда не попали.
83
+ * - Запросы по чекам — подзапросами, а не списком id: отчёт за месяц упёрся бы в лимит
84
+ * параметров SQLite.
85
+ */
86
+ class ReportService {
87
+ /**
88
+ * history — () => sync.history(companyId) либо null (облачный контур ядра, синка нет).
89
+ */
90
+ constructor({db, context, history = null}) {
91
+ this.db = db;
92
+ this.context = context;
93
+ this.history = history;
94
+ this.printers = new PrinterRepository(db);
95
+ }
96
+
97
+ notDeleted(alias) {
98
+ // Эти справочники снапшот кладёт на кассу только живыми, колонки deleted_at там нет.
99
+ return this.db.dialect === 'pgsql' ? ` AND ${alias}.deleted_at IS NULL` : '';
100
+ }
101
+
102
+ locale() {
103
+ return this.context ? this.context.locale() : 'az';
104
+ }
105
+
106
+ timezone() {
107
+ const team = this.context.getTeam();
108
+ return (team && team.timezone) || DEFAULT_TIMEZONE;
109
+ }
110
+
111
+ // ------------------------------------------------------------ getReport
112
+
113
+ async getReport(params = {}) {
114
+ const timeZone = this.timezone();
115
+ const errors = await this.validate(params, timeZone);
116
+ const window = errors ? null : await this.resolveWindow(params, timeZone);
117
+ const history = typeof this.history === 'function' ? this.history() : null;
118
+
119
+ // Период раньше полной истории кассы (или смена, которой у кассы нет) — считает облако,
120
+ // если оно на связи и в нём уже всё, что есть у кассы.
121
+ const shiftMissing = errors && errors.shift && Object.keys(errors).length === 1;
122
+ const early = window && history && window.from < history.from;
123
+ if (history && history.cloud && (early || shiftMissing)) {
124
+ const response = await this.fromCloud(history.cloud, params);
125
+ if (response) {
126
+ return {...response, printer: await this.printer()};
127
+ }
128
+ }
129
+
130
+ if (errors) {
131
+ return {success: false, error: 'validation', errors};
132
+ }
133
+
134
+ const report = await this.generate(window, params, timeZone);
135
+ if (early) {
136
+ report.partial_from = formatZoned(history.from, timeZone);
137
+ }
138
+ if (history && history.mode === 'offline') {
139
+ report.offline = true;
140
+ }
141
+ return {success: true, report, printer: await this.printer()};
142
+ }
143
+
144
+ async fromCloud(cloud, params) {
145
+ try {
146
+ const response = await cloud.call('reports.getReport', params, {timeout: CLOUD_REPORT_TIMEOUT_MS});
147
+ return response && response.success ? response : null;
148
+ } catch (e) {
149
+ console.warn(`[report] cloud report failed, using local data: ${e.message}`);
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /** Validator::make: 'date' => required|array, 'date.*' => required|date, type, shift exists. */
155
+ async validate(params, timeZone) {
156
+ const errors = {};
157
+ const date = params.date;
158
+ if (date === undefined || date === null || (Array.isArray(date) && date.length === 0)) {
159
+ errors.date = ['The date field is required.'];
160
+ } else if (!Array.isArray(date)) {
161
+ errors.date = ['The date field must be an array.'];
162
+ } else {
163
+ date.forEach((item, index) => {
164
+ if (item === null || item === undefined || item === '') {
165
+ errors[`date.${index}`] = [`The date.${index} field is required.`];
166
+ } else if (!parseDate(item, timeZone)) {
167
+ errors[`date.${index}`] = [`The date.${index} field must be a valid date.`];
168
+ }
169
+ });
170
+ }
171
+ if (!params.type) {
172
+ errors.type = ['The type field is required.'];
173
+ } else if (!TYPES.includes(params.type)) {
174
+ errors.type = ['The selected type is invalid.'];
175
+ }
176
+ if (params.shift !== undefined && params.shift !== null && params.shift !== '') {
177
+ // exists:shifts,id видит и удалённые смены. Не-uuid в Postgres — ошибка запроса.
178
+ let exists = null;
179
+ try {
180
+ exists = await this.db.value('SELECT 1 FROM shifts WHERE id = ?', [String(params.shift)]);
181
+ } catch (e) {
182
+ exists = null;
183
+ }
184
+ if (!exists) {
185
+ errors.shift = ['The selected shift is invalid.'];
186
+ }
187
+ }
188
+ return Object.keys(errors).length ? errors : null;
189
+ }
190
+
191
+ async resolveWindow(params, timeZone) {
192
+ // Shift::find не видит удалённую смену — тогда отчёт считается по датам, как в Laravel.
193
+ const shift = params.type === 'cash_shift' && params.shift
194
+ ? await this.db.get('SELECT * FROM shifts WHERE id = ? AND deleted_at IS NULL', [String(params.shift)])
195
+ : null;
196
+ if (shift) {
197
+ return {shift, from: parse(shift.started_at), to: shift.ended_at ? parse(shift.ended_at) : now()};
198
+ }
199
+ return {
200
+ shift: null,
201
+ from: parseDate(params.date[0], timeZone),
202
+ to: parseDate(params.date[1], timeZone),
203
+ };
204
+ }
205
+
206
+ async printer() {
207
+ const printer = await this.printers.findBillPrinter(this.context.terminalId);
208
+ if (!printer) {
209
+ return null;
210
+ }
211
+ return {
212
+ name: printer.name,
213
+ type: printer.type,
214
+ ip_address: printer.ip_address,
215
+ mac_address: printer.mac_address ?? null,
216
+ vendor_id: printer.vendor_id,
217
+ product_id: printer.product_id,
218
+ // Каста у модели нет: Postgres отдаёт boolean, SQLite — 0/1.
219
+ bell: printer.bell === null || printer.bell === undefined ? null : toBool(printer.bell),
220
+ cashdraw: false,
221
+ };
222
+ }
223
+
224
+ // ------------------------------------------------------------ generateReport
225
+
226
+ /** Условие выборки чеков отчёта: {sql, params} для подзапросов «чеки отчёта». */
227
+ ordersFilter(window) {
228
+ const team = this.context.getTeam();
229
+ const companyId = this.context.companyId;
230
+ const statuses = CLOSED_STATUSES.map(() => '?').join(', ');
231
+ if (window.shift) {
232
+ return {
233
+ sql: `o.company_id = ? AND o.deleted_at IS NULL
234
+ AND (o.created_at BETWEEN ? AND ? OR o.shift_id = ?)
235
+ AND o.status IN (${statuses})`,
236
+ params: [companyId, sqlUtc(window.from), sqlUtc(window.to), window.shift.id, ...CLOSED_STATUSES],
237
+ };
238
+ }
239
+ const column = team && CLOSED_AT_TEAMS.includes(team.id) ? 'closed_at' : 'created_at';
240
+ return {
241
+ sql: `o.company_id = ? AND o.deleted_at IS NULL AND o.${column} BETWEEN ? AND ?`,
242
+ params: [companyId, sqlUtc(window.from), sqlUtc(window.to)],
243
+ };
244
+ }
245
+
246
+ async shiftHeader(shift) {
247
+ const opened = shift.opened_by ? await this.userName(shift.opened_by) : null;
248
+ const closed = shift.closed_by ? await this.userName(shift.closed_by) : null;
249
+ return {opened_by: opened || DELETED_USER, closed_by: closed || null};
250
+ }
251
+
252
+ /** $teamUser->user?->name с withTrashed у сотрудника; удалённый пользователь — null. */
253
+ async userName(teamUserId) {
254
+ const row = await this.db.get(
255
+ `SELECT u.name FROM team_users tu
256
+ LEFT JOIN users u ON u.id = tu.user_id${this.notDeleted('u')}
257
+ WHERE tu.id = ?`,
258
+ [teamUserId]
259
+ );
260
+ return row ? row.name ?? null : null;
261
+ }
262
+
263
+ async generate(window, params, timeZone) {
264
+ const team = this.context.getTeam();
265
+ const companyId = this.context.companyId;
266
+ const locale = this.locale();
267
+ const formats = Array.isArray(params.formats) ? params.formats : [];
268
+ const has = (format) => formats.includes(format);
269
+
270
+ let report;
271
+ if (window.shift) {
272
+ const header = await this.shiftHeader(window.shift);
273
+ report = {
274
+ date: formatZoned(now(), timeZone),
275
+ from: formatZoned(window.from, timeZone),
276
+ to: formatZoned(window.to, timeZone),
277
+ shift: window.shift.id,
278
+ opened_by: header.opened_by,
279
+ closed_by: header.closed_by,
280
+ data: {},
281
+ };
282
+ } else {
283
+ report = {
284
+ date: formatZoned(now(), timeZone),
285
+ from: params.date[0],
286
+ to: params.date[1],
287
+ data: {},
288
+ };
289
+ }
290
+
291
+ const filter = this.ordersFilter(window);
292
+ const orderIds = `SELECT o.id FROM orders o WHERE ${filter.sql}`;
293
+
294
+ const orders = (await this.db.all(
295
+ `SELECT o.id, o.table_id, o.serving_id, o.order_source_id, o.number,
296
+ o.total, o.subtotal, o.service_charge_amount, o.discount_amount, o.deposit_gap,
297
+ o.is_closed_without_payment, o.without_payment_reason
298
+ FROM orders o WHERE ${filter.sql}`,
299
+ filter.params
300
+ )).map((order) => ({
301
+ ...order,
302
+ total: toFloat(order.total),
303
+ subtotal: toFloat(order.subtotal),
304
+ service_charge_amount: toFloat(order.service_charge_amount),
305
+ discount_amount: toFloat(order.discount_amount),
306
+ deposit_gap: toFloat(order.deposit_gap),
307
+ }));
308
+
309
+ // withTrashed: снятые позиции нужны для by_product и removed_products.
310
+ const orderProducts = (await this.db.all(
311
+ `SELECT op.id, op.order_id, op.product_id, op.deleted_at, op.total, op.price,
312
+ op.workshop_print_quantity, op.total_workshop_print_quantity
313
+ FROM order_products op WHERE op.order_id IN (${orderIds})`,
314
+ filter.params
315
+ )).map((row) => ({
316
+ ...row,
317
+ total: round(row.total, 3),
318
+ price: round(row.price, 3),
319
+ workshop_print_quantity: round(row.workshop_print_quantity, 3),
320
+ total_workshop_print_quantity: round(row.total_workshop_print_quantity, 3),
321
+ }));
322
+ const aliveProducts = orderProducts.filter((row) => !row.deleted_at);
323
+
324
+ // Удалённые товары Laravel не читает (SoftDeletes без withTrashed). Цех категории — для
325
+ // мутатора Product::productWorkshopId: ignore_workshop в select Laravel нет, поэтому
326
+ // мутатор отдаёт колонку, а пустую — цех живой категории.
327
+ const productRows = await this.db.all(
328
+ `SELECT p.id, p.full_name, p.product_category_id, p.product_workshop_id, p.is_weight_item, p.unit_id,
329
+ c.product_workshop_id AS category_workshop_id
330
+ FROM products p
331
+ LEFT JOIN product_categories c ON c.id = p.product_category_id${this.notDeleted('c')}
332
+ WHERE p.deleted_at IS NULL
333
+ AND p.id IN (SELECT op.product_id FROM order_products op WHERE op.order_id IN (${orderIds}))`,
334
+ filter.params
335
+ );
336
+ const products = new Map(productRows.map((product) => [product.id, {
337
+ ...product,
338
+ full_name: translated(product.full_name, locale),
339
+ }]));
340
+
341
+ if (has('by_table')) {
342
+ report.data.tables = await this.byTable(orders, companyId);
343
+ }
344
+ if (has('by_workshop')) {
345
+ report.data.workshops = await this.byWorkshop(aliveProducts, products, team);
346
+ }
347
+ if (has('by_employee')) {
348
+ report.data.employees = await this.byEmployee(orders);
349
+ }
350
+ if (has('by_category')) {
351
+ report.data.categories = await this.byCategory(aliveProducts, products, locale);
352
+ }
353
+ if (has('by_product')) {
354
+ report.data.products = this.byProduct(orderProducts, products);
355
+ report.data.removed_products = this.removedProducts(orderProducts, products);
356
+ }
357
+ if (has('by_payment_method')) {
358
+ report.data.payments = await this.byPaymentMethod(orderIds, filter.params);
359
+ }
360
+ if (has('by_order_source')) {
361
+ report.data.order_sources = await this.byOrderSource(orders);
362
+ }
363
+
364
+ // Закрытия без оплаты — всегда, не по formats (как в Laravel).
365
+ const withoutPayment = orders.filter((order) => toBool(order.is_closed_without_payment));
366
+ report.data.without_payments = this.withoutPayments(withoutPayment);
367
+
368
+ report.footer = {
369
+ without_payment_total: round(sumOf(withoutPayment, 'total'), 2),
370
+ without_payment_count: withoutPayment.length,
371
+ subtotal: round(sumOf(orders, 'subtotal'), 2),
372
+ service_charge_amount: round(sumOf(orders, 'service_charge_amount'), 2),
373
+ discount_amount: round(sumOf(orders, 'discount_amount'), 2),
374
+ deposit_gap: round(sumOf(orders, 'deposit_gap'), 2),
375
+ total: round(sumOf(orders, 'total'), 2),
376
+ };
377
+ return report;
378
+ }
379
+
380
+ async byTable(orders, companyId) {
381
+ const halls = await this.db.all(
382
+ `SELECT h.id, h.name FROM halls h WHERE h.company_id = ?${this.notDeleted('h')}`,
383
+ [companyId]
384
+ );
385
+ const rows = [];
386
+ for (const hall of halls) {
387
+ const tables = await this.db.all(
388
+ `SELECT t.id, t.label FROM tables t WHERE t.hall_id = ?${this.notDeleted('t')} ORDER BY t."order"`,
389
+ [hall.id]
390
+ );
391
+ for (const table of tables) {
392
+ rows.push({
393
+ label: `${hall.name ?? ''} ${table.label ?? ''}`,
394
+ value: round(sumOf(orders.filter((order) => order.table_id === table.id), 'total'), 2),
395
+ });
396
+ }
397
+ }
398
+ return rows.filter((row) => row.value > 0);
399
+ }
400
+
401
+ async byWorkshop(aliveProducts, products, team) {
402
+ const workshops = await this.db.all(
403
+ `SELECT w.id, w.name FROM product_workshops w WHERE w.team_id = ?${this.notDeleted('w')}`,
404
+ [team ? team.id : this.context.teamId]
405
+ );
406
+ return workshops.map((workshop) => {
407
+ let value = 0;
408
+ for (const orderProduct of aliveProducts) {
409
+ const product = products.get(orderProduct.product_id);
410
+ if (product && (product.product_workshop_id || product.category_workshop_id) === workshop.id) {
411
+ value += orderProduct.total;
412
+ }
413
+ }
414
+ return {label: workshop.name, value: round(value, 2)};
415
+ }).filter((row) => row.value > 0);
416
+ }
417
+
418
+ async byEmployee(orders) {
419
+ const servingIds = [...new Set(orders.map((order) => order.serving_id).filter(Boolean))];
420
+ if (!servingIds.length) {
421
+ return [];
422
+ }
423
+ const staff = await this.db.all(
424
+ `SELECT tu.id, u.name FROM team_users tu
425
+ LEFT JOIN users u ON u.id = tu.user_id${this.notDeleted('u')}
426
+ WHERE tu.id IN (${servingIds.map(() => '?').join(', ')})`,
427
+ servingIds
428
+ );
429
+ return staff.map((teamUser) => {
430
+ const own = orders.filter((order) => order.serving_id === teamUser.id);
431
+ return {
432
+ label: teamUser.name ?? null,
433
+ value: round(sumOf(own, 'total'), 2),
434
+ service_charge_amount: round(sumOf(own, 'service_charge_amount'), 2),
435
+ discount_amount: round(sumOf(own, 'discount_amount'), 2),
436
+ subtotal: round(sumOf(own, 'subtotal'), 2),
437
+ };
438
+ });
439
+ }
440
+
441
+ async byCategory(aliveProducts, products, locale) {
442
+ const categoryIds = [...new Set([...products.values()].map((product) => product.product_category_id).filter(Boolean))];
443
+ if (!categoryIds.length) {
444
+ return [];
445
+ }
446
+ const categories = await this.db.all(
447
+ `SELECT c.id, c.name FROM product_categories c
448
+ WHERE c.deleted_at IS NULL AND c.id IN (${categoryIds.map(() => '?').join(', ')})`,
449
+ categoryIds
450
+ );
451
+ return categories.map((category) => {
452
+ const ids = new Set([...products.values()]
453
+ .filter((product) => product.product_category_id === category.id)
454
+ .map((product) => product.id));
455
+ return {
456
+ label: translated(category.name, locale),
457
+ value: round(sumOf(aliveProducts.filter((row) => ids.has(row.product_id)), 'total'), 2),
458
+ };
459
+ });
460
+ }
461
+
462
+ byProduct(orderProducts, products) {
463
+ const rows = [];
464
+ for (const product of products.values()) {
465
+ // Снятые позиции тоже: их workshop_print_quantity — то, что успело уйти на кухню.
466
+ const groups = new Map();
467
+ for (const row of orderProducts) {
468
+ if (row.product_id !== product.id) {
469
+ continue;
470
+ }
471
+ const key = String(row.price);
472
+ if (!groups.has(key)) {
473
+ groups.set(key, []);
474
+ }
475
+ groups.get(key).push(row);
476
+ }
477
+ for (const group of groups.values()) {
478
+ const quantity = sumOf(group, 'workshop_print_quantity');
479
+ rows.push({
480
+ label: product.full_name,
481
+ quantity,
482
+ value: round(quantity * group[0].price, 2),
483
+ price: group[0].price,
484
+ is_weight_item: toBool(product.is_weight_item),
485
+ unit_id: product.unit_id ?? null,
486
+ });
487
+ }
488
+ }
489
+ // sortByDesc устойчив (PHP 8): равные остаются в исходном порядке.
490
+ return rows
491
+ .filter((row) => row.quantity > 0)
492
+ .map((row, index) => ({row, index}))
493
+ .sort((a, b) => (b.row.value - a.row.value) || (a.index - b.index))
494
+ .map(({row}) => row);
495
+ }
496
+
497
+ removedProducts(orderProducts, products) {
498
+ const removed = {};
499
+ for (const row of orderProducts) {
500
+ if (!(row.total_workshop_print_quantity > row.workshop_print_quantity)) {
501
+ continue;
502
+ }
503
+ const key = row.product_id ?? '';
504
+ if (!Object.prototype.hasOwnProperty.call(removed, key)) {
505
+ const product = products.get(row.product_id) || null;
506
+ removed[key] = {
507
+ label: (product && product.full_name) ?? DELETED_PRODUCT,
508
+ quantity: 0,
509
+ value: 0,
510
+ is_weight_item: Boolean(product && toBool(product.is_weight_item)),
511
+ unit_id: product ? product.unit_id ?? null : null,
512
+ };
513
+ }
514
+ removed[key].quantity += round(row.total_workshop_print_quantity - row.workshop_print_quantity, 3);
515
+ removed[key].value = round(removed[key].quantity * row.price, 2);
516
+ }
517
+ return Object.keys(removed).length ? removed : [];
518
+ }
519
+
520
+ async byPaymentMethod(orderIds, params) {
521
+ const payments = await this.db.all(
522
+ `SELECT p.payment_method_id, p.amount FROM order_payments p
523
+ WHERE p.deleted_at IS NULL AND p.order_id IN (${orderIds})`,
524
+ params
525
+ );
526
+ const methodIds = [...new Set(payments.map((payment) => payment.payment_method_id).filter(Boolean))];
527
+ if (!methodIds.length) {
528
+ return [];
529
+ }
530
+ const methods = await this.db.all(
531
+ `SELECT m.id, m.name FROM payment_methods m
532
+ WHERE m.id IN (${methodIds.map(() => '?').join(', ')})${this.notDeleted('m')}`,
533
+ methodIds
534
+ );
535
+ return methods.map((method) => ({
536
+ label: method.name,
537
+ value: round(sumOf(payments.filter((payment) => payment.payment_method_id === method.id), 'amount'), 2),
538
+ }));
539
+ }
540
+
541
+ async byOrderSource(orders) {
542
+ const sourceIds = [...new Set(orders.map((order) => order.order_source_id).filter(Boolean))];
543
+ if (!sourceIds.length) {
544
+ return [];
545
+ }
546
+ const sources = await this.db.all(
547
+ `SELECT s.id, s.name FROM order_sources s
548
+ WHERE s.id IN (${sourceIds.map(() => '?').join(', ')})${this.notDeleted('s')}`,
549
+ sourceIds
550
+ );
551
+ return sources.map((source) => ({
552
+ label: source.name,
553
+ value: round(sumOf(orders.filter((order) => order.order_source_id === source.id), 'total'), 2),
554
+ }));
555
+ }
556
+
557
+ withoutPayments(orders) {
558
+ const groups = new Map();
559
+ for (const order of orders) {
560
+ const reason = order.without_payment_reason ?? '';
561
+ if (!groups.has(reason)) {
562
+ groups.set(reason, []);
563
+ }
564
+ groups.get(reason).push(order);
565
+ }
566
+ const rows = [...groups.entries()].map(([reason, list]) => ({
567
+ label: WITHOUT_PAYMENT_REASONS[reason] ?? NO_REASON,
568
+ count: list.length,
569
+ value: round(sumOf(list, 'total'), 2),
570
+ numbers: list.map((order) => order.number).filter(Boolean).sort((a, b) => a - b),
571
+ }));
572
+ return rows
573
+ .map((row, index) => ({row, index}))
574
+ .sort((a, b) => (b.row.value - a.row.value) || (a.index - b.index))
575
+ .map(({row}) => row);
576
+ }
577
+ }
578
+
579
+ ReportService.parseDate = parseDate;
580
+
581
+ module.exports = ReportService;