@alizadefaig/pos-server 1.2.31 → 1.2.33
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/auth/posMode.js +1 -0
- package/app/core/repositories/OrderRepository.js +12 -0
- package/app/core/services/OrderService.js +33 -2
- package/app/core/services/PrintService.js +16 -2
- package/app/core/services/StorageService.js +72 -7
- package/app/sync/index.js +38 -1
- package/package.json +1 -1
- package/routes/common/api/orders.js +4 -0
- package/routes/desktop/local.js +4 -0
package/app/core/auth/posMode.js
CHANGED
|
@@ -4,6 +4,7 @@ const SnapshotRepository = require('./SnapshotRepository');
|
|
|
4
4
|
const {toSql, nowSql} = require('../support/time');
|
|
5
5
|
const {phpJsonEncode} = require('../support/str');
|
|
6
6
|
const {castColumn} = require('../support/db');
|
|
7
|
+
const {toInt} = require('../support/num');
|
|
7
8
|
|
|
8
9
|
// Колонки, которые ядро пишет само. Список явный: SELECT * читаем, а вот при записи
|
|
9
10
|
// молчаливый «сохраним всё, что оказалось на объекте» уронил бы UPDATE на первом же
|
|
@@ -96,6 +97,17 @@ class OrderRepository {
|
|
|
96
97
|
return Promise.all(rows.map((row) => this.hydrate(row)));
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Слоты открытых чеков официанта (режим слотов), кроме чека exceptId. */
|
|
101
|
+
async openSlotIndexes(companyId, servingId, exceptId) {
|
|
102
|
+
const rows = await this.db.all(
|
|
103
|
+
`SELECT slot_index FROM orders
|
|
104
|
+
WHERE company_id = ? AND serving_id = ? AND id <> ?
|
|
105
|
+
AND status NOT IN ('closing', 'closed') AND deleted_at IS NULL`,
|
|
106
|
+
[companyId, servingId, exceptId]
|
|
107
|
+
);
|
|
108
|
+
return rows.map((row) => toInt(row.slot_index, 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
async archivedOrders(companyId, fromSql, toSql_) {
|
|
100
112
|
const rows = await this.db.all(
|
|
101
113
|
`SELECT * FROM orders
|
|
@@ -610,12 +610,43 @@ class OrderService {
|
|
|
610
610
|
}
|
|
611
611
|
|
|
612
612
|
async changeWaiter({orderId, waiterId}) {
|
|
613
|
-
|
|
614
|
-
|
|
613
|
+
const order = await this.findOrFail(orderId);
|
|
614
|
+
this.assertMutable(order, 'orders.changeWaiter');
|
|
615
|
+
await this.orders.updateRow(orderId, {serving_id: waiterId, slot_index: await this.freeSlotIndex(order, waiterId)});
|
|
615
616
|
await this.saveEvents([{event: 'orders:change_waiter', time: nowSql(), order_id: orderId}]);
|
|
616
617
|
return await this.patchOrResource(orderId);
|
|
617
618
|
}
|
|
618
619
|
|
|
620
|
+
/** Режим слотов: чек из занятого слота — в свободный (OrderService::fixSlot в Laravel). */
|
|
621
|
+
async fixSlot({orderId}) {
|
|
622
|
+
const order = await this.findOrFail(orderId);
|
|
623
|
+
this.assertMutable(order, 'orders.fixSlot');
|
|
624
|
+
const slotIndex = await this.freeSlotIndex(order, order.serving_id);
|
|
625
|
+
if (slotIndex === toInt(order.slot_index, 1)) {
|
|
626
|
+
return await this.resource().order(order);
|
|
627
|
+
}
|
|
628
|
+
await this.orders.updateRow(orderId, {slot_index: slotIndex});
|
|
629
|
+
return await this.patchOrResource(orderId);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Режим слотов: переданный чек сохраняет слот, только если у нового официанта он свободен,
|
|
634
|
+
* иначе — первый свободный (два чека в одном слоте выглядели как пропажа одного).
|
|
635
|
+
* Как OrderService::freeSlotIndex в Laravel.
|
|
636
|
+
*/
|
|
637
|
+
async freeSlotIndex(order, waiterId) {
|
|
638
|
+
const current = Math.max(1, toInt(order.slot_index, 1));
|
|
639
|
+
const taken = new Set(await this.orders.openSlotIndexes(order.company_id, waiterId, order.id));
|
|
640
|
+
if (!taken.has(current)) {
|
|
641
|
+
return current;
|
|
642
|
+
}
|
|
643
|
+
let index = 1;
|
|
644
|
+
while (taken.has(index)) {
|
|
645
|
+
index++;
|
|
646
|
+
}
|
|
647
|
+
return index;
|
|
648
|
+
}
|
|
649
|
+
|
|
619
650
|
async changeGuestCount({orderId, guestCount = 1}) {
|
|
620
651
|
this.assertMutable(await this.findOrFail(orderId), 'orders.changeGuestCount');
|
|
621
652
|
await this.orders.updateRow(orderId, {guest_count: toInt(guestCount, 1)});
|
|
@@ -366,6 +366,7 @@ class PrintService {
|
|
|
366
366
|
const printerTerminal = await this.printers.findTerminal(printer.terminal_id);
|
|
367
367
|
jobs.push({
|
|
368
368
|
workshopId,
|
|
369
|
+
printerId: printer.id,
|
|
369
370
|
printData: {
|
|
370
371
|
data: printData.data,
|
|
371
372
|
terminal_id: printer.terminal_id,
|
|
@@ -376,15 +377,28 @@ class PrintService {
|
|
|
376
377
|
}
|
|
377
378
|
}
|
|
378
379
|
|
|
380
|
+
// Два принтера цеха на одном терминале с одним именем («ESC/POS») дали бы один ключ —
|
|
381
|
+
// терминал принял бы второй бегунок за повтор первого и не напечатал. Таким ключ
|
|
382
|
+
// различаем id принтера; с уникальными именами ключ прежний (как в Laravel).
|
|
383
|
+
const nameKeyOf = (workshopId, printData) => `${workshopId}|${printData.terminal_id}|${str(printData.printer.name)}`;
|
|
384
|
+
const nameCounts = new Map();
|
|
385
|
+
for (const {workshopId, printData} of jobs) {
|
|
386
|
+
const nameKey = nameKeyOf(workshopId, printData);
|
|
387
|
+
nameCounts.set(nameKey, (nameCounts.get(nameKey) || 0) + 1);
|
|
388
|
+
}
|
|
389
|
+
|
|
379
390
|
const receipts = jobs.length ? await this.receiptsOf(team) : null;
|
|
380
|
-
const requests = jobs.map(({workshopId, printData}) => {
|
|
391
|
+
const requests = jobs.map(({workshopId, printerId, printData}) => {
|
|
381
392
|
// Стабильный per-ticket ключ: одинаков при повторе того же состояния — терминал
|
|
382
393
|
// распознаёт дубль и не печатает второй раз. request_id в ключ не входит.
|
|
383
394
|
const ticketSig = (syncOrderProducts.get(workshopId) || [])
|
|
384
395
|
.map((opId) => `${opId}:${qtyById.has(opId) ? qtyById.get(opId) : 0}`)
|
|
385
396
|
.sort()
|
|
386
397
|
.join('|');
|
|
387
|
-
const
|
|
398
|
+
const printerKeyPart = nameCounts.get(nameKeyOf(workshopId, printData)) > 1
|
|
399
|
+
? `${str(printData.printer.name)}#${printerId}`
|
|
400
|
+
: str(printData.printer.name);
|
|
401
|
+
const printIdempotencyKey = `${id}:${workshopId}:${printData.terminal_id}:${printerKeyPart}:${md5(ticketSig)}`;
|
|
388
402
|
|
|
389
403
|
return {
|
|
390
404
|
workshopId,
|
|
@@ -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 — до проверки единицы).
|
package/app/sync/index.js
CHANGED
|
@@ -284,7 +284,18 @@ const snapshotDue = () => {
|
|
|
284
284
|
return now - state.lastSnapshotOk >= config.intervals().snapshot;
|
|
285
285
|
};
|
|
286
286
|
|
|
287
|
-
|
|
287
|
+
// Один снапшот за раз: цикл и внеочередной refresh (/local/refresh) ждут один и тот же.
|
|
288
|
+
let snapshotRun = null;
|
|
289
|
+
const runSnapshot = () => {
|
|
290
|
+
if (!snapshotRun) {
|
|
291
|
+
snapshotRun = pullAndApplySnapshot().finally(() => {
|
|
292
|
+
snapshotRun = null;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return snapshotRun;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const pullAndApplySnapshot = async () => {
|
|
288
299
|
state.lastSnapshotTry = Date.now();
|
|
289
300
|
const pullFn = isFollower() ? snapshot.pullInitOnly : snapshot.pullSnapshot;
|
|
290
301
|
const result = await pullFn({store: state.store, cloud: state.cloud});
|
|
@@ -1413,6 +1424,31 @@ const logout = async ({token} = {}) => {
|
|
|
1413
1424
|
return {success: true, handled: true, parked: true, pending: state.pending, master: isServingAsMaster()};
|
|
1414
1425
|
};
|
|
1415
1426
|
|
|
1427
|
+
/**
|
|
1428
|
+
* POST /local/refresh — «Keşi təmizlə» на кассе: снапшот справочников и app.init прямо сейчас,
|
|
1429
|
+
* а не через плановые OFFLINE_SNAPSHOT_MS. Без этого в локальном режиме правка в бэк-офисе
|
|
1430
|
+
* (режим зала, товары, столы) доходила до кассы только через 10 минут. Облака нет — 503.
|
|
1431
|
+
*/
|
|
1432
|
+
const refresh = async () => {
|
|
1433
|
+
if (!config.isSyncEnabled()) {
|
|
1434
|
+
return {success: false, enabled: false};
|
|
1435
|
+
}
|
|
1436
|
+
await start();
|
|
1437
|
+
if (!state.session || !state.cloud || !state.store) {
|
|
1438
|
+
throw new HttpError(409, {success: false, error_code: 'no_session', message: 'No terminal session'});
|
|
1439
|
+
}
|
|
1440
|
+
try {
|
|
1441
|
+
await runSnapshot();
|
|
1442
|
+
} catch (e) {
|
|
1443
|
+
if (e.name === 'CloudUnavailable') {
|
|
1444
|
+
throw new HttpError(503, {success: false, error_code: 'cloud_unavailable', message: e.message});
|
|
1445
|
+
}
|
|
1446
|
+
state.lastError = `snapshot: ${e.message}`;
|
|
1447
|
+
throw e;
|
|
1448
|
+
}
|
|
1449
|
+
return {success: true, snapshot_at: kv.get(state.store, 'snapshot_at')};
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1416
1452
|
/**
|
|
1417
1453
|
* POST /local/resume — вход обратно на запаркованную сессию по PIN сотрудника. PIN проверяется,
|
|
1418
1454
|
* как в офлайн-auth.unlock (тот же список users и тот же счётчик неудач). Отдаёт то, что фронт
|
|
@@ -1638,6 +1674,7 @@ module.exports = {
|
|
|
1638
1674
|
clearSession,
|
|
1639
1675
|
logout,
|
|
1640
1676
|
resume,
|
|
1677
|
+
refresh,
|
|
1641
1678
|
wake,
|
|
1642
1679
|
relayBroadcast,
|
|
1643
1680
|
terminalRequest,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alizadefaig/pos-server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.33",
|
|
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": {
|
|
@@ -66,6 +66,10 @@ registerOfflineRoutes(router, '/api', {
|
|
|
66
66
|
waiterId: get(p, 'waiter_id'),
|
|
67
67
|
})],
|
|
68
68
|
|
|
69
|
+
'orders.fixSlot': [MUTATION, ({orders}, p) => orders.fixSlot({
|
|
70
|
+
orderId: get(p, 'id'),
|
|
71
|
+
})],
|
|
72
|
+
|
|
69
73
|
'orders.changeGuestCount': [MUTATION, ({orders}, p) => orders.changeGuestCount({
|
|
70
74
|
orderId: get(p, 'id'),
|
|
71
75
|
guestCount: get(p, 'guest_count', 1),
|
package/routes/desktop/local.js
CHANGED
|
@@ -18,6 +18,7 @@ const {normalizeApiUrl} = require('../../app/sync/cloud');
|
|
|
18
18
|
* POST /local/logout — выход терминала: сессия паркуется, токен отзывается, когда
|
|
19
19
|
* очередь дослана и облако на связи;
|
|
20
20
|
* POST /local/resume — вход обратно на запаркованную сессию по PIN сотрудника (без облака);
|
|
21
|
+
* POST /local/refresh — внеочередной снапшот («Keşi təmizlə»): свежие справочники и init;
|
|
21
22
|
* GET /local/whoami — мастер подписанно отвечает ведомому, который его ищет;
|
|
22
23
|
* GET /local/lan — телефону на LAN-странице мастера: пора ли назад в облако.
|
|
23
24
|
*
|
|
@@ -127,4 +128,7 @@ router.post('/local/logout', loopbackOnly, strictOriginGuard,
|
|
|
127
128
|
router.post('/local/resume', loopbackOnly, strictOriginGuard,
|
|
128
129
|
respond('resume', (body) => sync.resume({code: body.code})));
|
|
129
130
|
|
|
131
|
+
router.post('/local/refresh', loopbackOnly, strictOriginGuard,
|
|
132
|
+
respond('refresh', () => sync.refresh()));
|
|
133
|
+
|
|
130
134
|
module.exports = router;
|