Files
aura-crm/production/backend/internal/pcbuilder/compat.go
T

166 lines
6.6 KiB
Go

package pcbuilder
import (
"fmt"
"strings"
)
// wattageBufferW covers motherboard/fans/drives/idle losses the CPU+GPU
// TDP sum doesn't itself account for — a common rule-of-thumb margin, not
// a precise calculation (this package flags "probably not enough," not a
// certified power budget).
const wattageBufferW = 100
type Component struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Price string `json:"price"`
Spec Spec `json:"spec"`
}
// MultiEntry is one row of a multi-quantity slot (ram/storage) — Qty lets
// "3 identical sticks" be one row instead of the same part id repeated 3
// times in the request. Distinct parts in the same slot (an SSD + an HDD)
// are just separate entries with Qty 1 each.
type MultiEntry struct {
Component *Component
Qty int
}
type Issue struct {
Level string `json:"level"` // "error" | "warning" | "info"
Message string `json:"message"`
}
type Result struct {
Issues []Issue `json:"issues"`
TotalPrice float64 `json:"total_price"`
EstimatedWattageW int `json:"estimated_wattage_w"`
}
func eqFold(a, b string) bool { return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) }
func containsFold(list []string, want string) bool {
for _, v := range list {
if eqFold(v, want) {
return true
}
}
return false
}
func priceOf(c *Component) float64 {
if c == nil || c.Price == "" {
return 0
}
var v float64
_, _ = fmt.Sscanf(c.Price, "%f", &v)
return v
}
// Check runs every pairwise rule this package knows about against the
// slots actually filled — a nil entry (or an empty/absent multi[type])
// means that slot's picker is still empty, not an error by itself (an
// in-progress build shouldn't yell about every missing category, only
// about what was picked not fitting together). by[type] may be nil.
//
// ram and storage are multi-quantity slots (see MultiEntry) — everything
// else stays exactly one part per build, same as before. Only ram carries
// real compatibility rules today (type must match the motherboard, total
// capacity is capped by it); storage has no cross-part rule in this
// package yet, entries there only contribute to price.
func Check(by map[string]*Component, multi map[string][]MultiEntry) Result {
var res Result
cpu, mobo, gpu, psu, kase, cooler := by["cpu"], by["motherboard"], by["gpu"], by["psu"], by["case"], by["cooler"]
ramEntries, storageEntries := multi["ram"], multi["storage"]
for _, c := range by {
res.TotalPrice += priceOf(c)
}
for _, e := range ramEntries {
res.TotalPrice += priceOf(e.Component) * float64(e.Qty)
}
for _, e := range storageEntries {
res.TotalPrice += priceOf(e.Component) * float64(e.Qty)
}
if cpu != nil {
res.EstimatedWattageW += cpu.Spec.TDPWatts
}
if gpu != nil {
res.EstimatedWattageW += gpu.Spec.TDPWatts
}
if res.EstimatedWattageW > 0 {
res.EstimatedWattageW += wattageBufferW
}
add := func(level, format string, args ...any) {
res.Issues = append(res.Issues, Issue{Level: level, Message: fmt.Sprintf(format, args...)})
}
// Both sides must actually carry a socket value before comparing them —
// scraped external CPUs (server sockets like SP3/SP5 the scraper's regex
// doesn't recognize, see internal/scraper/spec.go's socketRe) come
// through with Spec.Socket == "". Comparing that blank against a real
// board socket with eqFold used to read as "mismatch" (cpu.Spec.Socket
// == "" fails eqFold against anything non-empty), flagging genuinely
// compatible-or-unknown pairs as an error just because one side's specs
// were never filled in — checking mobo's side alone wasn't enough.
if cpu != nil && mobo != nil && cpu.Spec.Socket != "" && mobo.Spec.Socket != "" && !eqFold(cpu.Spec.Socket, mobo.Spec.Socket) {
add("error", "Процессор (%s) и материнская плата (%s) — разные сокеты", cpu.Spec.Socket, mobo.Spec.Socket)
}
// RAM: every stick must match the board's memory type; total capacity
// (sum across all rows, qty included) is capped by the board's max.
// mismatchTypes dedupes so 3 mismatched sticks of the same wrong type
// produce one error, not three.
if mobo != nil && mobo.Spec.RAMType != "" {
mismatchTypes := map[string]bool{}
for _, e := range ramEntries {
if e.Component.Spec.RAMType != "" && !eqFold(e.Component.Spec.RAMType, mobo.Spec.RAMType) && !mismatchTypes[e.Component.Spec.RAMType] {
mismatchTypes[e.Component.Spec.RAMType] = true
add("error", "Материнская плата поддерживает %s, а память — %s", mobo.Spec.RAMType, e.Component.Spec.RAMType)
}
}
}
if mobo != nil && mobo.Spec.MaxRAMGB > 0 {
totalRAMGB := 0
for _, e := range ramEntries {
totalRAMGB += e.Component.Spec.RAMCapacityGB * e.Qty
}
if totalRAMGB > mobo.Spec.MaxRAMGB {
add("warning", "Память (%d ГБ) превышает максимум материнской платы (%d ГБ)", totalRAMGB, mobo.Spec.MaxRAMGB)
}
}
if mobo != nil && kase != nil && mobo.Spec.FormFactor != "" && len(kase.Spec.FormFactorsSupported) > 0 &&
!containsFold(kase.Spec.FormFactorsSupported, mobo.Spec.FormFactor) {
add("error", "Корпус не поддерживает форм-фактор платы (%s)", mobo.Spec.FormFactor)
}
if gpu != nil && kase != nil && kase.Spec.MaxGPULengthMM > 0 && gpu.Spec.LengthMM > kase.Spec.MaxGPULengthMM {
add("error", "Видеокарта (%d мм) не помещается в корпус (макс. %d мм)", gpu.Spec.LengthMM, kase.Spec.MaxGPULengthMM)
}
if cooler != nil && kase != nil && kase.Spec.MaxCoolerHeightMM > 0 && cooler.Spec.HeightMM > kase.Spec.MaxCoolerHeightMM {
add("error", "Охлаждение (%d мм) не помещается в корпус (макс. %d мм)", cooler.Spec.HeightMM, kase.Spec.MaxCoolerHeightMM)
}
if cooler != nil && cpu != nil && len(cooler.Spec.SocketsSupported) > 0 && cpu.Spec.Socket != "" &&
!containsFold(cooler.Spec.SocketsSupported, cpu.Spec.Socket) {
add("error", "Охлаждение не поддерживает сокет процессора (%s)", cpu.Spec.Socket)
}
if psu != nil && res.EstimatedWattageW > 0 && psu.Spec.WattageW > 0 && psu.Spec.WattageW < res.EstimatedWattageW {
add("warning", "Блока питания (%d Вт) может не хватить — расчётное потребление ~%d Вт", psu.Spec.WattageW, res.EstimatedWattageW)
}
for _, required := range []string{"cpu", "motherboard", "psu", "case"} {
if by[required] == nil {
add("info", "Не выбран компонент: %s", TypeLabels[required])
}
}
if res.Issues == nil {
res.Issues = []Issue{}
}
return res
}