From 7edb80e498fc5133ff56ce4184d78e8a29a56e05 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 22 Jul 2026 13:34:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=20?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F/=D0=B0?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B4=D1=8B=20GPU-=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом 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 --- bible-local/02-architecture.md | 65 ++- bible-local/03-database.md | 3 + bible-local/04-api.md | 2 + bible-local/server-contract-qt-settings.md | 49 +++ cmd/qfs/main.go | 66 ++- internal/article/generator.go | 43 +- internal/article/generator_test.go | 5 +- internal/handlers/component.go | 36 ++ internal/localdb/converters.go | 28 ++ internal/localdb/localdb.go | 1 + internal/localdb/migrations.go | 15 + internal/localdb/models.go | 36 ++ internal/localdb/qt_settings.go | 30 +- internal/localdb/snapshots.go | 20 +- internal/models/configuration.go | 37 ++ internal/models/project.go | 1 + internal/repository/project.go | 1 + internal/services/local_configuration.go | 38 +- internal/services/project.go | 12 +- internal/services/rental.go | 212 ++++++++++ internal/services/sync/service.go | 1 + .../030_add_rental_enabled_to_projects.sql | 8 + ...31_add_rental_fields_to_configurations.sql | 9 + web/templates/index.html | 394 +++++++++++++++++- web/templates/project_detail.html | 11 +- 25 files changed, 1052 insertions(+), 71 deletions(-) create mode 100644 internal/services/rental.go create mode 100644 migrations/030_add_rental_enabled_to_projects.sql create mode 100644 migrations/031_add_rental_fields_to_configurations.sql diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index cc3b67e..796833b 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -132,7 +132,7 @@ Rules: ## Server-driven configurator settings (`qt_settings`) -QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite). +QF reads settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite). They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the full contract and JSON schemas. @@ -142,6 +142,7 @@ full contract and JSON schemas. | `tab_config` | Configurator tab structure, sections, singleSelect | | `always_visible_tabs` | Which tabs are shown even when empty | | `required_categories` | Per-config-type badge on tabs with unfilled required categories | +| `support_pricing` | Price data for the Base tab's support-level picker (see below) | Rules: - sync runs as part of the pricelist pull; failure is non-fatal (Warn log only); @@ -150,6 +151,68 @@ Rules: - `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere; - `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them. +## Support as a BOM LOT + +The Base tab's support-level picker adds/replaces a synthetic LOT in `cart` (e.g. +`SVC_3yB_HGX-H200`, quantity 1) exactly like adding any other component — not a separate +mechanism. This means support flows through `total_price`, the Pricing tab, exports, and the +rental calc the same way any BOM line does, with no special-casing needed in those paths. + +- lot_name shape: `SVC_{years}y{level}_{platform}` (e.g. `SVC_3yB_HGX-H200`); `internal/article + /generator.go`'s `buildSupportSegment` detects it by the `SVC_` lot_name prefix (support LOTs + aren't in the pricelist catalog, so there's no `lot_category` to key off — same lot_name-pattern + approach the generator already uses for GPU/CPU/memory parsing) and emits the `{years}y{level}` + token as the article's SUPPORT segment, replacing the old separate `BuildOptions.SupportCode` + field/`isSupportCodeValid` check; +- `Configuration.SupportCode` (the DB column) is no longer read by article generation; it's + inert legacy metadata now — the LOT in `items` is the source of truth; +- the picker's displayed price is computed client-side per "Регламент расчёта стоимости + технической поддержки серверов": x86 is a percent of the rest of the cart's total (proxy for + "цена продажи"), HGX platforms use a fixed multi-year price by chip generation. The pricing + table itself lives in `qt_settings["support_pricing"]` so it can be edited in MariaDB without + a QuoteForge release — see `bible-local/server-contract-qt-settings.md` for the schema; the + fixed list of offerable level×duration codes stays in the frontend, only the price is + server-driven; +- platform (x86 vs HGX-H100/H200 vs HGX-B200 vs HGX-B300) is auto-detected client-side from the + cart's GPU components, the same chip-generation classification used by the rental + depreciation calc (`internal/services/rental.go`), so the picker only offers combinations + valid for the current configuration; +- the support LOT has no `lot_category` (not in the pricelist), so it renders under the + "Other" category tab like any other uncategorized item — no new tab/category was added for it. + +## Rental / paid-testing pricing contract + +QuoteForge can quote paid testing / short-to-mid-term rental of a configuration's hardware, per the draft +regulation "Регламент расчёта стоимости платного тестирования и аренды GPU-серверов" (methodology may still +change; buyout/Step 9 of the regulation is intentionally not implemented). + +Rules: +- rental is enabled per **project** via `Project.RentalEnabled` (`qt_projects.rental_enabled`); when set, the + configurator shows a 4th top-level tab ("Аренда") for every configuration in that project; +- New/БУ condition per component lives only on the configuration, in `Configuration.RentalItems` + (`RentalItemCondition{LotName, Condition}`) — it is not a property of the lot/pricelist and does not touch + `ConfigItem`/BOM; the same lot_name can be "new" in one configuration and "used" in another; +- the "Цена" the methodology depreciates against is `Estimate buy price × (1 + RentalUpliftPercent/100)` — + QuoteForge has no per-component sale price, so a single project-configuration-wide uplift percentage stands + in for it; +- pricing is always quoted per week — Разовый (one-time layer-1 hit) + Еженедельный (recurring); there is no + term/weeks input, a sales rep multiplies the weekly rate by however many weeks are needed; +- annual BASE support price is **computed automatically**, never entered manually, per "Регламент расчёта + стоимости технической поддержки серверов": any GPU component present classifies the configuration as HGX and + uses the regulation's fixed per-platform price (H100/H200, B200, B300, by lot_name substring); no GPU present + falls back to 5% of the uplifted price sum as an x86 sale-price proxy — `support_code` is pure + article-formatting metadata (see `internal/article/generator.go`) and is never resolved to a price, so there + is no SVC_-catalog lookup to reuse; +- GPU "actual vs stabilized generation" classification for depreciation life (2yr vs 3yr) and the separate + GPU "support platform" classification (H100/H200 vs B200 vs B300) are both hardcoded lot_name substring + lists in `internal/services/rental.go`, not sourced from `lot_category`; +- `RentalItems` and `RentalUpliftPercent` are included in the revision dedup fingerprint + (`BuildConfigurationSpecPriceFingerprint`), so rental edits create new revisions like other spec/price-affecting + changes; +- `internal/services/rental.go` (`RentalService.Calculate`) is stateless: it re-derives categories via + `GetLocalLotCategoriesByServerPricelistID` and computes pricing (including the auto support price) from the + request body, so calculate calls do not require saving first. + ## Vendor BOM contract Vendor BOM is stored in `vendor_spec` on the configuration row. diff --git a/bible-local/03-database.md b/bible-local/03-database.md index ae1bb6a..f7adbd7 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -192,6 +192,8 @@ PK: lot_name | line_no | int | position within project | | price_updated_at | timestamp | | | vendor_spec | longtext JSON | | +| rental_items | JSON | per-lot New/БУ condition for the Аренда tab; see [02-architecture.md](02-architecture.md#rental--paid-testing-pricing-contract) | +| rental_uplift_percent | decimal(8,2) DEFAULT 0 | uplift applied to Estimate buy price for rental "Цена" | ### qt_lot_metadata PK: lot_name @@ -294,6 +296,7 @@ PK: username | tracker_url | varchar(500) | | | is_active | tinyint(1) DEFAULT 1 | | | is_system | tinyint(1) DEFAULT 0 | | +| rental_enabled | tinyint(1) DEFAULT 0 | shows the Аренда tab on this project's configurations | | created_at | timestamp | | | updated_at | timestamp ON UPDATE | | diff --git a/bible-local/04-api.md b/bible-local/04-api.md index ef49f66..3aa11c0 100644 --- a/bible-local/04-api.md +++ b/bible-local/04-api.md @@ -80,6 +80,8 @@ | `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM | | `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT | | `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart | +| `PUT` | `/api/configs/:uuid/rental` | persist Аренда tab state (per-lot New/БУ, weeks, uplift %, annual support) | +| `POST` | `/api/configs/:uuid/rental/calculate` | compute rental/paid-testing pricing without persisting | ## Projects diff --git a/bible-local/server-contract-qt-settings.md b/bible-local/server-contract-qt-settings.md index 52c6eb1..2b272e8 100644 --- a/bible-local/server-contract-qt-settings.md +++ b/bible-local/server-contract-qt-settings.md @@ -148,6 +148,55 @@ badge on the tab label when required categories are missing. --- +### `support_pricing` + +Price data from "Регламент расчёта стоимости технической поддержки серверов", used by the +Base tab's support-level picker to display a computed price next to the chosen support code. +This is reference/quoting data only — QF never adds it as a cart line or configuration cost; +see [02-architecture.md](02-architecture.md#support-pricing-reference-not-a-cart-item). + +x86 is a percent of sale price (QF approximates sale price as the configuration's Estimate +total). HGX platforms (any GPU category component present, classified by chip generation via +lot_name substring) use a fixed multi-year price instead. Both are keyed `level -> duration in +years (as a JSON string key) -> value`; a combination absent from the table is not offered by +the regulation and the picker won't show it. + +**Value format:** + +```json +{ + "x86_percent": { + "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} + }, + "hgx_price": { + "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} + } + } +} +``` + +Editing this setting in MariaDB (no QF release needed) changes the price shown by the picker +immediately on the next component sync — this is the intended way to keep support pricing +current as the regulation evolves. + +--- + ## Backward compatibility - If `qt_settings` does not exist (old server): QF logs `Warn` during sync and diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 5a9f8d5..a896bab 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -697,6 +697,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // Local-first configuration service (replaces old ConfigurationService) projectService = services.NewProjectService(local) configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) + rentalService := services.NewRentalService(local) // Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project. if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 { @@ -1344,6 +1345,45 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec) configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec) + // Rental / paid-testing pricing endpoints (Аренда tab) + configs.PUT("/:uuid/rental", func(c *gin.Context) { + var req services.RentalUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid request", err) + return + } + config, err := configService.UpdateRentalNoAuth(c.Param("uuid"), &req) + if err != nil { + switch { + case errors.Is(err, services.ErrConfigNotFound): + respondError(c, http.StatusNotFound, "resource not found", err) + default: + respondError(c, http.StatusInternalServerError, "internal server error", err) + } + return + } + c.JSON(http.StatusOK, config) + }) + + configs.POST("/:uuid/rental/calculate", func(c *gin.Context) { + var req services.RentalUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid request", err) + return + } + result, err := rentalService.Calculate(c.Param("uuid"), &req) + if err != nil { + switch { + case errors.Is(err, services.ErrConfigNotFound): + respondError(c, http.StatusNotFound, "resource not found", err) + default: + respondError(c, http.StatusInternalServerError, "internal server error", err) + } + return + } + c.JSON(http.StatusOK, result) + }) + configs.PATCH("/:uuid/server-count", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { @@ -1550,23 +1590,25 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // Return simplified list of all projects (UUID + Name only) type ProjectSimple struct { - UUID string `json:"uuid"` - Code string `json:"code"` - Variant string `json:"variant"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` + UUID string `json:"uuid"` + Code string `json:"code"` + Variant string `json:"variant"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + RentalEnabled bool `json:"rental_enabled"` + CreatedAt time.Time `json:"created_at"` } simplified := make([]ProjectSimple, 0, len(allProjects)) for _, p := range allProjects { simplified = append(simplified, ProjectSimple{ - UUID: p.UUID, - Code: p.Code, - Variant: p.Variant, - Name: derefString(p.Name), - IsActive: p.IsActive, - CreatedAt: p.CreatedAt, + UUID: p.UUID, + Code: p.Code, + Variant: p.Variant, + Name: derefString(p.Name), + IsActive: p.IsActive, + RentalEnabled: p.RentalEnabled, + CreatedAt: p.CreatedAt, }) } diff --git a/internal/article/generator.go b/internal/article/generator.go index 897eb59..9f15d15 100644 --- a/internal/article/generator.go +++ b/internal/article/generator.go @@ -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 { diff --git a/internal/article/generator_test.go b/internal/article/generator_test.go index 90a3d78..311b15d 100644 --- a/internal/article/generator_test.go +++ b/internal/article/generator_test.go @@ -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 diff --git a/internal/handlers/component.go b/internal/handlers/component.go index d24a79f..566591d 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -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{ { diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 5f0106f..8fdfd90 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -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, } diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 6b3ae36..c031fe7 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -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, diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index ef1f6be..3d8d809 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -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 { diff --git a/internal/localdb/models.go b/internal/localdb/models.go index f17ed30..8735d5d 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -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"` diff --git a/internal/localdb/qt_settings.go b/internal/localdb/qt_settings.go index a65f537..97f1b96 100644 --- a/internal/localdb/qt_settings.go +++ b/internal/localdb/qt_settings.go @@ -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 + } } } diff --git a/internal/localdb/snapshots.go b/internal/localdb/snapshots.go index 09ba9ce..d7586f3 100644 --- a/internal/localdb/snapshots.go +++ b/internal/localdb/snapshots.go @@ -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, } diff --git a/internal/models/configuration.go b/internal/models/configuration.go index 11af9d0..b508d63 100644 --- a/internal/models/configuration.go +++ b/internal/models/configuration.go @@ -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"` diff --git a/internal/models/project.go b/internal/models/project.go index 33d796c..1bec79d 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -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"` } diff --git a/internal/repository/project.go b/internal/repository/project.go index d878e1a..13e18d7 100644 --- a/internal/repository/project.go +++ b/internal/repository/project.go @@ -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 { diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index bf02b56..15198fe 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -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 { diff --git a/internal/services/project.go b/internal/services/project.go index 98e8825..167d71f 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -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 { diff --git a/internal/services/rental.go b/internal/services/rental.go new file mode 100644 index 0000000..0b72b57 --- /dev/null +++ b/internal/services/rental.go @@ -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 +} diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index f5ed695..6dd6969 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -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 diff --git a/migrations/030_add_rental_enabled_to_projects.sql b/migrations/030_add_rental_enabled_to_projects.sql new file mode 100644 index 0000000..218d12d --- /dev/null +++ b/migrations/030_add_rental_enabled_to_projects.sql @@ -0,0 +1,8 @@ +-- Tables affected: qt_projects +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_projects DROP COLUMN rental_enabled; +-- recovery.completed: no action needed +-- verify: rental_enabled column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='rental_enabled' HAVING COUNT(*)=0 + +ALTER TABLE qt_projects + ADD COLUMN IF NOT EXISTS rental_enabled TINYINT(1) NOT NULL DEFAULT 0; diff --git a/migrations/031_add_rental_fields_to_configurations.sql b/migrations/031_add_rental_fields_to_configurations.sql new file mode 100644 index 0000000..e6a3067 --- /dev/null +++ b/migrations/031_add_rental_fields_to_configurations.sql @@ -0,0 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN rental_items, DROP COLUMN rental_uplift_percent; +-- recovery.completed: no action needed +-- verify: rental_uplift_percent column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='rental_uplift_percent' HAVING COUNT(*)=0 + +ALTER TABLE qt_configurations + ADD COLUMN IF NOT EXISTS rental_items JSON NULL, + ADD COLUMN IF NOT EXISTS rental_uplift_percent DECIMAL(8,2) NOT NULL DEFAULT 0; diff --git a/web/templates/index.html b/web/templates/index.html index a781bf9..a66f29f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -78,6 +78,10 @@ class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700"> Ценообразование + @@ -305,6 +309,54 @@ + + + @@ -842,9 +894,15 @@ function applyServerSettings(settings) { }); } + // support_pricing → picker price table + if (settings.support_pricing && typeof settings.support_pricing === 'object') { + supportPricingTable = settings.support_pricing; + } + applyConfigTypeToTabs(); updateTabVisibility(); updateRequiredCategoryBadges(); + updateSupportPriceDisplay(); } function updateRequiredCategoryBadges() { @@ -904,6 +962,14 @@ document.addEventListener('DOMContentLoaded', async function() { projectUUID = config.project_uuid || ''; await loadProjectIndex(); updateConfigBreadcrumbs(); + applyRentalTabVisibility(); + + rentalConditions = {}; + (config.rental_items || []).forEach(item => { + rentalConditions[(item.lot_name || '').toUpperCase()] = item.condition === 'used' ? 'used' : 'new'; + }); + rentalUpliftPercent = config.rental_uplift_percent || 0; + document.getElementById('rental-uplift-percent').value = rentalUpliftPercent; document.getElementById('save-buttons').classList.remove('hidden'); // Set server count from config @@ -928,7 +994,7 @@ document.addEventListener('DOMContentLoaded', async function() { category: item.category })); } serverModelForQuote = config.server_model || ''; - supportCode = config.support_code || ''; + supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name); currentArticle = config.article || ''; restorePricingStateFromNotes(config.notes || ''); @@ -972,6 +1038,8 @@ document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('click', function(e) { if (!e.target.closest('.autocomplete-wrapper')) { hideAutocomplete(); + const supportDropdown = document.getElementById('support-code-dropdown'); + if (supportDropdown) supportDropdown.classList.add('hidden'); } }); @@ -1381,23 +1449,29 @@ function renderSingleSelectTab(categories) { html += `
- +
-
+
- +
+ + +
+
+
+
+
`; } @@ -2249,6 +2323,7 @@ function removeFromCart(lotName) { function updateCartUI() { updateTabVisibility(); updateRequiredCategoryBadges(); + updateSupportPriceDisplay(); window._currentCart = cart; // expose for BOM/Pricing tabs const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); document.getElementById('cart-total').textContent = formatMoney(total); @@ -2343,6 +2418,160 @@ function updateSupportCode(value) { scheduleArticlePreview(); } +// ==================== SUPPORT PICKER (Регламент расчёта стоимости техподдержки) ==================== +// Support is added to the spec as a regular LOT in `cart` (lot_name like "SVC_3yB_HGX-H200"), +// exactly like any other component — the article generator picks it up via the same +// lot_name-pattern segment mechanism used for GPU/CPU/etc (internal/article/generator.go), no +// separate support field/mechanism. Price data (supportPricingTable) is server-driven via +// qt_settings.support_pricing so it can be edited in MariaDB without a release; the list of +// offerable level/duration codes is fixed here. + +let supportPricingTable = null; + +const SUPPORT_LEVEL_NAMES = { W: 'Warranty', B: 'BASE', S: 'STANDARD', P: 'PREMIUM' }; + +// All (duration, level) combinations the regulation offers, per platform. +// HGX Warranty is included by default (no purchasable code), so it's x86-only here. +const SUPPORT_CODE_COMBOS = { + x86: ['3yW', '1yB', '3yB', '5yB', '1yS', '3yS', '5yS', '1yP', '3yP', '5yP'], + HGX: ['1yB', '3yB', '5yB', '1yS', '3yS', '5yS', '1yP', '3yP'] +}; + +function supportCodeLabel(code) { + if (!code) return ''; + const parts = code.split('y'); + if (parts.length !== 2) return code; + const years = parts[0]; + const levelName = SUPPORT_LEVEL_NAMES[parts[1]] || parts[1]; + return years + ' ' + (years === '1' ? 'год' : 'года') + ' — ' + levelName + ' (' + code + ')'; +} + +// Same "SVC_{years}y{level}_{platform}" shape as internal/article/generator.go's buildSupportSegment. +function parseSupportCodeFromLotName(lotName) { + if (!lotName) return ''; + const parts = lotName.split('_'); + return parts.length >= 2 ? parts[1] : ''; +} + +function findSupportCartIndex() { + return cart.findIndex(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')); +} + +// Mirrors the GPU chip-generation classification used by internal/services/rental.go, +// so the support picker and the rental calc agree on which platform a config is. +function detectSupportPlatform() { + const actualGenSubstrings = ['H200', 'B200', 'B300', 'BLACKWELL SE']; + let sawGPU = false; + for (const item of cart) { + if ((item.category || '').toUpperCase() !== 'GPU') continue; + sawGPU = true; + const upperLot = (item.lot_name || '').toUpperCase(); + if (upperLot.includes('B300')) return 'HGX-B300'; + if (upperLot.includes('B200')) return 'HGX-B200'; + } + return sawGPU ? 'HGX-H200' : 'x86'; +} + +// cartTotal excludes any existing support line so the x86 percent is based on hardware only. +function computeSupportPrice(code, platform) { + if (!supportPricingTable) return null; + const parts = code.split('y'); + if (parts.length !== 2) return null; + const years = parts[0]; + const level = parts[1]; + + if (platform === 'x86') { + const percent = supportPricingTable.x86_percent && supportPricingTable.x86_percent[level] && supportPricingTable.x86_percent[level][years]; + if (typeof percent !== 'number') return null; + const cartTotal = cart.reduce((sum, item) => { + if ((item.lot_name || '').toUpperCase().startsWith('SVC_')) return sum; + return sum + (getDisplayPrice(item) * item.quantity); + }, 0); + return cartTotal * percent; + } + const price = supportPricingTable.hgx_price && supportPricingTable.hgx_price[platform] && supportPricingTable.hgx_price[platform][level] && supportPricingTable.hgx_price[platform][level][years]; + return typeof price === 'number' ? price : null; +} + +// Re-syncs supportCode/input from cart so removing the SVC_ line via the normal +// per-item remove button (like any other component) also clears the picker. +function updateSupportPriceDisplay() { + supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name); + const input = document.getElementById('support-code-input'); + if (input && document.activeElement !== input) { + input.value = supportCodeLabel(supportCode); + } + + const el = document.getElementById('support-code-price'); + if (!el) return; + if (!supportCode) { + el.textContent = ''; + return; + } + const platform = detectSupportPlatform(); + const price = computeSupportPrice(supportCode, platform); + if (price === null) { + el.textContent = 'Нет цены для этой комбинации (' + platform + ')'; + return; + } + el.textContent = formatMoney(price) + ' за весь срок (' + platform + ')'; +} + +function onSupportCodeInput(text) { + const platform = detectSupportPlatform(); + const combos = platform === 'x86' ? SUPPORT_CODE_COMBOS.x86 : SUPPORT_CODE_COMBOS.HGX; + const query = (text || '').trim().toLowerCase(); + const matches = combos.filter(code => !query || supportCodeLabel(code).toLowerCase().includes(query) || code.toLowerCase().includes(query)); + renderSupportCodeDropdown(matches); +} + +function renderSupportCodeDropdown(codes) { + const dropdown = document.getElementById('support-code-dropdown'); + if (!dropdown) return; + let html = '
— без поддержки —
'; + html += codes.map(code => + '
' + + escapeHtml(supportCodeLabel(code)) + + '
' + ).join(''); + dropdown.innerHTML = html; + dropdown.classList.remove('hidden'); +} + +// Adds/replaces/removes the support LOT in `cart`, exactly like adding/removing any other +// component — this is what the article generator, totals, and everything else sees. +function selectSupportCode(code) { + const existingIdx = findSupportCartIndex(); + if (existingIdx >= 0) { + cart.splice(existingIdx, 1); + } + + supportCode = code || ''; + if (supportCode) { + const platform = detectSupportPlatform(); + const price = computeSupportPrice(supportCode, platform); + cart.push({ + lot_name: 'SVC_' + supportCode + '_' + platform, + quantity: 1, + unit_price: price || 0, + estimate_price: price || 0, + warehouse_price: null, + competitor_price: null, + description: supportCodeLabel(supportCode), + category: '' + }); + } + + const input = document.getElementById('support-code-input'); + if (input) input.value = supportCodeLabel(supportCode); + const dropdown = document.getElementById('support-code-dropdown'); + if (dropdown) dropdown.classList.add('hidden'); + + renderTab(); + updateCartUI(); + triggerAutoSave(); +} + function scheduleArticlePreview() { if (articlePreviewTimeout) { clearTimeout(articlePreviewTimeout); @@ -3059,7 +3288,7 @@ let currentTopTab = 'estimate'; function switchTopTab(tab) { currentTopTab = tab; - const tabs = ['estimate', 'bom', 'pricing']; + const tabs = ['estimate', 'bom', 'pricing', 'rental']; tabs.forEach(t => { const btn = document.getElementById('top-tab-' + t); const section = document.getElementById('top-section-' + t); @@ -3076,6 +3305,145 @@ function switchTopTab(tab) { if (tab === 'pricing') { renderPricingTab(); } + if (tab === 'rental') { + renderRentalTab(); + } +} + +// ==================== АРЕНДА (rental / paid testing) ==================== + +let rentalConditions = {}; // lot_name (upper) -> 'new'|'used' +let rentalUpliftPercent = 0; +let rentalRecalcTimer = null; + +function applyRentalTabVisibility() { + const enabled = !!(projectUUID && projectByUUID[projectUUID] && projectByUUID[projectUUID].rental_enabled); + const btn = document.getElementById('top-tab-rental'); + if (!btn) return; + if (enabled) { + btn.classList.remove('hidden'); + } else { + btn.classList.add('hidden'); + if (currentTopTab === 'rental') { + switchTopTab('estimate'); + } + } +} + +function scheduleRentalRecalc() { + clearTimeout(rentalRecalcTimer); + rentalRecalcTimer = setTimeout(renderRentalTab, 300); +} + +function rentalConditionFor(lotName) { + const key = (lotName || '').toUpperCase(); + return rentalConditions[key] === 'used' ? 'used' : 'new'; +} + +function onRentalConditionChange(lotName, isUsed) { + const key = (lotName || '').toUpperCase(); + rentalConditions[key] = isUsed ? 'used' : 'new'; + renderRentalTab(); +} + +function buildRentalItemsPayload() { + return cart.map(item => ({ + lot_name: item.lot_name, + condition: rentalConditionFor(item.lot_name) + })); +} + +async function renderRentalTab() { + const body = document.getElementById('rental-body'); + const foot = document.getElementById('rental-foot'); + const warningsEl = document.getElementById('rental-warnings'); + if (!configUUID || cart.length === 0) { + body.innerHTML = 'Загрузите компоненты во вкладке «Estimate»'; + foot.classList.add('hidden'); + return; + } + + rentalUpliftPercent = parseFloat(document.getElementById('rental-uplift-percent').value) || 0; + + const payload = { + items: buildRentalItemsPayload(), + uplift_percent: rentalUpliftPercent + }; + + let result; + try { + const resp = await fetch('/api/configs/' + configUUID + '/rental/calculate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + if (!resp.ok) { + body.innerHTML = 'Не удалось рассчитать стоимость аренды'; + foot.classList.add('hidden'); + return; + } + result = await resp.json(); + } catch (e) { + body.innerHTML = 'Не удалось рассчитать стоимость аренды'; + foot.classList.add('hidden'); + return; + } + + if (result.warnings && result.warnings.length > 0) { + warningsEl.textContent = result.warnings.join(' '); + warningsEl.classList.remove('hidden'); + } else { + warningsEl.classList.add('hidden'); + } + + const descByLot = {}; + cart.forEach(c => { descByLot[(c.lot_name || '').toUpperCase()] = c.description || ''; }); + + const rows = (result.items || []).map(item => { + const isUsed = item.condition === 'used'; + const desc = descByLot[(item.lot_name || '').toUpperCase()] || ''; + return '' + + '' + escapeHtml(item.lot_name) + '' + + '' + escapeHtml(desc) + '' + + '' + escapeHtml(item.category || '') + '' + + '' + item.quantity + '' + + '' + + '' + + '' + + '' + formatMoney(item.one_time) + '' + + '' + formatMoney(item.weekly) + '' + + ''; + }).join(''); + body.innerHTML = rows || 'Нет компонентов'; + + document.getElementById('rental-total-onetime').textContent = formatMoney(result.one_time_total); + document.getElementById('rental-total-weekly').textContent = formatMoney(result.weekly_total); + foot.classList.remove('hidden'); +} + +async function saveRentalSettings() { + if (!configUUID) return; + const statusEl = document.getElementById('rental-save-status'); + const payload = { + items: buildRentalItemsPayload(), + uplift_percent: rentalUpliftPercent + }; + try { + const resp = await fetch('/api/configs/' + configUUID + '/rental', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + if (!resp.ok) { + statusEl.textContent = 'Ошибка сохранения'; + return; + } + statusEl.textContent = 'Сохранено'; + setTimeout(() => { statusEl.textContent = ''; }, 2000); + } catch (e) { + statusEl.textContent = 'Ошибка сохранения'; + } } // ==================== BOM ВЕНДОРА ==================== diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 8580bb4..08c4259 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -314,6 +314,13 @@ class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
Оставьте пустым, чтобы скрыть ссылку.
+
+ +
Добавляет вкладку «Аренда» (расчёт стоимости платного тестирования/аренды) на конфигурациях этого проекта.
+
@@ -1253,6 +1260,7 @@ function openProjectSettingsModal() { document.getElementById('project-settings-variant').value = project.variant || ''; document.getElementById('project-settings-name').value = project.name || ''; document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim(); + document.getElementById('project-settings-rental-enabled').checked = !!project.rental_enabled; document.getElementById('project-settings-modal').classList.remove('hidden'); document.getElementById('project-settings-modal').classList.add('flex'); } @@ -1268,6 +1276,7 @@ async function saveProjectSettings() { const variant = document.getElementById('project-settings-variant').value.trim(); const name = document.getElementById('project-settings-name').value.trim(); const trackerURL = document.getElementById('project-settings-tracker-url').value.trim(); + const rentalEnabled = document.getElementById('project-settings-rental-enabled').checked; if (!code) { alert('Введите код проекта'); return; @@ -1275,7 +1284,7 @@ async function saveProjectSettings() { const resp = await fetch('/api/projects/' + projectUUID, { method: 'PUT', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL}) + body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL, rental_enabled: rentalEnabled}) }); if (!resp.ok) { if (resp.status === 409) {