@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,146 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const {registerOfflineRoutes, forwardToMaster} = require('../../../app/core');
4
+ const {HttpError} = require('../../../app/core/errors');
5
+ const {money} = require('../../../app/core/support/num');
6
+ const {translated} = require('../../../app/core/support/lang');
7
+ const ShiftService = require('../../../app/core/services/ShiftService');
8
+ const {shiftResource} = require('../../../app/core/resources/ShiftResource');
9
+ const syncConfig = require('../../../app/sync/config');
10
+ const kv = require('../../../app/sync/kv');
11
+ const {unlockThrottle, findUnlockUser, throttleKey} = require('../../../app/core/support/unlock');
12
+
13
+ /**
14
+ * Офлайн-обработчики модели F (OFFLINE-FIRST-PLAN §11): то, без чего касса не поднимется и
15
+ * не начнёт смену при оборванном интернете. Порты AuthController::init/unlock,
16
+ * ShiftController (кассовая смена) и ProductController::getExtraProducts.
17
+ *
18
+ * Все localOnly: пока облако доступно (и всегда без OFFLINE_SYNC) их обслуживает Laravel,
19
+ * ядро включается только в режимах offline/recovering. Только desktop — облачному Node
20
+ * они не нужны.
21
+ *
22
+ * Ведомый терминал зала обслуживает сам лишь app.init (followerLocal): свой blob терминала
23
+ * хранит он, а чеки — мастер. Остальное ядро ведомого пересылает мастеру.
24
+ */
25
+
26
+ const get = (params, name, fallback = null) => {
27
+ const value = params[name];
28
+ return value === undefined || value === null ? fallback : value;
29
+ };
30
+
31
+ const LOCAL_READ = {mutation: false, localOnly: true};
32
+ const LOCAL_MUTATION = {mutation: true, localOnly: true};
33
+
34
+ /** Ответ app.init, сохранённый снапшотом (kv 'init'). Без него офлайн-старт невозможен. */
35
+ const readInit = () => {
36
+ const store = require('../../../app/sync').store();
37
+ const blob = store ? kv.get(store, 'init') : null;
38
+ if (!blob || typeof blob !== 'object') {
39
+ throw new HttpError(503, {
40
+ success: false,
41
+ message: 'Offline data is not ready yet',
42
+ error_code: 'offline_not_ready',
43
+ });
44
+ }
45
+ return blob;
46
+ };
47
+
48
+ /**
49
+ * Снапшот снят для терминала самой кассы. Телефон официанта, ушедший на мастера по LAN
50
+ * (LAN_FRONT), получает init со СВОИМ терминалом из реестра устройств: иначе он стал бы
51
+ * работать от имени кассы. Терминала нет в реестре — честные 503, как без снапшота.
52
+ *
53
+ * `user` телефону не отдаётся: это пользователь кассы вместе с её bearer-токеном
54
+ * (AuthController::init кладёт в user.token токен запроса). Свой пользователь и токен у
55
+ * телефона уже есть — без поля фронт их не трогает.
56
+ */
57
+ const withRequestTerminal = (blob, headers) => {
58
+ if (!syncConfig.isLanFrontEnabled()) {
59
+ return blob;
60
+ }
61
+ const raw = headers ? Object.entries(headers).find(([key]) => key.toLowerCase() === 'terminal-id') : null;
62
+ const terminalId = raw ? (Array.isArray(raw[1]) ? raw[1][0] : raw[1]) : null;
63
+ if (!terminalId || (blob.terminal && blob.terminal.id === terminalId)) {
64
+ return blob;
65
+ }
66
+ const terminal = require('../../../app/sync/lanDevices').terminal(terminalId);
67
+ if (!terminal) {
68
+ throw new HttpError(503, {
69
+ success: false,
70
+ message: 'Offline data is not ready yet',
71
+ error_code: 'offline_not_ready',
72
+ });
73
+ }
74
+ const {user, ...rest} = blob;
75
+ return {...rest, terminal};
76
+ };
77
+
78
+ registerOfflineRoutes(router, '/api', {
79
+ /**
80
+ * AuthController::init — снапшотный blob терминала + живые открытые чеки. Чеки из blob
81
+ * не отдаём никогда: он снят до обрыва, и закрытый с тех пор чек выглядел бы открытым.
82
+ */
83
+ 'app.init': [{...LOCAL_READ, followerLocal: true}, async ({orders}, p, {headers}) => {
84
+ const blob = withRequestTerminal(readInit(), headers);
85
+ if (syncConfig.role() === 'follower') {
86
+ // Чеки живут у мастера. Он недоступен — честная ошибка лучше пустого зала:
87
+ // «свободный» стол, за которым сидят гости, опаснее экрана с ошибкой.
88
+ return {...blob, orders: await forwardToMaster('orders.getOpen', {headers, params: {}})};
89
+ }
90
+ return {...blob, orders: await orders.getOpenOrders()};
91
+ }],
92
+
93
+ /** AuthController::unlock — null, если PIN/карта не подошли. */
94
+ 'auth.unlock': [LOCAL_READ, async ({db, context}, p) => {
95
+ const code = get(p, 'code');
96
+ if (!code) {
97
+ return null;
98
+ }
99
+ const key = throttleKey(context.teamId, context.terminalId);
100
+ unlockThrottle.assertAllowed(key);
101
+ const blob = readInit();
102
+ const user = await findUnlockUser(db, Array.isArray(blob.users) ? blob.users : [], code, blob.pin_salt);
103
+ if (!user) {
104
+ unlockThrottle.fail(key);
105
+ return null;
106
+ }
107
+ unlockThrottle.reset(key);
108
+ const shifts = new ShiftService({db, context});
109
+ const [cashShift, workShift] = await Promise.all([
110
+ shifts.cashShift(),
111
+ shifts.workShift(user.id),
112
+ ]);
113
+ // JsonResource от null сериализуется в [] — повторяем, фронт проверяет именно так.
114
+ return {
115
+ user: {...user, cash_shift: shiftResource(cashShift), shift: shiftResource(workShift)},
116
+ shift: workShift ? shiftResource(workShift) : [],
117
+ cash_shift: cashShift ? shiftResource(cashShift) : [],
118
+ };
119
+ }],
120
+
121
+ /** ShiftController::getCashShifts — как в Laravel, без фильтра по типу смены. */
122
+ 'shifts.getCashShifts': [LOCAL_READ, async (services) => new ShiftService(services).cashShifts()],
123
+
124
+ /** ShiftController::openCashShift. Смена уедет в облако push'ем раньше чеков (sync_dirty). */
125
+ 'shifts.openCashShift': [LOCAL_MUTATION, async (services, p) => new ShiftService(services).openCashShift(p)],
126
+
127
+ /** ShiftController::closeCashShift. Отчёт в Telegram отправит облако, приняв смену push'ем. */
128
+ 'shifts.closeCashShift': [LOCAL_MUTATION, async (services, p) => new ShiftService(services).closeCashShift(p)],
129
+
130
+ /** ProductController::getExtraProducts — «свободные» позиции, name переведён на язык команды. */
131
+ 'products.getExtraProducts': [LOCAL_READ, async ({db, context}) => {
132
+ const rows = await db.all(
133
+ `SELECT id, full_name, product_workshop_id, price FROM products
134
+ WHERE type = 'extra' AND team_id = ? AND deleted_at IS NULL`,
135
+ [context.teamId]
136
+ );
137
+ return rows.map((row) => ({
138
+ id: row.id,
139
+ full_name: translated(row.full_name, context.locale()),
140
+ product_workshop_id: row.product_workshop_id,
141
+ price: row.price === null ? null : money(row.price),
142
+ }));
143
+ }],
144
+ });
145
+
146
+ module.exports = router;
@@ -7,6 +7,10 @@ escpos.USB = require('@alizadefaig/cafetap-escpos-usb');
7
7
  escpos.Network = require('escpos-network');
