- Drop `expected_discount_pct`, add `price_uplift DECIMAL(8,4) DEFAULT 1.3` to `qt_competitors` (migration 040); formula: effective_price = price / uplift - Extend `LoadLotMetrics` to return per-PN qty map (`pnQtysByLot`) - Add virtual fields `CompetitorNames`, `PriceSpreadPct`, `PartnumberQtys` to `PricelistItem`; populate via `enrichWarehouseItems` / `enrichCompetitorItems` - Competitor quotes filtered to qty > 0 before lot resolution - New "stock layout" on pricelist detail page for warehouse/competitor: Partnumbers column (PN + qty, only qty>0), Поставщик column, no Настройки/Доступно - Spread badge ±N% shown next to price for competitor rows - Bible updated: pricelist.md, history.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
11 KiB
Go
413 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"`
|
|
PriceUplift float64 `json:"price_uplift"`
|
|
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"
|
|
}
|
|
if req.PriceUplift <= 0 {
|
|
req.PriceUplift = 1.3
|
|
}
|
|
competitor := &models.Competitor{
|
|
Name: req.Name,
|
|
Code: req.Code,
|
|
DeliveryBasis: req.DeliveryBasis,
|
|
Currency: req.Currency,
|
|
PriceUplift: req.PriceUplift,
|
|
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"`
|
|
PriceUplift float64 `json:"price_uplift"`
|
|
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
|
|
}
|
|
if req.PriceUplift > 0 {
|
|
competitor.PriceUplift = req.PriceUplift
|
|
}
|
|
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})
|
|
}
|