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
+52
-99
@@ -1101,16 +1101,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConfigNotFound):
|
||||
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1217,16 +1212,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConfigNotFound):
|
||||
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrConfigNotFound, http.StatusNotFound, "resource not found"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
default:
|
||||
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername)
|
||||
if err != nil {
|
||||
switch {
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant),
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrCannotRenameMainVariant, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrCannotDeleteMainVariant, http.StatusBadRequest, "invalid request"},
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
switch {
|
||||
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)
|
||||
}
|
||||
respondByErrCase(c, err,
|
||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+63
-155
@@ -609,6 +609,19 @@ let warehouseStockLoadsByPricelist = new Map();
|
||||
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
|
||||
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
|
||||
let autocompleteInput = null;
|
||||
let autocompleteCategory = null;
|
||||
@@ -1885,7 +1898,10 @@ function renderAutocomplete() {
|
||||
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') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||||
@@ -1897,27 +1913,23 @@ function handleAutocompleteKey(event, category) {
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||||
selectAutocompleteItem(autocompleteIndex);
|
||||
onSelect(autocompleteIndex);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAutocompleteItem(index) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp || !autocompleteCategory) return;
|
||||
function handleAutocompleteKey(event, category) {
|
||||
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItem(index));
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Builds a fresh cart entry for a component picked from an autocomplete dropdown.
|
||||
// Warehouse/competitor prices and their deltas start unknown (null) until the
|
||||
// next price-levels refresh fills them in.
|
||||
function buildNewCartItem(comp, qty) {
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
return {
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
@@ -1933,15 +1945,35 @@ function selectAutocompleteItem(index) {
|
||||
price_missing: ['warehouse', 'competitor'],
|
||||
description: comp.description || '',
|
||||
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();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
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() {
|
||||
document.getElementById('autocomplete-dropdown').classList.add('hidden');
|
||||
autocompleteInput = null;
|
||||
@@ -1986,22 +2018,7 @@ function filterAutocompleteMulti(search) {
|
||||
}
|
||||
|
||||
function handleAutocompleteKeyMulti(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
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();
|
||||
}
|
||||
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemMulti(index));
|
||||
}
|
||||
|
||||
function selectAutocompleteItemMulti(index) {
|
||||
@@ -2010,31 +2027,10 @@ function selectAutocompleteItemMulti(index) {
|
||||
|
||||
const qtyInput = document.getElementById('new-qty');
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
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)
|
||||
});
|
||||
cart.push(buildNewCartItem(comp, qty));
|
||||
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
commitCartChange();
|
||||
}
|
||||
|
||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||
@@ -2091,22 +2087,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
||||
}
|
||||
|
||||
function handleAutocompleteKeySection(event, sectionId) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
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();
|
||||
}
|
||||
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemSection(index, sectionId));
|
||||
}
|
||||
|
||||
function selectAutocompleteItemSection(index, sectionId) {
|
||||
@@ -2115,25 +2096,7 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
|
||||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
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)
|
||||
});
|
||||
cart.push(buildNewCartItem(comp, qty));
|
||||
|
||||
hideAutocomplete();
|
||||
|
||||
@@ -2143,10 +2106,7 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
|
||||
// Reset quantity to 1
|
||||
if (qtyInput) qtyInput.value = '1';
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
commitCartChange();
|
||||
}
|
||||
|
||||
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
|
||||
@@ -2180,22 +2140,7 @@ function filterAutocompleteEditItem(search) {
|
||||
}
|
||||
|
||||
function handleAutocompleteKeyEditItem(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
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();
|
||||
}
|
||||
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteEditItem(index));
|
||||
}
|
||||
|
||||
function selectAutocompleteEditItem(index) {
|
||||
@@ -2205,29 +2150,10 @@ function selectAutocompleteEditItem(index) {
|
||||
const oldItem = cart.find(i => i.lot_name === lotName);
|
||||
const qty = oldItem?.quantity || 1;
|
||||
cart = cart.filter(i => i.lot_name !== lotName);
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
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)
|
||||
});
|
||||
cart.push(buildNewCartItem(comp, qty));
|
||||
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
commitCartChange();
|
||||
}
|
||||
|
||||
// Autocomplete for BOM LOT mapping
|
||||
@@ -2263,22 +2189,7 @@ function filterAutocompleteBOM(rowIdx, search) {
|
||||
}
|
||||
|
||||
function handleAutocompleteKeyBOM(event, rowIdx) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
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();
|
||||
}
|
||||
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemBOM(index, rowIdx));
|
||||
}
|
||||
|
||||
function selectAutocompleteItemBOM(index, rowIdx) {
|
||||
@@ -4573,12 +4484,9 @@ async function renderPricingTab() {
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
// Sale uplift applied to estimate (default 1.3)
|
||||
const saleUplift = (() => {
|
||||
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||
return v > 0 ? v : 1.3;
|
||||
})();
|
||||
const SALE_FIXED_MULT = 1.3;
|
||||
// Sale uplift applied to estimate; Stock/Competitor use the fixed factor. See PricingMarkup.
|
||||
const saleUplift = PricingMarkup.getSaleUplift();
|
||||
const SALE_FIXED_MULT = PricingMarkup.STOCK_COMPETITOR_FIXED;
|
||||
|
||||
// Helper: returns unit prices from pricelist for a single LOT
|
||||
const _getUnitPrices = (pl) => ({
|
||||
@@ -5028,7 +4936,7 @@ async function exportPricingCSV(table) {
|
||||
const basis = table === 'sale' ? 'ddp' : 'fob';
|
||||
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
||||
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 {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user