8
8
  const axios = require('axios');
9
9
  const {getConfig} = require("../../../app/config");
10
+ const monitor = require('../../../app/monitor');
11
+ const printerAddress = require('../../../app/printers/address');
12
+ const core = require('../../../app/core');
13
+ const syncConfig = require('../../../app/sync/config');
10
14
  const appConfig = getConfig();
11
15
 
12
16
  let PROXY_TARGET = appConfig.apiUrl || 'http://localhost:8000';
@@ -94,7 +98,22 @@ const getNetworkDevices = async (timeOut = 1000) => {
94
98
  router.get('/printers.get', async (req, res) => {
95
99
  let usbPrinters = await getUsbPrinters();
96
100
  let networkPrinters = await getNetworkDevices();
101
+ // Скан с MAC (фронт с VITE_PRINTER_MAC): Laravel узнаёт по нему принтер, сменивший IP.
102
+ if (req.query.mac_binding && process.env.PRINTER_MAC_BINDING !== 'false') {
103
+ networkPrinters = await printerAddress.withMacs(networkPrinters);
104
+ }
97
105
  const printers = [...usbPrinters, ...networkPrinters]
106
+ // Сохранённые настройки (цеха, счёт, звонок) без облака отдаёт ядро: своя БД кассы или
107
+ // мастер зала у ведомого. Иначе модалка «Printerlər» офлайн показывала бы голый скан.
108
+ const fromCore = () => core.dispatch('printers.get', {headers: req.headers, params: {printers}});
109
+ if (core.canHandle('printers.get')) {
110
+ try {
111
+ return res.json(await fromCore());
112
+ } catch (e) {
113
+ console.warn('[printers] printers.get from core failed:', e.message);
114
+ return res.json(printers);
115
+ }
116
+ }
98
117
  const headers = { ...req.headers };
99
118
  delete headers['accept-encoding'];
100
119
  axios.post(PROXY_TARGET + '/api/printers.get', {
@@ -111,7 +130,16 @@ router.get('/printers.get', async (req, res) => {
111
130
  }
112
131
  }).then(({data}) => {
113
132
  res.json(data);
114
- }).catch(e => {
133
+ }).catch(async (e) => {
134
+ // Облако не ответило, а касса ещё не перешла в офлайн (нужно два провала подряд):
135
+ // настройки уже лежат в локальной БД со снапшота.
136
+ if (core.isEnabled() && syncConfig.isSyncEnabled()) {
137
+ try {
138
+ return res.json(await fromCore());
139
+ } catch (coreError) {
140
+ console.warn('[printers] printers.get from core failed:', coreError.message);
141
+ }
142
+ }
115
143
  res.json([...usbPrinters, ...networkPrinters])
116
144
  })
117
145
  });
@@ -121,19 +149,261 @@ router.get('/printers.getUsbPrinters', async (req, res) => {
121
149
  res.json(usbPrinters);
122
150
  })
123
151
 
124
- router.post('/printers.print', async (req, res) => {
152
+ /**
153
+ * Честный ответ печати (PRINT_HONEST_ACK=true; по умолчанию — старый путь ниже, как сегодня).
154
+ *
155
+ * Старый путь отвечает success сразу после вызова printer.close(): запись в устройство идёт уже
156
+ * после ответа, а escpos в close() ошибку записи выбрасывает (flush(() => adapter.close(cb))).
157
+ * Потерянный чек рапортуется напечатанным, а следующее задание на тот же принтер стартует
158
+ * поверх незавершённой передачи — фронт закрывает это паузой DEVICE_SETTLE_MS (эвристика).
159
+ *
160
+ * Здесь:
161
+ * - ответ — по колбэку записи (USB: bulk-transfer дошёл до принтера; LAN: байты ушли в сокет);
162
+ * ошибка записи → success:false, error:'write_error';
163
+ * - таймаут `timeout` из запроса — только на открытие устройства (как раньше), на запись —
164
+ * отдельный PRINT_WRITE_TIMEOUT_MS (длинный чек пишется, пока принтер печатает). Не успели →
165
+ * устройство закрывается, error:'write_timeout' (чек мог выйти частично — оператор повторит);
166
+ * - задания на ОДНО физическое устройство выстраиваются в очередь в самом Node: сюда печатают
167
+ * не только свой фронт, но и мост ведомого терминала (app/sync/bridge.js).
168
+ */
169
+ const honestAck = () => process.env.PRINT_HONEST_ACK === 'true';
170
+ const writeTimeout = () => Number(process.env.PRINT_WRITE_TIMEOUT_MS) || 30000;
171
+
172
+ const deviceQueues = new Map();
173
+
174
+ // Ключ физического устройства — ровно как его открывает getPrinterDevice (LAN всегда :9100).
175
+ const deviceKey = (printer) => (printer.type === 'USB'
176
+ ? `usb:${+printer.vendor_id}:${+printer.product_id}`
177
+ : `lan:${printer.ip_address}`);
178
+
179
+ const enqueue = (key, task) => {
180
+ const previous = deviceQueues.get(key) || Promise.resolve();
181
+ const run = previous.then(task);
182
+ const tail = run.catch(() => {});
183
+ deviceQueues.set(key, tail);
184
+ tail.then(() => {
185
+ if (deviceQueues.get(key) === tail) {
186
+ deviceQueues.delete(key);
187
+ }
188
+ });
189
+ return run;
190
+ };
191
+
192
+ const writeReceipt = (printer, images, body) => {
193
+ const printMethod = body.print_method || 'raster';
194
+ printer.align('CT');
195
+ images.forEach(image => {
196
+ if (printMethod === 'raster') {
197
+ printer.raster(image, body.mode);
198
+ } else {
199
+ printer.image(image, body.mode);
200
+ }
201
+ });
202
+ if (body.bell) {
203
+ printer.beep(body.bell_count || 2, body.bell_time || 3);
204
+ }
205
+ if (body.cashdraw) {
206
+ printer.cashdraw(body.cashdraw_pin || 2);
207
+ }
208
+ printer.newLine().newLine().cut();
209
+ };
210
+
211
+ /**
212
+ * LAN: колбэк записи значит лишь «байты ушли в ядро ОС» — весь чек помещается в буферы сокета,
213
+ * даже если принтер не прочитал ни байта. Поэтому шлём FIN и ждём, пока принтер закроет свою
214
+ * сторону (значит, дочитал всё). Не закрыл за PRINT_LAN_DRAIN_MS (бывают принтеры, которые
215
+ * соединение не закрывают) — считаем отправленным без подтверждения: confirmed = false.
216
+ * Для длинного чека это время печати — ответ приходит позже, зато следующее задание не
217
+ * открывает второе соединение поверх недочитанного первого.
218
+ * USB: bulk-transfer и так завершается, когда принтер принял данные, — confirmed сразу.
219
+ */
220
+ const lanDrain = () => Number(process.env.PRINT_LAN_DRAIN_MS) || 3000;
221
+
222
+ const releaseDevice = (device) => new Promise((resolve) => {
223
+ const close = (confirmed) => {
224
+ try {
225
+ device.close(() => {});
226
+ } catch (e) {
227
+ }
228
+ resolve(confirmed);
229
+ };
230
+ const socket = device.device;
231
+ if (!(socket instanceof net.Socket) || socket.destroyed) {
232
+ return close(true);
233
+ }
234
+ const timer = setTimeout(() => close(false), lanDrain());
235
+ socket.once('close', () => {
236
+ clearTimeout(timer);
237
+ close(true);
238
+ });
239
+ socket.end();
240
+ });
241
+
242
+ /**
243
+ * Печатает один чек. `report(result)` вызывается один раз — это ответ фронту/Laravel; сам промис
244
+ * резолвится, когда устройство свободно для следующего задания.
245
+ */
246
+ const printHonestly = (device, images, body, report) => new Promise((resolve) => {
247
+ let reported = false;
248
+ let timer = null;
249
+ const finish = (result) => {
250
+ if (reported) {
251
+ return;
252
+ }
253
+ reported = true;
254
+ clearTimeout(timer);
255
+ report(result);
256
+ };
257
+ const fail = (result) => {
258
+ finish(result);
259
+ // Прерываем незавершённую передачу — ждать её нечего.
260
+ try {
261
+ device.close(() => {});
262
+ } catch (e) {
263
+ }
264
+ resolve();
265
+ };
266
+
267
+ timer = setTimeout(() => fail({success: false, error: 'timeout'}), body.timeout || 5000);
268
+
269
+ device.open((err) => {
270
+ if (reported) {
271
+ // Открылось после таймаута (или повторный error LAN-сокета) — устройство не держим.
272
+ try {
273
+ device.close(() => {});
274
+ } catch (e) {
275
+ }
276
+ return;
277
+ }
278
+ if (err) {
279
+ return fail({success: false, error: 'connection_error', message: err});
280
+ }
281
+ clearTimeout(timer);
282
+ timer = setTimeout(() => fail({success: false, error: 'write_timeout'}), writeTimeout());
283
+
284
+ const printer = new escpos.Printer(device, {});
285
+ try {
286
+ writeReceipt(printer, images, body);
287
+ } catch (e) {
288
+ return fail({success: false, error: 'write_error', message: e.message});
289
+ }
290
+ printer.flush((writeError) => {
291
+ if (reported) {
292
+ return;
293
+ }
294
+ if (writeError) {
295
+ return fail({success: false, error: 'write_error', message: writeError.message || String(writeError)});
296
+ }
297
+ // Таймер записи продолжает идти и на время LAN-дочитывания: зависший принтер → write_timeout.
298
+ releaseDevice(device).then((confirmed) => {
299
+ finish(confirmed ? {success: true} : {success: true, confirmed: false});
300
+ resolve();
301
+ });
302
+ });
303
+ });
304
+ });
305
+
306
+ // Итог печати — в мониторинг кассы (heartbeat → суперадминка). На сам ответ не влияет.
307
+ const trackPrint = (req, res, next) => {
308
+ const json = res.json.bind(res);
309
+ res.json = (body) => {
310
+ monitor.recordPrint(body);
311
+ return json(body);
312
+ };
313
+ next();
314
+ };
315
+
316
+ // Телефон официанта на странице мастера (req.lanDevice, app/sync/lan.js): USB-принтер кассы
317
+ // ему не нужен, а адрес сетевого принтера он задаёт сам — пускаем только в частную сеть IPv4.
318
+ const PRIVATE_IPV4 = /^(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})$/;
319
+ const devicePrinterAllowed = (printer) => Boolean(printer) && printer.type === 'LAN'
320
+ && PRIVATE_IPV4.test(String(printer.ip_address || ''));
321
+
322
+ router.post('/printers.print', trackPrint, async (req, res) => {
323
+ if (req.lanDevice && !devicePrinterAllowed(req.body?.printer)) {
324
+ return res.status(403).json({success: false, error: 'forbidden', message: 'Forbidden'});
325
+ }
125
326
  if (process.env.APP_DEBUG === 'true') {
126
327
  await new Promise(resolve => setTimeout(resolve, 100));
127
328
  return res.json({ success: true, mock: true });
128
329
  }
330
+ if (printerAddress.enabled(req.body)) {
331
+ return printByMac(req, res);
332
+ }
333
+ // print() асинхронный: без catch исключение (битый PNG, сбой sharp) оставило бы запрос без
334
+ // ответа, и терминал ждал бы его до таймаута круга печати (90 с).
335
+ return print(req, res, req.body.printer).catch((e) => {
336
+ console.warn('[printers] print failed:', e && e.message);
337
+ if (!res.headersSent) {
338
+ res.json({success: false, error: 'print_error', message: (e && e.message) || String(e)});
339
+ }
340
+ });
341
+ })
129
342
 
130
- const device = getPrinterDevice(req.body.printer);
343
+ /**
344
+ * Сетевой принтер с привязкой по MAC (app/printers/address.js): сначала узнаём, где он сейчас,
345
+ * потом печатаем обычным путём на найденный адрес. В честном режиме адрес ищется внутри очереди
346
+ * устройства — проба порта не пересекается с идущей печатью на тот же принтер.
347
+ *
348
+ * К успешному ответу добавляется:
349
+ * resolved_ip — принтер найден не по записанному адресу (фронт → printers.syncAddress);
350
+ * mac_address — MAC принтера (для принтера без привязки — прочитанный из ARP).
351
+ * Неудача сбрасывает проверку адреса: следующее задание проверит его заново.
352
+ */
353
+ const printByMac = (req, res) => {
354
+ const printer = req.body.printer;
355
+ const mac = printerAddress.normalizeMac(printer.mac_address);
356
+ const run = async () => {
357
+ const target = await printerAddress.resolve(printer);
358
+ if (target.error) {
359
+ return res.json({success: false, error: target.error, message: `Printer ${mac} not found in the network`});
360
+ }
361
+ const json = res.json.bind(res);
362
+ res.json = (body) => {
363
+ if (body && typeof body === 'object' && body.success) {
364
+ const known = target.mac || target.learnedMac;
365
+ body = {
366
+ ...body,
367
+ ...(target.moved ? {resolved_ip: target.ip} : {}),
368
+ ...(known ? {mac_address: known} : {}),
369
+ };
370
+ } else if (body && typeof body === 'object') {
371
+ printerAddress.invalidate(target.mac, target.ip);
372
+ }
373
+ return json(body);
374
+ };
375
+ return print(req, res, {...printer, ip_address: target.ip}, {queued: honestAck()});
376
+ };
377
+ const job = honestAck() ? enqueue(mac ? `lan:mac:${mac}` : deviceKey(printer), run) : run();
378
+ return job.catch((e) => {
379
+ console.warn('[printer-mac] print failed:', e.message);
380
+ if (!res.headersSent) {
381
+ res.json({success: false, error: 'print_error', message: e.message});
382
+ }
383
+ });
384
+ };
385
+
386
+ /**
387
+ * Печать на принтер `target`. `queued` — вызывающий уже держит очередь устройства (привязка по MAC):
388
+ * честная печать идёт сразу и возвращается, когда устройство свободно.
389
+ */
390
+ const print = async (req, res, target, {queued = false} = {}) => {
391
+ const device = getPrinterDevice(target);
131
392
  const printMethod = req.body.print_method || 'raster';
132
393
  const printMode = req.body.mode;
133
394
  const partHeight = req.body.height || 576;
134
395
  const partWidth = req.body.width || 576;
135
396
  if (!device) {
136
- return res.send('bad').status(404);
397
+ // Раньше здесь был `res.send('bad')` со статусом 200: фронт считал это успехом, и чек на
398
+ // неподключённый принтер пропадал молча. Мониторинг запишет ответ сам (trackPrint).
399
+ const where = target && target.type === 'USB'
400
+ ? `USB ${target.vendor_id}:${target.product_id}`
401
+ : String((target && target.ip_address) || '');
402
+ return res.json({
403
+ success: false,
404
+ error: 'no_device',
405
+ message: `Printer ${(target && target.name) || ''} (${where}) not found`,
406
+ });
137
407
  }
138
408
 
139
409
  const contentsBuffer = await splitImageByHeight(req.body.content, partHeight, partWidth);
@@ -149,6 +419,19 @@ router.post('/printers.print', async (req, res) => {
149
419
  }
150
420
  resolve(_images);
151
421
  });
422
+ if (honestAck()) {
423
+ let report;
424
+ const result = new Promise((resolve) => {
425
+ report = resolve;
426
+ });
427
+ const job = () => printHonestly(device, images, req.body, report);
428
+ if (queued) {
429
+ await job();
430
+ } else {
431
+ enqueue(deviceKey(target), job);
432
+ }
433
+ return res.json(await result);
434
+ }
152
435
  const printer = new escpos.Printer(device, {});
153
436
  const timeOut = req.body.timeout || 5000;
154
437
 
@@ -206,6 +489,6 @@ router.post('/printers.print', async (req, res) => {
206
489
  })
207
490
  }
208
491
  }, timeOut);
