feat: расчёт стоимости платного тестирования/аренды GPU-серверов

Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом
rental_enabled): расчёт по методичке "Регламент расчёта стоимости
платного тестирования и аренды GPU-серверов" — Разовый + Еженедельный
платёж по каждому компоненту (New/БУ чекбоксом), с аплифтом к Estimate.

Заодно: поддержка (support_code) в Base-вкладке теперь добавляется как
обычный LOT в спеку (SVC_{срок}y{уровень}_{платформа}) через
autocomplete-пикер вместо выпадающего списка — партномер собирается
тем же lot_name-based механизмом, что и для GPU/CPU/памяти, справочная
цена по регламенту техподдержки хранится в qt_settings.support_pricing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-22 13:34:07 +03:00
co-authored by Claude Sonnet 5
parent b48436de93
commit 7edb80e498
25 changed files with 1052 additions and 71 deletions
+64 -1
View File
@@ -132,7 +132,7 @@ Rules:
## Server-driven configurator settings (`qt_settings`) ## 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 They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
full contract and JSON schemas. full contract and JSON schemas.
@@ -142,6 +142,7 @@ full contract and JSON schemas.
| `tab_config` | Configurator tab structure, sections, singleSelect | | `tab_config` | Configurator tab structure, sections, singleSelect |
| `always_visible_tabs` | Which tabs are shown even when empty | | `always_visible_tabs` | Which tabs are shown even when empty |
| `required_categories` | Per-config-type badge on tabs with unfilled required categories | | `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: Rules:
- sync runs as part of the pricelist pull; failure is non-fatal (Warn log only); - 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; - `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. - `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 contract
Vendor BOM is stored in `vendor_spec` on the configuration row. Vendor BOM is stored in `vendor_spec` on the configuration row.
+3
View File
@@ -192,6 +192,8 @@ PK: lot_name
| line_no | int | position within project | | line_no | int | position within project |
| price_updated_at | timestamp | | | price_updated_at | timestamp | |
| vendor_spec | longtext JSON | | | 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 ### qt_lot_metadata
PK: lot_name PK: lot_name
@@ -294,6 +296,7 @@ PK: username
| tracker_url | varchar(500) | | | tracker_url | varchar(500) | |
| is_active | tinyint(1) DEFAULT 1 | | | is_active | tinyint(1) DEFAULT 1 | |
| is_system | tinyint(1) DEFAULT 0 | | | is_system | tinyint(1) DEFAULT 0 | |
| rental_enabled | tinyint(1) DEFAULT 0 | shows the Аренда tab on this project's configurations |
| created_at | timestamp | | | created_at | timestamp | |
| updated_at | timestamp ON UPDATE | | | updated_at | timestamp ON UPDATE | |
+2
View File
@@ -80,6 +80,8 @@
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM | | `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/resolve` | resolve PN -> LOT |
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart | | `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 ## Projects
@@ -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 ## Backward compatibility
- If `qt_settings` does not exist (old server): QF logs `Warn` during sync and - If `qt_settings` does not exist (old server): QF logs `Warn` during sync and
+54 -12
View File
@@ -697,6 +697,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Local-first configuration service (replaces old ConfigurationService) // Local-first configuration service (replaces old ConfigurationService)
projectService = services.NewProjectService(local) projectService = services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) 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. // Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project.
if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 { 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/resolve", vendorSpecHandler.ResolveVendorSpec)
configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec) 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) { configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req struct { 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) // Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct { type ProjectSimple struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Code string `json:"code"` Code string `json:"code"`
Variant string `json:"variant"` Variant string `json:"variant"`
Name string `json:"name"` Name string `json:"name"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` RentalEnabled bool `json:"rental_enabled"`
CreatedAt time.Time `json:"created_at"`
} }
simplified := make([]ProjectSimple, 0, len(allProjects)) simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects { for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{ simplified = append(simplified, ProjectSimple{
UUID: p.UUID, UUID: p.UUID,
Code: p.Code, Code: p.Code,
Variant: p.Variant, Variant: p.Variant,
Name: derefString(p.Name), Name: derefString(p.Name),
IsActive: p.IsActive, IsActive: p.IsActive,
CreatedAt: p.CreatedAt, RentalEnabled: p.RentalEnabled,
CreatedAt: p.CreatedAt,
}) })
} }
+16 -27
View File
@@ -12,7 +12,6 @@ import (
type BuildOptions struct { type BuildOptions struct {
ServerModel string ServerModel string
SupportCode string
ServerPricelist *uint ServerPricelist *uint
} }
@@ -95,12 +94,8 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
segs = append(segs, namedSeg{"PSU", psuSeg}) segs = append(segs, namedSeg{"PSU", psuSeg})
} }
if strings.TrimSpace(opts.SupportCode) != "" { if supportSeg := buildSupportSegment(items); supportSeg != "" {
code := strings.TrimSpace(opts.SupportCode) segs = append(segs, namedSeg{"SUPPORT", supportSeg})
if !isSupportCodeValid(code) {
return BuildResult{}, fmt.Errorf("invalid_support_code")
}
segs = append(segs, namedSeg{"SUPPORT", code})
} }
article := strings.Join(namedSegsValues(segs), "-") article := strings.Join(namedSegsValues(segs), "-")
@@ -132,28 +127,22 @@ func findSegGroup(segs []namedSeg, group string) int {
return -1 return -1
} }
func isSupportCodeValid(code string) bool { // buildSupportSegment finds a support LOT in items (added to the spec via the
if len(code) < 3 { // Base tab's support picker, e.g. "SVC_3yB_HGX-H200") and returns its
return false // 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
if !strings.Contains(code, "y") { // other lot_name-pattern parsers in this file, rather than by lot_category.
return false func buildSupportSegment(items []models.ConfigItem) string {
} for _, it := range items {
parts := strings.Split(code, "y") if !strings.HasPrefix(strings.ToUpper(it.LotName), "SVC_") {
if len(parts) != 2 || parts[0] == "" || parts[1] == "" { continue
return false }
} parts := strings.SplitN(it.LotName, "_", 3)
for _, r := range parts[0] { if len(parts) >= 2 && parts[1] != "" {
if r < '0' || r > '9' { return parts[1]
return false
} }
} }
switch parts[1] { return ""
case "W", "B", "S", "P":
return true
default:
return false
}
} }
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string { func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
+4 -1
View File
@@ -44,10 +44,10 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1}, {LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
{LotName: "HBA_2pFC32_Gen6", Quantity: 1}, {LotName: "HBA_2pFC32_Gen6", Quantity: 1},
{LotName: "PS_1000W_Platinum", Quantity: 2}, {LotName: "PS_1000W_Platinum", Quantity: 2},
{LotName: "SVC_1yW_x86", Quantity: 1},
} }
result, err := Build(local, items, BuildOptions{ result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11", ServerModel: "DL380GEN11",
SupportCode: "1yW",
ServerPricelist: &localPL.ServerID, ServerPricelist: &localPL.ServerID,
}) })
if err != nil { if err != nil {
@@ -59,6 +59,9 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") { if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
t.Fatalf("unexpected UNK in article: %s", result.Article) 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 // TestBuild_CompressArticle_NoGPU_PSUNotNIC reproduces the bug where 2 PSUs produced
+36
View File
@@ -134,10 +134,46 @@ func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
if len(s.RequiredCategories) == 0 { if len(s.RequiredCategories) == 0 {
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}} s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
} }
if s.SupportPricing == nil {
s.SupportPricing = defaultSupportPricing()
}
c.JSON(http.StatusOK, s) 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 { func defaultConfigTypes() []localdb.ConfigTypeDef {
return []localdb.ConfigTypeDef{ return []localdb.ConfigTypeDef{
{ {
+28
View File
@@ -74,6 +74,8 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec), VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
DisablePriceRefresh: cfg.DisablePriceRefresh, DisablePriceRefresh: cfg.DisablePriceRefresh,
OnlyInStock: cfg.OnlyInStock, OnlyInStock: cfg.OnlyInStock,
RentalItems: modelRentalItemsToLocal(cfg.RentalItems),
RentalUpliftPercent: cfg.RentalUpliftPercent,
Line: cfg.Line, Line: cfg.Line,
PriceUpdatedAt: cfg.PriceUpdatedAt, PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
@@ -123,6 +125,8 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
VendorSpec: localVendorSpecToModel(local.VendorSpec), VendorSpec: localVendorSpecToModel(local.VendorSpec),
DisablePriceRefresh: local.DisablePriceRefresh, DisablePriceRefresh: local.DisablePriceRefresh,
OnlyInStock: local.OnlyInStock, OnlyInStock: local.OnlyInStock,
RentalItems: localRentalItemsToModel(local.RentalItems),
RentalUpliftPercent: local.RentalUpliftPercent,
Line: local.Line, Line: local.Line,
PriceUpdatedAt: local.PriceUpdatedAt, PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt, CreatedAt: local.CreatedAt,
@@ -231,6 +235,28 @@ func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
return out 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 { func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{ local := &LocalProject{
UUID: project.UUID, UUID: project.UUID,
@@ -241,6 +267,7 @@ func ProjectToLocal(project *models.Project) *LocalProject {
TrackerURL: project.TrackerURL, TrackerURL: project.TrackerURL,
IsActive: project.IsActive, IsActive: project.IsActive,
IsSystem: project.IsSystem, IsSystem: project.IsSystem,
RentalEnabled: project.RentalEnabled,
CreatedAt: project.CreatedAt, CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt, UpdatedAt: project.UpdatedAt,
SyncStatus: "pending", SyncStatus: "pending",
@@ -262,6 +289,7 @@ func LocalToProject(local *LocalProject) *models.Project {
TrackerURL: local.TrackerURL, TrackerURL: local.TrackerURL,
IsActive: local.IsActive, IsActive: local.IsActive,
IsSystem: local.IsSystem, IsSystem: local.IsSystem,
RentalEnabled: local.RentalEnabled,
CreatedAt: local.CreatedAt, CreatedAt: local.CreatedAt,
UpdatedAt: local.UpdatedAt, UpdatedAt: local.UpdatedAt,
} }
+1
View File
@@ -211,6 +211,7 @@ CREATE TABLE local_projects (
tracker_url TEXT NULL, tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0, is_system INTEGER NOT NULL DEFAULT 0,
rental_enabled INTEGER NOT NULL DEFAULT 0,
created_at DATETIME, created_at DATETIME,
updated_at DATETIME, updated_at DATETIME,
synced_at DATETIME NULL, synced_at DATETIME NULL,
+15
View File
@@ -124,6 +124,21 @@ var localMigrations = []localMigration{
name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)", name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
run: deduplicatePricelistItemsAndAddUniqueIndex, 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 { type localPartnumberCatalogRow struct {
+36
View File
@@ -112,6 +112,8 @@ type LocalConfiguration struct {
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"` DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` 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"` VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
Line int `gorm:"column:line_no;index" json:"line"` Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
@@ -141,6 +143,7 @@ type LocalProject struct {
TrackerURL string `json:"tracker_url"` TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"` IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"` IsSystem bool `gorm:"default:false;index" json:"is_system"`
RentalEnabled bool `gorm:"default:false" json:"rental_enabled"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at,omitempty"` SyncedAt *time.Time `json:"synced_at,omitempty"`
@@ -298,6 +301,39 @@ func (LocalPartnumberBookItem) TableName() string {
return "local_partnumber_book_items" 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 // VendorSpecItem represents a single row in a vendor BOM specification
type VendorSpecItem struct { type VendorSpecItem struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
+24 -6
View File
@@ -31,14 +31,25 @@ type TabDef struct {
Sections []TabSection `json:"sections,omitempty"` 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; // Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
// callers are expected to apply hardcoded fallbacks in that case. // callers are expected to apply hardcoded fallbacks in that case.
type ConfiguratorSettings struct { type ConfiguratorSettings struct {
ConfigTypes []ConfigTypeDef `json:"config_types"` ConfigTypes []ConfigTypeDef `json:"config_types"`
TabConfig []TabDef `json:"tab_config"` TabConfig []TabDef `json:"tab_config"`
AlwaysVisibleTabs []string `json:"always_visible_tabs"` AlwaysVisibleTabs []string `json:"always_visible_tabs"`
RequiredCategories map[string][]string `json:"required_categories"` 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 // 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) { func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
out := &ConfiguratorSettings{} 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 { for _, key := range keys {
raw, found, err := l.GetQtSetting(key) raw, found, err := l.GetQtSetting(key)
if err != nil { if err != nil {
@@ -119,6 +130,13 @@ func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil { if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
slog.Warn("failed to parse required_categories setting", "error", err) slog.Warn("failed to parse required_categories setting", "error", err)
} }
case "support_pricing":
var table SupportPricingTable
if err := json.Unmarshal([]byte(raw), &table); err != nil {
slog.Warn("failed to parse support_pricing setting", "error", err)
} else {
out.SupportPricing = &table
}
} }
} }
+18 -2
View File
@@ -31,6 +31,8 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"competitor_pricelist_id": localCfg.CompetitorPricelistID, "competitor_pricelist_id": localCfg.CompetitorPricelistID,
"disable_price_refresh": localCfg.DisablePriceRefresh, "disable_price_refresh": localCfg.DisablePriceRefresh,
"only_in_stock": localCfg.OnlyInStock, "only_in_stock": localCfg.OnlyInStock,
"rental_items": localCfg.RentalItems,
"rental_uplift_percent": localCfg.RentalUpliftPercent,
"vendor_spec": localCfg.VendorSpec, "vendor_spec": localCfg.VendorSpec,
"line": localCfg.Line, "line": localCfg.Line,
"price_updated_at": localCfg.PriceUpdatedAt, "price_updated_at": localCfg.PriceUpdatedAt,
@@ -67,8 +69,10 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
PricelistID *uint `json:"pricelist_id"` PricelistID *uint `json:"pricelist_id"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id"` WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id"` CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
DisablePriceRefresh bool `json:"disable_price_refresh"` DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"` OnlyInStock bool `json:"only_in_stock"`
RentalItems RentalItemConditions `json:"rental_items"`
RentalUpliftPercent float64 `json:"rental_uplift_percent"`
VendorSpec VendorSpec `json:"vendor_spec"` VendorSpec VendorSpec `json:"vendor_spec"`
Line int `json:"line"` Line int `json:"line"`
PriceUpdatedAt *time.Time `json:"price_updated_at"` PriceUpdatedAt *time.Time `json:"price_updated_at"`
@@ -103,6 +107,8 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
CompetitorPricelistID: snapshot.CompetitorPricelistID, CompetitorPricelistID: snapshot.CompetitorPricelistID,
DisablePriceRefresh: snapshot.DisablePriceRefresh, DisablePriceRefresh: snapshot.DisablePriceRefresh,
OnlyInStock: snapshot.OnlyInStock, OnlyInStock: snapshot.OnlyInStock,
RentalItems: snapshot.RentalItems,
RentalUpliftPercent: snapshot.RentalUpliftPercent,
VendorSpec: snapshot.VendorSpec, VendorSpec: snapshot.VendorSpec,
Line: snapshot.Line, Line: snapshot.Line,
PriceUpdatedAt: snapshot.PriceUpdatedAt, PriceUpdatedAt: snapshot.PriceUpdatedAt,
@@ -121,6 +127,8 @@ type configurationSpecPriceFingerprint struct {
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"` CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"` DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"` OnlyInStock bool `json:"only_in_stock"`
RentalItems RentalItemConditions `json:"rental_items,omitempty"`
RentalUpliftPercent float64 `json:"rental_uplift_percent"`
VendorSpec VendorSpec `json:"vendor_spec,omitempty"` VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
} }
@@ -151,6 +159,12 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
return items[i].UnitPrice < items[j].UnitPrice 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{ payload := configurationSpecPriceFingerprint{
Items: items, Items: items,
ServerCount: localCfg.ServerCount, ServerCount: localCfg.ServerCount,
@@ -161,6 +175,8 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
CompetitorPricelistID: localCfg.CompetitorPricelistID, CompetitorPricelistID: localCfg.CompetitorPricelistID,
DisablePriceRefresh: localCfg.DisablePriceRefresh, DisablePriceRefresh: localCfg.DisablePriceRefresh,
OnlyInStock: localCfg.OnlyInStock, OnlyInStock: localCfg.OnlyInStock,
RentalItems: rentalItems,
RentalUpliftPercent: localCfg.RentalUpliftPercent,
VendorSpec: localCfg.VendorSpec, VendorSpec: localCfg.VendorSpec,
} }
+37
View File
@@ -64,6 +64,41 @@ type VendorSpecItem struct {
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"` 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 type VendorSpec []VendorSpecItem
func (v VendorSpec) Value() (driver.Value, error) { 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" ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"` DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` 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"` Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+1
View File
@@ -12,6 +12,7 @@ type Project struct {
TrackerURL string `gorm:"size:500" json:"tracker_url"` TrackerURL string `gorm:"size:500" json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"` IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"` 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"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }
+1
View File
@@ -38,6 +38,7 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
"tracker_url", "tracker_url",
"is_active", "is_active",
"is_system", "is_system",
"rental_enabled",
"updated_at", "updated_at",
}), }),
}).Create(project).Error; err != nil { }).Create(project).Error; err != nil {
+34 -4
View File
@@ -71,7 +71,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if strings.TrimSpace(req.ServerModel) != "" { if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID, ServerPricelist: pricelistID,
}) })
if articleErr != nil { if articleErr != nil {
@@ -169,7 +168,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
if strings.TrimSpace(req.ServerModel) != "" { if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID, ServerPricelist: pricelistID,
}) })
if articleErr != nil { if articleErr != nil {
@@ -226,7 +224,6 @@ func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewReque
} }
return article.Build(s.localDB, req.Items, article.BuildOptions{ return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID, ServerPricelist: pricelistID,
}) })
} }
@@ -534,7 +531,6 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
if strings.TrimSpace(req.ServerModel) != "" { if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID, ServerPricelist: pricelistID,
}) })
if articleErr != nil { if articleErr != nil {
@@ -1325,6 +1321,40 @@ func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec loc
return cfg, nil 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) { func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
+8 -4
View File
@@ -46,10 +46,11 @@ type CreateProjectRequest struct {
} }
type UpdateProjectRequest struct { type UpdateProjectRequest struct {
Code *string `json:"code,omitempty"` Code *string `json:"code,omitempty"`
Variant *string `json:"variant,omitempty"` Variant *string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
TrackerURL *string `json:"tracker_url,omitempty"` TrackerURL *string `json:"tracker_url,omitempty"`
RentalEnabled *bool `json:"rental_enabled,omitempty"`
} }
type ProjectConfigurationsResult struct { type ProjectConfigurationsResult struct {
@@ -148,6 +149,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
} else if strings.TrimSpace(localProject.TrackerURL) == "" { } else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "") localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
} }
if req.RentalEnabled != nil {
localProject.RentalEnabled = *req.RentalEnabled
}
localProject.UpdatedAt = time.Now() localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending" localProject.SyncStatus = "pending"
if err := s.localDB.SaveProject(localProject); err != nil { if err := s.localDB.SaveProject(localProject); err != nil {
+212
View File
@@ -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
}
+1
View File
@@ -217,6 +217,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
existing.TrackerURL = project.TrackerURL existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive existing.IsActive = project.IsActive
existing.IsSystem = project.IsSystem existing.IsSystem = project.IsSystem
existing.RentalEnabled = project.RentalEnabled
existing.CreatedAt = project.CreatedAt existing.CreatedAt = project.CreatedAt
existing.UpdatedAt = project.UpdatedAt existing.UpdatedAt = project.UpdatedAt
serverID := project.ID serverID := project.ID
@@ -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;
@@ -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;
+381 -13
View File
@@ -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"> class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
Ценообразование Ценообразование
</button> </button>
<button id="top-tab-rental" onclick="switchTopTab('rental')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700 hidden">
Аренда
</button>
</nav> </nav>
</div> </div>
@@ -305,6 +309,54 @@
</div><!-- end top-section-pricing --> </div><!-- end top-section-pricing -->
<!-- Top-tab section: Аренда (paid testing / rental) -->
<div id="top-section-rental" class="hidden space-y-6">
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-baseline gap-3 mb-1">
<h3 class="text-base font-semibold text-gray-800">Расчёт стоимости платного тестирования / аренды</h3>
</div>
<p class="text-xs text-gray-500 mb-3">Черновая методика — параметры и коэффициенты могут измениться. Цена всегда за 1 неделю: разовый платёж (один раз) + еженедельный платёж (за каждую неделю теста/аренды). Цена берётся из Estimate, увеличенного на аплифт ниже. Поддержка сюда не входит.</p>
<div class="flex flex-wrap items-end gap-4 mb-4">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Аплифт к Estimate, %</label>
<input type="number" id="rental-uplift-percent" min="0" step="0.1" value="0"
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="scheduleRentalRecalc()" onchange="scheduleRentalRecalc()">
</div>
<button onclick="saveRentalSettings()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
Сохранить
</button>
<span id="rental-save-status" class="text-xs text-gray-500"></span>
</div>
<div id="rental-warnings" class="hidden text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-3"></div>
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">Категория</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-center border-b">БУ</th>
<th class="px-3 py-2 text-right border-b">Разовый платёж</th>
<th class="px-3 py-2 text-right border-b">Еженедельный платёж</th>
</tr>
</thead>
<tbody id="rental-body">
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите компоненты во вкладке «Estimate»</td></tr>
</tbody>
<tfoot id="rental-foot" class="hidden bg-gray-50 font-semibold">
<tr>
<td colspan="5" class="px-3 py-2 text-right">Итого (с НДС):</td>
<td class="px-3 py-2 text-right" id="rental-total-onetime"></td>
<td class="px-3 py-2 text-right" id="rental-total-weekly"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div><!-- end top-section-rental -->
</div> </div>
<!-- Price settings modal --> <!-- Price settings modal -->
@@ -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(); applyConfigTypeToTabs();
updateTabVisibility(); updateTabVisibility();
updateRequiredCategoryBadges(); updateRequiredCategoryBadges();
updateSupportPriceDisplay();
} }
function updateRequiredCategoryBadges() { function updateRequiredCategoryBadges() {
@@ -904,6 +962,14 @@ document.addEventListener('DOMContentLoaded', async function() {
projectUUID = config.project_uuid || ''; projectUUID = config.project_uuid || '';
await loadProjectIndex(); await loadProjectIndex();
updateConfigBreadcrumbs(); 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'); document.getElementById('save-buttons').classList.remove('hidden');
// Set server count from config // Set server count from config
@@ -928,7 +994,7 @@ document.addEventListener('DOMContentLoaded', async function() {
category: item.category })); category: item.category }));
} }
serverModelForQuote = config.server_model || ''; serverModelForQuote = config.server_model || '';
supportCode = config.support_code || ''; supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name);
currentArticle = config.article || ''; currentArticle = config.article || '';
restorePricingStateFromNotes(config.notes || ''); restorePricingStateFromNotes(config.notes || '');
@@ -972,6 +1038,8 @@ document.addEventListener('DOMContentLoaded', async function() {
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete-wrapper')) { if (!e.target.closest('.autocomplete-wrapper')) {
hideAutocomplete(); hideAutocomplete();
const supportDropdown = document.getElementById('support-code-dropdown');
if (supportDropdown) supportDropdown.classList.add('hidden');
} }
}); });
@@ -1381,23 +1449,29 @@ function renderSingleSelectTab(categories) {
html += ` html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start"> <div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label> <label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label> <label for="support-code-input" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div> </div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start"> <div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<input type="text" <input type="text"
id="server-model-input" id="server-model-input"
value="${escapeHtml(serverModelForQuote)}" value="${escapeHtml(serverModelForQuote)}"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="updateServerModelForQuote(this.value)"> oninput="updateServerModelForQuote(this.value)">
<select id="support-code-select" <div class="autocomplete-wrapper relative">
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" <input type="text"
onchange="updateSupportCode(this.value)"> id="support-code-input"
<option value=""></option> autocomplete="off"
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option> placeholder="Начните вводить..."
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option> value="${escapeHtml(supportCodeLabel(supportCode))}"
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option> oninput="onSupportCodeInput(this.value)"
</select> onfocus="onSupportCodeInput(this.value)">
<div id="support-code-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-72 overflow-y-auto w-full mt-1"></div>
</div>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<div></div>
<div id="support-code-price" class="text-xs text-gray-500"></div>
</div> </div>
`; `;
} }
@@ -2249,6 +2323,7 @@ function removeFromCart(lotName) {
function updateCartUI() { function updateCartUI() {
updateTabVisibility(); updateTabVisibility();
updateRequiredCategoryBadges(); updateRequiredCategoryBadges();
updateSupportPriceDisplay();
window._currentCart = cart; // expose for BOM/Pricing tabs window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total); document.getElementById('cart-total').textContent = formatMoney(total);
@@ -2343,6 +2418,160 @@ function updateSupportCode(value) {
scheduleArticlePreview(); 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 = '<div class="autocomplete-item px-3 py-2 hover:bg-gray-100 cursor-pointer text-gray-500" onclick="selectSupportCode(\'\')">— без поддержки —</div>';
html += codes.map(code =>
'<div class="autocomplete-item px-3 py-2 hover:bg-gray-100 cursor-pointer" onclick="selectSupportCode(\'' + code + '\')">' +
escapeHtml(supportCodeLabel(code)) +
'</div>'
).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() { function scheduleArticlePreview() {
if (articlePreviewTimeout) { if (articlePreviewTimeout) {
clearTimeout(articlePreviewTimeout); clearTimeout(articlePreviewTimeout);
@@ -3059,7 +3288,7 @@ let currentTopTab = 'estimate';
function switchTopTab(tab) { function switchTopTab(tab) {
currentTopTab = tab; currentTopTab = tab;
const tabs = ['estimate', 'bom', 'pricing']; const tabs = ['estimate', 'bom', 'pricing', 'rental'];
tabs.forEach(t => { tabs.forEach(t => {
const btn = document.getElementById('top-tab-' + t); const btn = document.getElementById('top-tab-' + t);
const section = document.getElementById('top-section-' + t); const section = document.getElementById('top-section-' + t);
@@ -3076,6 +3305,145 @@ function switchTopTab(tab) {
if (tab === 'pricing') { if (tab === 'pricing') {
renderPricingTab(); 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 = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите компоненты во вкладке «Estimate»</td></tr>';
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 = '<tr><td colspan="7" class="px-3 py-8 text-center text-red-500">Не удалось рассчитать стоимость аренды</td></tr>';
foot.classList.add('hidden');
return;
}
result = await resp.json();
} catch (e) {
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-red-500">Не удалось рассчитать стоимость аренды</td></tr>';
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 '<tr class="border-b">' +
'<td class="px-3 py-2">' + escapeHtml(item.lot_name) + '</td>' +
'<td class="px-3 py-2 text-gray-500">' + escapeHtml(desc) + '</td>' +
'<td class="px-3 py-2 text-gray-500">' + escapeHtml(item.category || '') + '</td>' +
'<td class="px-3 py-2 text-right">' + item.quantity + '</td>' +
'<td class="px-3 py-2 text-center">' +
'<input type="checkbox" ' + (isUsed ? 'checked' : '') +
' onchange="onRentalConditionChange(\'' + escapeHtml(item.lot_name).replace(/'/g, "\\'") + '\', this.checked)" class="rounded border-gray-300">' +
'</td>' +
'<td class="px-3 py-2 text-right">' + formatMoney(item.one_time) + '</td>' +
'<td class="px-3 py-2 text-right">' + formatMoney(item.weekly) + '</td>' +
'</tr>';
}).join('');
body.innerHTML = rows || '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Нет компонентов</td></tr>';
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 ВЕНДОРА ==================== // ==================== BOM ВЕНДОРА ====================
+10 -1
View File
@@ -314,6 +314,13 @@
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div> <div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div>
</div> </div>
<div>
<label class="flex items-center space-x-2">
<input type="checkbox" id="project-settings-rental-enabled" class="rounded border-gray-300">
<span class="text-sm font-medium text-gray-700">Аренда</span>
</label>
<div class="text-xs text-gray-500 mt-1">Добавляет вкладку «Аренда» (расчёт стоимости платного тестирования/аренды) на конфигурациях этого проекта.</div>
</div>
</div> </div>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button> <button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
@@ -1253,6 +1260,7 @@ function openProjectSettingsModal() {
document.getElementById('project-settings-variant').value = project.variant || ''; document.getElementById('project-settings-variant').value = project.variant || '';
document.getElementById('project-settings-name').value = project.name || ''; document.getElementById('project-settings-name').value = project.name || '';
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim(); 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.remove('hidden');
document.getElementById('project-settings-modal').classList.add('flex'); 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 variant = document.getElementById('project-settings-variant').value.trim();
const name = document.getElementById('project-settings-name').value.trim(); const name = document.getElementById('project-settings-name').value.trim();
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim(); const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
const rentalEnabled = document.getElementById('project-settings-rental-enabled').checked;
if (!code) { if (!code) {
alert('Введите код проекта'); alert('Введите код проекта');
return; return;
@@ -1275,7 +1284,7 @@ async function saveProjectSettings() {
const resp = await fetch('/api/projects/' + projectUUID, { const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT', method: 'PUT',
headers: {'Content-Type': 'application/json'}, 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.ok) {
if (resp.status === 409) { if (resp.status === 409) {