refactor: устранить дублирование кода (Go-хендлеры, JS-автокомплит)

Найдено статическим анализом (dupl/jscpd) + проверено вручную:

Go:
- appstate/path.go: ResolveDBPath/ResolveConfigPath → общий resolvePath()
- handlers/respond.go: дженерик BindJSON[T] для bind+422-ошибки
- article/generator.go: buildNetSegment/buildPSUSegment → buildProfileSegment()
- cmd/qfs/main.go (+respond.go): 8 повторяющихся switch{case errors.Is(...)}
  → respondByErrCase(c, err, errCase{...}, ...)

Frontend (index.html):
- 5 идентичных обработчиков клавиатурной навигации автокомплита
  → handleAutocompleteKeyGeneric(event, onSelect)
- 4 функции сборки нового элемента корзины из автокомплита
  → buildNewCartItem()/commitCartChange()

Также: PricingMarkup — единая точка правды для аплифт-коэффициента
в JS (render + export), с cross-reference комментариями к Go-константам
в export.go (defaultSaleMarkup/stockCompetitorMarkupFactor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-10 20:13:27 +03:00
co-authored by Claude Sonnet 5
parent b087b7eb58
commit 4d7b0e13ef
8 changed files with 199 additions and 314 deletions
+9 -16
View File
@@ -21,30 +21,23 @@ const (
// ResolveDBPath returns the local SQLite path using priority:
// explicit CLI path > QFS_DB_PATH > OS-specific user state directory.
func ResolveDBPath(explicitPath string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envDBPath); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
dir, err := defaultStateDir()
if err != nil {
return "", err
}
return filepath.Join(dir, defaultDB), nil
return resolvePath(explicitPath, envDBPath, defaultDB)
}
// ResolveConfigPath returns the config path using priority:
// explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory.
func ResolveConfigPath(explicitPath string) (string, error) {
return resolvePath(explicitPath, envCfgPath, defaultCfg)
}
// resolvePath returns a path using priority: explicit CLI path > envVar > OS-specific
// user state directory joined with defaultFilename.
func resolvePath(explicitPath, envVar, defaultFilename string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
if fromEnv := os.Getenv(envVar); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
@@ -53,7 +46,7 @@ func ResolveConfigPath(explicitPath string) (string, error) {
return "", err
}
return filepath.Join(dir, defaultCfg), nil
return filepath.Join(dir, defaultFilename), nil
}
// ResolveConfigPathNearDB returns config path using priority:
+16 -31
View File
@@ -260,17 +260,28 @@ func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string
}
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
return buildProfileSegment(items, cats, GroupNET, parsePortSpeed, "UNKNET", "net_unknown")
}
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
return buildProfileSegment(items, cats, GroupPSU, parseWatts, "UNKPSU", "psu_unknown")
}
// buildProfileSegment groups items of the given category group by a profile token
// parsed from their lot name (e.g. port speed, wattage rating), falling back to
// unknownToken and warnCode when a lot's profile can't be determined.
func buildProfileSegment(items []models.ConfigItem, cats map[string]string, group Group, parseProfile func(string) string, unknownToken, warnCode string) (string, string) {
groupQty := map[string]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupNET {
g, ok := GroupForLotCategory(cats[it.LotName])
if !ok || g != group {
continue
}
profile := parsePortSpeed(it.LotName)
profile := parseProfile(it.LotName)
if profile == "" {
profile = "UNKNET"
warn = "net_unknown"
profile = unknownToken
warn = warnCode
}
groupQty[profile] += it.Quantity
}
@@ -285,32 +296,6 @@ func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string,
return strings.Join(parts, "+"), warn
}
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
groupQty := map[string]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupPSU {
continue
}
rating := parseWatts(it.LotName)
if rating == "" {
rating = "UNKPSU"
warn = "psu_unknown"
}
groupQty[rating] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for rating, qty := range groupQty {
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func normalizeModelToken(lotName string) string {
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
lotName = lotName[idx+1:]
+9 -12
View File
@@ -16,13 +16,12 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
}
func (h *QuoteHandler) Validate(c *gin.Context) {
var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
req, ok := BindJSON[services.QuoteRequest](c)
if !ok {
return
}
result, err := h.quoteService.ValidateAndCalculate(&req)
result, err := h.quoteService.ValidateAndCalculate(req)
if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
@@ -32,13 +31,12 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
}
func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
req, ok := BindJSON[services.QuoteRequest](c)
if !ok {
return
}
result, err := h.quoteService.ValidateAndCalculate(&req)
result, err := h.quoteService.ValidateAndCalculate(req)
if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
@@ -51,13 +49,12 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
}
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
req, ok := BindJSON[services.PriceLevelsRequest](c)
if !ok {
return
}
result, err := h.quoteService.CalculatePriceLevels(&req)
result, err := h.quoteService.CalculatePriceLevels(req)
if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
+12
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -16,6 +17,17 @@ func RespondError(c *gin.Context, status int, fallback string, err error) {
c.JSON(status, gin.H{"error": clientFacingErrorMessage(status, fallback, err)})
}
// BindJSON decodes the request body into T, responding with a 422 and writing
// the error via RespondError on failure. The second return value reports success.
func BindJSON[T any](c *gin.Context) (*T, bool) {
var req T
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return nil, false
}
return &req, true
}
func clientFacingErrorMessage(status int, fallback string, err error) string {
if err == nil {
return fallback
+7 -1
View File
@@ -63,11 +63,16 @@ type ProjectPricingExportOptions struct {
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
}
// defaultSaleMarkup mirrors PricingMarkup.DEFAULT_SALE_UPLIFT in web/templates/index.html.
// Keep both in sync; the frontend always sends an explicit value, this is a fallback for
// other API callers that omit sale_markup.
const defaultSaleMarkup = 1.3
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
if o.SaleMarkup > 0 {
return o.SaleMarkup
}
return 1.3
return defaultSaleMarkup
}
func (o ProjectPricingExportOptions) isDDP() bool {
@@ -516,6 +521,7 @@ func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]
// stockCompetitorMarkupFactor is the fixed DDP multiplier applied to Stock and
// Competitor columns, independent of the user-configurable estimate uplift.
// Mirrors PricingMarkup.STOCK_COMPETITOR_FIXED in web/templates/index.html.
const stockCompetitorMarkupFactor = 1.3
func applyDDPMarkup(rows []ProjectPricingExportRow, estimateFactor float64) {