- 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>
408 lines
11 KiB
Go
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})
|
|
}
|