@alizadefaig/pos-server 1.2.33 → 1.2.35
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/resources/OrderResource.js +1 -1
- package/app/core/resources/ProductResource.js +15 -6
- package/app/core/services/InitService.js +3 -0
- package/app/core/services/OrderProductService.js +2 -1
- package/app/core/services/OrderService.js +19 -5
- package/app/core/services/PrintService.js +2 -1
- package/app/core/support/delivery.js +36 -0
- package/app/core/support/prices.js +124 -0
- package/app/core/support/telegramReport.js +9 -0
- package/app/db/driver.js +6 -0
- package/app/db/migrations/014_product_prices.sql +17 -0
- package/app/relay/masters.js +22 -3
- package/app/sync/index.js +10 -2
- package/package.json +1 -1
- package/routes/common/api/orders.js +1 -0
- package/server.js +12 -0
|
@@ -85,7 +85,7 @@ class OrderResource {
|
|
|
85
85
|
shift_id: order.shift_id,
|
|
86
86
|
slot_index: order.slot_index,
|
|
87
87
|
takeaway: null,
|
|
88
|
-
delivery: null,
|
|
88
|
+
delivery: order.data?.delivery ?? null,
|
|
89
89
|
activities: [],
|
|
90
90
|
payments: order.payments,
|
|
91
91
|
products: order.products.map((item) => this.orderProduct(item, rel)),
|
|
@@ -21,23 +21,30 @@ const {nullableBool, modelArray} = require('./casts');
|
|
|
21
21
|
* @property {Map<string, object>} categories id → product_categories (для мутатора цеха)
|
|
22
22
|
* @property {?Map<string, string>} urls id картинки → URL; null — таблицы images нет (касса)
|
|
23
23
|
* @property {string} locale
|
|
24
|
+
* @property {?import('../support/prices').PriceBook} [prices] цены филиала/источника — только в
|
|
25
|
+
* справочнике кассы (InitService::products); у товаров внутри чеков — базовые
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
/** price — цена филиала кассы; source_prices — { order_source_id: цена } или null. */
|
|
29
|
+
const priceOf = (id, base, rel) => (rel.prices ? rel.prices.branch(id, base) : money(base));
|
|
30
|
+
const sourcePricesOf = (id, rel) => (rel.prices ? rel.prices.sourcePrices(id) : null);
|
|
31
|
+
|
|
32
|
+
const modifierSetModifierResource = (modifier, rel) => ({
|
|
27
33
|
id: modifier.id,
|
|
28
34
|
product_id: modifier.product_id,
|
|
29
35
|
type: modifier.type,
|
|
30
|
-
name: trans(modifier.name, locale),
|
|
36
|
+
name: trans(modifier.name, rel.locale),
|
|
31
37
|
// Каст 'boolean': в SQLite 0/1.
|
|
32
38
|
is_without_debit: nullableBool(modifier.is_without_debit),
|
|
33
39
|
min: modifier.min,
|
|
34
40
|
max: modifier.max,
|
|
35
41
|
// UnitableCast отдаёт значение как есть — numeric строкой.
|
|
36
42
|
gross: modifier.gross ?? null,
|
|
37
|
-
image: castImage(urls, modifier.image),
|
|
43
|
+
image: castImage(rel.urls, modifier.image),
|
|
38
44
|
default_count: modifier.default_count,
|
|
39
45
|
default: nullableBool(modifier.default),
|
|
40
|
-
price:
|
|
46
|
+
price: priceOf(modifier.id, modifier.price, rel),
|
|
47
|
+
source_prices: sourcePricesOf(modifier.id, rel),
|
|
41
48
|
});
|
|
42
49
|
|
|
43
50
|
const productModifierSetResource = (item, rel) => {
|
|
@@ -86,7 +93,8 @@ const modifierSetsForm = (sets, rel) => {
|
|
|
86
93
|
max: modifier.max,
|
|
87
94
|
min: modifier.min,
|
|
88
95
|
total: total !== null && total !== undefined && Number(total) < 0 ? total : 0,
|
|
89
|
-
price:
|
|
96
|
+
price: priceOf(modifier.id, modifier.price, rel),
|
|
97
|
+
source_prices: sourcePricesOf(modifier.id, rel),
|
|
90
98
|
modifier_set_modifier_id: modifier.id,
|
|
91
99
|
modifier_set_id: item.modifier_set_id,
|
|
92
100
|
};
|
|
@@ -108,7 +116,8 @@ const productResource = (product, rel, top = true) => {
|
|
|
108
116
|
full_name: trans(product.full_name, rel.locale),
|
|
109
117
|
name: trans(product.name, rel.locale),
|
|
110
118
|
color: product.color ?? null,
|
|
111
|
-
price: round2(
|
|
119
|
+
price: round2(priceOf(product.id, product.price, rel)),
|
|
120
|
+
source_prices: sourcePricesOf(product.id, rel),
|
|
112
121
|
};
|
|
113
122
|
// whenLoaded: у модификаций ключей modifications и modifier_sets нет вовсе.
|
|
114
123
|
if (top) {
|
|
@@ -12,6 +12,7 @@ const pin = require('../support/pin');
|
|
|
12
12
|
const {HttpError} = require('../errors');
|
|
13
13
|
const {toBool} = require('../support/num');
|
|
14
14
|
const {companiesFor} = require('../support/terminalAccess');
|
|
15
|
+
const prices = require('../support/prices');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Облачные справочники POS: порт AuthController::init и соседних чтений (ProductService,
|
|
@@ -187,6 +188,8 @@ class InitService {
|
|
|
187
188
|
return [];
|
|
188
189
|
}
|
|
189
190
|
const rel = await new ProductRelations(this.db, this.locale).load(products);
|
|
191
|
+
// Цены филиала кассы и источников заказа (support/prices.js).
|
|
192
|
+
rel.prices = await prices.forTeam(this.db, teamId, this.context.companyId);
|
|
190
193
|
return products.map((product) => productResource(product, rel));
|
|
191
194
|
}
|
|
192
195
|
|
|
@@ -241,7 +241,8 @@ class OrderProductService {
|
|
|
241
241
|
|
|
242
242
|
async updateModifiers({orderProductId, modifiers}) {
|
|
243
243
|
const orderProduct = await this.findMutable(orderProductId, 'orderProducts.updateModifiers');
|
|
244
|
-
const
|
|
244
|
+
const order = await this.orders.findRow(orderProduct.order_id);
|
|
245
|
+
const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifiers, this.context.locale(), order);
|
|
245
246
|
|
|
246
247
|
await this.orderProducts.update(orderProductId, {modifications_sets_hash: hash});
|
|
247
248
|
await this.orderProducts.deleteModifierSets(orderProductId);
|
|
@@ -19,6 +19,8 @@ const {castRow} = require('../support/db');
|
|
|
19
19
|
const {translated} = require('../support/lang');
|
|
20
20
|
const {assertOrderMutable} = require('../guard');
|
|
21
21
|
const {withLock} = require('../support/lock');
|
|
22
|
+
const prices = require('../support/prices');
|
|
23
|
+
const {normalizeDelivery} = require('../support/delivery');
|
|
22
24
|
|
|
23
25
|
const TYPE_TIMER = OrderProduct.TYPE_TIMER;
|
|
24
26
|
const TYPE_EXTRA = OrderProduct.TYPE_EXTRA;
|
|
@@ -150,7 +152,7 @@ class OrderService {
|
|
|
150
152
|
|
|
151
153
|
// ---------------------------------------------------------------- чек
|
|
152
154
|
|
|
153
|
-
async create({orderSourceId, tableId, guestCount = 1, hallId = null, servingId = null, id = null, slotIndex = 1}) {
|
|
155
|
+
async create({orderSourceId, tableId, guestCount = 1, hallId = null, servingId = null, id = null, slotIndex = 1, delivery = null}) {
|
|
154
156
|
const context = this.context;
|
|
155
157
|
|
|
156
158
|
let orderSource = await this.snapshot.findOrderSource(context.teamId, orderSourceId);
|
|
@@ -220,6 +222,8 @@ class OrderService {
|
|
|
220
222
|
global_number: numbers.globalNumber,
|
|
221
223
|
number: numbers.number,
|
|
222
224
|
status: 'created',
|
|
225
|
+
// Доставка: адрес/телефон/имя (OrderService::normalizeDelivery в Laravel).
|
|
226
|
+
data: (delivery = normalizeDelivery(delivery)) ? {delivery} : null,
|
|
223
227
|
});
|
|
224
228
|
});
|
|
225
229
|
|
|
@@ -241,7 +245,7 @@ class OrderService {
|
|
|
241
245
|
throw notFound('Product not found');
|
|
242
246
|
}
|
|
243
247
|
|
|
244
|
-
const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifierSets, this.context.locale());
|
|
248
|
+
const [sets, , hash] = await OrderService.collectModifierSets(this.snapshot, modifierSets, this.context.locale(), order);
|
|
245
249
|
|
|
246
250
|
// Повременная позиция уникальна по времени старта — её нельзя доливать в существующую.
|
|
247
251
|
const hasTimerModifier = await this.snapshot.hasTimerModifier(product.id);
|
|
@@ -261,7 +265,9 @@ class OrderService {
|
|
|
261
265
|
order_id: order.id,
|
|
262
266
|
product_id: product.id,
|
|
263
267
|
guest_number: guestNumber,
|
|
264
|
-
|
|
268
|
+
// Цена филиала/источника чека (support/prices.js), иначе базовая.
|
|
269
|
+
price: money((await prices.forItems(this.db, {productIds: [product.id]}, order.company_id))
|
|
270
|
+
.resolve(product.id, product.price, order.order_source_id)),
|
|
265
271
|
added_at: fromTimestamp(context.timestamp),
|
|
266
272
|
team_id: context.teamId,
|
|
267
273
|
company_id: context.companyId,
|
|
@@ -898,7 +904,7 @@ class OrderService {
|
|
|
898
904
|
* Порядок ключей в элементе важен: хэш считается от json_encode этого массива и служит
|
|
899
905
|
* ключом склейки строк чека — переставь поля, и одинаковые позиции перестанут склеиваться.
|
|
900
906
|
*/
|
|
901
|
-
static async collectModifierSets(snapshot, modifierSets, locale) {
|
|
907
|
+
static async collectModifierSets(snapshot, modifierSets, locale, order = null) {
|
|
902
908
|
let totalPrice = 0;
|
|
903
909
|
const data = [];
|
|
904
910
|
|
|
@@ -908,6 +914,11 @@ class OrderService {
|
|
|
908
914
|
? modifierSets
|
|
909
915
|
: (modifierSets && typeof modifierSets === 'object' ? Object.values(modifierSets) : []);
|
|
910
916
|
|
|
917
|
+
// Касса шлёт цену из справочника — своя цена филиала/источника чека её перекрывает.
|
|
918
|
+
const book = order && items.length
|
|
919
|
+
? await prices.forItems(snapshot.db, {modifierIds: items.map((item) => item && item.modifier_set_modifier_id)}, order.company_id)
|
|
920
|
+
: null;
|
|
921
|
+
|
|
911
922
|
for (const modifier of items) {
|
|
912
923
|
const modifierSet = await snapshot.findModifierSet(modifier.modifier_set_id);
|
|
913
924
|
if (!modifierSet) {
|
|
@@ -918,7 +929,10 @@ class OrderService {
|
|
|
918
929
|
throw notFound('Product modifier set not found');
|
|
919
930
|
}
|
|
920
931
|
|
|
921
|
-
const
|
|
932
|
+
const modifierId = modifier.modifier_set_modifier_id;
|
|
933
|
+
const price = book && modifierId && book.overrides(modifierId, order.order_source_id)
|
|
934
|
+
? book.resolve(modifierId, modifier.price, order.order_source_id)
|
|
935
|
+
: toFloat(modifier.price);
|
|
922
936
|
const quantity = modifier.quantity;
|
|
923
937
|
if (quantity === 0) {
|
|
924
938
|
continue;
|
|
@@ -10,6 +10,7 @@ const {uuid} = require('../support/ids');
|
|
|
10
10
|
const {notFound} = require('../errors');
|
|
11
11
|
const {normalizeReceipts, phpTruthy, findRow} = require('../support/receipts');
|
|
12
12
|
const {withLock, LockTimeoutError} = require('../support/lock');
|
|
13
|
+
const {deliveryLine} = require('../support/delivery');
|
|
13
14
|
|
|
14
15
|
const TYPE_TIMER = OrderProduct.TYPE_TIMER;
|
|
15
16
|
const DINE_IN = 'dine_in';
|
|
@@ -629,7 +630,7 @@ class PrintService {
|
|
|
629
630
|
table: table ? table.label : null,
|
|
630
631
|
courier: '',
|
|
631
632
|
comment: order.comment ?? null,
|
|
632
|
-
delivery_address:
|
|
633
|
+
delivery_address: deliveryLine(order.data?.delivery),
|
|
633
634
|
print_name: receipts.print_name_of === 'team'
|
|
634
635
|
? (team ? translated(team.name, this.locale()) : null)
|
|
635
636
|
: (context.getCompany() ? context.getCompany().name : null),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Адрес доставки чека (orders.data.delivery): {address, phone, name}, строки обрезаны до 255
|
|
3
|
+
* символов. Без адреса — null. Зеркало Laravel OrderService::normalizeDelivery.
|
|
4
|
+
*/
|
|
5
|
+
const FIELDS = ['address', 'phone', 'name'];
|
|
6
|
+
|
|
7
|
+
const clean = (value) => {
|
|
8
|
+
if (value === null || value === undefined || typeof value === 'object') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const text = Array.from(String(value).trim()).slice(0, 255).join('');
|
|
12
|
+
return text === '' ? null : text;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const normalizeDelivery = (delivery) => {
|
|
16
|
+
if (!delivery || typeof delivery !== 'object' || Array.isArray(delivery)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const field of FIELDS) {
|
|
21
|
+
result[field] = clean(delivery[field]);
|
|
22
|
+
}
|
|
23
|
+
return result.address ? result : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Строка «Çatdırılma ünvanı» счёта: адрес · телефон · имя (Laravel OrderService::deliveryLine). */
|
|
27
|
+
const deliveryLine = (delivery) => {
|
|
28
|
+
if (!delivery || typeof delivery !== 'object' || !delivery.address) {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
return [delivery.address, delivery.phone, delivery.name]
|
|
32
|
+
.filter((value) => value !== null && value !== undefined && value !== '')
|
|
33
|
+
.join(' · ');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
module.exports = {normalizeDelivery, deliveryLine};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const {round2} = require('./num');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Цены продажи по филиалу и по источнику заказа (таблица product_prices) — порт
|
|
5
|
+
* App\Services\Pricing\PriceBook. Правило в двух местах — правь оба.
|
|
6
|
+
*
|
|
7
|
+
* Строка — переопределение цены товара или модификатора либо для филиала, либо для источника.
|
|
8
|
+
* Порядок: цена источника → цена филиала → базовая цена. Источник важнее: «Çatdırılma 13»
|
|
9
|
+
* действует во всех филиалах.
|
|
10
|
+
*/
|
|
11
|
+
class PriceBook {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object[]} rows строки product_prices
|
|
14
|
+
* @param {?string} companyId филиал кассы / чека
|
|
15
|
+
*/
|
|
16
|
+
constructor(rows, companyId = null) {
|
|
17
|
+
this.companyId = companyId ? String(companyId) : null;
|
|
18
|
+
/** @type {Map<string, {company: Map<string, number>, source: Map<string, number>}>} */
|
|
19
|
+
this.items = new Map();
|
|
20
|
+
for (const row of rows || []) {
|
|
21
|
+
const key = row.product_id || row.modifier_set_modifier_id;
|
|
22
|
+
if (!key) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!this.items.has(String(key))) {
|
|
26
|
+
this.items.set(String(key), {company: new Map(), source: new Map()});
|
|
27
|
+
}
|
|
28
|
+
const item = this.items.get(String(key));
|
|
29
|
+
if (row.order_source_id) {
|
|
30
|
+
item.source.set(String(row.order_source_id), round2(Number(row.price)));
|
|
31
|
+
} else if (row.company_id) {
|
|
32
|
+
item.company.set(String(row.company_id), round2(Number(row.price)));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Цена в филиале без учёта источника — её касса показывает по умолчанию. */
|
|
38
|
+
branch(id, base) {
|
|
39
|
+
const item = this.items.get(String(id));
|
|
40
|
+
const company = item && this.companyId ? item.company.get(this.companyId) : undefined;
|
|
41
|
+
return company !== undefined ? company : round2(Number(base) || 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Цена позиции в чеке: источник → филиал → базовая. */
|
|
45
|
+
resolve(id, base, orderSourceId) {
|
|
46
|
+
const item = this.items.get(String(id));
|
|
47
|
+
if (item && orderSourceId && item.source.has(String(orderSourceId))) {
|
|
48
|
+
return item.source.get(String(orderSourceId));
|
|
49
|
+
}
|
|
50
|
+
return this.branch(id, base);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Есть ли у позиции своя цена для источника или этого филиала. */
|
|
54
|
+
overrides(id, orderSourceId) {
|
|
55
|
+
const item = this.items.get(String(id));
|
|
56
|
+
if (!item) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return Boolean((orderSourceId && item.source.has(String(orderSourceId)))
|
|
60
|
+
|| (this.companyId && item.company.has(this.companyId)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** { order_source_id: цена } или null — касса показывает source_prices[источник] ?? price. */
|
|
64
|
+
sourcePrices(id) {
|
|
65
|
+
const item = this.items.get(String(id));
|
|
66
|
+
if (!item || !item.source.size) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return Object.fromEntries(item.source);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Postgres облака: таблицу создаёт миграция Laravel. Пока её нет — цены базовые, а не 500.
|
|
74
|
+
// Проверка без ошибки запроса: упавший SELECT в транзакции Postgres ломает всю транзакцию.
|
|
75
|
+
// Найденная таблица запоминается навсегда, отсутствие — на минуту.
|
|
76
|
+
let presentAt = null;
|
|
77
|
+
let missingAt = 0;
|
|
78
|
+
const hasTable = async (db) => {
|
|
79
|
+
if (db.dialect !== 'pgsql' || presentAt) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (Date.now() - missingAt < 60_000) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const row = await db.get("SELECT to_regclass('product_prices') AS name", []);
|
|
86
|
+
if (row && row.name) {
|
|
87
|
+
presentAt = Date.now();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
missingAt = Date.now();
|
|
91
|
+
return false;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const safeAll = async (db, sql, params) => (await hasTable(db)) ? db.all(sql, params) : [];
|
|
95
|
+
|
|
96
|
+
/** Все цены команды — справочник кассы (app.init / products.get). */
|
|
97
|
+
const forTeam = async (db, teamId, companyId) => {
|
|
98
|
+
if (!teamId) {
|
|
99
|
+
return new PriceBook([], companyId);
|
|
100
|
+
}
|
|
101
|
+
return new PriceBook(await safeAll(db, 'SELECT * FROM product_prices WHERE team_id = ?', [teamId]), companyId);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Цены конкретных товаров/модификаторов — для добавления в чек. */
|
|
105
|
+
const forItems = async (db, {productIds = [], modifierIds = []}, companyId) => {
|
|
106
|
+
const products = productIds.filter(Boolean).map(String);
|
|
107
|
+
const modifiers = modifierIds.filter(Boolean).map(String);
|
|
108
|
+
if (!products.length && !modifiers.length) {
|
|
109
|
+
return new PriceBook([], companyId);
|
|
110
|
+
}
|
|
111
|
+
const where = [];
|
|
112
|
+
const params = [];
|
|
113
|
+
if (products.length) {
|
|
114
|
+
where.push(`product_id IN (${products.map(() => '?').join(', ')})`);
|
|
115
|
+
params.push(...products);
|
|
116
|
+
}
|
|
117
|
+
if (modifiers.length) {
|
|
118
|
+
where.push(`modifier_set_modifier_id IN (${modifiers.map(() => '?').join(', ')})`);
|
|
119
|
+
params.push(...modifiers);
|
|
120
|
+
}
|
|
121
|
+
return new PriceBook(await safeAll(db, `SELECT * FROM product_prices WHERE ${where.join(' OR ')}`, params), companyId);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
module.exports = {PriceBook, forTeam, forItems};
|
|
@@ -127,6 +127,15 @@ const render = (report) => {
|
|
|
127
127
|
}
|
|
128
128
|
lines.push('');
|
|
129
129
|
|
|
130
|
+
// Источники заказа (в зале / с собой / доставка / агрегаторы) — то же в telegram/report.blade.php.
|
|
131
|
+
const sources = rowsOf(data.order_sources);
|
|
132
|
+
if (sources.length) {
|
|
133
|
+
lines.push(RULE, `🧾 <b>${e('Sifariş mənbələri')}</b>`, '<pre>');
|
|
134
|
+
lines.push(...sources.map(labelValue));
|
|
135
|
+
lines.push('</pre>');
|
|
136
|
+
}
|
|
137
|
+
lines.push('');
|
|
138
|
+
|
|
130
139
|
const footer = report.footer;
|
|
131
140
|
if (footer && typeof footer === 'object' && Object.keys(footer).length) {
|
|
132
141
|
lines.push(RULE, `💰 <b>${e('YEKUN')}</b>`);
|
package/app/db/driver.js
CHANGED
|
@@ -179,6 +179,12 @@ const openPostgres = () => {
|
|
|
179
179
|
driverName = 'pg';
|
|
180
180
|
|
|
181
181
|
const pool = new pg.Pool(pgConfig());
|
|
182
|
+
// Простаивающее соединение оборвалось (рестарт Postgres, сеть): pg шлёт 'error' на пул, и без
|
|
183
|
+
// слушателя это необработанное исключение — процесс облачного Node падал. Пул сам выбросит
|
|
184
|
+
// мёртвое соединение и откроет новое на следующем запросе.
|
|
185
|
+
pool.on('error', (e) => {
|
|
186
|
+
console.error('[db] idle pg client error:', e && e.message);
|
|
187
|
+
});
|
|
182
188
|
|
|
183
189
|
/** Обёртка над клиентом/пулом — одинаковая, чтобы транзакция давала тот же интерфейс. */
|
|
184
190
|
const wrap = (executor, isTransaction = false) => ({
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Цены продажи по филиалу и по источнику заказа (Laravel 2026_11_13_000001, support/prices.js).
|
|
2
|
+
-- Строка — цена товара (product_id) или модификатора (modifier_set_modifier_id) для филиала
|
|
3
|
+
-- (company_id) либо для источника (order_source_id). Заполняет снапшот.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS product_prices (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
team_id TEXT,
|
|
7
|
+
product_id TEXT,
|
|
8
|
+
modifier_set_modifier_id TEXT,
|
|
9
|
+
company_id TEXT,
|
|
10
|
+
order_source_id TEXT,
|
|
11
|
+
price REAL,
|
|
12
|
+
created_at TEXT,
|
|
13
|
+
updated_at TEXT,
|
|
14
|
+
synced_at TEXT
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS product_prices_product ON product_prices (product_id);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS product_prices_modifier ON product_prices (modifier_set_modifier_id);
|
package/app/relay/masters.js
CHANGED
|
@@ -59,14 +59,24 @@ const verify = async (auth) => {
|
|
|
59
59
|
validateStatus: () => true,
|
|
60
60
|
});
|
|
61
61
|
} catch (e) {
|
|
62
|
-
return {ok: false, reason: 'verify_unavailable'};
|
|
62
|
+
return {ok: false, reason: 'verify_unavailable', transient: true};
|
|
63
63
|
}
|
|
64
64
|
if (response.status !== 200 || !response.data || response.data.token_matches !== true) {
|
|
65
|
-
|
|
65
|
+
// Laravel недоступен / в деплое / перегружен / лицензия — пройдёт само; 401/403 и
|
|
66
|
+
// чужой токен — нет (касса переподключится с новой сессией сама).
|
|
67
|
+
const transient = response.status >= 500 || [402, 404, 408, 429].includes(response.status);
|
|
68
|
+
return {ok: false, reason: `verify_failed_${response.status}`, transient};
|
|
66
69
|
}
|
|
67
70
|
return {ok: true};
|
|
68
71
|
};
|
|
69
72
|
|
|
73
|
+
// MASTER_RELAY_VERIFY_RETRY (по умолчанию выкл. = как раньше): разовый сбой проверки больше не
|
|
74
|
+
// оставляет подключённый сокет мастера вне реестра до его следующего обрыва — проверка
|
|
75
|
+
// повторяется с паузой, пока сокет жив. Без этого после рестарта облака или деплоя Laravel
|
|
76
|
+
// все клиенты заведения через облако получали master_offline часами.
|
|
77
|
+
const VERIFY_RETRY_DELAYS_MS = [3000, 10000, 30000, 60000];
|
|
78
|
+
const verifyRetryEnabled = () => (process.env.MASTER_RELAY_VERIFY_RETRY ?? 'false') === 'true';
|
|
79
|
+
|
|
70
80
|
/**
|
|
71
81
|
* Сигнальный сокет с handshake.auth.master. company_id в query сокета обязан совпасть с
|
|
72
82
|
* тем, что проверено токеном, иначе касса слушала бы комнату чужой компании.
|
|
@@ -78,9 +88,18 @@ const register = (socket) => {
|
|
|
78
88
|
socket.on('disconnect', () => unregister(socket));
|
|
79
89
|
|
|
80
90
|
(async () => {
|
|
81
|
-
|
|
91
|
+
let result = String(auth.company_id || '') === queryCompanyId
|
|
82
92
|
? await verify(auth)
|
|
83
93
|
: {ok: false, reason: 'company_mismatch'};
|
|
94
|
+
for (let attempt = 0; !result.ok && result.transient && verifyRetryEnabled() && socket.connected; attempt++) {
|
|
95
|
+
const delay = VERIFY_RETRY_DELAYS_MS[Math.min(attempt, VERIFY_RETRY_DELAYS_MS.length - 1)];
|
|
96
|
+
console.warn(`[relay] master verify failed (${result.reason}) terminal=${auth.terminal_id}, retry in ${delay / 1000}s`);
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
98
|
+
if (!socket.connected) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
result = await verify(auth);
|
|
102
|
+
}
|
|
84
103
|
if (!socket.connected) {
|
|
85
104
|
return;
|
|
86
105
|
}
|
package/app/sync/index.js
CHANGED
|
@@ -580,6 +580,8 @@ const applyCompanyMode = async (data) => {
|
|
|
580
580
|
await state.store.block((s) => kv.set(s, 'company_mode', state.company));
|
|
581
581
|
};
|
|
582
582
|
|
|
583
|
+
const MODE_NOT_FOUND_TO_ONLINE = 3;
|
|
584
|
+
|
|
583
585
|
const pollMode = async () => {
|
|
584
586
|
state.lastModePoll = Date.now();
|
|
585
587
|
state.modeRequested = false;
|
|
@@ -589,12 +591,18 @@ const pollMode = async () => {
|
|
|
589
591
|
} catch (e) {
|
|
590
592
|
state.lastModeError = e.message;
|
|
591
593
|
if (e.name === 'CloudRequestError' && e.status === 404) {
|
|
592
|
-
// Старый Laravel или Sync API выключен: модели M нет — заведение в онлайне.
|
|
593
|
-
|
|
594
|
+
// Старый Laravel или Sync API выключен: модели M нет — заведение в онлайне. Но 404 отдаёт
|
|
595
|
+
// и Traefik, пока контейнер облака пересоздаётся при деплое: разовый 404 снимал мастера
|
|
596
|
+
// на ~10 с (фронт уходил в облако, relay — not_master). Решаем по нескольким подряд.
|
|
597
|
+
state.modeNotFound = (state.modeNotFound || 0) + 1;
|
|
598
|
+
if (state.modeNotFound >= MODE_NOT_FOUND_TO_ONLINE) {
|
|
599
|
+
await applyCompanyMode({state: 'online', master_terminal_id: null, epoch: 0, enabled: false});
|
|
600
|
+
}
|
|
594
601
|
return;
|
|
595
602
|
}
|
|
596
603
|
throw e;
|
|
597
604
|
}
|
|
605
|
+
state.modeNotFound = 0;
|
|
598
606
|
state.lastModeError = null;
|
|
599
607
|
await applyCompanyMode(response);
|
|
600
608
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alizadefaig/pos-server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.35",
|
|
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": {
|
package/server.js
CHANGED
|
@@ -182,6 +182,18 @@ const runExpress = (conf = null) => {
|
|
|
182
182
|
// ==== Start Listen ====
|
|
183
183
|
const hostname = appConfig.host || 'localhost';
|
|
184
184
|
netstats.attach(server, {host: hostname, port});
|
|
185
|
+
// Порт занят (прежний процесс ещё не отпустил его после аварийного выхода, вторая копия) —
|
|
186
|
+
// раньше необработанное исключение: на кассе диалог Electron, локального Node нет до ручного
|
|
187
|
+
// перезапуска. Теперь повторяем, пока порт не освободится.
|
|
188
|
+
const LISTEN_RETRY_MS = 3000;
|
|
189
|
+
server.on('error', (e) => {
|
|
190
|
+
if (e && (e.code === 'EADDRINUSE' || e.code === 'EACCES') && !server.listening) {
|
|
191
|
+
console.error(`[server] port ${port} unavailable (${e.code}), retry in ${LISTEN_RETRY_MS / 1000}s`);
|
|
192
|
+
setTimeout(() => server.listen(port, hostname), LISTEN_RETRY_MS);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
console.error('[server] error:', e && e.message);
|
|
196
|
+
});
|
|
185
197
|
server.listen(port, hostname, async () => {
|
|
186
198
|
if (hostname === 'localhost') {
|
|
187
199
|
console.log(`Server started on ${hostname} http://${hostname}:${port}`);
|