From ec182abe9923f3c27ae1dde644a2ae570461b11d Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 13 Mar 2026 08:17:44 +0300 Subject: [PATCH] Competitor pricelist: aggregate all competitors, rebuild without re-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GetLatestQuotesAllCompetitors() repo method: latest quote per (competitor_id, partnumber) across all active competitors - Add RebuildPricelist() service method: loads all quotes, applies each competitor's discount, aggregates with weighted_median per lot, creates single combined competitor pricelist - Add POST /api/competitors/pricelist handler + route - JS: "Создать прайслист" on competitor tab calls new endpoint instead of the generic one that required explicit items This allows recreating the competitor pricelist after new lot mappings are added, without requiring a new file upload. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pfs/main.go | 21 +++++ internal/handlers/competitor.go | 32 ++++++++ internal/repository/competitor.go | 20 +++++ internal/services/competitor_import.go | 105 +++++++++++++++++++++++++ web/static/js/admin_pricing.js | 5 +- 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/cmd/pfs/main.go b/cmd/pfs/main.go index 6df9f42..62204b5 100644 --- a/cmd/pfs/main.go +++ b/cmd/pfs/main.go @@ -417,6 +417,13 @@ func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionMa // Create task manager taskManager := tasks.NewManager() + var competitorRepo *repository.CompetitorRepository + if mariaDB != nil { + competitorRepo = repository.NewCompetitorRepository(mariaDB) + } + competitorImportService := services.NewCompetitorImportService(mariaDB, competitorRepo, pricelistService) + competitorHandler := handlers.NewCompetitorHandler(competitorRepo, competitorImportService, taskManager, dbUser) + templatesPath := filepath.Join("web", "templates") componentHandler := handlers.NewComponentHandler(componentService) pricingHandler := handlers.NewPricingHandler( @@ -534,6 +541,7 @@ func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionMa router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/admin/pricing", webHandler.AdminPricing) router.GET("/vendor-mappings", webHandler.VendorMappings) + router.GET("/admin/competitors", webHandler.Competitors) partials := router.Group("/partials") { @@ -572,6 +580,19 @@ func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionMa pricelists.DELETE("/:id", pricelistHandler.Delete) } + competitors := api.Group("/competitors") + { + competitors.GET("", competitorHandler.List) + competitors.POST("", competitorHandler.Create) + competitors.POST("/parse-headers", competitorHandler.ParseHeaders) + competitors.GET("/:id", competitorHandler.Get) + competitors.PUT("/:id", competitorHandler.Update) + competitors.DELETE("/:id", competitorHandler.Delete) + competitors.PATCH("/:id/active", competitorHandler.SetActive) + competitors.POST("/:id/import", competitorHandler.Import) + competitors.POST("/pricelist", competitorHandler.RebuildPricelist) + } + pricingAdmin := api.Group("/admin/pricing") { pricingAdmin.GET("/stats", pricingHandler.GetStats) diff --git a/internal/handlers/competitor.go b/internal/handlers/competitor.go index 43419ad..49d6dd5 100644 --- a/internal/handlers/competitor.go +++ b/internal/handlers/competitor.go @@ -294,6 +294,38 @@ func (h *CompetitorHandler) SetActive(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"is_active": req.IsActive}) } +// RebuildPricelist creates a new competitor pricelist from all stored quotes of active competitors. +func (h *CompetitorHandler) RebuildPricelist(c *gin.Context) { + createdBy := h.dbUsername + if createdBy == "" { + createdBy = "admin" + } + + taskID := h.taskManager.Submit(tasks.TaskTypeCompetitorImport, func(_ context.Context, progressCb func(int, string)) (map[string]interface{}, error) { + result, err := h.importService.RebuildPricelist(createdBy, func(p services.CompetitorImportProgress) { + var progress int + if p.Total > 0 { + progress = int(float64(p.Current) / float64(p.Total) * 100) + } + progressCb(progress, p.Message) + }) + if err != nil { + return nil, err + } + progressCb(100, result.PricelistVer) + return map[string]interface{}{ + "rows_total": result.RowsTotal, + "inserted": result.Inserted, + "skipped": result.Skipped, + "unmapped": result.Unmapped, + "pricelist_id": result.PricelistID, + "pricelist_version": result.PricelistVer, + }, nil + }) + + c.JSON(http.StatusAccepted, gin.H{"task_id": taskID}) +} + func (h *CompetitorHandler) Import(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { diff --git a/internal/repository/competitor.go b/internal/repository/competitor.go index 30970b5..d58e3a4 100644 --- a/internal/repository/competitor.go +++ b/internal/repository/competitor.go @@ -98,6 +98,26 @@ func (r *CompetitorRepository) GetLatestQuotesByPN(competitorID uint64) ([]model return quotes, err } +// GetLatestQuotesAllCompetitors returns the most recent quote per (competitor_id, partnumber) +// across ALL active competitors. Used when building the combined competitor pricelist. +func (r *CompetitorRepository) GetLatestQuotesAllCompetitors() ([]models.CompetitorQuote, error) { + var quotes []models.CompetitorQuote + err := r.db.Raw(` + SELECT plc.* + FROM partnumber_log_competitors plc + INNER JOIN ( + SELECT competitor_id, partnumber, MAX(date) AS max_date + FROM partnumber_log_competitors + GROUP BY competitor_id, partnumber + ) latest ON plc.competitor_id = latest.competitor_id + AND plc.partnumber = latest.partnumber + AND plc.date = latest.max_date + INNER JOIN qt_competitors c ON c.id = plc.competitor_id AND c.is_active = 1 + ORDER BY plc.competitor_id, plc.partnumber ASC + `).Scan("es).Error + return quotes, err +} + // CompetitorQuoteCounts holds aggregate quote statistics for one competitor. type CompetitorQuoteCounts struct { CompetitorID uint64 `gorm:"column:competitor_id"` diff --git a/internal/services/competitor_import.go b/internal/services/competitor_import.go index 78e9a84..c760c22 100644 --- a/internal/services/competitor_import.go +++ b/internal/services/competitor_import.go @@ -270,6 +270,111 @@ func (s *CompetitorImportService) Import( }, nil } +// RebuildPricelist creates a new competitor pricelist from the latest stored quotes of ALL +// active competitors. Prices are discounted per competitor before aggregation. +func (s *CompetitorImportService) RebuildPricelist(createdBy string, onProgress func(CompetitorImportProgress)) (*CompetitorImportResult, error) { + report := func(p CompetitorImportProgress) { + if onProgress != nil { + onProgress(p) + } + } + report(CompetitorImportProgress{Status: "loading", Message: "Загрузка котировок", Current: 10, Total: 100}) + + // Load all active competitors (need their discount_pct). + competitors, err := s.competitorRepo.List() + if err != nil { + return nil, fmt.Errorf("load competitors: %w", err) + } + discountByID := make(map[uint64]float64, len(competitors)) + for _, c := range competitors { + discountByID[c.ID] = c.ExpectedDiscountPct + } + + // Load latest quote per (competitor_id, partnumber) for all active competitors. + allQuotes, err := s.competitorRepo.GetLatestQuotesAllCompetitors() + if err != nil { + return nil, fmt.Errorf("load quotes: %w", err) + } + if len(allQuotes) == 0 { + return nil, fmt.Errorf("нет котировок конкурентов в базе") + } + + report(CompetitorImportProgress{Status: "resolving", Message: "Разрешение маппингов", Current: 30, Total: 100}) + + pnMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db) + if err != nil { + return nil, fmt.Errorf("load pn matcher: %w", err) + } + + // Aggregate effective prices per lot across all competitors. + lotPoints := make(map[string][]weightedPricePoint) + unmapped := 0 + for _, q := range allQuotes { + lots := pnMatcher.MatchLotsWithVendor(q.Partnumber, "") + if len(lots) == 0 { + unmapped++ + continue + } + discount := discountByID[q.CompetitorID] + effectivePrice := q.Price * (1 - discount/100) + for _, lotName := range lots { + lotPoints[lotName] = append(lotPoints[lotName], weightedPricePoint{ + price: effectivePrice, + weight: q.Qty, + }) + } + } + + items := make([]pricelistsvc.CreateItemInput, 0, len(lotPoints)) + for lotName, points := range lotPoints { + wm := weightedMedian(points) + items = append(items, pricelistsvc.CreateItemInput{ + LotName: lotName, + Price: wm, + PriceMethod: "weighted_median", + PricePeriodDays: 0, + }) + } + + if len(items) == 0 { + return &CompetitorImportResult{ + RowsTotal: len(allQuotes), + Unmapped: unmapped, + }, nil + } + + report(CompetitorImportProgress{Status: "pricelist", Message: "Создание прайслиста", Current: 55, Total: 100}) + + pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceCompetitor), items, func(p pricelistsvc.CreateProgress) { + current := 55 + int(float64(p.Current)/float64(p.Total)*40) + if current >= 100 { + current = 99 + } + report(CompetitorImportProgress{Status: "pricelist", Message: p.Message, Current: current, Total: 100}) + }) + if err != nil { + return nil, fmt.Errorf("create pricelist: %w", err) + } + + report(CompetitorImportProgress{ + Status: "completed", + Message: fmt.Sprintf("Прайслист создан: %d позиций, не сопоставлено p/n: %d", len(items), unmapped), + Current: 100, + Total: 100, + RowsTotal: len(allQuotes), + Unmapped: unmapped, + PricelistID: pl.ID, + PricelistVer: pl.Version, + }) + + return &CompetitorImportResult{ + RowsTotal: len(allQuotes), + Unmapped: unmapped, + PricelistID: pl.ID, + PricelistVer: pl.Version, + }, nil +} + // buildCompetitorPricelistItems resolves current p/n→lot mappings for the latest quotes // and aggregates prices using weighted median per lot. // Returns pricelist items and the count of p/ns with no mapping. diff --git a/web/static/js/admin_pricing.js b/web/static/js/admin_pricing.js index 08129e6..91a3fb5 100644 --- a/web/static/js/admin_pricing.js +++ b/web/static/js/admin_pricing.js @@ -1769,7 +1769,10 @@ async function createPricelist() { progressStats.textContent = ''; // Start background task - const resp = await fetch('/api/pricelists/create-with-progress', { + const url = currentPricelistSource === 'competitor' + ? '/api/competitors/pricelist' + : '/api/pricelists/create-with-progress'; + const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: currentPricelistSource })