@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.
- package/app/core/Context.js +170 -0
- package/app/core/auth/index.js +133 -0
- package/app/core/auth/license.js +45 -0
- package/app/core/auth/posMode.js +260 -0
- package/app/core/auth/sanctum.js +209 -0
- package/app/core/auth/scope.js +231 -0
- package/app/core/emitter.js +67 -0
- package/app/core/errors.js +18 -0
- package/app/core/guard.js +54 -0
- package/app/core/idempotency.js +196 -0
- package/app/core/index.js +374 -0
- package/app/core/models/Order.js +176 -0
- package/app/core/models/OrderProduct.js +249 -0
- package/app/core/repositories/EventRepository.js +51 -0
- package/app/core/repositories/OrderProductRepository.js +225 -0
- package/app/core/repositories/OrderRepository.js +274 -0
- package/app/core/repositories/OutboxRepository.js +52 -0
- package/app/core/repositories/PaymentRepository.js +43 -0
- package/app/core/repositories/PrinterRepository.js +179 -0
- package/app/core/repositories/ProductRelations.js +148 -0
- package/app/core/repositories/SnapshotRepository.js +197 -0
- package/app/core/resources/HallResource.js +54 -0
- package/app/core/resources/OrderResource.js +177 -0
- package/app/core/resources/ProductResource.js +186 -0
- package/app/core/resources/ShiftResource.js +42 -0
- package/app/core/resources/TeamResource.js +85 -0
- package/app/core/resources/casts.js +55 -0
- package/app/core/services/AuthService.js +468 -0
- package/app/core/services/InitService.js +277 -0
- package/app/core/services/KdsService.js +639 -0
- package/app/core/services/OrderProductService.js +309 -0
- package/app/core/services/OrderService.js +903 -0
- package/app/core/services/PrintService.js +715 -0
- package/app/core/services/PrinterAdminService.js +324 -0
- package/app/core/services/ReportService.js +581 -0
- package/app/core/services/ShiftService.js +199 -0
- package/app/core/services/StorageService.js +425 -0
- package/app/core/services/TelegramService.js +162 -0
- package/app/core/support/db.js +48 -0
- package/app/core/support/ids.js +9 -0
- package/app/core/support/imageData.js +135 -0
- package/app/core/support/images.js +92 -0
- package/app/core/support/lanHandoff.js +105 -0
- package/app/core/support/lang.js +98 -0
- package/app/core/support/lock.js +63 -0
- package/app/core/support/num.js +51 -0
- package/app/core/support/php.js +125 -0
- package/app/core/support/pin.js +34 -0
- package/app/core/support/receipts.js +278 -0
- package/app/core/support/rows.js +38 -0
- package/app/core/support/str.js +31 -0
- package/app/core/support/teamSettings.js +77 -0
- package/app/core/support/telegram.js +253 -0
- package/app/core/support/telegramReport.js +143 -0
- package/app/core/support/time.js +58 -0
- package/app/core/support/tz.js +80 -0
- package/app/core/support/unlock.js +113 -0
- package/app/core/timers.js +151 -0
- package/app/db/Database.js +197 -1
- package/app/db/Migrator.js +45 -0
- package/app/db/driver.js +248 -0
- package/app/db/migrations/001_snapshot.sql +214 -0
- package/app/db/migrations/002_orders.sql +190 -0
- package/app/db/migrations/003_idempotency.sql +18 -0
- package/app/db/migrations/004_shifts.sql +21 -0
- package/app/db/migrations/005_printers.sql +43 -0
- package/app/db/migrations/006_sync.sql +136 -0
- package/app/db/migrations/007_printer_mac.sql +4 -0
- package/app/db/migrations/008_kds.sql +94 -0
- package/app/db/migrations/009_product_categories.sql +20 -0
- package/app/db/migrations/010_printers_sync.sql +43 -0
- package/app/db/migrations/011_team_logos.sql +13 -0
- package/app/db/migrations/012_category_workshop.sql +4 -0
- package/app/monitor.js +185 -0
- package/app/printers/address.js +348 -0
- package/app/proxy.js +11 -3
- package/app/relay/masters.js +173 -0
- package/app/security.js +218 -0
- package/app/socket/auth.js +160 -0
- package/app/socket/request.js +87 -0
- package/app/socket/server.js +121 -19
- package/app/sync/bridge.js +162 -0
- package/app/sync/cloud.js +120 -0
- package/app/sync/config.js +185 -0
- package/app/sync/discovery.js +546 -0
- package/app/sync/index.js +1644 -0
- package/app/sync/kv.js +26 -0
- package/app/sync/lan.js +139 -0
- package/app/sync/lanDevices.js +177 -0
- package/app/sync/lanFront.js +431 -0
- package/app/sync/mirror.js +236 -0
- package/app/sync/push.js +324 -0
- package/app/sync/reconcile.js +161 -0
- package/app/sync/signal.js +202 -0
- package/app/sync/snapshot.js +189 -0
- package/app/sync/store.js +234 -0
- package/app/tcp.js +11 -2
- package/package.json +6 -3
- package/routes/common/api/auth.js +38 -0
- package/routes/common/api/kds.js +37 -0
- package/routes/common/api/orderProducts.js +81 -0
- package/routes/common/api/orders.js +176 -0
- package/routes/common/api/printersCore.js +35 -0
- package/routes/common/api/reports.js +29 -0
- package/routes/common/socket.js +15 -35
- package/routes/common/tax.js +29 -7
- package/routes/desktop/api/offline.js +146 -0
- package/routes/desktop/api/printers.js +288 -5
- package/routes/desktop/local.js +130 -0
- package/routes/server/api/login.js +37 -0
- package/routes/server/api/platform.js +31 -0
- package/routes/server/api/shifts.js +25 -0
- package/routes/server/master.js +24 -0
- package/server.js +72 -3
- package/utils.js +6 -0
- package/tests/usbDiagnose.js +0 -59
- package/tests/usbPrinterTest.js +0 -20
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
const PrinterRepository = require('../repositories/PrinterRepository');
|
|
2
|
+
const KdsService = require('./KdsService');
|
|
3
|
+
const OrderProduct = require('../models/OrderProduct');
|
|
4
|
+
const {money, qty, round2, toBool, sum} = require('../support/num');
|
|
5
|
+
const {now, nowSql, parse} = require('../support/time');
|
|
6
|
+
const {DEFAULT_TIMEZONE, zonedParts} = require('../support/tz');
|
|
7
|
+
const {translated} = require('../support/lang');
|
|
8
|
+
const {md5} = require('../support/str');
|
|
9
|
+
const {uuid} = require('../support/ids');
|
|
10
|
+
const {notFound} = require('../errors');
|
|
11
|
+
const {normalizeReceipts, phpTruthy, findRow} = require('../support/receipts');
|
|
12
|
+
const {withLock, LockTimeoutError} = require('../support/lock');
|
|
13
|
+
|
|
14
|
+
const TYPE_TIMER = OrderProduct.TYPE_TIMER;
|
|
15
|
+
const DINE_IN = 'dine_in';
|
|
16
|
+
|
|
17
|
+
// Таймауты — те же, что Laravel передаёт в /socket/request. Цеховой чек ждёт реальную печать
|
|
18
|
+
// (90с, см. инвариант таймаутов в Laravel); счёт уходит через SocketService::request без
|
|
19
|
+
// time_out, то есть с дефолтом Node — 10с.
|
|
20
|
+
const WORKSHOP_TIMEOUT_MS = 90000;
|
|
21
|
+
const BILL_TIMEOUT_MS = 10000;
|
|
22
|
+
|
|
23
|
+
// TTL дедуп-меток — как у Cache::add/put в Laravel.
|
|
24
|
+
const LOCK_TTL_MS = 120 * 1000;
|
|
25
|
+
const DONE_TTL_MS = 30 * 1000;
|
|
26
|
+
|
|
27
|
+
// Отправки в цех одного чека идут строго по одной (как `print:order:<id>` в Laravel): иначе
|
|
28
|
+
// вторая читает workshop_print_quantity до того, как первая его проставила, и печатает те же
|
|
29
|
+
// позиции ещё раз. Ожидание — PRINT_ORDER_LOCK_WAIT (с), дефолт 15, больше 25 не бывает:
|
|
30
|
+
// ожидание + печать 90 держатся под таймаутом мастера 150 и фронта 160.
|
|
31
|
+
const ORDER_LOCK_WAIT_DEFAULT_S = 15;
|
|
32
|
+
const ORDER_LOCK_WAIT_MAX_S = 25;
|
|
33
|
+
|
|
34
|
+
const orderLockWaitMs = () => {
|
|
35
|
+
const raw = process.env.PRINT_ORDER_LOCK_WAIT;
|
|
36
|
+
const seconds = raw === undefined || raw === '' ? ORDER_LOCK_WAIT_DEFAULT_S : Number.parseInt(raw, 10);
|
|
37
|
+
if (!Number.isFinite(seconds)) {
|
|
38
|
+
return ORDER_LOCK_WAIT_DEFAULT_S * 1000;
|
|
39
|
+
}
|
|
40
|
+
return Math.min(Math.max(seconds, 0), ORDER_LOCK_WAIT_MAX_S) * 1000;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Дедуп-метки печати (`print:lock:*` / `print:done:*` из кэша Laravel).
|
|
45
|
+
*
|
|
46
|
+
* Здесь это Map в памяти процесса, а не Redis: одна касса-мастер — один процесс Node, и все
|
|
47
|
+
* терминалы заведения ходят в него же, поэтому гонка «два клика / два терминала» ловится.
|
|
48
|
+
* Цена — метки не переживают рестарт, что безопасно: рестарт длится дольше 30с окна done,
|
|
49
|
+
* а физический повтор всё равно отсекает терминал по print_idempotency_key.
|
|
50
|
+
*/
|
|
51
|
+
const printMarks = new Map();
|
|
52
|
+
|
|
53
|
+
const purgeExpired = (at) => {
|
|
54
|
+
for (const [key, expiresAt] of printMarks) {
|
|
55
|
+
if (expiresAt <= at) {
|
|
56
|
+
printMarks.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Cache::add — true, только если метки не было (или она истекла). */
|
|
62
|
+
const markAdd = (key, ttlMs) => {
|
|
63
|
+
const at = Date.now();
|
|
64
|
+
purgeExpired(at);
|
|
65
|
+
if (printMarks.has(key)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
printMarks.set(key, at + ttlMs);
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const markHas = (key) => {
|
|
73
|
+
const expiresAt = printMarks.get(key);
|
|
74
|
+
if (expiresAt === undefined) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (expiresAt <= Date.now()) {
|
|
78
|
+
printMarks.delete(key);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const markPut = (key, ttlMs) => {
|
|
85
|
+
printMarks.set(key, Date.now() + ttlMs);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const markForget = (key) => {
|
|
89
|
+
printMarks.delete(key);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Для смоуков: изолировать сценарии друг от друга. */
|
|
93
|
+
const resetPrintMarks = () => printMarks.clear();
|
|
94
|
+
|
|
95
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `$carbon->setTimezone($tz)->format('Y-m-d H:i:s')`. Время в чеке — стенные часы заведения,
|
|
99
|
+
* а не UTC: кассир сверяет его с часами на стене. Битая зона не должна срывать печать —
|
|
100
|
+
* откатываемся на Asia/Baku, как и весь остальной код ядра.
|
|
101
|
+
*/
|
|
102
|
+
const formatZoned = (date, timeZone) => {
|
|
103
|
+
let parts;
|
|
104
|
+
try {
|
|
105
|
+
parts = zonedParts(date, timeZone || DEFAULT_TIMEZONE);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
parts = zonedParts(date, DEFAULT_TIMEZONE);
|
|
108
|
+
}
|
|
109
|
+
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// В PHP null в строковой интерполяции — пустая строка, в JS — «null».
|
|
113
|
+
const str = (value) => (value === null || value === undefined ? '' : String(value));
|
|
114
|
+
|
|
115
|
+
// Флаги принтера Laravel отдаёт сырыми (кастов у модели нет): в Postgres это boolean, локально
|
|
116
|
+
// INTEGER 0/1. Приводим к boolean, null оставляем null — как пришёл бы из Postgres.
|
|
117
|
+
const flag = (value) => (value === null || value === undefined ? null : toBool(value));
|
|
118
|
+
|
|
119
|
+
const printerPayload = (printer, cashdraw) => ({
|
|
120
|
+
type: printer.type,
|
|
121
|
+
ip_address: printer.ip_address,
|
|
122
|
+
mac_address: printer.mac_address ?? null,
|
|
123
|
+
vendor_id: printer.vendor_id,
|
|
124
|
+
product_id: printer.product_id,
|
|
125
|
+
name: printer.name,
|
|
126
|
+
bell: flag(printer.bell),
|
|
127
|
+
cashdraw,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const printerError = (printerName, terminalName, suffix = '') => ({
|
|
131
|
+
title: 'Printerlə əlaqədə xəta',
|
|
132
|
+
message: `Printer ${str(printerName)} ilə əlaqə qurula bilmədi. (Terminal: ${str(terminalName)})${suffix}`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Ответ терминала: успех только при success, как `!$response || !$response['success']`.
|
|
136
|
+
const isSuccess = (response) => Boolean(response) && typeof response === 'object' && phpTruthy(response.success);
|
|
137
|
+
|
|
138
|
+
// Удалённые записи в облаке Eloquent не отдаёт; локально deleted_at у справочника может и не быть.
|
|
139
|
+
const alive = (row) => (row && !row.deleted_at ? row : null);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Печать — порт `OrderService::sendToWorkshops` / `printBillReceipt` и
|
|
143
|
+
* `PrinterController::getReceiptPrinter`.
|
|
144
|
+
*
|
|
145
|
+
* Ядро решает, ЧТО и КУДА печатать (цеха, принтеры, терминал), а растр и отправку на
|
|
146
|
+
* устройство по-прежнему делает терминал: задание уходит ему сокет-запросом
|
|
147
|
+
* `request:orders:print*:<id>`, тем же каналом, которым сегодня печатает Laravel. Payload и
|
|
148
|
+
* формат ответа совпадают байт в байт — терминалу всё равно, кто прислал задание.
|
|
149
|
+
*/
|
|
150
|
+
class PrintService {
|
|
151
|
+
/**
|
|
152
|
+
* @param {object} deps
|
|
153
|
+
* @param {object} deps.orders OrderService этого запроса (find, guard, syncWorkshopCount, patch)
|
|
154
|
+
* @param {Function} [deps.socketRequest] транспорт до терминала; подменяется в тестах
|
|
155
|
+
*/
|
|
156
|
+
constructor({db, context, emitter = null, orders, socketRequest = null}) {
|
|
157
|
+
this.db = db;
|
|
158
|
+
this.context = context;
|
|
159
|
+
this.emitter = emitter;
|
|
160
|
+
this.orders = orders;
|
|
161
|
+
this.printers = new PrinterRepository(db);
|
|
162
|
+
// Лениво: socket/request тянет utils (sharp) и socket.io-сервер, а ядро грузится и без них.
|
|
163
|
+
this.socketRequest = socketRequest || ((options) => require('../../socket/request').socketRequest(options));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
locale() {
|
|
167
|
+
return this.context.locale();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Настройки чеков команды заказа — уже прогнанные через каст Laravel. */
|
|
171
|
+
async receiptsOf(team) {
|
|
172
|
+
let settings = team ? team.settings : null;
|
|
173
|
+
if (typeof settings === 'string') {
|
|
174
|
+
try {
|
|
175
|
+
settings = JSON.parse(settings);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
settings = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const raw = settings && typeof settings === 'object' ? settings.receipts : null;
|
|
181
|
+
return normalizeReceipts(raw, await this.printers.findCurrencySymbol(team));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Терминал, который физически печатает. Для делегирующих (mobile без печати в браузере) —
|
|
186
|
+
* его print_via_terminal_id; удалённый/не найденный целевой терминал = печать своим.
|
|
187
|
+
*/
|
|
188
|
+
async resolvePrintTerminal(terminal) {
|
|
189
|
+
if (terminal && terminal.print_via_terminal_id) {
|
|
190
|
+
return (await this.printers.findTerminal(terminal.print_via_terminal_id)) || terminal;
|
|
191
|
+
}
|
|
192
|
+
return terminal;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Делегирование — по факту (терминалы различаются), а не по заполненному полю. */
|
|
196
|
+
static isDelegated(terminal, printTerminal) {
|
|
197
|
+
return Boolean(terminal && printTerminal && printTerminal.id !== terminal.id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ------------------------------------------------------------ цеховые чеки
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Только синхронный режим: терминал ждёт результат печати в ответе запроса. Async-ветка
|
|
204
|
+
* Laravel (Job в очереди `prints`) офлайн не нужна — очереди у кассы нет, а сама печать
|
|
205
|
+
* и так не держит пул воркеров (Node не блокируется на ожидании сокета).
|
|
206
|
+
*
|
|
207
|
+
* @param {boolean} allowClosed внутренний обход guard'а для auto_print_on_close
|
|
208
|
+
*/
|
|
209
|
+
async sendToWorkshops(id, {allowClosed = false} = {}) {
|
|
210
|
+
try {
|
|
211
|
+
return await withLock(`print:order:${id}`, () => this.sendToWorkshopsLocked(id, {allowClosed}), {
|
|
212
|
+
waitMs: orderLockWaitMs(),
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (!(error instanceof LockTimeoutError)) {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
console.warn('[core][print] sendToWorkshops: order is still printing, rejected', {order_id: id});
|
|
219
|
+
return {
|
|
220
|
+
async: false,
|
|
221
|
+
errors: [{
|
|
222
|
+
title: 'Çek çap olunur',
|
|
223
|
+
message: 'Bu çek hələ çap olunur. Bir az sonra yenidən göndərin.',
|
|
224
|
+
}],
|
|
225
|
+
order: await this.orders.resource().order(await this.orders.findOrFail(id, {withTrashed: true})),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async sendToWorkshopsLocked(id, {allowClosed}) {
|
|
231
|
+
const context = this.context;
|
|
232
|
+
const order = await this.orders.findOrFail(id);
|
|
233
|
+
if (!allowClosed) {
|
|
234
|
+
this.orders.assertMutable(order, 'orders.sendToWorkshops');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const team = await this.printers.findTeam(order.team_id);
|
|
238
|
+
const timeZone = (team && team.timezone) || DEFAULT_TIMEZONE;
|
|
239
|
+
const printedAt = formatZoned(now(), timeZone);
|
|
240
|
+
|
|
241
|
+
// KDS: тикеты отправки создаются до печати и от её успеха не зависят (свой счётчик
|
|
242
|
+
// kds_print_quantity). Сбой KDS печать не ломает — как в Laravel.
|
|
243
|
+
try {
|
|
244
|
+
await new KdsService({db: this.db, context, emitter: this.emitter}).createTicketsForSend(order);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
console.warn('[core][kds] createTicketsForSend failed', {order_id: id, error: e.message});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const terminal = context.getTerminal();
|
|
250
|
+
const printTerminal = await this.resolvePrintTerminal(terminal);
|
|
251
|
+
const isDelegated = PrintService.isDelegated(terminal, printTerminal);
|
|
252
|
+
|
|
253
|
+
const source = order.source;
|
|
254
|
+
if (!source) {
|
|
255
|
+
throw notFound('Order source not found');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const header = {
|
|
259
|
+
number: order.number,
|
|
260
|
+
printed_at: printedAt,
|
|
261
|
+
guest_count: order.guest_count,
|
|
262
|
+
order_source_type: source.type,
|
|
263
|
+
order_source_label: source.name,
|
|
264
|
+
waiter: order.waiter ? order.waiter.user_name ?? null : null,
|
|
265
|
+
workshop: '',
|
|
266
|
+
terminal: terminal ? terminal.name : null,
|
|
267
|
+
comment: order.comment ?? null,
|
|
268
|
+
comments: order.comment ?? null,
|
|
269
|
+
products: [],
|
|
270
|
+
};
|
|
271
|
+
if (source.type === DINE_IN) {
|
|
272
|
+
const table = alive(await this.orders.snapshot.findTable(order.table_id));
|
|
273
|
+
if (table) {
|
|
274
|
+
const hall = alive(await this.orders.snapshot.findHall(table.hall_id));
|
|
275
|
+
header.hall = hall ? hall.name : null;
|
|
276
|
+
header.table = table.label;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const printWorkshops = await this.printers.findPrintWorkshops(context.teamId);
|
|
281
|
+
const pendingRows = await this.printers.findPendingOrderProducts(order.id);
|
|
282
|
+
const pending = await Promise.all(pendingRows.map((row) => this.orders.orders.hydrateProduct(row)));
|
|
283
|
+
|
|
284
|
+
// Подпись идемпотентности строится от АБСОЛЮТНОГО quantity, а не от дельты: при
|
|
285
|
+
// добавлении по одной штуке дельта всегда 1 и новый чек ложно отбивался бы как дубль.
|
|
286
|
+
// Это же снимок того, ЧТО печатается: после печати syncWorkshopCount ставит
|
|
287
|
+
// workshop_print_quantity по нему, а не по текущему quantity, иначе количество,
|
|
288
|
+
// добавленное во время печати, помечалось бы напечатанным и терялось.
|
|
289
|
+
const qtyById = new Map(pending.map((item) => [item.id, item.quantity]));
|
|
290
|
+
|
|
291
|
+
const noWorkshop = [];
|
|
292
|
+
const productsToPrint = new Map();
|
|
293
|
+
const syncOrderProducts = new Map();
|
|
294
|
+
|
|
295
|
+
for (const item of pending) {
|
|
296
|
+
const product = item.product;
|
|
297
|
+
|
|
298
|
+
// Позиция без товара в справочнике: Laravel упал бы на null, а мы синкаем её
|
|
299
|
+
// без печати — цех всё равно не знает, что это.
|
|
300
|
+
if (item.type === 'standalone' || item.type === 'modifier' || !product) {
|
|
301
|
+
noWorkshop.push(item.id);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (toBool(product.ignore_workshop)) {
|
|
305
|
+
noWorkshop.push(item.id);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Цех — через мутатор (пустой берётся у категории), и у модификации, и у родителя.
|
|
310
|
+
const snapshot = this.orders.snapshot;
|
|
311
|
+
let workshopId = await snapshot.productWorkshopId(product);
|
|
312
|
+
if (!workshopId && product.parent_id) {
|
|
313
|
+
workshopId = await snapshot.productWorkshopId(await snapshot.findProduct(product.parent_id));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const workshop = workshopId ? printWorkshops.get(workshopId) : null;
|
|
317
|
+
if (!workshop) {
|
|
318
|
+
noWorkshop.push(item.id);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!productsToPrint.has(workshopId)) {
|
|
323
|
+
productsToPrint.set(workshopId, {data: {...header, workshop: workshop.name, products: []}});
|
|
324
|
+
syncOrderProducts.set(workshopId, []);
|
|
325
|
+
}
|
|
326
|
+
syncOrderProducts.get(workshopId).push(item.id);
|
|
327
|
+
|
|
328
|
+
productsToPrint.get(workshopId).data.products.push({
|
|
329
|
+
name: translated(product.full_name, this.locale()),
|
|
330
|
+
quantity: item.quantity - item.workshop_print_quantity,
|
|
331
|
+
// весовой товар печатается в цех как «1.5 kq», а не «1.5X»
|
|
332
|
+
is_weight_item: toBool(product.is_weight_item),
|
|
333
|
+
unit_id: item.unit_id ?? null,
|
|
334
|
+
comment: item.comment ?? null,
|
|
335
|
+
modifications: (item.modifiers || []).map((modifier) => ({
|
|
336
|
+
name: modifier.name,
|
|
337
|
+
quantity: qty(modifier.quantity),
|
|
338
|
+
})),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (noWorkshop.length) {
|
|
343
|
+
await this.orders.syncWorkshopCount(noWorkshop, order.id, qtyById);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Задания: по одному на каждый принтер каждого цеха.
|
|
347
|
+
const jobs = [];
|
|
348
|
+
for (const [workshopId, printData] of productsToPrint) {
|
|
349
|
+
let printers = await this.printers.findWorkshopPrinters([printTerminal ? printTerminal.id : null], workshopId);
|
|
350
|
+
|
|
351
|
+
// При делегировании печать жёстко привязана к целевому терминалу: fallback по всем
|
|
352
|
+
// терминалам компании в мультизоне напечатал бы заказ в чужой зоне.
|
|
353
|
+
if (!printers.length && !isDelegated) {
|
|
354
|
+
const terminalIds = await this.printers.findCompanyTerminalIds(order.team_id, context.companyId);
|
|
355
|
+
printers = await this.printers.findWorkshopPrinters(terminalIds, workshopId);
|
|
356
|
+
}
|
|
357
|
+
if (!printers.length) {
|
|
358
|
+
console.warn('[core][print] no printer found for workshop — skipping', {order_id: id, product_workshop_id: workshopId});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const printer of printers) {
|
|
363
|
+
if (!printer.terminal_id) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const printerTerminal = await this.printers.findTerminal(printer.terminal_id);
|
|
367
|
+
jobs.push({
|
|
368
|
+
workshopId,
|
|
369
|
+
printData: {
|
|
370
|
+
data: printData.data,
|
|
371
|
+
terminal_id: printer.terminal_id,
|
|
372
|
+
terminal_name: printerTerminal ? printerTerminal.name : null,
|
|
373
|
+
printer: printerPayload(printer, false),
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const receipts = jobs.length ? await this.receiptsOf(team) : null;
|
|
380
|
+
const requests = jobs.map(({workshopId, printData}) => {
|
|
381
|
+
// Стабильный per-ticket ключ: одинаков при повторе того же состояния — терминал
|
|
382
|
+
// распознаёт дубль и не печатает второй раз. request_id в ключ не входит.
|
|
383
|
+
const ticketSig = (syncOrderProducts.get(workshopId) || [])
|
|
384
|
+
.map((opId) => `${opId}:${qtyById.has(opId) ? qtyById.get(opId) : 0}`)
|
|
385
|
+
.sort()
|
|
386
|
+
.join('|');
|
|
387
|
+
const printIdempotencyKey = `${id}:${workshopId}:${printData.terminal_id}:${str(printData.printer.name)}:${md5(ticketSig)}`;
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
workshopId,
|
|
391
|
+
printer: printData.printer,
|
|
392
|
+
terminalName: printData.terminal_name ?? '',
|
|
393
|
+
terminalId: printData.terminal_id,
|
|
394
|
+
requestId: uuid(),
|
|
395
|
+
data: {
|
|
396
|
+
config: receipts.workshop_receipt_config,
|
|
397
|
+
print_idempotency_key: printIdempotencyKey,
|
|
398
|
+
...printData,
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Контентный ключ: тот же набор позиций+количеств = тот же ключ, с какого бы терминала
|
|
404
|
+
// или по какому бы клику ни пришла отправка.
|
|
405
|
+
let idempotencyKey = null;
|
|
406
|
+
if (requests.length) {
|
|
407
|
+
// По снимку, из которого собраны чеки, а не по перечитанной БД: позиция, изменённая
|
|
408
|
+
// после сборки, не должна менять подпись уже собранного чека.
|
|
409
|
+
const opIds = [...new Set([...syncOrderProducts.values()].flat())];
|
|
410
|
+
const signature = opIds.map((opId) => `${opId}:${qty(qtyById.get(opId) ?? 0)}`).sort().join('|');
|
|
411
|
+
idempotencyKey = `print:sig:${id}:${md5(signature)}`;
|
|
412
|
+
|
|
413
|
+
// Такой же набор печатается прямо сейчас — отклоняем дубль.
|
|
414
|
+
if (!markAdd(`print:lock:${idempotencyKey}`, LOCK_TTL_MS)) {
|
|
415
|
+
return {async: false, duplicate: true, errors: []};
|
|
416
|
+
}
|
|
417
|
+
// Такой же набор только что успешно напечатан — не дублируем.
|
|
418
|
+
if (markHas(`print:done:${idempotencyKey}`)) {
|
|
419
|
+
markForget(`print:lock:${idempotencyKey}`);
|
|
420
|
+
return {async: false, duplicate: true, errors: []};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const errors = [];
|
|
425
|
+
if (requests.length) {
|
|
426
|
+
// Все цеха параллельно — общее время = самый медленный принтер, а не сумма.
|
|
427
|
+
const responses = await Promise.all(requests.map((request) => this.socketRequest({
|
|
428
|
+
terminalId: request.terminalId,
|
|
429
|
+
eventName: 'orders:printWorkshop',
|
|
430
|
+
data: request.data,
|
|
431
|
+
requestId: request.requestId,
|
|
432
|
+
timeout: WORKSHOP_TIMEOUT_MS,
|
|
433
|
+
})));
|
|
434
|
+
|
|
435
|
+
for (const [index, request] of requests.entries()) {
|
|
436
|
+
if (!isSuccess(responses[index])) {
|
|
437
|
+
// Провал не синкает количество: позиция остаётся «неотправленной» и уйдёт
|
|
438
|
+
// при следующей отправке.
|
|
439
|
+
errors.push(printerError(request.printer.name, request.terminalName));
|
|
440
|
+
} else {
|
|
441
|
+
await this.orders.syncWorkshopCount(syncOrderProducts.get(request.workshopId), id, qtyById);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Успех → done (гасит повтор того же набора 30с), провал → снять lock (разрешить повтор).
|
|
446
|
+
if (idempotencyKey) {
|
|
447
|
+
if (!errors.length) {
|
|
448
|
+
markPut(`print:done:${idempotencyKey}`, DONE_TTL_MS);
|
|
449
|
+
} else {
|
|
450
|
+
markForget(`print:lock:${idempotencyKey}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Ответ самодостаточен: фронт берёт ошибки и обновлённый чек прямо из него,
|
|
456
|
+
// не завися от доставки сокет-патча.
|
|
457
|
+
return {
|
|
458
|
+
async: false,
|
|
459
|
+
errors,
|
|
460
|
+
order: await this.orders.patchOrResource(id, {withTrashed: true}),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ------------------------------------------------------------ счёт гостю
|
|
465
|
+
|
|
466
|
+
/** Товары счёта: гость → (товар + цена), модификаторы свёрнуты по имени и цене. */
|
|
467
|
+
billProducts(order, showTableServiceCharge) {
|
|
468
|
+
const byGuest = new Map();
|
|
469
|
+
for (const item of order.products) {
|
|
470
|
+
if (!byGuest.has(item.guest_number)) {
|
|
471
|
+
byGuest.set(item.guest_number, []);
|
|
472
|
+
}
|
|
473
|
+
byGuest.get(item.guest_number).push(item);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const products = [];
|
|
477
|
+
for (const group of byGuest.values()) {
|
|
478
|
+
const byProduct = new Map();
|
|
479
|
+
for (const item of group) {
|
|
480
|
+
const key = `${item.product_id}_${item.price}`;
|
|
481
|
+
if (!byProduct.has(key)) {
|
|
482
|
+
byProduct.set(key, []);
|
|
483
|
+
}
|
|
484
|
+
byProduct.get(key).push(item);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
for (const productGroup of byProduct.values()) {
|
|
488
|
+
const first = productGroup[0];
|
|
489
|
+
|
|
490
|
+
const modifications = new Map();
|
|
491
|
+
for (const item of productGroup) {
|
|
492
|
+
for (const modifier of item.modifiers || []) {
|
|
493
|
+
const price = money(modifier.price);
|
|
494
|
+
const key = `${modifier.name}_${price}`;
|
|
495
|
+
if (!modifications.has(key)) {
|
|
496
|
+
modifications.set(key, {name: modifier.name, quantity: 0, price});
|
|
497
|
+
}
|
|
498
|
+
modifications.get(key).quantity += qty(modifier.quantity);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let name = first.product ? translated(first.product.full_name, this.locale()) : null;
|
|
503
|
+
if (first.type === TYPE_TIMER) {
|
|
504
|
+
first.calculate(order.serviceChargeMode);
|
|
505
|
+
const minutes = first.elapsed_total_minutes;
|
|
506
|
+
name = `${str(name)} ${pad(Math.trunc(minutes / 60))}:${pad(minutes % 60)}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
products.push({
|
|
510
|
+
name,
|
|
511
|
+
quantity: sum(productGroup, 'quantity'),
|
|
512
|
+
// весовой товар печатается как «1.5 kq», цена — за 1 kq
|
|
513
|
+
is_weight_item: Boolean(first.product && toBool(first.product.is_weight_item)),
|
|
514
|
+
unit_id: first.unit_id ?? null,
|
|
515
|
+
price: first.price,
|
|
516
|
+
discount_amount: sum(productGroup, 'discount_amount'),
|
|
517
|
+
service_charge_amount: sum(productGroup, 'service_charge_amount'),
|
|
518
|
+
total: showTableServiceCharge ? sum(productGroup, 'total') : sum(productGroup, 'subtotal'),
|
|
519
|
+
modifications: [...modifications.values()],
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return products;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Счёт (или пречек) на принтер «Qəbz». Повторная печать оплаченного чека закрытого заказа
|
|
528
|
+
* легальна (копия гостю), пречек по закрытому — нет: это шаг ДО оплаты.
|
|
529
|
+
*/
|
|
530
|
+
async printBillReceipt(id, isPreReceipt = false) {
|
|
531
|
+
const context = this.context;
|
|
532
|
+
const preReceipt = phpTruthy(isPreReceipt);
|
|
533
|
+
const order = await this.orders.findOrFail(id, {withTrashed: true});
|
|
534
|
+
if (preReceipt) {
|
|
535
|
+
this.orders.assertMutable(order, 'orders.printBillReceipt:preReceipt');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const team = await this.printers.findTeam(order.team_id);
|
|
539
|
+
const timeZone = (team && team.timezone) || DEFAULT_TIMEZONE;
|
|
540
|
+
const terminal = context.getTerminal();
|
|
541
|
+
const cashier = await this.orders.snapshot.findTeamUserWithUser(context.employeeId);
|
|
542
|
+
const receipts = await this.receiptsOf(team);
|
|
543
|
+
const config = receipts.bill_receipt_config;
|
|
544
|
+
|
|
545
|
+
if (phpTruthy(receipts.auto_print_on_close)) {
|
|
546
|
+
// allowClosed: заказ здесь уже закрыт, guard обходим осознанно. Результат, как и в
|
|
547
|
+
// Laravel, не влияет на печать счёта.
|
|
548
|
+
await this.sendToWorkshops(order.id, {allowClosed: true});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const paymentsRow = findRow(config, 'payments');
|
|
552
|
+
const productsRow = findRow(config, 'order_products');
|
|
553
|
+
const showCharge = phpTruthy(paymentsRow?.config?.change?.value);
|
|
554
|
+
const showTableServiceCharge = phpTruthy(productsRow?.config?.service_charge?.value);
|
|
555
|
+
|
|
556
|
+
// Логотип — только если его печать включена в строке `logo` конфига и он есть у команды.
|
|
557
|
+
// На кассе это копия из снапшота (team_logos), в облаке — файл с диска картинки (findTeamLogo).
|
|
558
|
+
const logoRow = findRow(config, 'logo');
|
|
559
|
+
const logo = team && logoRow && phpTruthy(logoRow.print) ? await this.printers.findTeamLogo(team.id) : null;
|
|
560
|
+
|
|
561
|
+
const printedAt = now();
|
|
562
|
+
const source = order.source;
|
|
563
|
+
let hall = null;
|
|
564
|
+
let table = null;
|
|
565
|
+
if (source && source.type === DINE_IN) {
|
|
566
|
+
hall = alive(order.hall);
|
|
567
|
+
table = alive(order.table);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const payments = [];
|
|
571
|
+
for (const payment of order.payments) {
|
|
572
|
+
const method = await this.printers.findPaymentMethod(payment.payment_method_id);
|
|
573
|
+
// MoneyCast превращает null в 0, поэтому `real_amount ?? amount` в PHP — всегда real_amount.
|
|
574
|
+
const change = money(payment.change);
|
|
575
|
+
payments.push({
|
|
576
|
+
label: method ? method.name : null,
|
|
577
|
+
value: showCharge ? money(payment.real_amount) : money(payment.amount),
|
|
578
|
+
change: change > 0 && showCharge ? change : 0,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
order.calculate(true, false);
|
|
583
|
+
|
|
584
|
+
const openedAt = order.opened_at ? parse(order.opened_at) : now();
|
|
585
|
+
const data = {
|
|
586
|
+
logo,
|
|
587
|
+
number: order.number,
|
|
588
|
+
opened_at: formatZoned(openedAt, timeZone),
|
|
589
|
+
closed_at: order.closed_at ? formatZoned(parse(order.closed_at), timeZone) : null,
|
|
590
|
+
printed_at: formatZoned(printedAt, timeZone),
|
|
591
|
+
cashier: cashier ? cashier.user_name ?? null : null,
|
|
592
|
+
waiter: order.waiter ? order.waiter.user_name ?? null : null,
|
|
593
|
+
id: order.id,
|
|
594
|
+
total: order.total,
|
|
595
|
+
subtotal: order.subtotal,
|
|
596
|
+
discount: order.discount_amount,
|
|
597
|
+
service_charge: order.service_charge_amount,
|
|
598
|
+
deposit_gap: round2(order.deposit_gap),
|
|
599
|
+
deposit_amount: round2(order.deposit_amount),
|
|
600
|
+
order_source_type: source ? source.type : null,
|
|
601
|
+
order_source_label: source ? source.name : null,
|
|
602
|
+
hall: hall ? hall.name : null,
|
|
603
|
+
table: table ? table.label : null,
|
|
604
|
+
courier: '',
|
|
605
|
+
comment: order.comment ?? null,
|
|
606
|
+
delivery_address: '',
|
|
607
|
+
print_name: receipts.print_name_of === 'team'
|
|
608
|
+
? (team ? translated(team.name, this.locale()) : null)
|
|
609
|
+
: (context.getCompany() ? context.getCompany().name : null),
|
|
610
|
+
payments,
|
|
611
|
+
// appends Laravel-контроллер не пробрасывает (всегда []), фронт шлёт их зря.
|
|
612
|
+
appends: [],
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const products = this.billProducts(order, showTableServiceCharge);
|
|
616
|
+
|
|
617
|
+
const printTerminal = await this.resolvePrintTerminal(terminal);
|
|
618
|
+
const isDelegated = PrintService.isDelegated(terminal, printTerminal);
|
|
619
|
+
|
|
620
|
+
let printer = await this.printers.findBillPrinter(printTerminal ? printTerminal.id : null);
|
|
621
|
+
// При делегировании печать привязана к целевому терминалу — без fallback по компании.
|
|
622
|
+
if (!printer && !isDelegated) {
|
|
623
|
+
printer = await this.printers.findCompanyBillPrinter(terminal ? terminal.company_id : context.companyId);
|
|
624
|
+
}
|
|
625
|
+
if (!printer) {
|
|
626
|
+
// Имя терминала в тексте — оператору видно, где отметить принтер «Qəbz»
|
|
627
|
+
// (при делегировании это НЕ тот терминал, с которого нажали печать).
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
title: 'Printer tapılmadı',
|
|
631
|
+
message: isDelegated
|
|
632
|
+
? `"${str(printTerminal.name)}" terminalında qəbz printeri tapılmadı`
|
|
633
|
+
: 'Qəbz çapı üçün printer tapılmadı',
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
data.products = products;
|
|
638
|
+
const printData = {
|
|
639
|
+
data,
|
|
640
|
+
terminal_id: printer.terminal_id,
|
|
641
|
+
config,
|
|
642
|
+
printer: printerPayload(printer, !preReceipt && toBool(printer.cashdraw)),
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const response = await this.socketRequest({
|
|
646
|
+
terminalId: printData.terminal_id,
|
|
647
|
+
eventName: 'orders:printBillReceipt',
|
|
648
|
+
data: printData,
|
|
649
|
+
requestId: uuid(),
|
|
650
|
+
timeout: BILL_TIMEOUT_MS,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
if (!isSuccess(response)) {
|
|
654
|
+
const printerTerminal = await this.printers.findTerminal(printer.terminal_id);
|
|
655
|
+
// Хвостовой апостроф — опечатка исходной строки Laravel; оставлен ради паритета текста.
|
|
656
|
+
return {
|
|
657
|
+
success: false,
|
|
658
|
+
...printerError(printData.printer.name, printerTerminal ? printerTerminal.name : null, "'"),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await this.orders.saveEvents([{
|
|
663
|
+
event: 'orders:printBillReceipt',
|
|
664
|
+
time: nowSql(),
|
|
665
|
+
order_id: order.id,
|
|
666
|
+
}]);
|
|
667
|
+
|
|
668
|
+
// Laravel кладёт сюда Carbon в зоне команды, и Eloquent пишет его стенные часы как есть.
|
|
669
|
+
// Повторяем то же значение: в облачном контуре обе реализации пишут в одну колонку.
|
|
670
|
+
order.printed_at = formatZoned(printedAt, timeZone);
|
|
671
|
+
|
|
672
|
+
// Пречек — снимок суммы, гость ещё сидит: таймер НЕ останавливаем. Финальный чек
|
|
673
|
+
// останавливает повременные позиции (в памяти, сохранятся вместе с пересчётом).
|
|
674
|
+
if (!preReceipt) {
|
|
675
|
+
const stoppedAt = nowSql();
|
|
676
|
+
for (const item of order.products) {
|
|
677
|
+
if (item.type === TYPE_TIMER && !item.is_stopped) {
|
|
678
|
+
item.is_stopped = true;
|
|
679
|
+
item.stopped_at = stoppedAt;
|
|
680
|
+
item.receipt_print_quantity = 1;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
order.calculate(true, true);
|
|
686
|
+
await this.orders.orders.save(order);
|
|
687
|
+
await this.orders.emitPatch(order.id);
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ------------------------------------------------------------ принтер счёта терминала
|
|
692
|
+
|
|
693
|
+
/** PrinterController::getReceiptPrinter — принтер «Qəbz» текущего терминала или null. */
|
|
694
|
+
async getReceiptPrinter() {
|
|
695
|
+
const printer = await this.printers.findBillPrinter(this.context.terminalId);
|
|
696
|
+
if (!printer) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
name: printer.name,
|
|
701
|
+
type: printer.type,
|
|
702
|
+
ip_address: printer.ip_address,
|
|
703
|
+
mac_address: printer.mac_address ?? null,
|
|
704
|
+
vendor_id: printer.vendor_id,
|
|
705
|
+
product_id: printer.product_id,
|
|
706
|
+
bell: flag(printer.bell),
|
|
707
|
+
cashdraw: flag(printer.cashdraw),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
PrintService.resetPrintMarks = resetPrintMarks;
|
|
713
|
+
PrintService.formatZoned = formatZoned;
|
|
714
|
+
|
|
715
|
+
module.exports = PrintService;
|