@alizadefaig/pos-server 1.2.26 → 1.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +117 -0
- package/app/core/resources/casts.js +55 -0
- package/app/core/services/AuthService.js +468 -0
- package/app/core/services/InitService.js +279 -0
- package/app/core/services/KdsService.js +639 -0
- package/app/core/services/OrderProductService.js +309 -0
- package/app/core/services/OrderService.js +911 -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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Контекст запроса — порт PlatformService.
|
|
3
|
+
*
|
|
4
|
+
* В Laravel он живёт как singleton на запрос и раздаёт team/company/terminal/employee.
|
|
5
|
+
* Здесь то же самое, но данные берутся из БД ядра, а сам объект передаётся в сервисы явным
|
|
6
|
+
* аргументом: глобального контейнера в Node нет и заводить его незачем.
|
|
7
|
+
*
|
|
8
|
+
* Справочники подтягиваются ОДИН раз в load(), дальше геттеры синхронные: контекст читают
|
|
9
|
+
* из горячего пути десятки раз за операцию, и await на каждом обращении к team.settings
|
|
10
|
+
* превратил бы расчёт чека в цепочку лишних запросов.
|
|
11
|
+
*/
|
|
12
|
+
// Фронт шлёт незаполненный заголовок строкой 'null' / 'undefined' — это «нет значения».
|
|
13
|
+
const cleanId = (value) => {
|
|
14
|
+
if (value === null || value === undefined) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const trimmed = String(value).trim();
|
|
18
|
+
return trimmed === '' || trimmed === 'null' || trimmed === 'undefined' ? null : trimmed;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const UUID = /^[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}$/;
|
|
22
|
+
|
|
23
|
+
class Context {
|
|
24
|
+
constructor({teamId = null, companyId = null, terminalId = null, employeeId = null,
|
|
25
|
+
timestamp = null, ipAddress = null, operationId = null, db = null,
|
|
26
|
+
team = undefined, locale = undefined} = {}) {
|
|
27
|
+
this.teamId = cleanId(teamId);
|
|
28
|
+
this.companyId = cleanId(companyId);
|
|
29
|
+
this.terminalId = cleanId(terminalId);
|
|
30
|
+
this.employeeId = cleanId(employeeId);
|
|
31
|
+
this.timestamp = timestamp;
|
|
32
|
+
this.ipAddress = ipAddress;
|
|
33
|
+
this.operationId = operationId;
|
|
34
|
+
this.db = db;
|
|
35
|
+
|
|
36
|
+
this.team = null;
|
|
37
|
+
this.company = null;
|
|
38
|
+
this.terminal = null;
|
|
39
|
+
this.employee = null;
|
|
40
|
+
this.parsedSettings = null;
|
|
41
|
+
this.localeCode = null;
|
|
42
|
+
this.loaded = false;
|
|
43
|
+
|
|
44
|
+
// Облачная проверка запроса (app/core/auth) команду уже прочитала — второй раз не ходим.
|
|
45
|
+
this.preloadedTeam = team;
|
|
46
|
+
this.preloadedLocale = locale;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Строка справочника по id — как Eloquent find(): в облаке без мягко удалённых строк.
|
|
51
|
+
* Postgres роняет запрос на не-uuid в uuid-колонке, поэтому такой id — просто «не найдено».
|
|
52
|
+
* В локальном снапшоте удалённых строк нет, а у части таблиц нет и колонки deleted_at.
|
|
53
|
+
*/
|
|
54
|
+
find(table, id) {
|
|
55
|
+
if (!id) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (this.db.dialect !== 'pgsql') {
|
|
59
|
+
return this.db.get(`SELECT * FROM ${table} WHERE id = ?`, [id]);
|
|
60
|
+
}
|
|
61
|
+
if (!UUID.test(id)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return this.db.get(`SELECT * FROM ${table} WHERE id = ? AND deleted_at IS NULL`, [id]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Предзагрузка справочников контекста. Зовётся один раз перед работой сервисов. */
|
|
68
|
+
async load() {
|
|
69
|
+
if (this.loaded) {
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
const hasTeam = this.preloadedTeam !== undefined;
|
|
73
|
+
const [team, company, terminal, employee] = await Promise.all([
|
|
74
|
+
hasTeam ? this.preloadedTeam : this.find('teams', this.teamId),
|
|
75
|
+
this.find('companies', this.companyId),
|
|
76
|
+
this.find('terminals', this.terminalId),
|
|
77
|
+
this.find('team_users', this.employeeId),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
this.team = team;
|
|
81
|
+
this.company = company;
|
|
82
|
+
this.terminal = terminal;
|
|
83
|
+
this.employee = employee;
|
|
84
|
+
this.localeCode = hasTeam && this.preloadedLocale !== undefined
|
|
85
|
+
? this.preloadedLocale
|
|
86
|
+
: await this.resolveLocale(team);
|
|
87
|
+
this.loaded = true;
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Локаль = язык команды (в Laravel её ставит PlatformApiMiddleware из team->language->code).
|
|
93
|
+
* В облаке это связь teams.language_id → languages.code; в локальном снапшоте код языка
|
|
94
|
+
* лежит прямо в teams.language_code, отдельной таблицы там нет.
|
|
95
|
+
*/
|
|
96
|
+
async resolveLocale(team) {
|
|
97
|
+
if (!team) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (team.language_code) {
|
|
101
|
+
return team.language_code;
|
|
102
|
+
}
|
|
103
|
+
if (!team.language_id) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const row = await this.db.get('SELECT code FROM languages WHERE id = ?', [team.language_id]);
|
|
108
|
+
return row ? row.code : null;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Таблицы languages в локальном снапшоте нет — молча падаем на язык по умолчанию.
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getTeam() {
|
|
116
|
+
return this.team;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getCompany() {
|
|
120
|
+
return this.company;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getTerminal() {
|
|
124
|
+
return this.terminal;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getEmployee() {
|
|
128
|
+
return this.employee;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
settings() {
|
|
132
|
+
if (this.parsedSettings === null) {
|
|
133
|
+
const team = this.getTeam();
|
|
134
|
+
let raw = team ? team.settings : null;
|
|
135
|
+
// SQLite отдаёт JSON строкой, Postgres (jsonb) — уже объектом.
|
|
136
|
+
if (typeof raw === 'string') {
|
|
137
|
+
try {
|
|
138
|
+
raw = JSON.parse(raw);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
raw = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
this.parsedSettings = raw || {};
|
|
144
|
+
}
|
|
145
|
+
return this.parsedSettings;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** setting('terminal_settings.append_order_product', false) */
|
|
149
|
+
setting(path, fallback = null) {
|
|
150
|
+
const segments = String(path).split('.');
|
|
151
|
+
let current = this.settings();
|
|
152
|
+
for (const segment of segments) {
|
|
153
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
154
|
+
return fallback;
|
|
155
|
+
}
|
|
156
|
+
current = current[segment];
|
|
157
|
+
}
|
|
158
|
+
return current === undefined || current === null ? fallback : current;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
serviceChargeMode() {
|
|
162
|
+
return this.setting('terminal_settings.service_charge_calculate_mode') || 'after_discount';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
locale() {
|
|
166
|
+
return this.localeCode || 'az';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = Context;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const sanctum = require('./sanctum');
|
|
2
|
+
const scope = require('./scope');
|
|
3
|
+
const license = require('./license');
|
|
4
|
+
const posMode = require('./posMode');
|
|
5
|
+
const {HttpError} = require('../errors');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Проверки облачного POS-API — порт цепочки middleware Laravel на `/api/{event}`:
|
|
9
|
+
*
|
|
10
|
+
* PlatformApiMiddleware → auth:sanctum → PlatformScopeMiddleware → LocalModeMiddleware
|
|
11
|
+
*
|
|
12
|
+
* Идемпотентность идёт следом, уже в runEvent. Порядок тот же, поэтому и ответы те же: без
|
|
13
|
+
* команды — 403/500 раньше 401, просроченная лицензия — 402 даже без токена.
|
|
14
|
+
*
|
|
15
|
+
* Работает только в облачном контуре (CORE_MODE=cloud): на кассе ядро слушает localhost
|
|
16
|
+
* и ведомых с ключом зала, токен облака там проверять нечем и незачем.
|
|
17
|
+
*/
|
|
18
|
+
const isEnabled = () => String(process.env.CORE_AUTH ?? 'true').toLowerCase() !== 'false';
|
|
19
|
+
|
|
20
|
+
const UNAUTHENTICATED = {message: 'Unauthenticated.'};
|
|
21
|
+
// Laravel в проде (APP_DEBUG=false) на необработанное исключение отвечает так.
|
|
22
|
+
const SERVER_ERROR = {message: 'Server Error'};
|
|
23
|
+
|
|
24
|
+
const fail = ({status, body}) => new HttpError(status, body);
|
|
25
|
+
|
|
26
|
+
const platformHeaders = (headers) => ({
|
|
27
|
+
teamId: sanctum.header(headers, 'team-id'),
|
|
28
|
+
companyId: sanctum.header(headers, 'company-id'),
|
|
29
|
+
terminalId: sanctum.header(headers, 'terminal-id'),
|
|
30
|
+
employeeId: sanctum.header(headers, 'employee-id'),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Team::find: мягко удалённая команда — всё равно что отсутствующая.
|
|
34
|
+
const findTeam = async (db, rawTeamId) => {
|
|
35
|
+
const teamId = scope.value(rawTeamId);
|
|
36
|
+
if (!scope.isUuid(teamId)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return db.get('SELECT * FROM teams WHERE id = ? AND deleted_at IS NULL', [teamId]);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const teamLocale = async (db, team) => {
|
|
43
|
+
if (team.language_code) {
|
|
44
|
+
return team.language_code;
|
|
45
|
+
}
|
|
46
|
+
if (!team.language_id) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const row = await db.get('SELECT code FROM languages WHERE id = ?', [team.language_id]);
|
|
50
|
+
return row ? row.code : null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** PlatformApiMiddleware::missingTeam. */
|
|
54
|
+
const missingTeam = (event, platform, ip) => {
|
|
55
|
+
const isCheck = event === scope.CHECK_EVENT;
|
|
56
|
+
const scopeMode = scope.mode();
|
|
57
|
+
if (isCheck) {
|
|
58
|
+
return fail(scope.checkFailed(scope.REASON_TEAM));
|
|
59
|
+
}
|
|
60
|
+
if (scopeMode !== scope.MODE_OFF) {
|
|
61
|
+
// Пользователь ещё не аутентифицирован (мы до проверки токена) — пишем только заголовки.
|
|
62
|
+
console.warn('[platform-scope] mismatch', JSON.stringify({
|
|
63
|
+
reason: scope.REASON_TEAM,
|
|
64
|
+
mode: scopeMode,
|
|
65
|
+
event,
|
|
66
|
+
team_id: platform.teamId ?? null,
|
|
67
|
+
team_missing: true,
|
|
68
|
+
ip: ip ?? null,
|
|
69
|
+
}));
|
|
70
|
+
if (scopeMode === scope.MODE_ENFORCE) {
|
|
71
|
+
return fail(scope.deny(scope.REASON_TEAM));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Laravel дальше падает на ->language с 500 — повторяем исход, а не сам крах.
|
|
75
|
+
return new HttpError(500, SERVER_ERROR);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @returns {Promise<{auth, team, locale, platform, relayed: null|{status, body}}>}
|
|
80
|
+
* @throws HttpError — готовый ответ отказа
|
|
81
|
+
*/
|
|
82
|
+
const authorize = async (db, {event, headers = {}, params = {}, ip = null}) => {
|
|
83
|
+
const platform = platformHeaders(headers);
|
|
84
|
+
|
|
85
|
+
const team = await findTeam(db, platform.teamId);
|
|
86
|
+
if (!team) {
|
|
87
|
+
throw missingTeam(event, platform, ip);
|
|
88
|
+
}
|
|
89
|
+
const locale = await teamLocale(db, team);
|
|
90
|
+
|
|
91
|
+
const expired = license.check(team, platform.teamId, locale);
|
|
92
|
+
if (expired) {
|
|
93
|
+
throw fail(expired);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const auth = await sanctum.authenticate(db, headers);
|
|
97
|
+
if (!auth) {
|
|
98
|
+
throw new HttpError(401, UNAUTHENTICATED);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// PlatformScopeMiddleware. auth.check сверяет сам и отвечает своим телом.
|
|
102
|
+
const scopeMode = scope.mode();
|
|
103
|
+
if (scopeMode !== scope.MODE_OFF && event !== scope.CHECK_EVENT) {
|
|
104
|
+
let reason = null;
|
|
105
|
+
try {
|
|
106
|
+
reason = await scope.check(db, auth, platform, !scope.TERMINAL_SWITCH_EVENTS.has(event));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Сбой самой проверки не должен класть кассу: пропускаем, как Laravel.
|
|
109
|
+
console.warn('[platform-scope] check failed', JSON.stringify({event, error: e.message}));
|
|
110
|
+
}
|
|
111
|
+
if (reason !== null) {
|
|
112
|
+
scope.report(reason, {event, auth, platform, ip}, scopeMode);
|
|
113
|
+
if (scopeMode === scope.MODE_ENFORCE) {
|
|
114
|
+
throw fail(scope.deny(reason));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// LocalModeMiddleware: событие заведения в `local` выполняет мастер.
|
|
120
|
+
const routed = await posMode.route(db, {
|
|
121
|
+
event,
|
|
122
|
+
headers,
|
|
123
|
+
params,
|
|
124
|
+
companyId: scope.value(platform.companyId),
|
|
125
|
+
});
|
|
126
|
+
if (routed && !routed.relayed) {
|
|
127
|
+
throw fail(routed);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {auth, team, locale, platform, relayed: routed || null};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
module.exports = {isEnabled, authorize, platformHeaders};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const {nowSql} = require('../support/time');
|
|
2
|
+
const {translated} = require('../support/lang');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Лицензия команды — порт проверки из PlatformApiMiddleware + LicenseExpiredException::render.
|
|
6
|
+
*
|
|
7
|
+
* Laravel сравнивает `$team->expires_at < now()`: колонка без каста, это строка
|
|
8
|
+
* 'YYYY-MM-DD HH:mm:ss' в UTC, а Carbon сводится к той же строке — сравнение строковое.
|
|
9
|
+
* `null < now()` в PHP истинно: команда без срока считается просроченной.
|
|
10
|
+
*/
|
|
11
|
+
const ERROR_CODE = 'license_expired';
|
|
12
|
+
const CONTACT_PHONE = '+994 51 799 00 02';
|
|
13
|
+
|
|
14
|
+
// В lang-файлах этих строк нет — trans() возвращает ключ как есть.
|
|
15
|
+
const DEFAULT_MESSAGE = `Hörmətli müştəri lisenziyanın vaxtı bitmişdir. Xahiş edirik ödəniş edəsiniz. Əlaqə nömrəsi: ${CONTACT_PHONE}`;
|
|
16
|
+
const SPECIAL_TEAM = '9fe3ec92-0cd0-4809-bea6-64bf542f72c3';
|
|
17
|
+
const SPECIAL_MESSAGE = 'PULU VERIN BLƏ';
|
|
18
|
+
|
|
19
|
+
const expired = (team) => {
|
|
20
|
+
const at = team.expires_at;
|
|
21
|
+
if (at === null || at === undefined || at === '') {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return String(at) < nowSql();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** null — лицензия действует, иначе ответ 402 {status, body}. */
|
|
28
|
+
const check = (team, rawTeamId, locale) => {
|
|
29
|
+
if (!expired(team)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
status: 402,
|
|
34
|
+
body: {
|
|
35
|
+
success: false,
|
|
36
|
+
error_code: ERROR_CODE,
|
|
37
|
+
message: rawTeamId === SPECIAL_TEAM ? SPECIAL_MESSAGE : DEFAULT_MESSAGE,
|
|
38
|
+
phone: CONTACT_PHONE,
|
|
39
|
+
team_name: translated(team.name, locale),
|
|
40
|
+
expires_at: team.expires_at ?? null,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
module.exports = {check, expired, ERROR_CODE};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
const syncConfig = require('../../sync/config');
|
|
2
|
+
const {isUuid} = require('./scope');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Режим заведения в облаке (модель M) — порт LocalModeMiddleware + PosModeService::state.
|
|
6
|
+
*
|
|
7
|
+
* В `local` истина — SQLite кассы-мастера. Облачные клиенты (PWA, Expo, Electron вне зала)
|
|
8
|
+
* по-прежнему ходят в облако, а события ядра облако отдаёт мастеру по его сигнальному сокету
|
|
9
|
+
* (app/relay/masters.js) — раньше этот путь шёл через Laravel → POST /master/request → сюда же.
|
|
10
|
+
*
|
|
11
|
+
* Стоит ПЕРЕД дедупом: ретранслированное событие дедупит мастер (ключ уходит вместе с
|
|
12
|
+
* заголовками), а облачная таблица ключей не должна запомнить ответ, которого облако не давало.
|
|
13
|
+
*
|
|
14
|
+
* Флаг тот же, что у Laravel: POS_MODE_ENABLED. Выключен → прозрачно.
|
|
15
|
+
*/
|
|
16
|
+
const ONLINE = 'online';
|
|
17
|
+
const TO_LOCAL = 'to_local';
|
|
18
|
+
const LOCAL = 'local';
|
|
19
|
+
const TO_ONLINE = 'to_online';
|
|
20
|
+
const STATES = new Set([ONLINE, TO_LOCAL, LOCAL, TO_ONLINE]);
|
|
21
|
+
|
|
22
|
+
const PASS_PREFIXES = new Set(['sync', 'app', 'socket']);
|
|
23
|
+
const PASS_EVENTS = new Set(['auth.check']);
|
|
24
|
+
|
|
25
|
+
// Списки — копия cafepos-admin/config/pos_mode.php. Меняются вместе.
|
|
26
|
+
const CORE_MUTATIONS = new Set([
|
|
27
|
+
'orders.create',
|
|
28
|
+
'orders.addProduct',
|
|
29
|
+
'orders.addExtra',
|
|
30
|
+
'orders.changeTable',
|
|
31
|
+
'orders.changeWaiter',
|
|
32
|
+
'orders.changeGuestCount',
|
|
33
|
+
'orders.setComment',
|
|
34
|
+
'orders.addCustomer',
|
|
35
|
+
'orders.removeCustomer',
|
|
36
|
+
'orders.applyServiceCharge',
|
|
37
|
+
'orders.applyDiscount',
|
|
38
|
+
'orders.applyDeposit',
|
|
39
|
+
'orders.merge',
|
|
40
|
+
'orders.rollback',
|
|
41
|
+
'orders.removeOrder',
|
|
42
|
+
'orders.closeOrder',
|
|
43
|
+
'orders.syncClosedOrder',
|
|
44
|
+
'orders.syncWorkshops',
|
|
45
|
+
'orders.sendToWorkshops',
|
|
46
|
+
'orders.printBillReceipt',
|
|
47
|
+
'orders.dismissTimerModifier',
|
|
48
|
+
'orderProducts.setQuantity',
|
|
49
|
+
'orderProducts.setPrice',
|
|
50
|
+
'orderProducts.remove',
|
|
51
|
+
'orderProducts.setComment',
|
|
52
|
+
'orderProducts.updateModifiers',
|
|
53
|
+
'orderProducts.setGuestNumber',
|
|
54
|
+
'orderProducts.transfer',
|
|
55
|
+
'orderProducts.stopTimer',
|
|
56
|
+
'orderProducts.fixTimer',
|
|
57
|
+
'orderProducts.resumeTimer',
|
|
58
|
+
'orderProducts.setTimerLimit',
|
|
59
|
+
'shifts.openCashShift',
|
|
60
|
+
'shifts.closeCashShift',
|
|
61
|
+
'kds.setTicketStatus',
|
|
62
|
+
'kds.setItemStatus',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const CORE_READS = new Set([
|
|
66
|
+
'orders.getOpen',
|
|
67
|
+
'orders.archive',
|
|
68
|
+
'orders.isFiscalExists',
|
|
69
|
+
'orders.syncPrintBill',
|
|
70
|
+
'orderProducts.getById',
|
|
71
|
+
'printers.getReceiptPrinter',
|
|
72
|
+
'shifts.getCashShifts',
|
|
73
|
+
'kds.getTickets',
|
|
74
|
+
'kds.getReadiness',
|
|
75
|
+
'kds.getOrderTickets',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Чтения, которые при недоступном мастере можно отдать из облачной копии.
|
|
79
|
+
const REPLICA_READS = new Set(['orders.archive', 'printers.getReceiptPrinter', 'shifts.getCashShifts']);
|
|
80
|
+
|
|
81
|
+
const BLOCKED_IN_LOCAL = new Set(['orders.test']);
|
|
82
|
+
|
|
83
|
+
// Заголовки, которые нужны ядру мастера. authorization мастеру не нужен: облако токен уже проверило.
|
|
84
|
+
const FORWARD_HEADERS = ['team-id', 'company-id', 'terminal-id', 'employee-id', 'timestamp', 'ip-address', 'idempotency-key'];
|
|
85
|
+
|
|
86
|
+
const isEnabled = () => ['true', '1'].includes(String(process.env.POS_MODE_ENABLED ?? 'false').toLowerCase());
|
|
87
|
+
|
|
88
|
+
const cacheTtlMs = () => {
|
|
89
|
+
const seconds = parseInt(process.env.POS_MODE_CACHE_TTL ?? '5', 10);
|
|
90
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onlineState = () => ({state: ONLINE, master_terminal_id: null, epoch: 0});
|
|
94
|
+
|
|
95
|
+
const cache = new Map(); // company → {state, expires}
|
|
96
|
+
|
|
97
|
+
const describe = (company) => {
|
|
98
|
+
const value = company.pos_mode || ONLINE;
|
|
99
|
+
return {
|
|
100
|
+
state: STATES.has(value) ? value : ONLINE,
|
|
101
|
+
master_terminal_id: company.pos_master_terminal_id || null,
|
|
102
|
+
epoch: parseInt(company.pos_master_epoch, 10) || 0,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** PosModeService::state — режим заведения, кэш POS_MODE_CACHE_TTL. */
|
|
107
|
+
const state = async (db, companyId) => {
|
|
108
|
+
if (!isEnabled() || !companyId || !isUuid(companyId)) {
|
|
109
|
+
return onlineState();
|
|
110
|
+
}
|
|
111
|
+
const cached = cache.get(companyId);
|
|
112
|
+
if (cached && cached.expires > Date.now()) {
|
|
113
|
+
return cached.state;
|
|
114
|
+
}
|
|
115
|
+
const company = await db.get(
|
|
116
|
+
'SELECT pos_mode, pos_master_terminal_id, pos_master_epoch FROM companies WHERE id = ?',
|
|
117
|
+
[companyId]
|
|
118
|
+
);
|
|
119
|
+
const result = company ? describe(company) : onlineState();
|
|
120
|
+
const ttl = cacheTtlMs();
|
|
121
|
+
if (ttl > 0) {
|
|
122
|
+
if (cache.size > 10000) {
|
|
123
|
+
cache.clear();
|
|
124
|
+
}
|
|
125
|
+
cache.set(companyId, {state: result, expires: Date.now() + ttl});
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Суперадминка сменила режим заведения (Laravel будит всех событием pos:mode через /socket).
|
|
132
|
+
* Кэш этого процесса сбрасываем сразу, TTL остаётся страховкой.
|
|
133
|
+
*/
|
|
134
|
+
const forget = (companyId) => {
|
|
135
|
+
if (companyId) {
|
|
136
|
+
cache.delete(String(companyId));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Заведения не в online: тик повременных позиций для них ведёт мастер. */
|
|
141
|
+
const nonOnlineCompanyIds = async (db) => {
|
|
142
|
+
if (!isEnabled()) {
|
|
143
|
+
return new Set();
|
|
144
|
+
}
|
|
145
|
+
const rows = await db.all("SELECT id FROM companies WHERE pos_mode <> 'online'");
|
|
146
|
+
return new Set(rows.map((row) => String(row.id)));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const error = (status, code, message) => ({status, body: {success: false, message, error_code: code}});
|
|
150
|
+
const switching = () => error(409, 'mode_switching', 'Rejim dəyişdirilir, bir az gözləyin');
|
|
151
|
+
|
|
152
|
+
const header = (headers, name) => {
|
|
153
|
+
if (!headers) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (headers[name] !== undefined) {
|
|
157
|
+
return headers[name];
|
|
158
|
+
}
|
|
159
|
+
const found = Object.keys(headers).find((key) => key.toLowerCase() === name);
|
|
160
|
+
return found ? headers[found] : null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Мастер недоступен. Мутацию выполнить негде (облачная копия не истина — её затрёт следующая
|
|
165
|
+
* выгрузка мастера). Чтение из REPLICA_READS отдаём из облака.
|
|
166
|
+
*/
|
|
167
|
+
const unavailable = (event, isMutation) => {
|
|
168
|
+
if (!isMutation && REPLICA_READS.has(event)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return error(503, 'master_unavailable', 'Əsas kassa ilə əlaqə yoxdur');
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const relay = async ({event, headers, params, companyId, modeState, isMutation}) => {
|
|
175
|
+
// Реестр мастеров живёт в этом процессе только при включённой ретрансляции.
|
|
176
|
+
if (!syncConfig.isMasterRelayEnabled()) {
|
|
177
|
+
return unavailable(event, isMutation);
|
|
178
|
+
}
|
|
179
|
+
const forwarded = {};
|
|
180
|
+
for (const name of FORWARD_HEADERS) {
|
|
181
|
+
const found = header(headers, name);
|
|
182
|
+
if (found !== null && found !== undefined && found !== '') {
|
|
183
|
+
forwarded[name] = found;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let response;
|
|
187
|
+
try {
|
|
188
|
+
response = await require('../../relay/masters').relayRequest({
|
|
189
|
+
company_id: companyId,
|
|
190
|
+
master_terminal_id: modeState.master_terminal_id,
|
|
191
|
+
epoch: modeState.epoch,
|
|
192
|
+
event,
|
|
193
|
+
headers: forwarded,
|
|
194
|
+
params,
|
|
195
|
+
});
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.warn('[pos_mode] relay failed', JSON.stringify({event, company_id: companyId, error: e.message}));
|
|
198
|
+
return unavailable(event, isMutation);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const payload = response && response.body;
|
|
202
|
+
if (!payload || typeof payload !== 'object' || !payload.relayed) {
|
|
203
|
+
console.info('[pos_mode] master not reachable', JSON.stringify({
|
|
204
|
+
event,
|
|
205
|
+
company_id: companyId,
|
|
206
|
+
status: response ? response.status : null,
|
|
207
|
+
reason: payload && typeof payload === 'object' ? (payload.error_code ?? null) : null,
|
|
208
|
+
}));
|
|
209
|
+
return unavailable(event, isMutation);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let status = parseInt(payload.status ?? 200, 10);
|
|
213
|
+
if (!Number.isFinite(status) || status < 100 || status > 599) {
|
|
214
|
+
status = 200;
|
|
215
|
+
}
|
|
216
|
+
return {status, body: payload.body ?? null, relayed: true};
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* LocalModeMiddleware::handle. null — событие обслуживает облако, иначе готовый ответ
|
|
221
|
+
* {status, body} (отказ или ответ мастера).
|
|
222
|
+
*/
|
|
223
|
+
const route = async (db, {event, headers, params, companyId}) => {
|
|
224
|
+
if (!isEnabled()) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const prefix = String(event).split('.', 1)[0];
|
|
228
|
+
if (PASS_PREFIXES.has(prefix) || PASS_EVENTS.has(event)) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const modeState = await state(db, companyId);
|
|
233
|
+
if (modeState.state === ONLINE) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const isMutation = CORE_MUTATIONS.has(event);
|
|
238
|
+
const isRead = !isMutation && CORE_READS.has(event);
|
|
239
|
+
if (!isMutation && !isRead) {
|
|
240
|
+
return BLOCKED_IN_LOCAL.has(event)
|
|
241
|
+
? error(409, 'not_supported_in_local_mode', 'Bu əməliyyat lokal rejimdə mümkün deyil')
|
|
242
|
+
: null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
switch (modeState.state) {
|
|
246
|
+
case TO_LOCAL:
|
|
247
|
+
// Мастер забирает облако — новых правок быть не должно, чтения ещё верны здесь.
|
|
248
|
+
return isMutation ? switching() : null;
|
|
249
|
+
case TO_ONLINE:
|
|
250
|
+
// Мастер досылает очередь: правок не принимает никто, читать надо у него.
|
|
251
|
+
if (isMutation) {
|
|
252
|
+
return switching();
|
|
253
|
+
}
|
|
254
|
+
return relay({event, headers, params, companyId, modeState, isMutation: false});
|
|
255
|
+
default:
|
|
256
|
+
return relay({event, headers, params, companyId, modeState, isMutation});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
module.exports = {isEnabled, state, forget, route, nonOnlineCompanyIds, CORE_MUTATIONS, CORE_READS, _cache: cache};
|