@alizadefaig/pos-server 1.2.27 → 1.2.29

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.
@@ -42,11 +42,42 @@ const teamResource = (team, {currencySymbol = null, languageCode = null, urls =
42
42
  };
43
43
  };
44
44
 
45
+ const OFFLINE_MODES = ['off', 'standalone', 'hall'];
46
+
47
+ /**
48
+ * Порт OfflineSetupService::forTerminal (Laravel): офлайн-режим кассы из суперадминки — то, что
49
+ * касса записывает в секцию offline своего config.json. null — заведение офлайном не управляет.
50
+ * Правила меняются в обоих местах сразу.
51
+ *
52
+ * @param {object} terminal строка terminals
53
+ * @param {?object} company строка companies (offline_mode, offline_master_terminal_id, offline_lan_key, offline_lan_front)
54
+ */
55
+ const offlineConfig = (terminal, company) => {
56
+ const mode = company ? company.offline_mode : null;
57
+ if (!OFFLINE_MODES.includes(mode)) {
58
+ return null;
59
+ }
60
+ if (mode === 'off') {
61
+ return {enabled: false};
62
+ }
63
+ if (mode === 'hall' && company.offline_master_terminal_id && company.offline_lan_key) {
64
+ const isMaster = company.offline_master_terminal_id === terminal.id;
65
+ return {
66
+ enabled: true,
67
+ role: isMaster ? 'master' : 'follower',
68
+ master_url: isMaster ? null : 'auto',
69
+ lan_front: isMaster && Boolean(nullableBool(company.offline_lan_front)),
70
+ lan_key: company.offline_lan_key,
71
+ };
72
+ }
73
+ return {enabled: true, role: 'standalone', master_url: null, lan_front: false};
74
+ };
75
+
45
76
  /**
46
77
  * @param {object} terminal строка terminals
47
- * @param {{companyName: ?string, workshops: Array<{id, name}>, posMode: object}} rel
78
+ * @param {{companyName: ?string, workshops: Array<{id, name}>, posMode: object, company: ?object}} rel
48
79
  */
