@alizadefaig/pos-server 1.2.32 → 1.2.34
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 +18 -3
- package/app/core/services/StorageService.js +72 -7
- 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/migrations/014_product_prices.sql +17 -0
- package/package.json +43 -43
- package/routes/common/api/orders.js +1 -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';
|
|
@@ -366,6 +367,7 @@ class PrintService {
|
|
|
366
367
|
const printerTerminal = await this.printers.findTerminal(printer.terminal_id);
|
|
367
368
|
jobs.push({
|
|
368
369
|
workshopId,
|
|
370
|
+
printerId: printer.id,
|
|
369
371
|
printData: {
|
|
370
372
|
data: printData.data,
|
|
371
373
|
terminal_id: printer.terminal_id,
|
|
@@ -376,15 +378,28 @@ class PrintService {
|
|
|
376
378
|
}
|
|
377
379
|
}
|
|
378
380
|
|
|
381
|
+
// Два принтера цеха на одном терминале с одним именем («ESC/POS») дали бы один ключ —
|
|
382
|
+
// терминал принял бы второй бегунок за повтор первого и не напечатал. Таким ключ
|
|
383
|
+
// различаем id принтера; с уникальными именами ключ прежний (как в Laravel).
|
|
384
|
+
const nameKeyOf = (workshopId, printData) => `${workshopId}|${printData.terminal_id}|${str(printData.printer.name)}`;
|
|
385
|
+
const nameCounts = new Map();
|
|
386
|
+
for (const {workshopId, printData} of jobs) {
|
|
387
|
+
const nameKey = nameKeyOf(workshopId, printData);
|
|
388
|
+
nameCounts.set(nameKey, (nameCounts.get(nameKey) || 0) + 1);
|
|
389
|
+
}
|
|
390
|
+
|
|
379
391
|
const receipts = jobs.length ? await this.receiptsOf(team) : null;
|
|
380
|
-
const requests = jobs.map(({workshopId, printData}) => {
|
|
392
|
+
const requests = jobs.map(({workshopId, printerId, printData}) => {
|
|
381
393
|
// Стабильный per-ticket ключ: одинаков при повторе того же состояния — терминал
|
|
382
394
|
// распознаёт дубль и не печатает второй раз. request_id в ключ не входит.
|
|
383
395
|
const ticketSig = (syncOrderProducts.get(workshopId) || [])
|
|
384
396
|
.map((opId) => `${opId}:${qtyById.has(opId) ? qtyById.get(opId) : 0}`)
|
|
385
397
|
.sort()
|
|
386
398
|
.join('|');
|
|
387
|
-
const
|
|
399
|
+
const printerKeyPart = nameCounts.get(nameKeyOf(workshopId, printData)) > 1
|
|
400
|
+
? `${str(printData.printer.name)}#${printerId}`
|
|
401
|
+
: str(printData.printer.name);
|
|
402
|
+
const printIdempotencyKey = `${id}:${workshopId}:${printData.terminal_id}:${printerKeyPart}:${md5(ticketSig)}`;
|
|
388
403
|
|
|
389
404
|
return {
|
|
390
405
|
workshopId,
|
|
@@ -615,7 +630,7 @@ class PrintService {
|
|
|
615
630
|
table: table ? table.label : null,
|
|
616
631
|
courier: '',
|
|
617
632
|
comment: order.comment ?? null,
|
|
618
|
-
delivery_address:
|
|
633
|
+
delivery_address: deliveryLine(order.data?.delivery),
|
|
619
634
|
print_name: receipts.print_name_of === 'team'
|
|
620
635
|
? (team ? translated(team.name, this.locale()) : null)
|
|
621
636
|
: (context.getCompany() ? context.getCompany().name : null),
|
|
@@ -202,7 +202,7 @@ class StorageService {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/** Позиция, её товар, склад и единицы; null — склада нет, двигать некуда. */
|
|
205
|
-
async prepare(orderProductOrId) {
|
|
205
|
+
async prepare(orderProductOrId, sign = -1) {
|
|
206
206
|
const orderProductId = typeof orderProductOrId === 'object' && orderProductOrId
|
|
207
207
|
? orderProductOrId.id
|
|
208
208
|
: orderProductOrId;
|
|
@@ -217,14 +217,15 @@ class StorageService {
|
|
|
217
217
|
throw new Error('позиция не найдена');
|
|
218
218
|
}
|
|
219
219
|
const product = await this.db.get(
|
|
220
|
-
|
|
220
|
+
`SELECT id, type, unit_id, product_workshop_id, product_category_id, ignore_workshop
|
|
221
|
+
FROM products WHERE id = ?`,
|
|
221
222
|
[orderProduct.product_id]
|
|
222
223
|
);
|
|
223
224
|
if (!product) {
|
|
224
225
|
throw new Error('товар не найден');
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
const storageId = await this.storageId();
|
|
228
|
+
const storageId = await this.storageId(product, orderProduct.id, sign);
|
|
228
229
|
if (!storageId) {
|
|
229
230
|
return null;
|
|
230
231
|
}
|
|
@@ -234,7 +235,7 @@ class StorageService {
|
|
|
234
235
|
|
|
235
236
|
/** Только модификаторы из списка — тех. карту блюда и сам товар не трогает. */
|
|
236
237
|
async applyModifiers(orderProductOrId, modifiers, quantity, type, sign) {
|
|
237
|
-
const target = await this.prepare(orderProductOrId);
|
|
238
|
+
const target = await this.prepare(orderProductOrId, sign);
|
|
238
239
|
if (!target || target.product.type !== TYPE_DISH) {
|
|
239
240
|
return;
|
|
240
241
|
}
|
|
@@ -242,7 +243,7 @@ class StorageService {
|
|
|
242
243
|
}
|
|
243
244
|
|
|
244
245
|
async apply(orderProductOrId, quantity, type, sign) {
|
|
245
|
-
const target = await this.prepare(orderProductOrId);
|
|
246
|
+
const target = await this.prepare(orderProductOrId, sign);
|
|
246
247
|
if (!target) {
|
|
247
248
|
return;
|
|
248
249
|
}
|
|
@@ -333,10 +334,31 @@ class StorageService {
|
|
|
333
334
|
return unit;
|
|
334
335
|
}
|
|
335
336
|
|
|
336
|
-
/**
|
|
337
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Склад списания позиции — порт Platform\StorageService::storageId.
|
|
339
|
+
* Возврат ($sign > 0) — на склад, с которого позицию списали. Продажа — по правилу заведения
|
|
340
|
+
* `companies.storage_withdraw_type` (склад цеха; by_terminal_and_workshop — пары «терминал +
|
|
341
|
+
* цех», нет пары — любое правило цеха), иначе склад заведения, иначе первый склад команды.
|
|
342
|
+
*/
|
|
343
|
+
async storageId(product = null, orderProductId = null, sign = -1) {
|
|
338
344
|
const {teamId, companyId} = this.context;
|
|
345
|
+
if (sign > 0 && orderProductId) {
|
|
346
|
+
const sold = await this.db.value(
|
|
347
|
+
`SELECT storage_id FROM storage_stock_transactions
|
|
348
|
+
WHERE external_id = ? AND type = 'sale'
|
|
349
|
+
ORDER BY datetime DESC LIMIT 1`,
|
|
350
|
+
[orderProductId]
|
|
351
|
+
);
|
|
352
|
+
if (sold) {
|
|
353
|
+
return sold;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
339
356
|
if (companyId) {
|
|
357
|
+
// Схема без правил (старая локальная БД) — как раньше, склад заведения.
|
|
358
|
+
const byRule = await this.ruleStorageId(companyId, product).catch(() => null);
|
|
359
|
+
if (byRule) {
|
|
360
|
+
return byRule;
|
|
361
|
+
}
|
|
340
362
|
const storageId = await this.db.value(
|
|
341
363
|
'SELECT storage_id FROM company_storage WHERE company_id = ? LIMIT 1',
|
|
342
364
|
[companyId]
|
|
@@ -354,6 +376,49 @@ class StorageService {
|
|
|
354
376
|
);
|
|
355
377
|
}
|
|
356
378
|
|
|
379
|
+
/** Склад по правилу «склад цеха»; null — правила нет. */
|
|
380
|
+
async ruleStorageId(companyId, product) {
|
|
381
|
+
const workshopId = await this.workshopId(product);
|
|
382
|
+
if (!workshopId) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const type = await this.db.value('SELECT storage_withdraw_type FROM companies WHERE id = ?', [companyId]);
|
|
386
|
+
if (type !== 'by_workshop' && type !== 'by_terminal_and_workshop') {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const base = `SELECT r.storage_id FROM company_storage_withdraw_rules r
|
|
390
|
+
JOIN storages s ON s.id = r.storage_id AND s.deleted_at IS NULL
|
|
391
|
+
WHERE r.deleted_at IS NULL AND r.company_id = ? AND r.workshop_id = ?`;
|
|
392
|
+
const terminalId = this.context.terminalId;
|
|
393
|
+
if (type === 'by_terminal_and_workshop' && terminalId) {
|
|
394
|
+
const own = await this.db.value(
|
|
395
|
+
`${base} AND r.terminal_id = ? ORDER BY r.created_at, r.id LIMIT 1`,
|
|
396
|
+
[companyId, workshopId, terminalId]
|
|
397
|
+
);
|
|
398
|
+
if (own) {
|
|
399
|
+
return own;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return await this.db.value(`${base} ORDER BY r.created_at, r.id LIMIT 1`, [companyId, workshopId]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Мутатор Product::productWorkshopId: ignore_workshop гасит цех, пустой — у категории. */
|
|
406
|
+
async workshopId(product) {
|
|
407
|
+
if (!product || toBool(product.ignore_workshop)) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
if (product.product_workshop_id) {
|
|
411
|
+
return product.product_workshop_id;
|
|
412
|
+
}
|
|
413
|
+
if (!product.product_category_id) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
return await this.db.value(
|
|
417
|
+
'SELECT product_workshop_id FROM product_categories WHERE id = ?',
|
|
418
|
+
[product.product_category_id]
|
|
419
|
+
) || null;
|
|
420
|
+
}
|
|
421
|
+
|
|
357
422
|
/** StockService::register без суммы (продажа себестоимость не меняет) + applyToStock. */
|
|
358
423
|
async move(orderProduct, product, type, quantity, storageId, units) {
|
|
359
424
|
// Нулевое движение не пишется (как в PHP — до проверки единицы).
|
|
@@ -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>`);
|
|
@@ -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/package.json
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
2
|
+
"name": "@alizadefaig/pos-server",
|
|
3
|
+
"version": "1.2.34",
|
|
4
|
+
"description": "Server in express js with Proxy to Main server if need and will work with Electron JS",
|
|
5
|
+
"homepage": "https://gitlab.com/faigalizade/pos-server#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://gitlab.com/faigalizade/pos-server/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+ssh://git@gitlab.com/faigalizade/pos-server.git"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@alizadefaig/cafetap-escpos-usb": "^1.0.1",
|
|
15
|
+
"axios": "^1.13.2",
|
|
16
|
+
"bcryptjs": "^3.0.3",
|
|
17
|
+
"cors": "^2.8.5",
|
|
18
|
+
"escpos": "^3.0.0-alpha.6",
|
|
19
|
+
"escpos-network": "^3.0.0-alpha.5",
|
|
20
|
+
"express": "^5.1.0",
|
|
21
|
+
"http-proxy-middleware": "^3.0.5",
|
|
22
|
+
"lodash": "^4.17.21",
|
|
23
|
+
"moment": "^2.30.1",
|
|
24
|
+
"multer": "^2.0.2",
|
|
25
|
+
"pg": "^8.22.0",
|
|
26
|
+
"redis": "^5.10.0",
|
|
27
|
+
"sequelize": "^6.37.7",
|
|
28
|
+
"sharp": "^0.34.5",
|
|
29
|
+
"socket.io": "^4.8.1",
|
|
30
|
+
"socket.io-client": "^4.8.1",
|
|
31
|
+
"uuid": "^13.0.0"
|
|
32
|
+
},
|
|
33
|
+
"license": "ISC",
|
|
34
|
+
"author": "faigalizade@newave.az",
|
|
35
|
+
"type": "commonjs",
|
|
36
|
+
"main": "server.js",
|
|
37
|
+
"directories": {
|
|
38
|
+
"lib": "lib"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test --test-concurrency=1 \"test/**/*.test.js\"",
|
|
42
|
+
"test:watch": "node --test --watch --test-concurrency=1 \"test/**/*.test.js\"",
|
|
43
|
+
"start": "node server.js"
|
|
44
|
+
}
|
|
45
45
|
}
|