@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,179 @@
1
+ const {imageDataUri} = require('../support/imageData');
2
+
3
+ /**
4
+ * Чтения для печати: принтеры, цеха, терминалы, команда. Ничего не пишет — справочники
5
+ * наполняет синк снапшота (локально) или бэк-офис (облако).
6
+ *
7
+ * soft delete: в облаке у terminals/teams/product_workshops/payment_methods есть deleted_at, и
8
+ * Eloquent отсекает удалённые сам. В локальном снапшоте этих колонок нет вовсе (удалённое туда
9
+ * не синкается), поэтому условие добавляется только под Postgres — иначе SQLite упадёт на
10
+ * несуществующей колонке. У printers deleted_at есть в обеих схемах.
11
+ */
12
+ class PrinterRepository {
13
+ constructor(db) {
14
+ this.db = db;
15
+ this.cache = new Map();
16
+ }
17
+
18
+ notDeleted(alias = null) {
19
+ if (this.db.dialect !== 'pgsql') {
20
+ return '';
21
+ }
22
+ return ` AND ${alias ? `${alias}.` : ''}deleted_at IS NULL`;
23
+ }
24
+
25
+ async cached(key, resolver) {
26
+ if (this.cache.has(key)) {
27
+ return this.cache.get(key);
28
+ }
29
+ const value = await resolver();
30
+ this.cache.set(key, value);
31
+ return value;
32
+ }
33
+
34
+ async findTeam(id) {
35
+ if (!id) {
36
+ return null;
37
+ }
38
+ return this.cached(`team:${id}`, () =>
39
+ this.db.get(`SELECT * FROM teams WHERE id = ?${this.notDeleted()}`, [id]));
40
+ }
41
+
42
+ /**
43
+ * Логотип команды для счёта — data URI, как ImageService::toBase64. Касса берёт копию,
44
+ * которую привёз снапшот (team_logos). Облако качает файл по публичному адресу диска
45
+ * картинки (`support/imageData.js`, кэш в процессе).
46
+ */
47
+ async findTeamLogo(teamId) {
48
+ if (!teamId) {
49
+ return null;
50
+ }
51
+ if (this.db.dialect === 'pgsql') {
52
+ const team = await this.findTeam(teamId);
53
+ return team && team.logo ? imageDataUri(this.db, team.logo) : null;
54
+ }
55
+ return this.cached(`teamLogo:${teamId}`, async () => {
56
+ const row = await this.db.get('SELECT data FROM team_logos WHERE team_id = ?', [teamId]);
57
+ return row && row.data ? row.data : null;
58
+ });
59
+ }
60
+
61
+ /** Terminal::query()->find($id) — удалённый терминал не находится. */
62
+ async findTerminal(id) {
63
+ if (!id) {
64
+ return null;
65
+ }
66
+ return this.cached(`terminal:${id}`, () =>
67
+ this.db.get(`SELECT * FROM terminals WHERE id = ?${this.notDeleted()}`, [id]));
68
+ }
69
+
70
+ /**
71
+ * Символ валюты команды ($team->currency->symbol из каста настроек чеков). Таблицы
72
+ * currencies и колонки teams.currency_id в локальном снапшоте нет — тогда null, и
73
+ * normalizeReceipts возьмёт символ из сохранённого конфига.
74
+ */
75
+ async findCurrencySymbol(team) {
76
+ if (!team || !team.currency_id) {
77
+ return null;
78
+ }
79
+ try {
80
+ const row = await this.db.get(
81
+ `SELECT symbol FROM currencies WHERE id = ?${this.notDeleted()}`,
82
+ [team.currency_id]
83
+ );
84
+ return row ? row.symbol : null;
85
+ } catch (e) {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** Цеха команды, которым положен бумажный чек: ProductWorkshop::where('print_ticket', true). */
91
+ async findPrintWorkshops(teamId) {
92
+ const rows = await this.db.all(
93
+ `SELECT * FROM product_workshops WHERE print_ticket = ? AND team_id = ?${this.notDeleted()}`,
94
+ [true, teamId]
95
+ );
96
+ return new Map(rows.map((row) => [row.id, row]));
97
+ }
98
+
99
+ /** Позиции, по которым цех ещё не получил чек: `quantity != workshop_print_quantity`. */
100
+ async findPendingOrderProducts(orderId) {
101
+ return this.db.all(
102
+ `SELECT * FROM order_products
103
+ WHERE order_id = ? AND quantity != workshop_print_quantity AND deleted_at IS NULL
104
+ ORDER BY created_at, id`,
105
+ [orderId]
106
+ );
107
+ }
108
+
109
+ /** $team->terminals()->where('company_id', ...) — кандидаты на fallback-поиск принтера. */
110
+ async findCompanyTerminalIds(teamId, companyId) {
111
+ const rows = await this.db.all(
112
+ `SELECT id FROM terminals WHERE team_id = ? AND company_id = ?${this.notDeleted()}`,
113
+ [teamId, companyId]
114
+ );
115
+ return rows.map((row) => row.id);
116
+ }
117
+
118
+ /**
119
+ * Принтеры цеха на указанных терминалах (связь printer_workshop с включённым print_ticket).
120
+ * Берём только колонки printers: у SELECT * по join локальный printer_workshop.synced_at
121
+ * перетёр бы одноимённую колонку принтера. Laravel порядок не задаёт — ставим
122
+ * created_at, id, чтобы очередность заданий не зависела от плана запроса.
123
+ */
124
+ async findWorkshopPrinters(terminalIds, workshopId) {
125
+ const ids = terminalIds.filter(Boolean);
126
+ if (!ids.length) {
127
+ return [];
128
+ }
129
+ const placeholders = ids.map(() => '?').join(', ');
130
+ return this.db.all(
131
+ `SELECT printers.* FROM printers
132
+ LEFT JOIN printer_workshop ON printer_workshop.printer_id = printers.id
133
+ WHERE printers.terminal_id IN (${placeholders})
134
+ AND printer_workshop.product_workshop_id = ?
135
+ AND printer_workshop.print_ticket = ?
136
+ AND printers.deleted_at IS NULL
137
+ ORDER BY printers.created_at, printers.id`,
138
+ [...ids, workshopId, true]
139
+ );
140
+ }
141
+
142
+ /** Принтер счёта терминала — Printer::where('terminal_id')->where('bill', true)->first(). */
143
+ async findBillPrinter(terminalId) {
144
+ if (!terminalId) {
145
+ return null;
146
+ }
147
+ return this.db.get(
148
+ `SELECT * FROM printers
149
+ WHERE terminal_id = ? AND bill = ? AND deleted_at IS NULL
150
+ ORDER BY created_at, id
151
+ LIMIT 1`,
152
+ [terminalId, true]
153
+ );
154
+ }
155
+
156
+ /** Fallback: первый по времени создания принтер счёта в заведении. */
157
+ async findCompanyBillPrinter(companyId) {
158
+ if (!companyId) {
159
+ return null;
160
+ }
161
+ return this.db.get(
162
+ `SELECT * FROM printers
163
+ WHERE bill = ? AND company_id = ? AND deleted_at IS NULL
164
+ ORDER BY created_at, id
165
+ LIMIT 1`,
166
+ [true, companyId]
167
+ );
168
+ }
169
+
170
+ async findPaymentMethod(id) {
171
+ if (!id) {
172
+ return null;
173
+ }
174
+ return this.cached(`paymentMethod:${id}`, () =>
175
+ this.db.get(`SELECT * FROM payment_methods WHERE id = ?${this.notDeleted()}`, [id]));
176
+ }
177
+ }
178
+
179
+ module.exports = PrinterRepository;
@@ -0,0 +1,148 @@
1
+ const {nullableBool, takeText, jsonText} = require('../resources/casts');
2
+ const {imageUrls} = require('../support/images');
3
+ const {unique, groupBy, indexBy, alive, whereIn} = require('../support/rows');
4
+
5
+ /**
6
+ * Связи товаров для ProductResource (`app/core/resources/ProductResource.js`) — то, что Eloquent
7
+ * догружает к `products.product` чека и к справочнику ProductService::allProducts:
8
+ * `modifications` (ORDER BY price), `modifierSets.set`, `modifierSets.modifiers`, категория для
9
+ * мутатора цеха и картинки. Пакетно, отдельным `WHERE … IN (…)` на связь.
10
+ *
11
+ * Накопительный: повторный `load()` догружает только новые товары. Ресурс чека зовёт его на каждый
12
+ * чек и на каждую позицию, а связи общие.
13
+ *
14
+ * `modifier_sets` и `product_modifier_sets` Laravel читает без ORDER BY — порядок кучи Postgres.
15
+ * В SQLite это порядок вставки снапшота, а снапшот пишет строки в том порядке, в каком их отдало
16
+ * облако.
17
+ */
18
+ class ProductRelations {
19
+ /**
20
+ * @param {object} db драйвер ядра
21
+ * @param {string} locale
22
+ */
23
+ constructor(db, locale) {
24
+ this.db = db;
25
+ this.pgsql = db.dialect === 'pgsql';
26
+ /** @type {import('../resources/ProductResource').ProductRelations} */
27
+ this.rel = {
28
+ modifications: new Map(),
29
+ modifierSets: new Map(),
30
+ sets: new Map(),
31
+ modifiers: new Map(),
32
+ categories: new Map(),
33
+ // Таблицы images в снапшоте нет: на кассе картинка остаётся значением колонки.
34
+ urls: this.pgsql ? new Map() : null,
35
+ locale,
36
+ };
37
+ this.loadedProducts = new Set();
38
+ this.loadedSets = new Set();
39
+ this.loadedCategories = new Set();
40
+ }
41
+
42
+ /**
43
+ * @param {object[]} products строки products (null пропускаются)
44
+ * @returns {Promise<object>} rel для productResource
45
+ */
46
+ async load(products) {
47
+ const fresh = [];
48
+ for (const product of products) {
49
+ if (product && !this.loadedProducts.has(String(product.id))) {
50
+ this.loadedProducts.add(String(product.id));
51
+ fresh.push(product);
52
+ }
53
+ }
54
+ if (!fresh.length) {
55
+ return this.rel;
56
+ }
57
+ const db = this.db;
58
+ const ids = unique(fresh.map((product) => product.id));
59
+
60
+ const [modifications, modifierSets] = await Promise.all([
61
+ whereIn(db, `SELECT * FROM products WHERE parent_id IN (:ids)${alive(db)} ORDER BY price`, ids),
62
+ whereIn(db, `SELECT *${jsonText(db, ['config'])} FROM product_modifier_sets WHERE product_id IN (:ids)${alive(db)}`, ids),
63
+ ]);
64
+ modifierSets.forEach((row) => takeText(row, ['config']));
65
+ merge(this.rel.modifications, groupBy(modifications, 'parent_id'));
66
+ merge(this.rel.modifierSets, groupBy(modifierSets, 'product_id'));
67
+
68
+ const modifiers = await this.loadSets(modifierSets);
69
+ const all = [...fresh, ...modifications];
70
+ await Promise.all([
71
+ this.loadCategories(all),
72
+ this.loadImages([...all.map((product) => product.image), ...modifiers.map((modifier) => modifier.image)]),
73
+ ]);
74
+ return this.rel;
75
+ }
76
+
77
+ async loadSets(modifierSets) {
78
+ const db = this.db;
79
+ const setIds = unique(modifierSets.map((row) => row.modifier_set_id)).filter((id) => !this.loadedSets.has(id));
80
+ if (!setIds.length) {
81
+ return [];
82
+ }
83
+ setIds.forEach((id) => this.loadedSets.add(id));
84
+ const [sets, modifiers] = await Promise.all([
85
+ whereIn(db, `SELECT *${jsonText(db, ['config'])} FROM modifier_sets WHERE id IN (:ids)${alive(db)}`, setIds),
86
+ // hasManyThrough(ModifierSetModifier, ModifierSet): набор отсекается и по своему deleted_at.
87
+ whereIn(
88
+ db,
89
+ `SELECT msm.*${jsonText(db, ['gross'], 'msm.')}, ms.id AS laravel_through_key
90
+ FROM modifier_set_modifiers msm
91
+ INNER JOIN modifier_sets ms ON ms.id = msm.modifier_set_id
92
+ WHERE ms.id IN (:ids)${alive(db, 'msm')}${alive(db, 'ms')}`,
93
+ setIds
94
+ ),
95
+ ]);
96
+ sets.forEach((row) => takeText(row, ['config']));
97
+ modifiers.forEach((row) => {
98
+ takeText(row, ['gross']);
99
+ // decimal(20,3) без каста PDO отдаёт строкой '0.000'; в SQLite колонка REAL.
100
+ if (typeof row.gross === 'number') {
101
+ row.gross = row.gross.toFixed(3);
102
+ }
103
+ });
104
+ for (const [id, row] of indexBy(sets)) {
105
+ this.rel.sets.set(id, row);
106
+ }
107
+ merge(this.rel.modifiers, groupBy(modifiers, 'laravel_through_key'));
108
+ return modifiers;
109
+ }
110
+
111
+ /** Мутатор product_workshop_id: пустой цех берётся у категории (без удалённых). */
112
+ async loadCategories(products) {
113
+ const db = this.db;
114
+ const categoryIds = unique(products
115
+ .filter((product) => !product.product_workshop_id && !nullableBool(product.ignore_workshop))
116
+ .map((product) => product.product_category_id))
117
+ .filter((id) => !this.loadedCategories.has(id));
118
+ if (!categoryIds.length) {
119
+ return;
120
+ }
121
+ categoryIds.forEach((id) => this.loadedCategories.add(id));
122
+ const rows = await whereIn(db, `SELECT id, product_workshop_id FROM product_categories WHERE id IN (:ids)${alive(db)}`, categoryIds);
123
+ for (const [id, row] of indexBy(rows)) {
124
+ this.rel.categories.set(id, row);
125
+ }
126
+ }
127
+
128
+ async loadImages(values) {
129
+ if (!this.rel.urls) {
130
+ return;
131
+ }
132
+ const missing = values.filter((value) => value && !this.rel.urls.has(String(value)));
133
+ if (!missing.length) {
134
+ return;
135
+ }
136
+ for (const [id, url] of await imageUrls(this.db, missing)) {
137
+ this.rel.urls.set(id, url);
138
+ }
139
+ }
140
+ }
141
+
142
+ const merge = (target, source) => {
143
+ for (const [key, rows] of source) {
144
+ target.set(key, [...(target.get(key) || []), ...rows]);
145
+ }
146
+ };
147
+
148
+ module.exports = ProductRelations;
@@ -0,0 +1,197 @@
1
+ const {toBool} = require('../support/num');
2
+
3
+ /**
4
+ * Чтение справочников (в локальном контуре — снапшот, в облаке — те же таблицы Laravel).
5
+ * Ничего не пишет: наполнение это задача синка вниз / бэк-офиса.
6
+ *
7
+ * Справочники в пределах одного запроса не меняются, поэтому чтения кэшируются на экземпляр:
8
+ * расчёт чека дёргает findProduct десятки раз, и в облаке каждый такой вызов — сетевой
9
+ * round-trip к Postgres.
10
+ */
11
+ class SnapshotRepository {
12
+ constructor(db) {
13
+ this.db = db;
14
+ this.cache = new Map();
15
+ }
16
+
17
+ async cached(key, resolver) {
18
+ if (this.cache.has(key)) {
19
+ return this.cache.get(key);
20
+ }
21
+ const value = await resolver();
22
+ this.cache.set(key, value);
23
+ return value;
24
+ }
25
+
26
+ async findProduct(id) {
27
+ if (!id) {
28
+ return null;
29
+ }
30
+ return this.cached(`product:${id}`, () =>
31
+ this.db.get('SELECT * FROM products WHERE id = ?', [id]));
32
+ }
33
+
34
+ async findProducts(ids) {
35
+ if (!ids.length) {
36
+ return [];
37
+ }
38
+ const placeholders = ids.map(() => '?').join(', ');
39
+ return this.db.all(`SELECT * FROM products WHERE id IN (${placeholders})`, ids);
40
+ }
41
+
42
+ /**
43
+ * Открытая кассовая смена — порт `ShiftService::getCashShift` без проверки настройки
44
+ * (её делает вызывающий). Не кэшируется: смену могут закрыть между запросами.
45
+ */
46
+ async findOpenCashShift(teamId, companyId) {
47
+ return this.db.get(
48
+ `SELECT * FROM shifts
49
+ WHERE team_id = ? AND company_id = ? AND type = 'cash' AND ended_at IS NULL
50
+ AND deleted_at IS NULL
51
+ ORDER BY started_at DESC
52
+ LIMIT 1`,
53
+ [teamId, companyId]
54
+ );
55
+ }
56
+
57
+ async findOrderSource(teamId, id) {
58
+ if (!id) {
59
+ return null;
60
+ }
61
+ return this.db.get('SELECT * FROM order_sources WHERE team_id = ? AND id = ?', [teamId, id]);
62
+ }
63
+
64
+ async findOrderSourceByType(teamId, type) {
65
+ return this.db.get('SELECT * FROM order_sources WHERE team_id = ? AND type = ?', [teamId, type]);
66
+ }
67
+
68
+ async findOrderSourceById(id) {
69
+ if (!id) {
70
+ return null;
71
+ }
72
+ return this.cached(`orderSource:${id}`, () =>
73
+ this.db.get('SELECT * FROM order_sources WHERE id = ?', [id]));
74
+ }
75
+
76
+ async findTable(id) {
77
+ if (!id) {
78
+ return null;
79
+ }
80
+ return this.cached(`table:${id}`, () =>
81
+ this.db.get('SELECT * FROM tables WHERE id = ?', [id]));
82
+ }
83
+
84
+ async findHall(id) {
85
+ if (!id) {
86
+ return null;
87
+ }
88
+ return this.cached(`hall:${id}`, () =>
89
+ this.db.get('SELECT * FROM halls WHERE id = ?', [id]));
90
+ }
91
+
92
+ async findTeamUser(id) {
93
+ if (!id) {
94
+ return null;
95
+ }
96
+ return this.db.get('SELECT * FROM team_users WHERE id = ?', [id]);
97
+ }
98
+
99
+ /** serving_name в ресурсе — это имя пользователя за team_user, а не сама запись сотрудника. */
100
+ async findTeamUserWithUser(id) {
101
+ if (!id) {
102
+ return null;
103
+ }
104
+ return this.cached(`teamUserWithUser:${id}`, () => this.db.get(
105
+ `SELECT tu.*, u.name AS user_name
106
+ FROM team_users tu
107
+ LEFT JOIN users u ON u.id = tu.user_id
108
+ WHERE tu.id = ?`,
109
+ [id]
110
+ ));
111
+ }
112
+
113
+ async findCourierRole(teamId) {
114
+ // Сравнение через параметр, а не литерал 1: в Postgres колонка boolean,
115
+ // в SQLite — integer, и драйвер приводит true к 1 сам.
116
+ return this.db.get('SELECT * FROM roles WHERE team_id = ? AND is_courier = ?', [teamId, true]);
117
+ }
118
+
119
+ async findFirstTeamUserByRole(roleId) {
120
+ if (!roleId) {
121
+ return null;
122
+ }
123
+ return this.db.get('SELECT * FROM team_users WHERE role_id = ?', [roleId]);
124
+ }
125
+
126
+ async findUnitByCode(code) {
127
+ return this.cached(`unitByCode:${code}`, () =>
128
+ this.db.get('SELECT * FROM units WHERE code = ?', [code]));
129
+ }
130
+
131
+ async findModifierSet(id) {
132
+ return this.cached(`modifierSet:${id}`, () =>
133
+ this.db.get('SELECT * FROM modifier_sets WHERE id = ?', [id]));
134
+ }
135
+
136
+ /**
137
+ * Laravel берёт первую попавшуюся привязку набора к товару ради заголовка (title),
138
+ * без привязки к конкретному товару. Повторяем — иначе разойдутся подписи в чеке.
139
+ */
140
+ async findProductModifierSetBySet(modifierSetId) {
141
+ return this.cached(`pmsBySet:${modifierSetId}`, () =>
142
+ this.db.get('SELECT * FROM product_modifier_sets WHERE modifier_set_id = ?', [modifierSetId]));
143
+ }
144
+
145
+ /**
146
+ * Есть ли у товара модификатор-таймер. От этого зависит, склеивать ли позицию с уже
147
+ * существующей: две «почасовые» позиции нельзя объединять — у них разное время старта.
148
+ */
149
+ async hasTimerModifier(productId) {
150
+ return this.cached(`hasTimer:${productId}`, async () => {
151
+ const row = await this.db.get(
152
+ `SELECT 1 AS found
153
+ FROM product_modifier_sets pms
154
+ JOIN modifier_set_modifiers msm ON msm.modifier_set_id = pms.modifier_set_id
155
+ WHERE pms.product_id = ? AND msm.type = 'timer'
156
+ LIMIT 1`,
157
+ [productId]
158
+ );
159
+ return Boolean(row);
160
+ });
161
+ }
162
+
163
+ /** Категория для мутатора цеха; удалённая — как belongsTo с SoftDeletes, не находится. */
164
+ async findCategory(id) {
165
+ if (!id) {
166
+ return null;
167
+ }
168
+ const alive = this.db.dialect === 'pgsql' ? ' AND deleted_at IS NULL' : '';
169
+ return this.cached(`category:${id}`, () =>
170
+ this.db.get(`SELECT id, product_workshop_id FROM product_categories WHERE id = ?${alive}`, [id]));
171
+ }
172
+
173
+ /**
174
+ * Мутатор Product::productWorkshopId: `ignore_workshop` гасит цех, пустой берётся у категории.
175
+ * Печать, KDS и отчёт Laravel читают цех только через него.
176
+ */
177
+ async productWorkshopId(product) {
178
+ if (!product || toBool(product.ignore_workshop)) {
179
+ return null;
180
+ }
181
+ if (product.product_workshop_id) {
182
+ return product.product_workshop_id;
183
+ }
184
+ const category = await this.findCategory(product.product_category_id);
185
+ return category ? category.product_workshop_id || null : null;
186
+ }
187
+
188
+ async findWorkshop(id) {
189
+ if (!id) {
190
+ return null;
191
+ }
192
+ return this.cached(`workshop:${id}`, () =>
193
+ this.db.get('SELECT * FROM product_workshops WHERE id = ?', [id]));
194
+ }
195
+ }
196
+
197
+ module.exports = SnapshotRepository;
@@ -0,0 +1,54 @@
1
+ const {money, round2} = require('../support/num');
2
+ const {phpArray, deepZeroOneToBool} = require('../support/php');
3
+ const {nullableBool} = require('./casts');
4
+
5
+ /**
6
+ * Порты HallResource / HallTableResource (HallService::getHalls). Столы приходят готовыми,
7
+ * уже отсортированными по `order`.
8
+ */
9
+
10
+ const isContainer = (value) => value !== null && typeof value === 'object';
11
+
12
+ /**
13
+ * Table::depositConfig — аксессор главнее каста 'array': json_decode + '0'/'1' → bool по всему
14
+ * дереву. Пусто → []. (JSON-скаляр в PHP уронил бы array_map — здесь тоже [].)
15
+ */
16
+ const depositConfig = (raw) => {
17
+ if (!raw) {
18
+ return [];
19
+ }
20
+ let decoded = raw;
21
+ if (typeof raw === 'string') {
22
+ try {
23
+ decoded = JSON.parse(raw);
24
+ } catch (e) {
25
+ return [];
26
+ }
27
+ }
28
+ return isContainer(decoded) ? phpArray(deepZeroOneToBool(decoded)) : [];
29
+ };
30
+
31
+ const hallTableResource = (table) => ({
32
+ id: table.id,
33
+ label: table.label,
34
+ has_deposit: nullableBool(table.has_deposit),
35
+ deposit_amount: round2(money(table.deposit_amount)),
36
+ min_guest_count: table.min_guest_count ?? null,
37
+ max_guest_count: table.max_guest_count ?? null,
38
+ is_reservable: nullableBool(table.is_reservable),
39
+ order: table.order ?? null,
40
+ config: phpArray(table.config),
41
+ data: phpArray(table.data),
42
+ deposit_config: depositConfig(table.deposit_config),
43
+ reserves: [],
44
+ });
45
+
46
+ const hallResource = (hall, tables = []) => ({
47
+ id: hall.id,
48
+ name: hall.name,
49
+ short_name: hall.short_name,
50
+ type: hall.type,
51
+ tables: tables.map(hallTableResource),
52
+ });
53
+
54
+ module.exports = {hallResource, hallTableResource};