@alizadefaig/pos-server 1.2.26 → 1.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/app/core/Context.js +170 -0
  2. package/app/core/auth/index.js +133 -0
  3. package/app/core/auth/license.js +45 -0
  4. package/app/core/auth/posMode.js +260 -0
  5. package/app/core/auth/sanctum.js +209 -0
  6. package/app/core/auth/scope.js +231 -0
  7. package/app/core/emitter.js +67 -0
  8. package/app/core/errors.js +18 -0
  9. package/app/core/guard.js +54 -0
  10. package/app/core/idempotency.js +196 -0
  11. package/app/core/index.js +374 -0
  12. package/app/core/models/Order.js +176 -0
  13. package/app/core/models/OrderProduct.js +249 -0
  14. package/app/core/repositories/EventRepository.js +51 -0
  15. package/app/core/repositories/OrderProductRepository.js +225 -0
  16. package/app/core/repositories/OrderRepository.js +274 -0
  17. package/app/core/repositories/OutboxRepository.js +52 -0
  18. package/app/core/repositories/PaymentRepository.js +43 -0
  19. package/app/core/repositories/PrinterRepository.js +179 -0
  20. package/app/core/repositories/ProductRelations.js +148 -0
  21. package/app/core/repositories/SnapshotRepository.js +197 -0
  22. package/app/core/resources/HallResource.js +54 -0
  23. package/app/core/resources/OrderResource.js +177 -0
  24. package/app/core/resources/ProductResource.js +186 -0
  25. package/app/core/resources/ShiftResource.js +42 -0
  26. package/app/core/resources/TeamResource.js +117 -0
  27. package/app/core/resources/casts.js +55 -0
  28. package/app/core/services/AuthService.js +468 -0
  29. package/app/core/services/InitService.js +279 -0
  30. package/app/core/services/KdsService.js +639 -0
  31. package/app/core/services/OrderProductService.js +309 -0
  32. package/app/core/services/OrderService.js +911 -0
  33. package/app/core/services/PrintService.js +715 -0
  34. package/app/core/services/PrinterAdminService.js +324 -0
  35. package/app/core/services/ReportService.js +581 -0
  36. package/app/core/services/ShiftService.js +199 -0
  37. package/app/core/services/StorageService.js +425 -0
  38. package/app/core/services/TelegramService.js +162 -0
  39. package/app/core/support/db.js +48 -0
  40. package/app/core/support/ids.js +9 -0
  41. package/app/core/support/imageData.js +135 -0
  42. package/app/core/support/images.js +92 -0
  43. package/app/core/support/lanHandoff.js +105 -0
  44. package/app/core/support/lang.js +98 -0
  45. package/app/core/support/lock.js +63 -0
  46. package/app/core/support/num.js +51 -0
  47. package/app/core/support/php.js +125 -0
  48. package/app/core/support/pin.js +34 -0
  49. package/app/core/support/receipts.js +278 -0
  50. package/app/core/support/rows.js +38 -0
  51. package/app/core/support/str.js +31 -0
  52. package/app/core/support/teamSettings.js +77 -0
  53. package/app/core/support/telegram.js +253 -0
  54. package/app/core/support/telegramReport.js +143 -0
  55. package/app/core/support/time.js +58 -0
  56. package/app/core/support/tz.js +80 -0
  57. package/app/core/support/unlock.js +113 -0
  58. package/app/core/timers.js +151 -0
  59. package/app/db/Database.js +197 -1
  60. package/app/db/Migrator.js +45 -0
  61. package/app/db/driver.js +248 -0
  62. package/app/db/migrations/001_snapshot.sql +214 -0
  63. package/app/db/migrations/002_orders.sql +190 -0
  64. package/app/db/migrations/003_idempotency.sql +18 -0
  65. package/app/db/migrations/004_shifts.sql +21 -0
  66. package/app/db/migrations/005_printers.sql +43 -0
  67. package/app/db/migrations/006_sync.sql +136 -0
  68. package/app/db/migrations/007_printer_mac.sql +4 -0
  69. package/app/db/migrations/008_kds.sql +94 -0
  70. package/app/db/migrations/009_product_categories.sql +20 -0
  71. package/app/db/migrations/010_printers_sync.sql +43 -0
  72. package/app/db/migrations/011_team_logos.sql +13 -0
  73. package/app/db/migrations/012_category_workshop.sql +4 -0
  74. package/app/monitor.js +185 -0
  75. package/app/printers/address.js +348 -0
  76. package/app/proxy.js +11 -3
  77. package/app/relay/masters.js +173 -0
  78. package/app/security.js +218 -0
  79. package/app/socket/auth.js +160 -0
  80. package/app/socket/request.js +87 -0
  81. package/app/socket/server.js +121 -19
  82. package/app/sync/bridge.js +162 -0
  83. package/app/sync/cloud.js +120 -0
  84. package/app/sync/config.js +185 -0
  85. package/app/sync/discovery.js +546 -0
  86. package/app/sync/index.js +1644 -0
  87. package/app/sync/kv.js +26 -0
  88. package/app/sync/lan.js +139 -0
  89. package/app/sync/lanDevices.js +177 -0
  90. package/app/sync/lanFront.js +431 -0
  91. package/app/sync/mirror.js +236 -0
  92. package/app/sync/push.js +324 -0
  93. package/app/sync/reconcile.js +161 -0
  94. package/app/sync/signal.js +202 -0
  95. package/app/sync/snapshot.js +189 -0
  96. package/app/sync/store.js +234 -0
  97. package/app/tcp.js +11 -2
  98. package/package.json +6 -3
  99. package/routes/common/api/auth.js +38 -0
  100. package/routes/common/api/kds.js +37 -0
  101. package/routes/common/api/orderProducts.js +81 -0
  102. package/routes/common/api/orders.js +176 -0
  103. package/routes/common/api/printersCore.js +35 -0
  104. package/routes/common/api/reports.js +29 -0
  105. package/routes/common/socket.js +15 -35
  106. package/routes/common/tax.js +29 -7
  107. package/routes/desktop/api/offline.js +146 -0
  108. package/routes/desktop/api/printers.js +288 -5
  109. package/routes/desktop/local.js +130 -0
  110. package/routes/server/api/login.js +37 -0
  111. package/routes/server/api/platform.js +31 -0
  112. package/routes/server/api/shifts.js +25 -0
  113. package/routes/server/master.js +24 -0
  114. package/server.js +72 -3
  115. package/utils.js +6 -0
  116. package/tests/usbDiagnose.js +0 -59
  117. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,177 @@
