72b6879f4b
- Refactored max_bot from nested packages to flat module structure - Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets) - Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT) - Q1: Broadcast preview modal with 10s confirmation timer - Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary) - Q4: bot_settings showNotification(), editable max_bot_id - Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret - Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists() - Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py - Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings - Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py - Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
394 lines
17 KiB
PHP
394 lines
17 KiB
PHP
<?php
|
|
require_once __DIR__ . '/../../inc/auth.php';
|
|
require_once __DIR__ . '/../../inc/functions.php';
|
|
$session = auth_require('owner', 'engineer');
|
|
|
|
$pdo = getDB();
|
|
$message = '';
|
|
$userId = $session['user_id'];
|
|
|
|
// Create new session
|
|
if (isset($_GET['new'])) {
|
|
$stmt = $pdo->prepare("INSERT INTO questionnaire_sessions (created_by) VALUES (?)");
|
|
$stmt->execute([$userId]);
|
|
$sessionId = $pdo->lastInsertId();
|
|
header("Location: questionnaire.php?id=$sessionId");
|
|
exit;
|
|
}
|
|
|
|
$sessId = (int)($_GET['id'] ?? 0);
|
|
$sess = null;
|
|
if ($sessId) {
|
|
$stmt = $pdo->prepare("SELECT * FROM questionnaire_sessions WHERE id=?");
|
|
$stmt->execute([$sessId]);
|
|
$sess = $stmt->fetch();
|
|
if (!$sess) { $sessId = 0; }
|
|
}
|
|
|
|
// Handle manual step navigation via GET
|
|
if ($sessId && isset($_GET['step']) && $sess && $sess['status'] !== 'completed') {
|
|
$goStep = (int)$_GET['step'];
|
|
if ($goStep >= 1 && $goStep <= 5 && $goStep <= (int)$sess['current_step']) {
|
|
$pdo->prepare("UPDATE questionnaire_sessions SET current_step=? WHERE id=?")->execute([$goStep, $sessId]);
|
|
header("Location: questionnaire.php?id=$sessId");
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Save answers
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $sess) {
|
|
$csrf = $_POST['csrf_token'] ?? '';
|
|
if (!auth_csrf_verify($csrf)) {
|
|
$message = 'Ошибка безопасности.';
|
|
} else {
|
|
$step = (int)($_POST['step'] ?? 1);
|
|
$goNext = isset($_POST['next']);
|
|
$answers = [];
|
|
|
|
// Collect all answers from POST (skip meta fields)
|
|
foreach ($_POST as $key => $val) {
|
|
if (strpos($key, '_') === 0 || $key === 'csrf_token' || $key === 'step' || $key === 'next' || $key === 'save') continue;
|
|
if (is_array($val)) {
|
|
$answers[$key] = implode(',', $val);
|
|
} else {
|
|
$answers[$key] = $val;
|
|
}
|
|
}
|
|
|
|
// Save answers
|
|
$stmt = $pdo->prepare("INSERT INTO questionnaire_answers (session_id, step, question_key, answer_value) VALUES ($1,$2,$3,$4) ON CONFLICT (session_id, question_key) DO UPDATE SET answer_value=EXCLUDED.answer_value");
|
|
foreach ($answers as $qk => $qv) {
|
|
$stmt->execute([$sessId, $step, $qk, $qv]);
|
|
}
|
|
|
|
if ($goNext && $step < 5) {
|
|
$pdo->prepare("UPDATE questionnaire_sessions SET current_step=? WHERE id=?")->execute([$step + 1, $sessId]);
|
|
header("Location: questionnaire.php?id=$sessId");
|
|
exit;
|
|
}
|
|
|
|
// Step 5 = final calculation
|
|
if ($step === 5 || isset($_POST['save'])) {
|
|
// Calculate everything from all answers
|
|
$allAnswers = $pdo->prepare("SELECT question_key, answer_value FROM questionnaire_answers WHERE session_id=?");
|
|
$allAnswers->execute([$sessId]);
|
|
$qa = [];
|
|
foreach ($allAnswers as $row) {
|
|
$qa[$row['question_key']] = $row['answer_value'];
|
|
}
|
|
|
|
$riskScore = calc_risk_score(
|
|
($qa['no_archive'] ?? '') === 'on',
|
|
($qa['no_power'] ?? '') === 'on',
|
|
($qa['no_regulations'] ?? '') === 'on',
|
|
($qa['frequent_failures'] ?? '') === 'on',
|
|
($qa['no_documentation'] ?? '') === 'on'
|
|
);
|
|
|
|
$complexity = calc_complexity(
|
|
(int)($qa['camera_count'] ?? 0),
|
|
(int)($qa['access_points'] ?? 0),
|
|
$qa['fire_type'] ?? 'simple',
|
|
isset($qa['has_it']) && $qa['has_it'] === 'on'
|
|
);
|
|
|
|
$infraLoad = calc_infrastructure_load(
|
|
$qa['server_state'] ?? 'ok',
|
|
$qa['network_state'] ?? 'stable',
|
|
$qa['power_state'] ?? 'ok'
|
|
);
|
|
|
|
$serviceHist = calc_service_history($qa['service_state'] ?? 'none');
|
|
|
|
$objIndex = calc_object_index($riskScore, $complexity, $infraLoad, $serviceHist);
|
|
|
|
// Region factor
|
|
$regionFactor = (float)($qa['region_factor'] ?? 1.0);
|
|
$baseCost = 15000;
|
|
$riskMult = risk_multiplier($riskScore);
|
|
$slaPrice = calc_sla_price($baseCost, $objIndex, $regionFactor, $riskMult);
|
|
|
|
// Update session with results
|
|
$pdo->prepare("UPDATE questionnaire_sessions SET
|
|
status='completed', current_step=5,
|
|
risk_score=?, complexity_index=?, infrastructure_load=?,
|
|
service_history=?, object_index=?, sla_price_monthly=?
|
|
WHERE id=?")->execute([$riskScore, $complexity, $infraLoad, $serviceHist, $objIndex, $slaPrice, $sessId]);
|
|
|
|
// Create/update object if we have name
|
|
if (!empty($qa['object_name'])) {
|
|
$stmtObj = $pdo->prepare("INSERT INTO objects (name, object_type, address, contact_person, contact_phone, status, risk_score, complexity_index, infrastructure_load, service_history, object_index, sla_price_monthly, region_factor, created_by, notes)
|
|
VALUES ($1,$2,$3,$4,$5,'active',$6,$7,$8,$9,$10,$11,$12,$13,'Из опросника #' || $14)
|
|
ON CONFLICT DO NOTHING");
|
|
$stmtObj->execute([
|
|
$qa['object_name'], $qa['object_type'] ?? '', $qa['address'] ?? '',
|
|
$qa['contact_person'] ?? '', $qa['contact_phone'] ?? '',
|
|
$riskScore, $complexity, $infraLoad, $serviceHist, $objIndex, $slaPrice, $regionFactor, $userId, $sessId
|
|
]);
|
|
$objId = $pdo->lastInsertId();
|
|
} else {
|
|
$objId = null;
|
|
}
|
|
|
|
// Passport
|
|
$passportData = json_encode([
|
|
'answers' => $qa,
|
|
'risk_score' => $riskScore,
|
|
'complexity' => $complexity,
|
|
'infra_load' => $infraLoad,
|
|
'service_history' => $serviceHist,
|
|
'object_index' => $objIndex,
|
|
'sla_price' => $slaPrice,
|
|
]);
|
|
$pdo->prepare("INSERT INTO object_passports (session_id, object_id, object_index, risk_score, complexity_index, sla_price_monthly, passport_data, created_by) VALUES (?,?,?,?,?,?,?,?)")
|
|
->execute([$sessId, $objId, $objIndex, $riskScore, $complexity, $slaPrice, $passportData, $userId]);
|
|
|
|
$message = '✅ Расчёт выполнен! Паспорт объекта сформирован.';
|
|
$sess = $pdo->prepare("SELECT * FROM questionnaire_sessions WHERE id=?");
|
|
$sess->execute([$sessId]);
|
|
$sess = $sess->fetch();
|
|
}
|
|
|
|
if (!isset($_POST['save']) && $step < 5) {
|
|
$message = 'Шаг сохранён.';
|
|
}
|
|
|
|
if (isset($_POST['save'])) {
|
|
$message = 'Данные сохранены.';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load existing answers for editing
|
|
$answers = [];
|
|
if ($sessId) {
|
|
$stmt = $pdo->prepare("SELECT question_key, answer_value FROM questionnaire_answers WHERE session_id=?");
|
|
$stmt->execute([$sessId]);
|
|
foreach ($stmt as $row) {
|
|
$answers[$row['question_key']] = $row['answer_value'];
|
|
}
|
|
}
|
|
|
|
// Load questionnaire items from DB
|
|
$qItems = [];
|
|
$qSections = [];
|
|
if ($sessId) {
|
|
$stmt = $pdo->prepare("SELECT * FROM questionnaire_items WHERE is_active AND step=? ORDER BY sort_order, id");
|
|
$stmt->execute([$step]);
|
|
$qItems = $stmt->fetchAll();
|
|
|
|
// Group by section
|
|
foreach ($qItems as $qi) {
|
|
if ($qi['section'] && !isset($qSections[$qi['section']])) {
|
|
$qSections[$qi['section']] = [];
|
|
}
|
|
if ($qi['section']) {
|
|
$qSections[$qi['section']][] = $qi;
|
|
} else {
|
|
$qSections[''][] = $qi;
|
|
}
|
|
}
|
|
}
|
|
|
|
$csrf = auth_csrf_token();
|
|
|
|
// Sessions list
|
|
$sessions = $pdo->query("SELECT qs.*, u.full_name as author_name FROM questionnaire_sessions qs LEFT JOIN users u ON u.id=qs.created_by ORDER BY qs.created_at DESC LIMIT 20")->fetchAll();
|
|
|
|
require __DIR__ . '/../../inc/header.php';
|
|
?>
|
|
|
|
<div class="main-header">
|
|
<div>
|
|
<h1>Опросник объекта</h1>
|
|
<div class="breadcrumb"><a href="/service/dashboard.php">Главная</a> / Опросник</div>
|
|
</div>
|
|
<a href="?new=1" class="btn btn-primary btn-sm" data-tooltip="Начать новый опрос">+ Новый опросник</a>
|
|
</div>
|
|
|
|
<?php if ($message): ?>
|
|
<div class="alert alert-success"><?= $message ?></div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($sess && $sess['status'] === 'completed'): ?>
|
|
<div class="card" style="border-color:var(--success);">
|
|
<div class="card-header">
|
|
<h2>Результаты опросника</h2>
|
|
<span class="badge badge-done">Завершён</span>
|
|
</div>
|
|
<div class="card-grid">
|
|
<div>
|
|
<div class="card-value value-accent"><?= $sess['risk_score'] ?></div>
|
|
<div class="card-label">Risk Score (<?= risk_label($sess['risk_score']) ?>)</div>
|
|
</div>
|
|
<div>
|
|
<div class="card-value value-info"><?= $sess['complexity_index'] ?></div>
|
|
<div class="card-label">Complexity Index</div>
|
|
</div>
|
|
<div>
|
|
<div class="card-value"><?= $sess['object_index'] ?></div>
|
|
<div class="card-label">Object Index (класс <?= object_class($sess['object_index']) ?>)</div>
|
|
</div>
|
|
<div>
|
|
<div class="card-value value-green"><?= fmt_money($sess['sla_price_monthly']) ?></div>
|
|
<div class="card-label">SLA Цена / месяц</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($sess && $sess['status'] !== 'completed'): ?>
|
|
<?php $step = (int)$sess['current_step']; ?>
|
|
|
|
<div class="step-indicators">
|
|
<?php $labels = ['Коммерческий', 'Технический', 'Эксплуатация', 'Риски', 'Расчёт SLA']; ?>
|
|
<?php foreach ($labels as $i => $label): $num = $i + 1; ?>
|
|
<div class="step-dot <?= $num < $step ? 'completed' : ($num === $step ? 'active' : '') ?>">
|
|
<?php if ($num < $step): ?>✓<?php else: ?><?= $num ?><?php endif; ?>
|
|
<?= $label ?>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
|
|
<form method="post">
|
|
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
<input type="hidden" name="step" value="<?= $step ?>">
|
|
|
|
<?php if (!empty($qSections)): ?>
|
|
<div class="card">
|
|
<?php foreach ($qSections as $sectionName => $items): ?>
|
|
<?php if ($sectionName): ?>
|
|
<h2 style="margin-bottom:16px;"><?= htmlspecialchars($sectionName) ?></h2>
|
|
<?php endif; ?>
|
|
|
|
<?php foreach ($items as $qi): ?>
|
|
<?php
|
|
$val = $answers[$qi['question_key']] ?? '';
|
|
$opts = $qi['options'] ? json_decode($qi['options'], true) : null;
|
|
?>
|
|
|
|
<?php if ($qi['type'] === 'textarea'): ?>
|
|
<div class="form-group">
|
|
<label for="<?= $qi['question_key'] ?>"><?= htmlspecialchars($qi['label']) ?></label>
|
|
<textarea id="<?= $qi['question_key'] ?>" name="<?= $qi['question_key'] ?>" <?= $qi['required'] ? 'required' : '' ?>><?= htmlspecialchars($val) ?></textarea>
|
|
</div>
|
|
|
|
<?php elseif ($qi['type'] === 'select' && is_array($opts)): ?>
|
|
<div class="form-group">
|
|
<label for="<?= $qi['question_key'] ?>"><?= htmlspecialchars($qi['label']) ?></label>
|
|
<select id="<?= $qi['question_key'] ?>" name="<?= $qi['question_key'] ?>" <?= $qi['required'] ? 'required' : '' ?>>
|
|
<?php if (array_keys($opts) === range(0, count($opts) - 1)): ?>
|
|
<?php foreach ($opts as $opt): ?>
|
|
<option value="<?= htmlspecialchars($opt) ?>" <?= $val === $opt ? 'selected' : '' ?>><?= htmlspecialchars($opt) ?></option>
|
|
<?php endforeach; ?>
|
|
<?php else: ?>
|
|
<?php foreach ($opts as $k => $v): ?>
|
|
<option value="<?= htmlspecialchars($k) ?>" <?= $val === $k ? 'selected' : '' ?>><?= htmlspecialchars($v) ?></option>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<?php elseif ($qi['type'] === 'radio' && is_array($opts)): ?>
|
|
<div class="form-group">
|
|
<label><?= htmlspecialchars($qi['label']) ?></label>
|
|
<?php foreach ($opts as $k => $v): ?>
|
|
<label class="form-check">
|
|
<input type="radio" name="<?= $qi['question_key'] ?>" value="<?= htmlspecialchars($k) ?>" <?= $val === $k ? 'checked' : '' ?>> <?= htmlspecialchars($v) ?>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
|
|
<?php elseif ($qi['type'] === 'checkbox'): ?>
|
|
<div class="form-group">
|
|
<?php if (is_array($opts) && array_keys($opts) !== range(0, count($opts) - 1)): ?>
|
|
<label><?= htmlspecialchars($qi['label']) ?></label>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px;">
|
|
<?php foreach ($opts as $optKey => $optLabel): ?>
|
|
<label class="form-check">
|
|
<input type="checkbox" name="<?= $qi['question_key'] ?>[]" value="<?= htmlspecialchars($optKey) ?>" <?= in_array($optKey, explode(',', $val)) ? 'checked' : '' ?>>
|
|
<?= htmlspecialchars($optLabel) ?>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php else: ?>
|
|
<label class="form-check">
|
|
<input type="checkbox" name="<?= $qi['question_key'] ?>" <?= $val ? 'checked' : '' ?>> <?= htmlspecialchars($qi['label']) ?>
|
|
</label>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php else: ?>
|
|
<div class="form-group">
|
|
<label for="<?= $qi['question_key'] ?>"><?= htmlspecialchars($qi['label']) ?></label>
|
|
<input type="<?= $qi['type'] === 'number' ? 'number' : 'text' ?>" id="<?= $qi['question_key'] ?>" name="<?= $qi['question_key'] ?>" value="<?= htmlspecialchars($val) ?>" <?= $qi['required'] ? 'required' : '' ?>>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($qi['help_text']): ?>
|
|
<small style="color:var(--text-muted);display:block;margin-top:-4px;margin-bottom:12px;"><?= htmlspecialchars($qi['help_text']) ?></small>
|
|
<?php endif; ?>
|
|
<?php endforeach; ?>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php else: ?>
|
|
<div class="card" style="text-align:center;color:var(--text-muted);padding:40px;">
|
|
<p>На этом шаге пока нет вопросов. Настройте опросник в <a href="../owner/questionnaire_config.php" style="color:var(--accent);">конфигураторе</a>.</p>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div style="display:flex;gap:10px;justify-content:space-between;">
|
|
<div>
|
|
<button type="submit" name="save" class="btn btn-secondary">💾 Сохранить</button>
|
|
</div>
|
|
<div>
|
|
<?php if ($step > 1): ?>
|
|
<a href="?id=<?= $sessId ?>&step=<?= $step - 1 ?>" class="btn btn-secondary">← Назад</a>
|
|
<?php endif; ?>
|
|
<?php if ($step < 5): ?>
|
|
<button type="submit" name="next" class="btn btn-primary">Далее →</button>
|
|
<?php else: ?>
|
|
<button type="submit" name="next" class="btn btn-success" onclick="return confirm('Завершить расчёт SLA?')">✅ Рассчитать SLA</button>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<?php endif; ?>
|
|
|
|
<!-- Sessions list -->
|
|
<div class="card">
|
|
<div class="card-header"><h2>История опросников</h2></div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Автор</th>
|
|
<th>Статус</th>
|
|
<th>Risk Score</th>
|
|
<th>Object Index</th>
|
|
<th>SLA / мес</th>
|
|
<th>Дата</th>
|
|
<th class="actions"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($sessions as $s): ?>
|
|
<tr>
|
|
<td>#<?= $s['id'] ?></td>
|
|
<td><?= htmlspecialchars($s['author_name'] ?: '—') ?></td>
|
|
<td><span class="badge badge-<?= $s['status'] === 'completed' ? 'done' : ($s['status'] === 'draft' ? 'draft' : 'cancelled') ?>"><?= $s['status'] ?></span></td>
|
|
<td><?= $s['risk_score'] ?: '—' ?></td>
|
|
<td><?= $s['object_index'] ?: '—' ?></td>
|
|
<td><?= $s['sla_price_monthly'] ? fmt_money($s['sla_price_monthly']) : '—' ?></td>
|
|
<td><?= date('d.m.Y', strtotime($s['created_at'])) ?></td>
|
|
<td class="actions"><a href="?id=<?= $s['id'] ?>" class="btn btn-secondary btn-sm" data-tooltip="Просмотр">👁️</a></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<?php require __DIR__ . '/../../inc/footer.php'; ?>
|