@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,202 @@
|
|
|
1
|
+
const {io} = require('socket.io-client');
|
|
2
|
+
const {normalizeApiUrl} = require('./cloud');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Сигнальный сокет к облаку: «в компании что-то поменялось — сделай pull».
|
|
6
|
+
*
|
|
7
|
+
* Подключаемся как прокси-сокет (is_proxy_server): облачный Node кладёт такой сокет в комнату
|
|
8
|
+
* компании, но обработчиков фронта ему не вешает. terminal_id в query НЕ передаём — иначе облако
|
|
9
|
+
* считало бы этот сокет терминалом и адресовало бы ему запросы печати (socket/request).
|
|
10
|
+
*
|
|
11
|
+
* Данные из патчей не применяем: чек всё равно приходит pull'ом целиком, а сигнал лишь
|
|
12
|
+
* говорит, когда его делать. Так у зеркала один вход, и порядок событий не важен.
|
|
13
|
+
*
|
|
14
|
+
* Модель M (OFFLINE-FIRST-PLAN §12): тот же сокет — канал мастер-кассы. В handshake.auth уходят
|
|
15
|
+
* токен и заголовки терминала; облачный Node с MASTER_RELAY_ENABLED сверяет их с Laravel и
|
|
16
|
+
* отвечает `master:ready`. После этого по сокету ходят:
|
|
17
|
+
* облако → касса: master:request (событие клиента облака, ответ ack), sync:pull {ids}, pos:mode;
|
|
18
|
+
* касса → облако: master:broadcast (бродкаст ядра клиентам облака),
|
|
19
|
+
* master:terminalRequest (печать на терминал, подключённый только к облаку).
|
|
20
|
+
* Старый облачный Node auth просто игнорирует — сокет работает как раньше.
|
|
21
|
+
*
|
|
22
|
+
* sync:snapshot {source_terminal_id} — в облаке поменялся справочник, который касса правит и сама
|
|
23
|
+
* (принтеры): снапшот нужен сейчас, а не через 10 минут. Своё эхо (source = эта касса) пропускаем.
|
|
24
|
+
*/
|
|
25
|
+
const PULL_EVENTS = new Set(['orders:patch', 'orders:patchBatch', 'orders:update']);
|
|
26
|
+
|
|
27
|
+
// Служебные и адресные каналы: локальным клиентам их не пересылаем.
|
|
28
|
+
const isServiceEvent = (eventName) => eventName.startsWith('api:')
|
|
29
|
+
|| eventName.startsWith('request:')
|
|
30
|
+
|| eventName.startsWith('response:')
|
|
31
|
+
|| eventName.startsWith('master:')
|
|
32
|
+
|| eventName === 'pos:mode'
|
|
33
|
+
|| eventName === 'sync:pull'
|
|
34
|
+
|| eventName === 'sync:snapshot';
|
|
35
|
+
|
|
36
|
+
const noop = () => {};
|
|
37
|
+
|
|
38
|
+
class CloudSignal {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {object} options.session сессия терминала (тот же объект, что у CloudClient)
|
|
42
|
+
* @param {Function} options.onChange сигнал «облако что-то поменяло» (pull по курсору)
|
|
43
|
+
* @param {Function} [options.onSnapshot] сигнал «облако поменяло справочник» (sync:snapshot)
|
|
44
|
+
* @param {object} [options.master] обработчики модели M:
|
|
45
|
+
* isLocal() — касса сейчас истина заведения (local/draining);
|
|
46
|
+
* onRequest(payload) — событие клиента облака → Promise<ack>;
|
|
47
|
+
* onPull(ids) — sync:pull;
|
|
48
|
+
* onMode(state) — pos:mode;
|
|
49
|
+
* onBroadcast(event, args) — бродкаст облака для локальных клиентов.
|
|
50
|
+
*/
|
|
51
|
+
constructor({session, onChange, onSnapshot = noop, onConnect = noop, onDisconnect = noop, master = null}) {
|
|
52
|
+
this.session = session;
|
|
53
|
+
this.onChange = onChange;
|
|
54
|
+
this.onSnapshot = onSnapshot;
|
|
55
|
+
this.onConnectCb = onConnect;
|
|
56
|
+
this.onDisconnectCb = onDisconnect;
|
|
57
|
+
this.master = master;
|
|
58
|
+
this.socket = null;
|
|
59
|
+
this.connected = false;
|
|
60
|
+
// облачный Node признал сокет мастер-кассой (master:ready)
|
|
61
|
+
this.relayReady = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isLocal() {
|
|
65
|
+
return Boolean(this.master && this.master.isLocal());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start() {
|
|
69
|
+
if (this.socket || !this.session.api_url || !this.session.company_id) {
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
const session = this.session;
|
|
73
|
+
this.socket = io(normalizeApiUrl(session.api_url), {
|
|
74
|
+
query: {
|
|
75
|
+
company_id: session.company_id,
|
|
76
|
+
is_proxy_server: 'true',
|
|
77
|
+
},
|
|
78
|
+
// Функция, а не объект: токен/терминал могут смениться на месте (setSession),
|
|
79
|
+
// и переподключение должно предъявить актуальные.
|
|
80
|
+
// Токен уходит всегда: облако с SOCKET_AUTH=enforce без него сокет не пустит.
|
|
81
|
+
auth: (cb) => cb({
|
|
82
|
+
...(this.master ? {master: true} : {}),
|
|
83
|
+
token: session.token,
|
|
84
|
+
team_id: session.team_id,
|
|
85
|
+
company_id: session.company_id,
|
|
86
|
+
terminal_id: session.terminal_id,
|
|
87
|
+
}),
|
|
88
|
+
reconnection: true,
|
|
89
|
+
reconnectionDelay: 2000,
|
|
90
|
+
reconnectionDelayMax: 15000,
|
|
91
|
+
});
|
|
92
|
+
this.socket.on('connect', () => {
|
|
93
|
+
this.connected = true;
|
|
94
|
+
// Пока сокета не было, патчи могли пролететь мимо — догоняем pull'ом.
|
|
95
|
+
this.onConnectCb();
|
|
96
|
+
this.onChange();
|
|
97
|
+
if (this.master) {
|
|
98
|
+
// И режим заведения мог смениться, пока нас не было.
|
|
99
|
+
this.master.onMode(null);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
this.socket.on('disconnect', () => {
|
|
103
|
+
this.connected = false;
|
|
104
|
+
this.relayReady = false;
|
|
105
|
+
this.onDisconnectCb();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (this.master) {
|
|
109
|
+
this.socket.on('master:ready', (payload) => {
|
|
110
|
+
this.relayReady = Boolean(payload && payload.ok);
|
|
111
|
+
if (!this.relayReady) {
|
|
112
|
+
console.warn(`[sync] cloud relay refused this terminal: ${payload && payload.reason}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
this.socket.on('master:request', async (payload, ack) => {
|
|
116
|
+
if (typeof ack !== 'function') {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
ack(await this.master.onRequest(payload || {}));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error('[sync] master request failed:', e);
|
|
123
|
+
ack({relayed: false, error_code: 'master_error', message: e.message});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.socket.onAny((eventName, ...args) => {
|
|
129
|
+
if (this.master && eventName === 'pos:mode') {
|
|
130
|
+
this.master.onMode(args[0] || null);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (this.master && eventName === 'sync:pull') {
|
|
134
|
+
const ids = args[0] && Array.isArray(args[0].ids) ? args[0].ids : [];
|
|
135
|
+
if (ids.length) {
|
|
136
|
+
this.master.onPull(ids);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (eventName === 'sync:snapshot') {
|
|
141
|
+
const source = args[0] && args[0].source_terminal_id;
|
|
142
|
+
if (!source || source !== this.session.terminal_id) {
|
|
143
|
+
this.onSnapshot();
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (PULL_EVENTS.has(eventName)) {
|
|
148
|
+
// Истина у этой кассы: облачные патчи — эхо её же выгрузки, pull по ним не нужен.
|
|
149
|
+
// Настоящие расхождения облако присылает адресно (sync:pull).
|
|
150
|
+
if (!this.isLocal()) {
|
|
151
|
+
this.onChange();
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// KDS, экраны готовности и клиентские бродкасты облака — локальным клиентам.
|
|
156
|
+
if (this.isLocal() && !isServiceEvent(eventName)) {
|
|
157
|
+
this.master.onBroadcast(eventName, args);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Бродкаст ядра мастера → клиенты заведения, подключённые к облаку. */
|
|
164
|
+
broadcast(eventName, args) {
|
|
165
|
+
if (!this.socket || !this.connected || !this.relayReady) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
this.socket.emit('master:broadcast', {event: eventName, args});
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Запрос к терминалу, которого нет в локальном сокете (например, печать на PWA облака).
|
|
174
|
+
* null — канала нет, вызывающий отвечает «No socket» как раньше.
|
|
175
|
+
*/
|
|
176
|
+
terminalRequest({terminalId, eventName, data, requestId, timeout}) {
|
|
177
|
+
if (!this.socket || !this.connected || !this.relayReady) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const waitMs = Number(timeout) > 0 ? Number(timeout) : 10000;
|
|
181
|
+
return this.socket.timeout(waitMs + 5000).emitWithAck('master:terminalRequest', {
|
|
182
|
+
terminal_id: terminalId,
|
|
183
|
+
event: eventName,
|
|
184
|
+
data,
|
|
185
|
+
request_id: requestId,
|
|
186
|
+
timeout: waitMs,
|
|
187
|
+
}).catch(() => ({success: false, message: 'Timeout error'}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
stop() {
|
|
191
|
+
if (this.socket) {
|
|
192
|
+
this.socket.offAny();
|
|
193
|
+
this.socket.removeAllListeners();
|
|
194
|
+
this.socket.disconnect();
|
|
195
|
+
this.socket = null;
|
|
196
|
+
}
|
|
197
|
+
this.connected = false;
|
|
198
|
+
this.relayReady = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {CloudSignal, PULL_EVENTS, isServiceEvent};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const kv = require('./kv');
|
|
2
|
+
const {timeouts} = require('./config');
|
|
3
|
+
const {quote} = require('./store');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Снапшот справочников: облако → локальная БД (товары, залы, столы, сотрудники, настройки…).
|
|
7
|
+
*
|
|
8
|
+
* Таблица заменяется целиком: удалённое в бэк-офисе должно пропасть и на кассе. Чеки сюда
|
|
9
|
+
* не входят — их ведёт зеркало (mirror.js), а служебные таблицы синка не трогаются вовсе.
|
|
10
|
+
* Весь снапшот применяется одним блоком: касса никогда не видит половину старого каталога
|
|
11
|
+
* и половину нового.
|
|
12
|
+
*/
|
|
13
|
+
const DENY = new Set([
|
|
14
|
+
'orders', 'order_products', 'order_product_modifier_sets', 'order_payments', 'order_events',
|
|
15
|
+
// тикеты кухни едут вместе с чеком (агрегат push/зеркало), а не справочником
|
|
16
|
+
'kds_tickets', 'kds_ticket_items',
|
|
17
|
+
'outbox', 'idempotency_keys', 'number_sequences', 'kv', 'sync_dirty', 'sync_suppress',
|
|
18
|
+
// логотип едет отдельным ключом team_logo (applyTeamLogo)
|
|
19
|
+
'team_logos',
|
|
20
|
+
'_migrations',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Строки, которые нельзя стирать при замене таблицы:
|
|
25
|
+
* shifts — смена, открытая/закрытая офлайн и ещё не отправленная;
|
|
26
|
+
* products — «свободный» товар (addExtra), созданный кассой: облако его пока не знает;
|
|
27
|
+
* printers, printer_workshop — принтер, настроенный на кассе без облака (printers.update /
|
|
28
|
+
* delete / syncAddress в ядре), вместе с его цехами, пока push его не доставил.
|
|
29
|
+
*/
|
|
30
|
+
const DIRTY_PRINTERS = "SELECT id FROM sync_dirty WHERE entity = 'printer'";
|
|
31
|
+
const KEEP_LOCAL = {
|
|
32
|
+
shifts: "id IN (SELECT id FROM sync_dirty WHERE entity = 'shift')",
|
|
33
|
+
products: 'synced_at IS NULL',
|
|
34
|
+
printers: `id IN (${DIRTY_PRINTERS})`,
|
|
35
|
+
printer_workshop: `printer_id IN (${DIRTY_PRINTERS})`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Входящие строки, которые не пишутся: локальная версия ещё не отправлена и главнее.
|
|
40
|
+
* Возвращает функцию «пропустить строку?» или null.
|
|
41
|
+
*/
|
|
42
|
+
const skipDirty = (store, table) => {
|
|
43
|
+
const dirty = (entity) => new Set(
|
|
44
|
+
store.all('SELECT id FROM sync_dirty WHERE entity = ?', [entity]).map((row) => row.id)
|
|
45
|
+
);
|
|
46
|
+
if (table === 'shifts') {
|
|
47
|
+
const ids = dirty('shift');
|
|
48
|
+
return (row) => ids.has(row.id);
|
|
49
|
+
}
|
|
50
|
+
if (table === 'printers') {
|
|
51
|
+
const ids = dirty('printer');
|
|
52
|
+
return (row) => ids.has(row.id);
|
|
53
|
+
}
|
|
54
|
+
if (table === 'printer_workshop') {
|
|
55
|
+
const ids = dirty('printer');
|
|
56
|
+
return (row) => ids.has(row.printer_id);
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const applyTables = (store, tables, serverTime) => {
|
|
62
|
+
const applied = {};
|
|
63
|
+
const skipped = [];
|
|
64
|
+
for (const [table, rows] of Object.entries(tables || {})) {
|
|
65
|
+
if (DENY.has(table) || !Array.isArray(rows) || !store.hasTable(table)) {
|
|
66
|
+
skipped.push(table);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const columns = store.columns(table);
|
|
70
|
+
const keep = KEEP_LOCAL[table];
|
|
71
|
+
store.run(`DELETE FROM ${quote(table)}${keep ? ` WHERE NOT (${keep})` : ''}`);
|
|
72
|
+
|
|
73
|
+
const skip = skipDirty(store, table);
|
|
74
|
+
|
|
75
|
+
// Связки (printer_workshop, terminal_workshop…) своего id не имеют: таблица только что
|
|
76
|
+
// очищена целиком, поэтому их строки просто вставляются. Пропускать их нельзя — пустая
|
|
77
|
+
// printer_workshop оставила бы офлайн-печать цехов без принтеров.
|
|
78
|
+
const keyed = columns.has('id');
|
|
79
|
+
let count = 0;
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
if (!row || typeof row !== 'object' || (keyed && !row.id)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (skip && skip(row)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const value = columns.has('synced_at') ? {...row, synced_at: serverTime} : row;
|
|
88
|
+
if (keyed) {
|
|
89
|
+
store.upsert(table, value);
|
|
90
|
+
} else {
|
|
91
|
+
store.insert(table, value);
|
|
92
|
+
}
|
|
93
|
+
count += 1;
|
|
94
|
+
}
|
|
95
|
+
applied[table] = count;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Язык команды: в облаке это teams.language_id → languages.code, а ядро читает
|
|
99
|
+
// готовый teams.language_code (см. Context::resolveLocale).
|
|
100
|
+
if (store.hasTable('languages') && store.columns('teams').has('language_id')) {
|
|
101
|
+
store.run(
|
|
102
|
+
`UPDATE teams SET language_code = (SELECT code FROM languages WHERE languages.id = teams.language_id)
|
|
103
|
+
WHERE language_id IS NOT NULL
|
|
104
|
+
AND EXISTS (SELECT 1 FROM languages WHERE languages.id = teams.language_id AND code IS NOT NULL)`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return {applied, skipped};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Картинка, копия которой уже лежит на кассе, — облако её повторно не шлёт. Копия без данных
|
|
112
|
+
* (облако не смогло прочитать файл) не в счёт: пусть облако попробует снова.
|
|
113
|
+
*/
|
|
114
|
+
const heldLogoId = (store, teamId) => {
|
|
115
|
+
if (!teamId) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const row = store.get('SELECT image_id FROM team_logos WHERE team_id = ? AND data IS NOT NULL', [teamId]);
|
|
119
|
+
return row ? row.image_id : null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Логотип команды для счёта (ключ team_logo, SyncService::teamLogo):
|
|
124
|
+
* {id, data} — новая картинка; {id: null} — логотипа у команды больше нет;
|
|
125
|
+
* {id, unchanged: true} — у кассы та же картинка.
|
|
126
|
+
* Ключа нет (старое облако) или null (облако не прочитало файл) — копия кассы не трогается.
|
|
127
|
+
*/
|
|
128
|
+
const applyTeamLogo = (store, teamId, logo) => {
|
|
129
|
+
if (!teamId || !logo || typeof logo !== 'object' || logo.unchanged) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!logo.id) {
|
|
133
|
+
store.run('DELETE FROM team_logos WHERE team_id = ?', [teamId]);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
store.run(
|
|
137
|
+
`INSERT INTO team_logos (team_id, image_id, data, updated_at) VALUES (?, ?, ?, datetime('now'))
|
|
138
|
+
ON CONFLICT (team_id) DO UPDATE SET image_id = excluded.image_id, data = excluded.data, updated_at = excluded.updated_at`,
|
|
139
|
+
[teamId, String(logo.id), typeof logo.data === 'string' && logo.data ? logo.data : null]
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Снапшот мастера/одиночной кассы: справочники + blob app.init для офлайн-старта терминала. */
|
|
144
|
+
const pullSnapshot = async ({store, cloud}) => {
|
|
145
|
+
const teamId = (cloud.session && cloud.session.team_id) || null;
|
|
146
|
+
const logoId = heldLogoId(store, teamId);
|
|
147
|
+
const response = await cloud.call(
|
|
148
|
+
'sync.snapshot',
|
|
149
|
+
logoId ? {team_logo_id: logoId} : {},
|
|
150
|
+
{timeout: timeouts().snapshot}
|
|
151
|
+
) || {};
|
|
152
|
+
if (!response.tables || typeof response.tables !== 'object') {
|
|
153
|
+
throw new Error('sync.snapshot: empty response');
|
|
154
|
+
}
|
|
155
|
+
const serverTime = response.server_time || new Date().toISOString();
|
|
156
|
+
|
|
157
|
+
const result = await store.block((s) => {
|
|
158
|
+
const tables = applyTables(s, response.tables, serverTime);
|
|
159
|
+
applyTeamLogo(s, teamId, response.team_logo);
|
|
160
|
+
if (response.init) {
|
|
161
|
+
kv.set(s, 'init', response.init);
|
|
162
|
+
}
|
|
163
|
+
kv.set(s, 'snapshot_at', new Date().toISOString());
|
|
164
|
+
return tables;
|
|
165
|
+
}, {suppress: true});
|
|
166
|
+
|
|
167
|
+
if (result.skipped.length) {
|
|
168
|
+
console.log(`[sync] snapshot: skipped tables ${result.skipped.join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Ведомый терминал своей БД не ведёт, но офлайн-старт ему всё равно нужен: хранит свой ответ
|
|
175
|
+
* app.init (у каждого терминала он свой — цеха, принтеры, настройки терминала).
|
|
176
|
+
*/
|
|
177
|
+
const pullInitOnly = async ({store, cloud}) => {
|
|
178
|
+
const init = await cloud.call('app.init', {}, {timeout: timeouts().snapshot});
|
|
179
|
+
if (!init || typeof init !== 'object') {
|
|
180
|
+
throw new Error('app.init: empty response');
|
|
181
|
+
}
|
|
182
|
+
await store.block((s) => {
|
|
183
|
+
kv.set(s, 'init', init);
|
|
184
|
+
kv.set(s, 'snapshot_at', new Date().toISOString());
|
|
185
|
+
});
|
|
186
|
+
return {applied: {init: 1}, skipped: []};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
module.exports = {pullSnapshot, pullInitOnly, applyTables, DENY};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
const {getDatabase} = require('../db/Database');
|
|
2
|
+
const {normalizeParam} = require('../db/driver');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Доступ синка к локальной SQLite — синхронными блоками поверх того же соединения, что у ядра.
|
|
6
|
+
*
|
|
7
|
+
* Почему не мьютекс. Соединение одно на процесс, и любая запись посреди открытой транзакции
|
|
8
|
+
* уезжает в её COMMIT/ROLLBACK. Ядро транзакций почти не держит (единственная —
|
|
9
|
+
* idempotency.reclaimStale), зато между его await'ами может вклиниться что угодно. Поэтому
|
|
10
|
+
* блок синка ждёт, пока транзакции нет, и дальше исполняется целиком синхронно:
|
|
11
|
+
* BEGIN → работа → COMMIT без единого await внутри. Никакая запись ядра не попадёт ни внутрь
|
|
12
|
+
* блока, ни между его чтением и записью.
|
|
13
|
+
*
|
|
14
|
+
* Синхронно читается и агрегат для push: иначе строка, прочитанная посреди чужой транзакции,
|
|
15
|
+
* могла бы уехать в облако, а потом откатиться локально.
|
|
16
|
+
*/
|
|
17
|
+
const IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
18
|
+
|
|
19
|
+
const quote = (name) => {
|
|
20
|
+
if (!IDENT.test(name)) {
|
|
21
|
+
throw new Error(`sync: bad identifier ${name}`);
|
|
22
|
+
}
|
|
23
|
+
return `"${name}"`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
|
|
28
|
+
// 2026-09-24T12:00:00.000000Z · 2026-09-24 12:00:00+00 · 2026-09-24T12:00:00+04:00
|
|
29
|
+
const ZONED_TIMESTAMP = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)(\.\d+)?(Z|[+-]\d{2}(?::?\d{2})?)?$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Время из облака → локальный формат ядра ('YYYY-MM-DD HH:MM:SS', UTC). Laravel сериализует
|
|
33
|
+
* даты в ISO с зоной, Postgres в текстовом виде — с '+00'. Строки без зоны и без 'T' уже
|
|
34
|
+
* в нужном формате и не трогаются.
|
|
35
|
+
*/
|
|
36
|
+
const toSqlTimestamp = (value) => {
|
|
37
|
+
const match = ZONED_TIMESTAMP.exec(value);
|
|
38
|
+
if (!match || (!match[4] && !value.includes('T'))) {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
let zone = match[4] || 'Z';
|
|
42
|
+
if (/^[+-]\d{2}$/.test(zone)) {
|
|
43
|
+
zone += ':00';
|
|
44
|
+
} else if (/^[+-]\d{4}$/.test(zone)) {
|
|
45
|
+
zone = `${zone.slice(0, 3)}:${zone.slice(3)}`;
|
|
46
|
+
}
|
|
47
|
+
const time = match[2].length === 5 ? `${match[2]}:00` : match[2];
|
|
48
|
+
const fraction = match[3] ? match[3].slice(0, 4) : '';
|
|
49
|
+
const date = new Date(`${match[1]}T${time}${fraction}${zone}`);
|
|
50
|
+
if (Number.isNaN(date.getTime())) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
return date.toISOString().slice(0, 19).replace('T', ' ');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Значение из облака → значение для SQLite-колонки. */
|
|
57
|
+
const toLocalValue = (value) => {
|
|
58
|
+
if (value === undefined || value === null) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === 'boolean') {
|
|
62
|
+
return value ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === 'string') {
|
|
65
|
+
return toSqlTimestamp(value);
|
|
66
|
+
}
|
|
67
|
+
if (value instanceof Date) {
|
|
68
|
+
return value.toISOString().slice(0, 19).replace('T', ' ');
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === 'object') {
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
73
|
+
return normalizeParam(value);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
class SyncStore {
|
|
77
|
+
constructor(database) {
|
|
78
|
+
this.database = database;
|
|
79
|
+
this.driver = database.connection();
|
|
80
|
+
this.raw = this.driver.raw;
|
|
81
|
+
this.statements = new Map();
|
|
82
|
+
this.columnCache = new Map();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
prepare(sql) {
|
|
86
|
+
if (!this.statements.has(sql)) {
|
|
87
|
+
this.statements.set(sql, this.raw.prepare(sql));
|
|
88
|
+
}
|
|
89
|
+
return this.statements.get(sql);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
all(sql, params = []) {
|
|
93
|
+
return this.prepare(sql).all(...params.map(normalizeParam));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get(sql, params = []) {
|
|
97
|
+
const rows = this.all(sql, params);
|
|
98
|
+
return rows.length ? rows[0] : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
run(sql, params = []) {
|
|
102
|
+
const result = this.prepare(sql).run(...params.map(normalizeParam));
|
|
103
|
+
return result?.changes ?? 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Колонки локальной таблицы: name → {notnull, hasDefault}. Пустая Map — таблицы нет. */
|
|
107
|
+
columns(table) {
|
|
108
|
+
if (!this.columnCache.has(table)) {
|
|
109
|
+
const rows = this.all(`PRAGMA table_info(${quote(table)})`);
|
|
110
|
+
this.columnCache.set(table, new Map(rows.map((row) => [row.name, {
|
|
111
|
+
notnull: Boolean(row.notnull),
|
|
112
|
+
hasDefault: row.dflt_value !== null && row.dflt_value !== undefined,
|
|
113
|
+
}])));
|
|
114
|
+
}
|
|
115
|
+
return this.columnCache.get(table);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
hasTable(table) {
|
|
119
|
+
return IDENT.test(table) && this.columns(table).size > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Вставка-или-обновление строки облака. Пишутся только колонки, которые есть локально:
|
|
124
|
+
* облачная схема шире, а лишняя колонка уронила бы весь блок. null в NOT NULL-колонку
|
|
125
|
+
* не пишем — пусть сработает DEFAULT (Laravel такие поля при вставке не шлёт вовсе).
|
|
126
|
+
*/
|
|
127
|
+
upsert(table, row) {
|
|
128
|
+
const {names, values} = this.localRow(table, row);
|
|
129
|
+
if (!names.includes('id')) {
|
|
130
|
+
throw new Error(`sync: row of ${table} without id`);
|
|
131
|
+
}
|
|
132
|
+
const updates = names.filter((name) => name !== 'id')
|
|
133
|
+
.map((name) => `${quote(name)} = excluded.${quote(name)}`);
|
|
134
|
+
const sql = `INSERT INTO ${quote(table)} (${names.map(quote).join(', ')})
|
|
135
|
+
VALUES (${names.map(() => '?').join(', ')})
|
|
136
|
+
ON CONFLICT (id) DO ${updates.length ? `UPDATE SET ${updates.join(', ')}` : 'NOTHING'}`;
|
|
137
|
+
return this.run(sql, values);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Строка связки без собственного id (printer_workshop, terminal_workshop…): такие таблицы
|
|
142
|
+
* снапшот заменяет целиком, поэтому достаточно простой вставки.
|
|
143
|
+
*/
|
|
144
|
+
insert(table, row) {
|
|
145
|
+
const {names, values} = this.localRow(table, row);
|
|
146
|
+
if (!names.length) {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
return this.run(
|
|
150
|
+
`INSERT INTO ${quote(table)} (${names.map(quote).join(', ')}) VALUES (${names.map(() => '?').join(', ')})`,
|
|
151
|
+
values
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Пересечение строки облака с локальными колонками, значения приведены к SQLite. */
|
|
156
|
+
localRow(table, row) {
|
|
157
|
+
const columns = this.columns(table);
|
|
158
|
+
const names = [];
|
|
159
|
+
const values = [];
|
|
160
|
+
for (const [name, value] of Object.entries(row)) {
|
|
161
|
+
const column = columns.get(name);
|
|
162
|
+
if (!column) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const local = toLocalValue(value);
|
|
166
|
+
if (local === null && column.notnull) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
names.push(name);
|
|
170
|
+
values.push(local);
|
|
171
|
+
}
|
|
172
|
+
return {names, values};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `id IN (?, ?, ...)` для списка; пустой список — условие, которое ничего не выбирает. */
|
|
176
|
+
static inList(column, ids) {
|
|
177
|
+
if (!ids.length) {
|
|
178
|
+
return {sql: '0', params: []};
|
|
179
|
+
}
|
|
180
|
+
return {sql: `${column} IN (${ids.map(() => '?').join(', ')})`, params: ids};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Синхронный блок. fn обязан быть синхронным: вернул Promise — блок откатывается.
|
|
185
|
+
* suppress — триггеры sync_dirty молчат (пишем облачные данные, а не правки кассы).
|
|
186
|
+
*/
|
|
187
|
+
async block(fn, {suppress = false, waitMs = 30000} = {}) {
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
while (this.driver.inTransaction) {
|
|
190
|
+
if (Date.now() - started > waitMs) {
|
|
191
|
+
throw new Error('sync: core transaction is still open');
|
|
192
|
+
}
|
|
193
|
+
await sleep(5);
|
|
194
|
+
}
|
|
195
|
+
return this.blockNow(fn, {suppress});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
blockNow(fn, {suppress = false} = {}) {
|
|
199
|
+
this.raw.exec('BEGIN');
|
|
200
|
+
try {
|
|
201
|
+
if (suppress) {
|
|
202
|
+
this.run('INSERT OR IGNORE INTO sync_suppress (id) VALUES (1)');
|
|
203
|
+
}
|
|
204
|
+
const result = fn(this);
|
|
205
|
+
if (result && typeof result.then === 'function') {
|
|
206
|
+
throw new Error('sync: block callback must be synchronous');
|
|
207
|
+
}
|
|
208
|
+
if (suppress) {
|
|
209
|
+
this.run('DELETE FROM sync_suppress');
|
|
210
|
+
}
|
|
211
|
+
this.raw.exec('COMMIT');
|
|
212
|
+
return result;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
try {
|
|
215
|
+
this.raw.exec('ROLLBACK');
|
|
216
|
+
} catch (rollbackError) {
|
|
217
|
+
// транзакцию мог уже свернуть сам SQLite — исходная ошибка важнее
|
|
218
|
+
}
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let instance = null;
|
|
225
|
+
|
|
226
|
+
const getStore = async () => {
|
|
227
|
+
const database = await getDatabase();
|
|
228
|
+
if (!instance || instance.database !== database || instance.driver !== database.connection()) {
|
|
229
|
+
instance = new SyncStore(database);
|
|
230
|
+
}
|
|
231
|
+
return instance;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
module.exports = {SyncStore, getStore, toLocalValue, toSqlTimestamp, quote};
|
package/app/tcp.js
CHANGED
|
@@ -77,7 +77,16 @@ module.exports = function (io = null) {
|
|
|
77
77
|
socket.on('error', (err) => console.error('TCP Error:', err));
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// У TCP-канала нет ключа: кто достучался, тот рассылает в любую компанию. В проде Laravel
|
|
81
|
+
// ходит по HTTP (OVER_TCP=false) — тогда порт лучше не открывать вовсе (TCP_ENABLED=false)
|
|
82
|
+
// или хотя бы прибить к внутреннему адресу (TCP_HOST). Дефолты — как раньше.
|
|
83
|
+
if (process.env.TCP_ENABLED === 'false') {
|
|
84
|
+
console.log('TCP Server disabled (TCP_ENABLED=false)');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const tcpHost = process.env.TCP_HOST || '0.0.0.0';
|
|
88
|
+
const tcpPort = Number(process.env.TCP_PORT) || 3001;
|
|
89
|
+
server.listen(tcpPort, tcpHost, () => {
|
|
90
|
+
console.log(`TCP Server listening on ${tcpHost}:${tcpPort}`);
|
|
82
91
|
});
|
|
83
92
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alizadefaig/pos-server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.27",
|
|
4
4
|
"description": "Server in express js with Proxy to Main server if need and will work with Electron JS",
|
|
5
5
|
"homepage": "https://gitlab.com/faigalizade/pos-server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@alizadefaig/cafetap-escpos-usb": "^1.0.1",
|
|
15
15
|
"axios": "^1.13.2",
|
|
16
|
+
"bcryptjs": "^3.0.3",
|
|
16
17
|
"cors": "^2.8.5",
|
|
17
18
|
"escpos": "^3.0.0-alpha.6",
|
|
18
19
|
"escpos-network": "^3.0.0-alpha.5",
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"lodash": "^4.17.21",
|
|
22
23
|
"moment": "^2.30.1",
|
|
23
24
|
"multer": "^2.0.2",
|
|
25
|
+
"pg": "^8.22.0",
|
|
24
26
|
"redis": "^5.10.0",
|
|
25
27
|
"sequelize": "^6.37.7",
|
|
26
28
|
"sharp": "^0.34.5",
|
|
@@ -36,7 +38,8 @@
|
|
|
36
38
|
"lib": "lib"
|
|
37
39
|
},
|
|
38
40
|
"scripts": {
|
|
39
|
-
"test": "
|
|
41
|
+
"test": "node --test --test-concurrency=1 \"test/**/*.test.js\"",
|
|
42
|
+
"test:watch": "node --test --watch --test-concurrency=1 \"test/**/*.test.js\"",
|
|
40
43
|
"start": "node server.js"
|
|
41
44
|
}
|
|
42
|
-
}
|
|
45
|
+
}
|