@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const {imageDataUri} = require('../support/imageData');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Чтения для печати: принтеры, цеха, терминалы, команда. Ничего не пишет — справочники
|
|
5
|
+
* наполняет синк снапшота (локально) или бэк-офис (облако).
|
|
6
|
+
*
|
|
7
|
+
* soft delete: в облаке у terminals/teams/product_workshops/payment_methods есть deleted_at, и
|
|
8
|
+
* Eloquent отсекает удалённые сам. В локальном снапшоте этих колонок нет вовсе (удалённое туда
|
|
9
|
+
* не синкается), поэтому условие добавляется только под Postgres — иначе SQLite упадёт на
|
|
10
|
+
* несуществующей колонке. У printers deleted_at есть в обеих схемах.
|
|
11
|
+
*/
|
|
12
|
+
class PrinterRepository {
|
|
13
|
+
constructor(db) {
|
|
14
|
+
this.db = db;
|
|
15
|
+
this.cache = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
notDeleted(alias = null) {
|
|
19
|
+
if (this.db.dialect !== 'pgsql') {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
return ` AND ${alias ? `${alias}.` : ''}deleted_at IS NULL`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async cached(key, resolver) {
|
|
26
|
+
if (this.cache.has(key)) {
|
|
27
|
+
return this.cache.get(key);
|
|
28
|
+
}
|
|
29
|
+
const value = await resolver();
|
|
30
|
+
this.cache.set(key, value);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findTeam(id) {
|
|
35
|
+
if (!id) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return this.cached(`team:${id}`, () =>
|
|
39
|
+
this.db.get(`SELECT * FROM teams WHERE id = ?${this.notDeleted()}`, [id]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Логотип команды для счёта — data URI, как ImageService::toBase64. Касса берёт копию,
|
|
44
|
+
* которую привёз снапшот (team_logos). Облако качает файл по публичному адресу диска
|
|
45
|
+
* картинки (`support/imageData.js`, кэш в процессе).
|
|
46
|
+
*/
|
|
47
|
+
async findTeamLogo(teamId) {
|
|
48
|
+
if (!teamId) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (this.db.dialect === 'pgsql') {
|
|
52
|
+
const team = await this.findTeam(teamId);
|
|
53
|
+
return team && team.logo ? imageDataUri(this.db, team.logo) : null;
|
|
54
|
+
}
|
|
55
|
+
return this.cached(`teamLogo:${teamId}`, async () => {
|
|
56
|
+
const row = await this.db.get('SELECT data FROM team_logos WHERE team_id = ?', [teamId]);
|
|
57
|
+
return row && row.data ? row.data : null;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Terminal::query()->find($id) — удалённый терминал не находится. */
|
|
62
|
+
async findTerminal(id) {
|
|
63
|
+
if (!id) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return this.cached(`terminal:${id}`, () =>
|
|
67
|
+
this.db.get(`SELECT * FROM terminals WHERE id = ?${this.notDeleted()}`, [id]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Символ валюты команды ($team->currency->symbol из каста настроек чеков). Таблицы
|
|
72
|
+
* currencies и колонки teams.currency_id в локальном снапшоте нет — тогда null, и
|
|
73
|
+
* normalizeReceipts возьмёт символ из сохранённого конфига.
|
|
74
|
+
*/
|
|
75
|
+
async findCurrencySymbol(team) {
|
|
76
|
+
if (!team || !team.currency_id) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const row = await this.db.get(
|
|
81
|
+
`SELECT symbol FROM currencies WHERE id = ?${this.notDeleted()}`,
|
|
82
|
+
[team.currency_id]
|
|
83
|
+
);
|
|
84
|
+
return row ? row.symbol : null;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Цеха команды, которым положен бумажный чек: ProductWorkshop::where('print_ticket', true). */
|
|
91
|
+
async findPrintWorkshops(teamId) {
|
|
92
|
+
const rows = await this.db.all(
|
|
93
|
+
`SELECT * FROM product_workshops WHERE print_ticket = ? AND team_id = ?${this.notDeleted()}`,
|
|
94
|
+
[true, teamId]
|
|
95
|
+
);
|
|
96
|
+
return new Map(rows.map((row) => [row.id, row]));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Позиции, по которым цех ещё не получил чек: `quantity != workshop_print_quantity`. */
|
|
100
|
+
async findPendingOrderProducts(orderId) {
|
|
101
|
+
return this.db.all(
|
|
102
|
+
`SELECT * FROM order_products
|
|
103
|
+
WHERE order_id = ? AND quantity != workshop_print_quantity AND deleted_at IS NULL
|
|
104
|
+
ORDER BY created_at, id`,
|
|
105
|
+
[orderId]
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** $team->terminals()->where('company_id', ...) — кандидаты на fallback-поиск принтера. */
|
|
110
|
+
async findCompanyTerminalIds(teamId, companyId) {
|
|
111
|
+
const rows = await this.db.all(
|
|
112
|
+
`SELECT id FROM terminals WHERE team_id = ? AND company_id = ?${this.notDeleted()}`,
|
|
113
|
+
[teamId, companyId]
|
|
114
|
+
);
|
|
115
|
+
return rows.map((row) => row.id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Принтеры цеха на указанных терминалах (связь printer_workshop с включённым print_ticket).
|
|
120
|
+
* Берём только колонки printers: у SELECT * по join локальный printer_workshop.synced_at
|
|
121
|
+
* перетёр бы одноимённую колонку принтера. Laravel порядок не задаёт — ставим
|
|
122
|
+
* created_at, id, чтобы очередность заданий не зависела от плана запроса.
|
|
123
|
+
*/
|
|
124
|
+
async findWorkshopPrinters(terminalIds, workshopId) {
|
|
125
|
+
const ids = terminalIds.filter(Boolean);
|
|
126
|
+
if (!ids.length) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
130
|
+
return this.db.all(
|
|
131
|
+
`SELECT printers.* FROM printers
|
|
132
|
+
LEFT JOIN printer_workshop ON printer_workshop.printer_id = printers.id
|
|
133
|
+
WHERE printers.terminal_id IN (${placeholders})
|
|
134
|
+
AND printer_workshop.product_workshop_id = ?
|
|
135
|
+
AND printer_workshop.print_ticket = ?
|
|
136
|
+
AND printers.deleted_at IS NULL
|
|
137
|
+
ORDER BY printers.created_at, printers.id`,
|
|
138
|
+
[...ids, workshopId, true]
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Принтер счёта терминала — Printer::where('terminal_id')->where('bill', true)->first(). */
|
|
143
|
+
async findBillPrinter(terminalId) {
|
|
144
|
+
if (!terminalId) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return this.db.get(
|
|
148
|
+
`SELECT * FROM printers
|
|
149
|
+
WHERE terminal_id = ? AND bill = ? AND deleted_at IS NULL
|
|
150
|
+
ORDER BY created_at, id
|
|
151
|
+
LIMIT 1`,
|
|
152
|
+
[terminalId, true]
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Fallback: первый по времени создания принтер счёта в заведении. */
|
|
157
|
+
async findCompanyBillPrinter(companyId) {
|
|
158
|
+
if (!companyId) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return this.db.get(
|
|
162
|
+
`SELECT * FROM printers
|
|
163
|
+
WHERE bill = ? AND company_id = ? AND deleted_at IS NULL
|
|
164
|
+
ORDER BY created_at, id
|
|
165
|
+
LIMIT 1`,
|
|
166
|
+
[true, companyId]
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async findPaymentMethod(id) {
|
|
171
|
+
if (!id) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return this.cached(`paymentMethod:${id}`, () =>
|
|
175
|
+
this.db.get(`SELECT * FROM payment_methods WHERE id = ?${this.notDeleted()}`, [id]));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = PrinterRepository;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const {nullableBool, takeText, jsonText} = require('../resources/casts');
|
|
2
|
+
const {imageUrls} = require('../support/images');
|
|
3
|
+
const {unique, groupBy, indexBy, alive, whereIn} = require('../support/rows');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Связи товаров для ProductResource (`app/core/resources/ProductResource.js`) — то, что Eloquent
|
|
7
|
+
* догружает к `products.product` чека и к справочнику ProductService::allProducts:
|
|
8
|
+
* `modifications` (ORDER BY price), `modifierSets.set`, `modifierSets.modifiers`, категория для
|
|
9
|
+
* мутатора цеха и картинки. Пакетно, отдельным `WHERE … IN (…)` на связь.
|
|
10
|
+
*
|
|
11
|
+
* Накопительный: повторный `load()` догружает только новые товары. Ресурс чека зовёт его на каждый
|
|
12
|
+
* чек и на каждую позицию, а связи общие.
|
|
13
|
+
*
|
|
14
|
+
* `modifier_sets` и `product_modifier_sets` Laravel читает без ORDER BY — порядок кучи Postgres.
|
|
15
|
+
* В SQLite это порядок вставки снапшота, а снапшот пишет строки в том порядке, в каком их отдало
|
|
16
|
+
* облако.
|
|
17
|
+
*/
|
|
18
|
+
class ProductRelations {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} db драйвер ядра
|
|
21
|
+
* @param {string} locale
|
|
22
|
+
*/
|
|
23
|
+
constructor(db, locale) {
|
|
24
|
+
this.db = db;
|
|
25
|
+
this.pgsql = db.dialect === 'pgsql';
|
|
26
|
+
/** @type {import('../resources/ProductResource').ProductRelations} */
|
|
27
|
+
this.rel = {
|
|
28
|
+
modifications: new Map(),
|
|
29
|
+
modifierSets: new Map(),
|
|
30
|
+
sets: new Map(),
|
|
31
|
+
modifiers: new Map(),
|
|
32
|
+
categories: new Map(),
|
|
33
|
+
// Таблицы images в снапшоте нет: на кассе картинка остаётся значением колонки.
|
|
34
|
+
urls: this.pgsql ? new Map() : null,
|
|
35
|
+
locale,
|
|
36
|
+
};
|
|
37
|
+
this.loadedProducts = new Set();
|
|
38
|
+
this.loadedSets = new Set();
|
|
39
|
+
this.loadedCategories = new Set();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object[]} products строки products (null пропускаются)
|
|
44
|
+
* @returns {Promise<object>} rel для productResource
|
|
45
|
+
*/
|
|
46
|
+
async load(products) {
|
|
47
|
+
const fresh = [];
|
|
48
|
+
for (const product of products) {
|
|
49
|
+
if (product && !this.loadedProducts.has(String(product.id))) {
|
|
50
|
+
this.loadedProducts.add(String(product.id));
|
|
51
|
+
fresh.push(product);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!fresh.length) {
|
|
55
|
+
return this.rel;
|
|
56
|
+
}
|
|
57
|
+
const db = this.db;
|
|
58
|
+
const ids = unique(fresh.map((product) => product.id));
|
|
59
|
+
|
|
60
|
+
const [modifications, modifierSets] = await Promise.all([
|
|
61
|
+
whereIn(db, `SELECT * FROM products WHERE parent_id IN (:ids)${alive(db)} ORDER BY price`, ids),
|
|
62
|
+
whereIn(db, `SELECT *${jsonText(db, ['config'])} FROM product_modifier_sets WHERE product_id IN (:ids)${alive(db)}`, ids),
|
|
63
|
+
]);
|
|
64
|
+
modifierSets.forEach((row) => takeText(row, ['config']));
|
|
65
|
+
merge(this.rel.modifications, groupBy(modifications, 'parent_id'));
|
|
66
|
+
merge(this.rel.modifierSets, groupBy(modifierSets, 'product_id'));
|
|
67
|
+
|
|
68
|
+
const modifiers = await this.loadSets(modifierSets);
|
|
69
|
+
const all = [...fresh, ...modifications];
|
|
70
|
+
await Promise.all([
|
|
71
|
+
this.loadCategories(all),
|
|
72
|
+
this.loadImages([...all.map((product) => product.image), ...modifiers.map((modifier) => modifier.image)]),
|
|
73
|
+
]);
|
|
74
|
+
return this.rel;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async loadSets(modifierSets) {
|
|
78
|
+
const db = this.db;
|
|
79
|
+
const setIds = unique(modifierSets.map((row) => row.modifier_set_id)).filter((id) => !this.loadedSets.has(id));
|
|
80
|
+
if (!setIds.length) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
setIds.forEach((id) => this.loadedSets.add(id));
|
|
84
|
+
const [sets, modifiers] = await Promise.all([
|
|
85
|
+
whereIn(db, `SELECT *${jsonText(db, ['config'])} FROM modifier_sets WHERE id IN (:ids)${alive(db)}`, setIds),
|
|
86
|
+
// hasManyThrough(ModifierSetModifier, ModifierSet): набор отсекается и по своему deleted_at.
|
|
87
|
+
whereIn(
|
|
88
|
+
db,
|
|
89
|
+
`SELECT msm.*${jsonText(db, ['gross'], 'msm.')}, ms.id AS laravel_through_key
|
|
90
|
+
FROM modifier_set_modifiers msm
|
|
91
|
+
INNER JOIN modifier_sets ms ON ms.id = msm.modifier_set_id
|
|
92
|
+
WHERE ms.id IN (:ids)${alive(db, 'msm')}${alive(db, 'ms')}`,
|
|
93
|
+
setIds
|
|
94
|
+
),
|
|
95
|
+
]);
|
|
96
|
+
sets.forEach((row) => takeText(row, ['config']));
|
|
97
|
+
modifiers.forEach((row) => {
|
|
98
|
+
takeText(row, ['gross']);
|
|
99
|
+
// decimal(20,3) без каста PDO отдаёт строкой '0.000'; в SQLite колонка REAL.
|
|
100
|
+
if (typeof row.gross === 'number') {
|
|
101
|
+
row.gross = row.gross.toFixed(3);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
for (const [id, row] of indexBy(sets)) {
|
|
105
|
+
this.rel.sets.set(id, row);
|
|
106
|
+
}
|
|
107
|
+
merge(this.rel.modifiers, groupBy(modifiers, 'laravel_through_key'));
|
|
108
|
+
return modifiers;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Мутатор product_workshop_id: пустой цех берётся у категории (без удалённых). */
|
|
112
|
+
async loadCategories(products) {
|
|
113
|
+
const db = this.db;
|
|
114
|
+
const categoryIds = unique(products
|
|
115
|
+
.filter((product) => !product.product_workshop_id && !nullableBool(product.ignore_workshop))
|
|
116
|
+
.map((product) => product.product_category_id))
|
|
117
|
+
.filter((id) => !this.loadedCategories.has(id));
|
|
118
|
+
if (!categoryIds.length) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
categoryIds.forEach((id) => this.loadedCategories.add(id));
|
|
122
|
+
const rows = await whereIn(db, `SELECT id, product_workshop_id FROM product_categories WHERE id IN (:ids)${alive(db)}`, categoryIds);
|
|
123
|
+
for (const [id, row] of indexBy(rows)) {
|
|
124
|
+
this.rel.categories.set(id, row);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async loadImages(values) {
|
|
129
|
+
if (!this.rel.urls) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const missing = values.filter((value) => value && !this.rel.urls.has(String(value)));
|
|
133
|
+
if (!missing.length) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const [id, url] of await imageUrls(this.db, missing)) {
|
|
137
|
+
this.rel.urls.set(id, url);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const merge = (target, source) => {
|
|
143
|
+
for (const [key, rows] of source) {
|
|
144
|
+
target.set(key, [...(target.get(key) || []), ...rows]);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
module.exports = ProductRelations;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const {toBool} = require('../support/num');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Чтение справочников (в локальном контуре — снапшот, в облаке — те же таблицы Laravel).
|
|
5
|
+
* Ничего не пишет: наполнение это задача синка вниз / бэк-офиса.
|
|
6
|
+
*
|
|
7
|
+
* Справочники в пределах одного запроса не меняются, поэтому чтения кэшируются на экземпляр:
|
|
8
|
+
* расчёт чека дёргает findProduct десятки раз, и в облаке каждый такой вызов — сетевой
|
|
9
|
+
* round-trip к Postgres.
|
|
10
|
+
*/
|
|
11
|
+
class SnapshotRepository {
|
|
12
|
+
constructor(db) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.cache = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async cached(key, resolver) {
|
|
18
|
+
if (this.cache.has(key)) {
|
|
19
|
+
return this.cache.get(key);
|
|
20
|
+
}
|
|
21
|
+
const value = await resolver();
|
|
22
|
+
this.cache.set(key, value);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async findProduct(id) {
|
|
27
|
+
if (!id) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return this.cached(`product:${id}`, () =>
|
|
31
|
+
this.db.get('SELECT * FROM products WHERE id = ?', [id]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findProducts(ids) {
|
|
35
|
+
if (!ids.length) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
39
|
+
return this.db.all(`SELECT * FROM products WHERE id IN (${placeholders})`, ids);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Открытая кассовая смена — порт `ShiftService::getCashShift` без проверки настройки
|
|
44
|
+
* (её делает вызывающий). Не кэшируется: смену могут закрыть между запросами.
|
|
45
|
+
*/
|
|
46
|
+
async findOpenCashShift(teamId, companyId) {
|
|
47
|
+
return this.db.get(
|
|
48
|
+
`SELECT * FROM shifts
|
|
49
|
+
WHERE team_id = ? AND company_id = ? AND type = 'cash' AND ended_at IS NULL
|
|
50
|
+
AND deleted_at IS NULL
|
|
51
|
+
ORDER BY started_at DESC
|
|
52
|
+
LIMIT 1`,
|
|
53
|
+
[teamId, companyId]
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async findOrderSource(teamId, id) {
|
|
58
|
+
if (!id) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return this.db.get('SELECT * FROM order_sources WHERE team_id = ? AND id = ?', [teamId, id]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async findOrderSourceByType(teamId, type) {
|
|
65
|
+
return this.db.get('SELECT * FROM order_sources WHERE team_id = ? AND type = ?', [teamId, type]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async findOrderSourceById(id) {
|
|
69
|
+
if (!id) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return this.cached(`orderSource:${id}`, () =>
|
|
73
|
+
this.db.get('SELECT * FROM order_sources WHERE id = ?', [id]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async findTable(id) {
|
|
77
|
+
if (!id) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return this.cached(`table:${id}`, () =>
|
|
81
|
+
this.db.get('SELECT * FROM tables WHERE id = ?', [id]));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async findHall(id) {
|
|
85
|
+
if (!id) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return this.cached(`hall:${id}`, () =>
|
|
89
|
+
this.db.get('SELECT * FROM halls WHERE id = ?', [id]));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async findTeamUser(id) {
|
|
93
|
+
if (!id) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return this.db.get('SELECT * FROM team_users WHERE id = ?', [id]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** serving_name в ресурсе — это имя пользователя за team_user, а не сама запись сотрудника. */
|
|
100
|
+
async findTeamUserWithUser(id) {
|
|
101
|
+
if (!id) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return this.cached(`teamUserWithUser:${id}`, () => this.db.get(
|
|
105
|
+
`SELECT tu.*, u.name AS user_name
|
|
106
|
+
FROM team_users tu
|
|
107
|
+
LEFT JOIN users u ON u.id = tu.user_id
|
|
108
|
+
WHERE tu.id = ?`,
|
|
109
|
+
[id]
|
|
110
|
+
));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async findCourierRole(teamId) {
|
|
114
|
+
// Сравнение через параметр, а не литерал 1: в Postgres колонка boolean,
|
|
115
|
+
// в SQLite — integer, и драйвер приводит true к 1 сам.
|
|
116
|
+
return this.db.get('SELECT * FROM roles WHERE team_id = ? AND is_courier = ?', [teamId, true]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async findFirstTeamUserByRole(roleId) {
|
|
120
|
+
if (!roleId) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return this.db.get('SELECT * FROM team_users WHERE role_id = ?', [roleId]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async findUnitByCode(code) {
|
|
127
|
+
return this.cached(`unitByCode:${code}`, () =>
|
|
128
|
+
this.db.get('SELECT * FROM units WHERE code = ?', [code]));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async findModifierSet(id) {
|
|
132
|
+
return this.cached(`modifierSet:${id}`, () =>
|
|
133
|
+
this.db.get('SELECT * FROM modifier_sets WHERE id = ?', [id]));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Laravel берёт первую попавшуюся привязку набора к товару ради заголовка (title),
|
|
138
|
+
* без привязки к конкретному товару. Повторяем — иначе разойдутся подписи в чеке.
|
|
139
|
+
*/
|
|
140
|
+
async findProductModifierSetBySet(modifierSetId) {
|
|
141
|
+
return this.cached(`pmsBySet:${modifierSetId}`, () =>
|
|
142
|
+
this.db.get('SELECT * FROM product_modifier_sets WHERE modifier_set_id = ?', [modifierSetId]));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Есть ли у товара модификатор-таймер. От этого зависит, склеивать ли позицию с уже
|
|
147
|
+
* существующей: две «почасовые» позиции нельзя объединять — у них разное время старта.
|
|
148
|
+
*/
|
|
149
|
+
async hasTimerModifier(productId) {
|
|
150
|
+
return this.cached(`hasTimer:${productId}`, async () => {
|
|
151
|
+
const row = await this.db.get(
|
|
152
|
+
`SELECT 1 AS found
|
|
153
|
+
FROM product_modifier_sets pms
|
|
154
|
+
JOIN modifier_set_modifiers msm ON msm.modifier_set_id = pms.modifier_set_id
|
|
155
|
+
WHERE pms.product_id = ? AND msm.type = 'timer'
|
|
156
|
+
LIMIT 1`,
|
|
157
|
+
[productId]
|
|
158
|
+
);
|
|
159
|
+
return Boolean(row);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Категория для мутатора цеха; удалённая — как belongsTo с SoftDeletes, не находится. */
|
|
164
|
+
async findCategory(id) {
|
|
165
|
+
if (!id) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const alive = this.db.dialect === 'pgsql' ? ' AND deleted_at IS NULL' : '';
|
|
169
|
+
return this.cached(`category:${id}`, () =>
|
|
170
|
+
this.db.get(`SELECT id, product_workshop_id FROM product_categories WHERE id = ?${alive}`, [id]));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Мутатор Product::productWorkshopId: `ignore_workshop` гасит цех, пустой берётся у категории.
|
|
175
|
+
* Печать, KDS и отчёт Laravel читают цех только через него.
|
|
176
|
+
*/
|
|
177
|
+
async productWorkshopId(product) {
|
|
178
|
+
if (!product || toBool(product.ignore_workshop)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (product.product_workshop_id) {
|
|
182
|
+
return product.product_workshop_id;
|
|
183
|
+
}
|
|
184
|
+
const category = await this.findCategory(product.product_category_id);
|
|
185
|
+
return category ? category.product_workshop_id || null : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async findWorkshop(id) {
|
|
189
|
+
if (!id) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return this.cached(`workshop:${id}`, () =>
|
|
193
|
+
this.db.get('SELECT * FROM product_workshops WHERE id = ?', [id]));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = SnapshotRepository;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const {money, round2} = require('../support/num');
|
|
2
|
+
const {phpArray, deepZeroOneToBool} = require('../support/php');
|
|
3
|
+
const {nullableBool} = require('./casts');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Порты HallResource / HallTableResource (HallService::getHalls). Столы приходят готовыми,
|
|
7
|
+
* уже отсортированными по `order`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const isContainer = (value) => value !== null && typeof value === 'object';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Table::depositConfig — аксессор главнее каста 'array': json_decode + '0'/'1' → bool по всему
|
|
14
|
+
* дереву. Пусто → []. (JSON-скаляр в PHP уронил бы array_map — здесь тоже [].)
|
|
15
|
+
*/
|
|
16
|
+
const depositConfig = (raw) => {
|
|
17
|
+
if (!raw) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
let decoded = raw;
|
|
21
|
+
if (typeof raw === 'string') {
|
|
22
|
+
try {
|
|
23
|
+
decoded = JSON.parse(raw);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return isContainer(decoded) ? phpArray(deepZeroOneToBool(decoded)) : [];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hallTableResource = (table) => ({
|
|
32
|
+
id: table.id,
|
|
33
|
+
label: table.label,
|
|
34
|
+
has_deposit: nullableBool(table.has_deposit),
|
|
35
|
+
deposit_amount: round2(money(table.deposit_amount)),
|
|
36
|
+
min_guest_count: table.min_guest_count ?? null,
|
|
37
|
+
max_guest_count: table.max_guest_count ?? null,
|
|
38
|
+
is_reservable: nullableBool(table.is_reservable),
|
|
39
|
+
order: table.order ?? null,
|
|
40
|
+
config: phpArray(table.config),
|
|
41
|
+
data: phpArray(table.data),
|
|
42
|
+
deposit_config: depositConfig(table.deposit_config),
|
|
43
|
+
reserves: [],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const hallResource = (hall, tables = []) => ({
|
|
47
|
+
id: hall.id,
|
|
48
|
+
name: hall.name,
|
|
49
|
+
short_name: hall.short_name,
|
|
50
|
+
type: hall.type,
|
|
51
|
+
tables: tables.map(hallTableResource),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
module.exports = {hallResource, hallTableResource};
|