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
+52 -99
View File
@@ -1101,16 +1101,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
config, err := configService.UpdateNoAuth(uuid, &req) config, err := configService.UpdateNoAuth(uuid, &req)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrConfigNotFound): errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
respondError(c, http.StatusNotFound, "resource not found", err) )
case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
@@ -1217,16 +1212,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID) updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrConfigNotFound): errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
respondError(c, http.StatusNotFound, "resource not found", err) )
case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, updated) c.JSON(http.StatusOK, updated)
@@ -1354,12 +1344,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
config, err := configService.UpdateRentalNoAuth(c.Param("uuid"), &req) config, err := configService.UpdateRentalNoAuth(c.Param("uuid"), &req)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrConfigNotFound): errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) )
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
@@ -1373,12 +1360,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
result, err := rentalService.Calculate(c.Param("uuid"), &req) result, err := rentalService.Calculate(c.Param("uuid"), &req)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrConfigNotFound): errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) )
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
@@ -1627,16 +1611,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
project, err := projectService.Create(dbUsername, &req) project, err := projectService.Create(dbUsername, &req)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrReservedMainVariant), errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"},
errors.Is(err, services.ErrProjectCodeInvalidChars), errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
errors.Is(err, services.ErrProjectVariantInvalidChars): errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"},
respondError(c, http.StatusBadRequest, "invalid request", err) errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
case errors.Is(err, services.ErrProjectCodeExists): )
respondError(c, http.StatusConflict, "conflict detected", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusCreated, project) c.JSON(http.StatusCreated, project)
@@ -1645,14 +1625,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.GET("/:uuid", func(c *gin.Context) { projects.GET("/:uuid", func(c *gin.Context) {
project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername) project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
case errors.Is(err, services.ErrProjectForbidden): )
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, project) c.JSON(http.StatusOK, project)
@@ -1666,21 +1642,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrReservedMainVariant), errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"},
errors.Is(err, services.ErrCannotRenameMainVariant), errCase{services.ErrCannotRenameMainVariant, http.StatusBadRequest, "invalid request"},
errors.Is(err, services.ErrProjectCodeInvalidChars), errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
errors.Is(err, services.ErrProjectVariantInvalidChars): errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"},
respondError(c, http.StatusBadRequest, "invalid request", err) errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
case errors.Is(err, services.ErrProjectCodeExists): errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusConflict, "conflict detected", err) errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
case errors.Is(err, services.ErrProjectNotFound): )
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, project) c.JSON(http.StatusOK, project)
@@ -1688,14 +1658,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("/:uuid/archive", func(c *gin.Context) { projects.POST("/:uuid/archive", func(c *gin.Context) {
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
case errors.Is(err, services.ErrProjectForbidden): )
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "project archived"}) c.JSON(http.StatusOK, gin.H{"message": "project archived"})
@@ -1703,14 +1669,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("/:uuid/reactivate", func(c *gin.Context) { projects.POST("/:uuid/reactivate", func(c *gin.Context) {
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
case errors.Is(err, services.ErrProjectForbidden): )
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"}) c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
@@ -1718,16 +1680,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.DELETE("/:uuid", func(c *gin.Context) { projects.DELETE("/:uuid", func(c *gin.Context) {
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil { if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrCannotDeleteMainVariant): errCase{services.ErrCannotDeleteMainVariant, http.StatusBadRequest, "invalid request"},
respondError(c, http.StatusBadRequest, "invalid request", err) errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
respondError(c, http.StatusNotFound, "resource not found", err) )
case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "variant deleted"}) c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
@@ -1744,14 +1701,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status) result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status)
if err != nil { if err != nil {
switch { respondByErrCase(c, err,
case errors.Is(err, services.ErrProjectNotFound): errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
respondError(c, http.StatusNotFound, "resource not found", err) errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
case errors.Is(err, services.ErrProjectForbidden): )
respondError(c, http.StatusForbidden, "access denied", err)
default:
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return return
} }
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"errors"
"net/http"
"git.mchus.pro/mchus/quoteforge/internal/handlers"
"github.com/gin-gonic/gin"
)
// errCase maps a sentinel error to the HTTP status/message respondByErrCase
// should use when errors.Is matches it.
type errCase struct {
err error
status int
message string
}
// respondByErrCase responds with the status/message of the first matching
// case (checked in order), or a 500 "internal server error" fallback when
// none match. Centralizes the switch-on-sentinel-error pattern repeated
// across the config/project HTTP handlers in this file.
func respondByErrCase(c *gin.Context, err error, cases ...errCase) {
for _, cs := range cases {
if errors.Is(err, cs.err) {
handlers.RespondError(c, cs.status, cs.message, err)
return
}
}
handlers.RespondError(c, http.StatusInternalServerError, "internal server error", err)
}
+9 -16
View File
@@ -21,30 +21,23 @@ const (
// ResolveDBPath returns the local SQLite path using priority: // ResolveDBPath returns the local SQLite path using priority:
// explicit CLI path > QFS_DB_PATH > OS-specific user state directory. // explicit CLI path > QFS_DB_PATH > OS-specific user state directory.
func ResolveDBPath(explicitPath string) (string, error) { func ResolveDBPath(explicitPath string) (string, error) {
if explicitPath != "" { return resolvePath(explicitPath, envDBPath, defaultDB)
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
} }
// ResolveConfigPath returns the config path using priority: // ResolveConfigPath returns the config path using priority:
// explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory. // explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory.
func ResolveConfigPath(explicitPath string) (string, error) { 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 != "" { if explicitPath != "" {
return filepath.Clean(explicitPath), nil return filepath.Clean(explicitPath), nil
} }
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" { if fromEnv := os.Getenv(envVar); fromEnv != "" {
return filepath.Clean(fromEnv), nil return filepath.Clean(fromEnv), nil
} }
@@ -53,7 +46,7 @@ func ResolveConfigPath(explicitPath string) (string, error) {
return "", err return "", err
} }
return filepath.Join(dir, defaultCfg), nil return filepath.Join(dir, defaultFilename), nil
} }
// ResolveConfigPathNearDB returns config path using priority: // 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) { 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{} groupQty := map[string]int{}
warn := "" warn := ""
for _, it := range items { for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName]) g, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupNET { if !ok || g != group {
continue continue
} }
profile := parsePortSpeed(it.LotName) profile := parseProfile(it.LotName)
if profile == "" { if profile == "" {
profile = "UNKNET" profile = unknownToken
warn = "net_unknown" warn = warnCode
} }
groupQty[profile] += it.Quantity groupQty[profile] += it.Quantity
} }
@@ -285,32 +296,6 @@ func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string,
return strings.Join(parts, "+"), warn 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 { func normalizeModelToken(lotName string) string {
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) { if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
lotName = lotName[idx+1:] 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) { func (h *QuoteHandler) Validate(c *gin.Context) {
var req services.QuoteRequest req, ok := BindJSON[services.QuoteRequest](c)
if err := c.ShouldBindJSON(&req); err != nil { if !ok {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(req)
if err != nil { if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
@@ -32,13 +31,12 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
} }
func (h *QuoteHandler) Calculate(c *gin.Context) { func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest req, ok := BindJSON[services.QuoteRequest](c)
if err := c.ShouldBindJSON(&req); err != nil { if !ok {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(req)
if err != nil { if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
@@ -51,13 +49,12 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
} }
func (h *QuoteHandler) PriceLevels(c *gin.Context) { func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest req, ok := BindJSON[services.PriceLevelsRequest](c)
if err := c.ShouldBindJSON(&req); err != nil { if !ok {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
result, err := h.quoteService.CalculatePriceLevels(&req) result, err := h.quoteService.CalculatePriceLevels(req)
if err != nil { if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
+12
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "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)}) 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 { func clientFacingErrorMessage(status int, fallback string, err error) string {
if err == nil { if err == nil {
return fallback 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 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 { func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
if o.SaleMarkup > 0 { if o.SaleMarkup > 0 {
return o.SaleMarkup return o.SaleMarkup
} }
return 1.3 return defaultSaleMarkup
} }
func (o ProjectPricingExportOptions) isDDP() bool { 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 // stockCompetitorMarkupFactor is the fixed DDP multiplier applied to Stock and
// Competitor columns, independent of the user-configurable estimate uplift. // Competitor columns, independent of the user-configurable estimate uplift.
// Mirrors PricingMarkup.STOCK_COMPETITOR_FIXED in web/templates/index.html.
const stockCompetitorMarkupFactor = 1.3 const stockCompetitorMarkupFactor = 1.3
func applyDDPMarkup(rows []ProjectPricingExportRow, estimateFactor float64) { func applyDDPMarkup(rows []ProjectPricingExportRow, estimateFactor float64) {
+63 -155
View File
@@ -609,6 +609,19 @@ let warehouseStockLoadsByPricelist = new Map();
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
// ─── Sale (DDP) pricing markup — single source of truth for this file ───
// Mirrors internal/services/export.go (saleMarkupFactor / stockCompetitorMarkupFactor).
// Keep both sides in sync: Estimate scales by the user's uplift, Stock/Competitor by a fixed factor.
const PricingMarkup = {
DEFAULT_SALE_UPLIFT: 1.3,
STOCK_COMPETITOR_FIXED: 1.3,
// Reads the "Аплифт к estimate" input; falls back to DEFAULT_SALE_UPLIFT when empty/invalid.
getSaleUplift() {
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
return v > 0 ? v : this.DEFAULT_SALE_UPLIFT;
},
};
// Autocomplete state // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
let autocompleteCategory = null; let autocompleteCategory = null;
@@ -1885,7 +1898,10 @@ function renderAutocomplete() {
dropdown.classList.remove('hidden'); dropdown.classList.remove('hidden');
} }
function handleAutocompleteKey(event, category) { // Shared ArrowUp/ArrowDown/Enter/Escape navigation for all autocomplete dropdown
// variants (single-select, multi-select, section, edit-item, BOM row); onSelect
// receives the chosen autocompleteFiltered index and applies it to that context.
function handleAutocompleteKeyGeneric(event, onSelect) {
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1); autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
@@ -1897,27 +1913,23 @@ function handleAutocompleteKey(event, category) {
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) { if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItem(autocompleteIndex); onSelect(autocompleteIndex);
} }
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
hideAutocomplete(); hideAutocomplete();
} }
} }
function selectAutocompleteItem(index) { function handleAutocompleteKey(event, category) {
const comp = autocompleteFiltered[index]; handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItem(index));
if (!comp || !autocompleteCategory) return; }
// Remove existing item of this category // Builds a fresh cart entry for a component picked from an autocomplete dropdown.
cart = cart.filter(item => // Warehouse/competitor prices and their deltas start unknown (null) until the
ciStr(item.category) !== ciStr(autocompleteCategory) // next price-levels refresh fills them in.
); function buildNewCartItem(comp, qty) {
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0; const price = componentPricesCache[comp.lot_name] || 0;
return {
cart.push({
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: price, unit_price: price,
@@ -1933,15 +1945,35 @@ function selectAutocompleteItem(index) {
price_missing: ['warehouse', 'competitor'], price_missing: ['warehouse', 'competitor'],
description: comp.description || '', description: comp.description || '',
category: getComponentCategory(comp) category: getComponentCategory(comp)
}); };
}
hideAutocomplete(); // Re-renders the tab/cart and kicks off autosave + a price-levels refresh
// after a cart mutation from an autocomplete selection.
function commitCartChange() {
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave(); triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false }); schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
} }
function selectAutocompleteItem(index) {
const comp = autocompleteFiltered[index];
if (!comp || !autocompleteCategory) return;
// Remove existing item of this category
cart = cart.filter(item =>
ciStr(item.category) !== ciStr(autocompleteCategory)
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
cart.push(buildNewCartItem(comp, qty));
hideAutocomplete();
commitCartChange();
}
function hideAutocomplete() { function hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden'); document.getElementById('autocomplete-dropdown').classList.add('hidden');
autocompleteInput = null; autocompleteInput = null;
@@ -1986,22 +2018,7 @@ function filterAutocompleteMulti(search) {
} }
function handleAutocompleteKeyMulti(event) { function handleAutocompleteKeyMulti(event) {
if (event.key === 'ArrowDown') { handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemMulti(index));
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemMulti(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
} }
function selectAutocompleteItemMulti(index) { function selectAutocompleteItemMulti(index) {
@@ -2010,31 +2027,10 @@ function selectAutocompleteItemMulti(index) {
const qtyInput = document.getElementById('new-qty'); const qtyInput = document.getElementById('new-qty');
const qty = parseInt(qtyInput?.value) || 1; const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0; cart.push(buildNewCartItem(comp, qty));
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete(); hideAutocomplete();
renderTab(); commitCartChange();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
} }
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections) // Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
@@ -2091,22 +2087,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
} }
function handleAutocompleteKeySection(event, sectionId) { function handleAutocompleteKeySection(event, sectionId) {
if (event.key === 'ArrowDown') { handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemSection(index, sectionId));
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemSection(autocompleteIndex, sectionId);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
} }
function selectAutocompleteItemSection(index, sectionId) { function selectAutocompleteItemSection(index, sectionId) {
@@ -2115,25 +2096,7 @@ function selectAutocompleteItemSection(index, sectionId) {
const qtyInput = document.getElementById('new-qty-' + sectionId); const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1; const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0; cart.push(buildNewCartItem(comp, qty));
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete(); hideAutocomplete();
@@ -2143,10 +2106,7 @@ function selectAutocompleteItemSection(index, sectionId) {
// Reset quantity to 1 // Reset quantity to 1
if (qtyInput) qtyInput.value = '1'; if (qtyInput) qtyInput.value = '1';
renderTab(); commitCartChange();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
} }
// Autocomplete for editing an existing cart item's LOT (multi/section tabs) // Autocomplete for editing an existing cart item's LOT (multi/section tabs)
@@ -2180,22 +2140,7 @@ function filterAutocompleteEditItem(search) {
} }
function handleAutocompleteKeyEditItem(event) { function handleAutocompleteKeyEditItem(event) {
if (event.key === 'ArrowDown') { handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteEditItem(index));
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteEditItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
} }
function selectAutocompleteEditItem(index) { function selectAutocompleteEditItem(index) {
@@ -2205,29 +2150,10 @@ function selectAutocompleteEditItem(index) {
const oldItem = cart.find(i => i.lot_name === lotName); const oldItem = cart.find(i => i.lot_name === lotName);
const qty = oldItem?.quantity || 1; const qty = oldItem?.quantity || 1;
cart = cart.filter(i => i.lot_name !== lotName); cart = cart.filter(i => i.lot_name !== lotName);
const price = componentPricesCache[comp.lot_name] || 0; cart.push(buildNewCartItem(comp, qty));
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete(); hideAutocomplete();
renderTab(); commitCartChange();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
} }
// Autocomplete for BOM LOT mapping // Autocomplete for BOM LOT mapping
@@ -2263,22 +2189,7 @@ function filterAutocompleteBOM(rowIdx, search) {
} }
function handleAutocompleteKeyBOM(event, rowIdx) { function handleAutocompleteKeyBOM(event, rowIdx) {
if (event.key === 'ArrowDown') { handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemBOM(index, rowIdx));
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
} }
function selectAutocompleteItemBOM(index, rowIdx) { function selectAutocompleteItemBOM(index, rowIdx) {
@@ -4573,12 +4484,9 @@ async function renderPricingTab() {
} catch(e) { /* silent */ } } catch(e) { /* silent */ }
} }
// Sale uplift applied to estimate (default 1.3) // Sale uplift applied to estimate; Stock/Competitor use the fixed factor. See PricingMarkup.
const saleUplift = (() => { const saleUplift = PricingMarkup.getSaleUplift();
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || ''); const SALE_FIXED_MULT = PricingMarkup.STOCK_COMPETITOR_FIXED;
return v > 0 ? v : 1.3;
})();
const SALE_FIXED_MULT = 1.3;
// Helper: returns unit prices from pricelist for a single LOT // Helper: returns unit prices from pricelist for a single LOT
const _getUnitPrices = (pl) => ({ const _getUnitPrices = (pl) => ({
@@ -5028,7 +4936,7 @@ async function exportPricingCSV(table) {
const basis = table === 'sale' ? 'ddp' : 'fob'; const basis = table === 'sale' ? 'ddp' : 'fob';
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy'; const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || ''); const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
const saleUplift = table === 'sale' ? parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '') : 0; const saleUplift = table === 'sale' ? PricingMarkup.getSaleUplift() : 0;
try { try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, { const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST', method: 'POST',