feat: расчёт стоимости платного тестирования/аренды GPU-серверов

Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом
rental_enabled): расчёт по методичке "Регламент расчёта стоимости
платного тестирования и аренды GPU-серверов" — Разовый + Еженедельный
платёж по каждому компоненту (New/БУ чекбоксом), с аплифтом к Estimate.

Заодно: поддержка (support_code) в Base-вкладке теперь добавляется как
обычный LOT в спеку (SVC_{срок}y{уровень}_{платформа}) через
autocomplete-пикер вместо выпадающего списка — партномер собирается
тем же lot_name-based механизмом, что и для GPU/CPU/памяти, справочная
цена по регламенту техподдержки хранится в qt_settings.support_pricing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-22 13:34:07 +03:00
co-authored by Claude Sonnet 5
parent b48436de93
commit 7edb80e498
25 changed files with 1052 additions and 71 deletions
+381 -13
View File
@@ -78,6 +78,10 @@
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
Ценообразование
</button>
<button id="top-tab-rental" onclick="switchTopTab('rental')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700 hidden">
Аренда
</button>
</nav>
</div>
@@ -305,6 +309,54 @@
</div><!-- end top-section-pricing -->
<!-- Top-tab section: Аренда (paid testing / rental) -->
<div id="top-section-rental" class="hidden space-y-6">
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-baseline gap-3 mb-1">
<h3 class="text-base font-semibold text-gray-800">Расчёт стоимости платного тестирования / аренды</h3>
</div>
<p class="text-xs text-gray-500 mb-3">Черновая методика — параметры и коэффициенты могут измениться. Цена всегда за 1 неделю: разовый платёж (один раз) + еженедельный платёж (за каждую неделю теста/аренды). Цена берётся из Estimate, увеличенного на аплифт ниже. Поддержка сюда не входит.</p>
<div class="flex flex-wrap items-end gap-4 mb-4">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Аплифт к Estimate, %</label>
<input type="number" id="rental-uplift-percent" min="0" step="0.1" value="0"
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="scheduleRentalRecalc()" onchange="scheduleRentalRecalc()">
</div>
<button onclick="saveRentalSettings()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
Сохранить
</button>
<span id="rental-save-status" class="text-xs text-gray-500"></span>
</div>
<div id="rental-warnings" class="hidden text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-3"></div>
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">Категория</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-center border-b">БУ</th>
<th class="px-3 py-2 text-right border-b">Разовый платёж</th>
<th class="px-3 py-2 text-right border-b">Еженедельный платёж</th>
</tr>
</thead>
<tbody id="rental-body">
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите компоненты во вкладке «Estimate»</td></tr>
</tbody>
<tfoot id="rental-foot" class="hidden bg-gray-50 font-semibold">
<tr>
<td colspan="5" class="px-3 py-2 text-right">Итого (с НДС):</td>
<td class="px-3 py-2 text-right" id="rental-total-onetime"></td>
<td class="px-3 py-2 text-right" id="rental-total-weekly"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div><!-- end top-section-rental -->
</div>
<!-- Price settings modal -->
@@ -842,9 +894,15 @@ function applyServerSettings(settings) {
});
}
// support_pricing → picker price table
if (settings.support_pricing && typeof settings.support_pricing === 'object') {
supportPricingTable = settings.support_pricing;
}
applyConfigTypeToTabs();
updateTabVisibility();
updateRequiredCategoryBadges();
updateSupportPriceDisplay();
}
function updateRequiredCategoryBadges() {
@@ -904,6 +962,14 @@ document.addEventListener('DOMContentLoaded', async function() {
projectUUID = config.project_uuid || '';
await loadProjectIndex();
updateConfigBreadcrumbs();
applyRentalTabVisibility();
rentalConditions = {};
(config.rental_items || []).forEach(item => {
rentalConditions[(item.lot_name || '').toUpperCase()] = item.condition === 'used' ? 'used' : 'new';
});
rentalUpliftPercent = config.rental_uplift_percent || 0;
document.getElementById('rental-uplift-percent').value = rentalUpliftPercent;
document.getElementById('save-buttons').classList.remove('hidden');
// Set server count from config
@@ -928,7 +994,7 @@ document.addEventListener('DOMContentLoaded', async function() {
category: item.category }));
}
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name);
currentArticle = config.article || '';
restorePricingStateFromNotes(config.notes || '');
@@ -972,6 +1038,8 @@ document.addEventListener('DOMContentLoaded', async function() {
document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete-wrapper')) {
hideAutocomplete();
const supportDropdown = document.getElementById('support-code-dropdown');
if (supportDropdown) supportDropdown.classList.add('hidden');
}
});
@@ -1381,23 +1449,29 @@ function renderSingleSelectTab(categories) {
html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
<label for="support-code-input" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<input type="text"
id="server-model-input"
value="${escapeHtml(serverModelForQuote)}"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="updateServerModelForQuote(this.value)">
<select id="support-code-select"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateSupportCode(this.value)">
<option value="">—</option>
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
</select>
<div class="autocomplete-wrapper relative">
<input type="text"
id="support-code-input"
autocomplete="off"
placeholder="Начните вводить..."
value="${escapeHtml(supportCodeLabel(supportCode))}"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onSupportCodeInput(this.value)"
onfocus="onSupportCodeInput(this.value)">
<div id="support-code-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-72 overflow-y-auto w-full mt-1"></div>
</div>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<div></div>
<div id="support-code-price" class="text-xs text-gray-500"></div>
</div>
`;
}
@@ -2249,6 +2323,7 @@ function removeFromCart(lotName) {
function updateCartUI() {
updateTabVisibility();
updateRequiredCategoryBadges();
updateSupportPriceDisplay();
window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total);
@@ -2343,6 +2418,160 @@ function updateSupportCode(value) {
scheduleArticlePreview();
}
// ==================== SUPPORT PICKER (Регламент расчёта стоимости техподдержки) ====================
// Support is added to the spec as a regular LOT in `cart` (lot_name like "SVC_3yB_HGX-H200"),
// exactly like any other component — the article generator picks it up via the same
// lot_name-pattern segment mechanism used for GPU/CPU/etc (internal/article/generator.go), no
// separate support field/mechanism. Price data (supportPricingTable) is server-driven via
// qt_settings.support_pricing so it can be edited in MariaDB without a release; the list of
// offerable level/duration codes is fixed here.
let supportPricingTable = null;
const SUPPORT_LEVEL_NAMES = { W: 'Warranty', B: 'BASE', S: 'STANDARD', P: 'PREMIUM' };
// All (duration, level) combinations the regulation offers, per platform.
// HGX Warranty is included by default (no purchasable code), so it's x86-only here.
const SUPPORT_CODE_COMBOS = {
x86: ['3yW', '1yB', '3yB', '5yB', '1yS', '3yS', '5yS', '1yP', '3yP', '5yP'],
HGX: ['1yB', '3yB', '5yB', '1yS', '3yS', '5yS', '1yP', '3yP']
};
function supportCodeLabel(code) {
if (!code) return '';
const parts = code.split('y');
if (parts.length !== 2) return code;
const years = parts[0];
const levelName = SUPPORT_LEVEL_NAMES[parts[1]] || parts[1];
return years + ' ' + (years === '1' ? 'год' : 'года') + ' — ' + levelName + ' (' + code + ')';
}
// Same "SVC_{years}y{level}_{platform}" shape as internal/article/generator.go's buildSupportSegment.
function parseSupportCodeFromLotName(lotName) {
if (!lotName) return '';
const parts = lotName.split('_');
return parts.length >= 2 ? parts[1] : '';
}
function findSupportCartIndex() {
return cart.findIndex(i => (i.lot_name || '').toUpperCase().startsWith('SVC_'));
}
// Mirrors the GPU chip-generation classification used by internal/services/rental.go,
// so the support picker and the rental calc agree on which platform a config is.
function detectSupportPlatform() {
const actualGenSubstrings = ['H200', 'B200', 'B300', 'BLACKWELL SE'];
let sawGPU = false;
for (const item of cart) {
if ((item.category || '').toUpperCase() !== 'GPU') continue;
sawGPU = true;
const upperLot = (item.lot_name || '').toUpperCase();
if (upperLot.includes('B300')) return 'HGX-B300';
if (upperLot.includes('B200')) return 'HGX-B200';
}
return sawGPU ? 'HGX-H200' : 'x86';
}
// cartTotal excludes any existing support line so the x86 percent is based on hardware only.
function computeSupportPrice(code, platform) {
if (!supportPricingTable) return null;
const parts = code.split('y');
if (parts.length !== 2) return null;
const years = parts[0];
const level = parts[1];
if (platform === 'x86') {
const percent = supportPricingTable.x86_percent && supportPricingTable.x86_percent[level] && supportPricingTable.x86_percent[level][years];
if (typeof percent !== 'number') return null;
const cartTotal = cart.reduce((sum, item) => {
if ((item.lot_name || '').toUpperCase().startsWith('SVC_')) return sum;
return sum + (getDisplayPrice(item) * item.quantity);
}, 0);
return cartTotal * percent;
}
const price = supportPricingTable.hgx_price && supportPricingTable.hgx_price[platform] && supportPricingTable.hgx_price[platform][level] && supportPricingTable.hgx_price[platform][level][years];
return typeof price === 'number' ? price : null;
}
// Re-syncs supportCode/input from cart so removing the SVC_ line via the normal
// per-item remove button (like any other component) also clears the picker.
function updateSupportPriceDisplay() {
supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name);
const input = document.getElementById('support-code-input');
if (input && document.activeElement !== input) {
input.value = supportCodeLabel(supportCode);
}
const el = document.getElementById('support-code-price');
if (!el) return;
if (!supportCode) {
el.textContent = '';
return;
}
const platform = detectSupportPlatform();
const price = computeSupportPrice(supportCode, platform);
if (price === null) {
el.textContent = 'Нет цены для этой комбинации (' + platform + ')';
return;
}
el.textContent = formatMoney(price) + ' за весь срок (' + platform + ')';
}
function onSupportCodeInput(text) {
const platform = detectSupportPlatform();
const combos = platform === 'x86' ? SUPPORT_CODE_COMBOS.x86 : SUPPORT_CODE_COMBOS.HGX;
const query = (text || '').trim().toLowerCase();
const matches = combos.filter(code => !query || supportCodeLabel(code).toLowerCase().includes(query) || code.toLowerCase().includes(query));
renderSupportCodeDropdown(matches);
}
function renderSupportCodeDropdown(codes) {
const dropdown = document.getElementById('support-code-dropdown');
if (!dropdown) return;
let html = '<div class="autocomplete-item px-3 py-2 hover:bg-gray-100 cursor-pointer text-gray-500" onclick="selectSupportCode(\'\')">— без поддержки —</div>';
html += codes.map(code =>
'<div class="autocomplete-item px-3 py-2 hover:bg-gray-100 cursor-pointer" onclick="selectSupportCode(\'' + code + '\')">' +
escapeHtml(supportCodeLabel(code)) +
'</div>'
).join('');
dropdown.innerHTML = html;
dropdown.classList.remove('hidden');
}
// Adds/replaces/removes the support LOT in `cart`, exactly like adding/removing any other
// component — this is what the article generator, totals, and everything else sees.
function selectSupportCode(code) {
const existingIdx = findSupportCartIndex();
if (existingIdx >= 0) {
cart.splice(existingIdx, 1);
}
supportCode = code || '';
if (supportCode) {
const platform = detectSupportPlatform();
const price = computeSupportPrice(supportCode, platform);
cart.push({
lot_name: 'SVC_' + supportCode + '_' + platform,
quantity: 1,
unit_price: price || 0,
estimate_price: price || 0,
warehouse_price: null,
competitor_price: null,
description: supportCodeLabel(supportCode),
category: ''
});
}
const input = document.getElementById('support-code-input');
if (input) input.value = supportCodeLabel(supportCode);
const dropdown = document.getElementById('support-code-dropdown');
if (dropdown) dropdown.classList.add('hidden');
renderTab();
updateCartUI();
triggerAutoSave();
}
function scheduleArticlePreview() {
if (articlePreviewTimeout) {
clearTimeout(articlePreviewTimeout);
@@ -3059,7 +3288,7 @@ let currentTopTab = 'estimate';
function switchTopTab(tab) {
currentTopTab = tab;
const tabs = ['estimate', 'bom', 'pricing'];
const tabs = ['estimate', 'bom', 'pricing', 'rental'];
tabs.forEach(t => {
const btn = document.getElementById('top-tab-' + t);
const section = document.getElementById('top-section-' + t);
@@ -3076,6 +3305,145 @@ function switchTopTab(tab) {
if (tab === 'pricing') {
renderPricingTab();
}
if (tab === 'rental') {
renderRentalTab();
}
}
// ==================== АРЕНДА (rental / paid testing) ====================
let rentalConditions = {}; // lot_name (upper) -> 'new'|'used'
let rentalUpliftPercent = 0;
let rentalRecalcTimer = null;
function applyRentalTabVisibility() {
const enabled = !!(projectUUID && projectByUUID[projectUUID] && projectByUUID[projectUUID].rental_enabled);
const btn = document.getElementById('top-tab-rental');
if (!btn) return;
if (enabled) {
btn.classList.remove('hidden');
} else {
btn.classList.add('hidden');
if (currentTopTab === 'rental') {
switchTopTab('estimate');
}
}
}
function scheduleRentalRecalc() {
clearTimeout(rentalRecalcTimer);
rentalRecalcTimer = setTimeout(renderRentalTab, 300);
}
function rentalConditionFor(lotName) {
const key = (lotName || '').toUpperCase();
return rentalConditions[key] === 'used' ? 'used' : 'new';
}
function onRentalConditionChange(lotName, isUsed) {
const key = (lotName || '').toUpperCase();
rentalConditions[key] = isUsed ? 'used' : 'new';
renderRentalTab();
}
function buildRentalItemsPayload() {
return cart.map(item => ({
lot_name: item.lot_name,
condition: rentalConditionFor(item.lot_name)
}));
}
async function renderRentalTab() {
const body = document.getElementById('rental-body');
const foot = document.getElementById('rental-foot');
const warningsEl = document.getElementById('rental-warnings');
if (!configUUID || cart.length === 0) {
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите компоненты во вкладке «Estimate»</td></tr>';
foot.classList.add('hidden');
return;
}
rentalUpliftPercent = parseFloat(document.getElementById('rental-uplift-percent').value) || 0;
const payload = {
items: buildRentalItemsPayload(),
uplift_percent: rentalUpliftPercent
};
let result;
try {
const resp = await fetch('/api/configs/' + configUUID + '/rental/calculate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-red-500">Не удалось рассчитать стоимость аренды</td></tr>';
foot.classList.add('hidden');
return;
}
result = await resp.json();
} catch (e) {
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-red-500">Не удалось рассчитать стоимость аренды</td></tr>';
foot.classList.add('hidden');
return;
}
if (result.warnings && result.warnings.length > 0) {
warningsEl.textContent = result.warnings.join(' ');
warningsEl.classList.remove('hidden');
} else {
warningsEl.classList.add('hidden');
}
const descByLot = {};
cart.forEach(c => { descByLot[(c.lot_name || '').toUpperCase()] = c.description || ''; });
const rows = (result.items || []).map(item => {
const isUsed = item.condition === 'used';
const desc = descByLot[(item.lot_name || '').toUpperCase()] || '';
return '<tr class="border-b">' +
'<td class="px-3 py-2">' + escapeHtml(item.lot_name) + '</td>' +
'<td class="px-3 py-2 text-gray-500">' + escapeHtml(desc) + '</td>' +
'<td class="px-3 py-2 text-gray-500">' + escapeHtml(item.category || '') + '</td>' +
'<td class="px-3 py-2 text-right">' + item.quantity + '</td>' +
'<td class="px-3 py-2 text-center">' +
'<input type="checkbox" ' + (isUsed ? 'checked' : '') +
' onchange="onRentalConditionChange(\'' + escapeHtml(item.lot_name).replace(/'/g, "\\'") + '\', this.checked)" class="rounded border-gray-300">' +
'</td>' +
'<td class="px-3 py-2 text-right">' + formatMoney(item.one_time) + '</td>' +
'<td class="px-3 py-2 text-right">' + formatMoney(item.weekly) + '</td>' +
'</tr>';
}).join('');
body.innerHTML = rows || '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Нет компонентов</td></tr>';
document.getElementById('rental-total-onetime').textContent = formatMoney(result.one_time_total);
document.getElementById('rental-total-weekly').textContent = formatMoney(result.weekly_total);
foot.classList.remove('hidden');
}
async function saveRentalSettings() {
if (!configUUID) return;
const statusEl = document.getElementById('rental-save-status');
const payload = {
items: buildRentalItemsPayload(),
uplift_percent: rentalUpliftPercent
};
try {
const resp = await fetch('/api/configs/' + configUUID + '/rental', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
statusEl.textContent = 'Ошибка сохранения';
return;
}
statusEl.textContent = 'Сохранено';
setTimeout(() => { statusEl.textContent = ''; }, 2000);
} catch (e) {
statusEl.textContent = 'Ошибка сохранения';
}
}
// ==================== BOM ВЕНДОРА ====================
+10 -1
View File
@@ -314,6 +314,13 @@
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div>
</div>
<div>
<label class="flex items-center space-x-2">
<input type="checkbox" id="project-settings-rental-enabled" class="rounded border-gray-300">
<span class="text-sm font-medium text-gray-700">Аренда</span>
</label>
<div class="text-xs text-gray-500 mt-1">Добавляет вкладку «Аренда» (расчёт стоимости платного тестирования/аренды) на конфигурациях этого проекта.</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
@@ -1253,6 +1260,7 @@ function openProjectSettingsModal() {
document.getElementById('project-settings-variant').value = project.variant || '';
document.getElementById('project-settings-name').value = project.name || '';
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
document.getElementById('project-settings-rental-enabled').checked = !!project.rental_enabled;
document.getElementById('project-settings-modal').classList.remove('hidden');
document.getElementById('project-settings-modal').classList.add('flex');
}
@@ -1268,6 +1276,7 @@ async function saveProjectSettings() {
const variant = document.getElementById('project-settings-variant').value.trim();
const name = document.getElementById('project-settings-name').value.trim();
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
const rentalEnabled = document.getElementById('project-settings-rental-enabled').checked;
if (!code) {
alert('Введите код проекта');
return;
@@ -1275,7 +1284,7 @@ async function saveProjectSettings() {
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL, rental_enabled: rentalEnabled})
});
if (!resp.ok) {
if (resp.status === 409) {