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
+28
View File
@@ -74,6 +74,8 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
DisablePriceRefresh: cfg.DisablePriceRefresh,
OnlyInStock: cfg.OnlyInStock,
RentalItems: modelRentalItemsToLocal(cfg.RentalItems),
RentalUpliftPercent: cfg.RentalUpliftPercent,
Line: cfg.Line,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
@@ -123,6 +125,8 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
VendorSpec: localVendorSpecToModel(local.VendorSpec),
DisablePriceRefresh: local.DisablePriceRefresh,
OnlyInStock: local.OnlyInStock,
RentalItems: localRentalItemsToModel(local.RentalItems),
RentalUpliftPercent: local.RentalUpliftPercent,
Line: local.Line,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
@@ -231,6 +235,28 @@ func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
return out
}
func modelRentalItemsToLocal(items models.RentalItemConditions) RentalItemConditions {
if len(items) == 0 {
return nil
}
out := make(RentalItemConditions, len(items))
for i, item := range items {
out[i] = RentalItemCondition{LotName: item.LotName, Condition: item.Condition}
}
return out
}
func localRentalItemsToModel(items RentalItemConditions) models.RentalItemConditions {
if len(items) == 0 {
return nil
}
out := make(models.RentalItemConditions, len(items))
for i, item := range items {
out[i] = models.RentalItemCondition{LotName: item.LotName, Condition: item.Condition}
}
return out
}
func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{
UUID: project.UUID,
@@ -241,6 +267,7 @@ func ProjectToLocal(project *models.Project) *LocalProject {
TrackerURL: project.TrackerURL,
IsActive: project.IsActive,
IsSystem: project.IsSystem,
RentalEnabled: project.RentalEnabled,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
SyncStatus: "pending",
@@ -262,6 +289,7 @@ func LocalToProject(local *LocalProject) *models.Project {
TrackerURL: local.TrackerURL,
IsActive: local.IsActive,
IsSystem: local.IsSystem,
RentalEnabled: local.RentalEnabled,
CreatedAt: local.CreatedAt,
UpdatedAt: local.UpdatedAt,
}
+1
View File
@@ -211,6 +211,7 @@ CREATE TABLE local_projects (
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
rental_enabled INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
synced_at DATETIME NULL,
+15
View File
@@ -124,6 +124,21 @@ var localMigrations = []localMigration{
name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
run: deduplicatePricelistItemsAndAddUniqueIndex,
},
{
id: "2026_07_22_local_project_rental_enabled",
name: "Add rental_enabled to local_projects",
run: addLocalProjectRentalEnabled,
},
}
func addLocalProjectRentalEnabled(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN rental_enabled INTEGER NOT NULL DEFAULT 0`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
return nil
}
type localPartnumberCatalogRow struct {
+36
View File
@@ -112,6 +112,8 @@ type LocalConfiguration struct {
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
RentalItems RentalItemConditions `gorm:"type:text" json:"rental_items,omitempty"`
RentalUpliftPercent float64 `gorm:"default:0" json:"rental_uplift_percent"`
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
@@ -141,6 +143,7 @@ type LocalProject struct {
TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
RentalEnabled bool `gorm:"default:false" json:"rental_enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at,omitempty"`
@@ -298,6 +301,39 @@ func (LocalPartnumberBookItem) TableName() string {
return "local_partnumber_book_items"
}
// RentalItemCondition tracks New/БУ condition per component for a single
// configuration's Аренда (rental) tab. Independent of the lot's catalog data.
type RentalItemCondition struct {
LotName string `json:"lot_name"`
Condition string `json:"condition"` // "new" or "used"
}
type RentalItemConditions []RentalItemCondition
func (r RentalItemConditions) Value() (driver.Value, error) {
if r == nil {
return nil, nil
}
return json.Marshal(r)
}
func (r *RentalItemConditions) Scan(value interface{}) error {
if value == nil {
*r = nil
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for RentalItemConditions")
}
return json.Unmarshal(bytes, r)
}
// VendorSpecItem represents a single row in a vendor BOM specification
type VendorSpecItem struct {
SortOrder int `json:"sort_order"`
+24 -6
View File
@@ -31,14 +31,25 @@ type TabDef struct {
Sections []TabSection `json:"sections,omitempty"`
}
// ConfiguratorSettings holds all four server-driven settings consumed by the configurator.
// SupportPricingTable holds the price data from "Регламент расчёта стоимости
// технической поддержки серверов": x86 is a percent of sale price, HGX
// platforms (by chip generation) are a fixed multi-year price. Both are
// keyed level -> duration-in-years (as a string, since JSON object keys are
// strings) -> value. Missing combos are not offered by the regulation.
type SupportPricingTable struct {
X86Percent map[string]map[string]float64 `json:"x86_percent"`
HGXPrice map[string]map[string]map[string]float64 `json:"hgx_price"` // platform -> level -> years -> USD
}
// ConfiguratorSettings holds all server-driven settings consumed by the configurator.
// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
// callers are expected to apply hardcoded fallbacks in that case.
type ConfiguratorSettings struct {
ConfigTypes []ConfigTypeDef `json:"config_types"`
TabConfig []TabDef `json:"tab_config"`
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
RequiredCategories map[string][]string `json:"required_categories"`
ConfigTypes []ConfigTypeDef `json:"config_types"`
TabConfig []TabDef `json:"tab_config"`
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
RequiredCategories map[string][]string `json:"required_categories"`
SupportPricing *SupportPricingTable `json:"support_pricing,omitempty"`
}
// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the
@@ -93,7 +104,7 @@ func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error
func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
out := &ConfiguratorSettings{}
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"}
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories", "support_pricing"}
for _, key := range keys {
raw, found, err := l.GetQtSetting(key)
if err != nil {
@@ -119,6 +130,13 @@ func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
slog.Warn("failed to parse required_categories setting", "error", err)
}
case "support_pricing":
var table SupportPricingTable
if err := json.Unmarshal([]byte(raw), &table); err != nil {
slog.Warn("failed to parse support_pricing setting", "error", err)
} else {
out.SupportPricing = &table
}
}
}
+18 -2
View File
@@ -31,6 +31,8 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"competitor_pricelist_id": localCfg.CompetitorPricelistID,
"disable_price_refresh": localCfg.DisablePriceRefresh,
"only_in_stock": localCfg.OnlyInStock,
"rental_items": localCfg.RentalItems,
"rental_uplift_percent": localCfg.RentalUpliftPercent,
"vendor_spec": localCfg.VendorSpec,
"line": localCfg.Line,
"price_updated_at": localCfg.PriceUpdatedAt,
@@ -67,8 +69,10 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
PricelistID *uint `json:"pricelist_id"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
RentalItems RentalItemConditions `json:"rental_items"`
RentalUpliftPercent float64 `json:"rental_uplift_percent"`
VendorSpec VendorSpec `json:"vendor_spec"`
Line int `json:"line"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
@@ -103,6 +107,8 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
CompetitorPricelistID: snapshot.CompetitorPricelistID,
DisablePriceRefresh: snapshot.DisablePriceRefresh,
OnlyInStock: snapshot.OnlyInStock,
RentalItems: snapshot.RentalItems,
RentalUpliftPercent: snapshot.RentalUpliftPercent,
VendorSpec: snapshot.VendorSpec,
Line: snapshot.Line,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
@@ -121,6 +127,8 @@ type configurationSpecPriceFingerprint struct {
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
RentalItems RentalItemConditions `json:"rental_items,omitempty"`
RentalUpliftPercent float64 `json:"rental_uplift_percent"`
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
}
@@ -151,6 +159,12 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
return items[i].UnitPrice < items[j].UnitPrice
})
rentalItems := make(RentalItemConditions, len(localCfg.RentalItems))
copy(rentalItems, localCfg.RentalItems)
sort.Slice(rentalItems, func(i, j int) bool {
return rentalItems[i].LotName < rentalItems[j].LotName
})
payload := configurationSpecPriceFingerprint{
Items: items,
ServerCount: localCfg.ServerCount,
@@ -161,6 +175,8 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
CompetitorPricelistID: localCfg.CompetitorPricelistID,
DisablePriceRefresh: localCfg.DisablePriceRefresh,
OnlyInStock: localCfg.OnlyInStock,
RentalItems: rentalItems,
RentalUpliftPercent: localCfg.RentalUpliftPercent,
VendorSpec: localCfg.VendorSpec,
}