feat: расчёт стоимости платного тестирования/аренды GPU-серверов
Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом
rental_enabled): расчёт по методичке "Регламент расчёта стоимости
платного тестирования и аренды GPU-серверов" — Разовый + Еженедельный
платёж по каждому компоненту (New/БУ чекбоксом), с аплифтом к Estimate.
Заодно: поддержка (support_code) в Base-вкладке теперь добавляется как
обычный LOT в спеку (SVC_{срок}y{уровень}_{платформа}) через
autocomplete-пикер вместо выпадающего списка — партномер собирается
тем же lot_name-based механизмом, что и для GPU/CPU/памяти, справочная
цена по регламенту техподдержки хранится в qt_settings.support_pricing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b48436de93
commit
7edb80e498
@@ -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.
|
||||||
|
|||||||
@@ -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 | |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RentalService computes paid-testing/rental pricing for a configuration,
|
||||||
|
// per the draft "Регламент расчёта стоимости платного тестирования и аренды
|
||||||
|
// GPU-серверов" methodology (Steps 1-8 and 10; buyout/Step 9 is out of scope).
|
||||||
|
//
|
||||||
|
// Pricing is always quoted per week (Разовый + Еженедельный): the methodology
|
||||||
|
// prices in multiples of a week, and there is no separate "term" input — a
|
||||||
|
// sales rep multiplies the weekly rate by however many weeks are needed.
|
||||||
|
//
|
||||||
|
// The "Цена" the methodology depreciates against is derived from the
|
||||||
|
// configuration's existing Estimate buy price via a single project-wide
|
||||||
|
// uplift percentage — QuoteForge has no per-component sale price today.
|
||||||
|
//
|
||||||
|
// Support is out of scope here: this calc covers BOM component depreciation
|
||||||
|
// only. If a deal needs support priced in, that is handled outside the
|
||||||
|
// Аренда tab (e.g. via the Estimate tab / support pricing elsewhere) rather
|
||||||
|
// than being computed or added here.
|
||||||
|
type RentalService struct {
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRentalService(localDB *localdb.LocalDB) *RentalService {
|
||||||
|
return &RentalService{localDB: localDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
daysPerYear = 365
|
||||||
|
rentalMarginFactor = 1.03
|
||||||
|
rentalVATFactor = 1.22
|
||||||
|
rentalWeekDays = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
// rentalCoeffs holds the depreciation coefficients for one methodology category.
|
||||||
|
type rentalCoeffs struct {
|
||||||
|
KNew float64 // one-time layer-1 hit, new hardware only
|
||||||
|
LifeNewDays float64
|
||||||
|
LifeUsedDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
chassisCoeffs = rentalCoeffs{KNew: 0.30, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
|
||||||
|
cpuDramCoeffs = rentalCoeffs{KNew: 0.075, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
|
||||||
|
diskCoeffs = rentalCoeffs{KNew: 0.50, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
|
||||||
|
cardsCoeffs = rentalCoeffs{KNew: 0.30, LifeNewDays: 5 * daysPerYear, LifeUsedDays: 3 * daysPerYear}
|
||||||
|
gpuActualCoeffs = rentalCoeffs{KNew: 0.20, LifeNewDays: 2 * daysPerYear, LifeUsedDays: 1 * daysPerYear}
|
||||||
|
gpuStabilizedCoeffs = rentalCoeffs{KNew: 0.20, LifeNewDays: 3 * daysPerYear, LifeUsedDays: 1 * daysPerYear}
|
||||||
|
)
|
||||||
|
|
||||||
|
// categoryCoeffMap maps existing lot_category codes (models.DefaultCategories)
|
||||||
|
// to their methodology category. Categories absent here (including "Other")
|
||||||
|
// fall back to cardsCoeffs as the closest generic fit.
|
||||||
|
var categoryCoeffMap = map[string]rentalCoeffs{
|
||||||
|
"BB": chassisCoeffs,
|
||||||
|
"MB": chassisCoeffs,
|
||||||
|
"PSU": chassisCoeffs,
|
||||||
|
"PS": chassisCoeffs,
|
||||||
|
|
||||||
|
"CPU": cpuDramCoeffs,
|
||||||
|
"MEM": cpuDramCoeffs,
|
||||||
|
|
||||||
|
"SSD": diskCoeffs,
|
||||||
|
"HDD": diskCoeffs,
|
||||||
|
"M2": diskCoeffs,
|
||||||
|
"EDSFF": diskCoeffs,
|
||||||
|
"HHHL": diskCoeffs,
|
||||||
|
|
||||||
|
"NIC": cardsCoeffs,
|
||||||
|
"HCA": cardsCoeffs,
|
||||||
|
"DPU": cardsCoeffs,
|
||||||
|
"HBA": cardsCoeffs,
|
||||||
|
"RAID": cardsCoeffs,
|
||||||
|
"RISERS": cardsCoeffs,
|
||||||
|
"CARD": cardsCoeffs,
|
||||||
|
"ACC": cardsCoeffs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuActualGenSubstrings identifies "actual generation" GPUs per the
|
||||||
|
// regulation's model list (H200, RTX PRO 6000 Blackwell SE, B200/B300).
|
||||||
|
// Update manually as new GPU generations ship.
|
||||||
|
var gpuActualGenSubstrings = []string{"H200", "B200", "B300", "BLACKWELL SE"}
|
||||||
|
|
||||||
|
func rentalCoeffsFor(category, lotName string) rentalCoeffs {
|
||||||
|
cat := strings.ToUpper(strings.TrimSpace(category))
|
||||||
|
if cat == "GPU" {
|
||||||
|
upperLot := strings.ToUpper(lotName)
|
||||||
|
for _, s := range gpuActualGenSubstrings {
|
||||||
|
if strings.Contains(upperLot, s) {
|
||||||
|
return gpuActualCoeffs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gpuStabilizedCoeffs
|
||||||
|
}
|
||||||
|
if c, ok := categoryCoeffMap[cat]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return cardsCoeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
type RentalComponentResult struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
Condition string `json:"condition"`
|
||||||
|
BuyPrice float64 `json:"buy_price"` // per-unit Estimate price
|
||||||
|
Price float64 `json:"price"` // per-unit "Цена" after uplift
|
||||||
|
Layer1 float64 `json:"layer1"` // total for quantity, ex-VAT, no margin
|
||||||
|
DailyWear float64 `json:"daily_wear"` // total for quantity, ex-VAT, no margin
|
||||||
|
OneTime float64 `json:"one_time"` // Разовый, with margin+VAT
|
||||||
|
Weekly float64 `json:"weekly"` // Еженедельный, with margin+VAT
|
||||||
|
PriceMissing bool `json:"price_missing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RentalCalculateResult struct {
|
||||||
|
Items []RentalComponentResult `json:"items"`
|
||||||
|
Layer1Total float64 `json:"layer1_total"`
|
||||||
|
DailyWearTotal float64 `json:"daily_wear_total"`
|
||||||
|
OneTimeTotal float64 `json:"one_time_total"` // Разовый, with margin+VAT
|
||||||
|
WeeklyTotal float64 `json:"weekly_total"` // Еженедельный, with margin+VAT
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate computes rental pricing for a configuration, always priced per
|
||||||
|
// week (Разовый one-time + Еженедельный recurring).
|
||||||
|
func (s *RentalService) Calculate(configUUID string, req *RentalUpdateRequest) (*RentalCalculateResult, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
conditionByLot := make(map[string]string, len(req.Items))
|
||||||
|
for _, item := range req.Items {
|
||||||
|
conditionByLot[models.NormalizeLotName(item.LotName)] = item.Condition
|
||||||
|
}
|
||||||
|
|
||||||
|
lotNames := make([]string, 0, len(localCfg.Items))
|
||||||
|
for _, item := range localCfg.Items {
|
||||||
|
lotNames = append(lotNames, item.LotName)
|
||||||
|
}
|
||||||
|
var categories map[string]string
|
||||||
|
if localCfg.PricelistID != nil {
|
||||||
|
categories, _ = s.localDB.GetLocalLotCategoriesByServerPricelistID(*localCfg.PricelistID, lotNames)
|
||||||
|
}
|
||||||
|
if categories == nil {
|
||||||
|
categories = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RentalCalculateResult{
|
||||||
|
Items: make([]RentalComponentResult, 0, len(localCfg.Items)),
|
||||||
|
}
|
||||||
|
|
||||||
|
uplift := 1 + req.UpliftPercent/100
|
||||||
|
|
||||||
|
for _, item := range localCfg.Items {
|
||||||
|
condition := conditionByLot[item.LotName]
|
||||||
|
if condition != "used" {
|
||||||
|
condition = "new"
|
||||||
|
}
|
||||||
|
category := categories[item.LotName]
|
||||||
|
coeffs := rentalCoeffsFor(category, item.LotName)
|
||||||
|
|
||||||
|
price := item.UnitPrice * uplift
|
||||||
|
qty := float64(item.Quantity)
|
||||||
|
|
||||||
|
var layer1, residual, term float64
|
||||||
|
if condition == "new" {
|
||||||
|
layer1 = price * coeffs.KNew * qty
|
||||||
|
residual = (price - price*coeffs.KNew) * qty
|
||||||
|
term = coeffs.LifeNewDays
|
||||||
|
} else {
|
||||||
|
layer1 = 0
|
||||||
|
residual = price * qty
|
||||||
|
term = coeffs.LifeUsedDays
|
||||||
|
}
|
||||||
|
dailyWear := 0.0
|
||||||
|
if term > 0 {
|
||||||
|
dailyWear = residual / term
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Layer1Total += layer1
|
||||||
|
result.DailyWearTotal += dailyWear
|
||||||
|
|
||||||
|
oneTime := layer1 * rentalMarginFactor * rentalVATFactor
|
||||||
|
weekly := dailyWear * rentalWeekDays * rentalMarginFactor * rentalVATFactor
|
||||||
|
|
||||||
|
result.Items = append(result.Items, RentalComponentResult{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Category: category,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Condition: condition,
|
||||||
|
BuyPrice: item.UnitPrice,
|
||||||
|
Price: price,
|
||||||
|
Layer1: layer1,
|
||||||
|
DailyWear: dailyWear,
|
||||||
|
OneTime: oneTime,
|
||||||
|
Weekly: weekly,
|
||||||
|
PriceMissing: item.UnitPrice <= 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result.OneTimeTotal = result.Layer1Total * rentalMarginFactor * rentalVATFactor
|
||||||
|
result.WeeklyTotal = result.DailyWearTotal * rentalWeekDays * rentalMarginFactor * rentalVATFactor
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -217,6 +217,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
existing.TrackerURL = project.TrackerURL
|
existing.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
@@ -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 ВЕНДОРА ====================
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user