diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index a896bab..a164b5e 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -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 } diff --git a/cmd/qfs/respond.go b/cmd/qfs/respond.go new file mode 100644 index 0000000..2c2d4df --- /dev/null +++ b/cmd/qfs/respond.go @@ -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) +} diff --git a/internal/appstate/path.go b/internal/appstate/path.go index 0d3ff1c..2172f10 100644 --- a/internal/appstate/path.go +++ b/internal/appstate/path.go @@ -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: diff --git a/internal/article/generator.go b/internal/article/generator.go index 9f15d15..e0b2a24 100644 --- a/internal/article/generator.go +++ b/internal/article/generator.go @@ -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:] diff --git a/internal/handlers/quote.go b/internal/handlers/quote.go index 4487849..c83624b 100644 --- a/internal/handlers/quote.go +++ b/internal/handlers/quote.go @@ -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 diff --git a/internal/handlers/respond.go b/internal/handlers/respond.go index 12efcee..b5fef05 100644 --- a/internal/handlers/respond.go +++ b/internal/handlers/respond.go @@ -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 diff --git a/internal/services/export.go b/internal/services/export.go index 5824505..bd89922 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -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) { diff --git a/web/templates/index.html b/web/templates/index.html index c116d4d..5e8cbdc 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -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',