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 }