work-license-server 0.0.0-stage → 1.0.0
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 +17 -2
- package/bin/server.mjs +14 -0
- package/package.json +29 -4
- package/src/bots.js +311 -0
- package/src/catalog.js +451 -0
- package/src/crypto.js +120 -0
- package/src/index.js +17 -0
- package/src/landing.js +981 -0
- package/src/server.js +290 -0
- package/src/store.js +758 -0
- package/src/yookassa.js +184 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Хранилище лицензионного сервера work.odant.org:
|
|
3
|
+
* - Реестр выпущенных лицензий ПАК (вечные 5 мест + 1 год включённой поддержки + срочные годовые расширения);
|
|
4
|
+
* - Заказы и платежи ЮKassa;
|
|
5
|
+
* - Привязки клиентских чатов (MAX, Telegram, VK) по коду привязки или номеру лицензии;
|
|
6
|
+
* - Тикеты техподдержки с расчётом SLA времени реакции и лимитов обращений по уровням Start / Business / Enterprise.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import {
|
|
12
|
+
PAK_PRODUCT,
|
|
13
|
+
SLA_TIERS,
|
|
14
|
+
calculateOrderQuote,
|
|
15
|
+
REGIONS_CATALOG,
|
|
16
|
+
PARTNER_TRAINING_TRACKS,
|
|
17
|
+
calculateFranchiseFee,
|
|
18
|
+
calculatePartnerDiscount,
|
|
19
|
+
} from './catalog.js';
|
|
20
|
+
import { loadOrCreateAuthorityKeys, signCertificate } from './crypto.js';
|
|
21
|
+
|
|
22
|
+
const ONE_YEAR_MS = 365 * 24 * 3600_000;
|
|
23
|
+
|
|
24
|
+
export class LicenseStore {
|
|
25
|
+
constructor({ dataDir = './data' } = {}) {
|
|
26
|
+
this.dataDir = dataDir;
|
|
27
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
28
|
+
this.stateFile = path.join(this.dataDir, 'store.json');
|
|
29
|
+
this.keyFile = path.join(this.dataDir, 'authority-key.json');
|
|
30
|
+
this.authorityKeys = loadOrCreateAuthorityKeys(this.keyFile);
|
|
31
|
+
|
|
32
|
+
this.state = {
|
|
33
|
+
licenses: {},
|
|
34
|
+
orders: {},
|
|
35
|
+
tickets: {},
|
|
36
|
+
botBindings: {},
|
|
37
|
+
partners: {},
|
|
38
|
+
certificates: {},
|
|
39
|
+
};
|
|
40
|
+
this._load();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_load() {
|
|
44
|
+
if (!fs.existsSync(this.stateFile))
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
|
|
48
|
+
if (raw && typeof raw === 'object') {
|
|
49
|
+
this.state.licenses = raw.licenses || {};
|
|
50
|
+
this.state.orders = raw.orders || {};
|
|
51
|
+
this.state.tickets = raw.tickets || {};
|
|
52
|
+
this.state.botBindings = raw.botBindings || {};
|
|
53
|
+
this.state.partners = raw.partners || {};
|
|
54
|
+
this.state.certificates = raw.certificates || {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* начинаем с чистого состояния */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_save() {
|
|
61
|
+
const tmp = `${this.stateFile}.${process.pid}.tmp`;
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), 'utf8');
|
|
63
|
+
fs.renameSync(tmp, this.stateFile);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Зарегистрировать или обновить базовую лицензию ПАК (5 вечных мест + 1 год включённой поддержки).
|
|
68
|
+
* Если лицензию выдаёт региональный партнёр (partnerId) — фиксируется партнёр-интегратор,
|
|
69
|
+
* привязка оборудования и годовая гарантия на оборудование от партнёра (1 год).
|
|
70
|
+
*/
|
|
71
|
+
issueOrUpdateLicense({
|
|
72
|
+
licenseId,
|
|
73
|
+
hwid = '',
|
|
74
|
+
nodeFingerprint = '',
|
|
75
|
+
customer = {},
|
|
76
|
+
partnerId = '',
|
|
77
|
+
hardwareWarrantyYears = 1,
|
|
78
|
+
partnerAddons = {},
|
|
79
|
+
baseSeats = PAK_PRODUCT.baseSeats,
|
|
80
|
+
includedSupportTier = PAK_PRODUCT.includedSupportTier,
|
|
81
|
+
} = {}) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
let existing = licenseId ? this.state.licenses[licenseId] : null;
|
|
84
|
+
if (!existing && hwid) {
|
|
85
|
+
existing = Object.values(this.state.licenses).find(l => l.hardware?.hwid === hwid) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const partner = partnerId ? this.state.partners[partnerId] : (existing?.partner?.partnerId ? this.state.partners[existing.partner.partnerId] : null);
|
|
89
|
+
const warrantyYears = Math.max(1, Number(hardwareWarrantyYears ?? 1));
|
|
90
|
+
|
|
91
|
+
const id = existing?.licenseId || licenseId || ('WORK-PAK-' + crypto.randomBytes(4).toString('hex').toUpperCase());
|
|
92
|
+
const issuedAt = existing?.issuedAt || now;
|
|
93
|
+
const bindCode = existing?.support?.bindCode || ('BIND-' + crypto.randomBytes(3).toString('hex').toUpperCase());
|
|
94
|
+
|
|
95
|
+
const record = {
|
|
96
|
+
licenseId: id,
|
|
97
|
+
issuedAt,
|
|
98
|
+
updatedAt: now,
|
|
99
|
+
issuer: 'work.odant.org',
|
|
100
|
+
customer: {
|
|
101
|
+
name: customer.name || existing?.customer?.name || 'Организация ПАК WORK',
|
|
102
|
+
inn: customer.inn || existing?.customer?.inn || '',
|
|
103
|
+
email: customer.email || existing?.customer?.email || '',
|
|
104
|
+
phone: customer.phone || existing?.customer?.phone || '',
|
|
105
|
+
},
|
|
106
|
+
hardware: {
|
|
107
|
+
hwid: hwid || existing?.hardware?.hwid || ('PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase()),
|
|
108
|
+
nodeFingerprint: nodeFingerprint || existing?.hardware?.nodeFingerprint || '',
|
|
109
|
+
sku: PAK_PRODUCT.sku,
|
|
110
|
+
cpu: PAK_PRODUCT.hardware.cpu,
|
|
111
|
+
ramGb: PAK_PRODUCT.hardware.ramGb,
|
|
112
|
+
ssdTb: PAK_PRODUCT.hardware.ssdTb,
|
|
113
|
+
warrantyYears,
|
|
114
|
+
warrantyUntil: existing?.hardware?.warrantyUntil || (issuedAt + warrantyYears * ONE_YEAR_MS),
|
|
115
|
+
warrantyProvider: partner ? partner.companyName : (existing?.hardware?.warrantyProvider || 'Партнёр-интегратор WORK'),
|
|
116
|
+
},
|
|
117
|
+
partner: partner ? {
|
|
118
|
+
partnerId: partner.partnerId,
|
|
119
|
+
companyName: partner.companyName,
|
|
120
|
+
regionCode: partner.regionCode,
|
|
121
|
+
regionName: partner.regionName,
|
|
122
|
+
hardwareWarrantyYears: warrantyYears,
|
|
123
|
+
hardwareWarrantyUntil: issuedAt + warrantyYears * ONE_YEAR_MS,
|
|
124
|
+
addons: {
|
|
125
|
+
nasBackup: Boolean(partnerAddons.nasBackup ?? existing?.partner?.addons?.nasBackup),
|
|
126
|
+
inferenceHardware: Boolean(partnerAddons.inferenceHardware ?? existing?.partner?.addons?.inferenceHardware),
|
|
127
|
+
partnerModelApiUrl: String(partnerAddons.partnerModelApiUrl ?? existing?.partner?.addons?.partnerModelApiUrl ?? ''),
|
|
128
|
+
techSupportContract: Boolean(partnerAddons.techSupportContract ?? existing?.partner?.addons?.techSupportContract ?? true),
|
|
129
|
+
agentTrainingSkills: Boolean(partnerAddons.agentTrainingSkills ?? existing?.partner?.addons?.agentTrainingSkills),
|
|
130
|
+
},
|
|
131
|
+
} : (existing?.partner || null),
|
|
132
|
+
baseSeats: Number(baseSeats ?? existing?.baseSeats ?? PAK_PRODUCT.baseSeats),
|
|
133
|
+
perpetualBase: true,
|
|
134
|
+
addonSubscriptions: Array.isArray(existing?.addonSubscriptions) ? existing.addonSubscriptions : [],
|
|
135
|
+
support: {
|
|
136
|
+
tier: existing?.support?.tier || includedSupportTier || 'business',
|
|
137
|
+
includedUntil: existing?.support?.includedUntil || (issuedAt + ONE_YEAR_MS),
|
|
138
|
+
ticketsUsed: Number(existing?.support?.ticketsUsed || 0),
|
|
139
|
+
bindCode,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const signed = signCertificate(record, this.authorityKeys);
|
|
144
|
+
this.state.licenses[id] = signed;
|
|
145
|
+
this._save();
|
|
146
|
+
return this.getLicenseSummary(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Выдача и привязка лицензии ПАК непосредственно региональным партнёром
|
|
151
|
+
* (партнёр сам закупает оборудование, устанавливает стек, привязывает HWID и даёт 1 год гарантии на железо).
|
|
152
|
+
*/
|
|
153
|
+
partnerIssueLicense({
|
|
154
|
+
partnerId,
|
|
155
|
+
hwid = '',
|
|
156
|
+
nodeFingerprint = '',
|
|
157
|
+
customer = {},
|
|
158
|
+
hardwareWarrantyYears = 1,
|
|
159
|
+
partnerAddons = {},
|
|
160
|
+
recordSaleAmountRub = PAK_PRODUCT.basePriceRub,
|
|
161
|
+
} = {}) {
|
|
162
|
+
const p = this.state.partners[partnerId];
|
|
163
|
+
if (!p)
|
|
164
|
+
throw new Error(`Партнёр ${partnerId} не найден`);
|
|
165
|
+
const licSummary = this.issueOrUpdateLicense({
|
|
166
|
+
hwid,
|
|
167
|
+
nodeFingerprint,
|
|
168
|
+
customer,
|
|
169
|
+
partnerId: p.partnerId,
|
|
170
|
+
hardwareWarrantyYears,
|
|
171
|
+
partnerAddons,
|
|
172
|
+
});
|
|
173
|
+
p.issuedLicenses ??= [];
|
|
174
|
+
if (!p.issuedLicenses.includes(licSummary.licenseId)) {
|
|
175
|
+
p.issuedLicenses.push(licSummary.licenseId);
|
|
176
|
+
}
|
|
177
|
+
if (Number(recordSaleAmountRub) > 0) {
|
|
178
|
+
this.recordPartnerSale(p.partnerId, Number(recordSaleAmountRub));
|
|
179
|
+
}
|
|
180
|
+
this._save();
|
|
181
|
+
return {
|
|
182
|
+
license: this.getLicenseSummary(licSummary.licenseId),
|
|
183
|
+
partner: this.getPartnerSummary(p.partnerId),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Добавить срочную годовую подписку на дополнительные пользовательские места (после оплаты ЮKassa).
|
|
189
|
+
*/
|
|
190
|
+
grantAddonSubscription(licenseId, { tier = 'business', seats = 1, years = 1, orderId = '', paymentId = '' } = {}) {
|
|
191
|
+
let lic = this.state.licenses[licenseId];
|
|
192
|
+
if (!lic) {
|
|
193
|
+
this.issueOrUpdateLicense({ licenseId });
|
|
194
|
+
lic = this.state.licenses[licenseId];
|
|
195
|
+
}
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const tierKey = String(tier || 'business').toLowerCase();
|
|
198
|
+
const tierSpec = SLA_TIERS[tierKey] || SLA_TIERS.business;
|
|
199
|
+
const seatsCount = Math.max(0, Number(seats || 0));
|
|
200
|
+
const yearsCount = Math.max(1, Number(years || 1));
|
|
201
|
+
|
|
202
|
+
if (seatsCount > 0) {
|
|
203
|
+
// Ищем, не была ли уже выдана подписка по этому orderId (идемпотентность webhook ЮKassa)
|
|
204
|
+
const already = (lic.addonSubscriptions || []).find(s => orderId && s.orderId === orderId);
|
|
205
|
+
if (!already) {
|
|
206
|
+
lic.addonSubscriptions.push({
|
|
207
|
+
id: 'SUB-' + crypto.randomBytes(4).toString('hex').toUpperCase(),
|
|
208
|
+
tier: tierSpec.id,
|
|
209
|
+
seats: seatsCount,
|
|
210
|
+
years: yearsCount,
|
|
211
|
+
issuedAt: now,
|
|
212
|
+
expiresAt: now + yearsCount * ONE_YEAR_MS,
|
|
213
|
+
orderId: String(orderId || ''),
|
|
214
|
+
paymentId: String(paymentId || ''),
|
|
215
|
+
status: 'active',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Повышаем активный уровень поддержки, если докуплен более высокий тариф
|
|
221
|
+
const rank = { start: 1, business: 2, enterprise: 3 };
|
|
222
|
+
if ((rank[tierSpec.id] || 0) > (rank[lic.support?.tier] || 0)) {
|
|
223
|
+
lic.support.tier = tierSpec.id;
|
|
224
|
+
}
|
|
225
|
+
lic.updatedAt = now;
|
|
226
|
+
const signed = signCertificate(lic, this.authorityKeys);
|
|
227
|
+
this.state.licenses[licenseId] = signed;
|
|
228
|
+
this._save();
|
|
229
|
+
return this.getLicenseSummary(licenseId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Получить развёрнутую сводку по лицензии (с расчётом активных мест, SLA и квоты обращений).
|
|
234
|
+
*/
|
|
235
|
+
getLicenseSummary(licenseIdOrCode, now = Date.now()) {
|
|
236
|
+
const lic = this.findLicense(licenseIdOrCode);
|
|
237
|
+
if (!lic)
|
|
238
|
+
return null;
|
|
239
|
+
|
|
240
|
+
const baseSeats = Number(lic.baseSeats ?? PAK_PRODUCT.baseSeats);
|
|
241
|
+
const baseSupportUntil = Number(lic.support?.includedUntil || (lic.issuedAt + ONE_YEAR_MS));
|
|
242
|
+
const baseSupportActive = baseSupportUntil > now;
|
|
243
|
+
|
|
244
|
+
let addonSeats = 0;
|
|
245
|
+
let highestTier = baseSupportActive ? (lic.support?.tier || 'business') : null;
|
|
246
|
+
let ticketsQuota = baseSupportActive
|
|
247
|
+
? baseSeats * (SLA_TIERS[lic.support?.tier || 'business']?.ticketsLimitPerSeatYear || 48)
|
|
248
|
+
: 0;
|
|
249
|
+
|
|
250
|
+
const rank = { start: 1, business: 2, enterprise: 3 };
|
|
251
|
+
const activeAddons = [];
|
|
252
|
+
|
|
253
|
+
for (const sub of (lic.addonSubscriptions || [])) {
|
|
254
|
+
if (sub.status === 'active' && Number(sub.expiresAt) > now && Number(sub.seats) > 0) {
|
|
255
|
+
const tSpec = SLA_TIERS[sub.tier] || SLA_TIERS.start;
|
|
256
|
+
addonSeats += Number(sub.seats);
|
|
257
|
+
ticketsQuota += Number(sub.seats) * tSpec.ticketsLimitPerSeatYear;
|
|
258
|
+
if (!highestTier || (rank[tSpec.id] || 0) > (rank[highestTier] || 0))
|
|
259
|
+
highestTier = tSpec.id;
|
|
260
|
+
activeAddons.push({ ...sub, tierName: tSpec.name });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const effectiveTier = SLA_TIERS[highestTier || lic.support?.tier || 'start'] || SLA_TIERS.start;
|
|
265
|
+
if (ticketsQuota === 0)
|
|
266
|
+
ticketsQuota = baseSeats * SLA_TIERS.start.ticketsLimitPerSeatYear;
|
|
267
|
+
|
|
268
|
+
const ticketsUsed = Number(lic.support?.ticketsUsed || 0);
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
licenseId: lic.licenseId,
|
|
272
|
+
issuedAt: lic.issuedAt,
|
|
273
|
+
updatedAt: lic.updatedAt,
|
|
274
|
+
customer: lic.customer,
|
|
275
|
+
hardware: lic.hardware,
|
|
276
|
+
partner: lic.partner || null,
|
|
277
|
+
baseSeats,
|
|
278
|
+
perpetualBase: true,
|
|
279
|
+
addonSeats,
|
|
280
|
+
totalSeats: baseSeats + addonSeats,
|
|
281
|
+
baseSupportUntil,
|
|
282
|
+
baseSupportActive,
|
|
283
|
+
activeAddons,
|
|
284
|
+
support: {
|
|
285
|
+
active: baseSupportActive || activeAddons.length > 0,
|
|
286
|
+
tier: effectiveTier.id,
|
|
287
|
+
tierName: effectiveTier.name,
|
|
288
|
+
reactionMinutes: effectiveTier.reactionMinutes,
|
|
289
|
+
reactionLabel: effectiveTier.reactionLabel,
|
|
290
|
+
scheduleLabel: effectiveTier.scheduleLabel,
|
|
291
|
+
ticketsQuota,
|
|
292
|
+
ticketsUsed,
|
|
293
|
+
ticketsRemaining: Math.max(0, ticketsQuota - ticketsUsed),
|
|
294
|
+
bindCode: lic.support?.bindCode || '',
|
|
295
|
+
},
|
|
296
|
+
certificate: lic,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Найти лицензию по licenseId, HWID или коду привязки бота (bindCode).
|
|
302
|
+
*/
|
|
303
|
+
findLicense(query) {
|
|
304
|
+
const q = String(query || '').trim();
|
|
305
|
+
if (!q)
|
|
306
|
+
return null;
|
|
307
|
+
if (this.state.licenses[q])
|
|
308
|
+
return this.state.licenses[q];
|
|
309
|
+
const upper = q.toUpperCase();
|
|
310
|
+
for (const lic of Object.values(this.state.licenses)) {
|
|
311
|
+
if (
|
|
312
|
+
lic.licenseId.toUpperCase() === upper
|
|
313
|
+
|| lic.hardware?.hwid?.toUpperCase() === upper
|
|
314
|
+
|| lic.support?.bindCode?.toUpperCase() === upper
|
|
315
|
+
) {
|
|
316
|
+
return lic;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
listLicenses() {
|
|
323
|
+
return Object.keys(this.state.licenses).map(id => this.getLicenseSummary(id));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Создать заказ на покупку ПАК и/или дополнительных годовых лицензий.
|
|
328
|
+
*/
|
|
329
|
+
createOrder({
|
|
330
|
+
licenseId = '',
|
|
331
|
+
hwid = '',
|
|
332
|
+
nodeFingerprint = '',
|
|
333
|
+
includeBasePak = false,
|
|
334
|
+
tier = 'business',
|
|
335
|
+
addonSeats = 0,
|
|
336
|
+
years = 1,
|
|
337
|
+
customer = {},
|
|
338
|
+
partnerId = '',
|
|
339
|
+
returnUrl = '',
|
|
340
|
+
} = {}) {
|
|
341
|
+
const quote = calculateOrderQuote({ includeBasePak, tier, addonSeats, years });
|
|
342
|
+
if (quote.totalAmountRub <= 0)
|
|
343
|
+
throw new Error('Сумма заказа должна быть больше 0 ₽');
|
|
344
|
+
|
|
345
|
+
let targetLic = this.findLicense(licenseId || hwid);
|
|
346
|
+
if (!targetLic) {
|
|
347
|
+
targetLic = this.issueOrUpdateLicense({
|
|
348
|
+
licenseId: licenseId || undefined,
|
|
349
|
+
hwid,
|
|
350
|
+
nodeFingerprint,
|
|
351
|
+
customer,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const partner = partnerId ? this.getPartnerSummary(partnerId) : null;
|
|
356
|
+
const partnerDiscountPercent = partner ? partner.discountPercent : 0;
|
|
357
|
+
const partnerMarginRub = partner ? Math.round(quote.totalAmountRub * (partnerDiscountPercent / 100)) : 0;
|
|
358
|
+
|
|
359
|
+
const orderId = 'ORD-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
|
|
360
|
+
const order = {
|
|
361
|
+
orderId,
|
|
362
|
+
licenseId: targetLic.licenseId,
|
|
363
|
+
partnerId: partner ? partner.partnerId : '',
|
|
364
|
+
partnerDiscountPercent,
|
|
365
|
+
partnerMarginRub,
|
|
366
|
+
includeBasePak: quote.includeBasePak,
|
|
367
|
+
tier: quote.tier,
|
|
368
|
+
tierName: quote.tierName,
|
|
369
|
+
addonSeats: quote.addonSeats,
|
|
370
|
+
years: quote.years,
|
|
371
|
+
amountRub: quote.totalAmountRub,
|
|
372
|
+
items: quote.items,
|
|
373
|
+
customer: {
|
|
374
|
+
name: customer.name || targetLic.customer?.name || 'Организация WORK',
|
|
375
|
+
inn: customer.inn || targetLic.customer?.inn || '',
|
|
376
|
+
email: customer.email || targetLic.customer?.email || 'billing@odant.org',
|
|
377
|
+
phone: customer.phone || targetLic.customer?.phone || '',
|
|
378
|
+
},
|
|
379
|
+
status: 'pending',
|
|
380
|
+
paymentId: '',
|
|
381
|
+
confirmationUrl: '',
|
|
382
|
+
returnUrl: returnUrl || 'https://work.odant.org',
|
|
383
|
+
createdAt: Date.now(),
|
|
384
|
+
paidAt: null,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
this.state.orders[orderId] = order;
|
|
388
|
+
this._save();
|
|
389
|
+
return order;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
getOrder(orderId) {
|
|
393
|
+
return this.state.orders[orderId] || null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
findOrderByPaymentId(paymentId) {
|
|
397
|
+
if (!paymentId)
|
|
398
|
+
return null;
|
|
399
|
+
return Object.values(this.state.orders).find(o => o.paymentId === paymentId) || null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
updateOrder(orderId, patch = {}) {
|
|
403
|
+
const ord = this.state.orders[orderId];
|
|
404
|
+
if (!ord)
|
|
405
|
+
return null;
|
|
406
|
+
Object.assign(ord, patch);
|
|
407
|
+
this._save();
|
|
408
|
+
return ord;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Отметить заказ как оплаченный и выпустить/продлить лицензию.
|
|
413
|
+
*/
|
|
414
|
+
completePaidOrder(orderId, { paymentId = '' } = {}) {
|
|
415
|
+
const ord = this.state.orders[orderId];
|
|
416
|
+
if (!ord)
|
|
417
|
+
throw new Error(`Заказ ${orderId} не найден`);
|
|
418
|
+
if (ord.status === 'paid') {
|
|
419
|
+
return {
|
|
420
|
+
order: ord,
|
|
421
|
+
license: this.getLicenseSummary(ord.licenseId),
|
|
422
|
+
alreadyProcessed: true,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
ord.status = 'paid';
|
|
426
|
+
ord.paidAt = Date.now();
|
|
427
|
+
if (paymentId)
|
|
428
|
+
ord.paymentId = paymentId;
|
|
429
|
+
|
|
430
|
+
const license = this.grantAddonSubscription(ord.licenseId, {
|
|
431
|
+
tier: ord.tier,
|
|
432
|
+
seats: ord.addonSeats,
|
|
433
|
+
years: ord.years,
|
|
434
|
+
orderId: ord.orderId,
|
|
435
|
+
paymentId: ord.paymentId,
|
|
436
|
+
});
|
|
437
|
+
let partner = null;
|
|
438
|
+
if (ord.partnerId && this.state.partners[ord.partnerId]) {
|
|
439
|
+
partner = this.recordPartnerSale(ord.partnerId, ord.amountRub);
|
|
440
|
+
}
|
|
441
|
+
this._save();
|
|
442
|
+
return { order: ord, license, partner, alreadyProcessed: false };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Привязать чат мессенджера (MAX, Telegram, VK) к лицензии ПАК.
|
|
447
|
+
*/
|
|
448
|
+
bindBotChat({ channel, chatId, userName = '', codeOrLicenseId }) {
|
|
449
|
+
const lic = this.findLicense(codeOrLicenseId);
|
|
450
|
+
if (!lic)
|
|
451
|
+
throw new Error('Лицензия или код привязки не найден. Проверьте код в разделе «Лицензия ПАК» вашего сервера WORK.');
|
|
452
|
+
const key = `${String(channel).toLowerCase()}:${String(chatId)}`;
|
|
453
|
+
this.state.botBindings[key] = {
|
|
454
|
+
channel: String(channel).toLowerCase(),
|
|
455
|
+
chatId: String(chatId),
|
|
456
|
+
userName: String(userName || ''),
|
|
457
|
+
licenseId: lic.licenseId,
|
|
458
|
+
boundAt: Date.now(),
|
|
459
|
+
};
|
|
460
|
+
this._save();
|
|
461
|
+
return this.getLicenseSummary(lic.licenseId);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
getBoundLicenseForChat(channel, chatId) {
|
|
465
|
+
const key = `${String(channel).toLowerCase()}:${String(chatId)}`;
|
|
466
|
+
const binding = this.state.botBindings[key];
|
|
467
|
+
if (!binding?.licenseId)
|
|
468
|
+
return null;
|
|
469
|
+
return this.getLicenseSummary(binding.licenseId);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
getBindingsForLicense(licenseId) {
|
|
473
|
+
return Object.values(this.state.botBindings).filter(b => b.licenseId === licenseId);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Создать обращение (тикет) в техподдержку из MAX, Telegram, VK или веб-панели
|
|
478
|
+
* с автоматическим контролем квоты обращений и SLA времени реакции по тарифу (Start / Business / Enterprise).
|
|
479
|
+
*/
|
|
480
|
+
createSupportTicket({
|
|
481
|
+
licenseId,
|
|
482
|
+
channel = 'telegram',
|
|
483
|
+
chatId = '',
|
|
484
|
+
userName = 'Клиент',
|
|
485
|
+
text = '',
|
|
486
|
+
}) {
|
|
487
|
+
const summary = this.getLicenseSummary(licenseId);
|
|
488
|
+
if (!summary)
|
|
489
|
+
throw new Error('Лицензия ПАК не найдена');
|
|
490
|
+
|
|
491
|
+
if (summary.support.ticketsRemaining <= 0) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Исчерпан годовой лимит обращений по тарифу ${summary.support.tierName} `
|
|
494
|
+
+ `(${summary.support.ticketsUsed}/${summary.support.ticketsQuota}). `
|
|
495
|
+
+ `Для увеличения лимита и ускорения реакции перейдите на тариф Business или Enterprise на https://work.odant.org`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const rawLic = this.state.licenses[summary.licenseId];
|
|
500
|
+
rawLic.support.ticketsUsed = Number(rawLic.support?.ticketsUsed || 0) + 1;
|
|
501
|
+
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
const reactionMinutes = summary.support.reactionMinutes;
|
|
504
|
+
const slaDeadlineAt = now + reactionMinutes * 60_000;
|
|
505
|
+
const ticketId = 'TCK-' + Object.keys(this.state.tickets).length.toString().padStart(4, '0') + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
|
|
506
|
+
|
|
507
|
+
const ticket = {
|
|
508
|
+
ticketId,
|
|
509
|
+
licenseId: summary.licenseId,
|
|
510
|
+
customerName: summary.customer?.name || 'Клиент ПАК WORK',
|
|
511
|
+
channel: String(channel).toLowerCase(),
|
|
512
|
+
chatId: String(chatId),
|
|
513
|
+
userName: String(userName),
|
|
514
|
+
tier: summary.support.tier,
|
|
515
|
+
tierName: summary.support.tierName,
|
|
516
|
+
reactionMinutes,
|
|
517
|
+
reactionLabel: summary.support.reactionLabel,
|
|
518
|
+
slaDeadlineAt,
|
|
519
|
+
status: 'open',
|
|
520
|
+
createdAt: now,
|
|
521
|
+
firstReplyAt: null,
|
|
522
|
+
slaMet: null,
|
|
523
|
+
messages: [
|
|
524
|
+
{
|
|
525
|
+
from: 'client',
|
|
526
|
+
author: userName,
|
|
527
|
+
text: String(text || '').trim(),
|
|
528
|
+
time: now,
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
this.state.tickets[ticketId] = ticket;
|
|
534
|
+
this._save();
|
|
535
|
+
return {
|
|
536
|
+
ticket,
|
|
537
|
+
license: this.getLicenseSummary(summary.licenseId),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Ответить на тикет от лица инженера поддержки (и зафиксировать выполнение SLA времени реакции).
|
|
543
|
+
*/
|
|
544
|
+
replyToTicket(ticketId, { engineerName = 'Инженер поддержки ODANT', text = '' } = {}) {
|
|
545
|
+
const t = this.state.tickets[ticketId];
|
|
546
|
+
if (!t)
|
|
547
|
+
throw new Error(`Тикет ${ticketId} не найден`);
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
if (!t.firstReplyAt) {
|
|
550
|
+
t.firstReplyAt = now;
|
|
551
|
+
t.slaMet = now <= t.slaDeadlineAt;
|
|
552
|
+
}
|
|
553
|
+
t.status = 'answered';
|
|
554
|
+
t.messages.push({
|
|
555
|
+
from: 'support',
|
|
556
|
+
author: engineerName,
|
|
557
|
+
text: String(text || '').trim(),
|
|
558
|
+
time: now,
|
|
559
|
+
});
|
|
560
|
+
this._save();
|
|
561
|
+
return t;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
listTickets({ licenseId = '' } = {}) {
|
|
565
|
+
const all = Object.values(this.state.tickets).sort((a, b) => b.createdAt - a.createdAt);
|
|
566
|
+
if (licenseId)
|
|
567
|
+
return all.filter(t => t.licenseId === licenseId);
|
|
568
|
+
return all;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Получить статус квоты партнёров по региону (макс. число партнёров пропорционально населению).
|
|
573
|
+
*/
|
|
574
|
+
getRegionPartnerQuotaStatus(regionCode = 'RU-SVE', customPopulationMillions = null) {
|
|
575
|
+
const info = calculateFranchiseFee(regionCode, customPopulationMillions);
|
|
576
|
+
const existingInRegion = Object.values(this.state.partners).filter(
|
|
577
|
+
p => p.regionCode === info.regionCode && p.status !== 'revoked'
|
|
578
|
+
);
|
|
579
|
+
return {
|
|
580
|
+
...info,
|
|
581
|
+
activePartnersCount: existingInRegion.length,
|
|
582
|
+
availablePartnerSlots: Math.max(0, info.maxPartnersQuota - existingInRegion.length),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Зарегистрировать регионального партнёра-франчайзи с контролем региональной квоты (по населению)
|
|
588
|
+
* и расчётом фиксированного паушального взноса (пропорционально экономике региона).
|
|
589
|
+
*/
|
|
590
|
+
registerPartner({
|
|
591
|
+
partnerId = '',
|
|
592
|
+
companyName = '',
|
|
593
|
+
inn = '',
|
|
594
|
+
regionCode = 'RU-SVE',
|
|
595
|
+
populationMillions = null,
|
|
596
|
+
email = '',
|
|
597
|
+
phone = '',
|
|
598
|
+
contactPerson = '',
|
|
599
|
+
annualSalesRub = 0,
|
|
600
|
+
} = {}) {
|
|
601
|
+
if (!companyName)
|
|
602
|
+
throw new Error('Укажите наименование компании-партнёра');
|
|
603
|
+
|
|
604
|
+
const quotaStatus = this.getRegionPartnerQuotaStatus(regionCode, populationMillions);
|
|
605
|
+
const existing = partnerId ? this.state.partners[partnerId] : null;
|
|
606
|
+
|
|
607
|
+
if (!existing && quotaStatus.availablePartnerSlots <= 0) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`В регионе «${quotaStatus.regionName}» исчерпана квота партнёров `
|
|
610
|
+
+ `(${quotaStatus.activePartnersCount} из ${quotaStatus.maxPartnersQuota}, `
|
|
611
|
+
+ `пропорционально населению ${quotaStatus.populationMillions} млн чел.)`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const id = existing?.partnerId || partnerId || ('PRT-' + quotaStatus.regionCode.replace(/^RU-/, '') + '-' + crypto.randomBytes(3).toString('hex').toUpperCase());
|
|
616
|
+
const discountInfo = calculatePartnerDiscount(annualSalesRub ?? existing?.annualSalesRub ?? 0);
|
|
617
|
+
const now = Date.now();
|
|
618
|
+
|
|
619
|
+
const partner = {
|
|
620
|
+
partnerId: id,
|
|
621
|
+
companyName: String(companyName).trim(),
|
|
622
|
+
inn: String(inn || existing?.inn || '').trim(),
|
|
623
|
+
regionCode: quotaStatus.regionCode,
|
|
624
|
+
regionName: quotaStatus.regionName,
|
|
625
|
+
district: quotaStatus.district,
|
|
626
|
+
populationMillions: quotaStatus.populationMillions,
|
|
627
|
+
maxRegionPartnersQuota: quotaStatus.maxPartnersQuota,
|
|
628
|
+
economicIndex: quotaStatus.economicIndex,
|
|
629
|
+
franchiseFeeRub: quotaStatus.franchiseFeeRub,
|
|
630
|
+
email: String(email || existing?.email || '').trim(),
|
|
631
|
+
phone: String(phone || existing?.phone || '').trim(),
|
|
632
|
+
contactPerson: String(contactPerson || existing?.contactPerson || '').trim(),
|
|
633
|
+
annualSalesRub: discountInfo.annualSalesRub,
|
|
634
|
+
discountPercent: discountInfo.discountPercent,
|
|
635
|
+
statusLevel: discountInfo.statusLevel,
|
|
636
|
+
certifiedSpecialists: Array.isArray(existing?.certifiedSpecialists) ? existing.certifiedSpecialists : [],
|
|
637
|
+
certifiedStatus: Boolean(existing?.certifiedStatus),
|
|
638
|
+
status: existing?.status || 'active',
|
|
639
|
+
createdAt: existing?.createdAt || now,
|
|
640
|
+
updatedAt: now,
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
this.state.partners[id] = partner;
|
|
644
|
+
this._save();
|
|
645
|
+
return this.getPartnerSummary(id);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Зафиксировать продажу партнёра и пересчитать партнёрскую скидку (от 10% до 50% пропорционально годовому объёму).
|
|
650
|
+
*/
|
|
651
|
+
recordPartnerSale(partnerId, saleAmountRub = 0) {
|
|
652
|
+
const p = this.state.partners[partnerId];
|
|
653
|
+
if (!p)
|
|
654
|
+
throw new Error(`Партнёр ${partnerId} не найден`);
|
|
655
|
+
const added = Math.max(0, Number(saleAmountRub || 0));
|
|
656
|
+
const newSales = Number(p.annualSalesRub || 0) + added;
|
|
657
|
+
const discountInfo = calculatePartnerDiscount(newSales);
|
|
658
|
+
p.annualSalesRub = discountInfo.annualSalesRub;
|
|
659
|
+
p.discountPercent = discountInfo.discountPercent;
|
|
660
|
+
p.statusLevel = discountInfo.statusLevel;
|
|
661
|
+
p.updatedAt = Date.now();
|
|
662
|
+
this._save();
|
|
663
|
+
return this.getPartnerSummary(partnerId);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Провести аттестацию и выпустить сертификат специалиста партнёра по программе обучения WORK.
|
|
668
|
+
*/
|
|
669
|
+
certifyPartnerSpecialist({
|
|
670
|
+
partnerId,
|
|
671
|
+
specialistName = '',
|
|
672
|
+
email = '',
|
|
673
|
+
trackId = 'pak-ai-engineer',
|
|
674
|
+
score = 100,
|
|
675
|
+
} = {}) {
|
|
676
|
+
const p = this.state.partners[partnerId];
|
|
677
|
+
if (!p)
|
|
678
|
+
throw new Error(`Партнёр ${partnerId} не найден`);
|
|
679
|
+
if (!specialistName)
|
|
680
|
+
throw new Error('Укажите ФИО аттестуемого специалиста');
|
|
681
|
+
|
|
682
|
+
const track = PARTNER_TRAINING_TRACKS.find(t => t.id === trackId || t.code === trackId);
|
|
683
|
+
if (!track)
|
|
684
|
+
throw new Error(`Учебный курс ${trackId} не найден в программе обучения`);
|
|
685
|
+
|
|
686
|
+
const numericScore = Number(score ?? 0);
|
|
687
|
+
if (numericScore < track.passScore) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`Балл аттестации (${numericScore}) ниже проходного порога (${track.passScore}) для курса «${track.title}»`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const certId = `${track.code}-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
694
|
+
const now = Date.now();
|
|
695
|
+
const cert = {
|
|
696
|
+
certificateId: certId,
|
|
697
|
+
partnerId: p.partnerId,
|
|
698
|
+
companyName: p.companyName,
|
|
699
|
+
regionCode: p.regionCode,
|
|
700
|
+
specialistName: String(specialistName).trim(),
|
|
701
|
+
email: String(email || '').trim(),
|
|
702
|
+
trackId: track.id,
|
|
703
|
+
trackCode: track.code,
|
|
704
|
+
trackTitle: track.title,
|
|
705
|
+
hours: track.hours,
|
|
706
|
+
score: numericScore,
|
|
707
|
+
issuedAt: now,
|
|
708
|
+
validUntil: now + 2 * ONE_YEAR_MS,
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
this.state.certificates[certId] = cert;
|
|
712
|
+
p.certifiedSpecialists ??= [];
|
|
713
|
+
p.certifiedSpecialists.push(cert);
|
|
714
|
+
|
|
715
|
+
// Проверяем, закрыты ли все обязательные треки для статуса сертифицированного франчайзи
|
|
716
|
+
const requiredTrackIds = PARTNER_TRAINING_TRACKS.filter(t => t.requiredForFranchise).map(t => t.id);
|
|
717
|
+
const passedTrackIds = new Set(p.certifiedSpecialists.map(c => c.trackId));
|
|
718
|
+
p.certifiedStatus = requiredTrackIds.every(reqId => passedTrackIds.has(reqId));
|
|
719
|
+
p.updatedAt = now;
|
|
720
|
+
|
|
721
|
+
this._save();
|
|
722
|
+
return {
|
|
723
|
+
certificate: cert,
|
|
724
|
+
partner: this.getPartnerSummary(p.partnerId),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
getPartnerSummary(partnerId) {
|
|
729
|
+
const p = this.state.partners[partnerId];
|
|
730
|
+
if (!p)
|
|
731
|
+
return null;
|
|
732
|
+
const discountInfo = calculatePartnerDiscount(p.annualSalesRub);
|
|
733
|
+
const regionQuota = this.getRegionPartnerQuotaStatus(p.regionCode, p.populationMillions);
|
|
734
|
+
return {
|
|
735
|
+
...p,
|
|
736
|
+
discountPercent: discountInfo.discountPercent,
|
|
737
|
+
statusLevel: discountInfo.statusLevel,
|
|
738
|
+
nextTargetSalesRub: discountInfo.nextTargetSalesRub,
|
|
739
|
+
regionQuota: {
|
|
740
|
+
populationMillions: regionQuota.populationMillions,
|
|
741
|
+
maxPartnersQuota: regionQuota.maxPartnersQuota,
|
|
742
|
+
activePartnersCount: regionQuota.activePartnersCount,
|
|
743
|
+
availablePartnerSlots: regionQuota.availablePartnerSlots,
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
listPartners({ regionCode = '' } = {}) {
|
|
749
|
+
const list = Object.keys(this.state.partners).map(id => this.getPartnerSummary(id));
|
|
750
|
+
if (regionCode)
|
|
751
|
+
return list.filter(p => p.regionCode === regionCode);
|
|
752
|
+
return list;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
listRegionsWithQuotas() {
|
|
756
|
+
return REGIONS_CATALOG.map(r => this.getRegionPartnerQuotaStatus(r.code));
|
|
757
|
+
}
|
|
758
|
+
}
|