work-license-server 1.0.3 → 1.0.4

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/store.js CHANGED
@@ -10,6 +10,9 @@ import path from 'node:path';
10
10
  import crypto from 'node:crypto';
11
11
  import {
12
12
  PAK_PRODUCT,
13
+ BASE_LICENSE_PRODUCT,
14
+ ADDITIONAL_PRODUCTS,
15
+ VENDOR_CONTACTS,
13
16
  SLA_TIERS,
14
17
  calculateOrderQuote,
15
18
  FRANCHISE_PROGRAM,
@@ -17,18 +20,29 @@ import {
17
20
  PARTNER_TRAINING_TRACKS,
18
21
  calculateFranchiseFee,
19
22
  calculatePartnerDiscount,
23
+ PUBLIC_OFERTA,
20
24
  } from './catalog.js';
21
- import { loadOrCreateAuthorityKeys, signCertificate } from './crypto.js';
25
+ import {
26
+ loadOrCreateAuthorityKeys,
27
+ signCertificate,
28
+ signLicenseFile,
29
+ createOwnerAccessToken,
30
+ parseOwnerAccessToken,
31
+ verifyOwnerAccessToken,
32
+ } from './crypto.js';
22
33
 
23
34
  const ONE_YEAR_MS = 365 * 24 * 3600_000;
24
35
 
25
36
  export class LicenseStore {
26
- constructor({ dataDir = './data' } = {}) {
37
+ constructor({ dataDir = './data', adminLogin = '', adminPassword = '' } = {}) {
27
38
  this.dataDir = dataDir;
28
39
  fs.mkdirSync(this.dataDir, { recursive: true });
29
40
  this.stateFile = path.join(this.dataDir, 'store.json');
30
41
  this.keyFile = path.join(this.dataDir, 'authority-key.json');
31
42
  this.authorityKeys = loadOrCreateAuthorityKeys(this.keyFile);
43
+ this.adminLogin = String(adminLogin || process.env.WORK_ADMIN_LOGIN || 'admin').trim();
44
+ this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
45
+ this.adminSessions = new Map();
32
46
 
33
47
  this.state = {
34
48
  licenses: {},
@@ -43,6 +57,54 @@ export class LicenseStore {
43
57
  this._load();
44
58
  }
45
59
 
60
+ /**
61
+ * Авторизация администратора центра лицензирования (/admin).
62
+ */
63
+ loginAdmin({ login = 'admin', password = '' } = {}) {
64
+ const cleanLogin = String(login || 'admin').trim();
65
+ const cleanPass = String(password || '');
66
+ if (cleanLogin !== this.adminLogin || cleanPass !== this.adminPassword) {
67
+ throw new Error('Неверный логин или ключ администратора');
68
+ }
69
+ const token = 'adm_' + crypto.randomBytes(24).toString('hex');
70
+ const now = Date.now();
71
+ const session = {
72
+ token,
73
+ login: cleanLogin,
74
+ createdAt: now,
75
+ expiresAt: now + 7 * 24 * 3600_000,
76
+ };
77
+ this.adminSessions.set(token, session);
78
+ return {
79
+ authenticated: true,
80
+ login: cleanLogin,
81
+ token,
82
+ expiresAt: session.expiresAt,
83
+ };
84
+ }
85
+
86
+ logoutAdmin(token = '') {
87
+ const clean = String(token || '').trim();
88
+ if (clean) this.adminSessions.delete(clean);
89
+ return { ok: true };
90
+ }
91
+
92
+ verifyAdminToken(token = '') {
93
+ const clean = String(token || '').trim();
94
+ if (!clean)
95
+ return false;
96
+ if (clean === this.adminPassword && this.adminPassword.length >= 6)
97
+ return true;
98
+ const sess = this.adminSessions.get(clean);
99
+ if (!sess)
100
+ return false;
101
+ if (sess.expiresAt < Date.now()) {
102
+ this.adminSessions.delete(clean);
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+
46
108
  _load() {
47
109
  if (!fs.existsSync(this.stateFile))
48
110
  return;
@@ -78,10 +140,14 @@ export class LicenseStore {
78
140
  hwid = '',
79
141
  nodeFingerprint = '',
80
142
  customer = {},
143
+ ownerAccountId = '',
144
+ ownerEmail = '',
81
145
  partnerId = '',
82
146
  hardwareWarrantyYears = 1,
83
147
  partnerAddons = {},
84
148
  baseSeats = PAK_PRODUCT.baseSeats,
149
+ baseActivated = undefined,
150
+ baseSku = '',
85
151
  includedSupportTier = PAK_PRODUCT.includedSupportTier,
86
152
  } = {}) {
87
153
  const now = Date.now();
@@ -97,11 +163,19 @@ export class LicenseStore {
97
163
  const issuedAt = existing?.issuedAt || now;
98
164
  const bindCode = existing?.support?.bindCode || ('BIND-' + crypto.randomBytes(3).toString('hex').toUpperCase());
99
165
 
166
+ const isBaseActivated = baseActivated !== undefined
167
+ ? Boolean(baseActivated)
168
+ : (existing?.baseActivated !== undefined ? Boolean(existing.baseActivated) : true);
169
+
100
170
  const record = {
101
171
  licenseId: id,
102
172
  issuedAt,
103
173
  updatedAt: now,
104
174
  issuer: 'work.odant.org',
175
+ ownerAccountId: String(ownerAccountId || existing?.ownerAccountId || ''),
176
+ ownerEmail: String(ownerEmail || customer.email || existing?.ownerEmail || existing?.customer?.email || '').toLowerCase(),
177
+ baseActivated: isBaseActivated,
178
+ baseSku: String(baseSku || existing?.baseSku || PAK_PRODUCT.sku),
105
179
  customer: {
106
180
  name: customer.name || existing?.customer?.name || 'Организация ПАК WORK',
107
181
  inn: customer.inn || existing?.customer?.inn || '',
@@ -111,7 +185,7 @@ export class LicenseStore {
111
185
  hardware: {
112
186
  hwid: hwid || existing?.hardware?.hwid || ('PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase()),
113
187
  nodeFingerprint: nodeFingerprint || existing?.hardware?.nodeFingerprint || '',
114
- sku: PAK_PRODUCT.sku,
188
+ sku: String(baseSku || existing?.hardware?.sku || PAK_PRODUCT.sku),
115
189
  cpu: PAK_PRODUCT.hardware.cpu,
116
190
  ramGb: PAK_PRODUCT.hardware.ramGb,
117
191
  ssdTb: PAK_PRODUCT.hardware.ssdTb,
@@ -151,6 +225,95 @@ export class LicenseStore {
151
225
  return this.getLicenseSummary(id);
152
226
  }
153
227
 
228
+ /**
229
+ * Сформировать отдельные подписанные файлы лицензий (.lic.json) для размещения в папке #system локальной системы:
230
+ * - файл первичной постоянной лицензии (10 вечных пользователей + разрешение агентской системы);
231
+ * - отдельные файлы для каждой активной временной лицензии (на количество конкурентных пользователей и период).
232
+ */
233
+ buildLicenseFiles(licenseIdOrCode, now = Date.now()) {
234
+ const lic = this.findLicense(licenseIdOrCode);
235
+ if (!lic)
236
+ return [];
237
+
238
+ const files = [];
239
+ const ownerAccountId = lic.ownerAccountId || '';
240
+ const ownerEmail = (lic.ownerEmail || lic.customer?.email || '').toLowerCase();
241
+
242
+ if (lic.baseActivated !== false) {
243
+ const baseFileObj = signLicenseFile({
244
+ fileId: `BASE-${lic.licenseId}`,
245
+ kind: 'base',
246
+ licenseId: lic.licenseId,
247
+ ownerAccountId,
248
+ ownerEmail,
249
+ issuer: lic.issuer || 'work.odant.org',
250
+ issuedAt: lic.issuedAt || now,
251
+ expiresAt: null,
252
+ perpetual: true,
253
+ seats: Number(lic.baseSeats ?? PAK_PRODUCT.baseSeats),
254
+ years: 0,
255
+ tier: String(lic.support?.tier || 'enterprise').toLowerCase(),
256
+ aiAgentsEnabled: true,
257
+ orderId: lic.baseOrderId || '',
258
+ hardware: {
259
+ hwid: String(lic.hardware?.hwid || ''),
260
+ nodeFingerprint: String(lic.hardware?.nodeFingerprint || ''),
261
+ sku: String(lic.baseSku || lic.hardware?.sku || PAK_PRODUCT.sku),
262
+ },
263
+ customer: {
264
+ name: String(lic.customer?.name || ''),
265
+ inn: String(lic.customer?.inn || ''),
266
+ email: String(lic.customer?.email || ''),
267
+ },
268
+ }, this.authorityKeys);
269
+
270
+ files.push({
271
+ fileName: `license-base-${lic.licenseId}.lic.json`,
272
+ kind: 'base',
273
+ licenseFile: baseFileObj,
274
+ });
275
+ }
276
+
277
+ for (const sub of (lic.addonSubscriptions || [])) {
278
+ if (!sub || sub.status === 'canceled' || sub.status === 'revoked')
279
+ continue;
280
+ const addonFileObj = signLicenseFile({
281
+ fileId: sub.id,
282
+ kind: 'addon',
283
+ licenseId: lic.licenseId,
284
+ ownerAccountId,
285
+ ownerEmail,
286
+ issuer: lic.issuer || 'work.odant.org',
287
+ issuedAt: Number(sub.issuedAt || now),
288
+ expiresAt: Number(sub.expiresAt || 0),
289
+ perpetual: false,
290
+ seats: Number(sub.seats || 0),
291
+ years: Number(sub.years || 1),
292
+ tier: String(sub.tier || 'business').toLowerCase(),
293
+ aiAgentsEnabled: true,
294
+ orderId: String(sub.orderId || ''),
295
+ hardware: {
296
+ hwid: String(lic.hardware?.hwid || ''),
297
+ nodeFingerprint: String(lic.hardware?.nodeFingerprint || ''),
298
+ sku: String(lic.hardware?.sku || PAK_PRODUCT.sku),
299
+ },
300
+ customer: {
301
+ name: String(lic.customer?.name || ''),
302
+ inn: String(lic.customer?.inn || ''),
303
+ email: String(lic.customer?.email || ''),
304
+ },
305
+ }, this.authorityKeys);
306
+
307
+ files.push({
308
+ fileName: `license-addon-${sub.id}.lic.json`,
309
+ kind: 'addon',
310
+ licenseFile: addonFileObj,
311
+ });
312
+ }
313
+
314
+ return files;
315
+ }
316
+
154
317
  /**
155
318
  * Выдача и привязка лицензии ПАК непосредственно региональным партнёром
156
319
  * (партнёр сам закупает оборудование, устанавливает стек, привязывает HWID и даёт 1 год гарантии на железо).
@@ -276,13 +439,18 @@ export class LicenseStore {
276
439
  licenseId: lic.licenseId,
277
440
  issuedAt: lic.issuedAt,
278
441
  updatedAt: lic.updatedAt,
442
+ ownerAccountId: lic.ownerAccountId || '',
443
+ ownerEmail: lic.ownerEmail || lic.customer?.email || '',
444
+ baseActivated: lic.baseActivated !== false,
445
+ baseSku: lic.baseSku || PAK_PRODUCT.sku,
279
446
  customer: lic.customer,
280
447
  hardware: lic.hardware,
281
448
  partner: lic.partner || null,
282
- baseSeats,
283
- perpetualBase: true,
449
+ baseSeats: lic.baseActivated !== false ? baseSeats : 0,
450
+ perpetualBase: lic.baseActivated !== false,
451
+ aiAgentsEnabled: lic.baseActivated !== false,
284
452
  addonSeats,
285
- totalSeats: baseSeats + addonSeats,
453
+ totalSeats: (lic.baseActivated !== false ? baseSeats : 1) + addonSeats,
286
454
  baseSupportUntil,
287
455
  baseSupportActive,
288
456
  activeAddons,
@@ -299,6 +467,7 @@ export class LicenseStore {
299
467
  bindCode: lic.support?.bindCode || '',
300
468
  },
301
469
  certificate: lic,
470
+ licenseFiles: this.buildLicenseFiles(lic.licenseId, now),
302
471
  };
303
472
  }
304
473
 
@@ -329,21 +498,33 @@ export class LicenseStore {
329
498
  }
330
499
 
331
500
  /**
332
- * Создать заказ на покупку ПАК и/или дополнительных годовых лицензий.
501
+ * Создать заказ на покупку ПАК (5 млн ₽), отдельно базовой постоянной лицензии без железа (4 млн ₽),
502
+ * дополнительных годовых лицензий и прочих товаров/услуг (оплата через ЮKassa или по счёту/договору для юрлиц).
333
503
  */
334
504
  createOrder({
335
505
  licenseId = '',
336
506
  hwid = '',
337
507
  nodeFingerprint = '',
338
508
  includeBasePak = false,
509
+ includeBaseLicenseOnly = false,
339
510
  tier = 'business',
340
511
  addonSeats = 0,
341
512
  years = 1,
513
+ extraProductIds = [],
514
+ paymentMethod = 'yookassa',
342
515
  customer = {},
516
+ ownerAccountId = '',
343
517
  partnerId = '',
344
518
  returnUrl = '',
345
519
  } = {}) {
346
- const quote = calculateOrderQuote({ includeBasePak, tier, addonSeats, years });
520
+ const quote = calculateOrderQuote({
521
+ includeBasePak,
522
+ includeBaseLicenseOnly,
523
+ tier,
524
+ addonSeats,
525
+ years,
526
+ extraProductIds,
527
+ });
347
528
  if (quote.totalAmountRub <= 0)
348
529
  throw new Error('Сумма заказа должна быть больше 0 ₽');
349
530
 
@@ -354,6 +535,8 @@ export class LicenseStore {
354
535
  hwid,
355
536
  nodeFingerprint,
356
537
  customer,
538
+ ownerAccountId,
539
+ ownerEmail: customer.email || '',
357
540
  });
358
541
  }
359
542
 
@@ -361,31 +544,49 @@ export class LicenseStore {
361
544
  const partnerDiscountPercent = partner ? partner.discountPercent : 0;
362
545
  const partnerMarginRub = partner ? Math.round(quote.totalAmountRub * (partnerDiscountPercent / 100)) : 0;
363
546
 
364
- const orderId = 'ORD-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
547
+ const now = Date.now();
548
+ const orderId = 'ORD-' + now.toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
549
+ const method = String(paymentMethod || 'yookassa').toLowerCase() === 'invoice' ? 'invoice' : 'yookassa';
550
+ const dateTag = new Date(now).toISOString().slice(0, 10).replace(/-/g, '');
551
+ const shortRand = crypto.randomBytes(2).toString('hex').toUpperCase();
552
+
365
553
  const order = {
366
554
  orderId,
367
555
  licenseId: targetLic.licenseId,
556
+ ownerAccountId: ownerAccountId || targetLic.ownerAccountId || '',
368
557
  partnerId: partner ? partner.partnerId : '',
369
558
  partnerDiscountPercent,
370
559
  partnerMarginRub,
371
560
  includeBasePak: quote.includeBasePak,
561
+ includeBaseLicenseOnly: quote.includeBaseLicenseOnly,
562
+ basePakPriceRub: quote.basePakPriceRub,
563
+ baseLicenseOnlyPriceRub: quote.baseLicenseOnlyPriceRub,
372
564
  tier: quote.tier,
373
565
  tierName: quote.tierName,
374
566
  addonSeats: quote.addonSeats,
375
567
  years: quote.years,
568
+ extraProductIds: Array.isArray(extraProductIds) ? extraProductIds : [],
569
+ extraAmountRub: quote.extraAmountRub || 0,
376
570
  amountRub: quote.totalAmountRub,
377
571
  items: quote.items,
572
+ paymentMethod: method,
573
+ invoiceNumber: method === 'invoice' ? `СЧ-${dateTag}-${shortRand}` : '',
574
+ contractNumber: method === 'invoice' ? `ДОГ-${dateTag}-${shortRand}` : '',
575
+ invoiceUrl: method === 'invoice' ? `/api/orders/${orderId}/invoice` : '',
378
576
  customer: {
379
577
  name: customer.name || targetLic.customer?.name || 'Организация WORK',
380
578
  inn: customer.inn || targetLic.customer?.inn || '',
579
+ kpp: customer.kpp || '',
580
+ legalAddress: customer.legalAddress || '',
381
581
  email: customer.email || targetLic.customer?.email || 'billing@odant.org',
382
582
  phone: customer.phone || targetLic.customer?.phone || '',
383
583
  },
384
- status: 'pending',
584
+ status: method === 'invoice' ? 'awaiting_invoice' : 'pending',
385
585
  paymentId: '',
386
- confirmationUrl: '',
586
+ paymentOrderNumber: '',
587
+ confirmationUrl: method === 'invoice' ? `/api/orders/${orderId}/invoice` : '',
387
588
  returnUrl: returnUrl || 'https://work.odant.org',
388
- createdAt: Date.now(),
589
+ createdAt: now,
389
590
  paidAt: null,
390
591
  };
391
592
 
@@ -421,6 +622,15 @@ export class LicenseStore {
421
622
  if (!ord)
422
623
  throw new Error(`Заказ ${orderId} не найден`);
423
624
  if (ord.status === 'paid') {
625
+ if (ord.kind === 'franchise_reservation' && ord.reservationId && this.state.reservations[ord.reservationId]) {
626
+ const resv = this.state.reservations[ord.reservationId];
627
+ return {
628
+ order: ord,
629
+ reservation: resv,
630
+ regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
631
+ alreadyProcessed: true,
632
+ };
633
+ }
424
634
  return {
425
635
  order: ord,
426
636
  license: this.getLicenseSummary(ord.licenseId),
@@ -437,7 +647,8 @@ export class LicenseStore {
437
647
  resv.depositPaid = true;
438
648
  resv.paidAt = ord.paidAt;
439
649
  resv.paymentId = ord.paymentId;
440
- resv.status = 'deposit_paid';
650
+ if (!resv.convertedToPartnerId)
651
+ resv.status = 'deposit_paid';
441
652
  this._save();
442
653
  return {
443
654
  order: ord,
@@ -447,6 +658,22 @@ export class LicenseStore {
447
658
  };
448
659
  }
449
660
 
661
+ // Если заказ включает базовый комплект с железом (5 млн ₽) или базовую лицензию без железа (4 млн ₽) —
662
+ // активируем базовую постоянную лицензию на 10 вечных пользователей
663
+ if (ord.includeBasePak || ord.includeBaseLicenseOnly) {
664
+ const rawLic = this.state.licenses[ord.licenseId];
665
+ if (rawLic) {
666
+ rawLic.baseActivated = true;
667
+ rawLic.baseSeats = PAK_PRODUCT.baseSeats;
668
+ rawLic.perpetualBase = true;
669
+ rawLic.baseSku = ord.includeBasePak ? PAK_PRODUCT.sku : BASE_LICENSE_PRODUCT.sku;
670
+ rawLic.baseOrderId = ord.orderId;
671
+ if (ord.ownerAccountId) rawLic.ownerAccountId = ord.ownerAccountId;
672
+ if (ord.customer?.email) rawLic.ownerEmail = String(ord.customer.email).toLowerCase();
673
+ this.state.licenses[ord.licenseId] = signCertificate(rawLic, this.authorityKeys);
674
+ }
675
+ }
676
+
450
677
  const license = this.grantAddonSubscription(ord.licenseId, {
451
678
  tier: ord.tier,
452
679
  seats: ord.addonSeats,
@@ -462,6 +689,117 @@ export class LicenseStore {
462
689
  return { order: ord, license, partner, alreadyProcessed: false };
463
690
  }
464
691
 
692
+ /**
693
+ * Подтвердить оплату заказа по договору (счёту) для юридических лиц (безналичный расчёт).
694
+ */
695
+ confirmInvoicePayment(orderId, { paymentOrderNumber = '', paidAt = Date.now(), comment = '' } = {}) {
696
+ const ord = this.state.orders[orderId];
697
+ if (!ord)
698
+ throw new Error(`Заказ ${orderId} не найден`);
699
+ ord.paymentOrderNumber = String(paymentOrderNumber || ord.invoiceNumber || `ПП-${orderId}`).trim();
700
+ if (comment)
701
+ ord.invoiceComment = String(comment).trim();
702
+ if (paidAt)
703
+ ord.invoiceConfirmedAt = Number(paidAt) || Date.now();
704
+ return this.completePaidOrder(orderId, { paymentId: ord.paymentOrderNumber });
705
+ }
706
+
707
+ /**
708
+ * Сформировать печатную форму Счёта-договора на оплату для юридических лиц.
709
+ */
710
+ renderOrderInvoiceHtml(orderId) {
711
+ const ord = this.state.orders[orderId];
712
+ if (!ord)
713
+ return null;
714
+ const dateStr = new Date(ord.createdAt || Date.now()).toLocaleDateString('ru-RU');
715
+ const rowsHtml = (ord.items || []).map((it, idx) => `
716
+ <tr>
717
+ <td style="text-align:center;">${idx + 1}</td>
718
+ <td>${it.title}</td>
719
+ <td style="text-align:center;">${it.quantity}</td>
720
+ <td style="text-align:right;">${Number(it.unitPriceRub || 0).toLocaleString('ru-RU')} ₽</td>
721
+ <td style="text-align:right;font-weight:700;">${Number(it.totalRub || 0).toLocaleString('ru-RU')} ₽</td>
722
+ </tr>
723
+ `).join('');
724
+
725
+ return `<!DOCTYPE html>
726
+ <html lang="ru">
727
+ <head>
728
+ <meta charset="utf-8">
729
+ <title>Счёт-договор № ${ord.invoiceNumber || ord.orderId} от ${dateStr}</title>
730
+ <style>
731
+ body { font-family: Arial, sans-serif; color: #111827; max-width: 860px; margin: 24px auto; padding: 20px; line-height: 1.45; }
732
+ h1 { font-size: 20px; margin-bottom: 6px; }
733
+ table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
734
+ th, td { border: 1px solid #cbd5e1; padding: 8px 10px; }
735
+ th { background: #f1f5f9; text-align: left; }
736
+ .meta { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; font-size: 13px; }
737
+ .status { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; background: ${ord.status === 'paid' ? '#dcfce7' : '#fef3c7'}; color: ${ord.status === 'paid' ? '#166534' : '#92400e'}; }
738
+ </style>
739
+ </head>
740
+ <body>
741
+ <div style="display:flex;justify-content:space-between;align-items:center;">
742
+ <div>
743
+ <h1>Счёт-договор № ${ord.invoiceNumber || ord.orderId} от ${dateStr}</h1>
744
+ <div>Договор № ${ord.contractNumber || ord.orderId} · Заказ № ${ord.orderId}</div>
745
+ </div>
746
+ <span class="status">${ord.status === 'paid' ? 'ОПЛАЧЕН' : 'ОЖИДАЕТ ОПЛАТЫ ПО СЧЁТУ'}</span>
747
+ </div>
748
+ <div class="meta" style="margin-top:14px;">
749
+ <div><strong>Поставщик (Правообладатель):</strong> ООО «${VENDOR_CONTACTS.company}», адрес: ${VENDOR_CONTACTS.address}, тел.: ${VENDOR_CONTACTS.phones.join(', ')}</div>
750
+ <div style="margin-top:6px;"><strong>Покупатель (Лицензиат):</strong> ${ord.customer?.name || '—'}, ИНН: ${ord.customer?.inn || '—'}${ord.customer?.kpp ? ', КПП: ' + ord.customer.kpp : ''}, Email: ${ord.customer?.email || '—'}</div>
751
+ </div>
752
+ <table>
753
+ <thead>
754
+ <tr>
755
+ <th style="width:40px;">№</th>
756
+ <th>Наименование товара / лицензии / услуги</th>
757
+ <th style="width:70px;">Кол-во</th>
758
+ <th style="width:130px;">Цена</th>
759
+ <th style="width:140px;">Сумма</th>
760
+ </tr>
761
+ </thead>
762
+ <tbody>${rowsHtml}</tbody>
763
+ <tfoot>
764
+ <tr>
765
+ <td colspan="4" style="text-align:right;font-weight:700;">ИТОГО К ОПЛАТЕ (без НДС):</td>
766
+ <td style="text-align:right;font-weight:800;font-size:15px;">${Number(ord.amountRub || 0).toLocaleString('ru-RU')} ₽</td>
767
+ </tr>
768
+ </tfoot>
769
+ </table>
770
+ <p style="font-size:12.5px;color:#475569;">
771
+ Оплата настоящего счёта-договора означает полное согласие Лицензиата с условиями Публичной оферты ODANT-WORK.
772
+ После зачисления средств лицензии автоматически формируются в личном кабинете владельца (${ord.customer?.email || ''})
773
+ и загружаются локальной системой по привязанному токену.
774
+ </p>
775
+ </body>
776
+ </html>`;
777
+ }
778
+
779
+ /**
780
+ * Отменить неоплаченный заказ (при отмене резерва франшизы также освобождает региональную квоту).
781
+ */
782
+ cancelOrder(orderId) {
783
+ const ord = this.state.orders[orderId];
784
+ if (!ord)
785
+ throw new Error(`Заказ ${orderId} не найден`);
786
+ if (ord.status === 'paid')
787
+ throw new Error(`Заказ ${orderId} уже оплачен и не может быть отменён без возврата`);
788
+ ord.status = 'canceled';
789
+ ord.canceledAt = Date.now();
790
+ let regionQuota = null;
791
+ if (ord.kind === 'franchise_reservation' && ord.reservationId && this.state.reservations[ord.reservationId]) {
792
+ const resv = this.state.reservations[ord.reservationId];
793
+ if (!resv.convertedToPartnerId) {
794
+ resv.status = 'canceled';
795
+ resv.canceledAt = ord.canceledAt;
796
+ }
797
+ regionQuota = this.getRegionPartnerQuotaStatus(resv.regionCode);
798
+ }
799
+ this._save();
800
+ return { order: ord, regionQuota };
801
+ }
802
+
465
803
  /**
466
804
  * Привязать чат мессенджера (MAX, Telegram, VK) к лицензии ПАК.
467
805
  */
@@ -581,6 +919,16 @@ export class LicenseStore {
581
919
  return t;
582
920
  }
583
921
 
922
+ closeTicket(ticketId) {
923
+ const t = this.state.tickets[ticketId];
924
+ if (!t)
925
+ throw new Error(`Тикет ${ticketId} не найден`);
926
+ t.status = 'closed';
927
+ t.closedAt = Date.now();
928
+ this._save();
929
+ return t;
930
+ }
931
+
584
932
  listTickets({ licenseId = '' } = {}) {
585
933
  const all = Object.values(this.state.tickets).sort((a, b) => b.createdAt - a.createdAt);
586
934
  if (licenseId)
@@ -588,37 +936,98 @@ export class LicenseStore {
588
936
  return all;
589
937
  }
590
938
 
939
+ _formatUserResponse(user) {
940
+ if (!user)
941
+ return null;
942
+ return {
943
+ userId: user.userId,
944
+ email: user.email,
945
+ name: user.name,
946
+ position: user.position || '',
947
+ companyName: user.companyName,
948
+ inn: user.inn,
949
+ phone: user.phone || '',
950
+ licenseId: user.licenseId,
951
+ sessionKey: user.sessionKey,
952
+ hubToken: user.hubToken || '',
953
+ hubTokenId: user.hubTokenId || '',
954
+ hubUrl: user.hubUrl || 'https://work.odant.org',
955
+ ofertaAccepted: Boolean(user.ofertaAccepted),
956
+ ofertaAcceptedAt: user.ofertaAcceptedAt || null,
957
+ ofertaVersion: user.ofertaVersion || '',
958
+ ofertaSignerName: user.ofertaSignerName || '',
959
+ ofertaSignatureId: user.ofertaSignatureId || '',
960
+ createdAt: user.createdAt,
961
+ lastLoginAt: user.lastLoginAt,
962
+ license: this.getLicenseSummary(user.licenseId),
963
+ };
964
+ }
965
+
591
966
  /**
592
967
  * Зарегистрировать или авторизовать пользователя личного кабинета ODANT-WORK.
593
- * Докупка дополнительных пользовательских лицензий доступна только зарегистрированным пользователям.
968
+ * Любые операции заказа комплекса, докупки лицензий, оплаты и резервирования франшизы
969
+ * доступны пользователю после регистрации и подписания публичной оферты (ПЭП).
594
970
  */
595
- registerOrLoginUser({ email = '', name = '', companyName = '', inn = '', licenseId = '' } = {}) {
971
+ registerOrLoginUser({
972
+ email = '',
973
+ name = '',
974
+ position = '',
975
+ companyName = '',
976
+ inn = '',
977
+ phone = '',
978
+ licenseId = '',
979
+ ofertaAccepted = false,
980
+ hubUrl = 'https://work.odant.org',
981
+ } = {}) {
596
982
  const cleanEmail = String(email || '').trim().toLowerCase();
597
983
  if (!cleanEmail || !cleanEmail.includes('@'))
598
- throw new Error('Укажите корректный email для входа в личный кабинет ODANT-WORK');
984
+ throw new Error('Укажите корректный корпоративный email для регистрации в личном кабинете ODANT-WORK');
599
985
 
600
986
  let user = this.state.users[cleanEmail];
601
987
  const now = Date.now();
988
+ const userId = user?.userId || ('USR-' + crypto.createHash('sha256').update(cleanEmail).digest('hex').slice(0, 10).toUpperCase());
989
+
602
990
  if (!user) {
603
991
  let lic = licenseId ? this.findLicense(licenseId) : null;
604
992
  if (!lic) {
605
993
  const summary = this.issueOrUpdateLicense({
994
+ ownerAccountId: userId,
995
+ ownerEmail: cleanEmail,
606
996
  customer: {
607
997
  name: companyName || name || 'Организация ODANT-WORK',
608
998
  inn: inn || '',
609
999
  email: cleanEmail,
1000
+ phone: phone || '',
610
1001
  },
611
1002
  });
612
1003
  lic = summary.certificate;
613
1004
  }
1005
+ const tokData = createOwnerAccessToken({
1006
+ hubUrl: hubUrl || 'https://work.odant.org',
1007
+ accountId: userId,
1008
+ email: cleanEmail,
1009
+ issuedAt: now,
1010
+ }, this.authorityKeys);
1011
+
614
1012
  user = {
615
- userId: 'USR-' + crypto.createHash('sha256').update(cleanEmail).digest('hex').slice(0, 10).toUpperCase(),
1013
+ userId,
616
1014
  email: cleanEmail,
617
1015
  name: String(name || companyName || cleanEmail.split('@')[0]).trim(),
1016
+ position: String(position || '').trim(),
618
1017
  companyName: String(companyName || lic.customer?.name || 'Организация ODANT-WORK').trim(),
619
1018
  inn: String(inn || lic.customer?.inn || '').trim(),
1019
+ phone: String(phone || lic.customer?.phone || '').trim(),
620
1020
  licenseId: lic.licenseId,
621
1021
  sessionKey: 'wks_' + crypto.randomBytes(16).toString('hex'),
1022
+ hubToken: tokData.token,
1023
+ hubTokenId: tokData.payload.tokenId,
1024
+ hubTokenSecret: tokData.payload.secret,
1025
+ hubUrl: tokData.payload.hubUrl,
1026
+ ofertaAccepted: false,
1027
+ ofertaAcceptedAt: null,
1028
+ ofertaVersion: '',
1029
+ ofertaSignerName: '',
1030
+ ofertaSignatureId: '',
622
1031
  createdAt: now,
623
1032
  lastLoginAt: now,
624
1033
  };
@@ -627,25 +1036,215 @@ export class LicenseStore {
627
1036
  else {
628
1037
  user.lastLoginAt = now;
629
1038
  if (name) user.name = String(name).trim();
1039
+ if (position) user.position = String(position).trim();
630
1040
  if (companyName) user.companyName = String(companyName).trim();
631
1041
  if (inn) user.inn = String(inn).trim();
1042
+ if (phone) user.phone = String(phone).trim();
632
1043
  if (licenseId && this.findLicense(licenseId)) {
633
1044
  user.licenseId = this.findLicense(licenseId).licenseId;
634
1045
  }
1046
+ if (!user.hubToken) {
1047
+ const tokData = createOwnerAccessToken({
1048
+ hubUrl: hubUrl || user.hubUrl || 'https://work.odant.org',
1049
+ accountId: user.userId,
1050
+ email: user.email,
1051
+ issuedAt: now,
1052
+ }, this.authorityKeys);
1053
+ user.hubToken = tokData.token;
1054
+ user.hubTokenId = tokData.payload.tokenId;
1055
+ user.hubTokenSecret = tokData.payload.secret;
1056
+ user.hubUrl = tokData.payload.hubUrl;
1057
+ }
635
1058
  }
1059
+
1060
+ if (ofertaAccepted && !user.ofertaAccepted) {
1061
+ user.ofertaAccepted = true;
1062
+ user.ofertaAcceptedAt = now;
1063
+ user.ofertaVersion = PUBLIC_OFERTA.version;
1064
+ user.ofertaSignerName = String(name || user.name || user.companyName).trim();
1065
+ user.ofertaSignatureId = 'PEP-' + crypto.randomBytes(4).toString('hex').toUpperCase();
1066
+ }
1067
+
1068
+ // Синхронизируем реквизиты клиента и привязку аккаунта владельца с лицензией
1069
+ if (user.licenseId && this.state.licenses[user.licenseId]) {
1070
+ const rawLic = this.state.licenses[user.licenseId];
1071
+ rawLic.ownerAccountId = user.userId;
1072
+ rawLic.ownerEmail = user.email;
1073
+ if (user.companyName) rawLic.customer.name = user.companyName;
1074
+ if (user.inn) rawLic.customer.inn = user.inn;
1075
+ if (user.email) rawLic.customer.email = user.email;
1076
+ if (user.phone) rawLic.customer.phone = user.phone;
1077
+ this.state.licenses[user.licenseId] = signCertificate(rawLic, this.authorityKeys);
1078
+ }
1079
+
636
1080
  this._save();
1081
+ return this._formatUserResponse(user);
1082
+ }
1083
+
1084
+ /**
1085
+ * Сгенерировать (или перевыпустить) токен владельца в личном кабинете (содержит URL сервера поддержки).
1086
+ */
1087
+ generateUserHubToken(sessionKeyOrEmail = '', { hubUrl = 'https://work.odant.org' } = {}) {
1088
+ const key = String(sessionKeyOrEmail || '').trim();
1089
+ const user = Object.values(this.state.users).find(
1090
+ u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
1091
+ );
1092
+ if (!user)
1093
+ throw new Error('Аккаунт владельца не найден в личном кабинете');
1094
+
1095
+ const tokData = createOwnerAccessToken({
1096
+ hubUrl: hubUrl || user.hubUrl || 'https://work.odant.org',
1097
+ accountId: user.userId,
1098
+ email: user.email,
1099
+ issuedAt: Date.now(),
1100
+ }, this.authorityKeys);
1101
+
1102
+ user.hubToken = tokData.token;
1103
+ user.hubTokenId = tokData.payload.tokenId;
1104
+ user.hubTokenSecret = tokData.payload.secret;
1105
+ user.hubUrl = tokData.payload.hubUrl;
1106
+ this._save();
1107
+
637
1108
  return {
638
- userId: user.userId,
1109
+ ok: true,
1110
+ token: tokData.token,
1111
+ tokenId: tokData.payload.tokenId,
1112
+ accountId: user.userId,
639
1113
  email: user.email,
640
- name: user.name,
641
- companyName: user.companyName,
642
- inn: user.inn,
1114
+ hubUrl: tokData.payload.hubUrl,
1115
+ issuedAt: tokData.payload.issuedAt,
643
1116
  licenseId: user.licenseId,
644
- sessionKey: user.sessionKey,
645
- license: this.getLicenseSummary(user.licenseId),
646
1117
  };
647
1118
  }
648
1119
 
1120
+ /**
1121
+ * Проверить токен владельца (owk1...), привязать локальную систему (HWID / nodeFingerprint) к аккаунту
1122
+ * владельца и выдать подписанные файлы лицензий (базовую на 10 вечных мест и дополнительные временные)
1123
+ * для сохранения в папке #system.
1124
+ */
1125
+ syncByOwnerToken({
1126
+ token = '',
1127
+ hwid = '',
1128
+ nodeFingerprint = '',
1129
+ nodePublicKey = '',
1130
+ serverId = '',
1131
+ canonicalHubUrl = '',
1132
+ } = {}) {
1133
+ const parsed = parseOwnerAccessToken(token);
1134
+ if (!verifyOwnerAccessToken(token, this.authorityKeys.publicKey)) {
1135
+ const err = new Error('Подпись токена владельца не прошла криптографическую проверку');
1136
+ err.statusCode = 401;
1137
+ throw err;
1138
+ }
1139
+
1140
+ const { accountId, email, tokenId, secret, hubUrl } = parsed.payload;
1141
+ const user = Object.values(this.state.users).find(
1142
+ u => u.userId === accountId || (email && u.email === email.toLowerCase())
1143
+ );
1144
+ if (!user) {
1145
+ const err = new Error('Владелец, указанный в токене, не найден на сервере поддержки');
1146
+ err.statusCode = 404;
1147
+ throw err;
1148
+ }
1149
+ if (user.hubTokenId && (user.hubTokenId !== tokenId || user.hubTokenSecret !== secret)) {
1150
+ const err = new Error('Токен владельца был отозван или перевыпущен в личном кабинете');
1151
+ err.statusCode = 401;
1152
+ throw err;
1153
+ }
1154
+
1155
+ // Обновляем привязку оборудования к лицензии владельца
1156
+ const summary = this.issueOrUpdateLicense({
1157
+ licenseId: user.licenseId,
1158
+ hwid: hwid || undefined,
1159
+ nodeFingerprint: nodeFingerprint || undefined,
1160
+ ownerAccountId: user.userId,
1161
+ ownerEmail: user.email,
1162
+ customer: {
1163
+ name: user.companyName,
1164
+ inn: user.inn,
1165
+ email: user.email,
1166
+ phone: user.phone,
1167
+ },
1168
+ });
1169
+
1170
+ user.boundNodes ??= [];
1171
+ const existingNode = user.boundNodes.find(n => n.hwid === summary.hardware?.hwid);
1172
+ if (existingNode) {
1173
+ existingNode.nodeFingerprint = nodeFingerprint || existingNode.nodeFingerprint;
1174
+ existingNode.serverId = serverId || existingNode.serverId;
1175
+ existingNode.lastSyncAt = Date.now();
1176
+ }
1177
+ else if (hwid || nodeFingerprint) {
1178
+ user.boundNodes.push({
1179
+ hwid: summary.hardware?.hwid || hwid,
1180
+ nodeFingerprint,
1181
+ nodePublicKey,
1182
+ serverId,
1183
+ boundAt: Date.now(),
1184
+ lastSyncAt: Date.now(),
1185
+ });
1186
+ }
1187
+ this._save();
1188
+
1189
+ const resolvedHubUrl = String(canonicalHubUrl || user.hubUrl || hubUrl || 'https://work.odant.org').replace(/\/+$/, '');
1190
+ return {
1191
+ ok: true,
1192
+ accountId: user.userId,
1193
+ ownerEmail: user.email,
1194
+ companyName: user.companyName,
1195
+ canonicalHubUrl: resolvedHubUrl,
1196
+ licenseId: summary.licenseId,
1197
+ baseActivated: summary.baseActivated,
1198
+ baseSeats: summary.baseSeats,
1199
+ addonSeats: summary.addonSeats,
1200
+ totalSeats: summary.totalSeats,
1201
+ aiAgentsEnabled: summary.aiAgentsEnabled,
1202
+ certificate: summary.certificate,
1203
+ licenseFiles: summary.licenseFiles,
1204
+ license: summary,
1205
+ };
1206
+ }
1207
+
1208
+ /**
1209
+ * Подписать публичную оферту ODANT-WORK простой электронной подписью (ПЭП) в личном кабинете.
1210
+ */
1211
+ acceptUserOferta(sessionKeyOrEmail = '', {
1212
+ signerName = '',
1213
+ position = '',
1214
+ companyName = '',
1215
+ inn = '',
1216
+ phone = '',
1217
+ } = {}) {
1218
+ const key = String(sessionKeyOrEmail || '').trim();
1219
+ let user = Object.values(this.state.users).find(u => u.sessionKey === key || u.email === key.toLowerCase());
1220
+ if (!user)
1221
+ throw new Error('Пользователь не найден. Пожалуйста, войдите или зарегистрируйтесь в личном кабинете.');
1222
+
1223
+ const now = Date.now();
1224
+ if (signerName) user.name = String(signerName).trim();
1225
+ if (position) user.position = String(position).trim();
1226
+ if (companyName) user.companyName = String(companyName).trim();
1227
+ if (inn) user.inn = String(inn).trim();
1228
+ if (phone) user.phone = String(phone).trim();
1229
+
1230
+ user.ofertaAccepted = true;
1231
+ user.ofertaAcceptedAt = user.ofertaAcceptedAt || now;
1232
+ user.ofertaVersion = PUBLIC_OFERTA.version;
1233
+ user.ofertaSignerName = String(signerName || user.name || user.companyName).trim();
1234
+ user.ofertaSignatureId = user.ofertaSignatureId || ('PEP-' + crypto.randomBytes(4).toString('hex').toUpperCase());
1235
+
1236
+ if (user.licenseId && this.state.licenses[user.licenseId]) {
1237
+ const rawLic = this.state.licenses[user.licenseId];
1238
+ if (user.companyName) rawLic.customer.name = user.companyName;
1239
+ if (user.inn) rawLic.customer.inn = user.inn;
1240
+ if (user.phone) rawLic.customer.phone = user.phone;
1241
+ this.state.licenses[user.licenseId] = signCertificate(rawLic, this.authorityKeys);
1242
+ }
1243
+
1244
+ this._save();
1245
+ return this._formatUserResponse(user);
1246
+ }
1247
+
649
1248
  findUserBySessionKey(sessionKey = '') {
650
1249
  const key = String(sessionKey || '').trim();
651
1250
  if (!key)
@@ -653,27 +1252,33 @@ export class LicenseStore {
653
1252
  const user = Object.values(this.state.users).find(u => u.sessionKey === key);
654
1253
  if (!user)
655
1254
  return null;
656
- return {
657
- userId: user.userId,
658
- email: user.email,
659
- name: user.name,
660
- companyName: user.companyName,
661
- inn: user.inn,
662
- licenseId: user.licenseId,
663
- license: this.getLicenseSummary(user.licenseId),
664
- };
1255
+ return this._formatUserResponse(user);
1256
+ }
1257
+
1258
+ listUsers() {
1259
+ return Object.values(this.state.users)
1260
+ .sort((a, b) => (b.lastLoginAt || 0) - (a.lastLoginAt || 0))
1261
+ .map(u => {
1262
+ const formatted = this._formatUserResponse(u);
1263
+ delete formatted.sessionKey;
1264
+ return formatted;
1265
+ });
665
1266
  }
666
1267
 
667
1268
  /**
668
1269
  * Получить статус квоты и вакантных франшиз по региону (макс. число партнёров пропорционально населению).
669
1270
  */
670
- getRegionPartnerQuotaStatus(regionCode = 'RU-SVE', customPopulationMillions = null) {
1271
+ getRegionPartnerQuotaStatus(regionCode = 'RU-SVE', customPopulationMillions = null, options = {}) {
671
1272
  const info = calculateFranchiseFee(regionCode, customPopulationMillions);
1273
+ const ignoreResId = options?.ignoreReservationId || '';
672
1274
  const existingInRegion = Object.values(this.state.partners).filter(
673
1275
  p => p.regionCode === info.regionCode && p.status !== 'revoked'
674
1276
  );
675
1277
  const reservedInRegion = Object.values(this.state.reservations || {}).filter(
676
- r => r.regionCode === info.regionCode && r.status !== 'canceled' && !r.convertedToPartnerId
1278
+ r => r.regionCode === info.regionCode
1279
+ && r.status !== 'canceled'
1280
+ && !r.convertedToPartnerId
1281
+ && r.reservationId !== ignoreResId
677
1282
  );
678
1283
  const occupied = existingInRegion.length + reservedInRegion.length;
679
1284
  const vacantFranchises = Math.max(0, info.maxPartnersQuota - occupied);
@@ -786,9 +1391,11 @@ export class LicenseStore {
786
1391
  /**
787
1392
  * Зарегистрировать регионального партнёра-франчайзи с контролем региональной квоты (по населению)
788
1393
  * и расчётом фиксированного паушального взноса (пропорционально экономике региона).
1394
+ * Если передан reservationId — конвертирует существующий резерв франшизы в действующего партнёра.
789
1395
  */
790
1396
  registerPartner({
791
1397
  partnerId = '',
1398
+ reservationId = '',
792
1399
  companyName = '',
793
1400
  inn = '',
794
1401
  regionCode = 'RU-SVE',
@@ -798,10 +1405,25 @@ export class LicenseStore {
798
1405
  contactPerson = '',
799
1406
  annualSalesRub = 0,
800
1407
  } = {}) {
801
- if (!companyName)
1408
+ const resv = reservationId ? this.state.reservations[reservationId] : null;
1409
+ if (reservationId && !resv)
1410
+ throw new Error(`Резерв франшизы ${reservationId} не найден`);
1411
+ if (resv?.convertedToPartnerId && this.state.partners[resv.convertedToPartnerId])
1412
+ return this.getPartnerSummary(resv.convertedToPartnerId);
1413
+
1414
+ const effCompany = String(companyName || resv?.companyName || '').trim();
1415
+ const effRegion = String(regionCode || resv?.regionCode || 'RU-SVE').trim();
1416
+ const effInn = String(inn || resv?.inn || '').trim();
1417
+ const effEmail = String(email || resv?.email || '').trim();
1418
+ const effPhone = String(phone || resv?.phone || '').trim();
1419
+ const effContact = String(contactPerson || resv?.contactPerson || '').trim();
1420
+
1421
+ if (!effCompany)
802
1422
  throw new Error('Укажите наименование компании-партнёра');
803
1423
 
804
- const quotaStatus = this.getRegionPartnerQuotaStatus(regionCode, populationMillions);
1424
+ const quotaStatus = this.getRegionPartnerQuotaStatus(effRegion, populationMillions, {
1425
+ ignoreReservationId: resv?.reservationId || '',
1426
+ });
805
1427
  const existing = partnerId ? this.state.partners[partnerId] : null;
806
1428
 
807
1429
  if (!existing && quotaStatus.availablePartnerSlots <= 0) {
@@ -818,8 +1440,9 @@ export class LicenseStore {
818
1440
 
819
1441
  const partner = {
820
1442
  partnerId: id,
821
- companyName: String(companyName).trim(),
822
- inn: String(inn || existing?.inn || '').trim(),
1443
+ reservationId: resv?.reservationId || existing?.reservationId || '',
1444
+ companyName: effCompany,
1445
+ inn: effInn || existing?.inn || '',
823
1446
  regionCode: quotaStatus.regionCode,
824
1447
  regionName: quotaStatus.regionName,
825
1448
  district: quotaStatus.district,
@@ -827,9 +1450,9 @@ export class LicenseStore {
827
1450
  maxRegionPartnersQuota: quotaStatus.maxPartnersQuota,
828
1451
  economicIndex: quotaStatus.economicIndex,
829
1452
  franchiseFeeRub: quotaStatus.franchiseFeeRub,
830
- email: String(email || existing?.email || '').trim(),
831
- phone: String(phone || existing?.phone || '').trim(),
832
- contactPerson: String(contactPerson || existing?.contactPerson || '').trim(),
1453
+ email: effEmail || existing?.email || '',
1454
+ phone: effPhone || existing?.phone || '',
1455
+ contactPerson: effContact || existing?.contactPerson || '',
833
1456
  annualSalesRub: discountInfo.annualSalesRub,
834
1457
  discountPercent: discountInfo.discountPercent,
835
1458
  statusLevel: discountInfo.statusLevel,
@@ -841,6 +1464,16 @@ export class LicenseStore {
841
1464
  };
842
1465
 
843
1466
  this.state.partners[id] = partner;
1467
+ if (resv) {
1468
+ resv.convertedToPartnerId = id;
1469
+ resv.status = 'converted';
1470
+ resv.convertedAt = now;
1471
+ resv.depositPaid = true;
1472
+ if (resv.orderId && this.state.orders[resv.orderId] && this.state.orders[resv.orderId].status === 'pending') {
1473
+ this.state.orders[resv.orderId].status = 'paid';
1474
+ this.state.orders[resv.orderId].paidAt = now;
1475
+ }
1476
+ }
844
1477
  this._save();
845
1478
  return this.getPartnerSummary(id);
846
1479
  }
@@ -870,8 +1503,11 @@ export class LicenseStore {
870
1503
  partnerId,
871
1504
  specialistName = '',
872
1505
  email = '',
873
- trackId = 'pak-ai-engineer',
874
- score = 100,
1506
+ specialistEmail = '',
1507
+ trackId = '',
1508
+ trackCode = '',
1509
+ score = undefined,
1510
+ examScore = undefined,
875
1511
  } = {}) {
876
1512
  const p = this.state.partners[partnerId];
877
1513
  if (!p)
@@ -879,11 +1515,21 @@ export class LicenseStore {
879
1515
  if (!specialistName)
880
1516
  throw new Error('Укажите ФИО аттестуемого специалиста');
881
1517
 
882
- const track = PARTNER_TRAINING_TRACKS.find(t => t.id === trackId || t.code === trackId);
1518
+ const requestedTrack = String(trackId || trackCode || '').trim();
1519
+ let track = null;
1520
+ if (!requestedTrack || requestedTrack === 'next' || requestedTrack === 'TRK-PAK-HW') {
1521
+ const passedIds = new Set((p.certifiedSpecialists || []).map(c => c.trackId));
1522
+ track = PARTNER_TRAINING_TRACKS.find(t => t.requiredForFranchise && !passedIds.has(t.id))
1523
+ || PARTNER_TRAINING_TRACKS.find(t => !passedIds.has(t.id))
1524
+ || PARTNER_TRAINING_TRACKS[0];
1525
+ }
1526
+ else {
1527
+ track = PARTNER_TRAINING_TRACKS.find(t => t.id === requestedTrack || t.code === requestedTrack);
1528
+ }
883
1529
  if (!track)
884
- throw new Error(`Учебный курс ${trackId} не найден в программе обучения`);
1530
+ throw new Error(`Учебный курс ${requestedTrack} не найден в программе обучения`);
885
1531
 
886
- const numericScore = Number(score ?? 0);
1532
+ const numericScore = Number(score ?? examScore ?? 100);
887
1533
  if (numericScore < track.passScore) {
888
1534
  throw new Error(
889
1535
  `Балл аттестации (${numericScore}) ниже проходного порога (${track.passScore}) для курса «${track.title}»`
@@ -898,7 +1544,7 @@ export class LicenseStore {
898
1544
  companyName: p.companyName,
899
1545
  regionCode: p.regionCode,
900
1546
  specialistName: String(specialistName).trim(),
901
- email: String(email || '').trim(),
1547
+ email: String(email || specialistEmail || '').trim(),
902
1548
  trackId: track.id,
903
1549
  trackCode: track.code,
904
1550
  trackTitle: track.title,