work-license-server 1.0.3 → 1.0.5

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/bin/server.mjs CHANGED
@@ -2,13 +2,16 @@
2
2
  /**
3
3
  * Точка запуска лицензионного сервера и лендинга ПАК WORK (work.odant.org).
4
4
  */
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { createLicenseServerApp } from '../src/server.js';
6
7
 
7
8
  const port = Number(process.env.PORT || process.env.WORK_LICENSE_PORT || 8020);
8
- const dataDir = process.env.WORK_LICENSE_DATA_DIR || './data';
9
+ const defaultDataDir = fileURLToPath(new URL('../data', import.meta.url));
10
+ const dataDir = process.env.WORK_LICENSE_DATA_DIR || defaultDataDir;
9
11
 
10
12
  const app = createLicenseServerApp({ dataDir });
11
13
  const server = await app.listen(port);
12
14
  const addr = server.address();
13
15
  const actualPort = typeof addr === 'object' && addr ? addr.port : port;
14
- console.log(`[work.odant.org] Лицензионный сервер и лендинг ПАК WORK запущен: http://localhost:${actualPort}/`);
16
+ console.log(`[work.odant.org] Витрина ODANT-WORK: http://localhost:${actualPort}/`);
17
+ console.log(`[work.odant.org] Панель администратора (отдельная страница): http://localhost:${actualPort}/admin (логин: ${app.store.adminLogin})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/admin.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Отдельная защищённая страница панели администратора центра лицензирования и биллинга ODANT-WORK (/admin).
3
+ *
4
+ * Не связана ссылками с публичной витриной (/).
5
+ * Требует обязательной авторизации администратора (POST /api/admin/login -> Bearer adm_...).
6
+ */
7
+ import {
8
+ PAK_PRODUCT,
9
+ SLA_TIERS,
10
+ FRANCHISE_PROGRAM,
11
+ REGIONS_CATALOG,
12
+ PARTNER_TRAINING_TRACKS,
13
+ VENDOR_CONTACTS,
14
+ } from './catalog.js';
15
+ import { icon } from './landing.js';
16
+
17
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[ch]));
18
+ const rub = (n) => Number(n || 0).toLocaleString('ru-RU').replace(/\u00a0/g, ' ') + ' ₽';
19
+ const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
20
+
21
+ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
22
+ const v = assetVersion ? '?v=' + encodeURIComponent(assetVersion) : '';
23
+ const P = PAK_PRODUCT;
24
+ const visibleRegions = REGIONS_CATALOG.filter(r => !r.hidden);
25
+
26
+ const adminData = {
27
+ product: P,
28
+ tiers: Object.values(SLA_TIERS),
29
+ franchise: FRANCHISE_PROGRAM,
30
+ regions: visibleRegions,
31
+ tracks: PARTNER_TRAINING_TRACKS,
32
+ vendor: VENDOR_CONTACTS,
33
+ publicKey,
34
+ };
35
+
36
+ return /* html */`<!DOCTYPE html>
37
+ <html lang="ru" data-theme="dark">
38
+ <head>
39
+ <meta charset="utf-8">
40
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
41
+ <title>ODANT-WORK · Консоль администратора центра лицензирования</title>
42
+ <meta name="robots" content="noindex, nofollow">
43
+ <meta name="theme-color" content="#04060b">
44
+ <link rel="icon" type="image/png" href="/favicon.ico">
45
+ <link rel="preconnect" href="https://fonts.googleapis.com">
46
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
47
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap">
48
+ <link rel="stylesheet" href="/assets/landing.css${v}">
49
+ <script>try{var t=localStorage.getItem('odant_work_theme');if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
50
+ </head>
51
+ <body data-stage="admin">
52
+ <div class="bg-aurora" aria-hidden="true"><i></i><i></i><i></i></div>
53
+ <div class="bg-grid" aria-hidden="true"></div>
54
+ <div class="bg-vignette" aria-hidden="true"></div>
55
+
56
+ <!-- Экран защищённого входа администратора -->
57
+ <div class="admin-auth-gate" id="adminLoginScreen">
58
+ <div class="card card-glow admin-login-card">
59
+ <div class="modal-head" style="margin-bottom:18px">
60
+ <img src="/assets/odant-logo.png" alt="ODANT">
61
+ <div>
62
+ <span class="kicker">Защищённый контур</span>
63
+ <h1 class="h3" style="margin:4px 0 2px;font-size:21px">Админ-панель ODANT-WORK</h1>
64
+ <div class="small muted">Центр сертификации Ed25519 и биллинга ЮKassa</div>
65
+ </div>
66
+ </div>
67
+ <form id="adminLoginForm" onsubmit="return false;">
68
+ <div class="field">
69
+ <label for="admLoginUser">Логин администратора</label>
70
+ <input type="text" id="admLoginUser" value="admin" autocomplete="username" required>
71
+ </div>
72
+ <div class="field">
73
+ <label for="admLoginPass">Пароль / Ключ администратора (WORK_ADMIN_PASSWORD)</label>
74
+ <input type="password" id="admLoginPass" placeholder="Введите ключ администратора…" autocomplete="current-password" required>
75
+ </div>
76
+ <div class="status" id="admLoginError" style="color:var(--danger);margin:6px 0 12px;min-height:18px"></div>
77
+ <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="btnAdminLoginSubmit">
78
+ ${icon('lock')} Войти в панель администратора
79
+ </button>
80
+ </form>
81
+ <div class="small muted" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">
82
+ Ключ ЦС Ed25519: <code>${esc(publicKey.slice(0, 26))}…</code>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Основная консоль администратора (скрыта до успешной авторизации) -->
88
+ <div class="shell" id="adminAppShell" style="display:none">
89
+ <header class="topbar" style="grid-template-columns: auto 1fr auto">
90
+ <div class="brand" style="cursor:default">
91
+ <img src="/assets/odant-logo.png" alt="ODANT">
92
+ <span>
93
+ <span class="brand-name">ODANT<b>·</b>WORK <span class="tag" style="margin-left:6px;vertical-align:middle">ADMIN</span></span><br>
94
+ <span class="brand-sub">центр сертификации Ed25519 · биллинг ЮKassa · франшизы · SLA</span>
95
+ </span>
96
+ </div>
97
+ <div></div>
98
+ <div class="top-actions">
99
+ <button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
100
+ <button class="btn btn-ghost btn-sm" data-action="registry">${icon('server')}<span>Обновить реестр</span></button>
101
+ <button class="btn btn-ok btn-sm" data-action="demo-pak">+ Демо-лицензия ПАК</button>
102
+ <button class="btn btn-ghost btn-sm" data-action="admin-logout" title="Завершить сессию администратора">${icon('lock')}<span id="admUserLabel">Выйти</span></button>
103
+ </div>
104
+ </header>
105
+
106
+ <section class="stage active" style="padding-top:26px">
107
+ <div class="stats" style="grid-template-columns:repeat(6,1fr)">
108
+ <div class="stat"><b id="admStatLic">0</b><span id="admStatLicSub">лицензий ПАК · 0 мест</span></div>
109
+ <div class="stat"><b id="admStatPaid">0 ₽</b><span id="admStatPaidSub">оплачено заказов: 0</span></div>
110
+ <div class="stat"><b id="admStatPending">0 ₽</b><span id="admStatPendingSub">ожидают оплаты: 0</span></div>
111
+ <div class="stat"><b id="admStatPartners">0 / 0</b><span>партнёров / резервов франшиз</span></div>
112
+ <div class="stat"><b id="admStatUsers">0</b><span id="admStatUsersSub">клиентов (оферт ПЭП: 0)</span></div>
113
+ <div class="stat"><b id="admStatTickets">0</b><span id="admStatTicketsSub">открытых тикетов SLA</span></div>
114
+ </div>
115
+
116
+ <div class="admin-workspace" id="adminWorkspace">
117
+ <!-- Левая панель навигации и ИИ-агента администратора -->
118
+ <aside class="admin-left">
119
+ <nav class="admin-nav" aria-label="Вкладки админ-панели">
120
+ <button class="adm-tab on" data-adm-tab="all">${icon('ai')}<span>ИИ-агент + Все разделы</span><i class="live-dot"></i></button>
121
+ <button class="adm-tab" data-adm-tab="agent">${icon('agents')}<span>Только ИИ-агент</span></button>
122
+ <button class="adm-tab" data-adm-tab="licenses">${icon('key')}<span>Лицензии (${P.baseSeats} вечн. + доп.)</span></button>
123
+ <button class="adm-tab" data-adm-tab="orders">${icon('cart')}<span>Заказы ЮKassa</span></button>
124
+ <button class="adm-tab" data-adm-tab="partners">${icon('map')}<span>Франшизы и партнёры</span></button>
125
+ <button class="adm-tab" data-adm-tab="tickets">${icon('chat')}<span>Тикеты SLA (MAX·TG·VK)</span></button>
126
+ <button class="adm-tab" data-adm-tab="users" style="grid-column:1 / -1">${icon('user')}<span>Зарегистрированные клиенты и подписанные оферты (ПЭП)</span></button>
127
+ </nav>
128
+
129
+ <div class="card adm-agent-card" id="admAgentCard">
130
+ <div class="ai-head" style="padding:0 0 12px;margin-bottom:10px">
131
+ <img src="/assets/odant-logo.png" alt="">
132
+ <div>
133
+ <b>ИИ-агент администратора</b>
134
+ <span>● управление реестром · защищённый контур</span>
135
+ </div>
136
+ </div>
137
+ <div class="ai-msgs adm-ai-msgs" id="admAiMessages">
138
+ <div class="msg bot">Здравствуйте! Я ИИ-агент консоли администратора ODANT-WORK. Помогу проанализировать реестр лицензий (${P.baseSeats} вечных мест + 1 год Enterprise), выпустить сертификат Ed25519, провести оплату заказов ЮKassa, активировать резервы франшиз или проверить квоты по регионам РФ.</div>
139
+ </div>
140
+ <div class="ai-sugg" style="padding:8px 0;border-top:1px solid var(--line)">
141
+ <button data-adm-ask="Сводка по серверу лицензий">Сводка реестра</button>
142
+ <button data-adm-ask="Выпусти базовую лицензию для ООО «АгроТех» ИНН 6234001122">+ Выпустить лицензию</button>
143
+ <button data-adm-ask="Проведи оплату ожидающих заказов">Провести оплаты</button>
144
+ <button data-adm-ask="Активируй резервы франшиз">Активировать резервы</button>
145
+ <button data-adm-ask="Сколько вакантных франшиз в Татарстане?">Квота Татарстана</button>
146
+ </div>
147
+ <div class="ai-input" style="padding:10px 0 0">
148
+ <input type="text" id="admAiInput" placeholder="Команда или вопрос ИИ-агенту админки…" autocomplete="off">
149
+ <button class="btn btn-primary" data-action="adm-ai-send" aria-label="Отправить">${icon('send')}</button>
150
+ </div>
151
+ </div>
152
+ </aside>
153
+
154
+ <!-- Правая область разделов админки -->
155
+ <div class="admin-main" id="adminMain">
156
+ <!-- 1. ЛИЦЕНЗИИ -->
157
+ <div class="card" data-adm-sec="licenses">
158
+ <div class="sec-head" style="margin-bottom:14px">
159
+ <div>
160
+ <span class="kicker">Выпуск сертификата Ed25519</span>
161
+ <h3 class="h3" style="margin:4px 0 0">Выпустить базовую лицензию комплекса (${P.baseSeats} вечных лицензий + 1 год поддержки Enterprise)</h3>
162
+ </div>
163
+ </div>
164
+ <div class="grid g4" style="align-items:end">
165
+ <div class="field" style="margin:0"><label for="admLicOrg">Организация</label><input type="text" id="admLicOrg" value="АО «РязаньАгроПром»"></div>
166
+ <div class="field" style="margin:0"><label for="admLicInn">ИНН</label><input type="text" id="admLicInn" value="6234109876"></div>
167
+ <div class="field" style="margin:0"><label for="admLicEmail">Email клиента</label><input type="email" id="admLicEmail" value="it@ryazan-agro.ru"></div>
168
+ <div class="field" style="margin:0"><label for="admLicHwid">HWID комплекса (необязательно)</label><input type="text" id="admLicHwid" placeholder="PAK-..."></div>
169
+ </div>
170
+ <div style="display:flex;align-items:center;gap:14px;margin-top:14px;flex-wrap:wrap">
171
+ <button class="btn btn-primary btn-sm" data-action="adm-issue-lic">${icon('key')} Выпустить и подписать сертификат</button>
172
+ <span class="small muted" id="admIssueStatus"></span>
173
+ </div>
174
+ </div>
175
+
176
+ <div class="card" data-adm-sec="licenses">
177
+ <h3 class="h3" style="margin:0 0 12px">Выпущенные лицензии комплексов ODANT-WORK</h3>
178
+ <div class="table-wrap">
179
+ <table class="t">
180
+ <thead><tr>
181
+ <th>Лицензия / Код бота</th>
182
+ <th>Организация / ИНН</th>
183
+ <th>Привязка оборудования / Партнёр</th>
184
+ <th class="num">Сессии (вечные + доп.)</th>
185
+ <th>SLA и срок</th>
186
+ <th>Сертификат</th>
187
+ </tr></thead>
188
+ <tbody id="admLicensesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
189
+ </table>
190
+ </div>
191
+ </div>
192
+
193
+ <!-- 2. ЗАКАЗЫ ЮKASSA -->
194
+ <div class="card" data-adm-sec="orders">
195
+ <h3 class="h3" style="margin:0 0 12px">Заказы и платежи ЮKassa</h3>
196
+ <div class="table-wrap">
197
+ <table class="t">
198
+ <thead><tr>
199
+ <th>Заказ / Платёж</th>
200
+ <th>Назначение</th>
201
+ <th>Плательщик</th>
202
+ <th class="num">Сумма</th>
203
+ <th>Статус</th>
204
+ <th>Действия</th>
205
+ </tr></thead>
206
+ <tbody id="admOrdersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
207
+ </table>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- 3. ФРАНШИЗЫ И ПАРТНЁРЫ -->
212
+ <div class="card" data-adm-sec="partners">
213
+ <div class="sec-head" style="margin-bottom:14px">
214
+ <div>
215
+ <span class="kicker">Региональная партнёрская сеть</span>
216
+ <h3 class="h3" style="margin:4px 0 0">Зарегистрировать партнёра-франчайзи вручную</h3>
217
+ </div>
218
+ </div>
219
+ <div class="grid g4" style="align-items:end">
220
+ <div class="field" style="margin:0">
221
+ <label for="admPartRegion">Субъект РФ</label>
222
+ <select id="admPartRegion">
223
+ ${visibleRegions.map(r => `<option value="${esc(r.code)}" ${r.code === 'RU-RYA' ? 'selected' : ''}>${esc(r.name)} (${esc(r.district)})</option>`).join('')}
224
+ </select>
225
+ </div>
226
+ <div class="field" style="margin:0"><label for="admPartCompany">Компания-партнёр</label><input type="text" id="admPartCompany" value="ООО «ЦентрИнтеграция»"></div>
227
+ <div class="field" style="margin:0"><label for="admPartInn">ИНН</label><input type="text" id="admPartInn" value="6234556677"></div>
228
+ <div class="field" style="margin:0"><label for="admPartEmail">Email партнёра</label><input type="email" id="admPartEmail" value="partner@center-it.ru"></div>
229
+ </div>
230
+ <div style="display:flex;align-items:center;gap:14px;margin-top:14px;flex-wrap:wrap">
231
+ <button class="btn btn-primary btn-sm" data-action="adm-register-partner">${icon('map')} Зарегистрировать партнёра</button>
232
+ <span class="small muted" id="admPartnerStatus"></span>
233
+ </div>
234
+ </div>
235
+
236
+ <div class="card" data-adm-sec="partners">
237
+ <h3 class="h3" style="margin:0 0 12px">Резервы региональных франшиз и действующие партнёры</h3>
238
+ <div class="table-wrap">
239
+ <table class="t">
240
+ <thead><tr>
241
+ <th>ID / Тип</th>
242
+ <th>Субъект РФ</th>
243
+ <th>Компания / ИНН</th>
244
+ <th>Условия / Скидка</th>
245
+ <th>Статус и аттестация</th>
246
+ <th>Действия</th>
247
+ </tr></thead>
248
+ <tbody id="admPartnersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
249
+ </table>
250
+ </div>
251
+ </div>
252
+
253
+ <!-- 4. ТИКЕТЫ SLA -->
254
+ <div class="card" data-adm-sec="tickets">
255
+ <h3 class="h3" style="margin:0 0 12px">Обращения техподдержки (MAX · Telegram · VK)</h3>
256
+ <div class="table-wrap">
257
+ <table class="t">
258
+ <thead><tr>
259
+ <th>Тикет / Канал</th>
260
+ <th>Клиент / Лицензия</th>
261
+ <th>SLA</th>
262
+ <th>Переписка</th>
263
+ <th>Ответ инженера</th>
264
+ </tr></thead>
265
+ <tbody id="admTicketsBody"><tr><td colspan="5" class="muted">Загрузка…</td></tr></tbody>
266
+ </table>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- 5. КЛИЕНТЫ И ОФЕРТЫ ПЭП -->
271
+ <div class="card" data-adm-sec="users">
272
+ <h3 class="h3" style="margin:0 0 12px">Зарегистрированные клиенты и подписанные договоры-оферты (ПЭП)</h3>
273
+ <div class="table-wrap">
274
+ <table class="t">
275
+ <thead><tr>
276
+ <th>ID / Email</th>
277
+ <th>Организация / ИНН</th>
278
+ <th>Представитель</th>
279
+ <th>Лицензия комплекса</th>
280
+ <th>Статус оферты (ПЭП)</th>
281
+ <th>Дата акцепта</th>
282
+ </tr></thead>
283
+ <tbody id="admUsersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
284
+ </table>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </section>
290
+
291
+ <footer>
292
+ <div class="footer-bottom">
293
+ <span>© ODANT · Консоль администратора центра лицензирования ${esc(P.domain)}</span>
294
+ <span>Ключ центра сертификации Ed25519: <code>${esc(publicKey)}</code></span>
295
+ </div>
296
+ </footer>
297
+ </div>
298
+
299
+ <!-- Просмотр подписанного сертификата Ed25519 -->
300
+ <div class="modal" id="certModal" role="dialog" aria-modal="true" aria-labelledby="certTitle">
301
+ <div class="card" style="max-width:640px">
302
+ <div class="modal-head">
303
+ <img src="/assets/odant-logo.png" alt="">
304
+ <div><h3 class="h3" id="certTitle" style="margin:0">Сертификат лицензии Ed25519</h3><div class="small muted" id="certSub">WORK-LIC-v1</div></div>
305
+ </div>
306
+ <pre class="console" id="certJsonBox" style="max-height:360px;margin:0"></pre>
307
+ <div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap">
308
+ <button class="btn btn-primary" data-action="cert-copy">Скопировать JSON</button>
309
+ <button class="btn btn-ok" data-action="cert-download">Скачать .json</button>
310
+ <button class="btn btn-ghost" data-action="cert-close">Закрыть</button>
311
+ </div>
312
+ </div>
313
+ </div>
314
+
315
+ <script>window.__ODANT_ADMIN__ = ${jsonForScript(adminData)};</script>
316
+ <script src="/assets/admin.js${v}" defer></script>
317
+ </body>
318
+ </html>`;
319
+ }
package/src/agent.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  FRANCHISE_PROGRAM,
16
16
  REGIONS_CATALOG,
17
17
  calculateOrderQuote,
18
+ PUBLIC_OFERTA,
18
19
  } from './catalog.js';
19
20
 
20
21
  const GENERIC_WORDS = new Set([
@@ -58,11 +59,13 @@ export class LicenseServerAiAgent {
58
59
  this.fetchImpl = fetchImpl;
59
60
  }
60
61
 
61
- async chat({ message = '', user = null } = {}) {
62
+ async chat({ message = '', user = null, isAdmin = false } = {}) {
62
63
  const text = String(message || '').trim();
63
64
  if (!text) {
64
65
  return {
65
- reply: 'Здравствуйте! Я ИИ-агент лицензионного центра ODANT-WORK. Спросите о комплексе, ИИ-агентах платформы, расчёте лицензий со скидкой, вакантных франшизах или управлении реестром сервера.',
66
+ reply: isAdmin
67
+ ? 'Здравствуйте! Я ИИ-агент консоли администратора ODANT-WORK. Спросите сводку по реестру, выпустите сертификат Ed25519, проведите оплату заказов ЮKassa или активируйте резервы франшиз.'
68
+ : 'Здравствуйте! Я ИИ-агент ODANT-WORK. Спросите о комплексе, ИИ-агентах платформы, расчёте лицензий со скидкой, условиях публичной оферты или вакантных франшизах в вашем регионе.',
66
69
  actions: [],
67
70
  };
68
71
  }
@@ -70,6 +73,12 @@ export class LicenseServerAiAgent {
70
73
 
71
74
  // 0.1 Команда администратора: выпуск новой лицензии ПАК
72
75
  if (/(выпуст|создай|оформ)\S*\s+(?:базов\S*\s+|демо[-\s]*)?лиценз/i.test(lower)) {
76
+ if (!isAdmin) {
77
+ return {
78
+ reply: 'Прямой выпуск сертификатов лицензий доступен только авторизованному администратору центра сертификации. Для покупки комплекса или докупки мест зарегистрируйтесь в личном кабинете и подпишите договор-оферту.',
79
+ actions: [{ type: 'open_auth' }],
80
+ };
81
+ }
73
82
  const orgMatch = text.match(/(?:для\s+)?((?:ООО|АО|ПАО|ЗАО|ИП)\s*«[^»]+»)/i);
74
83
  const innMatch = text.match(/ИНН\s*(\d{10,12})/i) || text.match(/\b(\d{10,12})\b/);
75
84
  const emailMatch = text.match(/([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})/i);
@@ -95,6 +104,12 @@ export class LicenseServerAiAgent {
95
104
 
96
105
  // 0.2 Команда администратора: проведение оплаты ожидающих заказов ЮKassa
97
106
  if (/(провед|подтверд)\S*\s+оплат/i.test(lower)) {
107
+ if (!isAdmin) {
108
+ return {
109
+ reply: 'Подтверждение платежей вручную доступно только администратору центра биллинга.',
110
+ actions: [],
111
+ };
112
+ }
98
113
  const pending = Object.values(this.store.state.orders || {}).filter(o => o.status === 'pending');
99
114
  if (!pending.length) {
100
115
  return {
@@ -105,25 +120,60 @@ export class LicenseServerAiAgent {
105
120
  const completed = [];
106
121
  for (const o of pending) {
107
122
  const done = this.store.completePaidOrder(o.orderId, { paymentId: o.paymentId || ('yk_' + o.orderId) });
108
- if (done) completed.push(done);
123
+ if (done?.order) completed.push(done.order);
109
124
  }
110
125
  return {
111
126
  reply: [
112
127
  `Проведена оплата по **${completed.length}** заказам ЮKassa:`,
113
- ...completed.map(o => `• **${o.orderId}** — ${o.customer?.name || 'Клиент'} · **${fmt(o.amountRub)} ₽** (статус: оплачен)`),
128
+ ...completed.map(ord => `• **${ord.orderId}** — ${ord.customer?.name || 'Клиент'} · **${fmt(ord.amountRub)} ₽** (статус: оплачен)`),
114
129
  'Сертификаты лицензий и резервы франшиз автоматически обновлены.',
115
130
  ].join('\n'),
116
131
  actions: [{ type: 'refresh_admin' }, { type: 'admin_tab', tab: 'orders' }],
117
132
  };
118
133
  }
119
134
 
135
+ // 0.25 Команда администратора: активация резервов франшиз в действующих партнёров
136
+ if (isAdmin && /(активир|конвертир|перевед)\S*\s+(?:резерв|франш|партн)/i.test(lower)) {
137
+ const pendingRes = Object.values(this.store.state.reservations || {}).filter(
138
+ r => r.status !== 'canceled' && !r.convertedToPartnerId
139
+ );
140
+ if (!pendingRes.length) {
141
+ return {
142
+ reply: 'Сейчас нет неактивированных резервов франшиз.',
143
+ actions: [{ type: 'refresh_admin' }],
144
+ };
145
+ }
146
+ const activated = [];
147
+ for (const r of pendingRes) {
148
+ const p = this.store.registerPartner({ reservationId: r.reservationId });
149
+ activated.push(p);
150
+ }
151
+ return {
152
+ reply: [
153
+ `Активировано партнёров-франчайзи из резервов: **${activated.length}**:`,
154
+ ...activated.map(p => `• **${p.partnerId}** — ${p.companyName} (${p.regionName}, скидка ${p.discountPercent}%)`),
155
+ ].join('\n'),
156
+ actions: [{ type: 'refresh_admin' }, { type: 'admin_tab', tab: 'partners' }],
157
+ };
158
+ }
159
+
120
160
  // 0.3 Команда администратора: сводка по реестру сервера лицензий / заказам / тикетам
121
161
  if (/(сводк|реестр|статус сервер|обзор сервер|неоплачен\S*\s+заказ|покажи заказ|открыт\S*\s+тикет|покажи тикет|резерв\S*\s+франш)/i.test(lower)) {
162
+ if (!isAdmin) {
163
+ return {
164
+ reply: 'Внутренняя сводка по реестру сервера доступна только в консоли администратора. В личном кабинете на сайте вы можете посмотреть статус своей лицензии и подписанной оферты.',
165
+ actions: user ? [{ type: 'switch_stage', stage: 'addons' }] : [{ type: 'open_auth' }],
166
+ };
167
+ }
122
168
  const licenses = this.store.listLicenses();
123
169
  const orders = Object.values(this.store.state.orders || {});
124
170
  const partners = this.store.listPartners();
125
- const reservations = Object.values(this.store.state.franchiseReservations || {});
171
+ const reservations = Object.values(this.store.state.reservations || {}).filter(
172
+ r => r.status !== 'canceled' && !r.convertedToPartnerId
173
+ );
126
174
  const tickets = this.store.listTickets();
175
+ const users = this.store.listUsers();
176
+ const signedUsers = users.filter(u => u.ofertaAccepted);
127
177
  const totalSeats = licenses.reduce((s, l) => s + (l.totalSeats || 0), 0);
128
178
  const paidOrders = orders.filter(o => o.status === 'paid');
129
179
  const pendingOrders = orders.filter(o => o.status === 'pending');
@@ -138,6 +188,7 @@ export class LicenseServerAiAgent {
138
188
  `• Оплачено заказов ЮKassa: **${paidOrders.length}** на сумму **${fmt(paidSum)} ₽**`,
139
189
  `• Ожидают оплаты: **${pendingOrders.length}** на сумму **${fmt(pendingSum)} ₽**`,
140
190
  `• Франшизы: **${partners.length}** действующих партнёров и **${reservations.length}** активных резервов`,
191
+ `• Клиенты и оферты: **${users.length}** зарегистрировано (оферта ПЭП подписана у **${signedUsers.length}**)`,
141
192
  `• Техподдержка (MAX · TG · VK): **${openTickets.length}** открытых из **${tickets.length}** обращений`,
142
193
  pendingOrders.length > 0 ? '\nНапишите «Проведи оплату заказов», чтобы подтвердить ожидающие платежи.' : '',
143
194
  ].filter(Boolean).join('\n'),
@@ -145,6 +196,21 @@ export class LicenseServerAiAgent {
145
196
  };
146
197
  }
147
198
 
199
+ // 0.4 Вопросы по публичной оферте и порядку регистрации
200
+ if (/(оферт|договор|пэп|подпис\S*\s+оферт|регистрац|акцепт)/i.test(lower)) {
201
+ return {
202
+ reply: [
203
+ `**${PUBLIC_OFERTA.title}** (редакция \`${PUBLIC_OFERTA.version}\` от ${PUBLIC_OFERTA.updatedAt}):`,
204
+ '• Любые действия по заказу комплекса, докупке годовых лицензий, оплате через ЮKassa и резервированию региональной франшизы выполняются **только после регистрации в личном кабинете и подписания оферты простой электронной подписью (ПЭП)**.',
205
+ '• При подписании фиксируются наименование организации, ИНН, корпоративный email, ФИО представителя и уникальный идентификатор подписи `PEP-...`.',
206
+ user?.ofertaAccepted
207
+ ? `• У вашей организации (**${user.companyName}**) оферта уже подписана: \`${user.ofertaSignatureId}\`.`
208
+ : '• Нажмите «Войти / Регистрация», чтобы ознакомиться с текстом оферты и подписать её.',
209
+ ].join('\n'),
210
+ actions: [{ type: 'open_oferta' }],
211
+ };
212
+ }
213
+
148
214
  // 1. Франшиза в конкретном субъекте РФ
149
215
  const region = findRegionInText(text);
150
216
  if (region && /(франш|паушал|взнос|регион|вакант|квот|резерв|залог|партн|сколько|област|край|республ)/i.test(lower)) {