Competitor pricelist: aggregate all competitors, rebuild without re-import

- 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 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-03-13 08:17:44 +03:00
parent 592d77e30b
commit ec182abe99
5 changed files with 182 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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(&quotes).Error
return quotes, err
}
// CompetitorQuoteCounts holds aggregate quote statistics for one competitor.
type CompetitorQuoteCounts struct {
CompetitorID uint64 `gorm:"column:competitor_id"`

View File

@@ -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.