@alizadefaig/pos-server 1.2.29 → 1.2.32
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/posMode.js +1 -0
- 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/OrderRepository.js +12 -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 +41 -5
- 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/discovery.js +201 -38
- package/app/sync/index.js +51 -5
- package/package.json +1 -1
- package/routes/common/api/orders.js +4 -0
- package/routes/desktop/api/offline.js +1 -1
- package/routes/desktop/api/printers.js +33 -11
- package/routes/desktop/local.js +5 -1
- package/routes/server/api/platform.js +1 -1
- package/server.js +4 -0
package/app/core/Context.js
CHANGED
|
@@ -18,6 +18,8 @@ const cleanId = (value) => {
|
|
|
18
18
|
return trimmed === '' || trimmed === 'null' || trimmed === 'undefined' ? null : trimmed;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
const {applyCompany} = require('./support/terminalAccess');
|
|
22
|
+
|
|
21
23
|
const UUID = /^[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}$/;
|
|
22
24
|
|
|
23
25
|
class Context {
|
|
@@ -79,7 +81,8 @@ class Context {
|
|
|
79
81
|
|
|
80
82
|
this.team = team;
|
|
81
83
|
this.company = company;
|
|
82
|
-
|
|
84
|
+
// Переключаемый терминал — в заведении, выбранном при входе (заголовок company-id).
|
|
85
|
+
this.terminal = await applyCompany(this.db, terminal, this.companyId);
|
|
83
86
|
this.employee = employee;
|
|
84
87
|
this.localeCode = hasTeam && this.preloadedLocale !== undefined
|
|
85
88
|
? this.preloadedLocale
|
package/app/core/auth/posMode.js
CHANGED
package/app/core/auth/scope.js
CHANGED
|
@@ -90,8 +90,9 @@ const terminalBelongs = async (db, terminalId, teamId, companyId = null, aliveOn
|
|
|
90
90
|
let sql = 'terminals WHERE id = ? AND team_id = ?';
|
|
91
91
|
const params = [terminalId, teamId];
|
|
92
92
|
if (companyId !== null) {
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// Переключаемый терминал работает в любом заведении своей команды (support/terminalAccess.js).
|
|
94
|
+
sql += ' AND (company_id = ? OR company_switchable = ?)';
|
|
95
|
+
params.push(companyId, true);
|
|
95
96
|
}
|
|
96
97
|
if (aliveOnly) {
|
|
97
98
|
sql += ' AND deleted_at IS NULL';
|
|
@@ -125,6 +126,18 @@ const resolve = async (db, {userId, teamId, companyId, terminalId, employeeId, t
|
|
|
125
126
|
if (!belongs) {
|
|
126
127
|
return REASON_EMPLOYEE;
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
// Привязки: сотрудник к заведению, терминал к сотруднику (support/terminalAccess.js).
|
|
131
|
+
const member = await db.get('SELECT company_id FROM team_users WHERE id = ?', [employeeId]);
|
|
132
|
+
if (member?.company_id && companyId !== null && member.company_id.toLowerCase() !== companyId.toLowerCase()) {
|
|
133
|
+
return REASON_EMPLOYEE;
|
|
134
|
+
}
|
|
135
|
+
if (terminalId !== null && isUuid(terminalId)) {
|
|
136
|
+
const terminal = await db.get('SELECT team_user_id FROM terminals WHERE id = ?', [terminalId]);
|
|
137
|
+
if (terminal?.team_user_id && terminal.team_user_id.toLowerCase() !== employeeId.toLowerCase()) {
|
|
138
|
+
return REASON_EMPLOYEE;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
if (withTokenTerminal && tokenTerminalId !== null && terminalId !== null
|
package/app/core/index.js
CHANGED
|
@@ -106,6 +106,21 @@ const isOfflineRoute = (path) => {
|
|
|
106
106
|
return Boolean(event) && canHandle(event);
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* События, которые ядро обслужит вне облака (offline/local): тот же canHandle, но без
|
|
111
|
+
* текущего режима синка. Фронт по списку сразу отвечает «недоступно офлайн» вместо таймаута
|
|
112
|
+
* прокси. null — ядро выключено: фронт ведёт себя как со старым Node.
|
|
113
|
+
*/
|
|
114
|
+
const offlineEvents = () => {
|
|
115
|
+
if (!isEnabled()) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return [...registry.entries()]
|
|
119
|
+
.filter(([event, entry]) => allowed(event) && !entry.cloudOnly)
|
|
120
|
+
.map(([event]) => event)
|
|
121
|
+
.sort();
|
|
122
|
+
};
|
|
123
|
+
|
|
109
124
|
/** Заголовки, которые нужны апстриму при досылке операции из outbox. */
|
|
110
125
|
const FORWARD_HEADERS = [
|
|
111
126
|
'team-id', 'company-id', 'terminal-id', 'employee-id',
|
|
@@ -368,6 +383,7 @@ module.exports = {
|
|
|
368
383
|
canHandle,
|
|
369
384
|
dispatch,
|
|
370
385
|
isOfflineRoute,
|
|
386
|
+
offlineEvents,
|
|
371
387
|
makeServices,
|
|
372
388
|
forwardToMaster,
|
|
373
389
|
registerOfflineRoutes,
|
package/app/core/models/Order.js
CHANGED
|
@@ -27,6 +27,7 @@ class Order {
|
|
|
27
27
|
this.is_closed_without_payment = toBool(row.is_closed_without_payment);
|
|
28
28
|
this.should_send_to_kitchen = toBool(row.should_send_to_kitchen);
|
|
29
29
|
this.service_charge = toFloat(row.service_charge, 0);
|
|
30
|
+
this.data = Order.parseData(row.data);
|
|
30
31
|
|
|
31
32
|
for (const field of [
|
|
32
33
|
'subtotal', 'total', 'profit', 'cost', 'discount', 'discount_amount',
|
|
@@ -44,18 +45,38 @@ class Order {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
static parseCustomers(value) {
|
|
48
|
+
if (typeof value === 'string' && value.length) {
|
|
49
|
+
try {
|
|
50
|
+
value = JSON.parse(value);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return [1];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
47
55
|
if (Array.isArray(value)) {
|
|
48
56
|
return value;
|
|
49
57
|
}
|
|
50
|
-
|
|
58
|
+
// Laravel до исправления removeCustomer писал {"0":1,"2":3} вместо массива.
|
|
59
|
+
if (value && typeof value === 'object') {
|
|
60
|
+
const customers = Object.values(value);
|
|
61
|
+
return customers.length ? customers : [1];
|
|
62
|
+
}
|
|
63
|
+
return [1];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** orders.data: SQLite отдаёт строку, Postgres — объект. Пустое → null (как в Laravel). */
|
|
67
|
+
static parseData(value) {
|
|
68
|
+
if (typeof value === 'string') {
|
|
69
|
+
if (!value.length) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
51
72
|
try {
|
|
52
73
|
const parsed = JSON.parse(value);
|
|
53
|
-
return
|
|
74
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
54
75
|
} catch (e) {
|
|
55
|
-
return
|
|
76
|
+
return null;
|
|
56
77
|
}
|
|
57
78
|
}
|
|
58
|
-
return
|
|
79
|
+
return value && typeof value === 'object' ? value : null;
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
isClosed() {
|
|
@@ -136,6 +157,40 @@ class Order {
|
|
|
136
157
|
}
|
|
137
158
|
}
|
|
138
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Подарки лояльности — порт Order::applyLoyaltyRewards (Laravel): скидка на qty единиц позиции
|
|
162
|
+
* по её итоговой цене, не больше итога позиции; уменьшает total и profit.
|
|
163
|
+
*/
|
|
164
|
+
applyLoyaltyRewards() {
|
|
165
|
+
const rewards = this.data?.loyalty?.rewards || [];
|
|
166
|
+
if (!Array.isArray(rewards) || !rewards.length) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const used = {};
|
|
170
|
+
let amount = 0;
|
|
171
|
+
for (const reward of rewards) {
|
|
172
|
+
const product = this.products.find((item) => item.id === reward?.order_product_id);
|
|
173
|
+
const quantity = toFloat(product?.quantity);
|
|
174
|
+
if (!product || quantity <= 0) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const total = toFloat(product.total);
|
|
178
|
+
const left = total - (used[product.id] || 0);
|
|
179
|
+
const value = Math.min(left, money(total / quantity * Math.max(0, toFloat(reward.quantity ?? 1))));
|
|
180
|
+
if (value <= 0) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
used[product.id] = (used[product.id] || 0) + value;
|
|
184
|
+
amount += value;
|
|
185
|
+
}
|
|
186
|
+
if (amount <= 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.discount_amount = money(this.discount_amount + amount);
|
|
190
|
+
this.total = money(Math.max(0, this.total - amount));
|
|
191
|
+
this.profit = money(this.profit - amount);
|
|
192
|
+
}
|
|
193
|
+
|
|
139
194
|
calculate(withProducts = true, full = false) {
|
|
140
195
|
if ((withProducts && this.calculateWithProducts) || full) {
|
|
141
196
|
for (const product of this.products) {
|
|
@@ -166,6 +221,7 @@ class Order {
|
|
|
166
221
|
this.total = money(sum(this.products, 'total'));
|
|
167
222
|
|
|
168
223
|
this.applyDepositGap();
|
|
224
|
+
this.applyLoyaltyRewards();
|
|
169
225
|
|
|
170
226
|
return this;
|
|
171
227
|
}
|
|
@@ -136,6 +136,11 @@ class OrderProduct {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
const isTimer = this.type === TYPE_TIMER;
|
|
139
|
+
if (isTimer) {
|
|
140
|
+
// Скидка считается от цены за единицу. У повременной позиции это набежавшая по времени
|
|
141
|
+
// стоимость, а не тариф за час: иначе 20% от 8.5 ₼ при тарифе 5 ₼/ч давали 1 ₼, а не 1.7 ₼.
|
|
142
|
+
price = this.quantity > 0 ? this.price_total / this.quantity : this.price_total;
|
|
143
|
+
}
|
|
139
144
|
if (mode === 'before_discount') {
|
|
140
145
|
if (!isTimer) {
|
|
141
146
|
this.calculateServiceCharge(price);
|
|
@@ -4,6 +4,7 @@ const SnapshotRepository = require('./SnapshotRepository');
|
|
|
4
4
|
const {toSql, nowSql} = require('../support/time');
|
|
5
5
|
const {phpJsonEncode} = require('../support/str');
|
|
6
6
|
const {castColumn} = require('../support/db');
|
|
7
|
+
const {toInt} = require('../support/num');
|
|
7
8
|
|
|
8
9
|
// Колонки, которые ядро пишет само. Список явный: SELECT * читаем, а вот при записи
|
|
9
10
|
// молчаливый «сохраним всё, что оказалось на объекте» уронил бы UPDATE на первом же
|
|
@@ -96,6 +97,17 @@ class OrderRepository {
|
|
|
96
97
|
return Promise.all(rows.map((row) => this.hydrate(row)));
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Слоты открытых чеков официанта (режим слотов), кроме чека exceptId. */
|
|
101
|
+
async openSlotIndexes(companyId, servingId, exceptId) {
|
|
102
|
+
const rows = await this.db.all(
|
|
103
|
+
`SELECT slot_index FROM orders
|
|
104
|
+
WHERE company_id = ? AND serving_id = ? AND id <> ?
|
|
105
|
+
AND status NOT IN ('closing', 'closed') AND deleted_at IS NULL`,
|
|
106
|
+
[companyId, servingId, exceptId]
|
|
107
|
+
);
|
|
108
|
+
return rows.map((row) => toInt(row.slot_index, 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
async archivedOrders(companyId, fromSql, toSql_) {
|
|
100
112
|
const rows = await this.db.all(
|
|
101
113
|
`SELECT * FROM orders
|
|
@@ -11,9 +11,9 @@ const {unique, groupBy, indexBy, alive, whereIn} = require('../support/rows');
|
|
|
11
11
|
* Накопительный: повторный `load()` догружает только новые товары. Ресурс чека зовёт его на каждый
|
|
12
12
|
* чек и на каждую позицию, а связи общие.
|
|
13
13
|
*
|
|
14
|
-
* `
|
|
15
|
-
* В
|
|
16
|
-
*
|
|
14
|
+
* `product_modifier_sets` — по `order` (сортировка сетов в админке), затем `created_at`, как связь
|
|
15
|
+
* `Product::modifierSets`. В локальной схеме `created_at` нет: при равном `order` — порядок вставки
|
|
16
|
+
* снапшота. `modifier_sets` Laravel читает без ORDER BY — порядок кучи Postgres.
|
|
17
17
|
*/
|
|
18
18
|
class ProductRelations {
|
|
19
19
|
/**
|
|
@@ -59,7 +59,7 @@ class ProductRelations {
|
|
|
59
59
|
|
|
60
60
|
const [modifications, modifierSets] = await Promise.all([
|
|
61
61
|
whereIn(db, `SELECT * FROM products WHERE parent_id IN (:ids)${alive(db)} ORDER BY price`, ids),
|
|
62
|
-
whereIn(db, `SELECT *${jsonText(db, ['config'])} FROM product_modifier_sets WHERE product_id IN (:ids)${alive(db)}`, ids),
|
|
62
|
+
whereIn(db, `SELECT *${jsonText(db, ['config'])} FROM product_modifier_sets WHERE product_id IN (:ids)${alive(db)} ORDER BY "order"${this.pgsql ? ', created_at' : ''}`, ids),
|
|
63
63
|
]);
|
|
64
64
|
modifierSets.forEach((row) => takeText(row, ['config']));
|
|
65
65
|
merge(this.rel.modifications, groupBy(modifications, 'parent_id'));
|
|
@@ -63,6 +63,7 @@ class OrderResource {
|
|
|
63
63
|
without_payment_reason: order.without_payment_reason,
|
|
64
64
|
customers: order.customers,
|
|
65
65
|
client_id: order.client_id,
|
|
66
|
+
loyalty: order.data?.loyalty ?? null,
|
|
66
67
|
table_id: order.table_id,
|
|
67
68
|
hall_id: order.hall_id,
|
|
68
69
|
team_id: order.team_id,
|
|
@@ -77,13 +77,18 @@ const offlineConfig = (terminal, company) => {
|
|
|
77
77
|
* @param {object} terminal строка terminals
|
|
78
78
|
* @param {{companyName: ?string, workshops: Array<{id, name}>, posMode: object, company: ?object}} rel
|
|
79
79
|
*/
|
|
80
|
-
const terminalResource = (terminal, {companyName = null, workshops = [], posMode, company = null}) => ({
|
|
80
|
+
const terminalResource = (terminal, {companyName = null, workshops = [], posMode, company = null, companies = []}) => ({
|
|
81
81
|
id: terminal.id,
|
|
82
82
|
name: terminal.name,
|
|
83
83
|
is_active: nullableBool(terminal.is_active),
|
|
84
84
|
is_primary: nullableBool(terminal.is_primary),
|
|
85
85
|
company_id: terminal.company_id,
|
|
86
86
|
company_name: companyName,
|
|
87
|
+
// Привязки (support/terminalAccess.js): переключаемый терминал и заведения, которые можно выбрать
|
|
88
|
+
// вошедшему пользователю; team_user_id — единственный сотрудник, который входит на терминал.
|
|
89
|
+
company_switchable: Boolean(nullableBool(terminal.company_switchable)),
|
|
90
|
+
team_user_id: terminal.team_user_id ?? null,
|
|
91
|
+
companies,
|
|
87
92
|
type: terminal.type,
|
|
88
93
|
workshops: workshops.map((workshop) => ({id: workshop.id, name: workshop.name})),
|
|
89
94
|
// Каст 'array', `?? []`.
|
|
@@ -104,6 +109,7 @@ const teamUserResource = (member, {user = null, role = null, scopes = {}, cashSh
|
|
|
104
109
|
id: member.id,
|
|
105
110
|
...pin.fields(member.pin ?? null, member.ic_card ?? null, member.team_id ?? teamId),
|
|
106
111
|
name: user ? user.name : null,
|
|
112
|
+
company_id: member.company_id ?? null,
|
|
107
113
|
role_name: role ? role.name : null,
|
|
108
114
|
role_id: role ? role.id : null,
|
|
109
115
|
scopes: Object.keys(scopes).length ? scopes : [],
|
|
@@ -13,6 +13,7 @@ const {unique, groupBy, indexBy, alive, whereIn} = require('../support/rows');
|
|
|
13
13
|
const {nowSql} = require('../support/time');
|
|
14
14
|
const ShiftService = require('./ShiftService');
|
|
15
15
|
const {HttpError, notFound} = require('../errors');
|
|
16
|
+
const {companyAllowed, memberInCompany, companiesFor, visibleTo, deny} = require('../support/terminalAccess');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Вход терминала — порт AuthController (auth, loginTerminal, logoutApp, unlock) и
|
|
@@ -144,15 +145,17 @@ const companyArray = (row) => modelArray(takeText(row, ['working_times_by_week_d
|
|
|
144
145
|
pos_mode_changed_at: isoDate,
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
/** Terminal (+ company). password не отдаётся. */
|
|
148
|
-
const terminalArray = (row, company) => {
|
|
148
|
+
/** Terminal (+ company, + заведения на выбор — TerminalAccess::companiesFor). password не отдаётся. */
|
|
149
|
+
const terminalArray = (row, company, companies = []) => {
|
|
149
150
|
const {password, ...rest} = row;
|
|
150
151
|
const result = modelArray(rest, {
|
|
151
152
|
settings: phpArray,
|
|
152
153
|
sync_status: phpArray,
|
|
153
154
|
sync_seen_at: isoDate,
|
|
155
|
+
company_switchable: nullableBool,
|
|
154
156
|
});
|
|
155
157
|
result.company = company;
|
|
158
|
+
result.companies = companies;
|
|
156
159
|
return result;
|
|
157
160
|
};
|
|
158
161
|
|
|
@@ -245,7 +248,7 @@ class AuthService {
|
|
|
245
248
|
const ownerIds = unique(rows.map((row) => row.user_id));
|
|
246
249
|
const currencyIds = unique(rows.map((row) => row.currency_id));
|
|
247
250
|
|
|
248
|
-
const [owners, terminals, currencies, urls] = await Promise.all([
|
|
251
|
+
const [owners, terminals, currencies, urls, members] = await Promise.all([
|
|
249
252
|
this.whereIn(`SELECT * FROM users WHERE id IN (:ids)${this.alive()}`, ownerIds),
|
|
250
253
|
this.whereIn(
|
|
251
254
|
`SELECT * FROM terminals WHERE team_id IN (:ids) AND is_logged_in = ?${this.alive()}`,
|
|
@@ -256,6 +259,7 @@ class AuthService {
|
|
|
256
259
|
? this.whereIn(`SELECT * FROM currencies WHERE id IN (:ids)${this.alive()}`, currencyIds)
|
|
257
260
|
: [],
|
|
258
261
|
imageUrls(this.db, rows.map((row) => row.logo)),
|
|
262
|
+
this.whereIn(`SELECT * FROM team_users WHERE team_id IN (:ids) AND user_id = ?${this.alive()}`, teamIds, [userId]),
|
|
259
263
|
]);
|
|
260
264
|
|
|
261
265
|
const companyIds = unique(terminals.map((row) => row.company_id));
|
|
@@ -270,14 +274,32 @@ class AuthService {
|
|
|
270
274
|
const ownersById = indexBy(owners);
|
|
271
275
|
const currenciesById = indexBy(currencies);
|
|
272
276
|
const terminalsByTeam = groupBy(terminals, 'team_id');
|
|
277
|
+
const membersByTeam = new Map(members.map((member) => [String(member.team_id), member]));
|
|
278
|
+
|
|
279
|
+
// Привязки (support/terminalAccess.js): терминал без заведения сотрудника в списке не
|
|
280
|
+
// показываем. Закреплённые за сотрудником терминалы видны всем — закрепление только для PIN.
|
|
281
|
+
const visibleByTeam = new Map(await Promise.all(teamIds.map(async (teamId) => {
|
|
282
|
+
const member = membersByTeam.get(String(teamId)) || null;
|
|
283
|
+
const list = [];
|
|
284
|
+
for (const terminal of terminalsByTeam.get(String(teamId)) || []) {
|
|
285
|
+
if (member && !await visibleTo(this.db, terminal, member)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
list.push(terminalArray(
|
|
289
|
+
terminal,
|
|
290
|
+
companiesById.get(String(terminal.company_id)) || null,
|
|
291
|
+
await companiesFor(this.db, terminal, member)
|
|
292
|
+
));
|
|
293
|
+
}
|
|
294
|
+
return [String(teamId), list];
|
|
295
|
+
})));
|
|
273
296
|
|
|
274
297
|
return rows.map((row) => {
|
|
275
298
|
const owner = ownersById.get(String(row.user_id));
|
|
276
299
|
const currency = row.currency_id ? currenciesById.get(String(row.currency_id)) : null;
|
|
277
300
|
return teamArray(row, {
|
|
278
301
|
owner: owner ? userArray(owner) : null,
|
|
279
|
-
terminals:
|
|
280
|
-
terminalArray(terminal, companiesById.get(String(terminal.company_id)) || null)),
|
|
302
|
+
terminals: visibleByTeam.get(String(row.id)) || [],
|
|
281
303
|
currency: currency ? currencyArray(currency, locale) : null,
|
|
282
304
|
urls,
|
|
283
305
|
locale,
|
|
@@ -313,12 +335,25 @@ class AuthService {
|
|
|
313
335
|
// В PHP токен переписывался раньше поиска терминала, и на отсутствующем терминале
|
|
314
336
|
// запрос падал 500 с уже изменённым токеном. Здесь сначала ищем.
|
|
315
337
|
const terminal = scope.isUuid(id)
|
|
316
|
-
? await this.db.get(`SELECT
|
|
338
|
+
? await this.db.get(`SELECT * FROM terminals WHERE id = ?${this.alive()}`, [id])
|
|
317
339
|
: null;
|
|
318
340
|
if (!terminal) {
|
|
319
341
|
throw notFound('Terminal not found');
|
|
320
342
|
}
|
|
321
343
|
|
|
344
|
+
// Привязки (support/terminalAccess.js) — всегда, независимо от PLATFORM_SCOPE_CHECK.
|
|
345
|
+
if (String(terminal.team_id).toLowerCase() === String(platform.teamId ?? '').toLowerCase()) {
|
|
346
|
+
const member = await this.db.get(
|
|
347
|
+
`SELECT * FROM team_users WHERE team_id = ? AND user_id = ?${this.alive()} LIMIT 1`,
|
|
348
|
+
[terminal.team_id, who.userId]
|
|
349
|
+
);
|
|
350
|
+
const companyId = scope.value(params.company_id) ?? terminal.company_id;
|
|
351
|
+
if (!await companyAllowed(this.db, terminal, companyId)
|
|
352
|
+
|| (member && !memberInCompany(member, terminal, companyId))) {
|
|
353
|
+
throw deny();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
322
357
|
// Eloquent пишет только изменённое: та же привязка не трогает updated_at.
|
|
323
358
|
const stamp = nowSql();
|
|
324
359
|
await this.db.run(
|
|
@@ -423,15 +458,32 @@ class AuthService {
|
|
|
423
458
|
return null;
|
|
424
459
|
}
|
|
425
460
|
const value = phpString(code);
|
|
461
|
+
// Привязки: на терминал входит только его сотрудник; сотрудник с заведением — только в нём.
|
|
462
|
+
// Терминал из контекста уже в выбранном заведении (Context.load → applyCompany).
|
|
463
|
+
await this.context.load();
|
|
464
|
+
let terminal = this.context.getTerminal();
|
|
465
|
+
if (terminal && String(terminal.team_id).toLowerCase() !== String(this.context.teamId).toLowerCase()) {
|
|
466
|
+
terminal = null;
|
|
467
|
+
}
|
|
468
|
+
let binding = '';
|
|
469
|
+
const params = [this.context.teamId, value, value, true, true];
|
|
470
|
+
if (terminal) {
|
|
471
|
+
binding = ' AND (tu.company_id IS NULL OR tu.company_id = ?)';
|
|
472
|
+
params.push(terminal.company_id);
|
|
473
|
+
if (terminal.team_user_id) {
|
|
474
|
+
binding += ' AND tu.id = ?';
|
|
475
|
+
params.push(terminal.team_user_id);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
426
478
|
return this.db.get(
|
|
427
479
|
`SELECT tu.* FROM team_users tu
|
|
428
480
|
WHERE tu.team_id = ? AND (tu.pin = ? OR tu.ic_card = ?)${this.alive('tu')}
|
|
429
481
|
AND EXISTS (
|
|
430
482
|
SELECT 1 FROM roles r
|
|
431
483
|
WHERE r.id = tu.role_id AND (r.can_work_with_terminal = ? OR r.is_owner = ?)${this.alive('r')}
|
|
432
|
-
)
|
|
484
|
+
)${binding}
|
|
433
485
|
LIMIT 1`,
|
|
434
|
-
|
|
486
|
+
params
|
|
435
487
|
);
|
|
436
488
|
}
|
|
437
489
|
|
|
@@ -10,6 +10,8 @@ const posMode = require('../auth/posMode');
|
|
|
10
10
|
const lanHandoff = require('../support/lanHandoff');
|
|
11
11
|
const pin = require('../support/pin');
|
|
12
12
|
const {HttpError} = require('../errors');
|
|
13
|
+
const {toBool} = require('../support/num');
|
|
14
|
+
const {companiesFor} = require('../support/terminalAccess');
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Облачные справочники POS: порт AuthController::init и соседних чтений (ProductService,
|
|
@@ -50,7 +52,8 @@ class InitService {
|
|
|
50
52
|
|
|
51
53
|
// ------------------------------------------------------------ app.init
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
/** @param extra контекст запроса ядра: из токена берётся пользователь для списка заведений */
|
|
56
|
+
async init(extra = {}) {
|
|
54
57
|
await this.context.load();
|
|
55
58
|
const team = this.context.getTeam();
|
|
56
59
|
const terminal = this.context.getTerminal();
|
|
@@ -62,7 +65,7 @@ class InitService {
|
|
|
62
65
|
const companyId = this.context.companyId;
|
|
63
66
|
|
|
64
67
|
const [header, halls, products, users, orders, categories, units, workshops, paymentMethods, orderSources] = await Promise.all([
|
|
65
|
-
this.header(team, terminal),
|
|
68
|
+
this.header(team, terminal, extra.auth?.auth?.userId ?? null),
|
|
66
69
|
this.halls(companyId),
|
|
67
70
|
this.products(teamId),
|
|
68
71
|
this.users(teamId, team),
|
|
@@ -97,8 +100,8 @@ class InitService {
|
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
/** TeamResource + TerminalResource (с company и workshops). */
|
|
100
|
-
async header(team, terminal) {
|
|
101
|
-
const [currency, language, company, terminalWorkshops, urls, mode] = await Promise.all([
|
|
103
|
+
async header(team, terminal, userId = null) {
|
|
104
|
+
const [currency, language, company, terminalWorkshops, urls, mode, companies] = await Promise.all([
|
|
102
105
|
team.currency_id
|
|
103
106
|
? this.db.get(`SELECT symbol FROM currencies WHERE id = ?${this.alive()}`, [team.currency_id])
|
|
104
107
|
: null,
|
|
@@ -116,6 +119,7 @@ class InitService {
|
|
|
116
119
|
),
|
|
117
120
|
imageUrls(this.db, [team.logo]),
|
|
118
121
|
posMode.state(this.db, terminal.company_id),
|
|
122
|
+
this.terminalCompanies(terminal, userId),
|
|
119
123
|
]);
|
|
120
124
|
const currencySymbol = currency ? currency.symbol : null;
|
|
121
125
|
return {
|
|
@@ -131,10 +135,25 @@ class InitService {
|
|
|
131
135
|
posMode: mode,
|
|
132
136
|
// SELECT * выше: офлайн-колонок (offline_*) в локальной схеме кассы нет.
|
|
133
137
|
company,
|
|
138
|
+
companies,
|
|
134
139
|
}),
|
|
135
140
|
};
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
/** TerminalResource::companies — заведения на выбор у переключаемого терминала. */
|
|
144
|
+
async terminalCompanies(terminal, userId) {
|
|
145
|
+
if (!toBool(terminal.company_switchable)) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const member = userId
|
|
149
|
+
? await this.db.get(
|
|
150
|
+
`SELECT * FROM team_users WHERE team_id = ? AND user_id = ?${this.alive()} LIMIT 1`,
|
|
151
|
+
[terminal.team_id, userId]
|
|
152
|
+
)
|
|
153
|
+
: null;
|
|
154
|
+
return companiesFor(this.db, terminal, member);
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
// ------------------------------------------------------------ залы
|
|
139
158
|
|
|
140
159
|
/** HallService::getHalls — залы по order, столы по order. */
|
|
@@ -39,10 +39,13 @@ class OrderProductService {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// ------------------------------------------------------------ таймеры
|
|
42
|
+
// После каждой правки — полный пересчёт и запись чека, как `calculate(true, true)` в Laravel:
|
|
43
|
+
// без него total в БД и в патче оставался суммой до остановки таймера.
|
|
42
44
|
|
|
43
45
|
async stopTimer({orderProductId, orderId}) {
|
|
44
46
|
await this.findMutable(orderProductId, 'orderProducts.stopTimer');
|
|
45
47
|
await this.orderProducts.update(orderProductId, {is_stopped: 1, stopped_at: nowSql()});
|
|
48
|
+
await this.orderService.recalculate(orderId, {full: true});
|
|
46
49
|
await this.orderService.emitPatch(orderId);
|
|
47
50
|
return true;
|
|
48
51
|
}
|
|
@@ -50,6 +53,7 @@ class OrderProductService {
|
|
|
50
53
|
async fixTimer({orderProductId, orderId}) {
|
|
51
54
|
await this.findMutable(orderProductId, 'orderProducts.fixTimer');
|
|
52
55
|
await this.orderProducts.update(orderProductId, {is_stopped: 1, stopped_at: nowSql(), is_fixed: 1});
|
|
56
|
+
await this.orderService.recalculate(orderId, {full: true});
|
|
53
57
|
await this.orderService.emitPatch(orderId);
|
|
54
58
|
return true;
|
|
55
59
|
}
|
|
@@ -65,6 +69,7 @@ class OrderProductService {
|
|
|
65
69
|
stopped_at: null,
|
|
66
70
|
paused_seconds: toInt(orderProduct.paused_seconds, 0) + additionalPaused,
|
|
67
71
|
});
|
|
72
|
+
await this.orderService.recalculate(orderId, {full: true});
|
|
68
73
|
await this.orderService.emitPatch(orderId);
|
|
69
74
|
return true;
|
|
70
75
|
}
|
|
@@ -74,6 +79,7 @@ class OrderProductService {
|
|
|
74
79
|
await this.orderProducts.update(orderProductId, {
|
|
75
80
|
timer_limit_minutes: toFloat(timerLimitMinutes) > 0 ? toInt(timerLimitMinutes) : null,
|
|
76
81
|
});
|
|
82
|
+
await this.orderService.recalculate(orderId, {full: true});
|
|
77
83
|
await this.orderService.emitPatch(orderId);
|
|
78
84
|
return true;
|
|
79
85
|
}
|
|
@@ -14,6 +14,7 @@ const {now, nowSql, fromTimestamp, toSql} = require('../support/time');
|
|
|
14
14
|
const {businessDay} = require('../support/tz');
|
|
15
15
|
const {uuid, isUuid} = require('../support/ids');
|
|
16
16
|
const {htmlspecialchars, md5, phpJsonEncode} = require('../support/str');
|
|
17
|
+
const {isNumeric} = require('../support/receipts');
|
|
17
18
|
const {castRow} = require('../support/db');
|
|
18
19
|
const {translated} = require('../support/lang');
|
|
19
20
|
const {assertOrderMutable} = require('../guard');
|
|
@@ -24,7 +25,7 @@ const TYPE_EXTRA = OrderProduct.TYPE_EXTRA;
|
|
|
24
25
|
|
|
25
26
|
const DINE_IN = 'dine_in';
|
|
26
27
|
const DELIVERY = 'delivery';
|
|
27
|
-
const PIECE_CODE = '
|
|
28
|
+
const PIECE_CODE = 'piece';
|
|
28
29
|
|
|
29
30
|
const numberFormat = (value, decimals = 2) => toFloat(value).toFixed(decimals);
|
|
30
31
|
|
|
@@ -572,6 +573,9 @@ class OrderService {
|
|
|
572
573
|
order_id: orderId,
|
|
573
574
|
product_id: orderProduct.product_id,
|
|
574
575
|
quantity: removeQuantity,
|
|
576
|
+
// Только для Telegram (в order_events колонки нет): иначе цена ищется по
|
|
577
|
+
// product_id в чеке и берётся у первой попавшейся позиции этого товара.
|
|
578
|
+
price: money(orderProduct.price),
|
|
575
579
|
}]);
|
|
576
580
|
}
|
|
577
581
|
|
|
@@ -606,12 +610,43 @@ class OrderService {
|
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
async changeWaiter({orderId, waiterId}) {
|
|
609
|
-
|
|
610
|
-
|
|
613
|
+
const order = await this.findOrFail(orderId);
|
|
614
|
+
this.assertMutable(order, 'orders.changeWaiter');
|
|
615
|
+
await this.orders.updateRow(orderId, {serving_id: waiterId, slot_index: await this.freeSlotIndex(order, waiterId)});
|
|
611
616
|
await this.saveEvents([{event: 'orders:change_waiter', time: nowSql(), order_id: orderId}]);
|
|
612
617
|
return await this.patchOrResource(orderId);
|
|
613
618
|
}
|
|
614
619
|
|
|
620
|
+
/** Режим слотов: чек из занятого слота — в свободный (OrderService::fixSlot в Laravel). */
|
|
621
|
+
async fixSlot({orderId}) {
|
|
622
|
+
const order = await this.findOrFail(orderId);
|
|
623
|
+
this.assertMutable(order, 'orders.fixSlot');
|
|
624
|
+
const slotIndex = await this.freeSlotIndex(order, order.serving_id);
|
|
625
|
+
if (slotIndex === toInt(order.slot_index, 1)) {
|
|
626
|
+
return await this.resource().order(order);
|
|
627
|
+
}
|
|
628
|
+
await this.orders.updateRow(orderId, {slot_index: slotIndex});
|
|
629
|
+
return await this.patchOrResource(orderId);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Режим слотов: переданный чек сохраняет слот, только если у нового официанта он свободен,
|
|
634
|
+
* иначе — первый свободный (два чека в одном слоте выглядели как пропажа одного).
|
|
635
|
+
* Как OrderService::freeSlotIndex в Laravel.
|
|
636
|
+
*/
|
|
637
|
+
async freeSlotIndex(order, waiterId) {
|
|
638
|
+
const current = Math.max(1, toInt(order.slot_index, 1));
|
|
639
|
+
const taken = new Set(await this.orders.openSlotIndexes(order.company_id, waiterId, order.id));
|
|
640
|
+
if (!taken.has(current)) {
|
|
641
|
+
return current;
|
|
642
|
+
}
|
|
643
|
+
let index = 1;
|
|
644
|
+
while (taken.has(index)) {
|
|
645
|
+
index++;
|
|
646
|
+
}
|
|
647
|
+
return index;
|
|
648
|
+
}
|
|
649
|
+
|
|
615
650
|
async changeGuestCount({orderId, guestCount = 1}) {
|
|
616
651
|
this.assertMutable(await this.findOrFail(orderId), 'orders.changeGuestCount');
|
|
617
652
|
await this.orders.updateRow(orderId, {guest_count: toInt(guestCount, 1)});
|
|
@@ -652,10 +687,11 @@ class OrderService {
|
|
|
652
687
|
async applyDiscountByAmount(orderId, amount) {
|
|
653
688
|
const order = await this.findOrFail(orderId);
|
|
654
689
|
this.assertMutable(order, 'orders.applyDiscount:amount');
|
|
655
|
-
|
|
656
|
-
if (!
|
|
690
|
+
// is_numeric, как в Laravel: toFloat('abc') === 0 применял скидку 0 вместо отказа.
|
|
691
|
+
if (!isNumeric(amount)) {
|
|
657
692
|
return false;
|
|
658
693
|
}
|
|
694
|
+
const value = toFloat(amount);
|
|
659
695
|
if (order.max_discount_amount < value) {
|
|
660
696
|
throw new HttpError(403, {success: false, error: 'max_discount_amount'});
|
|
661
697
|
}
|