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:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user