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 })