@alizadefaig/pos-server 1.2.28 → 1.2.31
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 +4 -1
- package/app/core/auth/scope.js +15 -2
- package/app/core/index.js +16 -0
- package/app/core/models/Order.js +60 -4
- package/app/core/models/OrderProduct.js +5 -0
- package/app/core/repositories/ProductRelations.js +4 -4
- package/app/core/resources/OrderResource.js +1 -0
- package/app/core/resources/TeamResource.js +7 -1
- package/app/core/services/AuthService.js +60 -8
- package/app/core/services/InitService.js +23 -4
- package/app/core/services/OrderProductService.js +6 -0
- package/app/core/services/OrderService.js +8 -3
- package/app/core/services/PrintService.js +16 -4
- package/app/core/services/ReportService.js +87 -55
- package/app/core/support/teamSettings.js +10 -1
- package/app/core/support/terminalAccess.js +93 -0
- package/app/core/support/unlock.js +9 -1
- package/app/db/migrations/013_terminal_bindings.sql +5 -0
- package/app/netstats.js +98 -0
- package/app/sync/config.js +1 -1
- package/app/sync/discovery.js +201 -38
- package/app/sync/index.js +40 -29
- package/package.json +1 -1
- package/routes/desktop/api/offline.js +1 -1
- package/routes/desktop/api/printers.js +33 -11
- package/routes/desktop/local.js +1 -1
- package/routes/server/api/platform.js +1 -1
- package/server.js +4 -0
|
@@ -69,6 +69,28 @@ const parseDate = (value, timeZone) => {
|
|
|
69
69
|
// Collection::sum по полю — float в порядке коллекции, как в PHP.
|
|
70
70
|
const sumOf = (rows, key) => rows.reduce((carry, row) => carry + toFloat(row[key]), 0);
|
|
71
71
|
|
|
72
|
+
/** Суммы по ключу за один проход: фильтр коллекции на каждую строку справочника давал O(n×m). */
|
|
73
|
+
const totalsBy = (rows, key, valueKey) => {
|
|
74
|
+
const totals = new Map();
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
if (row[key]) {
|
|
77
|
+
totals.set(row[key], (totals.get(row[key]) ?? 0) + toFloat(row[valueKey]));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return totals;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const groupBy = (rows, key) => {
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
if (!groups.has(row[key])) {
|
|
87
|
+
groups.set(row[key], []);
|
|
88
|
+
}
|
|
89
|
+
groups.get(row[key]).push(row);
|
|
90
|
+
}
|
|
91
|
+
return groups;
|
|
92
|
+
};
|
|
93
|
+
|
|
72
94
|
/**
|
|
73
95
|
* Порт App\Http\Controllers\Api\Platform\ReportController (reports.getReport): X/Z-отчёт кассы
|
|
74
96
|
* без облака. Форма ответа совпадает с Laravel поле в поле — её печатает ReportReceipt.vue.
|
|
@@ -321,16 +343,16 @@ class ReportService {
|
|
|
321
343
|
}));
|
|
322
344
|
const aliveProducts = orderProducts.filter((row) => !row.deleted_at);
|
|
323
345
|
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
346
|
+
// Справочники — вместе с удалёнными, как в Laravel (withTrashed): удаление товара,
|
|
347
|
+
// категории, цеха, зала, способа оплаты или источника не отменяет продаж прошлого.
|
|
348
|
+
// Цех категории — для мутатора Product::productWorkshopId: ignore_workshop в select
|
|
349
|
+
// Laravel нет, поэтому мутатор отдаёт колонку, а пустую — цех категории.
|
|
327
350
|
const productRows = await this.db.all(
|
|
328
351
|
`SELECT p.id, p.full_name, p.product_category_id, p.product_workshop_id, p.is_weight_item, p.unit_id,
|
|
329
352
|
c.product_workshop_id AS category_workshop_id
|
|
330
353
|
FROM products p
|
|
331
|
-
LEFT JOIN product_categories c ON c.id = p.product_category_id
|
|
332
|
-
WHERE p.
|
|
333
|
-
AND p.id IN (SELECT op.product_id FROM order_products op WHERE op.order_id IN (${orderIds}))`,
|
|
354
|
+
LEFT JOIN product_categories c ON c.id = p.product_category_id
|
|
355
|
+
WHERE p.id IN (SELECT op.product_id FROM order_products op WHERE op.order_id IN (${orderIds}))`,
|
|
334
356
|
filter.params
|
|
335
357
|
);
|
|
336
358
|
const products = new Map(productRows.map((product) => [product.id, {
|
|
@@ -379,19 +401,20 @@ class ReportService {
|
|
|
379
401
|
|
|
380
402
|
async byTable(orders, companyId) {
|
|
381
403
|
const halls = await this.db.all(
|
|
382
|
-
`SELECT h.id, h.name FROM halls h WHERE h.company_id =
|
|
404
|
+
`SELECT h.id, h.name FROM halls h WHERE h.company_id = ?`,
|
|
383
405
|
[companyId]
|
|
384
406
|
);
|
|
407
|
+
const totals = totalsBy(orders, 'table_id', 'total');
|
|
385
408
|
const rows = [];
|
|
386
409
|
for (const hall of halls) {
|
|
387
410
|
const tables = await this.db.all(
|
|
388
|
-
`SELECT t.id, t.label FROM tables t WHERE t.hall_id =
|
|
411
|
+
`SELECT t.id, t.label FROM tables t WHERE t.hall_id = ? ORDER BY t."order"`,
|
|
389
412
|
[hall.id]
|
|
390
413
|
);
|
|
391
414
|
for (const table of tables) {
|
|
392
415
|
rows.push({
|
|
393
416
|
label: `${hall.name ?? ''} ${table.label ?? ''}`,
|
|
394
|
-
value: round(
|
|
417
|
+
value: round(totals.get(table.id) ?? 0, 2),
|
|
395
418
|
});
|
|
396
419
|
}
|
|
397
420
|
}
|
|
@@ -400,19 +423,20 @@ class ReportService {
|
|
|
400
423
|
|
|
401
424
|
async byWorkshop(aliveProducts, products, team) {
|
|
402
425
|
const workshops = await this.db.all(
|
|
403
|
-
`SELECT w.id, w.name FROM product_workshops w WHERE w.team_id =
|
|
426
|
+
`SELECT w.id, w.name FROM product_workshops w WHERE w.team_id = ?`,
|
|
404
427
|
[team ? team.id : this.context.teamId]
|
|
405
428
|
);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
429
|
+
const totals = new Map();
|
|
430
|
+
for (const orderProduct of aliveProducts) {
|
|
431
|
+
const product = products.get(orderProduct.product_id);
|
|
432
|
+
const workshopId = product && (product.product_workshop_id || product.category_workshop_id);
|
|
433
|
+
if (workshopId) {
|
|
434
|
+
totals.set(workshopId, (totals.get(workshopId) ?? 0) + orderProduct.total);
|
|
413
435
|
}
|
|
414
|
-
|
|
415
|
-
|
|
436
|
+
}
|
|
437
|
+
return workshops
|
|
438
|
+
.map((workshop) => ({label: workshop.name, value: round(totals.get(workshop.id) ?? 0, 2)}))
|
|
439
|
+
.filter((row) => row.value > 0);
|
|
416
440
|
}
|
|
417
441
|
|
|
418
442
|
async byEmployee(orders) {
|
|
@@ -422,12 +446,13 @@ class ReportService {
|
|
|
422
446
|
}
|
|
423
447
|
const staff = await this.db.all(
|
|
424
448
|
`SELECT tu.id, u.name FROM team_users tu
|
|
425
|
-
LEFT JOIN users u ON u.id = tu.user_id
|
|
449
|
+
LEFT JOIN users u ON u.id = tu.user_id
|
|
426
450
|
WHERE tu.id IN (${servingIds.map(() => '?').join(', ')})`,
|
|
427
451
|
servingIds
|
|
428
452
|
);
|
|
453
|
+
const byServing = groupBy(orders, 'serving_id');
|
|
429
454
|
return staff.map((teamUser) => {
|
|
430
|
-
const own =
|
|
455
|
+
const own = byServing.get(teamUser.id) ?? [];
|
|
431
456
|
return {
|
|
432
457
|
label: teamUser.name ?? null,
|
|
433
458
|
value: round(sumOf(own, 'total'), 2),
|
|
@@ -445,44 +470,49 @@ class ReportService {
|
|
|
445
470
|
}
|
|
446
471
|
const categories = await this.db.all(
|
|
447
472
|
`SELECT c.id, c.name FROM product_categories c
|
|
448
|
-
WHERE c.
|
|
473
|
+
WHERE c.id IN (${categoryIds.map(() => '?').join(', ')})`,
|
|
449
474
|
categoryIds
|
|
450
475
|
);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
476
|
+
const totals = new Map();
|
|
477
|
+
for (const row of aliveProducts) {
|
|
478
|
+
const product = products.get(row.product_id);
|
|
479
|
+
if (product && product.product_category_id) {
|
|
480
|
+
totals.set(product.product_category_id, (totals.get(product.product_category_id) ?? 0) + row.total);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return categories.map((category) => ({
|
|
484
|
+
label: translated(category.name, locale),
|
|
485
|
+
value: round(totals.get(category.id) ?? 0, 2),
|
|
486
|
+
}));
|
|
460
487
|
}
|
|
461
488
|
|
|
462
489
|
byProduct(orderProducts, products) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
const key = String(row.price);
|
|
472
|
-
if (!groups.has(key)) {
|
|
473
|
-
groups.set(key, []);
|
|
474
|
-
}
|
|
475
|
-
groups.get(key).push(row);
|
|
490
|
+
// Снятые позиции тоже: их workshop_print_quantity — то, что успело уйти на кухню.
|
|
491
|
+
// Товара нет в базе вовсе — строка «Silinən məhsul», продажа из отчёта не пропадает.
|
|
492
|
+
const byProduct = new Map();
|
|
493
|
+
for (const row of orderProducts) {
|
|
494
|
+
const productKey = row.product_id ?? '';
|
|
495
|
+
if (!byProduct.has(productKey)) {
|
|
496
|
+
byProduct.set(productKey, new Map());
|
|
476
497
|
}
|
|
498
|
+
const groups = byProduct.get(productKey);
|
|
499
|
+
const key = String(row.price);
|
|
500
|
+
if (!groups.has(key)) {
|
|
501
|
+
groups.set(key, {quantity: 0, price: row.price});
|
|
502
|
+
}
|
|
503
|
+
groups.get(key).quantity += row.workshop_print_quantity;
|
|
504
|
+
}
|
|
505
|
+
const rows = [];
|
|
506
|
+
for (const [productId, groups] of byProduct) {
|
|
507
|
+
const product = (productId && products.get(productId)) || null;
|
|
477
508
|
for (const group of groups.values()) {
|
|
478
|
-
const quantity = sumOf(group, 'workshop_print_quantity');
|
|
479
509
|
rows.push({
|
|
480
|
-
label: product.full_name,
|
|
481
|
-
quantity,
|
|
482
|
-
value: round(quantity * group
|
|
483
|
-
price: group
|
|
484
|
-
is_weight_item: toBool(product.is_weight_item),
|
|
485
|
-
unit_id: product.unit_id ?? null,
|
|
510
|
+
label: (product && product.full_name) ?? DELETED_PRODUCT,
|
|
511
|
+
quantity: group.quantity,
|
|
512
|
+
value: round(group.quantity * group.price, 2),
|
|
513
|
+
price: group.price,
|
|
514
|
+
is_weight_item: Boolean(product && toBool(product.is_weight_item)),
|
|
515
|
+
unit_id: product ? product.unit_id ?? null : null,
|
|
486
516
|
});
|
|
487
517
|
}
|
|
488
518
|
}
|
|
@@ -529,12 +559,13 @@ class ReportService {
|
|
|
529
559
|
}
|
|
530
560
|
const methods = await this.db.all(
|
|
531
561
|
`SELECT m.id, m.name FROM payment_methods m
|
|
532
|
-
WHERE m.id IN (${methodIds.map(() => '?').join(', ')})
|
|
562
|
+
WHERE m.id IN (${methodIds.map(() => '?').join(', ')})`,
|
|
533
563
|
methodIds
|
|
534
564
|
);
|
|
565
|
+
const totals = totalsBy(payments, 'payment_method_id', 'amount');
|
|
535
566
|
return methods.map((method) => ({
|
|
536
567
|
label: method.name,
|
|
537
|
-
value: round(
|
|
568
|
+
value: round(totals.get(method.id) ?? 0, 2),
|
|
538
569
|
}));
|
|
539
570
|
}
|
|
540
571
|
|
|
@@ -545,12 +576,13 @@ class ReportService {
|
|
|
545
576
|
}
|
|
546
577
|
const sources = await this.db.all(
|
|
547
578
|
`SELECT s.id, s.name FROM order_sources s
|
|
548
|
-
WHERE s.id IN (${sourceIds.map(() => '?').join(', ')})
|
|
579
|
+
WHERE s.id IN (${sourceIds.map(() => '?').join(', ')})`,
|
|
549
580
|
sourceIds
|
|
550
581
|
);
|
|
582
|
+
const totals = totalsBy(orders, 'order_source_id', 'total');
|
|
551
583
|
return sources.map((source) => ({
|
|
552
584
|
label: source.name,
|
|
553
|
-
value: round(
|
|
585
|
+
value: round(totals.get(source.id) ?? 0, 2),
|
|
554
586
|
}));
|
|
555
587
|
}
|
|
556
588
|
|
|
@@ -6,7 +6,7 @@ const {phpArray, zeroOneToBool, filterBool} = require('./php');
|
|
|
6
6
|
* app.init). Фронт терминала читает отсюда всё: настройки чека, терминала, KDS, управления.
|
|
7
7
|
*
|
|
8
8
|
* Порядок ключей как в PHP: существующие остаются на месте, недостающие дописываются в конец в
|
|
9
|
-
* порядке manage → terminal_settings → receipts → kds.
|
|
9
|
+
* порядке manage → terminal_settings → receipts → kds → loyalty.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const isContainer = (value) => value !== null && typeof value === 'object';
|
|
@@ -48,6 +48,14 @@ const kdsSettings = (value) => {
|
|
|
48
48
|
return kds;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
/** Лояльность — как KDS: секции может не быть, флаги приводятся к boolean (TeamSettingsCast). */
|
|
52
|
+
const loyaltySettings = (value) => {
|
|
53
|
+
const loyalty = isContainer(value) ? asObject(value) : {};
|
|
54
|
+
loyalty.enabled = filterBool(loyalty.enabled ?? false);
|
|
55
|
+
loyalty.balance_enabled = filterBool(loyalty.balance_enabled ?? false);
|
|
56
|
+
return loyalty;
|
|
57
|
+
};
|
|
58
|
+
|
|
51
59
|
/**
|
|
52
60
|
* @param {string|object|null} raw teams.settings (строка JSON или уже разобранный объект)
|
|
53
61
|
* @param {string|null} currencySymbol символ валюты команды — для конфига счёта
|
|
@@ -62,6 +70,7 @@ const teamSettings = (raw, currencySymbol = null) => {
|
|
|
62
70
|
// receipts — из исходного JSON: normalizeReceipts тот же, что уходит на терминал в печати.
|
|
63
71
|
settings.receipts = normalizeReceipts(isContainer(decoded) ? decoded.receipts : null, currencySymbol);
|
|
64
72
|
settings.kds = kdsSettings(settings.kds);
|
|
73
|
+
settings.loyalty = loyaltySettings(settings.loyalty);
|
|
65
74
|
return settings;
|
|
66
75
|
};
|
|
67
76
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const {HttpError} = require('../errors');
|
|
2
|
+
const {alive} = require('./rows');
|
|
3
|
+
const {toBool} = require('./num');
|
|
4
|
+
const {value, isUuid} = require('../auth/scope');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Привязки входа на кассу — порт App\Services\Platform\TerminalAccess (миграция Laravel
|
|
8
|
+
* 2026_10_28_000001):
|
|
9
|
+
* - team_users.company_id — сотрудник работает только в этом заведении (null — в любом заведении команды);
|
|
10
|
+
* - terminals.team_user_id — на терминал входит только этот сотрудник (и по PIN, и логином);
|
|
11
|
+
* - terminals.company_switchable — терминал не закреплён за заведением: при входе выбирается любое
|
|
12
|
+
* заведение команды, а terminals.company_id остаётся заведением по умолчанию.
|
|
13
|
+
*
|
|
14
|
+
* Правила действуют всегда, без PLATFORM_SCOPE_CHECK: колонки по умолчанию пустые, поэтому для
|
|
15
|
+
* команд без привязок поведение прежнее. Правишь здесь — правь и Laravel.
|
|
16
|
+
*/
|
|
17
|
+
const DENY_MESSAGE = 'Bu terminala giriş icazəniz yoxdur';
|
|
18
|
+
const ERROR_CODE = 'terminal_access_denied';
|
|
19
|
+
|
|
20
|
+
const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase();
|
|
21
|
+
|
|
22
|
+
/** Заведение доступно терминалу: своё или любое заведение команды у переключаемого. */
|
|
23
|
+
const companyAllowed = async (db, terminal, companyId) => {
|
|
24
|
+
companyId = value(companyId);
|
|
25
|
+
if (companyId === null || same(terminal.company_id, companyId)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (!toBool(terminal.company_switchable) || !isUuid(companyId)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return Boolean(await db.get(
|
|
32
|
+
`SELECT 1 AS ok FROM companies WHERE id = ? AND team_id = ?${alive(db)} LIMIT 1`,
|
|
33
|
+
[companyId, terminal.team_id],
|
|
34
|
+
));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Сотрудник может работать на терминале в этом заведении (PIN, employee-id). Закреплённый
|
|
39
|
+
* терминал — только для его сотрудника.
|
|
40
|
+
*/
|
|
41
|
+
const employeeAllowed = (member, terminal, companyId = null) => {
|
|
42
|
+
if (terminal.team_user_id && !same(terminal.team_user_id, member.id)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return memberInCompany(member, terminal, companyId);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Вход терминала логином (app.auth, loginTerminal): закрепление терминала за сотрудником
|
|
50
|
+
* его не касается, оно только для PIN. Проверяется лишь заведение сотрудника.
|
|
51
|
+
*/
|
|
52
|
+
const memberInCompany = (member, terminal, companyId = null) => {
|
|
53
|
+
companyId = value(companyId) ?? terminal.company_id;
|
|
54
|
+
return !member.company_id || same(member.company_id, companyId);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Заведения, которые можно выбрать на терминале: у обычного — только его, у переключаемого —
|
|
59
|
+
* все заведения команды; сотруднику с привязкой — только его заведение. `[{id, name}]`.
|
|
60
|
+
*/
|
|
61
|
+
const companiesFor = async (db, terminal, member = null) => {
|
|
62
|
+
// В снапшоте кассы created_at у companies нет — там порядок по имени.
|
|
63
|
+
const order = db.dialect === 'pgsql' ? 'created_at' : 'name';
|
|
64
|
+
let companies = toBool(terminal.company_switchable)
|
|
65
|
+
? await db.all(`SELECT id, name FROM companies WHERE team_id = ?${alive(db)} ORDER BY ${order}`, [terminal.team_id])
|
|
66
|
+
: await db.all(`SELECT id, name FROM companies WHERE id = ?${alive(db)}`, [terminal.company_id]);
|
|
67
|
+
if (member?.company_id) {
|
|
68
|
+
companies = companies.filter((company) => same(company.id, member.company_id));
|
|
69
|
+
}
|
|
70
|
+
return companies.map((company) => ({id: company.id, name: company.name}));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Терминал виден сотруднику в списке входа логином (app.auth); закрепление не учитывается. */
|
|
74
|
+
const visibleTo = async (db, terminal, member) => (await companiesFor(db, terminal, member)).length > 0;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Переключаемый терминал работает в заведении из заголовка company-id: подменяем company_id
|
|
78
|
+
* только в памяти, чтобы ресурсы, синк и печать видели выбранное заведение.
|
|
79
|
+
*/
|
|
80
|
+
const applyCompany = async (db, terminal, companyId) => {
|
|
81
|
+
companyId = value(companyId);
|
|
82
|
+
if (!terminal || !toBool(terminal.company_switchable) || companyId === null
|
|
83
|
+
|| same(terminal.company_id, companyId)
|
|
84
|
+
|| !await companyAllowed(db, terminal, companyId)) {
|
|
85
|
+
return terminal;
|
|
86
|
+
}
|
|
87
|
+
terminal.company_id = companyId;
|
|
88
|
+
return terminal;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const deny = () => new HttpError(403, {message: DENY_MESSAGE, error_code: ERROR_CODE});
|
|
92
|
+
|
|
93
|
+
module.exports = {DENY_MESSAGE, ERROR_CODE, companyAllowed, employeeAllowed, memberInCompany, companiesFor, visibleTo, applyCompany, deny};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const {createHmac, timingSafeEqual} = require('node:crypto');
|
|
2
2
|
const {HttpError} = require('../errors');
|
|
3
3
|
const {toBool} = require('./num');
|
|
4
|
+
const {employeeAllowed} = require('./terminalAccess');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Проверка PIN сотрудника на кассе без облака: офлайн-`auth.unlock` (routes/desktop/api/offline.js)
|
|
@@ -87,7 +88,11 @@ const hashMatches = (expected, actual) => {
|
|
|
87
88
|
return timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
|
|
88
89
|
};
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
/**
|
|
92
|
+
* terminal — терминал из init-блоба: привязки сотрудника к заведению и терминала к сотруднику
|
|
93
|
+
* (support/terminalAccess.js) действуют и без облака.
|
|
94
|
+
*/
|
|
95
|
+
const findUnlockUser = async (db, users, code, salt = null, terminal = null) => {
|
|
91
96
|
const value = String(code);
|
|
92
97
|
const hashed = filled(salt) ? codeHash(salt, value) : null;
|
|
93
98
|
for (const user of users) {
|
|
@@ -100,6 +105,9 @@ const findUnlockUser = async (db, users, code, salt = null) => {
|
|
|
100
105
|
if (!matches) {
|
|
101
106
|
continue;
|
|
102
107
|
}
|
|
108
|
+
if (terminal && !employeeAllowed(user, terminal)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
103
111
|
if (toBool(user.can_work_with_terminal) || await roleIsOwner(db, user)) {
|
|
104
112
|
return user;
|
|
105
113
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Привязки входа на кассу (Laravel 2026_10_28_000001, support/terminalAccess.js): сотрудник к
|
|
2
|
+
-- заведению, терминал к сотруднику, терминал без закреплённого заведения. Снапшот заполнит колонки.
|
|
3
|
+
ALTER TABLE team_users ADD COLUMN company_id TEXT;
|
|
4
|
+
ALTER TABLE terminals ADD COLUMN team_user_id TEXT;
|
|
5
|
+
ALTER TABLE terminals ADD COLUMN company_switchable INTEGER DEFAULT 0;
|
package/app/netstats.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сетевая диагностика Node кассы: на каком адресе он слушает и доходят ли до него соединения
|
|
3
|
+
* из зала. Уезжает в /local/status (`net`) и оттуда в облачную диагностику кассы.
|
|
4
|
+
*
|
|
5
|
+
* Главный вопрос, на который отвечает: ведомый не находит мастера — пакеты до мастера не доходят
|
|
6
|
+
* вовсе (брандмауэр, изоляция Wi-Fi) или доходят, но отвергаются (ключ, origin, токен)?
|
|
7
|
+
* `remote.connections` считает TCP-соединения с не-loopback адресов ещё до любой обработки,
|
|
8
|
+
* `remote.requests` — HTTP-запросы с итоговым статусом.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_PEERS = 10;
|
|
12
|
+
|
|
13
|
+
const state = {
|
|
14
|
+
listen: {host: null, port: null, address: null, error: null, at: null},
|
|
15
|
+
remote: {
|
|
16
|
+
connections: 0,
|
|
17
|
+
last_connection_from: null,
|
|
18
|
+
last_connection_at: null,
|
|
19
|
+
requests: 0,
|
|
20
|
+
statuses: {},
|
|
21
|
+
last_request: null,
|
|
22
|
+
peers: {},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isLoopback = (address) => {
|
|
27
|
+
const value = String(address || '');
|
|
28
|
+
return value === '::1' || value.startsWith('127.') || value.startsWith('::ffff:127.') || value === '';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const plain = (address) => String(address || '').replace(/^::ffff:/, '');
|
|
32
|
+
|
|
33
|
+
const notePeer = (address) => {
|
|
34
|
+
const peers = state.remote.peers;
|
|
35
|
+
if (peers[address] === undefined && Object.keys(peers).length >= MAX_PEERS) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
peers[address] = (peers[address] || 0) + 1;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Подключить к http.Server до listen(). host — с каким адресом слушаем (0.0.0.0 / localhost). */
|
|
42
|
+
const attach = (server, {host, port}) => {
|
|
43
|
+
state.listen.host = host;
|
|
44
|
+
state.listen.port = port;
|
|
45
|
+
server.on('listening', () => {
|
|
46
|
+
const address = server.address();
|
|
47
|
+
state.listen.address = address && typeof address === 'object'
|
|
48
|
+
? `${address.address}:${address.port}`
|
|
49
|
+
: String(address);
|
|
50
|
+
state.listen.error = null;
|
|
51
|
+
state.listen.at = new Date().toISOString();
|
|
52
|
+
});
|
|
53
|
+
server.on('error', (e) => {
|
|
54
|
+
state.listen.error = `${e.code || ''} ${e.message}`.trim();
|
|
55
|
+
state.listen.at = new Date().toISOString();
|
|
56
|
+
// Раньше слушателя 'error' не было и сбой listen (занятый порт) ронял процесс — в режиме
|
|
57
|
+
// utilityProcess Electron его перезапускает. Сохраняем это поведение.
|
|
58
|
+
if (server.listenerCount('error') === 1) {
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
server.on('connection', (socket) => {
|
|
63
|
+
const address = socket.remoteAddress;
|
|
64
|
+
if (isLoopback(address)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
state.remote.connections += 1;
|
|
68
|
+
state.remote.last_connection_from = plain(address);
|
|
69
|
+
state.remote.last_connection_at = new Date().toISOString();
|
|
70
|
+
notePeer(plain(address));
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** express middleware: HTTP-запросы с не-loopback адресов и чем они закончились. */
|
|
75
|
+
const middleware = (req, res, next) => {
|
|
76
|
+
const address = req.socket && req.socket.remoteAddress;
|
|
77
|
+
if (isLoopback(address)) {
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
state.remote.requests += 1;
|
|
81
|
+
const path = String(req.path || req.url || '').slice(0, 80);
|
|
82
|
+
res.on('finish', () => {
|
|
83
|
+
const bucket = `${Math.floor(res.statusCode / 100)}xx`;
|
|
84
|
+
state.remote.statuses[bucket] = (state.remote.statuses[bucket] || 0) + 1;
|
|
85
|
+
state.remote.last_request = {
|
|
86
|
+
from: plain(address),
|
|
87
|
+
method: req.method,
|
|
88
|
+
path,
|
|
89
|
+
status: res.statusCode,
|
|
90
|
+
at: new Date().toISOString(),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
return next();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const snapshot = () => JSON.parse(JSON.stringify(state));
|
|
97
|
+
|
|
98
|
+
module.exports = {attach, middleware, snapshot, isLoopback};
|
package/app/sync/config.js
CHANGED
|
@@ -71,7 +71,7 @@ const intervals = () => ({
|
|
|
71
71
|
// модель M: как часто спрашивать у облака режим заведения (заодно это heartbeat кассы)
|
|
72
72
|
modePoll: num('OFFLINE_MODE_POLL_MS', 10000),
|
|
73
73
|
// выход терминала при недосланном: столько push досылает очередь, прежде чем токен отзовут
|
|
74
|
-
|
|
74
|
+
logoutDrain: num('OFFLINE_LOGOUT_DRAIN_MS', 30000),
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
const timeouts = () => ({
|