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:
co-authored by
Claude Sonnet 5
parent
b48436de93
commit
7edb80e498
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
type BuildOptions struct {
|
||||
ServerModel string
|
||||
SupportCode string
|
||||
ServerPricelist *uint
|
||||
}
|
||||
|
||||
@@ -95,12 +94,8 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
||||
segs = append(segs, namedSeg{"PSU", psuSeg})
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||
code := strings.TrimSpace(opts.SupportCode)
|
||||
if !isSupportCodeValid(code) {
|
||||
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||
}
|
||||
segs = append(segs, namedSeg{"SUPPORT", code})
|
||||
if supportSeg := buildSupportSegment(items); supportSeg != "" {
|
||||
segs = append(segs, namedSeg{"SUPPORT", supportSeg})
|
||||
}
|
||||
|
||||
article := strings.Join(namedSegsValues(segs), "-")
|
||||
@@ -132,28 +127,22 @@ func findSegGroup(segs []namedSeg, group string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func isSupportCodeValid(code string) bool {
|
||||
if len(code) < 3 {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(code, "y") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(code, "y")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range parts[0] {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
// buildSupportSegment finds a support LOT in items (added to the spec via the
|
||||
// Base tab's support picker, e.g. "SVC_3yB_HGX-H200") and returns its
|
||||
// duration+level token ("3yB") for the article. Support is a regular BOM
|
||||
// LOT, not pricelist-backed, so it's detected by lot_name prefix like the
|
||||
// other lot_name-pattern parsers in this file, rather than by lot_category.
|
||||
func buildSupportSegment(items []models.ConfigItem) string {
|
||||
for _, it := range items {
|
||||
if !strings.HasPrefix(strings.ToUpper(it.LotName), "SVC_") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(it.LotName, "_", 3)
|
||||
if len(parts) >= 2 && parts[1] != "" {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
switch parts[1] {
|
||||
case "W", "B", "S", "P":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||
|
||||
@@ -44,10 +44,10 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
|
||||
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
||||
{LotName: "PS_1000W_Platinum", Quantity: 2},
|
||||
{LotName: "SVC_1yW_x86", Quantity: 1},
|
||||
}
|
||||
result, err := Build(local, items, BuildOptions{
|
||||
ServerModel: "DL380GEN11",
|
||||
SupportCode: "1yW",
|
||||
ServerPricelist: &localPL.ServerID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -59,6 +59,9 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
|
||||
t.Fatalf("unexpected UNK in article: %s", result.Article)
|
||||
}
|
||||
if !contains(result.Article, "1yW") {
|
||||
t.Fatalf("expected support segment 1yW in article: %s", result.Article)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_CompressArticle_NoGPU_PSUNotNIC reproduces the bug where 2 PSUs produced
|
||||
|
||||
@@ -134,10 +134,46 @@ func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
|
||||
if len(s.RequiredCategories) == 0 {
|
||||
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
|
||||
}
|
||||
if s.SupportPricing == nil {
|
||||
s.SupportPricing = defaultSupportPricing()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// defaultSupportPricing mirrors "Регламент расчёта стоимости технической
|
||||
// поддержки серверов" (BASE/STANDARD/PREMIUM tiers, x86 % of sale price /
|
||||
// HGX fixed multi-year price by chip generation). Used only when the
|
||||
// qt_settings "support_pricing" key is absent so it can be edited in
|
||||
// MariaDB without a QuoteForge release.
|
||||
func defaultSupportPricing() *localdb.SupportPricingTable {
|
||||
return &localdb.SupportPricingTable{
|
||||
X86Percent: map[string]map[string]float64{
|
||||
"W": {"3": 0.06},
|
||||
"B": {"1": 0.05, "3": 0.10, "5": 0.14},
|
||||
"S": {"1": 0.07, "3": 0.13, "5": 0.18},
|
||||
"P": {"1": 0.12, "3": 0.20, "5": 0.45},
|
||||
},
|
||||
HGXPrice: map[string]map[string]map[string]float64{
|
||||
"HGX-H200": {
|
||||
"B": {"1": 20000, "3": 55000, "5": 80000},
|
||||
"S": {"1": 28000, "3": 70000, "5": 100000},
|
||||
"P": {"1": 63000, "3": 105000},
|
||||
},
|
||||
"HGX-B200": {
|
||||
"B": {"1": 40000, "3": 100000, "5": 145000},
|
||||
"S": {"1": 55000, "3": 130000, "5": 185000},
|
||||
"P": {"1": 101000, "3": 176000},
|
||||
},
|
||||
"HGX-B300": {
|
||||
"B": {"1": 40000, "3": 100000, "5": 145000},
|
||||
"S": {"1": 55000, "3": 130000, "5": 185000},
|
||||
"P": {"1": 112000, "3": 187000},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfigTypes() []localdb.ConfigTypeDef {
|
||||
return []localdb.ConfigTypeDef{
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,41 @@ type VendorSpecItem struct {
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
// RentalItemCondition tracks whether a component is being rented/tested as
|
||||
// new or used hardware, for a single configuration's Аренда tab. This is
|
||||
// independent of the lot's catalog data: the same lot_name can be "new" in
|
||||
// one configuration and "used" in another.
|
||||
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)
|
||||
}
|
||||
|
||||
type VendorSpec []VendorSpecItem
|
||||
|
||||
func (v VendorSpec) Value() (driver.Value, error) {
|
||||
@@ -114,6 +149,8 @@ type Configuration struct {
|
||||
ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
RentalItems RentalItemConditions `gorm:"type:json" json:"rental_items,omitempty"`
|
||||
RentalUpliftPercent float64 `gorm:"type:decimal(8,2);default:0" json:"rental_uplift_percent"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@@ -12,6 +12,7 @@ type Project struct {
|
||||
TrackerURL string `gorm:"size:500" 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 `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
"is_system",
|
||||
"rental_enabled",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(project).Error; err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user