Files
PriceForge/internal/handlers/competitor.go
Mikhail Chusavitin ec182abe99 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>
2026-03-13 08:17:44 +03:00

408 lines
11 KiB
Go

package handlers
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
"git.mchus.pro/mchus/priceforge/internal/models"
"git.mchus.pro/mchus/priceforge/internal/repository"
"git.mchus.pro/mchus/priceforge/internal/services"
"git.mchus.pro/mchus/priceforge/internal/tasks"
"github.com/gin-gonic/gin"
)
const maxCompetitorImportFileSize int64 = 25 * 1024 * 1024
type sampleCacheEntry struct {
content []byte
expiresAt time.Time
}
type CompetitorHandler struct {
repo *repository.CompetitorRepository
importService *services.CompetitorImportService
taskManager *tasks.Manager
dbUsername string
sampleCacheMu sync.Mutex
sampleCache map[string]sampleCacheEntry
}
func generateToken() string {
b := make([]byte, 16)
rand.Read(b) //nolint:errcheck
return hex.EncodeToString(b)
}
func NewCompetitorHandler(
repo *repository.CompetitorRepository,
importService *services.CompetitorImportService,
taskManager *tasks.Manager,
dbUsername string,
) *CompetitorHandler {
return &CompetitorHandler{
repo: repo,
importService: importService,
taskManager: taskManager,
dbUsername: dbUsername,
sampleCache: make(map[string]sampleCacheEntry),
}
}
// ParseHeaders parses the headers and preview rows from an uploaded xlsx file.
// Accepts: multipart file (first call) or form field "token" (subsequent calls for pagination).
// Returns: token, headers, rows (10 at a time), has_more, total_data_rows.
func (h *CompetitorHandler) ParseHeaders(c *gin.Context) {
var content []byte
var token string
// Try cached token first.
if tokenInput := c.PostForm("token"); tokenInput != "" {
h.sampleCacheMu.Lock()
entry, ok := h.sampleCache[tokenInput]
h.sampleCacheMu.Unlock()
if ok && time.Now().Before(entry.expiresAt) {
content = entry.content
token = tokenInput
}
}
// Fall back to uploaded file.
if content == nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCompetitorImportFileSize)
file, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file or token required"})
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
token = generateToken()
content = data
// Evict expired entries and store new one.
h.sampleCacheMu.Lock()
now := time.Now()
for k, v := range h.sampleCache {
if now.After(v.expiresAt) {
delete(h.sampleCache, k)
}
}
h.sampleCache[token] = sampleCacheEntry{content: content, expiresAt: now.Add(2 * time.Hour)}
h.sampleCacheMu.Unlock()
}
sheetNum := 1
if s := c.PostForm("sheet"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 1 {
sheetNum = n
}
}
headerRow := 1
if hr := c.PostForm("header_row"); hr != "" {
if n, err := strconv.Atoi(hr); err == nil && n >= 1 {
headerRow = n
}
}
offset := 0
if o := c.PostForm("offset"); o != "" {
if n, err := strconv.Atoi(o); err == nil && n >= 0 {
offset = n
}
}
result, err := services.ParseXLSXPreview(content, sheetNum, headerRow, offset, 10)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"all_rows": result.AllRows,
"header_row_num": result.HeaderRowNum,
"col_count": result.ColCount,
"has_more": result.HasMore,
"total_rows": result.TotalRows,
})
}
func (h *CompetitorHandler) List(c *gin.Context) {
competitors, err := h.repo.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
counts, _ := h.repo.GetQuoteCountsByCompetitor() // best-effort
countByID := make(map[uint64]repository.CompetitorQuoteCounts, len(counts))
for _, cnt := range counts {
countByID[cnt.CompetitorID] = cnt
}
type competitorWithCounts struct {
models.Competitor
UniquePN int64 `json:"unique_pn"`
TotalQuotes int64 `json:"total_quotes"`
}
result := make([]competitorWithCounts, len(competitors))
for i, comp := range competitors {
cnt := countByID[comp.ID]
result[i] = competitorWithCounts{
Competitor: comp,
UniquePN: cnt.UniquePN,
TotalQuotes: cnt.TotalQuotes,
}
}
c.JSON(http.StatusOK, result)
}
func (h *CompetitorHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
competitor, err := h.repo.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, competitor)
}
func (h *CompetitorHandler) Create(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Code string `json:"code" binding:"required"`
DeliveryBasis string `json:"delivery_basis"`
Currency string `json:"currency"`
ExpectedDiscountPct float64 `json:"expected_discount_pct"`
ColumnMapping []byte `json:"column_mapping"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.DeliveryBasis == "" {
req.DeliveryBasis = "DDP"
}
if req.Currency == "" {
req.Currency = "USD"
}
competitor := &models.Competitor{
Name: req.Name,
Code: req.Code,
DeliveryBasis: req.DeliveryBasis,
Currency: req.Currency,
ExpectedDiscountPct: req.ExpectedDiscountPct,
ColumnMapping: req.ColumnMapping,
IsActive: true,
}
if err := h.repo.Create(competitor); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, competitor)
}
func (h *CompetitorHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
competitor, err := h.repo.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req struct {
Name string `json:"name"`
DeliveryBasis string `json:"delivery_basis"`
Currency string `json:"currency"`
ExpectedDiscountPct float64 `json:"expected_discount_pct"`
ColumnMapping []byte `json:"column_mapping"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
competitor.Name = req.Name
}
if req.DeliveryBasis != "" {
competitor.DeliveryBasis = req.DeliveryBasis
}
if req.Currency != "" {
competitor.Currency = req.Currency
}
competitor.ExpectedDiscountPct = req.ExpectedDiscountPct
if req.ColumnMapping != nil {
competitor.ColumnMapping = req.ColumnMapping
}
if err := h.repo.Update(competitor); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, competitor)
}
func (h *CompetitorHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.repo.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (h *CompetitorHandler) SetActive(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.repo.SetActive(id, req.IsActive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCompetitorImportFileSize)
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
defer file.Close()
quoteDateStr := c.PostForm("quote_date")
quoteDate := time.Now()
if quoteDateStr != "" {
if d, err := time.Parse("2006-01-02", quoteDateStr); err == nil {
quoteDate = d
}
}
rateToUSD := 1.0
if rateStr := c.PostForm("rate_to_usd"); rateStr != "" {
if r, err := strconv.ParseFloat(rateStr, 64); err == nil && r > 0 {
rateToUSD = r
}
}
content := make([]byte, header.Size)
if _, err := file.Read(content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
createdBy := h.dbUsername
if createdBy == "" {
createdBy = "admin"
}
competitorID := id
filename := header.Filename
quoteDateCopy := quoteDate
rateCopy := rateToUSD
taskID := h.taskManager.Submit(tasks.TaskTypeCompetitorImport, func(_ context.Context, progressCb func(int, string)) (map[string]interface{}, error) {
result, impErr := h.importService.Import(competitorID, filename, content, quoteDateCopy, rateCopy, 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 impErr != nil {
return nil, impErr
}
var message string
if result.PricelistID > 0 {
message = fmt.Sprintf("Импорт завершён: добавлено %d позиций, не сопоставлено p/n: %d, прайслист %s", result.Inserted, result.Unmapped, result.PricelistVer)
} else {
message = fmt.Sprintf("Импорт завершён: добавлено %d позиций, не сопоставлено p/n: %d", result.Inserted, result.Unmapped)
}
progressCb(100, message)
return map[string]interface{}{
"rows_total": result.RowsTotal,
"inserted": result.Inserted,
"skipped": result.Skipped,
"ignored": result.Ignored,
"unmapped": result.Unmapped,
"pricelist_id": result.PricelistID,
"pricelist_version": result.PricelistVer,
}, nil
})
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
}