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
+34 -4
View File
@@ -71,7 +71,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
@@ -169,7 +168,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
@@ -226,7 +224,6 @@ func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewReque
}
return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
}
@@ -534,7 +531,6 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
@@ -1325,6 +1321,40 @@ func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec loc
return cfg, nil
}
// RentalUpdateRequest carries the editable fields of a configuration's
// Аренда (rental) tab. Persisted independently of the BOM/pricing tabs.
type RentalUpdateRequest struct {
Items []models.RentalItemCondition `json:"items"`
UpliftPercent float64 `json:"uplift_percent"`
}
// UpdateRentalNoAuth updates only the rental fields of a configuration without ownership check.
func (s *LocalConfigurationService) UpdateRentalNoAuth(uuid string, req *RentalUpdateRequest) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
conditions := make(localdb.RentalItemConditions, 0, len(req.Items))
for _, item := range req.Items {
conditions = append(conditions, localdb.RentalItemCondition{
LotName: models.NormalizeLotName(item.LotName),
Condition: item.Condition,
})
}
localCfg.RentalItems = conditions
localCfg.RentalUpliftPercent = req.UpliftPercent
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("update rental fields without auth with version: %w", err)
}
return cfg, nil
}
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
+8 -4
View File
@@ -46,10 +46,11 @@ type CreateProjectRequest struct {
}
type UpdateProjectRequest struct {
Code *string `json:"code,omitempty"`
Variant *string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL *string `json:"tracker_url,omitempty"`
Code *string `json:"code,omitempty"`
Variant *string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL *string `json:"tracker_url,omitempty"`
RentalEnabled *bool `json:"rental_enabled,omitempty"`
}
type ProjectConfigurationsResult struct {
@@ -148,6 +149,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
}
if req.RentalEnabled != nil {
localProject.RentalEnabled = *req.RentalEnabled
}
localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending"
if err := s.localDB.SaveProject(localProject); err != nil {
+212
View File
@@ -0,0 +1,212 @@
package services
import (
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
// RentalService computes paid-testing/rental pricing for a configuration,
// per the draft "Регламент расчёта стоимости платного тестирования и аренды
// GPU-серверов" methodology (Steps 1-8 and 10; buyout/Step 9 is out of scope).
//
// Pricing is always quoted per week (Разовый + Еженедельный): the methodology
// prices in multiples of a week, and there is no separate "term" input — a
// sales rep multiplies the weekly rate by however many weeks are needed.
//
// The "Цена" the methodology depreciates against is derived from the
// configuration's existing Estimate buy price via a single project-wide
// uplift percentage — QuoteForge has no per-component sale price today.
//
// Support is out of scope here: this calc covers BOM component depreciation
// only. If a deal needs support priced in, that is handled outside the
// Аренда tab (e.g. via the Estimate tab / support pricing elsewhere) rather
// than being computed or added here.
type RentalService struct {
localDB *localdb.LocalDB
}
func NewRentalService(localDB *localdb.LocalDB) *RentalService {
return &RentalService{localDB: localDB}
}
const (
daysPerYear = 365
rentalMarginFactor = 1.03
rentalVATFactor = 1.22
rentalWeekDays = 7
)
// rentalCoeffs holds the depreciation coefficients for one methodology category.
type rentalCoeffs struct {
KNew float64 // one-time layer-1 hit, new hardware only
LifeNewDays float64
LifeUsedDays float64
}
var (
chassisCoeffs = rentalCoeffs{KNew: 0.30, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
cpuDramCoeffs = rentalCoeffs{KNew: 0.075, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
diskCoeffs = rentalCoeffs{KNew: 0.50, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
cardsCoeffs = rentalCoeffs{KNew: 0.30, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
gpuActualCoeffs = rentalCoeffs{KNew: 0.20, LifeNewDays: 2 * daysPerYear, LifeUsedDays: 1 * daysPerYear}
gpuStabilizedCoeffs = rentalCoeffs{KNew: 0.20, LifeNewDays: 3 * daysPerYear, LifeUsedDays: 1 * daysPerYear}
)
// categoryCoeffMap maps existing lot_category codes (models.DefaultCategories)
// to their methodology category. Categories absent here (including "Other")
// fall back to cardsCoeffs as the closest generic fit.
var categoryCoeffMap = map[string]rentalCoeffs{
"BB": chassisCoeffs,
"MB": chassisCoeffs,
"PSU": chassisCoeffs,
"PS": chassisCoeffs,
"CPU": cpuDramCoeffs,
"MEM": cpuDramCoeffs,
"SSD": diskCoeffs,
"HDD": diskCoeffs,
"M2": diskCoeffs,
"EDSFF": diskCoeffs,
"HHHL": diskCoeffs,
"NIC": cardsCoeffs,
"HCA": cardsCoeffs,
"DPU": cardsCoeffs,
"HBA": cardsCoeffs,
"RAID": cardsCoeffs,
"RISERS": cardsCoeffs,
"CARD": cardsCoeffs,
"ACC": cardsCoeffs,
}
// gpuActualGenSubstrings identifies "actual generation" GPUs per the
// regulation's model list (H200, RTX PRO 6000 Blackwell SE, B200/B300).
// Update manually as new GPU generations ship.
var gpuActualGenSubstrings = []string{"H200", "B200", "B300", "BLACKWELL SE"}
func rentalCoeffsFor(category, lotName string) rentalCoeffs {
cat := strings.ToUpper(strings.TrimSpace(category))
if cat == "GPU" {
upperLot := strings.ToUpper(lotName)
for _, s := range gpuActualGenSubstrings {
if strings.Contains(upperLot, s) {
return gpuActualCoeffs
}
}
return gpuStabilizedCoeffs
}
if c, ok := categoryCoeffMap[cat]; ok {
return c
}
return cardsCoeffs
}
type RentalComponentResult struct {
LotName string `json:"lot_name"`
Category string `json:"category"`
Quantity int `json:"quantity"`
Condition string `json:"condition"`
BuyPrice float64 `json:"buy_price"` // per-unit Estimate price
Price float64 `json:"price"` // per-unit "Цена" after uplift
Layer1 float64 `json:"layer1"` // total for quantity, ex-VAT, no margin
DailyWear float64 `json:"daily_wear"` // total for quantity, ex-VAT, no margin
OneTime float64 `json:"one_time"` // Разовый, with margin+VAT
Weekly float64 `json:"weekly"` // Еженедельный, with margin+VAT
PriceMissing bool `json:"price_missing"`
}
type RentalCalculateResult struct {
Items []RentalComponentResult `json:"items"`
Layer1Total float64 `json:"layer1_total"`
DailyWearTotal float64 `json:"daily_wear_total"`
OneTimeTotal float64 `json:"one_time_total"` // Разовый, with margin+VAT
WeeklyTotal float64 `json:"weekly_total"` // Еженедельный, with margin+VAT
Warnings []string `json:"warnings,omitempty"`
}
// Calculate computes rental pricing for a configuration, always priced per
// week (Разовый one-time + Еженедельный recurring).
func (s *RentalService) Calculate(configUUID string, req *RentalUpdateRequest) (*RentalCalculateResult, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
if err != nil {
return nil, ErrConfigNotFound
}
conditionByLot := make(map[string]string, len(req.Items))
for _, item := range req.Items {
conditionByLot[models.NormalizeLotName(item.LotName)] = item.Condition
}
lotNames := make([]string, 0, len(localCfg.Items))
for _, item := range localCfg.Items {
lotNames = append(lotNames, item.LotName)
}
var categories map[string]string
if localCfg.PricelistID != nil {
categories, _ = s.localDB.GetLocalLotCategoriesByServerPricelistID(*localCfg.PricelistID, lotNames)
}
if categories == nil {
categories = map[string]string{}
}
result := &RentalCalculateResult{
Items: make([]RentalComponentResult, 0, len(localCfg.Items)),
}
uplift := 1 + req.UpliftPercent/100
for _, item := range localCfg.Items {
condition := conditionByLot[item.LotName]
if condition != "used" {
condition = "new"
}
category := categories[item.LotName]
coeffs := rentalCoeffsFor(category, item.LotName)
price := item.UnitPrice * uplift
qty := float64(item.Quantity)
var layer1, residual, term float64
if condition == "new" {
layer1 = price * coeffs.KNew * qty
residual = (price - price*coeffs.KNew) * qty
term = coeffs.LifeNewDays
} else {
layer1 = 0
residual = price * qty
term = coeffs.LifeUsedDays
}
dailyWear := 0.0
if term > 0 {
dailyWear = residual / term
}
result.Layer1Total += layer1
result.DailyWearTotal += dailyWear
oneTime := layer1 * rentalMarginFactor * rentalVATFactor
weekly := dailyWear * rentalWeekDays * rentalMarginFactor * rentalVATFactor
result.Items = append(result.Items, RentalComponentResult{
LotName: item.LotName,
Category: category,
Quantity: item.Quantity,
Condition: condition,
BuyPrice: item.UnitPrice,
Price: price,
Layer1: layer1,
DailyWear: dailyWear,
OneTime: oneTime,
Weekly: weekly,
PriceMissing: item.UnitPrice <= 0,
})
}
result.OneTimeTotal = result.Layer1Total * rentalMarginFactor * rentalVATFactor
result.WeeklyTotal = result.DailyWearTotal * rentalWeekDays * rentalMarginFactor * rentalVATFactor
return result, nil
}
+1
View File
@@ -217,6 +217,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive
existing.IsSystem = project.IsSystem
existing.RentalEnabled = project.RentalEnabled
existing.CreatedAt = project.CreatedAt
existing.UpdatedAt = project.UpdatedAt
serverID := project.ID