@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,199 @@
|
|
|
1
|
+
const SnapshotRepository = require('../repositories/SnapshotRepository');
|
|
2
|
+
const ReportService = require('./ReportService');
|
|
3
|
+
const telegram = require('../support/telegram');
|
|
4
|
+
const telegramReport = require('../support/telegramReport');
|
|
5
|
+
const {HttpError} = require('../errors');
|
|
6
|
+
const {money, toBool} = require('../support/num');
|
|
7
|
+
const {fromTimestamp, nowSql, toSql, diffInSeconds} = require('../support/time');
|
|
8
|
+
const {uuid, isUuid} = require('../support/ids');
|
|
9
|
+
const {isoDate} = require('../support/php');
|
|
10
|
+
const {DEFAULT_TIMEZONE} = require('../support/tz');
|
|
11
|
+
const {cashShiftLabels} = require('../resources/ShiftResource');
|
|
12
|
+
|
|
13
|
+
// ShiftService::sendTelegramReport — те же разбивки, что и в Laravel.
|
|
14
|
+
const REPORT_FORMATS = [
|
|
15
|
+
'by_table',
|
|
16
|
+
'by_workshop',
|
|
17
|
+
'by_employee',
|
|
18
|
+
'by_category',
|
|
19
|
+
'by_product',
|
|
20
|
+
'by_payment_method',
|
|
21
|
+
'by_order_source',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const param = (params, name, fallback = null) => {
|
|
25
|
+
const value = params ? params[name] : undefined;
|
|
26
|
+
return value === undefined || value === null ? fallback : value;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Кассовые и рабочие смены — порт App\Services\Platform\ShiftService и ShiftController.
|
|
31
|
+
* Один код для обоих контуров: облако (Postgres Laravel) и касса (SQLite, офлайн-обработчики).
|
|
32
|
+
*
|
|
33
|
+
* Колонка `deleted_at` у `shifts` есть в обеих схемах, поэтому мягко удалённые смены отсекаются
|
|
34
|
+
* всегда (SoftDeletes модели).
|
|
35
|
+
*
|
|
36
|
+
* Отчёт о закрытой смене в Telegram шлёт только облако: на кассе смена уедет наверх push'ем, и
|
|
37
|
+
* отчёт отправит облако при приёме (SyncService), как это делает Laravel.
|
|
38
|
+
*/
|
|
39
|
+
class ShiftService {
|
|
40
|
+
constructor({db, context}) {
|
|
41
|
+
this.db = db;
|
|
42
|
+
this.context = context;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Лениво: ядро (index.js) само требует сервисы, прямой require замкнул бы цикл. */
|
|
46
|
+
cloud() {
|
|
47
|
+
return require('../index').mode() === 'cloud' && this.db.dialect === 'pgsql';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ShiftService::getCashShift — открытая кассовая смена заведения, если смены включены. */
|
|
51
|
+
async cashShift() {
|
|
52
|
+
if (!toBool(this.context.setting('manage.use_cash_shift', false))) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return new SnapshotRepository(this.db).findOpenCashShift(this.context.teamId, this.context.companyId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ShiftService::getWorkShift. PHP ищет по заголовку employee-id (а кэширует по id сотрудника),
|
|
60
|
+
* поэтому на разблокировке другим сотрудником отдавал чужую смену. Здесь — по сотруднику.
|
|
61
|
+
*/
|
|
62
|
+
async workShift(teamUserId) {
|
|
63
|
+
if (!toBool(this.context.setting('manage.use_work_shift', false))) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return this.db.get(
|
|
67
|
+
`SELECT * FROM shifts
|
|
68
|
+
WHERE team_id = ? AND company_id = ? AND opened_by = ? AND type = 'work'
|
|
69
|
+
AND ended_at IS NULL AND deleted_at IS NULL
|
|
70
|
+
ORDER BY started_at DESC
|
|
71
|
+
LIMIT 1`,
|
|
72
|
+
[this.context.teamId, this.context.companyId, teamUserId]
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** ShiftController::getCashShifts — все смены заведения (без фильтра по типу, как в PHP). */
|
|
77
|
+
async cashShifts() {
|
|
78
|
+
const team = this.context.getTeam();
|
|
79
|
+
const rows = await this.db.all(
|
|
80
|
+
`SELECT id, started_at, ended_at FROM shifts
|
|
81
|
+
WHERE team_id = ? AND company_id = ? AND deleted_at IS NULL
|
|
82
|
+
ORDER BY started_at DESC`,
|
|
83
|
+
[this.context.teamId, this.context.companyId]
|
|
84
|
+
);
|
|
85
|
+
return {success: true, data: cashShiftLabels(rows, (team && team.timezone) || DEFAULT_TIMEZONE)};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ShiftController::openCashShift. `user` — модель сразу после create(): только заполненные
|
|
90
|
+
* атрибуты, в порядке Eloquent (fillable, затем id из HasUuids, затем метки времени).
|
|
91
|
+
* На кассе смена уедет в облако push'ем раньше чеков (sync_dirty).
|
|
92
|
+
*/
|
|
93
|
+
async openCashShift(params) {
|
|
94
|
+
const now = nowSql();
|
|
95
|
+
const shift = {
|
|
96
|
+
id: uuid(),
|
|
97
|
+
team_id: this.context.teamId,
|
|
98
|
+
company_id: this.context.companyId,
|
|
99
|
+
type: 'cash',
|
|
100
|
+
opened_by: this.context.employeeId,
|
|
101
|
+
// MoneyCast::set — round((float) $value, 3): пусто и мусор дают 0.
|
|
102
|
+
start_amount: money(param(params, 'amount', 0)),
|
|
103
|
+
end_amount: 0,
|
|
104
|
+
started_at: toSql(fromTimestamp(this.context.timestamp)),
|
|
105
|
+
created_at: now,
|
|
106
|
+
updated_at: now,
|
|
107
|
+
};
|
|
108
|
+
await this.db.insert('shifts', shift);
|
|
109
|
+
return {
|
|
110
|
+
success: true,
|
|
111
|
+
user: {
|
|
112
|
+
team_id: shift.team_id,
|
|
113
|
+
company_id: shift.company_id,
|
|
114
|
+
type: shift.type,
|
|
115
|
+
opened_by: shift.opened_by,
|
|
116
|
+
start_amount: shift.start_amount,
|
|
117
|
+
started_at: isoDate(shift.started_at),
|
|
118
|
+
id: shift.id,
|
|
119
|
+
updated_at: isoDate(shift.updated_at),
|
|
120
|
+
created_at: isoDate(shift.created_at),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* ShiftController::closeCashShift — смена по id, иначе открытая кассовая. Ответ пустой.
|
|
127
|
+
* Отличия от PHP: смена ищется только в своей команде; смены нет — 404, а не 500.
|
|
128
|
+
*/
|
|
129
|
+
async closeCashShift(params) {
|
|
130
|
+
const id = param(params, 'id');
|
|
131
|
+
let shift = id && isUuid(String(id))
|
|
132
|
+
? await this.db.get(
|
|
133
|
+
'SELECT * FROM shifts WHERE id = ? AND team_id = ? AND deleted_at IS NULL',
|
|
134
|
+
[String(id), this.context.teamId]
|
|
135
|
+
)
|
|
136
|
+
: null;
|
|
137
|
+
if (!shift) {
|
|
138
|
+
shift = await this.cashShift();
|
|
139
|
+
}
|
|
140
|
+
if (!shift) {
|
|
141
|
+
throw new HttpError(404, {success: false, message: 'Shift not found'});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const endedAt = toSql(fromTimestamp(this.context.timestamp));
|
|
145
|
+
await this.db.update('shifts', shift.id, {
|
|
146
|
+
closed_by: this.context.employeeId,
|
|
147
|
+
end_amount: money(param(params, 'amount', 0)),
|
|
148
|
+
ended_at: endedAt,
|
|
149
|
+
// ceil($shift->started_at->diffInMinutes($endAt)); отрицательное (часы терминала
|
|
150
|
+
// отстали) PHP записал бы как есть — здесь 0.
|
|
151
|
+
minutes: Math.max(0, Math.ceil(diffInSeconds(shift.started_at, endedAt) / 60)),
|
|
152
|
+
updated_at: nowSql(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (this.cloud()) {
|
|
156
|
+
// PHP слал отчёт внутри запроса (Telegram до 30 с держал ответ кассе). Здесь — после.
|
|
157
|
+
const settings = this.context.settings();
|
|
158
|
+
setImmediate(() => {
|
|
159
|
+
ShiftService.sendTelegramReport({db: this.db, context: this.context}, shift.id, settings);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* ShiftService::sendTelegramReport — отчёт по закрытой кассовой смене в чат команды.
|
|
167
|
+
* Статический: он же нужен приёму смен с кассы. Никогда не бросает, ждёт только сборку
|
|
168
|
+
* текста — отправка уходит в очередь чата (`support/telegram.js`).
|
|
169
|
+
*
|
|
170
|
+
* @param {{db, context}} services контекст команды и заведения смены
|
|
171
|
+
* @returns {Promise<boolean>} поставлен ли отчёт в очередь
|
|
172
|
+
*/
|
|
173
|
+
static async sendTelegramReport({db, context}, shiftId, settings) {
|
|
174
|
+
const chatId = telegram.chatIdOf(settings);
|
|
175
|
+
if (!chatId) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const reports = new ReportService({db, context});
|
|
180
|
+
const timeZone = reports.timezone();
|
|
181
|
+
const params = {type: 'cash_shift', shift: shiftId, formats: REPORT_FORMATS};
|
|
182
|
+
const window = await reports.resolveWindow(params, timeZone);
|
|
183
|
+
if (!window.shift) {
|
|
184
|
+
console.warn(`[telegram] отчёт смены ${shiftId}: смена не найдена`);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const report = await reports.generate(window, params, timeZone);
|
|
188
|
+
telegram.send(chatId, telegramReport.render(report));
|
|
189
|
+
return true;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.warn(`[telegram] отчёт смены ${shiftId}: не собран — ${e.message}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ShiftService.REPORT_FORMATS = REPORT_FORMATS;
|
|
198
|
+
|
|
199
|
+
module.exports = ShiftService;
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
const {round, qty, toFloat, toBool} = require('../support/num');
|
|
2
|
+
const {nowSql} = require('../support/time');
|
|
3
|
+
const {uuid} = require('../support/ids');
|
|
4
|
+
const {withLock} = require('../support/lock');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Склад при продаже — порт App\Services\Platform\StorageService + StockService::register +
|
|
8
|
+
* UnitConverter (cafepos-admin).
|
|
9
|
+
*
|
|
10
|
+
* Работает только в облачном контуре (`CORE_MODE=cloud`): там ядро пишет в ту же базу, что и
|
|
11
|
+
* Laravel, и событие можно возить между ними через CORE_EVENTS — остатки обязаны меняться
|
|
12
|
+
* одинаково. Поэтому правила учёта общие с PHP, и правятся они в обоих местах сразу. На кассе
|
|
13
|
+
* (SQLite) складских таблиц нет: там это no-op, а движения по проданному облако делает само при
|
|
14
|
+
* приёме чеков (SyncService::moveStock).
|
|
15
|
+
*
|
|
16
|
+
* Правило: по позиции со склада списано held(quantity, workshop_print_quantity) единиц.
|
|
17
|
+
* Добавили — списываем. Сняли то, что кухня ещё не получала, — возвращаем сразу (rollback).
|
|
18
|
+
* Сняли напечатанное — возвращаем, когда отмена уйдёт в цех или закроется чек (refund в
|
|
19
|
+
* OrderService.syncWorkshopCount). Тех. карта блюда и количество модификатора — на одну порцию,
|
|
20
|
+
* движение умножается на количество позиции. Прежние ошибки учёта (блюдо списывало одну порцию
|
|
21
|
+
* на любое количество, возврат при печати считался от напечатанного и повторялся на каждой
|
|
22
|
+
* печати) исправлены по этому правилу и здесь, и в PHP.
|
|
23
|
+
*
|
|
24
|
+
* Отличия от PHP, не меняющие результат:
|
|
25
|
+
* - остаток меняется атомарным `UPDATE … quantity + ?`, а не чтением и записью (в PHP два
|
|
26
|
+
* параллельных движения по одному товару теряли одно из них);
|
|
27
|
+
* - товар без единицы, удалённый ингредиент, модификатор без товара: PHP падал 500 уже после
|
|
28
|
+
* вставки позиции, здесь такое движение пропускается с предупреждением, чек не срывается.
|
|
29
|
+
*/
|
|
30
|
+
const TYPE_PRODUCT = 'product';
|
|
31
|
+
const TYPE_DISH = 'dish';
|
|
32
|
+
|
|
33
|
+
const TX_SALE = 'sale';
|
|
34
|
+
const TX_SALE_ROLLBACK = 'sale_rollback';
|
|
35
|
+
const TX_SALE_REFUND = 'sale_refund';
|
|
36
|
+
|
|
37
|
+
const PIECE_CODE = 'piece';
|
|
38
|
+
const LITER_CODE = 'liter';
|
|
39
|
+
const KILOGRAM_CODE = 'kilogram';
|
|
40
|
+
const MILLILITER_CODE = 'milliliter';
|
|
41
|
+
const GRAM_CODE = 'gram';
|
|
42
|
+
|
|
43
|
+
// Справочник единиц общий для всех команд и меняется в админке раз в год. PHP держит его
|
|
44
|
+
// статикой на запрос; здесь — на процесс с коротким сроком жизни.
|
|
45
|
+
const UNITS_TTL_MS = 60000;
|
|
46
|
+
let unitsCache = null;
|
|
47
|
+
|
|
48
|
+
const loadUnits = async (db) => {
|
|
49
|
+
if (unitsCache && unitsCache.until > Date.now()) {
|
|
50
|
+
return unitsCache.units;
|
|
51
|
+
}
|
|
52
|
+
// Порядок нужен только base(): у единицы без родителя берётся первая дочерняя.
|
|
53
|
+
const rows = await db.all(
|
|
54
|
+
'SELECT id, code, parent_id, unit FROM units WHERE deleted_at IS NULL ORDER BY created_at, id'
|
|
55
|
+
);
|
|
56
|
+
const units = new Map(rows.map((row) => [String(row.id), row]));
|
|
57
|
+
unitsCache = {units, until: Date.now() + UNITS_TTL_MS};
|
|
58
|
+
return units;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** UnitConverter::base — единица с родителем уже базовая, иначе её первая дочерняя. */
|
|
62
|
+
const baseUnit = (units, unit) => {
|
|
63
|
+
if (unit.parent_id) {
|
|
64
|
+
return unit;
|
|
65
|
+
}
|
|
66
|
+
for (const candidate of units.values()) {
|
|
67
|
+
if (candidate.parent_id && String(candidate.parent_id) === String(unit.id)) {
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return unit;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** UnitConverter::factor / toBase. */
|
|
75
|
+
const toBase = (units, value, unit) => {
|
|
76
|
+
const base = baseUnit(units, unit);
|
|
77
|
+
let factor = 1;
|
|
78
|
+
if (String(base.id) !== String(unit.id)) {
|
|
79
|
+
const baseSize = toFloat(base.unit) || 1;
|
|
80
|
+
const ownSize = toFloat(unit.unit) || 1;
|
|
81
|
+
factor = baseSize <= 0 ? 1 : ownSize / baseSize;
|
|
82
|
+
}
|
|
83
|
+
return qty(toFloat(value) * factor);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Хелпер getByUnit() из bootstrap/helpers.php. */
|
|
87
|
+
const getByUnit = (value, fromUnit, toUnit) => {
|
|
88
|
+
const num = toFloat(value);
|
|
89
|
+
if (toUnit.code === PIECE_CODE || String(fromUnit.id) === String(toUnit.id)) {
|
|
90
|
+
return round(num, 3);
|
|
91
|
+
}
|
|
92
|
+
if (fromUnit.code === KILOGRAM_CODE || fromUnit.code === LITER_CODE) {
|
|
93
|
+
return round(num * 1000, 3);
|
|
94
|
+
}
|
|
95
|
+
if (fromUnit.code === GRAM_CODE || fromUnit.code === MILLILITER_CODE) {
|
|
96
|
+
return round(num / 1000, 3);
|
|
97
|
+
}
|
|
98
|
+
return round(num, 3);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Пропуск движения: PHP здесь падал бы, чек при этом уже изменён. */
|
|
102
|
+
class SkipMove extends Error {}
|
|
103
|
+
|
|
104
|
+
class StorageService {
|
|
105
|
+
constructor({db, context}) {
|
|
106
|
+
this.db = db;
|
|
107
|
+
this.context = context;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Лениво: ядро (index.js) само требует сервисы заказа, прямой require замкнул бы цикл. */
|
|
111
|
+
active() {
|
|
112
|
+
return require('../index').mode() === 'cloud' && this.db.dialect === 'pgsql';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Сколько единиц позиции должно лежать списанным: всё, что в чеке, и напечатанное, но снятое,
|
|
117
|
+
* пока отмена не ушла в цех. Порт StorageService::held.
|
|
118
|
+
*/
|
|
119
|
+
static held(quantity, printed) {
|
|
120
|
+
return round(Math.max(toFloat(quantity), toFloat(printed), 0), 3);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Довести списание позиции от `before` до `after` (оба — held()): больше — продажа, меньше —
|
|
125
|
+
* возврат типом `returnType` ('rollback' — снятие до печати, 'refund' — отмена ушла в цех).
|
|
126
|
+
* Вне облачного контура — no-op, как и сами движения.
|
|
127
|
+
*/
|
|
128
|
+
async settle(orderProduct, before, after, returnType = 'rollback') {
|
|
129
|
+
if (!this.active()) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const delta = round(toFloat(after) - toFloat(before), 3);
|
|
133
|
+
if (delta > 0) {
|
|
134
|
+
await this.withdraw(orderProduct, delta);
|
|
135
|
+
} else if (delta < 0) {
|
|
136
|
+
if (returnType === 'refund') {
|
|
137
|
+
await this.refund(orderProduct, -delta);
|
|
138
|
+
} else {
|
|
139
|
+
await this.rollback(orderProduct, -delta);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
withdraw(orderProduct, quantity = 1) {
|
|
145
|
+
return this.run(orderProduct, quantity, TX_SALE, -1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
rollback(orderProduct, quantity = 1) {
|
|
149
|
+
return this.run(orderProduct, quantity, TX_SALE_ROLLBACK, 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
refund(orderProduct, quantity = 1) {
|
|
153
|
+
return this.run(orderProduct, quantity, TX_SALE_REFUND, 1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Смена модификаторов позиции (orderProducts.updateModifiers): по старым модификаторам
|
|
158
|
+
* списанное возвращается (rollback), по новым (уже записанным) списывается, оба раза на
|
|
159
|
+
* held позиции. Тех. карта не трогается; двигаются только модификаторы блюда — как в apply().
|
|
160
|
+
*
|
|
161
|
+
* @param {Array} previous строки order_product_modifier_sets до замены
|
|
162
|
+
*/
|
|
163
|
+
async swapModifiers(orderProduct, previous, held) {
|
|
164
|
+
if (!this.active() || !(round(toFloat(held), 3) > 0)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const id = typeof orderProduct === 'object' && orderProduct ? orderProduct.id : orderProduct;
|
|
168
|
+
await this.run(orderProduct, held, TX_SALE_ROLLBACK, 1, previous);
|
|
169
|
+
await this.run(orderProduct, held, TX_SALE, -1, await this.liveModifiers(id));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async liveModifiers(orderProductId) {
|
|
173
|
+
return this.db.all(
|
|
174
|
+
`SELECT opms.quantity, opms.modifier_set_modifier_id
|
|
175
|
+
FROM order_product_modifier_sets opms
|
|
176
|
+
WHERE opms.order_product_id = ? AND opms.deleted_at IS NULL`,
|
|
177
|
+
[orderProductId]
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Общая часть withdraw/rollback/refund. Ошибка склада чек не срывает: позиция уже
|
|
183
|
+
* записана, а 500 на этом месте в PHP оставлял чек изменённым без ответа терминалу.
|
|
184
|
+
*
|
|
185
|
+
* @param {Array|null} modifiers задан — движение только по этим модификаторам (swapModifiers)
|
|
186
|
+
*/
|
|
187
|
+
async run(orderProduct, quantity, type, sign, modifiers = null) {
|
|
188
|
+
if (!this.active()) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
if (modifiers) {
|
|
193
|
+
await this.applyModifiers(orderProduct, modifiers, toFloat(quantity), type, sign);
|
|
194
|
+
} else {
|
|
195
|
+
await this.apply(orderProduct, toFloat(quantity), type, sign);
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const id = typeof orderProduct === 'object' && orderProduct ? orderProduct.id : orderProduct;
|
|
199
|
+
console.warn(`[storage] ${type} для позиции ${id} не проведён: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Позиция, её товар, склад и единицы; null — склада нет, двигать некуда. */
|
|
205
|
+
async prepare(orderProductOrId) {
|
|
206
|
+
const orderProductId = typeof orderProductOrId === 'object' && orderProductOrId
|
|
207
|
+
? orderProductOrId.id
|
|
208
|
+
: orderProductOrId;
|
|
209
|
+
// Позицию перечитываем всегда: нужен product_id, а строку из сервиса могли уже изменить.
|
|
210
|
+
// withTrashed — как связь OrderProduct::product и findOrFail по id (мягко удалённая
|
|
211
|
+
// позиция в PHP тоже находилась бы только через withTrashed на product).
|
|
212
|
+
const orderProduct = await this.db.get(
|
|
213
|
+
'SELECT id, product_id FROM order_products WHERE id = ?',
|
|
214
|
+
[orderProductId]
|
|
215
|
+
);
|
|
216
|
+
if (!orderProduct) {
|
|
217
|
+
throw new Error('позиция не найдена');
|
|
218
|
+
}
|
|
219
|
+
const product = await this.db.get(
|
|
220
|
+
'SELECT id, type, unit_id FROM products WHERE id = ?',
|
|
221
|
+
[orderProduct.product_id]
|
|
222
|
+
);
|
|
223
|
+
if (!product) {
|
|
224
|
+
throw new Error('товар не найден');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const storageId = await this.storageId();
|
|
228
|
+
if (!storageId) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const units = await loadUnits(this.db);
|
|
232
|
+
return {orderProduct, product, storageId, units};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Только модификаторы из списка — тех. карту блюда и сам товар не трогает. */
|
|
236
|
+
async applyModifiers(orderProductOrId, modifiers, quantity, type, sign) {
|
|
237
|
+
const target = await this.prepare(orderProductOrId);
|
|
238
|
+
if (!target || target.product.type !== TYPE_DISH) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await this.moveModifiers(target, modifiers, quantity, type, sign);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async apply(orderProductOrId, quantity, type, sign) {
|
|
245
|
+
const target = await this.prepare(orderProductOrId);
|
|
246
|
+
if (!target) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const {orderProduct, product, storageId, units} = target;
|
|
250
|
+
|
|
251
|
+
if (product.type === TYPE_PRODUCT) {
|
|
252
|
+
await this.move(orderProduct, product, type, sign * quantity, storageId, units);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (product.type !== TYPE_DISH) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Тех. карта — на одну порцию, поэтому всё умножается на количество позиции.
|
|
260
|
+
const ingredients = await this.db.all(
|
|
261
|
+
`SELECT pi.gross, pi.unit_id, pi.ingredient_id
|
|
262
|
+
FROM product_ingredients pi
|
|
263
|
+
WHERE pi.product_id = ? AND pi.deleted_at IS NULL`,
|
|
264
|
+
[product.id]
|
|
265
|
+
);
|
|
266
|
+
for (const ingredient of ingredients) {
|
|
267
|
+
await this.safely(async () => {
|
|
268
|
+
const ingredientProduct = await this.liveProduct(ingredient.ingredient_id);
|
|
269
|
+
const fromUnit = this.unit(units, ingredient.unit_id, 'единица ингредиента');
|
|
270
|
+
const toUnit = this.unit(units, ingredientProduct.unit_id, 'единица товара-ингредиента');
|
|
271
|
+
const perPortion = getByUnit(toFloat(ingredient.gross), fromUnit, toUnit);
|
|
272
|
+
const amount = round(quantity * perPortion, 3);
|
|
273
|
+
await this.move(orderProduct, ingredientProduct, type, sign * amount, storageId, units);
|
|
274
|
+
}, type, orderProduct.id);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await this.moveModifiers(target, await this.liveModifiers(orderProduct.id), quantity, type, sign);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Движения по выбранным модификаторам блюда (`quantity`, `modifier_set_modifier_id`). */
|
|
281
|
+
async moveModifiers({orderProduct, storageId, units}, modifiers, quantity, type, sign) {
|
|
282
|
+
for (const selected of modifiers) {
|
|
283
|
+
await this.safely(async () => {
|
|
284
|
+
const modifier = await this.db.get(
|
|
285
|
+
`SELECT id, product_id, unit_id, gross, is_without_debit
|
|
286
|
+
FROM modifier_set_modifiers WHERE id = ? AND deleted_at IS NULL`,
|
|
287
|
+
[selected.modifier_set_modifier_id]
|
|
288
|
+
);
|
|
289
|
+
if (!modifier) {
|
|
290
|
+
throw new SkipMove('модификатор удалён');
|
|
291
|
+
}
|
|
292
|
+
if (toBool(modifier.is_without_debit)) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const modifierProduct = await this.liveProduct(modifier.product_id);
|
|
296
|
+
const fromUnit = this.unit(units, modifier.unit_id, 'единица модификатора');
|
|
297
|
+
const toUnit = this.unit(units, modifierProduct.unit_id, 'единица товара модификатора');
|
|
298
|
+
// quantity модификатора — на одну порцию блюда.
|
|
299
|
+
const perPortion = toFloat(selected.quantity) * getByUnit(toFloat(modifier.gross), fromUnit, toUnit);
|
|
300
|
+
const amount = round(quantity * perPortion, 3);
|
|
301
|
+
await this.move(orderProduct, modifierProduct, type, sign * amount, storageId, units);
|
|
302
|
+
}, type, orderProduct.id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Одно движение блюда не мешает остальным ингредиентам. */
|
|
307
|
+
async safely(fn, type, orderProductId) {
|
|
308
|
+
try {
|
|
309
|
+
await fn();
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.warn(`[storage] ${type} для позиции ${orderProductId}: пропущено движение — ${e.message}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async liveProduct(id) {
|
|
316
|
+
const product = id
|
|
317
|
+
? await this.db.get(
|
|
318
|
+
'SELECT id, type, unit_id FROM products WHERE id = ? AND deleted_at IS NULL',
|
|
319
|
+
[id]
|
|
320
|
+
)
|
|
321
|
+
: null;
|
|
322
|
+
if (!product) {
|
|
323
|
+
throw new SkipMove('товар удалён или не задан');
|
|
324
|
+
}
|
|
325
|
+
return product;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
unit(units, id, what) {
|
|
329
|
+
const unit = id ? units.get(String(id)) : null;
|
|
330
|
+
if (!unit) {
|
|
331
|
+
throw new SkipMove(`${what} не задана`);
|
|
332
|
+
}
|
|
333
|
+
return unit;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Склад заведения (`company_storage`), иначе первый склад команды. */
|
|
337
|
+
async storageId() {
|
|
338
|
+
const {teamId, companyId} = this.context;
|
|
339
|
+
if (companyId) {
|
|
340
|
+
const storageId = await this.db.value(
|
|
341
|
+
'SELECT storage_id FROM company_storage WHERE company_id = ? LIMIT 1',
|
|
342
|
+
[companyId]
|
|
343
|
+
);
|
|
344
|
+
if (storageId) {
|
|
345
|
+
return storageId;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!teamId) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return await this.db.value(
|
|
352
|
+
'SELECT id FROM storages WHERE team_id = ? AND deleted_at IS NULL LIMIT 1',
|
|
353
|
+
[teamId]
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** StockService::register без суммы (продажа себестоимость не меняет) + applyToStock. */
|
|
358
|
+
async move(orderProduct, product, type, quantity, storageId, units) {
|
|
359
|
+
// Нулевое движение не пишется (как в PHP — до проверки единицы).
|
|
360
|
+
if (round(quantity, 3) === 0) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const teamId = this.context.teamId;
|
|
364
|
+
const productUnit = product.unit_id ? units.get(String(product.unit_id)) : null;
|
|
365
|
+
if (!productUnit) {
|
|
366
|
+
// В PHP: unit_id движения = null → нарушение NOT NULL, 500.
|
|
367
|
+
throw new SkipMove(`у товара ${product.id} нет единицы`);
|
|
368
|
+
}
|
|
369
|
+
const baseQuantity = toBase(units, quantity, productUnit);
|
|
370
|
+
const timestamp = nowSql();
|
|
371
|
+
|
|
372
|
+
await this.db.insert('storage_stock_transactions', {
|
|
373
|
+
id: uuid(),
|
|
374
|
+
type,
|
|
375
|
+
storage_id: storageId,
|
|
376
|
+
team_id: teamId,
|
|
377
|
+
product_id: product.id,
|
|
378
|
+
unit_id: baseUnit(units, productUnit).id,
|
|
379
|
+
quantity: baseQuantity,
|
|
380
|
+
form_unit_id: productUnit.id,
|
|
381
|
+
form_quantity: qty(quantity),
|
|
382
|
+
amount: null,
|
|
383
|
+
datetime: timestamp,
|
|
384
|
+
external_id: orderProduct.id,
|
|
385
|
+
created_at: timestamp,
|
|
386
|
+
updated_at: timestamp,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Строка остатка создаётся при первом движении — под локом, чтобы два движения
|
|
390
|
+
// одного товара не создали две. Межпроцессной защиты нет, как и в PHP.
|
|
391
|
+
await withLock(`stock:${teamId}:${product.id}:${storageId}`, async () => {
|
|
392
|
+
const stockId = await this.db.value(
|
|
393
|
+
`SELECT id FROM storage_stocks
|
|
394
|
+
WHERE team_id = ? AND product_id = ? AND storage_id = ? AND deleted_at IS NULL
|
|
395
|
+
LIMIT 1`,
|
|
396
|
+
[teamId, product.id, storageId]
|
|
397
|
+
);
|
|
398
|
+
if (stockId) {
|
|
399
|
+
await this.db.run(
|
|
400
|
+
'UPDATE storage_stocks SET quantity = ROUND(quantity + ?, 3), updated_at = ? WHERE id = ?',
|
|
401
|
+
[baseQuantity, timestamp, stockId]
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
await this.db.insert('storage_stocks', {
|
|
406
|
+
id: uuid(),
|
|
407
|
+
team_id: teamId,
|
|
408
|
+
product_id: product.id,
|
|
409
|
+
storage_id: storageId,
|
|
410
|
+
unit_id: product.unit_id,
|
|
411
|
+
quantity: qty(baseQuantity),
|
|
412
|
+
cost: 0,
|
|
413
|
+
created_at: timestamp,
|
|
414
|
+
updated_at: timestamp,
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
StorageService.getByUnit = getByUnit;
|
|
421
|
+
StorageService.flushUnits = () => {
|
|
422
|
+
unitsCache = null;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
module.exports = StorageService;
|