209
- })
492
+ }
210
493
 
211
494
  module.exports = router;
@@ -0,0 +1,130 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const sync = require('../../app/sync');
4
+ const {isSyncEnabled} = require('../../app/sync/config');
5
+ const {isLoopback} = require('../../app/sync/lan');
6
+ const {strictOriginGuard} = require('../../app/security');
7
+ const {getConfig} = require('../../app/config');
8
+ const {normalizeApiUrl} = require('../../app/sync/cloud');
9
+
10
+ /**
11
+ * Управление офлайн-контуром кассы (модель F, OFFLINE-FIRST-PLAN §11).
12
+ *
13
+ * GET /local/status — куда сейчас ходить фронту (mode) и состояние синка;
14
+ * его же читает ведомый терминал у мастера (с ключом зала);
15
+ * POST /local/session — фронт отдаёт токен и заголовки терминала: с ними синк ходит
16
+ * в облако;
17
+ * DELETE /local/session — выход терминала, токен забывается (старый фронт);
18
+ * POST /local/logout — выход терминала: сессия паркуется, токен отзывается, когда
19
+ * очередь дослана и облако на связи;
20
+ * POST /local/resume — вход обратно на запаркованную сессию по PIN сотрудника (без облака);
21
+ * GET /local/whoami — мастер подписанно отвечает ведомому, который его ищет;
22
+ * GET /local/lan — телефону на LAN-странице мастера: пора ли назад в облако.
23
+ *
24
+ * Только desktop: облачный Node эти пути не обслуживает, иначе старый Electron (у которого
25
+ * /local/* проксируется в облако) получил бы 200 и опрашивал облако каждые 2 секунды.
26
+ * Синк выключен — 404: фронт считает Node старым и перестаёт опрашивать.
27
+ */
28
+ const disabled = (res) => res.status(404).json({success: false, message: 'Offline mode is disabled'});
29
+
30
+ // Сессию задаёт только фронт этой же машины — даже с ключом зала чужой не подменит токен.
31
+ const loopbackOnly = (req, res, next) => {
32
+ if (isLoopback(req.socket && req.socket.remoteAddress)) {
33
+ return next();
34
+ }
35
+ return res.status(403).json({success: false, message: 'Forbidden'});
36
+ };
37
+
38
+ /**
39
+ * Куда синк ходит в облако, решает не тело запроса, а конфиг самого Node (тот же apiUrl, что у
40
+ * прокси). Иначе страница, до которой дотянулся localhost, увела бы офлайн-чеки на свой сервер
41
+ * и залила бы в кассу свой снапшот. OFFLINE_TRUST_SESSION_API_URL=true — старое поведение (стенды).
42
+ */
43
+ const pinApiUrl = (body) => {
44
+ const pinned = getConfig().apiUrl;
45
+ if (process.env.OFFLINE_TRUST_SESSION_API_URL === 'true' || !pinned) {
46
+ return body;
47
+ }
48
+ if (body.api_url && normalizeApiUrl(String(body.api_url)) !== normalizeApiUrl(pinned)) {
49
+ console.warn(`[sync] session api_url ${body.api_url} ignored, using ${pinned}`);
50
+ }
51
+ return {...body, api_url: pinned};
52
+ };
53
+
54
+ router.get('/local/status', (req, res) => {
55
+ if (!isSyncEnabled()) {
56
+ return disabled(res);
57
+ }
58
+ res.set('Cache-Control', 'no-store');
59
+ return res.json(sync.status());
60
+ });
61
+
62
+ // Ведомый ищет мастера (app/sync/discovery.js): подписанный ответ на nonce, без ключа зала.
63
+ router.get('/local/whoami', (req, res) => {
64
+ const answer = isSyncEnabled() ? sync.whoami(req.query.nonce) : null;
65
+ if (!answer) {
66
+ return res.status(404).json({success: false});
67
+ }
68
+ res.set('Cache-Control', 'no-store');
69
+ return res.json(answer);
70
+ });
71
+
72
+ // Телефон официанта на LAN-странице мастера спрашивает, пора ли назад в облако. Без ключа
73
+ // зала (app/sync/lan.js): режим и доступность облака секретом не являются.
74
+ router.get('/local/lan', (req, res) => {
75
+ const info = isSyncEnabled() ? sync.lanInfo() : null;
76
+ if (!info) {
77
+ return res.status(404).json({success: false});
78
+ }
79
+ res.set('Cache-Control', 'no-store');
80
+ return res.json(info);
81
+ });
82
+
83
+ router.post('/local/session', loopbackOnly, strictOriginGuard, async (req, res) => {
84
+ if (!isSyncEnabled()) {
85
+ return disabled(res);
86
+ }
87
+ try {
88
+ const result = await sync.setSession(pinApiUrl(req.body || {}));
89
+ return res.status(result.success ? 200 : 422).json(result);
90
+ } catch (e) {
91
+ console.error('[sync] set session failed:', e);
92
+ return res.status(500).json({success: false, message: e.message});
93
+ }
94
+ });
95
+
96
+ router.delete('/local/session', loopbackOnly, strictOriginGuard, async (req, res) => {
97
+ if (!isSyncEnabled()) {
98
+ return disabled(res);
99
+ }
100
+ try {
101
+ return res.json(await sync.clearSession());
102
+ } catch (e) {
103
+ console.error('[sync] clear session failed:', e);
104
+ return res.status(500).json({success: false, message: e.message});
105
+ }
106
+ });
107
+
108
+ /** Ответ синка или его HttpError (409/422/429/503) как есть. */
109
+ const respond = (name, fn) => async (req, res) => {
110
+ if (!isSyncEnabled()) {
111
+ return disabled(res);
112
+ }
113
+ try {
114
+ return res.json(await fn(req.body || {}));
115
+ } catch (e) {
116
+ if (e.name === 'HttpError') {
117
+ return res.status(e.status).json(e.body);
118
+ }
119
+ console.error(`[sync] ${name} failed:`, e);
120
+ return res.status(500).json({success: false, message: e.message});
121
+ }
122
+ };
123
+
124
+ router.post('/local/logout', loopbackOnly, strictOriginGuard,
125
+ respond('logout', (body) => sync.logout({token: body.token})));
126
+
127
+ router.post('/local/resume', loopbackOnly, strictOriginGuard,
128
+ respond('resume', (body) => sync.resume({code: body.code})));
129
+
130
+ module.exports = router;
@@ -0,0 +1,37 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const {registerOfflineRoutes} = require('../../../app/core');
4
+ const AuthService = require('../../../app/core/services/AuthService');
5
+
6
+ /**
7
+ * Вход терминала (NODE-MIGRATION-PLAN, этап 2): AuthController::auth, loginTerminal, logoutApp,
8
+ * unlock. Только облако: на кассе `auth.unlock` обслуживает офлайн-обработчик из снапшота
9
+ * (`routes/desktop/api/offline.js`), а вход по логину без облака невозможен.
10
+ *
11
+ * Группы `app` и `auth` в Laravel ведут в один контроллер — регистрируем оба имени.
12
+ * Переключаются по одному через CORE_EVENTS, как и остальное ядро.
13
+ */
14
+ const CLOUD_READ = {mutation: false, cloudOnly: true};
15
+ const CLOUD_WRITE = {mutation: true, cloudOnly: true};
16
+
17
+ const service = (services) => new AuthService(services);
18
+
19
+ const loginTerminal = async (services, params, extra) => service(services).loginTerminal(params, extra);
20
+ const logoutApp = async (services, params, extra) => service(services).logoutApp(params, extra);
21
+ const unlock = async (services, params, extra) => service(services).unlock(params, extra);
22
+
23
+ registerOfflineRoutes(router, '/api', {
24
+ /** Route::any('app.auth') — вне auth:sanctum: логин и пароль, выпуск токена. */
25
+ 'app.auth': [{...CLOUD_READ, public: true}, async (services, params) => service(services).auth(params)],
26
+
27
+ 'auth.loginTerminal': [CLOUD_WRITE, loginTerminal],
28
+ 'app.loginTerminal': [CLOUD_WRITE, loginTerminal],
29
+
30
+ 'auth.logoutApp': [CLOUD_WRITE, logoutApp],
31
+ 'app.logoutApp': [CLOUD_WRITE, logoutApp],
32
+
33
+ 'auth.unlock': [CLOUD_READ, unlock],
34
+ 'app.unlock': [CLOUD_READ, unlock],
35
+ });
36
+
37
+ module.exports = router;