1
+ const {round2, toBool} = require('../support/num');
2
+ const {translated} = require('../support/lang');
3
+ const {isoDate} = require('../support/php');
4
+ const {productResource, productModifierSetResource} = require('./ProductResource');
5
+ const ProductRelations = require('../repositories/ProductRelations');
6
+
7
+ /**
8
+ * Порт App\Http\Resources\Platform\* — форма ответа, на которую завязан терминал.
9
+ * Отклонение здесь тише всего ломает фронт, поэтому набор и порядок полей повторены буквально,
10
+ * включая странности вроде 'fiscal_url' => fiscal_id.
11
+ */
12
+ class OrderResource {
13
+ constructor(snapshot, locale = 'az') {
14
+ this.snapshot = snapshot;
15
+ this.locale = locale;
16
+ // Связи товаров позиций — как Eloquent грузит `products.product.modifications`,
17
+ // `products.product.modifierSets.set|modifiers`. Одни на весь ответ: список чеков
18
+ // делит справочник, и повторно он не читается.
19
+ this.relations = new ProductRelations(snapshot.db, locale);
20
+ }
21
+
22
+ text(value) {
23
+ return translated(value, this.locale);
24
+ }
25
+
26
+ /** Связи товаров всех чеков списка одним заходом, до сборки по одному. */
27
+ async preload(orders) {
28
+ await this.relations.load(orders.flatMap((order) => order.products.map((item) => item.product)));
29
+ }
30
+
31
+ async order(order) {
32
+ const rel = await this.relations.load(order.products.map((item) => item.product));
33
+
34
+ // should_send_to_kitchen считается на лету: колонка отражает намерение, а расхождение
35
+ // quantity/workshop_print_quantity — реальное состояние позиций.
36
+ let shouldSendToKitchen = false;
37
+ for (const item of order.products) {
38
+ if (item.quantity != item.workshop_print_quantity) {
39
+ shouldSendToKitchen = true;
40
+ break;
41
+ }
42
+ }
43
+
44
+ return {
45
+ id: order.id,
46
+ number: order.number,
47
+ opened_at: order.opened_at,
48
+ closed_at: order.closed_at,
49
+ guest_count: order.guest_count,
50
+ comment: order.comment,
51
+ subtotal: round2(order.subtotal),
52
+ // decimal(2,0) без каста — PDO отдаёт строкой.
53
+ service_charge: order.service_charge === null || order.service_charge === undefined
54
+ ? null
55
+ : String(order.service_charge),
56
+ deposit_amount: round2(order.deposit_amount),
57
+ deposit_gap: round2(order.deposit_gap),
58
+ discount_amount: round2(order.discount_amount),
59
+ total: round2(order.total),
60
+ is_closed_without_payment: toBool(order.is_closed_without_payment),
61
+ fiscal_id: order.fiscal_id,
62
+ fiscal_url: order.fiscal_id,
63
+ without_payment_reason: order.without_payment_reason,
64
+ customers: order.customers,
65
+ client_id: order.client_id,
66
+ table_id: order.table_id,
67
+ hall_id: order.hall_id,
68
+ team_id: order.team_id,
69
+ company_id: order.company_id,
70
+ terminal_id: order.terminal_id,
71
+ order_source_id: order.order_source_id,
72
+ order_source_type: order.source ? order.source.type : null,
73
+ order_source_name: order.source ? this.text(order.source.name) : null,
74
+ opened_by_id: order.opened_by_id,
75
+ closed_by_id: order.closed_by_id,
76
+ serving_id: order.serving_id,
77
+ serving_name: order.waiter ? order.waiter.user_name : null,
78
+ // Каст 'datetime'.
79
+ printed_at: isoDate(order.printed_at),
80
+ should_send_to_kitchen: toBool(order.should_send_to_kitchen) || shouldSendToKitchen,
81
+ service_charge_amount: round2(order.service_charge_amount),
82
+ discount_type: order.discount_type,
83
+ discount: order.discount,
84
+ shift_id: order.shift_id,
85
+ slot_index: order.slot_index,
86
+ takeaway: null,
87
+ delivery: null,
88
+ activities: [],
89
+ payments: order.payments,
90
+ products: order.products.map((item) => this.orderProduct(item, rel)),
91
+ rrn: order.rrn,
92
+ };
93
+ }
94
+
95
+ orderProduct(item, rel) {
96
+ const modifiers = item.modifiers.map((modifier) => this.orderProductModifier(modifier));
97
+
98
+ const groupedModifiers = {};
99
+ for (const modifier of modifiers) {
100
+ if (!groupedModifiers[modifier.modifier_set_id]) {
101
+ groupedModifiers[modifier.modifier_set_id] = {
102
+ title: modifier.modifier_set_title,
103
+ type: modifier.modifier_set_type,
104
+ children: [],
105
+ };
106
+ }
107
+ groupedModifiers[modifier.modifier_set_id].children.push(modifier);
108
+ }
109
+
110
+ const product = item.product || null;
111
+
112
+ return {
113
+ id: item.id,
114
+ price: round2(item.price),
115
+ product_id: item.product_id,
116
+ guest_number: item.guest_number,
117
+ // 3 знака — вес весовых товаров хранится в кг с точностью до грамма
118
+ quantity: item.quantity,
119
+ subtotal: round2(item.subtotal),
120
+ discount_type: item.discount_type,
121
+ discount_amount: item.discount_amount,
122
+ comment: item.comment,
123
+ discount: item.discount,
124
+ price_after_service_charge: item.price_after_service_charge,
125
+ total: item.total,
126
+ type: item.type,
127
+ is_stopped: item.is_stopped,
128
+ is_fixed: toBool(item.is_fixed),
129
+ stopped_at: item.stopped_at,
130
+ paused_seconds: item.paused_seconds || 0,
131
+ timer_limit_minutes: item.timer_limit_minutes,
132
+ elapsed_total_minutes: item.elapsed_total_minutes,
133
+ order_id: item.order_id,
134
+ added_at: item.added_at,
135
+ updated_at: isoDate(item.updated_at),
136
+ modified_at: item.modified_at,
137
+ is_gift: toBool(item.is_gift),
138
+ receipt_print_quantity: item.receipt_print_quantity,
139
+ service_charge_amount: item.service_charge_amount,
140
+ service_charge: item.service_charge,
141
+ total_workshop_print_quantity: item.total_workshop_print_quantity,
142
+ workshop_print_quantity: item.workshop_print_quantity,
143
+ max_discount_amount: item.max_discount_amount,
144
+ // Пустые модификаторы — именно объект, а не массив: фронт обращается по ключу.
145
+ modifiers: modifiers.length
146
+ ? Object.fromEntries(modifiers.map((modifier) => [modifier.modifier_set_modifier_id, modifier]))
147
+ : {},
148
+ // PHP-массив: пустой в JSON — `[]`, с ключами наборов — объект.
149
+ grouped_modifiers: Object.keys(groupedModifiers).length ? groupedModifiers : [],
150
+ name: product ? this.text(product.full_name) : 'Silinən',
151
+ // Весовой товар: quantity — вес в кг, price — цена за 1 кг
152
+ is_weight_item: Boolean(product && toBool(product.is_weight_item)),
153
+ unit_id: item.unit_id,
154
+ // ProductModifierSetResource::collection($this->product?->modifierSets ?? []).
155
+ modifier_sets: product
156
+ ? (rel.modifierSets.get(String(product.id)) || []).map((set) => productModifierSetResource(set, rel))
157
+ : [],
158
+ product: product ? productResource(product, rel) : null,
159
+ };
160
+ }
161
+
162
+ orderProductModifier(modifier) {
163
+ return {
164
+ modifier_set_id: modifier.modifier_set_id,
165
+ modifier_set_title: modifier.modifier_set_title,
166
+ modifier_set_type: modifier.modifier_set_type,
167
+ name: modifier.name,
168
+ price: modifier.price,
169
+ total: modifier.total,
170
+ quantity: modifier.quantity,
171
+ modifier_set_modifier_id: modifier.modifier_set_modifier_id,
172
+ selected: modifier.modifier_set_type === 'single',
173
+ };
174
+ }
175
+ }
176
+
177
+ module.exports = OrderResource;
@@ -0,0 +1,186 @@
1
+ const {money, round2, toBool} = require('../support/num');
2
+ const {phpArray} = require('../support/php');
3
+ const {trans, transArray} = require('../support/lang');
4
+ const {GSTATIC, castImage} = require('../support/images');
5
+ const {nullableBool, modelArray} = require('./casts');
6
+
7
+ /**
8
+ * Порты ProductResource, ProductModifierSetResource, ModifierSetModifierResource и
9
+ * ProductCategoryResource — справочник товаров app.init / products.get. Функции чистые: связи
10
+ * (модификации, наборы модификаторов, категории, картинки) пакетно собирает ProductRelations.
11
+ *
12
+ * json-колонки без каста (`config`, `gross`) приходят текстом, как их отдаёт PDO.
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} ProductRelations
17
+ * @property {Map<string, object[]>} modifications parent_id → строки модификаций (ORDER BY price)
18
+ * @property {Map<string, object[]>} modifierSets product_id → product_modifier_sets
19
+ * @property {Map<string, object>} sets id → modifier_sets
20
+ * @property {Map<string, object[]>} modifiers modifier_set_id → modifier_set_modifiers
21
+ * @property {Map<string, object>} categories id → product_categories (для мутатора цеха)
22
+ * @property {?Map<string, string>} urls id картинки → URL; null — таблицы images нет (касса)
23
+ * @property {string} locale
24
+ */
25
+
26
+ const modifierSetModifierResource = (modifier, {urls, locale}) => ({
27
+ id: modifier.id,
28
+ product_id: modifier.product_id,
29
+ type: modifier.type,
30
+ name: trans(modifier.name, locale),
31
+ // Каст 'boolean': в SQLite 0/1.
32
+ is_without_debit: nullableBool(modifier.is_without_debit),
33
+ min: modifier.min,
34
+ max: modifier.max,
35
+ // UnitableCast отдаёт значение как есть — numeric строкой.
36
+ gross: modifier.gross ?? null,
37
+ image: castImage(urls, modifier.image),
38
+ default_count: modifier.default_count,
39
+ default: nullableBool(modifier.default),
40
+ price: money(modifier.price),
41
+ });
42
+
43
+ const productModifierSetResource = (item, rel) => {
44
+ const set = rel.sets.get(String(item.modifier_set_id)) || null;
45
+ const title = trans(item.title, rel.locale);
46
+ const description = trans(item.description, rel.locale);
47
+ return {
48
+ id: item.id,
49
+ min: set ? set.min : null,
50
+ max: set ? set.max : null,
51
+ title: title ?? (set ? trans(set.name, rel.locale) : ''),
52
+ type: set ? set.type : null,
53
+ modifier_set_id: set ? set.id : item.modifier_set_id,
54
+ config: item.config ?? (set ? set.config ?? null : []),
55
+ description: description ?? (set ? trans(set.description, rel.locale) : ''),
56
+ modifiers: (rel.modifiers.get(String(item.modifier_set_id)) || [])
57
+ .map((modifier) => modifierSetModifierResource(modifier, rel)),
58
+ };
59
+ };
60
+
61
+ /** Product::productWorkshopId — ignore_workshop гасит цех, пустой берётся у категории. */
62
+ const productWorkshopId = (product, categories) => {
63
+ if (toBool(product.ignore_workshop)) {
64
+ return null;
65
+ }
66
+ if (!product.product_workshop_id) {
67
+ const category = product.product_category_id ? categories.get(String(product.product_category_id)) : null;
68
+ return category ? category.product_workshop_id ?? null : null;
69
+ }
70
+ return product.product_workshop_id;
71
+ };
72
+
73
+ /**
74
+ * modifier_sets_form: по id модификатора. Набор удалён (set = null) — PHP читает null->type,
75
+ * это не 'single'. Колонки `total` у модификатора нет — в PHP там всегда 0.
76
+ */
77
+ const modifierSetsForm = (sets, rel) => {
78
+ const form = {};
79
+ for (const item of sets) {
80
+ const set = rel.sets.get(String(item.modifier_set_id)) || null;
81
+ for (const modifier of rel.modifiers.get(String(item.modifier_set_id)) || []) {
82
+ const total = modifier.total;
83
+ form[modifier.id] = {
84
+ quantity: set && set.type === 'single' ? (toBool(modifier.default) ? 1 : 0) : modifier.default_count,
85
+ name: trans(modifier.name, rel.locale),
86
+ max: modifier.max,
87
+ min: modifier.min,
88
+ total: total !== null && total !== undefined && Number(total) < 0 ? total : 0,
89
+ price: money(modifier.price),
90
+ modifier_set_modifier_id: modifier.id,
91
+ modifier_set_id: item.modifier_set_id,
92
+ };
93
+ }
94
+ }
95
+ return Object.keys(form).length ? form : [];
96
+ };
97
+
98
+ /**
99
+ * @param {object} product строка products
100
+ * @param {ProductRelations} rel
101
+ * @param {boolean} top верхний уровень: связи modifications / modifierSets загружены только у него
102
+ */
103
+ const productResource = (product, rel, top = true) => {
104
+ const children = top ? rel.modifications.get(String(product.id)) || [] : [];
105
+ const sets = top ? rel.modifierSets.get(String(product.id)) || [] : [];
106
+ const result = {
107
+ id: product.id,
108
+ full_name: trans(product.full_name, rel.locale),
109
+ name: trans(product.name, rel.locale),
110
+ color: product.color ?? null,
111
+ price: round2(money(product.price)),
112
+ };
113
+ // whenLoaded: у модификаций ключей modifications и modifier_sets нет вовсе.
114
+ if (top) {
115
+ result.modifications = children.map((child) => productResource(child, rel, false));
116
+ }
117
+ result.product_category_id = product.product_category_id ?? null;
118
+ result.product_workshop_id = productWorkshopId(product, rel.categories);
119
+ if (top) {
120
+ result.modifier_sets = sets.map((item) => productModifierSetResource(item, rel));
121
+ }
122
+ Object.assign(result, {
123
+ modifier_sets_form: top ? modifierSetsForm(sets, rel) : [],
124
+ type: product.type,
125
+ image: castImage(rel.urls, product.image),
126
+ unit_id: product.unit_id ?? null,
127
+ is_weight_item: toBool(product.is_weight_item),
128
+ is_with_modification: nullableBool(product.is_with_modification),
129
+ is_hidden: nullableBool(product.is_hidden),
130
+ is_available: nullableBool(product.is_available),
131
+ is_active: nullableBool(product.is_active),
132
+ is_modification: product.parent_id !== null && product.parent_id !== undefined,
133
+ order: product.order ?? null,
134
+ parent_id: product.parent_id ?? null,
135
+ _modifications_name: top
136
+ ? children.map((child) => trans(child.full_name, rel.locale) ?? '').join(', ')
137
+ : '',
138
+ data: phpArray(product.data ?? null),
139
+ });
140
+ return result;
141
+ };
142
+
143
+ /**
144
+ * ProductCategoryResource = `$category->toArray()`: колонки в порядке строки, переводимые поля
145
+ * через каст spatie, картинка — URL, в конце append `thumbnail_url`.
146
+ */
147
+ const categoryResource = (category, {urls, locale}) => {
148
+ const result = modelArray(category, {
149
+ name: (value) => transArray(value, locale),
150
+ description: (value) => transArray(value, locale),
151
+ image: (value) => castImage(urls, value),
152
+ });
153
+ result.thumbnail_url = result.image || GSTATIC;
154
+ return result;
155
+ };
156
+
157
+ /**
158
+ * `products.getExtraProducts` / `orderProducts.getExtraProducts`: select id, full_name,
159
+ * product_workshop_id, price + appends модели (thumbnail_url, in_stock, is_can_comment).
160
+ * Картинка не выбрана — thumbnail всегда заглушка. Мутатор цеха без ignore_workshop в select
161
+ * видит null и берёт колонку, а без неё идёт в категорию по NULL product_category_id → null.
162
+ * Этот поход лениво грузит связь `category` (null), и toArray выводит её последним ключом.
163
+ */
164
+ const extraProductResource = (row, locale) => {
165
+ const result = {
166
+ id: row.id,
167
+ full_name: transArray(row.full_name, locale),
168
+ product_workshop_id: row.product_workshop_id || null,
169
+ price: money(row.price),
170
+ thumbnail_url: GSTATIC,
171
+ in_stock: true,
172
+ is_can_comment: true,
173
+ };
174
+ if (!row.product_workshop_id) {
175
+ result.category = null;
176
+ }
177
+ return result;
178
+ };
179
+
180
+ module.exports = {
181
+ productResource,
182
+ productModifierSetResource,
183
+ modifierSetModifierResource,
184
+ categoryResource,
185
+ extraProductResource,
186
+ };
@@ -0,0 +1,42 @@
1
+ const {money} = require('../support/num');
2
+ const {isoDate, formatInZone} = require('../support/php');
3
+ const {modelArray} = require('./casts');
4
+
5
+ /**
6
+ * ShiftResource = Shift::toArray(). Касты модели: minutes — integer, суммы — MoneyCast
7
+ * (он вызывается и для null, поэтому null → 0), даты — ISO.
8
+ *
9
+ * Белый список нужен локальной SQLite: там у смены есть служебные колонки синка, наружу они
10
+ * не уходят. Порядок — как в строке.
11
+ */
12
+ const SHIFT_COLUMNS = new Set([
13
+ 'id', 'team_id', 'started_at', 'ended_at', 'type', 'start_amount', 'end_amount', 'minutes',
14
+ 'opened_by', 'closed_by', 'company_id', 'deleted_at', 'created_at', 'updated_at',
15
+ ]);
16
+
17
+ const toInteger = (value) => (value === null || value === undefined ? null : parseInt(value, 10));
18
+
19
+ const SHIFT_CASTS = {
20
+ minutes: toInteger,
21
+ start_amount: money,
22
+ end_amount: money,
23
+ started_at: isoDate,
24
+ ended_at: isoDate,
25
+ };
26
+
27
+ const shiftResource = (row) => (row ? modelArray(row, SHIFT_CASTS, SHIFT_COLUMNS) : null);
28
+
29
+ /**
30
+ * ShiftController::getCashShifts — `{<id>: {id, label}}`, подпись в зоне команды.
31
+ * Строки уже отсортированы запросом (started_at DESC). Пустой PHP-массив в JSON — `[]`.
32
+ */
33
+ const cashShiftLabels = (rows, timeZone) => {
34
+ const data = {};
35
+ for (const row of rows) {
36
+ const ended = row.ended_at ? formatInZone(row.ended_at, timeZone) : 'Bağlanmayıb';
37
+ data[row.id] = {id: row.id, label: `${formatInZone(row.started_at, timeZone)} - ${ended}`};
38
+ }
39
+ return Object.keys(data).length ? data : [];
40
+ };
41
+
42
+ module.exports = {SHIFT_COLUMNS, shiftResource, cashShiftLabels};
@@ -0,0 +1,117 @@
1
+ const {parse, now} = require('../support/time');
2
+ const {isoDate, phpArray} = require('../support/php');
3
+ const {trans} = require('../support/lang');
4
+ const {castImage} = require('../support/images');
5
+ const {teamSettings, hasTaxIntegration} = require('../support/teamSettings');
6
+ const pin = require('../support/pin');
7
+ const {nullableBool} = require('./casts');
8
+ const {shiftResource} = require('./ShiftResource');
9
+
10
+ /**
11
+ * Порты TeamResource, TerminalResource и TeamUserResource — шапка app.init. Функции чистые:
12
+ * всё, что Laravel догружает связями, приходит готовым (InitService собирает пакетно).
13
+ *
14
+ * Отсутствующая связь (удалённая валюта, язык, роль, пользователь) в PHP роняет весь init в 500.
15
+ * Здесь поле становится null: касса стартует, а поле фронт и так читает с `?.`.
16
+ */
17
+
18
+ /**
19
+ * @param {object} team строка teams
20
+ * @param {{currencySymbol: ?string, languageCode: ?string, urls: Map, locale: string}} rel
21
+ */
22
+ const teamResource = (team, {currencySymbol = null, languageCode = null, urls = new Map(), locale}) => {
23
+ // Carbon::parse(null) — «сейчас»: без срока команда не просрочена.
24
+ const expiresAt = parse(team.expires_at) || now();
25
+ return {
26
+ id: team.id,
27
+ name: trans(team.name, locale),
28
+ currency: {symbol: currencySymbol},
29
+ is_expired: now().getTime() > expiresAt.getTime(),
30
+ // Carbon в JSON — toISOString() в UTC, часовой пояс Asia/Baku на это не влияет.
31
+ expires_at: isoDate(expiresAt),
32
+ is_trial: nullableBool(team.is_trial),
33
+ is_developer_account: nullableBool(team.is_developer_account),
34
+ logo: castImage(urls, team.logo),
35
+ settings: teamSettings(team.settings, currencySymbol),
36
+ timezone: team.timezone ?? null,
37
+ // Без каста — строка из БД как есть.
38
+ trial_ends_at: team.trial_ends_at ?? null,
39
+ shift_ends_at: team.shift_ends_at ?? null,
40
+ language: languageCode,
41
+ has_tax_integration: hasTaxIntegration(team.settings),
42
+ };
43
+ };
44
+
45
+ const OFFLINE_MODES = ['off', 'standalone', 'hall'];
46
+
47
+ /**
48
+ * Порт OfflineSetupService::forTerminal (Laravel): офлайн-режим кассы из суперадминки — то, что
49
+ * касса записывает в секцию offline своего config.json. null — заведение офлайном не управляет.
50
+ * Правила меняются в обоих местах сразу.
51
+ *
52
+ * @param {object} terminal строка terminals
53
+ * @param {?object} company строка companies (offline_mode, offline_master_terminal_id, offline_lan_key, offline_lan_front)
54
+ */
55
+ const offlineConfig = (terminal, company) => {
56
+ const mode = company ? company.offline_mode : null;
57
+ if (!OFFLINE_MODES.includes(mode)) {
58
+ return null;
59
+ }
60
+ if (mode === 'off') {
61
+ return {enabled: false};
62
+ }
63
+ if (mode === 'hall' && company.offline_master_terminal_id && company.offline_lan_key) {
64
+ const isMaster = company.offline_master_terminal_id === terminal.id;
65
+ return {
66
+ enabled: true,
67
+ role: isMaster ? 'master' : 'follower',
68
+ master_url: isMaster ? null : 'auto',
69
+ lan_front: isMaster && Boolean(nullableBool(company.offline_lan_front)),
70
+ lan_key: company.offline_lan_key,
71
+ };
72
+ }
73
+ return {enabled: true, role: 'standalone', master_url: null, lan_front: false};
74
+ };
75
+
76
+ /**
77
+ * @param {object} terminal строка terminals
78
+ * @param {{companyName: ?string, workshops: Array<{id, name}>, posMode: object, company: ?object}} rel
79
+ */
80
+ const terminalResource = (terminal, {companyName = null, workshops = [], posMode, company = null}) => ({
81
+ id: terminal.id,
82
+ name: terminal.name,
83
+ is_active: nullableBool(terminal.is_active),
84
+ is_primary: nullableBool(terminal.is_primary),
85
+ company_id: terminal.company_id,
86
+ company_name: companyName,
87
+ type: terminal.type,
88
+ workshops: workshops.map((workshop) => ({id: workshop.id, name: workshop.name})),
89
+ // Каст 'array', `?? []`.
90
+ settings: phpArray(terminal.settings) ?? [],
91
+ pos_mode: posMode,
92
+ offline: offlineConfig(terminal, company),
93
+ });
94
+
95
+ /**
96
+ * Смены берутся только из аргументов конструктора TeamUserResource ($cashShift, $workShift):
97
+ * `TeamUserResource::collection` (app.init) создаёт ресурс без них — там обе всегда null,
98
+ * eager-load workShift в ответ не попадает. Со сменами ресурс строит только auth.unlock.
99
+ *
100
+ * @param {object} member строка team_users
101
+ * @param {{user: ?object, role: ?object, scopes: object, cashShift: ?object, workShift: ?object, teamOwnerId: ?string, teamId: ?string}} rel
102
+ */
103
+ const teamUserResource = (member, {user = null, role = null, scopes = {}, cashShift = null, workShift = null, teamOwnerId = null, teamId = null}) => ({
104
+ id: member.id,
105
+ ...pin.fields(member.pin ?? null, member.ic_card ?? null, member.team_id ?? teamId),
106
+ name: user ? user.name : null,
107
+ role_name: role ? role.name : null,
108
+ role_id: role ? role.id : null,
109
+ scopes: Object.keys(scopes).length ? scopes : [],
110
+ can_work_with_terminal: role ? nullableBool(role.can_work_with_terminal) : null,
111
+ is_owner: Boolean(teamOwnerId) && member.user_id === teamOwnerId,
112
+ is_courier: role ? nullableBool(role.is_courier) : null,
113
+ cash_shift: shiftResource(cashShift),
114
+ shift: shiftResource(workShift),
115
+ });
116
+
117
+ module.exports = {teamResource, terminalResource, teamUserResource, offlineConfig};
@@ -0,0 +1,55 @@
1
+ const {isoDate} = require('../support/php');
2
+ const {toBool} = require('../support/num');
3
+
4
+ /**
5
+ * Общие касты для ресурсов, которые повторяют `$model->toArray()` Laravel: ключи в порядке
6
+ * колонок строки, даты модели — ISO, остальное — как лежит в БД, если каст не задан.
7
+ */
8
+ const DATE_COLUMNS = new Set(['created_at', 'updated_at', 'deleted_at']);
9
+
10
+ /** Каст 'boolean': null остаётся null, остальное — bool (SQLite отдаёт 0/1). */
11
+ const nullableBool = (value) => (value === null || value === undefined ? null : toBool(value));
12
+
13
+ /**
14
+ * @param {object} row строка БД
15
+ * @param {object} casts {колонка: fn(value, row)} — поверх дат по умолчанию
16
+ * @param {Set<string>|null} only белый список колонок (null — все)
17
+ */
18
+ const modelArray = (row, casts = {}, only = null) => {
19
+ const result = {};
20
+ for (const [key, value] of Object.entries(row)) {
21
+ if (only && !only.has(key)) {
22
+ continue;
23
+ }
24
+ const cast = casts[key] || (DATE_COLUMNS.has(key) ? isoDate : null);
25
+ result[key] = cast ? cast(value, row) : value;
26
+ }
27
+ return result;
28
+ };
29
+
30
+ /**
31
+ * json-колонки Postgres выбираются дополнительно как `col::text AS "col__text"` (см.
32
+ * `jsonText`): модели без каста отдают их строкой, как в БД. Текст ставится на место колонки,
33
+ * порядок ключей не меняется.
34
+ */
35
+ const TEXT_SUFFIX = '__text';
36
+
37
+ const takeText = (row, columns) => {
38
+ for (const column of columns) {
39
+ const key = column + TEXT_SUFFIX;
40
+ if (Object.prototype.hasOwnProperty.call(row, key)) {
41
+ row[column] = row[key];
42
+ delete row[key];
43
+ } else if (row[column] !== null && typeof row[column] === 'object') {
44
+ row[column] = JSON.stringify(row[column]);
45
+ }
46
+ }
47
+ return row;
48
+ };
49
+
50
+ /** Доп. колонки для SELECT: в SQLite json и так строка — ничего не добавляем. */
51
+ const jsonText = (db, columns, alias = '') => (db.dialect === 'pgsql'
52
+ ? columns.map((column) => `, ${alias}${column}::text AS "${column}${TEXT_SUFFIX}"`).join('')
53
+ : '');
54
+
55
+ module.exports = {DATE_COLUMNS, nullableBool, modelArray, takeText, jsonText};