work-license-server 1.0.7 → 1.0.9

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/src/server.js CHANGED
@@ -17,7 +17,9 @@
17
17
  * - GET /api/admin/overview — Сводка по лицензиям, заказам и тикетам.
18
18
  */
19
19
  import http from 'node:http';
20
+ import tls from 'node:tls';
20
21
  import fs from 'node:fs';
22
+ import path from 'node:path';
21
23
  import crypto from 'node:crypto';
22
24
  import { fileURLToPath } from 'node:url';
23
25
  import {
@@ -116,7 +118,106 @@ function sendHtml(res, status, html) {
116
118
  res.end(html);
117
119
  }
118
120
 
121
+ function loadSmtpConfig() {
122
+ if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
123
+ return {
124
+ host: process.env.SMTP_HOST,
125
+ port: Number(process.env.SMTP_PORT || 465),
126
+ user: process.env.SMTP_USER,
127
+ pass: process.env.SMTP_PASS,
128
+ };
129
+ }
130
+ const candidates = [
131
+ path.resolve(process.cwd(), '#system/mail.json'),
132
+ path.resolve(process.cwd(), '../#system/mail.json'),
133
+ fileURLToPath(new URL('../../#system/mail.json', import.meta.url)),
134
+ ];
135
+ for (const p of candidates) {
136
+ try {
137
+ if (fs.existsSync(p)) {
138
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
139
+ if (j?.host && j?.auth?.user && j?.auth?.pass) {
140
+ return {
141
+ host: j.host,
142
+ port: Number(j.port || 465),
143
+ user: j.auth.user,
144
+ pass: j.auth.pass,
145
+ };
146
+ }
147
+ }
148
+ }
149
+ catch { /* ignore */ }
150
+ }
151
+ return {
152
+ host: 'smtp.yandex.ru',
153
+ port: 465,
154
+ user: 'proman1968.perepelkin@yandex.ru',
155
+ pass: 'zifeomdqijldyece',
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Отправка реального письма с 6-значным кодом подтверждения по SMTPS (TLS 465, без внешних зависимостей).
161
+ */
162
+ async function sendOtpEmail({ to, code }) {
163
+ const cfg = loadSmtpConfig();
164
+ const subjectB64 = Buffer.from(`Код подтверждения ODANT-WORK: ${code}`, 'utf8').toString('base64');
165
+ const htmlBody = [
166
+ '<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px;">',
167
+ '<h2 style="margin:0 0 12px;color:#0f172a;">Вход в Личный кабинет ODANT-WORK</h2>',
168
+ '<p style="color:#475569;font-size:15px;">Ваш одноразовый код подтверждения:</p>',
169
+ `<div style="font-size:36px;font-weight:800;letter-spacing:8px;text-align:center;padding:16px;background:#f8fafc;border-radius:10px;color:#0284c7;">${code}</div>`,
170
+ '<p style="color:#64748b;font-size:13px;margin-top:16px;">Код действителен 10 минут. Если вы не запрашивали вход на work.odant.org, просто проигнорируйте это письмо.</p>',
171
+ '</div>',
172
+ ].join('\r\n');
173
+ const mimeMsg = [
174
+ `From: "ODANT-WORK" <${cfg.user}>`,
175
+ `To: <${to}>`,
176
+ `Subject: =?UTF-8?B?${subjectB64}?=`,
177
+ 'MIME-Version: 1.0',
178
+ 'Content-Type: text/html; charset=utf-8',
179
+ 'Content-Transfer-Encoding: base64',
180
+ '',
181
+ Buffer.from(htmlBody, 'utf8').toString('base64'),
182
+ '.',
183
+ '',
184
+ ].join('\r\n');
185
+
186
+ return new Promise((resolve, reject) => {
187
+ const sock = tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.host, timeout: 12000 });
188
+ let step = 0;
189
+ let buf = '';
190
+ const sendCmd = (line) => sock.write(line + '\r\n');
191
+ sock.on('timeout', () => { sock.destroy(); reject(new Error('Таймаут соединения с почтовым сервером')); });
192
+ sock.on('error', (err) => reject(err));
193
+ sock.on('data', (chunk) => {
194
+ buf += chunk.toString('utf8');
195
+ const lines = buf.split('\r\n');
196
+ buf = lines.pop() || '';
197
+ for (const line of lines) {
198
+ if (!/^\d{3} /.test(line)) continue;
199
+ const status = Number(line.slice(0, 3));
200
+ if (status >= 400) {
201
+ sock.destroy();
202
+ reject(new Error('Ошибка SMTP: ' + line));
203
+ return;
204
+ }
205
+ if (step === 0) { step = 1; sendCmd('EHLO work.odant.org'); }
206
+ else if (step === 1) { step = 2; sendCmd('AUTH LOGIN'); }
207
+ else if (step === 2) { step = 3; sendCmd(Buffer.from(cfg.user, 'utf8').toString('base64')); }
208
+ else if (step === 3) { step = 4; sendCmd(Buffer.from(cfg.pass, 'utf8').toString('base64')); }
209
+ else if (step === 4) { step = 5; sendCmd(`MAIL FROM:<${cfg.user}>`); }
210
+ else if (step === 5) { step = 6; sendCmd(`RCPT TO:<${to}>`); }
211
+ else if (step === 6) { step = 7; sendCmd('DATA'); }
212
+ else if (step === 7) { step = 8; sock.write(mimeMsg); }
213
+ else if (step === 8) { step = 9; sendCmd('QUIT'); sock.end(); resolve({ ok: true }); }
214
+ }
215
+ });
216
+ });
217
+ }
218
+
119
219
  export function createLicenseServerApp(options = {}) {
220
+ const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
120
221
  const store = options.store || new LicenseStore({
121
222
  dataDir: options.dataDir || DEFAULT_DATA_DIR,
122
223
  adminLogin: options.adminLogin,
@@ -247,7 +348,57 @@ export function createLicenseServerApp(options = {}) {
247
348
  return sendJson(res, 200, PUBLIC_OFERTA);
248
349
  }
249
350
 
250
- // 2.0 Вход / регистрация пользователя и подписание публичной оферты ПЭП
351
+ // 2.0 Отправка одноразового 6-значного кода подтверждения на Email и проверка кода
352
+ if (req.method === 'POST' && pathname === '/api/auth/send-code') {
353
+ const body = await readJsonBody(req);
354
+ const cleanEmail = String(body.email || '').trim().toLowerCase();
355
+ if (!cleanEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail))
356
+ return sendJson(res, 400, { error: 'Укажите корректный email адрес' });
357
+ const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
358
+ pendingOtpCodes.set(cleanEmail, {
359
+ code,
360
+ expiresAt: Date.now() + 10 * 60_000,
361
+ attempts: 0,
362
+ });
363
+ if (!options.skipEmailSend) {
364
+ await sendOtpEmail({ to: cleanEmail, code });
365
+ }
366
+ return sendJson(res, 200, {
367
+ ok: true,
368
+ email: cleanEmail,
369
+ message: `На адрес ${cleanEmail} отправлено письмо с 6-значным кодом подтверждения.`,
370
+ });
371
+ }
372
+
373
+ if (req.method === 'POST' && pathname === '/api/auth/verify-code') {
374
+ const body = await readJsonBody(req);
375
+ const cleanEmail = String(body.email || '').trim().toLowerCase();
376
+ const code = String(body.code || '').trim();
377
+ const entry = pendingOtpCodes.get(cleanEmail);
378
+ if (!entry || Date.now() > entry.expiresAt) {
379
+ pendingOtpCodes.delete(cleanEmail);
380
+ return sendJson(res, 400, { error: 'Срок действия кода истёк или код не запрашивался. Запросите новый код.' });
381
+ }
382
+ entry.attempts += 1;
383
+ if (entry.attempts > 5) {
384
+ pendingOtpCodes.delete(cleanEmail);
385
+ return sendJson(res, 400, { error: 'Превышено число попыток ввода кода. Запросите новый код.' });
386
+ }
387
+ const a = Buffer.from(entry.code);
388
+ const b = Buffer.from(code);
389
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
390
+ return sendJson(res, 400, { error: 'Неверный код подтверждения. Проверьте письмо и попробуйте снова.' });
391
+ }
392
+ pendingOtpCodes.delete(cleanEmail);
393
+ const user = store.registerOrLoginUser({
394
+ email: cleanEmail,
395
+ ofertaAccepted: true,
396
+ hubUrl: body.hubUrl || reqOrigin,
397
+ });
398
+ return sendJson(res, 200, user);
399
+ }
400
+
401
+ // 2.0.0 Прямая регистрация по API (для серверных интеграций и автотестов)
251
402
  if (req.method === 'POST' && (pathname === '/api/auth/login' || pathname === '/api/auth/register')) {
252
403
  const body = await readJsonBody(req);
253
404
  const user = store.registerOrLoginUser({
@@ -327,7 +478,8 @@ export function createLicenseServerApp(options = {}) {
327
478
  const user = ensureCustomerOferta(req, body);
328
479
  const reserved = store.reserveFranchiseSlot({
329
480
  ...body,
330
- companyName: body.companyName || user?.companyName || '',
481
+ ownerAccountId: user?.userId || body.ownerAccountId || '',
482
+ companyName: body.companyName || user?.companyName || user?.email || '',
331
483
  inn: body.inn || user?.inn || '',
332
484
  email: body.email || user?.email || '',
333
485
  });
@@ -340,6 +492,90 @@ export function createLicenseServerApp(options = {}) {
340
492
  });
341
493
  }
342
494
 
495
+ // 2.6.1 Добавление позиции («Купить» или «Зарезервировать») как неоплаченного заказа в Личный кабинет
496
+ if (req.method === 'POST' && pathname === '/api/cabinet/orders/draft') {
497
+ const body = await readJsonBody(req);
498
+ const user = ensureCustomerOferta(req, body);
499
+ let order;
500
+ let reservation = null;
501
+ if (body.kind === 'franchise_reservation' || body.regionCode) {
502
+ const reserved = store.reserveFranchiseSlot({
503
+ regionCode: body.regionCode,
504
+ ownerAccountId: user?.userId || '',
505
+ companyName: body.companyName || user?.companyName || user?.email || 'Партнёр ODANT-WORK',
506
+ inn: body.inn || user?.inn || '',
507
+ email: body.email || user?.email || '',
508
+ });
509
+ order = reserved.order;
510
+ reservation = reserved.reservation;
511
+ }
512
+ else {
513
+ order = store.createOrder({
514
+ ...body,
515
+ licenseId: body.licenseId || user?.licenseId || '',
516
+ ownerAccountId: user?.userId || '',
517
+ customer: {
518
+ name: body.customer?.name || user?.companyName || user?.email || '',
519
+ inn: body.customer?.inn || user?.inn || '',
520
+ email: body.customer?.email || user?.email || '',
521
+ phone: body.customer?.phone || user?.phone || '',
522
+ },
523
+ });
524
+ }
525
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
526
+ return sendJson(res, 200, {
527
+ ok: true,
528
+ order,
529
+ reservation,
530
+ user: updatedUser,
531
+ });
532
+ }
533
+
534
+ // 2.6.2 Оплата существующего заказа из Личного кабинета (через ЮKassa или по счёту-договору для юрлиц)
535
+ if (req.method === 'POST' && pathname === '/api/cabinet/orders/pay') {
536
+ const body = await readJsonBody(req);
537
+ const user = ensureCustomerOferta(req, body);
538
+ const order = store.prepareOrderPayment(body.orderId, {
539
+ paymentMethod: body.paymentMethod || 'yookassa',
540
+ companyName: body.companyName || user?.companyName || '',
541
+ inn: body.inn || user?.inn || '',
542
+ });
543
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
544
+ if (order.paymentMethod === 'invoice') {
545
+ return sendJson(res, 200, {
546
+ ok: true,
547
+ orderId: order.orderId,
548
+ paymentMethod: 'invoice',
549
+ invoiceNumber: order.invoiceNumber,
550
+ contractNumber: order.contractNumber,
551
+ invoiceUrl: `${reqOrigin}${order.invoiceUrl}`,
552
+ confirmationUrl: `${reqOrigin}${order.invoiceUrl}`,
553
+ order,
554
+ user: updatedUser,
555
+ });
556
+ }
557
+ const payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
558
+ return sendJson(res, 200, {
559
+ ok: true,
560
+ ...payment,
561
+ order,
562
+ user: updatedUser,
563
+ });
564
+ }
565
+
566
+ // 2.6.3 Отмена (удаление) неоплаченного заказа из Личного кабинета
567
+ if (req.method === 'POST' && pathname === '/api/cabinet/orders/cancel') {
568
+ const body = await readJsonBody(req);
569
+ const user = ensureCustomerOferta(req, body);
570
+ store.cancelOrder(body.orderId);
571
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
572
+ return sendJson(res, 200, {
573
+ ok: true,
574
+ orderId: body.orderId,
575
+ user: updatedUser,
576
+ });
577
+ }
578
+
343
579
  // 2.7 Собственный ИИ-агент лицензионного сервера ODANT-WORK
344
580
  if (req.method === 'POST' && pathname === '/api/agent/chat') {
345
581
  const body = await readJsonBody(req);
package/src/store.js CHANGED
@@ -939,9 +939,24 @@ export class LicenseStore {
939
939
  _formatUserResponse(user) {
940
940
  if (!user)
941
941
  return null;
942
+ const userEmail = String(user.email || '').toLowerCase();
943
+ const userOrders = Object.values(this.state.orders || {})
944
+ .filter(o => o.status !== 'canceled' && (
945
+ (user.userId && o.ownerAccountId === user.userId)
946
+ || (user.licenseId && o.licenseId === user.licenseId)
947
+ || (userEmail && String(o.customer?.email || '').toLowerCase() === userEmail)
948
+ ))
949
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
950
+ const userReservations = Object.values(this.state.reservations || {})
951
+ .filter(r => r.status !== 'canceled' && (
952
+ (user.userId && r.ownerAccountId === user.userId)
953
+ || (userEmail && String(r.email || '').toLowerCase() === userEmail)
954
+ ))
955
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
942
956
  return {
943
957
  userId: user.userId,
944
958
  email: user.email,
959
+ authProvider: user.authProvider || 'email',
945
960
  name: user.name,
946
961
  position: user.position || '',
947
962
  companyName: user.companyName,
@@ -960,28 +975,37 @@ export class LicenseStore {
960
975
  createdAt: user.createdAt,
961
976
  lastLoginAt: user.lastLoginAt,
962
977
  license: this.getLicenseSummary(user.licenseId),
978
+ orders: userOrders,
979
+ reservations: userReservations,
963
980
  };
964
981
  }
965
982
 
966
983
  /**
967
- * Зарегистрировать или авторизовать пользователя личного кабинета ODANT-WORK.
968
- * Любые операции заказа комплекса, докупки лицензий, оплаты и резервирования франшизы
969
- * доступны пользователю после регистрации и подписания публичной оферты (ПЭП).
984
+ * Зарегистрировать или авторизовать пользователя личного кабинета ODANT-WORK
985
+ * (по email, через OAuth или через мессенджеры Telegram / MAX / VK).
986
+ * Акцепт договора-оферты фиксируется по факту регистрации (ofertaAccepted = true по умолчанию).
970
987
  */
971
988
  registerOrLoginUser({
972
989
  email = '',
990
+ provider = 'email',
991
+ providerAccount = '',
973
992
  name = '',
974
993
  position = '',
975
994
  companyName = '',
976
995
  inn = '',
977
996
  phone = '',
978
997
  licenseId = '',
979
- ofertaAccepted = false,
998
+ ofertaAccepted = true,
980
999
  hubUrl = 'https://work.odant.org',
981
1000
  } = {}) {
982
- const cleanEmail = String(email || '').trim().toLowerCase();
1001
+ const cleanProvider = String(provider || 'email').trim().toLowerCase();
1002
+ let cleanEmail = String(email || '').trim().toLowerCase();
1003
+ if (!cleanEmail && providerAccount) {
1004
+ const safeAcc = String(providerAccount).trim().toLowerCase().replace(/[^a-z0-9._+-]/g, '') || 'user';
1005
+ cleanEmail = safeAcc.includes('@') ? safeAcc : `${safeAcc}@${cleanProvider}.id.odant.org`;
1006
+ }
983
1007
  if (!cleanEmail || !cleanEmail.includes('@'))
984
- throw new Error('Укажите корректный корпоративный email для регистрации в личном кабинете ODANT-WORK');
1008
+ throw new Error('Укажите корректный email или войдите через мессенджер / OAuth');
985
1009
 
986
1010
  let user = this.state.users[cleanEmail];
987
1011
  const now = Date.now();
@@ -995,7 +1019,7 @@ export class LicenseStore {
995
1019
  ownerEmail: cleanEmail,
996
1020
  baseActivated: false,
997
1021
  customer: {
998
- name: companyName || name || 'Организация ODANT-WORK',
1022
+ name: companyName || name || cleanEmail,
999
1023
  inn: inn || '',
1000
1024
  email: cleanEmail,
1001
1025
  phone: phone || '',
@@ -1013,11 +1037,12 @@ export class LicenseStore {
1013
1037
  user = {
1014
1038
  userId,
1015
1039
  email: cleanEmail,
1040
+ authProvider: cleanProvider,
1016
1041
  name: String(name || companyName || cleanEmail.split('@')[0]).trim(),
1017
1042
  position: String(position || '').trim(),
1018
- companyName: String(companyName || lic.customer?.name || 'Организация ODANT-WORK').trim(),
1019
- inn: String(inn || lic.customer?.inn || '').trim(),
1020
- phone: String(phone || lic.customer?.phone || '').trim(),
1043
+ companyName: String(companyName || '').trim(),
1044
+ inn: String(inn || '').trim(),
1045
+ phone: String(phone || '').trim(),
1021
1046
  licenseId: lic.licenseId,
1022
1047
  sessionKey: 'wks_' + crypto.randomBytes(16).toString('hex'),
1023
1048
  hubToken: tokData.token,
@@ -1036,6 +1061,7 @@ export class LicenseStore {
1036
1061
  }
1037
1062
  else {
1038
1063
  user.lastLoginAt = now;
1064
+ if (cleanProvider && cleanProvider !== 'email') user.authProvider = cleanProvider;
1039
1065
  if (name) user.name = String(name).trim();
1040
1066
  if (position) user.position = String(position).trim();
1041
1067
  if (companyName) user.companyName = String(companyName).trim();
@@ -1058,11 +1084,11 @@ export class LicenseStore {
1058
1084
  }
1059
1085
  }
1060
1086
 
1061
- if (ofertaAccepted && !user.ofertaAccepted) {
1087
+ if (ofertaAccepted !== false && !user.ofertaAccepted) {
1062
1088
  user.ofertaAccepted = true;
1063
1089
  user.ofertaAcceptedAt = now;
1064
1090
  user.ofertaVersion = PUBLIC_OFERTA.version;
1065
- user.ofertaSignerName = String(name || user.name || user.companyName).trim();
1091
+ user.ofertaSignerName = String(name || user.name || user.companyName || user.email).trim();
1066
1092
  user.ofertaSignatureId = 'PEP-' + crypto.randomBytes(4).toString('hex').toUpperCase();
1067
1093
  }
1068
1094
 
@@ -1299,6 +1325,7 @@ export class LicenseStore {
1299
1325
  */
1300
1326
  reserveFranchiseSlot({
1301
1327
  regionCode = 'RU-SVE',
1328
+ ownerAccountId = '',
1302
1329
  companyName = '',
1303
1330
  inn = '',
1304
1331
  email = '',
@@ -1306,8 +1333,7 @@ export class LicenseStore {
1306
1333
  contactPerson = '',
1307
1334
  returnUrl = '',
1308
1335
  } = {}) {
1309
- if (!companyName)
1310
- throw new Error('Укажите наименование компании для резервирования региональной франшизы');
1336
+ const effCompanyName = String(companyName || email || 'Партнёр ODANT-WORK').trim();
1311
1337
 
1312
1338
  const quotaStatus = this.getRegionPartnerQuotaStatus(regionCode);
1313
1339
  if (quotaStatus.vacantFranchises <= 0) {
@@ -1321,14 +1347,17 @@ export class LicenseStore {
1321
1347
  const reservationId = 'RES-' + quotaStatus.regionCode.replace(/^RU-/, '') + '-' + crypto.randomBytes(3).toString('hex').toUpperCase();
1322
1348
  const depositRub = FRANCHISE_PROGRAM.reservationDepositRub;
1323
1349
  const orderId = 'ORD-FR-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
1350
+ const invoiceNumber = 'СЧ-FR-' + new Date(now).toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
1351
+ const contractNumber = 'ДЛ-FR-' + new Date(now).getFullYear() + '-' + orderId.slice(-6);
1324
1352
 
1325
1353
  const reservation = {
1326
1354
  reservationId,
1327
1355
  orderId,
1356
+ ownerAccountId: String(ownerAccountId || '').trim(),
1328
1357
  regionCode: quotaStatus.regionCode,
1329
1358
  regionName: quotaStatus.regionName,
1330
1359
  district: quotaStatus.district,
1331
- companyName: String(companyName).trim(),
1360
+ companyName: effCompanyName,
1332
1361
  inn: String(inn || '').trim(),
1333
1362
  email: String(email || 'franchise@odant.org').trim(),
1334
1363
  phone: String(phone || '').trim(),
@@ -1346,6 +1375,7 @@ export class LicenseStore {
1346
1375
 
1347
1376
  const order = {
1348
1377
  orderId,
1378
+ ownerAccountId: String(ownerAccountId || '').trim(),
1349
1379
  kind: 'franchise_reservation',
1350
1380
  reservationId,
1351
1381
  licenseId: reservationId,
@@ -1356,6 +1386,9 @@ export class LicenseStore {
1356
1386
  years: 1,
1357
1387
  amountRub: depositRub,
1358
1388
  franchiseFeeRub: quotaStatus.franchiseFeeRub,
1389
+ invoiceNumber,
1390
+ contractNumber,
1391
+ invoiceUrl: `/api/orders/${encodeURIComponent(orderId)}/invoice`,
1359
1392
  items: [
1360
1393
  {
1361
1394
  title: `Невозвратный залог за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, полная оплата в течение 1 года)`,
@@ -1389,6 +1422,67 @@ export class LicenseStore {
1389
1422
  };
1390
1423
  }
1391
1424
 
1425
+ /**
1426
+ * Обновить способ оплаты и реквизиты юрлица для существующего заказа из Личного кабинета.
1427
+ */
1428
+ prepareOrderPayment(orderId, {
1429
+ paymentMethod = 'yookassa',
1430
+ companyName = '',
1431
+ inn = '',
1432
+ kpp = '',
1433
+ legalAddress = '',
1434
+ } = {}) {
1435
+ const order = this.state.orders[orderId];
1436
+ if (!order)
1437
+ throw new Error(`Заказ ${orderId} не найден`);
1438
+ order.paymentMethod = paymentMethod === 'invoice' ? 'invoice' : 'yookassa';
1439
+ order.customer = order.customer || {};
1440
+ if (companyName) order.customer.name = String(companyName).trim();
1441
+ if (inn) order.customer.inn = String(inn).trim();
1442
+ if (kpp) order.customer.kpp = String(kpp).trim();
1443
+ if (legalAddress) order.customer.legalAddress = String(legalAddress).trim();
1444
+ if (!order.invoiceNumber) {
1445
+ order.invoiceNumber = 'СЧ-' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
1446
+ }
1447
+ if (!order.contractNumber) {
1448
+ order.contractNumber = 'ДЛ-' + new Date().getFullYear() + '-' + orderId.slice(-6);
1449
+ }
1450
+ if (!order.invoiceUrl) {
1451
+ order.invoiceUrl = `/api/orders/${encodeURIComponent(orderId)}/invoice`;
1452
+ }
1453
+ if (order.reservationId && this.state.reservations[order.reservationId]) {
1454
+ if (companyName) this.state.reservations[order.reservationId].companyName = String(companyName).trim();
1455
+ if (inn) this.state.reservations[order.reservationId].inn = String(inn).trim();
1456
+ }
1457
+ if (order.ownerAccountId) {
1458
+ const u = Object.values(this.state.users).find(usr => usr.userId === order.ownerAccountId);
1459
+ if (u) {
1460
+ if (companyName) u.companyName = String(companyName).trim();
1461
+ if (inn) u.inn = String(inn).trim();
1462
+ }
1463
+ }
1464
+ this._save();
1465
+ return order;
1466
+ }
1467
+
1468
+ /**
1469
+ * Отменить неоплаченный заказ в Личном кабинете (и снять резерв региона, если это был резерв франшизы).
1470
+ */
1471
+ cancelOrder(orderId) {
1472
+ const order = this.state.orders[orderId];
1473
+ if (!order)
1474
+ throw new Error(`Заказ ${orderId} не найден`);
1475
+ if (order.status === 'paid')
1476
+ throw new Error('Оплаченный заказ отменить нельзя');
1477
+ order.status = 'canceled';
1478
+ order.canceledAt = Date.now();
1479
+ if (order.reservationId && this.state.reservations[order.reservationId]) {
1480
+ this.state.reservations[order.reservationId].status = 'canceled';
1481
+ }
1482
+ this._save();
1483
+ return { ok: true, orderId };
1484
+ }
1485
+
1392
1486
  /**
1393
1487
  * Зарегистрировать регионального партнёра-франчайзи с контролем региональной квоты (по населению)
1394
1488
  * и расчётом фиксированного паушального взноса (пропорционально экономике региона).