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:
co-authored by
Claude Sonnet 5
parent
b087b7eb58
commit
4d7b0e13ef
@@ -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:
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user