@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.
@@ -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
- this.terminal = terminal;
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
@@ -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
- sql += ' AND company_id = ?';
94
- params.push(companyId);
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,
@@ -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
- if (typeof value === 'string' && value.length) {
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 Array.isArray(parsed) ? parsed : [1];
74
+ return parsed && typeof parsed === 'object' ? parsed : null;
54
75
  } catch (e) {
55
- return [1];
76
+ return null;
56
77
  }
57
78
  }
58
- return [1];
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);
@@ -11,9 +11,9 @@ const {unique, groupBy, indexBy, alive, whereIn} = require('../support/rows');
11
11
  * Накопительный: повторный `load()` догружает только новые товары. Ресурс чека зовёт его на каждый
12
12
  * чек и на каждую позицию, а связи общие.
13
13
  *
14
- * `modifier_sets` и `product_modifier_sets` Laravel читает без ORDER BY — порядок кучи Postgres.
15
- * В SQLite это порядок вставки снапшота, а снапшот пишет строки в том порядке, в каком их отдало
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: (terminalsByTeam.get(String(row.id)) || []).map((terminal) =>
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 id FROM terminals WHERE id = ?${this.alive()}`, [id])
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
- [this.context.teamId, value, value, true, true]
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
- async init() {
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 = 'pcs';
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
 
@@ -652,10 +656,11 @@ class OrderService {
652
656
  async applyDiscountByAmount(orderId, amount) {
653
657
  const order = await this.findOrFail(orderId);
654
658
  this.assertMutable(order, 'orders.applyDiscount:amount');
655
- const value = toFloat(amount);
656
- if (!Number.isFinite(value)) {
659
+ // is_numeric, как в Laravel: toFloat('abc') === 0 применял скидку 0 вместо отказа.
660
+ if (!isNumeric(amount)) {
657
661
  return false;
658
662
  }
663
+ const value = toFloat(amount);
659
664
  if (order.max_discount_amount < value) {
660
665
  throw new HttpError(403, {success: false, error: 'max_discount_amount'});
661
666
  }
@@ -1,7 +1,7 @@
1
1
  const PrinterRepository = require('../repositories/PrinterRepository');
2
2
  const KdsService = require('./KdsService');
3
3
  const OrderProduct = require('../models/OrderProduct');
4
- const {money, qty, round2, toBool, sum} = require('../support/num');
4
+ const {money, qty, round2, toBool, toFloat, sum} = require('../support/num');
5
5
  const {now, nowSql, parse} = require('../support/time');
6
6
  const {DEFAULT_TIMEZONE, zonedParts} = require('../support/tz');
7
7
  const {translated} = require('../support/lang');
@@ -463,7 +463,19 @@ class PrintService {
463
463
 
464
464
  // ------------------------------------------------------------ счёт гостю
465
465
 
466
- /** Товары счёта: гость → (товар + цена), модификаторы свёрнуты по имени и цене. */
466
+ /**
467
+ * Цена за 1 шт в счёте. У обычной позиции — с модификаторами, как в `OrderProduct.calculate`
468
+ * (иначе тех-карта с ценой в модификаторах печаталась «1 × 0 = 5»), у повременной — тариф.
469
+ */
470
+ billUnitPrice(item) {
471
+ if (item.type === TYPE_TIMER) {
472
+ return item.price;
473
+ }
474
+ const modifiersTotal = (item.modifiers || []).reduce((carry, modifier) => carry + toFloat(modifier.total), 0);
475
+ return money(toFloat(item.price) + Math.max(modifiersTotal, 0));
476
+ }
477
+
478
+ /** Товары счёта: гость → (товар + цена за 1 шт), модификаторы свёрнуты по имени и цене. */
467
479
  billProducts(order, showTableServiceCharge) {
468
480
  const byGuest = new Map();
469
481
  for (const item of order.products) {
@@ -477,7 +489,7 @@ class PrintService {
477
489
  for (const group of byGuest.values()) {
478
490
  const byProduct = new Map();
479
491
  for (const item of group) {
480
- const key = `${item.product_id}_${item.price}`;
492
+ const key = `${item.product_id}_${this.billUnitPrice(item)}`;
481
493
  if (!byProduct.has(key)) {
482
494
  byProduct.set(key, []);
483
495
  }
@@ -512,7 +524,7 @@ class PrintService {
512
524
  // весовой товар печатается как «1.5 kq», цена — за 1 kq
513
525
  is_weight_item: Boolean(first.product && toBool(first.product.is_weight_item)),
514
526
  unit_id: first.unit_id ?? null,
515
- price: first.price,
527
+ price: this.billUnitPrice(first),
516
528
  discount_amount: sum(productGroup, 'discount_amount'),
517
529
  service_charge_amount: sum(productGroup, 'service_charge_amount'),
518
530
  total: showTableServiceCharge ? sum(productGroup, 'total') : sum(productGroup, 'subtotal'),