@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,209 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const zlib = require('zlib');
|
|
3
|
+
|
|
4
|
+
const {nowSql, parse, now} = require('../support/time');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Проверка токена терминала — порт guard'а Laravel Sanctum (`auth:sanctum` на `/api/{event}`).
|
|
8
|
+
*
|
|
9
|
+
* Токен выпускает Laravel (`createToken`): в таблицу ложится sha256 от случайной строки, клиенту
|
|
10
|
+
* уходит `<id>|<строка>`. Здесь та же проверка по той же таблице, поэтому один и тот же токен
|
|
11
|
+
* принимают и Laravel, и Node — событие можно возить между ними без перелогина касс.
|
|
12
|
+
*/
|
|
13
|
+
const USER_MODEL = 'App\\Models\\User';
|
|
14
|
+
|
|
15
|
+
// Sanctum пишет last_used_at на каждый запрос. Касса шлёт десятки запросов в минуту, а это поле
|
|
16
|
+
// читает только выбор свежих токенов для мастера зала (sync.lanDevices) — минуты хватает.
|
|
17
|
+
const TOUCH_EVERY_MS = () => Number(process.env.CORE_TOKEN_TOUCH_MS ?? 60000);
|
|
18
|
+
const TOUCH_LIMIT = 20000;
|
|
19
|
+
const touched = new Map(); // token id → когда писали last_used_at
|
|
20
|
+
|
|
21
|
+
// bigint Postgres вмещает 19 цифр; длиннее — Laravel упал бы на запросе (500), мы просто
|
|
22
|
+
// не узнаём токен.
|
|
23
|
+
const MAX_ID_DIGITS = 18;
|
|
24
|
+
|
|
25
|
+
const header = (headers, name) => {
|
|
26
|
+
if (!headers) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (headers[name] !== undefined) {
|
|
30
|
+
return headers[name];
|
|
31
|
+
}
|
|
32
|
+
const found = Object.keys(headers).find((key) => key.toLowerCase() === name);
|
|
33
|
+
return found ? headers[found] : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Request::bearerToken(): последнее вхождение «Bearer » без учёта регистра, до первой запятой. */
|
|
37
|
+
const bearerToken = (headers) => {
|
|
38
|
+
const raw = header(headers, 'authorization');
|
|
39
|
+
if (typeof raw !== 'string') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const position = raw.toLowerCase().lastIndexOf('bearer ');
|
|
43
|
+
if (position === -1) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const value = raw.slice(position + 7);
|
|
47
|
+
const comma = value.indexOf(',');
|
|
48
|
+
return comma === -1 ? value : value.slice(0, comma);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// PHP empty(): '' и '0' — пусто.
|
|
52
|
+
const phpEmpty = (value) => value === '' || value === '0';
|
|
53
|
+
|
|
54
|
+
/** Guard::isValidBearerToken — у модели целочисленный ключ. */
|
|
55
|
+
const isValidFormat = (token) => {
|
|
56
|
+
if (token === null || token === undefined) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const bar = token.indexOf('|');
|
|
60
|
+
if (bar === -1) {
|
|
61
|
+
return !phpEmpty(token);
|
|
62
|
+
}
|
|
63
|
+
const id = token.slice(0, bar);
|
|
64
|
+
return /^\d+$/.test(id) && !phpEmpty(token.slice(bar + 1));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
68
|
+
|
|
69
|
+
const sameHash = (stored, computed) => {
|
|
70
|
+
if (typeof stored !== 'string' || stored.length !== computed.length) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return crypto.timingSafeEqual(Buffer.from(stored), Buffer.from(computed));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const TOKEN_SQL = `
|
|
77
|
+
SELECT t.id, t.token, t.expires_at, t.terminal_id, t.tokenable_type, u.id AS user_id
|
|
78
|
+
FROM personal_access_tokens t
|
|
79
|
+
LEFT JOIN users u ON u.id = t.tokenable_id AND t.tokenable_type = ? AND u.deleted_at IS NULL`;
|
|
80
|
+
|
|
81
|
+
/** PersonalAccessToken::findToken + владелец токена одним запросом. */
|
|
82
|
+
const findToken = async (db, token) => {
|
|
83
|
+
const bar = token.indexOf('|');
|
|
84
|
+
if (bar === -1) {
|
|
85
|
+
return db.get(`${TOKEN_SQL} WHERE t.token = ?`, [USER_MODEL, sha256(token)]);
|
|
86
|
+
}
|
|
87
|
+
const id = token.slice(0, bar);
|
|
88
|
+
if (id.length > MAX_ID_DIGITS) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const row = await db.get(`${TOKEN_SQL} WHERE t.id = ?`, [USER_MODEL, Number(id)]);
|
|
92
|
+
return row && sameHash(row.token, sha256(token.slice(bar + 1))) ? row : null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const expired = (expiresAt) => {
|
|
96
|
+
if (expiresAt === null || expiresAt === undefined || expiresAt === '') {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const at = parse(expiresAt);
|
|
100
|
+
return Boolean(at) && at.getTime() < now().getTime();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const touch = (db, id) => {
|
|
104
|
+
const at = Date.now();
|
|
105
|
+
const last = touched.get(id);
|
|
106
|
+
if (last !== undefined && at - last < TOUCH_EVERY_MS()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (touched.size >= TOUCH_LIMIT) {
|
|
110
|
+
touched.clear();
|
|
111
|
+
}
|
|
112
|
+
touched.set(id, at);
|
|
113
|
+
const stamp = nowSql();
|
|
114
|
+
// Не ждём: запись отметки не должна задерживать сам запрос.
|
|
115
|
+
db.run('UPDATE personal_access_tokens SET last_used_at = ?, updated_at = ? WHERE id = ?', [stamp, stamp, id])
|
|
116
|
+
.catch((e) => console.warn('[core-auth] last_used_at:', e.message));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @returns {Promise<null|{userId: string, tokenId: number, tokenTerminalId: string|null, plain: string}>}
|
|
121
|
+
* null — Laravel ответил бы 401 {"message":"Unauthenticated."}
|
|
122
|
+
*/
|
|
123
|
+
const authenticate = async (db, headers) => {
|
|
124
|
+
const token = bearerToken(headers);
|
|
125
|
+
if (!isValidFormat(token)) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const row = await findToken(db, token);
|
|
129
|
+
if (!row || !row.user_id || expired(row.expires_at)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const id = Number(row.id);
|
|
133
|
+
touch(db, id);
|
|
134
|
+
const terminal = row.terminal_id;
|
|
135
|
+
return {
|
|
136
|
+
userId: String(row.user_id),
|
|
137
|
+
tokenId: id,
|
|
138
|
+
tokenTerminalId: typeof terminal === 'string' && terminal !== '' ? terminal : null,
|
|
139
|
+
plain: token,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ------------------------------------------------------------ выпуск и отзыв (app.auth, logoutApp)
|
|
144
|
+
|
|
145
|
+
const ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
146
|
+
|
|
147
|
+
/** Str::random(40): буквы и цифры из CSPRNG. */
|
|
148
|
+
const randomString = (length) => {
|
|
149
|
+
let result = '';
|
|
150
|
+
for (let i = 0; i < length; i += 1) {
|
|
151
|
+
result += ALPHANUMERIC[crypto.randomInt(ALPHANUMERIC.length)];
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let crcTable = null;
|
|
157
|
+
const crc32Fallback = (value) => {
|
|
158
|
+
if (!crcTable) {
|
|
159
|
+
crcTable = new Int32Array(256);
|
|
160
|
+
for (let n = 0; n < 256; n += 1) {
|
|
161
|
+
let c = n;
|
|
162
|
+
for (let k = 0; k < 8; k += 1) {
|
|
163
|
+
c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
|
164
|
+
}
|
|
165
|
+
crcTable[n] = c;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let crc = -1;
|
|
169
|
+
for (const byte of Buffer.from(value)) {
|
|
170
|
+
crc = crcTable[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
|
|
171
|
+
}
|
|
172
|
+
return (crc ^ -1) >>> 0;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** hash('crc32b', …) PHP — 8 hex-символов в нижнем регистре. zlib.crc32 есть с Node 20.15/22.2. */
|
|
176
|
+
const crc32b = (value) => {
|
|
177
|
+
const sum = typeof zlib.crc32 === 'function' ? zlib.crc32(value) : crc32Fallback(value);
|
|
178
|
+
return sum.toString(16).padStart(8, '0');
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* HasApiTokens::createToken(name) Sanctum 4: строка `<SANCTUM_TOKEN_PREFIX><40 символов><crc32b>`,
|
|
183
|
+
* в таблицу — sha256 от неё целиком, abilities `["*"]`, без срока. Такой токен принимает и
|
|
184
|
+
* guard Laravel, и `authenticate` выше.
|
|
185
|
+
*
|
|
186
|
+
* @returns {Promise<{id: number, plainTextToken: string}>}
|
|
187
|
+
*/
|
|
188
|
+
const issue = async (db, userId, name = 'terminal') => {
|
|
189
|
+
const entropy = randomString(40);
|
|
190
|
+
const plain = `${process.env.SANCTUM_TOKEN_PREFIX || ''}${entropy}${crc32b(entropy)}`;
|
|
191
|
+
const stamp = nowSql();
|
|
192
|
+
const row = await db.get(
|
|
193
|
+
`INSERT INTO personal_access_tokens
|
|
194
|
+
(tokenable_type, tokenable_id, name, token, abilities, expires_at, created_at, updated_at)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
196
|
+
RETURNING id`,
|
|
197
|
+
[USER_MODEL, userId, name, sha256(plain), '["*"]', null, stamp, stamp]
|
|
198
|
+
);
|
|
199
|
+
const id = Number(row.id);
|
|
200
|
+
return {id, plainTextToken: `${id}|${plain}`};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** currentAccessToken()->delete(). */
|
|
204
|
+
const revoke = async (db, tokenId) => {
|
|
205
|
+
touched.delete(Number(tokenId));
|
|
206
|
+
await db.run('DELETE FROM personal_access_tokens WHERE id = ?', [Number(tokenId)]);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
module.exports = {authenticate, bearerToken, isValidFormat, sha256, header, issue, revoke, crc32b, _touched: touched};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Принадлежность заголовков токену — порт App\Services\Platform\PlatformScope.
|
|
5
|
+
*
|
|
6
|
+
* Токен говорит, КТО пришёл; заголовки team-id/company-id/terminal-id/employee-id — ОТ ЧЬЕГО
|
|
7
|
+
* имени он действует. Без сверки любой живой токен работал бы с чужой командой.
|
|
8
|
+
*
|
|
9
|
+
* PLATFORM_SCOPE_CHECK: off (по умолчанию, как сегодня) → log (только предупреждение) → enforce (403).
|
|
10
|
+
* Причины, тексты и форма ответов — как у Laravel.
|
|
11
|
+
*/
|
|
12
|
+
const MODE_OFF = 'off';
|
|
13
|
+
const MODE_LOG = 'log';
|
|
14
|
+
const MODE_ENFORCE = 'enforce';
|
|
15
|
+
|
|
16
|
+
const REASON_TEAM = 'team';
|
|
17
|
+
const REASON_COMPANY = 'company';
|
|
18
|
+
const REASON_TERMINAL = 'terminal';
|
|
19
|
+
const REASON_EMPLOYEE = 'employee';
|
|
20
|
+
const REASON_TOKEN_TERMINAL = 'token_terminal';
|
|
21
|
+
|
|
22
|
+
const ERROR_CODE = 'scope_mismatch';
|
|
23
|
+
const DENY_MESSAGE = 'Giriş icazəsi yoxdur';
|
|
24
|
+
|
|
25
|
+
// Эти события и переключают терминал токена: сверять старую привязку с новым терминалом нельзя.
|
|
26
|
+
const TERMINAL_SWITCH_EVENTS = new Set(['auth.loginTerminal', 'app.loginTerminal']);
|
|
27
|
+
const CHECK_EVENT = 'auth.check';
|
|
28
|
+
|
|
29
|
+
const CACHE_LIMIT = 20000;
|
|
30
|
+
const okCache = new Map(); // ключ → истекает (мс)
|
|
31
|
+
const logged = new Map();
|
|
32
|
+
|
|
33
|
+
const mode = () => {
|
|
34
|
+
const value = String(process.env.PLATFORM_SCOPE_CHECK ?? MODE_OFF).trim().toLowerCase();
|
|
35
|
+
return value === MODE_LOG || value === MODE_ENFORCE ? value : MODE_OFF;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ttlMs = () => {
|
|
39
|
+
const seconds = parseInt(process.env.PLATFORM_SCOPE_CACHE_TTL ?? '60', 10);
|
|
40
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** '', 'null', 'undefined' и не-строки — «заголовка нет». */
|
|
44
|
+
const value = (raw) => {
|
|
45
|
+
if (typeof raw !== 'string') {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
return trimmed === '' || trimmed === 'null' || trimmed === 'undefined' ? null : trimmed;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Str::isUuid: любой uuid без проверки версии. Невалидный uuid в where по uuid-колонке
|
|
53
|
+
// Postgres роняет запрос — отсекаем до БД.
|
|
54
|
+
const UUID = /^[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}$/;
|
|
55
|
+
const isUuid = (raw) => typeof raw === 'string' && UUID.test(raw);
|
|
56
|
+
|
|
57
|
+
const cacheHas = (map, key) => {
|
|
58
|
+
const expires = map.get(key);
|
|
59
|
+
if (expires === undefined) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (expires <= Date.now()) {
|
|
63
|
+
map.delete(key);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const cachePut = (map, key, ttl) => {
|
|
70
|
+
if (map.size >= CACHE_LIMIT) {
|
|
71
|
+
map.clear();
|
|
72
|
+
}
|
|
73
|
+
map.set(key, Date.now() + ttl);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const sha1 = (text) => crypto.createHash('sha1').update(text).digest('hex');
|
|
77
|
+
|
|
78
|
+
const exists = async (db, sql, params) => Boolean(await db.get(`SELECT 1 AS ok FROM ${sql} LIMIT 1`, params));
|
|
79
|
+
|
|
80
|
+
const terminalBelongs = async (db, terminalId, teamId, companyId = null, aliveOnly = false) => {
|
|
81
|
+
terminalId = value(terminalId);
|
|
82
|
+
teamId = value(teamId);
|
|
83
|
+
companyId = value(companyId);
|
|
84
|
+
if (!isUuid(terminalId) || !isUuid(teamId)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (companyId !== null && !isUuid(companyId)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
let sql = 'terminals WHERE id = ? AND team_id = ?';
|
|
91
|
+
const params = [terminalId, teamId];
|
|
92
|
+
if (companyId !== null) {
|
|
93
|
+
sql += ' AND company_id = ?';
|
|
94
|
+
params.push(companyId);
|
|
95
|
+
}
|
|
96
|
+
if (aliveOnly) {
|
|
97
|
+
sql += ' AND deleted_at IS NULL';
|
|
98
|
+
}
|
|
99
|
+
return exists(db, sql, params);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolve = async (db, {userId, teamId, companyId, terminalId, employeeId, tokenTerminalId, withTokenTerminal}) => {
|
|
103
|
+
// Член команды — живая строка team_users или владелец команды.
|
|
104
|
+
const isMember = await exists(db, 'team_users WHERE team_id = ? AND user_id = ? AND deleted_at IS NULL', [teamId, userId])
|
|
105
|
+
|| await exists(db, 'teams WHERE id = ? AND user_id = ? AND deleted_at IS NULL', [teamId, userId]);
|
|
106
|
+
if (!isMember) {
|
|
107
|
+
return REASON_TEAM;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Компания, терминал и сотрудник сверяются вместе с удалёнными строками своей команды:
|
|
111
|
+
// касса с устаревшим заголовком должна хотя бы разблокироваться и получить свежий init.
|
|
112
|
+
if (companyId !== null) {
|
|
113
|
+
const belongs = isUuid(companyId) && await exists(db, 'companies WHERE id = ? AND team_id = ?', [companyId, teamId]);
|
|
114
|
+
if (!belongs) {
|
|
115
|
+
return REASON_COMPANY;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (terminalId !== null && !await terminalBelongs(db, terminalId, teamId, companyId)) {
|
|
120
|
+
return REASON_TERMINAL;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (employeeId !== null) {
|
|
124
|
+
const belongs = isUuid(employeeId) && await exists(db, 'team_users WHERE id = ? AND team_id = ?', [employeeId, teamId]);
|
|
125
|
+
if (!belongs) {
|
|
126
|
+
return REASON_EMPLOYEE;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (withTokenTerminal && tokenTerminalId !== null && terminalId !== null
|
|
131
|
+
&& tokenTerminalId.toLowerCase() !== terminalId.toLowerCase()) {
|
|
132
|
+
return REASON_TOKEN_TERMINAL;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* PlatformScope::check. null — всё сходится, иначе причина.
|
|
140
|
+
*
|
|
141
|
+
* @param auth результат sanctum.authenticate (null — пользователя нет)
|
|
142
|
+
* @param platform сырые заголовки {teamId, companyId, terminalId, employeeId}
|
|
143
|
+
*/
|
|
144
|
+
const check = async (db, auth, platform, withTokenTerminal = true) => {
|
|
145
|
+
const teamId = value(platform.teamId);
|
|
146
|
+
if (!auth || !isUuid(teamId)) {
|
|
147
|
+
return REASON_TEAM;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const companyId = value(platform.companyId);
|
|
151
|
+
const terminalId = value(platform.terminalId);
|
|
152
|
+
const employeeId = value(platform.employeeId);
|
|
153
|
+
const tokenTerminalId = auth.tokenTerminalId;
|
|
154
|
+
|
|
155
|
+
const key = sha1([
|
|
156
|
+
auth.userId,
|
|
157
|
+
String(auth.tokenId ?? ''),
|
|
158
|
+
tokenTerminalId ?? '',
|
|
159
|
+
withTokenTerminal ? '1' : '0',
|
|
160
|
+
teamId,
|
|
161
|
+
companyId ?? '',
|
|
162
|
+
terminalId ?? '',
|
|
163
|
+
employeeId ?? '',
|
|
164
|
+
].join('|'));
|
|
165
|
+
const ttl = ttlMs();
|
|
166
|
+
if (ttl > 0 && cacheHas(okCache, key)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const reason = await resolve(db, {
|
|
171
|
+
userId: auth.userId, teamId, companyId, terminalId, employeeId, tokenTerminalId, withTokenTerminal,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Кэшируем только «ок»: отказ должен сняться сразу, как только данные поправили.
|
|
175
|
+
if (reason === null && ttl > 0) {
|
|
176
|
+
cachePut(okCache, key, ttl);
|
|
177
|
+
}
|
|
178
|
+
return reason;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** PlatformScope::report — одна строка на одинаковое расхождение за TTL. */
|
|
182
|
+
const report = (reason, {event, auth, platform, ip}, reportMode, extra = {}) => {
|
|
183
|
+
const context = {
|
|
184
|
+
reason,
|
|
185
|
+
mode: reportMode,
|
|
186
|
+
event,
|
|
187
|
+
user_id: auth ? auth.userId : null,
|
|
188
|
+
token_id: auth ? auth.tokenId : null,
|
|
189
|
+
token_terminal_id: auth ? auth.tokenTerminalId : null,
|
|
190
|
+
team_id: platform.teamId ?? null,
|
|
191
|
+
company_id: platform.companyId ?? null,
|
|
192
|
+
terminal_id: platform.terminalId ?? null,
|
|
193
|
+
employee_id: platform.employeeId ?? null,
|
|
194
|
+
ip: ip ?? null,
|
|
195
|
+
...extra,
|
|
196
|
+
};
|
|
197
|
+
const ttl = ttlMs();
|
|
198
|
+
const json = JSON.stringify(context);
|
|
199
|
+
if (ttl > 0) {
|
|
200
|
+
const key = sha1(json);
|
|
201
|
+
if (cacheHas(logged, key)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
cachePut(logged, key, ttl);
|
|
205
|
+
}
|
|
206
|
+
console.warn('[platform-scope] mismatch', json);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const deny = (reason) => ({status: 403, body: {message: DENY_MESSAGE, error_code: ERROR_CODE, reason}});
|
|
210
|
+
|
|
211
|
+
const checkFailed = (reason) => ({status: 403, body: {ok: false, error_code: ERROR_CODE, reason}});
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
MODE_OFF,
|
|
215
|
+
MODE_LOG,
|
|
216
|
+
MODE_ENFORCE,
|
|
217
|
+
REASON_TEAM,
|
|
218
|
+
REASON_TERMINAL,
|
|
219
|
+
CHECK_EVENT,
|
|
220
|
+
TERMINAL_SWITCH_EVENTS,
|
|
221
|
+
mode,
|
|
222
|
+
value,
|
|
223
|
+
isUuid,
|
|
224
|
+
check,
|
|
225
|
+
report,
|
|
226
|
+
deny,
|
|
227
|
+
checkFailed,
|
|
228
|
+
terminalBelongs,
|
|
229
|
+
_okCache: okCache,
|
|
230
|
+
_logged: logged,
|
|
231
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const {socketSendByFilter} = require('../../utils');
|
|
2
|
+
const syncConfig = require('../sync/config');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Порт SocketService в части бродкаста заказов.
|
|
6
|
+
*
|
|
7
|
+
* В Laravel эмит идёт по TCP в Node, который уже раздаёт его в комнату компании. Здесь мы
|
|
8
|
+
* УЖЕ внутри Node — поэтому зовём socketSendByFilter напрямую, без TCP-петли.
|
|
9
|
+
*
|
|
10
|
+
* updateOrders требует список открытых чеков, и строит его сам: OrderService передаёт
|
|
11
|
+
* эмиттеру только companyId (как и SocketService::updateOrders в Laravel).
|
|
12
|
+
*/
|
|
13
|
+
class SocketEmitter {
|
|
14
|
+
constructor({db, context, buildOpenOrders = null}) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
this.context = context;
|
|
17
|
+
this.buildOpenOrders = buildOpenOrders;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
emit(eventName, companyId, data) {
|
|
21
|
+
if (!companyId) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Модель M: касса — мастер заведения, часть терминалов работает через облако.
|
|
25
|
+
// Вне этого режима relayBroadcast ничего не делает.
|
|
26
|
+
if (syncConfig.isSyncEnabled()) {
|
|
27
|
+
try {
|
|
28
|
+
require('../sync').relayBroadcast(eventName, [data]);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error('[core] relay broadcast failed:', e.message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Сокет-сервер поднимается вместе с express; если его нет (тесты, ранний старт) —
|
|
34
|
+
// молча пропускаем бродкаст, мутация уже записана в локальную БД.
|
|
35
|
+
if (!require('../socket/server')()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
socketSendByFilter({companyId: String(companyId), eventName, data});
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Бродкаст не должен ронять мутацию: чек уже записан в локальную БД,
|
|
42
|
+
// терминал догонит состояние следующим ответом или переподключением.
|
|
43
|
+
console.error('[core] socket emit failed:', e.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
orderPatch(companyId, payload) {
|
|
48
|
+
this.emit('orders:patch', companyId, payload);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
orderPatchBatch(companyId, patches) {
|
|
52
|
+
if (!patches || !patches.length) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.emit('orders:patchBatch', companyId, patches);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async updateOrders(companyId) {
|
|
59
|
+
const target = companyId || this.context.companyId;
|
|
60
|
+
if (!target || !this.buildOpenOrders) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.emit('orders:update', target, await this.buildOpenOrders(target));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = SocketEmitter;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ошибка с готовым HTTP-ответом. Laravel возвращает из сервисов response()->json(...)
|
|
3
|
+
* с кодом — здесь то же самое, но через исключение, чтобы роут не разбирал тип результата.
|
|
4
|
+
* `headers` — заголовки ответа (Retry-After у 429), их ставит только HTTP-транспорт.
|
|
5
|
+
*/
|
|
6
|
+
class HttpError extends Error {
|
|
7
|
+
constructor(status, body, headers = null) {
|
|
8
|
+
super(typeof body === 'string' ? body : (body && body.message) || `HTTP ${status}`);
|
|
9
|
+
this.name = 'HttpError';
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.body = typeof body === 'string' ? {message: body} : body;
|
|
12
|
+
this.headers = headers;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const notFound = (message = 'Not found') => new HttpError(404, {success: false, message});
|
|
17
|
+
|
|
18
|
+
module.exports = {HttpError, notFound};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const {HttpError} = require('./errors');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Порт guard закрытого чека (`OrderService::isOrderClosed` / `assertOrderMutable` в Laravel).
|
|
5
|
+
*
|
|
6
|
+
* `closing` и `closed` оба значат «деньги уже приняты». closed_at проверяется отдельно: он
|
|
7
|
+
* ставится раньше финального статуса, и по нему закрытый чек отличим от открытого, даже если
|
|
8
|
+
* статус застрял промежуточным.
|
|
9
|
+
*
|
|
10
|
+
* Формат ответа — байт в байт как у `OrderClosedException::render`: терминал опознаёт его по
|
|
11
|
+
* `error_code` в теле (Node статус наверх не прокидывает) и убирает чек у себя.
|
|
12
|
+
*/
|
|
13
|
+
const CLOSED_STATUSES = ['closing', 'closed'];
|
|
14
|
+
const ERROR_CODE = 'order_closed';
|
|
15
|
+
const DEFAULT_MESSAGE = 'Bağlanmış çekdə dəyişiklik etmək olmaz. Əvvəlcə çeki geri açın.';
|
|
16
|
+
|
|
17
|
+
const isOrderClosed = (order) => {
|
|
18
|
+
if (!order) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const closedAt = order.closed_at;
|
|
22
|
+
return (closedAt !== null && closedAt !== undefined && closedAt !== '')
|
|
23
|
+
|| CLOSED_STATUSES.includes(order.status);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const assertOrderMutable = (order, action, message = null, context = null) => {
|
|
27
|
+
if (!isOrderClosed(order)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Отклонённую попытку нужно уметь найти постфактум, а не только заблокировать.
|
|
32
|
+
console.warn('[order-guard] попытка изменить закрытый чек отклонена', JSON.stringify({
|
|
33
|
+
action,
|
|
34
|
+
order_id: order.id,
|
|
35
|
+
order_number: order.number,
|
|
36
|
+
order_status: order.status,
|
|
37
|
+
closed_at: order.closed_at,
|
|
38
|
+
company_id: order.company_id,
|
|
39
|
+
employee_id: context ? context.employeeId : null,
|
|
40
|
+
terminal_id: context ? context.terminalId : null,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
throw new HttpError(422, {
|
|
44
|
+
success: false,
|
|
45
|
+
error_code: ERROR_CODE,
|
|
46
|
+
message: message || DEFAULT_MESSAGE,
|
|
47
|
+
action,
|
|
48
|
+
order_id: order.id,
|
|
49
|
+
order_status: order.status,
|
|
50
|
+
closed_at: order.closed_at,
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
module.exports = {CLOSED_STATUSES, ERROR_CODE, isOrderClosed, assertOrderMutable};
|