@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,113 @@
|
|
|
1
|
+
const {createHmac, timingSafeEqual} = require('node:crypto');
|
|
2
|
+
const {HttpError} = require('../errors');
|
|
3
|
+
const {toBool} = require('./num');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Проверка PIN сотрудника на кассе без облака: офлайн-`auth.unlock` (routes/desktop/api/offline.js)
|
|
7
|
+
* и возврат на кассу после выхода терминала (app/sync `resume`). Источник — список `users` из
|
|
8
|
+
* blob app.init, который привозит снапшот.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Перебор PIN на кассе (4 цифры — 10 000 вариантов). Считаются только неудачи, ключ —
|
|
13
|
+
* команда + терминал. OFFLINE_UNLOCK_MAX_ATTEMPTS=0 выключает, как и UNLOCK_MAX_ATTEMPTS в Laravel.
|
|
14
|
+
* Один счётчик на оба входа: возврат на кассу не даёт обойти лимит блокировки.
|
|
15
|
+
*/
|
|
16
|
+
const unlockThrottle = (() => {
|
|
17
|
+
const attempts = new Map();
|
|
18
|
+
const limit = () => {
|
|
19
|
+
const value = Number(process.env.OFFLINE_UNLOCK_MAX_ATTEMPTS);
|
|
20
|
+
return Number.isFinite(value) && value >= 0 ? value : 10;
|
|
21
|
+
};
|
|
22
|
+
const decayMs = () => (Number(process.env.OFFLINE_UNLOCK_DECAY_SECONDS) || 60) * 1000;
|
|
23
|
+
const entry = (key) => {
|
|
24
|
+
const current = attempts.get(key);
|
|
25
|
+
if (current && current.until > Date.now()) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
attempts.delete(key);
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
assertAllowed(key) {
|
|
33
|
+
const current = entry(key);
|
|
34
|
+
if (limit() > 0 && current && current.count >= limit()) {
|
|
35
|
+
const retryAfter = Math.max(1, Math.ceil((current.until - Date.now()) / 1000));
|
|
36
|
+
throw new HttpError(429, {
|
|
37
|
+
success: false,
|
|
38
|
+
error_code: 'too_many_attempts',
|
|
39
|
+
message: 'Çox cəhd edildi, bir az gözləyin',
|
|
40
|
+
retry_after: retryAfter,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
fail(key) {
|
|
45
|
+
if (limit() <= 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const current = entry(key) || {count: 0, until: Date.now() + decayMs()};
|
|
49
|
+
current.count += 1;
|
|
50
|
+
attempts.set(key, current);
|
|
51
|
+
},
|
|
52
|
+
reset(key) {
|
|
53
|
+
attempts.delete(key);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Роль «владелец» (roles.is_owner) — точное условие TeamUserService::unlock. В blob app.init
|
|
60
|
+
* её нет (там is_owner = «владелец команды»), поэтому смотрим локальный справочник ролей и
|
|
61
|
+
* только без него падаем на поле ресурса. `db.get` может быть и синхронным (store синка).
|
|
62
|
+
*/
|
|
63
|
+
const roleIsOwner = async (db, user) => {
|
|
64
|
+
if (!user.role_id) {
|
|
65
|
+
return toBool(user.is_owner);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const role = await db.get('SELECT is_owner FROM roles WHERE id = ?', [user.role_id]);
|
|
69
|
+
return role ? toBool(role.is_owner) : toBool(user.is_owner);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return toBool(user.is_owner);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const filled = (value) => value !== null && value !== undefined && value !== '';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Laravel с USER_PIN_EXPOSURE=hash отдаёт вместо PIN и карты HMAC-SHA256 с солью команды
|
|
79
|
+
* (pin_salt в корне init-ответа). Сравниваем хеш введённого кода; открытые поля — старый режим.
|
|
80
|
+
*/
|
|
81
|
+
const codeHash = (salt, value) => createHmac('sha256', String(salt)).update(value).digest('hex');
|
|
82
|
+
|
|
83
|
+
const hashMatches = (expected, actual) => {
|
|
84
|
+
if (typeof expected !== 'string' || expected.length !== actual.length) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const findUnlockUser = async (db, users, code, salt = null) => {
|
|
91
|
+
const value = String(code);
|
|
92
|
+
const hashed = filled(salt) ? codeHash(salt, value) : null;
|
|
93
|
+
for (const user of users) {
|
|
94
|
+
if (!user) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const matches = (filled(user.pin) && String(user.pin) === value)
|
|
98
|
+
|| (filled(user.ic_card) && String(user.ic_card) === value)
|
|
99
|
+
|| (hashed !== null && (hashMatches(user.pin_hash, hashed) || hashMatches(user.ic_card_hash, hashed)));
|
|
100
|
+
if (!matches) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (toBool(user.can_work_with_terminal) || await roleIsOwner(db, user)) {
|
|
104
|
+
return user;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Ключ счётчика неудач: команда + терминал. */
|
|
111
|
+
const throttleKey = (teamId, terminalId) => `${teamId || ''}|${terminalId || ''}`;
|
|
112
|
+
|
|
113
|
+
module.exports = {unlockThrottle, findUnlockUser, throttleKey};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const {getDatabase} = require('../db/Database');
|
|
2
|
+
const Context = require('./Context');
|
|
3
|
+
const SocketEmitter = require('./emitter');
|
|
4
|
+
const OrderService = require('./services/OrderService');
|
|
5
|
+
const OrderRepository = require('./repositories/OrderRepository');
|
|
6
|
+
const {isEnabled, mode} = require('./index');
|
|
7
|
+
const {Idempotency} = require('./idempotency');
|
|
8
|
+
const syncConfig = require('../sync/config');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Тик повременных позиций — порт цикла `timers:tick` из Laravel.
|
|
12
|
+
*
|
|
13
|
+
* Офлайн облако до кассы не достаёт, а цена таймера растёт со временем: без тика терминалы
|
|
14
|
+
* показывали бы замороженную сумму. Ничего не пишет в БД — только пересчёт в памяти и
|
|
15
|
+
* батч-патч по компаниям (см. OrderService::tickTimers).
|
|
16
|
+
*
|
|
17
|
+
* Облачный контур (CORE_MODE=cloud) тикает только при CORE_TIMERS=true: пока флаг снят, это
|
|
18
|
+
* делает Laravel `timers:tick`, и два тика дали бы двойные бродкасты. Включать вместе с
|
|
19
|
+
* остановкой команды Laravel.
|
|
20
|
+
*/
|
|
21
|
+
const TICK_MS = Number(process.env.OFFLINE_TIMER_TICK_MS || 10000);
|
|
22
|
+
|
|
23
|
+
// Просроченные ключи идемпотентности локальному мастеру убирать некому — чистим редко, чтобы
|
|
24
|
+
// не мешать горячему пути.
|
|
25
|
+
const CLEANUP_EVERY_TICKS = Number(process.env.OFFLINE_IDEMPOTENCY_CLEANUP_TICKS || 100);
|
|
26
|
+
|
|
27
|
+
// Облако — раз в час и пачками, как cleanupExpiredIdempotencyKeys в Laravel.
|
|
28
|
+
const CLOUD_CLEANUP_EVERY_TICKS = Math.max(1, Math.floor(3600000 / TICK_MS));
|
|
29
|
+
const CLEANUP_CHUNK = 1000;
|
|
30
|
+
|
|
31
|
+
let handle = null;
|
|
32
|
+
let ticks = 0;
|
|
33
|
+
let running = false;
|
|
34
|
+
|
|
35
|
+
const cloudTimersEnabled = () => String(process.env.CORE_TIMERS || 'false').toLowerCase() === 'true';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* С синком (модель F) тик нужен, только пока фронт работает через локальный Node: в облачном
|
|
39
|
+
* режиме таймеры тикает облако, а ведомый терминал чеков не держит вовсе — тикает мастер.
|
|
40
|
+
*/
|
|
41
|
+
const tickNeeded = () => {
|
|
42
|
+
if (mode() === 'cloud') {
|
|
43
|
+
return cloudTimersEnabled();
|
|
44
|
+
}
|
|
45
|
+
if (!syncConfig.isSyncEnabled()) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (syncConfig.role() === 'follower') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return require('../sync').mode() !== 'cloud';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const tickTeam = async (db, teamId, filter) => {
|
|
55
|
+
const context = new Context({teamId, db});
|
|
56
|
+
await context.load();
|
|
57
|
+
const emitter = new SocketEmitter({db, context});
|
|
58
|
+
return new OrderService({db, context, emitter}).tickTimers(filter);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Облако: у каждой команды свой контекст (сервисный сбор, язык названий в патче). Заведения не в
|
|
63
|
+
* `online` пропускаются — их чеки тикает мастер-касса (PosModeService::nonOnlineCompanyIds).
|
|
64
|
+
* Сбой одной команды не останавливает остальные.
|
|
65
|
+
*/
|
|
66
|
+
const tickCloud = async (db) => {
|
|
67
|
+
const skipCompanyIds = await require('./auth/posMode').nonOnlineCompanyIds(db);
|
|
68
|
+
const teamIds = await new OrderRepository(db, new Context({db})).teamsWithRunningTimers();
|
|
69
|
+
for (const teamId of teamIds) {
|
|
70
|
+
try {
|
|
71
|
+
await tickTeam(db, teamId, {teamId, skipCompanyIds});
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.error('timers:tick error:', e.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const tick = async () => {
|
|
80
|
+
const db = await getDatabase();
|
|
81
|
+
if (mode() === 'cloud') {
|
|
82
|
+
return tickCloud(db);
|
|
83
|
+
}
|
|
84
|
+
// Локальная установка обслуживает одну команду — её язык нужен только для локализации
|
|
85
|
+
// названий в патче.
|
|
86
|
+
const team = await db.get('SELECT id FROM teams LIMIT 1');
|
|
87
|
+
return tickTeam(db, team ? team.id : null);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const cleanupIdempotency = async () => {
|
|
91
|
+
const db = await getDatabase();
|
|
92
|
+
const idempotency = new Idempotency(db);
|
|
93
|
+
if (mode() !== 'cloud') {
|
|
94
|
+
return idempotency.cleanup();
|
|
95
|
+
}
|
|
96
|
+
let total = 0;
|
|
97
|
+
let deleted;
|
|
98
|
+
do {
|
|
99
|
+
deleted = await idempotency.cleanup(CLEANUP_CHUNK);
|
|
100
|
+
total += deleted;
|
|
101
|
+
} while (deleted >= CLEANUP_CHUNK);
|
|
102
|
+
if (total > 0) {
|
|
103
|
+
console.info(`idempotency cleanup: deleted ${total} expired keys`);
|
|
104
|
+
}
|
|
105
|
+
return total;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const cleanupDue = () => {
|
|
109
|
+
if (mode() === 'local') {
|
|
110
|
+
return ticks % CLEANUP_EVERY_TICKS === 0;
|
|
111
|
+
}
|
|
112
|
+
return cloudTimersEnabled() && ticks % CLOUD_CLEANUP_EVERY_TICKS === 0;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const startTimerTicker = () => {
|
|
116
|
+
if (!isEnabled() || handle) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
handle = setInterval(() => {
|
|
120
|
+
// Тик асинхронный: ошибку ловим через .catch, иначе получим unhandled rejection
|
|
121
|
+
// и, в свежих Node, падение процесса.
|
|
122
|
+
// В облаке тик по всем командам может не уложиться в интервал — следующий ждёт.
|
|
123
|
+
if (tickNeeded() && !running) {
|
|
124
|
+
running = true;
|
|
125
|
+
tick()
|
|
126
|
+
.catch((e) => console.error('[core] timers tick failed:', e.message))
|
|
127
|
+
.finally(() => {
|
|
128
|
+
running = false;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
ticks += 1;
|
|
133
|
+
if (cleanupDue()) {
|
|
134
|
+
cleanupIdempotency().catch((e) => console.error('[core] idempotency cleanup failed:', e.message));
|
|
135
|
+
}
|
|
136
|
+
}, TICK_MS);
|
|
137
|
+
// Не держим процесс живым ради тика.
|
|
138
|
+
if (handle.unref) {
|
|
139
|
+
handle.unref();
|
|
140
|
+
}
|
|
141
|
+
return handle;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const stopTimerTicker = () => {
|
|
145
|
+
if (handle) {
|
|
146
|
+
clearInterval(handle);
|
|
147
|
+
handle = null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
module.exports = {startTimerTicker, stopTimerTicker, tick, cleanupIdempotency};
|
package/app/db/Database.js
CHANGED
|
@@ -1,6 +1,202 @@
|
|
|
1
|
+
const {join} = require('node:path');
|
|
2
|
+
const {mkdirSync, existsSync, chmodSync} = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
|
|
5
|
+
const {openDriver, getDriverName} = require('./driver');
|
|
6
|
+
const {migrate} = require('./Migrator');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* StorageAdapter ядра — единственная точка доступа к БД.
|
|
10
|
+
*
|
|
11
|
+
* Два контура, один слой:
|
|
12
|
+
* CORE_DB=pgsql — облако, общая база с Laravel (схему ведут его миграции);
|
|
13
|
+
* CORE_DB=sqlite — локальный мастер, схему ведут локальные миграции из ./migrations.
|
|
14
|
+
*
|
|
15
|
+
* Правило из OFFLINE-FIRST-PLAN §5: SQL пишем чистый, без ORM-специфики. Диалектные отличия
|
|
16
|
+
* (плейсхолдеры, типы) снимает driver.js, здесь — только тонкие хелперы над query/run.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Файл базы и его WAL-спутники — только владельцу (0600). На Windows chmod почти ничего не
|
|
20
|
+
* делает, там каталог и так в профиле пользователя (%APPDATA%). Ошибка прав — не повод не
|
|
21
|
+
* стартовать кассу.
|
|
22
|
+
*/
|
|
23
|
+
const restrictFile = (file) => {
|
|
24
|
+
if (!file || file === ':memory:') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const path of [file, `${file}-wal`, `${file}-shm`]) {
|
|
28
|
+
try {
|
|
29
|
+
if (existsSync(path)) {
|
|
30
|
+
chmodSync(path, 0o600);
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// права не поменялись — работаем как раньше
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
1
38
|
class Database {
|
|
2
|
-
constructor() {
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
const dialect = options.dialect || Database.defaultDialect();
|
|
41
|
+
this.dialect = dialect === 'postgres' ? 'pgsql' : dialect;
|
|
42
|
+
this.file = this.dialect === 'sqlite' ? (options.file || Database.defaultFile()) : null;
|
|
43
|
+
this.driver = null;
|
|
44
|
+
this.connecting = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static defaultDialect() {
|
|
48
|
+
const value = (process.env.CORE_DB || 'sqlite').toLowerCase();
|
|
49
|
+
return value === 'postgres' ? 'pgsql' : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static defaultFile() {
|
|
53
|
+
if (process.env.CAFEPOS_DB_FILE) {
|
|
54
|
+
return process.env.CAFEPOS_DB_FILE;
|
|
55
|
+
}
|
|
56
|
+
const dir = process.env.CAFEPOS_DATA_DIR || join(os.homedir(), '.cafepos');
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
// В базе PIN сотрудников, чеки и токен синка — только владельцу процесса.
|
|
59
|
+
mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
60
|
+
}
|
|
61
|
+
return join(dir, 'pos.sqlite');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Идемпотентно и безопасно при параллельных вызовах: соединение поднимается один раз. */
|
|
65
|
+
async connect() {
|
|
66
|
+
if (this.driver) {
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
if (!this.connecting) {
|
|
70
|
+
this.connecting = this.open();
|
|
71
|
+
}
|
|
72
|
+
await this.connecting;
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async open() {
|
|
77
|
+
const driver = openDriver({dialect: this.dialect, file: this.file});
|
|
78
|
+
|
|
79
|
+
if (this.dialect === 'sqlite') {
|
|
80
|
+
// WAL — читатели не блокируют писателя: терминалы читают заказы, пока касса пишет.
|
|
81
|
+
// busy_timeout — вместо мгновенного SQLITE_BUSY ждём до 5с.
|
|
82
|
+
await driver.exec('PRAGMA journal_mode = WAL;');
|
|
83
|
+
await driver.exec('PRAGMA foreign_keys = ON;');
|
|
84
|
+
await driver.exec('PRAGMA busy_timeout = 5000;');
|
|
85
|
+
restrictFile(this.file);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.driver = driver;
|
|
89
|
+
|
|
90
|
+
// В облаке схему ведёт Laravel — свои миграции там не запускаем никогда.
|
|
91
|
+
if (this.dialect === 'sqlite') {
|
|
92
|
+
await migrate(this);
|
|
93
|
+
}
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
driverName() {
|
|
98
|
+
return getDriverName();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Подключение обязано быть поднято: connect() зовётся один раз на старте контекста. */
|
|
102
|
+
connection() {
|
|
103
|
+
if (!this.driver) {
|
|
104
|
+
throw new Error('Database is not connected: await db.connect() first');
|
|
105
|
+
}
|
|
106
|
+
return this.driver;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async exec(sql) {
|
|
110
|
+
await this.connection().exec(sql);
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async all(sql, params = []) {
|
|
115
|
+
return this.connection().query(sql, params);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async get(sql, params = []) {
|
|
119
|
+
const rows = await this.connection().query(sql, params);
|
|
120
|
+
return rows.length ? rows[0] : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async run(sql, params = []) {
|
|
124
|
+
return this.connection().run(sql, params);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async value(sql, params = []) {
|
|
128
|
+
const row = await this.get(sql, params);
|
|
129
|
+
if (!row) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const keys = Object.keys(row);
|
|
133
|
+
return keys.length ? row[keys[0]] : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async insert(table, data) {
|
|
137
|
+
const columns = Object.keys(data);
|
|
138
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
139
|
+
await this.run(
|
|
140
|
+
`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`,
|
|
141
|
+
columns.map((column) => data[column])
|
|
142
|
+
);
|
|
143
|
+
return data.id ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async update(table, id, data) {
|
|
147
|
+
const columns = Object.keys(data);
|
|
148
|
+
if (!columns.length) {
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const assignments = columns.map((column) => `${column} = ?`).join(', ');
|
|
152
|
+
const result = await this.run(
|
|
153
|
+
`UPDATE ${table} SET ${assignments} WHERE id = ?`,
|
|
154
|
+
[...columns.map((column) => data[column]), id]
|
|
155
|
+
);
|
|
156
|
+
return result?.changes ?? 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Транзакция. Колбэк получает Database, привязанную к тому же соединению
|
|
161
|
+
* (в pg это выделенный клиент из пула — иначе BEGIN и запросы уедут в разные соединения).
|
|
162
|
+
*/
|
|
163
|
+
async transaction(callback) {
|
|
164
|
+
return this.connection().transaction(async (scopedDriver) => {
|
|
165
|
+
const scoped = Object.create(Database.prototype);
|
|
166
|
+
scoped.dialect = this.dialect;
|
|
167
|
+
scoped.file = this.file;
|
|
168
|
+
scoped.driver = scopedDriver;
|
|
169
|
+
scoped.connecting = null;
|
|
170
|
+
return callback(scoped);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async close() {
|
|
175
|
+
if (this.driver) {
|
|
176
|
+
await this.driver.close();
|
|
177
|
+
this.driver = null;
|
|
178
|
+
this.connecting = null;
|
|
179
|
+
}
|
|
3
180
|
}
|
|
4
181
|
}
|
|
5
182
|
|
|
183
|
+
let instance = null;
|
|
184
|
+
|
|
185
|
+
/** Один пул/соединение на процесс. Возвращает уже подключённую БД. */
|
|
186
|
+
const getDatabase = async () => {
|
|
187
|
+
if (!instance) {
|
|
188
|
+
instance = new Database();
|
|
189
|
+
}
|
|
190
|
+
await instance.connect();
|
|
191
|
+
return instance;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const setDatabase = (database) => {
|
|
195
|
+
instance = database;
|
|
196
|
+
return instance;
|
|
197
|
+
};
|
|
198
|
+
|
|
6
199
|
module.exports = Database;
|
|
200
|
+
module.exports.Database = Database;
|
|
201
|
+
module.exports.getDatabase = getDatabase;
|
|
202
|
+
module.exports.setDatabase = setDatabase;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const {readdirSync, readFileSync} = require('node:fs');
|
|
2
|
+
const {join, extname} = require('node:path');
|
|
3
|
+
|
|
4
|
+
const MIGRATIONS_DIR = join(__dirname, 'migrations');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Локальные миграции — обычные .sql-файлы, применяются по возрастанию имени ровно один раз.
|
|
8
|
+
*
|
|
9
|
+
* Только для локального контура (SQLite). В облаке схему ведёт Laravel: там ядро пишет
|
|
10
|
+
* в ту же базу, и своя миграция означала бы две системы записи схемы.
|
|
11
|
+
*/
|
|
12
|
+
const migrate = async (database) => {
|
|
13
|
+
await database.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
15
|
+
name TEXT PRIMARY KEY,
|
|
16
|
+
applied_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
`);
|
|
19
|
+
|
|
20
|
+
const rows = await database.all('SELECT name FROM _migrations');
|
|
21
|
+
const applied = new Set(rows.map((row) => row.name));
|
|
22
|
+
|
|
23
|
+
const files = readdirSync(MIGRATIONS_DIR)
|
|
24
|
+
.filter((name) => extname(name) === '.sql')
|
|
25
|
+
.sort();
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
if (applied.has(file)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
|
32
|
+
await database.transaction(async (tx) => {
|
|
33
|
+
await tx.exec(sql);
|
|
34
|
+
await tx.run('INSERT INTO _migrations (name, applied_at) VALUES (?, ?)', [
|
|
35
|
+
file,
|
|
36
|
+
new Date().toISOString(),
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
console.log(`[db] migration applied: ${file}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return files.length;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
module.exports = {migrate, MIGRATIONS_DIR};
|