49
- const terminalResource = (terminal, {companyName = null, workshops = [], posMode}) => ({
80
+ const terminalResource = (terminal, {companyName = null, workshops = [], posMode, company = null}) => ({
50
81
  id: terminal.id,
51
82
  name: terminal.name,
52
83
  is_active: nullableBool(terminal.is_active),
@@ -58,6 +89,7 @@ const terminalResource = (terminal, {companyName = null, workshops = [], posMode
58
89
  // Каст 'array', `?? []`.
59
90
  settings: phpArray(terminal.settings) ?? [],
60
91
  pos_mode: posMode,
92
+ offline: offlineConfig(terminal, company),
61
93
  });
62
94
 
63
95
  /**
@@ -82,4 +114,4 @@ const teamUserResource = (member, {user = null, role = null, scopes = {}, cashSh
82
114
  shift: shiftResource(workShift),
83
115
  });
84
116
 
85
- module.exports = {teamResource, terminalResource, teamUserResource};
117
+ module.exports = {teamResource, terminalResource, teamUserResource, offlineConfig};
@@ -106,7 +106,7 @@ class InitService {
106
106
  ? this.db.get(`SELECT code FROM languages WHERE id = ?${this.alive()}`, [team.language_id])
107
107
  : null,
108
108
  terminal.company_id
109
- ? this.db.get(`SELECT name FROM companies WHERE id = ?${this.alive()}`, [terminal.company_id])
109
+ ? this.db.get(`SELECT * FROM companies WHERE id = ?${this.alive()}`, [terminal.company_id])
110
110
  : null,
111
111
  this.db.all(
112
112
  `SELECT pw.id, pw.name FROM product_workshops pw
@@ -129,6 +129,8 @@ class InitService {
129
129
  companyName: company ? company.name : null,
130
130
  workshops: terminalWorkshops,
131
131
  posMode: mode,
132
+ // SELECT * выше: офлайн-колонок (offline_*) в локальной схеме кассы нет.
133
+ company,
132
134
  }),
133
135
  };
134
136
  }
@@ -235,7 +235,7 @@ class OrderProductService {
235
235
 
236
236
  async updateModifiers({orderProductId, modifiers}) {
237
237
  const orderProduct = await this.findMutable(orderProductId, 'orderProducts.updateModifiers');
238
- const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifiers);
238
+ const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifiers, this.context.locale());
239
239
 
240
240
  await this.orderProducts.update(orderProductId, {modifications_sets_hash: hash});
241
241
  await this.orderProducts.deleteModifierSets(orderProductId);
@@ -15,6 +15,7 @@ const {businessDay} = require('../support/tz');
15
15
  const {uuid, isUuid} = require('../support/ids');
16
16
  const {htmlspecialchars, md5, phpJsonEncode} = require('../support/str');
17
17
  const {castRow} = require('../support/db');
18
+ const {translated} = require('../support/lang');
18
19
  const {assertOrderMutable} = require('../guard');
19
20
  const {withLock} = require('../support/lock');
20
21
 
@@ -239,7 +240,7 @@ class OrderService {
239
240
  throw notFound('Product not found');
240
241
  }
241
242
 
242
- const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifierSets);
243
+ const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifierSets, this.context.locale());
243
244
 
244
245
  // Повременная позиция уникальна по времени старта — её нельзя доливать в существующую.
245
246
  const hasTimerModifier = await this.snapshot.hasTimerModifier(product.id);
@@ -861,11 +862,17 @@ class OrderService {
861
862
  * Порядок ключей в элементе важен: хэш считается от json_encode этого массива и служит
862
863
  * ключом склейки строк чека — переставь поля, и одинаковые позиции перестанут склеиваться.
863
864
  */
864
- static async collectModifierSets(snapshot, modifierSets) {
865
+ static async collectModifierSets(snapshot, modifierSets, locale) {
865
866
  let totalPrice = 0;
866
867
  const data = [];
867
868
 
868
- for (const modifier of modifierSets || []) {
869
+ // Фронт шлёт modifier_sets_form как есть — объект по id модификатора (так его отдаёт и
870
+ // ProductResource). PHP-шный foreach обходит его как массив, for..of — нет.
871
+ const items = Array.isArray(modifierSets)
872
+ ? modifierSets
873
+ : (modifierSets && typeof modifierSets === 'object' ? Object.values(modifierSets) : []);
874
+
875
+ for (const modifier of items) {
869
876
  const modifierSet = await snapshot.findModifierSet(modifier.modifier_set_id);
870
877
  if (!modifierSet) {
871
878
  throw notFound('Modifier set not found');
@@ -884,7 +891,8 @@ class OrderService {
884
891
 
885
892
  data.push({
886
893
  modifier_set_type: modifierSet.type,
887
- modifier_set_title: productModifierSet.title,
894
+ // title переводимый (Spatie): PHP отдаёт строку локали, а не JSON из колонки.
895
+ modifier_set_title: translated(productModifierSet.title, locale),
888
896
  name: modifier.name,
889
897
  total,
890
898
  price,
@@ -71,7 +71,7 @@ const intervals = () => ({
71
71
  // модель M: как часто спрашивать у облака режим заведения (заодно это heartbeat кассы)
72
72
  modePoll: num('OFFLINE_MODE_POLL_MS', 10000),
73
73
  // выход терминала при недосланном: столько push досылает очередь, прежде чем токен отзовут
74
- logoutGrace: num('OFFLINE_LOGOUT_GRACE_MS', 600000),
74
+ logoutDrain: num('OFFLINE_LOGOUT_DRAIN_MS', 30000),
75
75
  });
76
76
 
77
77
  const timeouts = () => ({
package/app/sync/index.js CHANGED
@@ -100,9 +100,7 @@ const state = {
100
100
  // чеки, которые облако просит перечитать (sync:pull)
101
101
  pullIds: new Set(),
102
102
 
103
- // выход терминала (logout): с какого момента облако на связи, а очередь ещё не пуста;
104
- // идёт отзыв токена — возврат на кассу (resume) в это время невозможен
105
- revokeWaitFrom: null,
103
+ // выход терминала (logout): идёт отзыв токена — возврат на кассу (resume) в это время невозможен
106
104
  revoking: false,
107
105
 
108
106
  // сверка касса ↔ облако (reconcile.js): последний итог, расхождений подряд, сколько вылечено
@@ -386,10 +384,12 @@ const authoritativeEpoch = () => (
386
384
  : null
387
385
  );
388
386
 
389
- const runPush = async () => {
387
+ // until — выход терминала: досылать, пока облако отвечает (без гистерезиса), не дольше этого срока.
388
+ const runPush = async ({until = null} = {}) => {
390
389
  let rounds = 0;
391
390
  reconcilePoison();
392
- while (refreshPending() > 0 && rounds < MAX_PUSH_ROUNDS && state.online) {
391
+ const going = () => (until === null ? state.online : state.oks > 0 && Date.now() < until);
392
+ while (refreshPending() > 0 && rounds < MAX_PUSH_ROUNDS && going()) {
393
393
  rounds += 1;
394
394
  const epoch = authoritativeEpoch();
395
395
  let result;
@@ -1244,7 +1244,6 @@ const setSession = async (input) => {
1244
1244
  // Фронт снова прислал сессию — значит, терминал вошёл обратно (resume).
1245
1245
  delete state.session.parked;
1246
1246
  }
1247
- state.revokeWaitFrom = null;
1248
1247
  return {success: true, mode: state.mode};
1249
1248
  };
1250
1249
 
@@ -1260,7 +1259,6 @@ const clearSession = async () => {
1260
1259
  }
1261
1260
  stopComponents();
1262
1261
  state.session = null;
1263
- state.revokeWaitFrom = null;
1264
1262
  await state.store.block((s) => kv.del(s, 'session'));
1265
1263
  updateMode();
1266
1264
  return {success: true};
@@ -1273,12 +1271,12 @@ const clearSession = async () => {
1273
1271
  * (auth.logoutApp) и стирал сессию синка: без интернета выход падал, а недосланное
1274
1272
  * ждало следующего входа, которого без интернета не бывает (app.auth — только облако).
1275
1273
  *
1276
- * Теперь выход «паркует» сессию: фронт свою авторизацию стирает, а синк токен держит, пока
1277
- * он нужен, — досылает очередь, а мастер модели M продолжает обслуживать зал. Токен
1278
- * отзывается, когда облако на связи, очередь пуста и касса не держит заведение
1279
- * (очередь не опустела за OFFLINE_LOGOUT_GRACE_MS — отзыв всё равно, недосланное уйдёт со
1280
- * следующим входом в это заведение). До отзыва на кассу можно вернуться по PIN сотрудника
1281
- * (resume) — это единственный вход без облака.
1274
+ * Теперь выход «паркует» сессию: фронт свою авторизацию стирает, а синк держит токен до
1275
+ * первого ответа облака. Облако ответило (гистерезис связи не ждём) — синк досылает очередь не
1276
+ * дольше OFFLINE_LOGOUT_DRAIN_MS и отзывает токен в любом случае, в том числе на мастере модели M
1277
+ * (заведение он после этого не обслуживает). Недосланное остаётся помеченным и уйдёт со
1278
+ * следующим входом в это заведение на этой кассе. До отзыва на кассу можно вернуться по PIN
1279
+ * сотрудника (resume) — это единственный вход без облака.
1282
1280
  */
1283
1281
 
1284
1282
  const initNames = () => {
@@ -1324,8 +1322,11 @@ const revokeToken = async (client, data = {}, timeout = config.timeouts().api) =
1324
1322
  }
1325
1323
  };
1326
1324
 
1325
+ // Облако ответило на последнюю проверку связи: для выхода гистерезис не ждём.
1326
+ const cloudAnswers = () => state.oks > 0;
1327
+
1327
1328
  const canRevokeParked = () => Boolean(state.session && state.session.parked && state.cloud)
1328
- && state.online === true && !state.revoking && !isServingAsMaster();
1329
+ && cloudAnswers() && !state.revoking;
1329
1330
 
1330
1331
  /** Отзыв токена запаркованной сессии. Вернулись на кассу, пока шёл запрос, — сессию не трогаем. */
1331
1332
  const revokeParked = async (timeout) => {
@@ -1347,16 +1348,20 @@ const revokeParked = async (timeout) => {
1347
1348
 
1348
1349
  const runRevoke = async () => {
1349
1350
  if (!canRevokeParked()) {
1350
- state.revokeWaitFrom = null;
1351
1351
  return;
1352
1352
  }
1353
- if (refreshPending() > 0) {
1354
- state.revokeWaitFrom = state.revokeWaitFrom || Date.now();
1355
- if (Date.now() - state.revokeWaitFrom < config.intervals().logoutGrace) {
1353
+ if (!isFollower() && hasInit() && refreshPending() > 0) {
1354
+ await step('logout push', () => runPush({until: Date.now() + config.intervals().logoutDrain}));
1355
+ if (!canRevokeParked()) {
1356
1356
  return;
1357
1357
  }
1358
- console.warn(`[sync] logout: ${state.pending} unsent record(s) still queued after the grace period — `
1359
- + 'revoking the token anyway, they wait for the next login to this company');
1358
+ if (refreshPending() > 0) {
1359
+ console.warn(`[sync] logout: ${state.pending} unsent record(s) left — revoking the token anyway, `
1360
+ + 'they wait for the next login to this company');
1361
+ }
1362
+ }
1363
+ if (isServingAsMaster()) {
1364
+ console.warn('[sync] terminal logged out on the master — the company is not served after the token is revoked');
1360
1365
  }
1361
1366
  await revokeParked();
1362
1367
  };
@@ -1376,7 +1381,7 @@ const runLogoutQueue = async () => {
1376
1381
  };
1377
1382
 
1378
1383
  const runLogout = async () => {
1379
- if (state.online === true && state.store && (kv.get(state.store, 'logout_queue') || []).length) {
1384
+ if (cloudAnswers() && state.store && (kv.get(state.store, 'logout_queue') || []).length) {
1380
1385
  await step('logout queue', runLogoutQueue);
1381
1386
  }
1382
1387
  await step('revoke', runRevoke);
@@ -1402,9 +1407,7 @@ const logout = async ({token} = {}) => {
1402
1407
  if (canRevokeParked() && refreshPending() === 0 && await revokeParked(REVOKE_TIMEOUT_MS)) {
1403
1408
  return {success: true, handled: true, revoked: true};
1404
1409
  }
1405
- if (isServingAsMaster()) {
1406
- console.warn('[sync] terminal logged out on the master — the company is still served, token kept');
1407
- }
1410
+ // Очередь не пуста или облако не ответило — досылка и отзыв идут в цикле сразу же.
1408
1411
  schedule(0);
1409
1412
  return {success: true, handled: true, parked: true, pending: state.pending, master: isServingAsMaster()};
1410
1413
  };
@@ -1450,7 +1453,6 @@ const resume = async ({code} = {}) => {
1450
1453
  throw busy();
1451
1454
  }
1452
1455
  delete session.parked;
1453
- state.revokeWaitFrom = null;
1454
1456
  await state.store.block((s) => kv.set(s, 'session', {...session}));
1455
1457
  log('terminal logged back in by PIN');
1456
1458
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alizadefaig/pos-server",
3
- "version": "1.2.27",
3
+ "version": "1.2.29",
4
4
  "description": "Server in express js with Proxy to Main server if need and will work with Electron JS",
5
5
  "homepage": "https://gitlab.com/faigalizade/pos-server#readme",
6
6
  "bugs": {