Files
PriceForge/internal/handlers/competitor.go
Mikhail Chusavitin c53c484bde Replace competitor discount with price_uplift; stock pricelist detail UI
- 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>
2026-03-13 12:58:41 +03:00

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})
}