Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом
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>
213 lines
7.3 KiB
Go
213 lines
7.3 KiB
Go
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
|
|
}
|