work-license-server 1.0.2 → 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/README.md +9 -2
- package/assets/admin.js +572 -0
- package/assets/landing.css +113 -1
- package/assets/landing.js +322 -290
- package/bin/server.mjs +5 -2
- package/package.json +1 -1
- package/src/admin.js +319 -0
- package/src/agent.js +152 -8
- package/src/bots.js +1 -1
- package/src/catalog.js +153 -17
- package/src/crypto.js +249 -17
- package/src/index.js +22 -1
- package/src/landing.js +113 -141
- package/src/server.js +229 -24
- package/src/store.js +696 -50
- package/src/yookassa.js +22 -8
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 {
|
|
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,
|
|
@@ -138,7 +212,7 @@ export class LicenseStore {
|
|
|
138
212
|
perpetualBase: true,
|
|
139
213
|
addonSubscriptions: Array.isArray(existing?.addonSubscriptions) ? existing.addonSubscriptions : [],
|
|
140
214
|
support: {
|
|
141
|
-
tier: existing?.support?.tier || includedSupportTier || '
|
|
215
|
+
tier: existing?.support?.tier || includedSupportTier || PAK_PRODUCT.includedSupportTier || 'enterprise',
|
|
142
216
|
includedUntil: existing?.support?.includedUntil || (issuedAt + ONE_YEAR_MS),
|
|
143
217
|
ticketsUsed: Number(existing?.support?.ticketsUsed || 0),
|
|
144
218
|
bindCode,
|
|
@@ -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 год гарантии на железо).
|
|
@@ -247,9 +410,9 @@ export class LicenseStore {
|
|
|
247
410
|
const baseSupportActive = baseSupportUntil > now;
|
|
248
411
|
|
|
249
412
|
let addonSeats = 0;
|
|
250
|
-
let highestTier = baseSupportActive ? (lic.support?.tier || '
|
|
413
|
+
let highestTier = baseSupportActive ? (lic.support?.tier || PAK_PRODUCT.includedSupportTier || 'enterprise') : null;
|
|
251
414
|
let ticketsQuota = baseSupportActive
|
|
252
|
-
? baseSeats * (SLA_TIERS[lic.support?.tier || '
|
|
415
|
+
? baseSeats * (SLA_TIERS[lic.support?.tier || PAK_PRODUCT.includedSupportTier || 'enterprise']?.ticketsLimitPerSeatYear || 9999)
|
|
253
416
|
: 0;
|
|
254
417
|
|
|
255
418
|
const rank = { start: 1, business: 2, enterprise: 3 };
|
|
@@ -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:
|
|
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({
|
|
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
|
|
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
|
-
|
|
586
|
+
paymentOrderNumber: '',
|
|
587
|
+
confirmationUrl: method === 'invoice' ? `/api/orders/${orderId}/invoice` : '',
|
|
387
588
|
returnUrl: returnUrl || 'https://work.odant.org',
|
|
388
|
-
createdAt:
|
|
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.
|
|
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({
|
|
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 для
|
|
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
|
|
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
|
-
|
|
1109
|
+
ok: true,
|
|
1110
|
+
token: tokData.token,
|
|
1111
|
+
tokenId: tokData.payload.tokenId,
|
|
1112
|
+
accountId: user.userId,
|
|
639
1113
|
email: user.email,
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
822
|
-
|
|
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:
|
|
831
|
-
phone:
|
|
832
|
-
contactPerson:
|
|
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
|
-
|
|
874
|
-
|
|
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
|
|
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(`Учебный курс ${
|
|
1530
|
+
throw new Error(`Учебный курс ${requestedTrack} не найден в программе обучения`);
|
|
885
1531
|
|
|
886
|
-
const numericScore = Number(score ??
|
|
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,
|