@alizadefaig/pos-server 1.2.25 → 1.2.27
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/Context.js +170 -0
- package/app/core/auth/index.js +133 -0
- package/app/core/auth/license.js +45 -0
- package/app/core/auth/posMode.js +260 -0
- package/app/core/auth/sanctum.js +209 -0
- package/app/core/auth/scope.js +231 -0
- package/app/core/emitter.js +67 -0
- package/app/core/errors.js +18 -0
- package/app/core/guard.js +54 -0
- package/app/core/idempotency.js +196 -0
- package/app/core/index.js +374 -0
- package/app/core/models/Order.js +176 -0
- package/app/core/models/OrderProduct.js +249 -0
- package/app/core/repositories/EventRepository.js +51 -0
- package/app/core/repositories/OrderProductRepository.js +225 -0
- package/app/core/repositories/OrderRepository.js +274 -0
- package/app/core/repositories/OutboxRepository.js +52 -0
- package/app/core/repositories/PaymentRepository.js +43 -0
- package/app/core/repositories/PrinterRepository.js +179 -0
- package/app/core/repositories/ProductRelations.js +148 -0
- package/app/core/repositories/SnapshotRepository.js +197 -0
- package/app/core/resources/HallResource.js +54 -0
- package/app/core/resources/OrderResource.js +177 -0
- package/app/core/resources/ProductResource.js +186 -0
- package/app/core/resources/ShiftResource.js +42 -0
- package/app/core/resources/TeamResource.js +85 -0
- package/app/core/resources/casts.js +55 -0
- package/app/core/services/AuthService.js +468 -0
- package/app/core/services/InitService.js +277 -0
- package/app/core/services/KdsService.js +639 -0
- package/app/core/services/OrderProductService.js +309 -0
- package/app/core/services/OrderService.js +903 -0
- package/app/core/services/PrintService.js +715 -0
- package/app/core/services/PrinterAdminService.js +324 -0
- package/app/core/services/ReportService.js +581 -0
- package/app/core/services/ShiftService.js +199 -0
- package/app/core/services/StorageService.js +425 -0
- package/app/core/services/TelegramService.js +162 -0
- package/app/core/support/db.js +48 -0
- package/app/core/support/ids.js +9 -0
- package/app/core/support/imageData.js +135 -0
- package/app/core/support/images.js +92 -0
- package/app/core/support/lanHandoff.js +105 -0
- package/app/core/support/lang.js +98 -0
- package/app/core/support/lock.js +63 -0
- package/app/core/support/num.js +51 -0
- package/app/core/support/php.js +125 -0
- package/app/core/support/pin.js +34 -0
- package/app/core/support/receipts.js +278 -0
- package/app/core/support/rows.js +38 -0
- package/app/core/support/str.js +31 -0
- package/app/core/support/teamSettings.js +77 -0
- package/app/core/support/telegram.js +253 -0
- package/app/core/support/telegramReport.js +143 -0
- package/app/core/support/time.js +58 -0
- package/app/core/support/tz.js +80 -0
- package/app/core/support/unlock.js +113 -0
- package/app/core/timers.js +151 -0
- package/app/db/Database.js +197 -1
- package/app/db/Migrator.js +45 -0
- package/app/db/driver.js +248 -0
- package/app/db/migrations/001_snapshot.sql +214 -0
- package/app/db/migrations/002_orders.sql +190 -0
- package/app/db/migrations/003_idempotency.sql +18 -0
- package/app/db/migrations/004_shifts.sql +21 -0
- package/app/db/migrations/005_printers.sql +43 -0
- package/app/db/migrations/006_sync.sql +136 -0
- package/app/db/migrations/007_printer_mac.sql +4 -0
- package/app/db/migrations/008_kds.sql +94 -0
- package/app/db/migrations/009_product_categories.sql +20 -0
- package/app/db/migrations/010_printers_sync.sql +43 -0
- package/app/db/migrations/011_team_logos.sql +13 -0
- package/app/db/migrations/012_category_workshop.sql +4 -0
- package/app/monitor.js +185 -0
- package/app/printers/address.js +348 -0
- package/app/proxy.js +20 -3
- package/app/relay/masters.js +173 -0
- package/app/security.js +218 -0
- package/app/socket/auth.js +160 -0
- package/app/socket/request.js +87 -0
- package/app/socket/server.js +121 -19
- package/app/sync/bridge.js +162 -0
- package/app/sync/cloud.js +120 -0
- package/app/sync/config.js +185 -0
- package/app/sync/discovery.js +546 -0
- package/app/sync/index.js +1644 -0
- package/app/sync/kv.js +26 -0
- package/app/sync/lan.js +139 -0
- package/app/sync/lanDevices.js +177 -0
- package/app/sync/lanFront.js +431 -0
- package/app/sync/mirror.js +236 -0
- package/app/sync/push.js +324 -0
- package/app/sync/reconcile.js +161 -0
- package/app/sync/signal.js +202 -0
- package/app/sync/snapshot.js +189 -0
- package/app/sync/store.js +234 -0
- package/app/tcp.js +11 -2
- package/package.json +6 -3
- package/routes/common/api/auth.js +38 -0
- package/routes/common/api/kds.js +37 -0
- package/routes/common/api/orderProducts.js +81 -0
- package/routes/common/api/orders.js +176 -0
- package/routes/common/api/printersCore.js +35 -0
- package/routes/common/api/reports.js +29 -0
- package/routes/common/socket.js +15 -35
- package/routes/common/tax.js +35 -7
- package/routes/desktop/api/offline.js +146 -0
- package/routes/desktop/api/printers.js +288 -5
- package/routes/desktop/local.js +130 -0
- package/routes/server/api/login.js +37 -0
- package/routes/server/api/platform.js +31 -0
- package/routes/server/api/shifts.js +25 -0
- package/routes/server/master.js +24 -0
- package/server.js +111 -4
- package/utils.js +6 -0
- package/tests/usbPrinterTest.js +0 -20
package/app/sync/push.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
const {randomUUID} = require('node:crypto');
|
|
2
|
+
const {SyncStore} = require('./store');
|
|
3
|
+
const {limits, timeouts} = require('./config');
|
|
4
|
+
const {BOOLEAN_COLUMNS} = require('../core/support/db');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Push: офлайн-правки кассы → облако.
|
|
8
|
+
*
|
|
9
|
+
* Отправляется не журнал операций, а агрегат чека целиком (чек + все позиции, модификаторы,
|
|
10
|
+
* платежи, события) — облако сливает его по своим правилам (sync.pushOrders) и отвечает по
|
|
11
|
+
* каждому чеку: applied / skipped / conflict / error. Пометку sync_dirty снимаем только с той версией,
|
|
12
|
+
* которую отправляли: правка, сделанная кассой во время запроса, поднимет версию, и чек
|
|
13
|
+
* уйдёт следующим push.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Колонки, которые локально лежат JSON-строкой, а облаку нужны объектом (cast array в Laravel).
|
|
17
|
+
const JSON_COLUMNS = {
|
|
18
|
+
orders: ['data', 'customers', 'extras'],
|
|
19
|
+
order_events: ['data'],
|
|
20
|
+
products: ['name', 'full_name', 'data'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const decodeJson = (value) => {
|
|
24
|
+
if (typeof value !== 'string') {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(trimmed);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Локальная строка → строка для облака: флаги true/false, JSON — объектом. */
|
|
39
|
+
const toCloudRow = (table, row) => {
|
|
40
|
+
const result = {...row};
|
|
41
|
+
for (const column of Object.keys(result)) {
|
|
42
|
+
if (BOOLEAN_COLUMNS.has(column) && result[column] !== null && result[column] !== undefined) {
|
|
43
|
+
result[column] = Boolean(Number(result[column]));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const column of JSON_COLUMNS[table] || []) {
|
|
47
|
+
if (column in result) {
|
|
48
|
+
result[column] = decodeJson(result[column]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const toCloudRows = (table, rows) => rows.map((row) => toCloudRow(table, row));
|
|
55
|
+
|
|
56
|
+
/** Агрегат чека для отправки. null — чека локально нет (пометку можно просто снять). */
|
|
57
|
+
const readOrderAggregate = (store, id) => {
|
|
58
|
+
const order = store.get('SELECT * FROM orders WHERE id = ?', [id]);
|
|
59
|
+
if (!order) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const products = store.all('SELECT * FROM order_products WHERE order_id = ? ORDER BY created_at', [id]);
|
|
63
|
+
const productIds = products.map((row) => row.id);
|
|
64
|
+
const owners = SyncStore.inList('order_product_id', productIds);
|
|
65
|
+
const modifiers = productIds.length
|
|
66
|
+
? store.all(`SELECT * FROM order_product_modifier_sets WHERE ${owners.sql}`, owners.params)
|
|
67
|
+
: [];
|
|
68
|
+
const payments = store.all('SELECT * FROM order_payments WHERE order_id = ?', [id]);
|
|
69
|
+
const events = store.all('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at', [id]);
|
|
70
|
+
|
|
71
|
+
// Тикеты кухни едут вместе с чеком, включая погашенные (deleted_at): облако держит ту же
|
|
72
|
+
// историю отправок, иначе номер следующего тикета разошёлся бы с кассой.
|
|
73
|
+
const tickets = store.all('SELECT * FROM kds_tickets WHERE order_id = ? ORDER BY number, created_at', [id]);
|
|
74
|
+
const ticketOwners = SyncStore.inList('kds_ticket_id', tickets.map((row) => row.id));
|
|
75
|
+
const ticketItems = tickets.length
|
|
76
|
+
? store.all(`SELECT * FROM kds_ticket_items WHERE ${ticketOwners.sql} ORDER BY created_at`, ticketOwners.params)
|
|
77
|
+
: [];
|
|
78
|
+
|
|
79
|
+
// Товары-экстры, созданные кассой офлайн: облаку они нужны раньше позиций, которые на них
|
|
80
|
+
// ссылаются. synced_at IS NULL — облако их ещё не подтверждало снапшотом.
|
|
81
|
+
const catalogIds = [...new Set(products.map((row) => row.product_id).filter(Boolean))];
|
|
82
|
+
const catalog = SyncStore.inList('id', catalogIds);
|
|
83
|
+
const extraProducts = catalogIds.length
|
|
84
|
+
? store.all(`SELECT * FROM products WHERE synced_at IS NULL AND ${catalog.sql}`, catalog.params)
|
|
85
|
+
: [];
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
order: toCloudRow('orders', order),
|
|
89
|
+
products: toCloudRows('order_products', products),
|
|
90
|
+
modifier_sets: toCloudRows('order_product_modifier_sets', modifiers),
|
|
91
|
+
payments: toCloudRows('order_payments', payments),
|
|
92
|
+
events: toCloudRows('order_events', events),
|
|
93
|
+
extra_products: toCloudRows('products', extraProducts),
|
|
94
|
+
kds_tickets: toCloudRows('kds_tickets', tickets),
|
|
95
|
+
kds_ticket_items: toCloudRows('kds_ticket_items', ticketItems),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Принтер, настроенный на кассе без облака (printers.update / delete / syncAddress в ядре),
|
|
101
|
+
* вместе со всеми его цехами: связь без своего id облако заменяет целиком. Удалённый принтер
|
|
102
|
+
* (deleted_at) тоже едет — облако должно его удалить. null — принтера локально нет.
|
|
103
|
+
*/
|
|
104
|
+
const readPrinterAggregate = (store, id) => {
|
|
105
|
+
const printer = store.get('SELECT * FROM printers WHERE id = ?', [id]);
|
|
106
|
+
if (!printer) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const workshops = store.all('SELECT * FROM printer_workshop WHERE printer_id = ?', [id]);
|
|
110
|
+
// synced_at — локальная метка снапшота, облаку она ни к чему.
|
|
111
|
+
const local = ({synced_at, ...row}) => row;
|
|
112
|
+
return {
|
|
113
|
+
printer: toCloudRow('printers', local(printer)),
|
|
114
|
+
workshops: toCloudRows('printer_workshop', workshops.map(local)),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Пометки с заведением, которому принадлежит запись. Запись другого заведения облако примет
|
|
120
|
+
* только с его токеном (иначе ответит foreign_company), поэтому push и счётчик очереди берут
|
|
121
|
+
* лишь заведение сессии, а чужие записи ждут повторного входа в своё заведение.
|
|
122
|
+
* Запись без заведения, или которой локально уже нет, считается своей: первую облако отвергнет,
|
|
123
|
+
* и она станет «отравленной» (видна в /local/status), вторую push снимет как отсутствующую.
|
|
124
|
+
*/
|
|
125
|
+
const DIRTY_FROM = `sync_dirty d
|
|
126
|
+
LEFT JOIN orders o ON d.entity = 'order' AND o.id = d.id
|
|
127
|
+
LEFT JOIN shifts s ON d.entity = 'shift' AND s.id = d.id
|
|
128
|
+
LEFT JOIN printers p ON d.entity = 'printer' AND p.id = d.id`;
|
|
129
|
+
const OWNER = 'COALESCE(o.company_id, s.company_id, p.company_id)';
|
|
130
|
+
|
|
131
|
+
/** companyId пуст (сессии нет) — без фильтра, как раньше. */
|
|
132
|
+
const ownScope = (companyId) => (companyId
|
|
133
|
+
? {sql: `WHERE (${OWNER} IS NULL OR ${OWNER} = ?)`, params: [String(companyId)]}
|
|
134
|
+
: {sql: '', params: []});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Пачка на отправку — читается синхронно, в одном блоке: всё, что уходит в облако, уже
|
|
138
|
+
* закоммичено локально и согласовано между собой.
|
|
139
|
+
*
|
|
140
|
+
* @param {Set<string>} excluded ключи 'entity:id' отравленных записей (см. index.js)
|
|
141
|
+
* @param {string|null} companyId заведение сессии: только его записи уходят с её токеном
|
|
142
|
+
*/
|
|
143
|
+
const readBatch = (store, {batchSize, excluded = new Set(), companyId = null}) => {
|
|
144
|
+
const scope = ownScope(companyId);
|
|
145
|
+
// Смены — первыми: закрытый офлайн чек ссылается на смену, открытую тоже офлайн.
|
|
146
|
+
// Принтеры — до чеков: печать чеков в облаке сразу пойдёт на исправленный адрес.
|
|
147
|
+
const dirty = store.all(
|
|
148
|
+
`SELECT d.entity, d.id, d.version FROM ${DIRTY_FROM}
|
|
149
|
+
${scope.sql}
|
|
150
|
+
ORDER BY CASE d.entity WHEN 'shift' THEN 0 WHEN 'printer' THEN 1 ELSE 2 END, d.dirty_at, d.id
|
|
151
|
+
LIMIT ?`,
|
|
152
|
+
[...scope.params, batchSize + excluded.size]
|
|
153
|
+
).filter((row) => !excluded.has(`${row.entity}:${row.id}`)).slice(0, batchSize);
|
|
154
|
+
|
|
155
|
+
const orders = [];
|
|
156
|
+
const shifts = [];
|
|
157
|
+
const printers = [];
|
|
158
|
+
const versions = new Map();
|
|
159
|
+
const missing = [];
|
|
160
|
+
|
|
161
|
+
for (const row of dirty) {
|
|
162
|
+
const key = `${row.entity}:${row.id}`;
|
|
163
|
+
if (row.entity === 'shift') {
|
|
164
|
+
const shift = store.get('SELECT * FROM shifts WHERE id = ?', [row.id]);
|
|
165
|
+
if (!shift) {
|
|
166
|
+
missing.push(row);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
shifts.push(toCloudRow('shifts', shift));
|
|
170
|
+
} else if (row.entity === 'order') {
|
|
171
|
+
const aggregate = readOrderAggregate(store, row.id);
|
|
172
|
+
if (!aggregate) {
|
|
173
|
+
missing.push(row);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
orders.push(aggregate);
|
|
177
|
+
} else if (row.entity === 'printer') {
|
|
178
|
+
const aggregate = readPrinterAggregate(store, row.id);
|
|
179
|
+
if (!aggregate) {
|
|
180
|
+
missing.push(row);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
printers.push(aggregate);
|
|
184
|
+
} else {
|
|
185
|
+
missing.push(row);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
versions.set(key, row.version);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {orders, shifts, printers, versions, missing};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const clearMark = (store, entity, id, version) => store.run(
|
|
195
|
+
'DELETE FROM sync_dirty WHERE entity = ? AND id = ? AND version = ?',
|
|
196
|
+
[entity, id, version]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ответ облака, после которого пометку можно снять. conflict — облако победило (закрыт или
|
|
201
|
+
* удалён там), свою версию подтянет pull по ids. Любой skipped (invalid, foreign_company) и
|
|
202
|
+
* error оставляют пометку и копят попытки — после лимита запись попадает в «отравленные» и видна
|
|
203
|
+
* в /local/status, а не пропадает молча. foreign_company раньше пометку снимал, и запись другого
|
|
204
|
+
* заведения, ушедшая с чужим токеном, больше не отправлялась, а через keepClosedDays удалялась.
|
|
205
|
+
*/
|
|
206
|
+
const isDone = (result) => result.status === 'applied' || result.status === 'conflict';
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Одна пачка push. Возвращает {sent, conflicts, failed, results, authoritative}.
|
|
210
|
+
*
|
|
211
|
+
* @param {{store, cloud, excluded: Set<string>, onFailure: Function, batchSize?: number, epoch?: number,
|
|
212
|
+
* companyId?: string}} options
|
|
213
|
+
* companyId — заведение сессии, см. readBatch.
|
|
214
|
+
* onFailure(key, version, reason) — облако ответило, но запись не приняло (для учёта «отравленных»).
|
|
215
|
+
* epoch — мастер-касса (модель M) выгружает авторитетно: облако принимает её версию чека как
|
|
216
|
+
* истину. Облако само решает, так ли это, и подтверждает флагом authoritative в ответе.
|
|
217
|
+
*/
|
|
218
|
+
const pushBatch = async ({store, cloud, excluded = new Set(), onFailure = () => {}, batchSize = null, epoch = null,
|
|
219
|
+
companyId = null}) => {
|
|
220
|
+
const size = batchSize || limits().pushBatch;
|
|
221
|
+
const batch = await store.block((s) => {
|
|
222
|
+
const read = readBatch(s, {batchSize: size, excluded, companyId});
|
|
223
|
+
// Пометки на то, чего локально уже нет, снимаем сразу — отправлять нечего.
|
|
224
|
+
for (const row of read.missing) {
|
|
225
|
+
clearMark(s, row.entity, row.id, row.version);
|
|
226
|
+
}
|
|
227
|
+
return read;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!batch.orders.length && !batch.shifts.length && !batch.printers.length) {
|
|
231
|
+
return {sent: 0, conflicts: [], failed: [], results: [], authoritative: null};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const body = {orders: batch.orders, shifts: batch.shifts};
|
|
235
|
+
// Ключ только когда есть что слать: облако без приёма принтеров пакет без них примет как раньше.
|
|
236
|
+
if (batch.printers.length) {
|
|
237
|
+
body.printers = batch.printers;
|
|
238
|
+
}
|
|
239
|
+
if (epoch !== null && epoch !== undefined) {
|
|
240
|
+
body.epoch = epoch;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let response;
|
|
244
|
+
try {
|
|
245
|
+
response = await cloud.call(
|
|
246
|
+
'sync.pushOrders',
|
|
247
|
+
body,
|
|
248
|
+
{timeout: timeouts().push, idempotencyKey: randomUUID()}
|
|
249
|
+
) || {};
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e.name === 'CloudRequestError') {
|
|
252
|
+
// Облако отвергло пачку целиком (валидация) — каждая запись получает попытку,
|
|
253
|
+
// а index.js переходит на пачки по одной, чтобы найти виновную.
|
|
254
|
+
for (const [key, version] of batch.versions) {
|
|
255
|
+
onFailure(key, version, e.message);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const conflicts = [];
|
|
262
|
+
const failed = [];
|
|
263
|
+
const answered = new Set();
|
|
264
|
+
const results = [
|
|
265
|
+
...(response.results || []).map((result) => ({...result, entity: 'order'})),
|
|
266
|
+
...(response.shifts || []).map((result) => ({...result, entity: 'shift'})),
|
|
267
|
+
...(response.printers || []).map((result) => ({...result, entity: 'printer'})),
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
await store.block((s) => {
|
|
271
|
+
for (const result of results) {
|
|
272
|
+
const key = `${result.entity}:${result.id}`;
|
|
273
|
+
if (!batch.versions.has(key)) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
answered.add(key);
|
|
277
|
+
if (isDone(result)) {
|
|
278
|
+
clearMark(s, result.entity, result.id, batch.versions.get(key));
|
|
279
|
+
if (result.status === 'conflict' && result.entity === 'order') {
|
|
280
|
+
conflicts.push({id: result.id, reason: result.reason || null});
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
failed.push({key, reason: [result.status, result.reason, result.message].filter(Boolean).join(': ') || 'error'});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
for (const [key, version] of batch.versions) {
|
|
289
|
+
if (!answered.has(key)) {
|
|
290
|
+
failed.push({key, reason: 'no result'});
|
|
291
|
+
}
|
|
292
|
+
const failure = failed.find((item) => item.key === key);
|
|
293
|
+
if (failure) {
|
|
294
|
+
onFailure(key, version, failure.reason);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {sent: batch.versions.size, conflicts, failed, results, authoritative: Boolean(response.authoritative)};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** Сколько записей заведения сессии ждут отправки (без отравленных). */
|
|
302
|
+
const pendingCount = (store, excluded = new Set(), companyId = null) => {
|
|
303
|
+
const scope = ownScope(companyId);
|
|
304
|
+
if (!excluded.size) {
|
|
305
|
+
return Number(store.get(`SELECT COUNT(*) AS count FROM ${DIRTY_FROM} ${scope.sql}`, scope.params).count) || 0;
|
|
306
|
+
}
|
|
307
|
+
const rows = store.all(`SELECT d.entity, d.id FROM ${DIRTY_FROM} ${scope.sql}`, scope.params);
|
|
308
|
+
return rows.filter((row) => !excluded.has(`${row.entity}:${row.id}`)).length;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** Недосланное других заведений: [{company_id, count}]. Уйдёт при входе в своё заведение. */
|
|
312
|
+
const foreignPending = (store, companyId) => {
|
|
313
|
+
if (!companyId) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
return store.all(
|
|
317
|
+
`SELECT ${OWNER} AS company_id, COUNT(*) AS count FROM ${DIRTY_FROM}
|
|
318
|
+
WHERE ${OWNER} IS NOT NULL AND ${OWNER} <> ?
|
|
319
|
+
GROUP BY ${OWNER} ORDER BY ${OWNER}`,
|
|
320
|
+
[String(companyId)]
|
|
321
|
+
).map((row) => ({company_id: row.company_id, count: Number(row.count) || 0}));
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
module.exports = {pushBatch, pendingCount, foreignPending, readBatch, readOrderAggregate, readPrinterAggregate, toCloudRow};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const config = require('./config');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Сверка касса ↔ облако (OFFLINE-FIRST-PLAN §12.8, P2).
|
|
5
|
+
*
|
|
6
|
+
* push и pull двигают чеки по пометкам sync_dirty и курсору. Если пометка потерялась (крах
|
|
7
|
+
* посреди блока, ручная правка файла БД, ошибка в правилах слияния), расхождение живёт молча:
|
|
8
|
+
* ни одна из сторон о нём не знает. Сверка ловит такие случаи по закрытым чекам — именно из
|
|
9
|
+
* них складывается выручка.
|
|
10
|
+
*
|
|
11
|
+
* Раз в OFFLINE_RECONCILE_MS касса шлёт облаку сводку {count, total} закрытых неудалённых
|
|
12
|
+
* чеков компании за окно [now − windowHours − settle, now − settle]. Совпало — всё. Нет —
|
|
13
|
+
* облако отдаёт свой список за то же окно, и касса сравнивает построчно.
|
|
14
|
+
*
|
|
15
|
+
* Лечение — теми же путями, что и обычный синк, своих записей в облако сверка не делает:
|
|
16
|
+
* чек жив у кассы, в облаке его нет → пометить грязным, уедет обычным push;
|
|
17
|
+
* касса — мастер заведения (истина у неё) → любой расходящийся чек, который есть локально, — push;
|
|
18
|
+
* иначе (истина в облаке) → pull по ids.
|
|
19
|
+
*
|
|
20
|
+
* Сверка запускается только при пустой очереди push: иначе расхождение — это просто
|
|
21
|
+
* ещё не досланные правки.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SQL_TIME = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
|
|
25
|
+
|
|
26
|
+
const cents = (value) => Math.round((Number(value) || 0) * 100);
|
|
27
|
+
|
|
28
|
+
const MARK_DIRTY_SQL = `INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', ?, 1, datetime('now'))
|
|
29
|
+
ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at`;
|
|
30
|
+
|
|
31
|
+
const window = (now = Date.now()) => {
|
|
32
|
+
const {windowHours, settle} = config.reconcile();
|
|
33
|
+
const to = now - settle;
|
|
34
|
+
return {from: SQL_TIME(to - windowHours * 3600 * 1000), to: SQL_TIME(to)};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Закрытые чеки компании за окно — и удалённые тоже: «удалён здесь, жив там» тоже расхождение. */
|
|
38
|
+
const localRows = (store, companyId, {from, to}) => store.all(
|
|
39
|
+
`SELECT id, closed_at, total, deleted_at FROM orders
|
|
40
|
+
WHERE company_id = ? AND closed_at IS NOT NULL AND closed_at >= ? AND closed_at <= ?`,
|
|
41
|
+
[companyId, from, to]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const summarize = (rows) => {
|
|
45
|
+
let count = 0;
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (!row.deleted_at) {
|
|
49
|
+
count += 1;
|
|
50
|
+
total += cents(row.total);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {count, total: total / 100};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Построчное сравнение: списки id по видам расхождения. */
|
|
57
|
+
const compare = (local, cloud) => {
|
|
58
|
+
const localById = new Map(local.map((row) => [String(row.id), row]));
|
|
59
|
+
const cloudById = new Map(cloud.map((row) => [String(row.id), row]));
|
|
60
|
+
const diff = {missing_in_cloud: [], missing_locally: [], total_mismatch: [], deleted_mismatch: []};
|
|
61
|
+
|
|
62
|
+
for (const [id, row] of localById) {
|
|
63
|
+
const alive = !row.deleted_at;
|
|
64
|
+
const remote = cloudById.get(id);
|
|
65
|
+
if (!remote) {
|
|
66
|
+
if (alive) {
|
|
67
|
+
diff.missing_in_cloud.push(id);
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (alive !== !remote.deleted) {
|
|
72
|
+
diff.deleted_mismatch.push(id);
|
|
73
|
+
} else if (alive && cents(row.total) !== cents(remote.total)) {
|
|
74
|
+
diff.total_mismatch.push(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const [id, remote] of cloudById) {
|
|
78
|
+
if (!remote.deleted && !localById.has(id)) {
|
|
79
|
+
diff.missing_locally.push(id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return diff;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const diffSize = (diff) => (diff
|
|
86
|
+
? diff.missing_in_cloud.length + diff.missing_locally.length + diff.total_mismatch.length + diff.deleted_mismatch.length
|
|
87
|
+
: 0);
|
|
88
|
+
|
|
89
|
+
/** Что делать с расхождениями: {push: ids, pull: ids}. skip — ключи отравленных записей. */
|
|
90
|
+
const plan = (store, diff, {authoritative = false, skip = new Set(), limit = Infinity} = {}) => {
|
|
91
|
+
const push = [];
|
|
92
|
+
const pull = [];
|
|
93
|
+
const add = (list, id) => {
|
|
94
|
+
if (push.length + pull.length < limit && !list.includes(id)) {
|
|
95
|
+
list.push(id);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const existsLocally = (id) => Boolean(store.get('SELECT 1 AS found FROM orders WHERE id = ?', [id]));
|
|
99
|
+
|
|
100
|
+
for (const id of diff.missing_in_cloud) {
|
|
101
|
+
// Отравленную запись облако и так отвергает — новая версия лишь сбросила бы счётчик попыток.
|
|
102
|
+
if (!skip.has(`order:${id}`)) {
|
|
103
|
+
add(push, id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const id of [...diff.total_mismatch, ...diff.deleted_mismatch, ...diff.missing_locally]) {
|
|
107
|
+
if (authoritative && existsLocally(id)) {
|
|
108
|
+
if (!skip.has(`order:${id}`)) {
|
|
109
|
+
add(push, id);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
add(pull, id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {push, pull};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Пометить чеки грязными — вне sync_suppress, как если бы их поправила сама касса. */
|
|
119
|
+
const markDirty = (store, ids) => {
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
store.run(MARK_DIRTY_SQL, [id]);
|
|
122
|
+
}
|
|
123
|
+
return ids.length;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Одна сверка. Возвращает {ok, window, local, cloud, diff, truncated, push, pull}; ids для
|
|
128
|
+
* pull вызывающий ставит в свою очередь, пометки для push уже записаны.
|
|
129
|
+
*/
|
|
130
|
+
const run = async ({store, cloud, companyId, authoritative = false, skip = new Set(), now = Date.now()}) => {
|
|
131
|
+
const range = window(now);
|
|
132
|
+
const rows = localRows(store, companyId, range);
|
|
133
|
+
const local = summarize(rows);
|
|
134
|
+
|
|
135
|
+
const response = await cloud.call('sync.reconcile', {...range, summary: local});
|
|
136
|
+
const remote = response && response.cloud ? {
|
|
137
|
+
count: Number(response.cloud.count) || 0,
|
|
138
|
+
total: Number(response.cloud.total) || 0,
|
|
139
|
+
} : null;
|
|
140
|
+
const result = {ok: Boolean(response && response.ok), window: range, local, cloud: remote,
|
|
141
|
+
diff: null, truncated: false, push: [], pull: []};
|
|
142
|
+
if (result.ok) {
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(response && response.orders) || response.has_more) {
|
|
146
|
+
// Список облака неполный — построчно не сравнить, чтобы не «лечить» то, чего не видно.
|
|
147
|
+
result.truncated = true;
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
result.diff = compare(rows, response.orders);
|
|
152
|
+
const {push, pull} = plan(store, result.diff, {authoritative, skip, limit: config.reconcile().maxHeal});
|
|
153
|
+
if (push.length) {
|
|
154
|
+
await store.block((s) => markDirty(s, push));
|
|
155
|
+
}
|
|
156
|
+
result.push = push;
|
|
157
|
+
result.pull = pull;
|
|
158
|
+
return result;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
module.exports = {run, window, localRows, summarize, compare, plan, markDirty, diffSize};
|