Add stock pricelist admin flow with mapping placeholders and warehouse details

This commit is contained in:
Mikhail Chusavitin
2026-02-06 19:37:12 +03:00
parent 65871a8b04
commit 919f4cb99c
14 changed files with 1941 additions and 66 deletions

View File

@@ -471,6 +471,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
var exportService *services.ExportService var exportService *services.ExportService
var alertService *alerts.Service var alertService *alerts.Service
var pricelistService *pricelist.Service var pricelistService *pricelist.Service
var stockImportService *services.StockImportService
var syncService *sync.Service var syncService *sync.Service
var projectService *services.ProjectService var projectService *services.ProjectService
@@ -484,6 +485,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
exportService = services.NewExportService(cfg.Export, categoryRepo) exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService) pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
stockImportService = services.NewStockImportService(mariaDB, pricelistService)
} else { } else {
// In offline mode, we still need to create services that don't require DB // In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing) pricingService = pricing.NewService(nil, nil, cfg.Pricing)
@@ -492,6 +494,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
exportService = services.NewExportService(cfg.Export, nil) exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing) alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil, nil) pricelistService = pricelist.NewService(nil, nil, nil, nil)
stockImportService = nil
} }
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -546,7 +549,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo) pricingHandler := handlers.NewPricingHandler(
mariaDB,
pricingService,
alertService,
componentRepo,
priceRepo,
statsRepo,
stockImportService,
local.GetDBUser(),
)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
@@ -1314,6 +1326,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)

View File

@@ -1,17 +1,20 @@
package handlers package handlers
import ( import (
"io"
"net/http" "net/http"
"os"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts" "git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -41,12 +44,14 @@ func calculateAverage(prices []float64) float64 {
} }
type PricingHandler struct { type PricingHandler struct {
db *gorm.DB db *gorm.DB
pricingService *pricing.Service pricingService *pricing.Service
alertService *alerts.Service alertService *alerts.Service
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository statsRepo *repository.StatsRepository
stockImportService *services.StockImportService
dbUsername string
} }
func NewPricingHandler( func NewPricingHandler(
@@ -56,14 +61,18 @@ func NewPricingHandler(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository, priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
stockImportService *services.StockImportService,
dbUsername string,
) *PricingHandler { ) *PricingHandler {
return &PricingHandler{ return &PricingHandler{
db: db, db: db,
pricingService: pricingService, pricingService: pricingService,
alertService: alertService, alertService: alertService,
componentRepo: componentRepo, componentRepo: componentRepo,
priceRepo: priceRepo, priceRepo: priceRepo,
statsRepo: statsRepo, statsRepo: statsRepo,
stockImportService: stockImportService,
dbUsername: dbUsername,
} }
} }
@@ -936,3 +945,167 @@ func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []stri
return result return result
} }
func (h *PricingHandler) ImportStockLog(c *gin.Context) {
if h.stockImportService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Импорт склада доступен только в онлайн режиме",
"offline": true,
})
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
return
}
modTime := time.Now()
if statter, ok := file.(interface{ Stat() (os.FileInfo, error) }); ok {
if st, statErr := statter.Stat(); statErr == nil {
modTime = st.ModTime()
}
}
flusher, ok := c.Writer.(http.Flusher)
if !ok {
result, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, nil)
if impErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": impErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "completed",
"rows_total": result.RowsTotal,
"valid_rows": result.ValidRows,
"inserted": result.Inserted,
"deleted": result.Deleted,
"unmapped": result.Unmapped,
"conflicts": result.Conflicts,
"fallback_matches": result.FallbackMatches,
"parse_errors": result.ParseErrors,
"import_date": result.ImportDate.Format("2006-01-02"),
"warehouse_pricelist_id": result.WarehousePLID,
"warehouse_pricelist_version": result.WarehousePLVer,
})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
send := func(p gin.H) {
c.SSEvent("progress", p)
flusher.Flush()
}
send(gin.H{"status": "starting", "message": "Запуск импорта"})
_, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, func(p services.StockImportProgress) {
send(gin.H{
"status": p.Status,
"message": p.Message,
"current": p.Current,
"total": p.Total,
"rows_total": p.RowsTotal,
"valid_rows": p.ValidRows,
"inserted": p.Inserted,
"deleted": p.Deleted,
"unmapped": p.Unmapped,
"conflicts": p.Conflicts,
"fallback_matches": p.FallbackMatches,
"parse_errors": p.ParseErrors,
"import_date": p.ImportDate,
"warehouse_pricelist_id": p.PricelistID,
"warehouse_pricelist_version": p.PricelistVer,
})
})
if impErr != nil {
send(gin.H{"status": "error", "message": impErr.Error()})
return
}
}
func (h *PricingHandler) ListStockMappings(c *gin.Context) {
if h.stockImportService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search")
rows, total, err := h.stockImportService.ListMappings(page, perPage, search)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": rows,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
if h.stockImportService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
var req struct {
Partnumber string `json:"partnumber" binding:"required"`
LotName string `json:"lot_name" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stockImportService.UpsertMapping(req.Partnumber, req.LotName, req.Description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "mapping saved"})
}
func (h *PricingHandler) DeleteStockMapping(c *gin.Context) {
if h.stockImportService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
partnumber := c.Param("partnumber")
deleted, err := h.stockImportService.DeleteMapping(partnumber)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}

View File

@@ -37,3 +37,31 @@ type Supplier struct {
func (Supplier) TableName() string { func (Supplier) TableName() string {
return "supplier" return "supplier"
} }
// StockLog stores warehouse stock snapshots imported from external files.
type StockLog struct {
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
Lot string `gorm:"column:lot;size:255;not null"`
Supplier *string `gorm:"column:supplier;size:255"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`
Quality *string `gorm:"column:quality;size:255"`
Comments *string `gorm:"column:comments;size:15000"`
Vendor *string `gorm:"column:vendor;size:255"`
Qty *float64 `gorm:"column:qty"`
}
func (StockLog) TableName() string {
return "stock_log"
}
// LotPartnumber maps external part numbers to internal lots.
type LotPartnumber struct {
Partnumber string `gorm:"column:partnumber;size:255;primaryKey"`
LotName string `gorm:"column:lot_name;size:255;primaryKey"`
Description *string `gorm:"column:description;size:10000"`
}
func (LotPartnumber) TableName() string {
return "lot_partnumbers"
}

View File

@@ -65,8 +65,10 @@ type PricelistItem struct {
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"` MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
// Virtual fields for display // Virtual fields for display
LotDescription string `gorm:"-" json:"lot_description,omitempty"` LotDescription string `gorm:"-" json:"lot_description,omitempty"`
Category string `gorm:"-" json:"category,omitempty"` Category string `gorm:"-" json:"category,omitempty"`
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
} }
func (PricelistItem) TableName() string { func (PricelistItem) TableName() string {

View File

@@ -240,9 +240,86 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
} }
} }
var pl models.Pricelist
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil && pl.Source == string(models.PricelistSourceWarehouse) {
if err := r.enrichWarehouseItems(items); err != nil {
return nil, 0, fmt.Errorf("enriching warehouse items: %w", err)
}
}
return items, total, nil return items, total, nil
} }
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
}
lots := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := seen[lot]; ok {
continue
}
seen[lot] = struct{}{}
lots = append(lots, lot)
}
if len(lots) == 0 {
return nil
}
type lotQty struct {
Lot string
Qty float64
}
var qtyRows []lotQty
if err := r.db.Model(&models.StockLog{}).
Select("lot, COALESCE(SUM(qty), 0) AS qty").
Where("lot IN ?", lots).
Group("lot").
Scan(&qtyRows).Error; err != nil {
return err
}
qtyByLot := make(map[string]float64, len(qtyRows))
for _, row := range qtyRows {
qtyByLot[row.Lot] = row.Qty
}
var mappings []models.LotPartnumber
if err := r.db.Where("lot_name IN ? AND TRIM(lot_name) <> ''", lots).
Order("partnumber ASC").
Find(&mappings).Error; err != nil {
return err
}
partnumbersByLot := make(map[string][]string, len(lots))
seenPair := make(map[string]struct{}, len(mappings))
for _, m := range mappings {
lot := strings.TrimSpace(m.LotName)
pn := strings.TrimSpace(m.Partnumber)
if lot == "" || pn == "" {
continue
}
key := lot + "\x00" + strings.ToLower(pn)
if _, ok := seenPair[key]; ok {
continue
}
seenPair[key] = struct{}{}
partnumbersByLot[lot] = append(partnumbersByLot[lot], pn)
}
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
q := qty
items[i].AvailableQty = &q
}
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
}
return nil
}
// GetPriceForLot returns item price for a lot within a pricelist. // GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) { func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem var item models.PricelistItem
@@ -265,17 +342,18 @@ func (r *PricelistRepository) GenerateVersion() (string, error) {
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source. // GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) { func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
prefix := versionPrefixBySource(source)
var last models.Pricelist var last models.Pricelist
err := r.db.Model(&models.Pricelist{}). err := r.db.Model(&models.Pricelist{}).
Select("version"). Select("version").
Where("source = ? AND version LIKE ?", source, today+"-%"). Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
Order("version DESC"). Order("version DESC").
Limit(1). Limit(1).
Take(&last).Error Take(&last).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Sprintf("%s-001", today), nil return fmt.Sprintf("%s-%s-001", prefix, today), nil
} }
return "", fmt.Errorf("loading latest today's pricelist version: %w", err) return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
} }
@@ -290,7 +368,18 @@ func (r *PricelistRepository) GenerateVersionBySource(source string) (string, er
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err) return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
} }
return fmt.Sprintf("%s-%03d", today, n+1), nil return fmt.Sprintf("%s-%s-%03d", prefix, today, n+1), nil
}
func versionPrefixBySource(source string) string {
switch models.NormalizePricelistSource(source) {
case models.PricelistSourceWarehouse:
return "S"
case models.PricelistSourceCompetitor:
return "B"
default:
return "E"
}
} }
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source. // GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.

View File

@@ -19,7 +19,7 @@ func TestGenerateVersion_FirstOfDay(t *testing.T) {
} }
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
want := fmt.Sprintf("%s-001", today) want := fmt.Sprintf("E-%s-001", today)
if version != want { if version != want {
t.Fatalf("expected %s, got %s", want, version) t.Fatalf("expected %s, got %s", want, version)
} }
@@ -30,8 +30,8 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{ seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true}, {Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-001", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true}, {Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-003", today), CreatedBy: "test", IsActive: true},
} }
for _, pl := range seed { for _, pl := range seed {
if err := repo.Create(&pl); err != nil { if err := repo.Create(&pl); err != nil {
@@ -44,7 +44,7 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
t.Fatalf("GenerateVersionBySource returned error: %v", err) t.Fatalf("GenerateVersionBySource returned error: %v", err)
} }
want := fmt.Sprintf("%s-004", today) want := fmt.Sprintf("E-%s-004", today)
if version != want { if version != want {
t.Fatalf("expected %s, got %s", want, version) t.Fatalf("expected %s, got %s", want, version)
} }
@@ -55,8 +55,8 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{ seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-009", today), CreatedBy: "test", IsActive: true}, {Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-009", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("%s-002", today), CreatedBy: "test", IsActive: true}, {Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("S-%s-002", today), CreatedBy: "test", IsActive: true},
} }
for _, pl := range seed { for _, pl := range seed {
if err := repo.Create(&pl); err != nil { if err := repo.Create(&pl); err != nil {
@@ -69,7 +69,7 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
t.Fatalf("GenerateVersionBySource returned error: %v", err) t.Fatalf("GenerateVersionBySource returned error: %v", err)
} }
want := fmt.Sprintf("%s-003", today) want := fmt.Sprintf("S-%s-003", today)
if version != want { if version != want {
t.Fatalf("expected %s, got %s", want, version) t.Fatalf("expected %s, got %s", want, version)
} }

View File

@@ -0,0 +1,989 @@
package services
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type StockImportProgress struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"`
RowsTotal int `json:"rows_total,omitempty"`
ValidRows int `json:"valid_rows,omitempty"`
Inserted int `json:"inserted,omitempty"`
Deleted int64 `json:"deleted,omitempty"`
Unmapped int `json:"unmapped,omitempty"`
Conflicts int `json:"conflicts,omitempty"`
FallbackMatches int `json:"fallback_matches,omitempty"`
ParseErrors int `json:"parse_errors,omitempty"`
ImportDate string `json:"import_date,omitempty"`
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
}
type StockImportResult struct {
RowsTotal int
ValidRows int
Inserted int
Deleted int64
Unmapped int
Conflicts int
FallbackMatches int
ParseErrors int
ImportDate time.Time
WarehousePLID uint
WarehousePLVer string
}
type pendingMapping struct {
Partnumber string
Description string
}
type StockImportService struct {
db *gorm.DB
pricelistSvc *pricelistsvc.Service
}
func NewStockImportService(db *gorm.DB, pricelistSvc *pricelistsvc.Service) *StockImportService {
return &StockImportService{
db: db,
pricelistSvc: pricelistSvc,
}
}
type stockImportRow struct {
Folder string
Article string
Description string
Vendor string
Price float64
Qty float64
}
func (s *StockImportService) Import(
filename string,
content []byte,
fileModTime time.Time,
createdBy string,
onProgress func(StockImportProgress),
) (*StockImportResult, error) {
if s.db == nil {
return nil, fmt.Errorf("offline mode: stock import unavailable")
}
if len(content) == 0 {
return nil, fmt.Errorf("empty file")
}
report := func(p StockImportProgress) {
if onProgress != nil {
onProgress(p)
}
}
report(StockImportProgress{Status: "starting", Message: "Запуск импорта", Current: 0, Total: 100})
rows, err := parseStockRows(filename, content)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no rows parsed")
}
report(StockImportProgress{Status: "parsing", Message: "Файл распарсен", RowsTotal: len(rows), Current: 10, Total: 100})
importDate := detectImportDate(content, filename, fileModTime)
report(StockImportProgress{
Status: "parsing",
Message: "Дата импорта определена",
ImportDate: importDate.Format("2006-01-02"),
Current: 15,
Total: 100,
})
resolver, err := s.newLotResolver()
if err != nil {
return nil, err
}
var (
records []models.StockLog
unmapped int
conflicts int
fallbackMatches int
parseErrors int
pendingByPN = make(map[string]pendingMapping)
)
for _, row := range rows {
if strings.TrimSpace(row.Article) == "" {
parseErrors++
continue
}
lot, matchType, resolveErr := resolver.resolve(row.Article)
if resolveErr != nil {
trimmedPN := strings.TrimSpace(row.Article)
if trimmedPN != "" {
key := normalizeKey(trimmedPN)
if key != "" {
candidate := pendingMapping{
Partnumber: trimmedPN,
Description: strings.TrimSpace(row.Description),
}
if prev, ok := pendingByPN[key]; !ok || (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") {
pendingByPN[key] = candidate
}
}
}
if errors.Is(resolveErr, errResolveConflict) {
conflicts++
} else {
unmapped++
}
continue
}
if matchType == "article_exact" || matchType == "prefix" {
fallbackMatches++
}
var comments *string
if trimmed := strings.TrimSpace(row.Description); trimmed != "" {
comments = &trimmed
}
var vendor *string
if trimmed := strings.TrimSpace(row.Vendor); trimmed != "" {
vendor = &trimmed
}
qty := row.Qty
records = append(records, models.StockLog{
Lot: lot,
Date: importDate,
Price: row.Price,
Comments: comments,
Vendor: vendor,
Qty: &qty,
})
}
if len(pendingByPN) > 0 {
pending := make([]pendingMapping, 0, len(pendingByPN))
for _, m := range pendingByPN {
pending = append(pending, m)
}
if err := s.upsertPendingMappings(pending); err != nil {
return nil, err
}
}
if len(records) == 0 {
return nil, fmt.Errorf("no valid rows after mapping")
}
report(StockImportProgress{
Status: "mapping",
Message: "Сопоставление article -> lot завершено",
RowsTotal: len(rows),
ValidRows: len(records),
Unmapped: unmapped,
Conflicts: conflicts,
FallbackMatches: fallbackMatches,
ParseErrors: parseErrors,
Current: 40,
Total: 100,
})
deleted, inserted, err := s.replaceStockLogs(records)
if err != nil {
return nil, err
}
report(StockImportProgress{
Status: "writing",
Message: "Данные stock_log обновлены",
Inserted: inserted,
Deleted: deleted,
Current: 60,
Total: 100,
ImportDate: importDate.Format("2006-01-02"),
})
items, err := s.buildWarehousePricelistItems()
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("stock_log does not contain positive prices for warehouse pricelist")
}
if createdBy == "" {
createdBy = "unknown"
}
report(StockImportProgress{Status: "recalculating_warehouse", Message: "Создание warehouse прайслиста", Current: 70, Total: 100})
var warehousePLID uint
var warehousePLVer string
if s.pricelistSvc == nil {
return nil, fmt.Errorf("pricelist service unavailable")
}
pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) {
report(StockImportProgress{
Status: "recalculating_warehouse",
Message: p.Message,
Current: 70 + int(float64(p.Current)*0.3),
Total: 100,
})
})
if err != nil {
return nil, err
}
warehousePLID = pl.ID
warehousePLVer = pl.Version
result := &StockImportResult{
RowsTotal: len(rows),
ValidRows: len(records),
Inserted: inserted,
Deleted: deleted,
Unmapped: unmapped,
Conflicts: conflicts,
FallbackMatches: fallbackMatches,
ParseErrors: parseErrors,
ImportDate: importDate,
WarehousePLID: warehousePLID,
WarehousePLVer: warehousePLVer,
}
report(StockImportProgress{
Status: "completed",
Message: "Импорт завершен",
RowsTotal: result.RowsTotal,
ValidRows: result.ValidRows,
Inserted: result.Inserted,
Deleted: result.Deleted,
Unmapped: result.Unmapped,
Conflicts: result.Conflicts,
FallbackMatches: result.FallbackMatches,
ParseErrors: result.ParseErrors,
ImportDate: result.ImportDate.Format("2006-01-02"),
PricelistID: result.WarehousePLID,
PricelistVer: result.WarehousePLVer,
Current: 100,
Total: 100,
})
return result, nil
}
func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64, int, error) {
var deleted int64
err := s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Exec("DELETE FROM stock_log")
if res.Error != nil {
return res.Error
}
deleted = res.RowsAffected
if err := tx.CreateInBatches(records, 500).Error; err != nil {
return err
}
return nil
})
if err != nil {
return 0, 0, err
}
return deleted, len(records), nil
}
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
var logs []models.StockLog
if err := s.db.Select("lot, price").Where("price > 0").Find(&logs).Error; err != nil {
return nil, err
}
grouped := make(map[string][]float64)
for _, l := range logs {
lot := strings.TrimSpace(l.Lot)
if lot == "" || l.Price <= 0 {
continue
}
grouped[lot] = append(grouped[lot], l.Price)
}
items := make([]pricelistsvc.CreateItemInput, 0, len(grouped))
for lot, prices := range grouped {
price := median(prices)
if price <= 0 {
continue
}
items = append(items, pricelistsvc.CreateItemInput{
LotName: lot,
Price: price,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].LotName < items[j].LotName
})
return items, nil
}
func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("offline mode: mappings unavailable")
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 500 {
perPage = 500
}
offset := (page - 1) * perPage
query := s.db.Model(&models.LotPartnumber{})
if search = strings.TrimSpace(search); search != "" {
like := "%" + search + "%"
query = query.Where("partnumber LIKE ? OR lot_name LIKE ? OR description LIKE ?", like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []models.LotPartnumber
if err := query.Order("CASE WHEN TRIM(lot_name) = '' THEN 0 ELSE 1 END, partnumber ASC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (s *StockImportService) UpsertMapping(partnumber, lotName, description string) error {
if s.db == nil {
return fmt.Errorf("offline mode: mappings unavailable")
}
partnumber = strings.TrimSpace(partnumber)
lotName = strings.TrimSpace(lotName)
description = strings.TrimSpace(description)
if partnumber == "" || lotName == "" {
return fmt.Errorf("partnumber and lot_name are required")
}
var lotCount int64
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
return err
}
if lotCount == 0 {
return fmt.Errorf("lot not found: %s", lotName)
}
return s.db.Transaction(func(tx *gorm.DB) error {
var existing []models.LotPartnumber
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Find(&existing).Error; err != nil {
return err
}
if description == "" {
for _, row := range existing {
if row.Description != nil && strings.TrimSpace(*row.Description) != "" {
description = strings.TrimSpace(*row.Description)
break
}
}
}
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Delete(&models.LotPartnumber{}).Error; err != nil {
return err
}
var descPtr *string
if description != "" {
descPtr = &description
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.LotPartnumber{
Partnumber: partnumber,
LotName: lotName,
Description: descPtr,
}).Error
})
}
func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) {
if s.db == nil {
return 0, fmt.Errorf("offline mode: mappings unavailable")
}
partnumber = strings.TrimSpace(partnumber)
if partnumber == "" {
return 0, fmt.Errorf("partnumber is required")
}
res := s.db.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Delete(&models.LotPartnumber{})
return res.RowsAffected, res.Error
}
func (s *StockImportService) upsertPendingMappings(rows []pendingMapping) error {
if s.db == nil || len(rows) == 0 {
return nil
}
return s.db.Transaction(func(tx *gorm.DB) error {
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
desc := strings.TrimSpace(row.Description)
var existing []models.LotPartnumber
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil {
return err
}
if len(existing) == 0 {
var descPtr *string
if desc != "" {
descPtr = &desc
}
if err := tx.Create(&models.LotPartnumber{
Partnumber: pn,
LotName: "",
Description: descPtr,
}).Error; err != nil {
return err
}
continue
}
if desc == "" {
continue
}
needsDescription := true
for _, item := range existing {
if item.Description != nil && strings.TrimSpace(*item.Description) != "" {
needsDescription = false
break
}
}
if needsDescription {
if err := tx.Model(&models.LotPartnumber{}).
Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).
Update("description", desc).Error; err != nil {
return err
}
}
}
return nil
})
}
var (
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
errResolveConflict = errors.New("multiple lot matches")
errResolveNotFound = errors.New("lot not found")
)
func parseStockRows(filename string, content []byte) ([]stockImportRow, error) {
switch strings.ToLower(filepath.Ext(filename)) {
case ".mxl":
return parseMXLRows(content)
case ".xlsx":
return parseXLSXRows(content)
default:
return nil, fmt.Errorf("unsupported file format: %s", filepath.Ext(filename))
}
}
func parseMXLRows(content []byte) ([]stockImportRow, error) {
text := string(content)
matches := mxlCellRe.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil, fmt.Errorf("mxl parsing failed: no cells found")
}
rows := make([]map[int]string, 0, 128)
current := map[int]string{}
for _, m := range matches {
val := strings.ReplaceAll(m[1], `""`, `"`)
col, err := strconv.Atoi(m[2])
if err != nil {
continue
}
if col == 1 && len(current) > 0 {
rows = append(rows, current)
current = map[int]string{}
}
current[col] = strings.TrimSpace(val)
}
if len(current) > 0 {
rows = append(rows, current)
}
result := make([]stockImportRow, 0, len(rows))
for _, r := range rows {
article := strings.TrimSpace(r[2])
if article == "" || strings.EqualFold(article, "Артикул") {
continue
}
price, err := parseLocalizedFloat(r[5])
if err != nil {
continue
}
qty, err := parseLocalizedFloat(r[6])
if err != nil {
qty = 0
}
result = append(result, stockImportRow{
Folder: strings.TrimSpace(r[1]),
Article: article,
Description: strings.TrimSpace(r[3]),
Vendor: strings.TrimSpace(r[4]),
Price: price,
Qty: qty,
})
}
return result, nil
}
func parseXLSXRows(content []byte) ([]stockImportRow, error) {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return nil, fmt.Errorf("opening xlsx: %w", err)
}
sharedStrings, _ := readSharedStrings(zr)
sheetPath := firstWorksheetPath(zr)
if sheetPath == "" {
return nil, fmt.Errorf("xlsx parsing failed: worksheet not found")
}
sheetData, err := readZipFile(zr, sheetPath)
if err != nil {
return nil, err
}
type xlsxInline struct {
T string `xml:"t"`
}
type xlsxCell struct {
R string `xml:"r,attr"`
T string `xml:"t,attr"`
V string `xml:"v"`
IS *xlsxInline `xml:"is"`
}
type xlsxRow struct {
C []xlsxCell `xml:"c"`
}
type xlsxSheet struct {
Rows []xlsxRow `xml:"sheetData>row"`
}
var ws xlsxSheet
if err := xml.Unmarshal(sheetData, &ws); err != nil {
return nil, fmt.Errorf("decode worksheet: %w", err)
}
grid := make([]map[int]string, 0, len(ws.Rows))
for _, r := range ws.Rows {
rowMap := make(map[int]string, len(r.C))
for _, c := range r.C {
colIdx := excelRefColumn(c.R)
if colIdx < 0 {
continue
}
inlineText := ""
if c.IS != nil {
inlineText = c.IS.T
}
rowMap[colIdx] = decodeXLSXCell(c.T, c.V, inlineText, sharedStrings)
}
grid = append(grid, rowMap)
}
headerRow := -1
headers := map[string]int{}
for i, row := range grid {
for idx, val := range row {
norm := normalizeHeader(val)
switch norm {
case "папка", "артикул", "описание", "вендор", "стоимость", "свободно":
headers[norm] = idx
}
}
_, hasArticle := headers["артикул"]
_, hasPrice := headers["стоимость"]
if hasArticle && hasPrice {
headerRow = i
break
}
}
if headerRow < 0 {
return nil, fmt.Errorf("xlsx parsing failed: header row not found")
}
result := make([]stockImportRow, 0, len(grid)-headerRow-1)
idxFolder, hasFolder := headers["папка"]
idxArticle := headers["артикул"]
idxDesc, hasDesc := headers["описание"]
idxVendor, hasVendor := headers["вендор"]
idxPrice := headers["стоимость"]
idxQty, hasQty := headers["свободно"]
for i := headerRow + 1; i < len(grid); i++ {
row := grid[i]
article := strings.TrimSpace(row[idxArticle])
if article == "" {
continue
}
price, err := parseLocalizedFloat(row[idxPrice])
if err != nil {
continue
}
qty := 0.0
if hasQty {
qty, err = parseLocalizedFloat(row[idxQty])
if err != nil {
qty = 0
}
}
folder := ""
if hasFolder {
folder = strings.TrimSpace(row[idxFolder])
}
description := ""
if hasDesc {
description = strings.TrimSpace(row[idxDesc])
}
vendor := ""
if hasVendor {
vendor = strings.TrimSpace(row[idxVendor])
}
result = append(result, stockImportRow{
Folder: folder,
Article: article,
Description: description,
Vendor: vendor,
Price: price,
Qty: qty,
})
}
return result, nil
}
func parseLocalizedFloat(value string) (float64, error) {
clean := strings.TrimSpace(value)
clean = strings.ReplaceAll(clean, "\u00a0", "")
clean = strings.ReplaceAll(clean, " ", "")
clean = strings.ReplaceAll(clean, ",", ".")
if clean == "" {
return 0, fmt.Errorf("empty number")
}
return strconv.ParseFloat(clean, 64)
}
func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time {
if d, ok := extractDateFromText(string(content)); ok {
return d
}
if d, ok := extractDateFromFilename(filename); ok {
return d
}
if !fileModTime.IsZero() {
return normalizeDate(fileModTime)
}
return normalizeDate(time.Now())
}
func extractDateFromText(text string) (time.Time, bool) {
if m := reISODate.FindStringSubmatch(text); len(m) == 4 {
d, err := time.Parse("2006-01-02", m[0])
if err == nil {
return normalizeDate(d), true
}
}
if m := reRuDate.FindStringSubmatch(text); len(m) == 4 {
d, err := time.Parse("02.01.2006", m[0])
if err == nil {
return normalizeDate(d), true
}
}
return time.Time{}, false
}
func extractDateFromFilename(filename string) (time.Time, bool) {
base := filepath.Base(filename)
if m := reISODate.FindStringSubmatch(base); len(m) == 4 {
d, err := time.Parse("2006-01-02", m[0])
if err == nil {
return normalizeDate(d), true
}
}
if m := reRuDate.FindStringSubmatch(base); len(m) == 4 {
d, err := time.Parse("02.01.2006", m[0])
if err == nil {
return normalizeDate(d), true
}
}
return time.Time{}, false
}
func normalizeDate(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
func median(values []float64) float64 {
if len(values) == 0 {
return 0
}
c := append([]float64(nil), values...)
sort.Float64s(c)
n := len(c)
if n%2 == 0 {
return (c[n/2-1] + c[n/2]) / 2
}
return c[n/2]
}
type lotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
func (s *StockImportService) newLotResolver() (*lotResolver, error) {
var mappings []models.LotPartnumber
if err := s.db.Find(&mappings).Error; err != nil {
return nil, err
}
partnumberToLots := make(map[string][]string, len(mappings))
for _, m := range mappings {
p := normalizeKey(m.Partnumber)
if p == "" || strings.TrimSpace(m.LotName) == "" {
continue
}
partnumberToLots[p] = append(partnumberToLots[p], m.LotName)
}
var lots []models.Lot
if err := s.db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, err
}
exactLots := make(map[string]string, len(lots))
allLots := make([]string, 0, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
k := normalizeKey(name)
exactLots[k] = name
allLots = append(allLots, name)
}
sort.Slice(allLots, func(i, j int) bool {
li := len([]rune(allLots[i]))
lj := len([]rune(allLots[j]))
if li == lj {
return allLots[i] < allLots[j]
}
return li > lj
})
return &lotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}, nil
}
func (r *lotResolver) resolve(article string) (string, string, error) {
key := normalizeKey(article)
if key == "" {
return "", "", errResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
uniq := uniqueStrings(mapped)
if len(uniq) == 1 {
return uniq[0], "mapping_table", nil
}
return "", "", errResolveConflict
}
if lot, ok := r.exactLots[key]; ok {
return lot, "article_exact", nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := normalizeKey(lot)
if lotKey == "" {
continue
}
if strings.HasPrefix(key, lotKey) {
l := len([]rune(lotKey))
if l > bestLen {
best = lot
bestLen = l
tie = false
} else if l == bestLen && !strings.EqualFold(best, lot) {
tie = true
}
}
}
if best == "" {
return "", "", errResolveNotFound
}
if tie {
return "", "", errResolveConflict
}
return best, "prefix", nil
}
func normalizeKey(v string) string {
return strings.ToLower(strings.TrimSpace(v))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
k := strings.ToLower(v)
if seen[k] {
continue
}
seen[k] = true
out = append(out, v)
}
sort.Strings(out)
return out
}
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
return nil, fmt.Errorf("zip entry not found: %s", name)
}
func firstWorksheetPath(zr *zip.Reader) string {
candidates := make([]string, 0, 4)
for _, f := range zr.File {
if strings.HasPrefix(f.Name, "xl/worksheets/") && strings.HasSuffix(f.Name, ".xml") {
candidates = append(candidates, f.Name)
}
}
if len(candidates) == 0 {
return ""
}
sort.Strings(candidates)
for _, c := range candidates {
if strings.HasSuffix(c, "sheet1.xml") {
return c
}
}
return candidates[0]
}
func readSharedStrings(zr *zip.Reader) ([]string, error) {
data, err := readZipFile(zr, "xl/sharedStrings.xml")
if err != nil {
return nil, err
}
type richRun struct {
Text string `xml:"t"`
}
type si struct {
Text string `xml:"t"`
Runs []richRun `xml:"r"`
}
type sst struct {
Items []si `xml:"si"`
}
var parsed sst
if err := xml.Unmarshal(data, &parsed); err != nil {
return nil, err
}
values := make([]string, 0, len(parsed.Items))
for _, item := range parsed.Items {
if item.Text != "" {
values = append(values, item.Text)
continue
}
var b strings.Builder
for _, run := range item.Runs {
b.WriteString(run.Text)
}
values = append(values, b.String())
}
return values, nil
}
func decodeXLSXCell(cellType, value, inlineText string, sharedStrings []string) string {
switch cellType {
case "s":
idx, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil && idx >= 0 && idx < len(sharedStrings) {
return strings.TrimSpace(sharedStrings[idx])
}
case "inlineStr":
return strings.TrimSpace(inlineText)
default:
return strings.TrimSpace(value)
}
return strings.TrimSpace(value)
}
func excelRefColumn(ref string) int {
if ref == "" {
return -1
}
var letters []rune
for _, r := range ref {
if r >= 'A' && r <= 'Z' {
letters = append(letters, r)
} else if r >= 'a' && r <= 'z' {
letters = append(letters, r-'a'+'A')
} else {
break
}
}
if len(letters) == 0 {
return -1
}
col := 0
for _, r := range letters {
col = col*26 + int(r-'A'+1)
}
return col - 1
}
func normalizeHeader(v string) string {
return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(v, "\u00a0", " ")))
}

View File

@@ -0,0 +1,256 @@
package services
import (
"archive/zip"
"bytes"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestParseMXLRows(t *testing.T) {
content := strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
`{16,2,{1,1,{"ru","10"}},0},6,`,
}, "\n")
rows, err := parseMXLRows([]byte(content))
if err != nil {
t.Fatalf("parseMXLRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Article != "CPU_X" {
t.Fatalf("unexpected article: %s", rows[0].Article)
}
if rows[0].Price != 125.50 {
t.Fatalf("unexpected price: %v", rows[0].Price)
}
if rows[0].Qty != 10 {
t.Fatalf("unexpected qty: %v", rows[0].Qty)
}
}
func TestParseXLSXRows(t *testing.T) {
xlsx := buildMinimalXLSX(t, []string{
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
}, []string{
"Серверы", "CPU_A", "Процессор", "AMD", "99,25", "7",
})
rows, err := parseXLSXRows(xlsx)
if err != nil {
t.Fatalf("parseXLSXRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Article != "CPU_A" {
t.Fatalf("unexpected article: %s", rows[0].Article)
}
if rows[0].Price != 99.25 {
t.Fatalf("unexpected price: %v", rows[0].Price)
}
}
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
r := &lotResolver{
partnumberToLots: map[string][]string{
"pn-1": {"LOT_MAPPED"},
"pn-conflict": {"LOT_A", "LOT_B"},
},
exactLots: map[string]string{
"cpu_a": "CPU_A",
},
allLots: []string{"CPU_A_LONG", "CPU_A", "ABC ", "ABC\t"},
}
lot, typ, err := r.resolve("pn-1")
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = r.resolve("cpu_a")
if err != nil || lot != "CPU_A" || typ != "article_exact" {
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = r.resolve("cpu_a_long_suffix")
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
_, _, err = r.resolve("abx")
if err == nil {
t.Fatalf("expected not found error")
}
_, _, err = r.resolve("pn-conflict")
if err == nil || err != errResolveConflict {
t.Fatalf("expected conflict, got %v", err)
}
}
func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
t.Fatalf("automigrate stock_log: %v", err)
}
existing := models.StockLog{
Lot: "CPU_A",
Date: time.Now(),
Price: 10,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
}
svc := NewStockImportService(db, nil)
headerOnly := []byte(strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
}, "\n"))
if _, err := svc.Import("test.mxl", headerOnly, time.Now(), "tester", nil); err == nil {
t.Fatalf("expected import error")
}
var count int64
if err := db.Model(&models.StockLog{}).Count(&count).Error; err != nil {
t.Fatalf("count stock_log: %v", err)
}
if count != 1 {
t.Fatalf("expected stock_log unchanged, got %d rows", count)
}
}
func TestReplaceStockLogs(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
t.Fatalf("automigrate stock_log: %v", err)
}
if err := db.Create(&models.StockLog{Lot: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
t.Fatalf("seed old row: %v", err)
}
svc := NewStockImportService(db, nil)
records := []models.StockLog{
{Lot: "NEW_1", Date: time.Now(), Price: 2},
{Lot: "NEW_2", Date: time.Now(), Price: 3},
}
deleted, inserted, err := svc.replaceStockLogs(records)
if err != nil {
t.Fatalf("replaceStockLogs: %v", err)
}
if deleted != 1 || inserted != 2 {
t.Fatalf("unexpected replace stats deleted=%d inserted=%d", deleted, inserted)
}
var rows []models.StockLog
if err := db.Order("lot").Find(&rows).Error; err != nil {
t.Fatalf("read rows: %v", err)
}
if len(rows) != 2 || rows[0].Lot != "NEW_1" || rows[1].Lot != "NEW_2" {
t.Fatalf("unexpected rows after replace: %#v", rows)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}
func buildMinimalXLSX(t *testing.T, headers, values []string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
write := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
write("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`)
write("_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`)
write("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
</sheets>
</workbook>`)
write("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`)
makeCell := func(ref, value string) string {
escaped := strings.ReplaceAll(value, "&", "&amp;")
escaped = strings.ReplaceAll(escaped, "<", "&lt;")
escaped = strings.ReplaceAll(escaped, ">", "&gt;")
return `<c r="` + ref + `" t="inlineStr"><is><t>` + escaped + `</t></is></c>`
}
cols := []string{"A", "B", "C", "D", "E", "F"}
var headerCells, valueCells strings.Builder
for i := 0; i < len(cols) && i < len(headers); i++ {
headerCells.WriteString(makeCell(cols[i]+"1", headers[i]))
}
for i := 0; i < len(cols) && i < len(values); i++ {
valueCells.WriteString(makeCell(cols[i]+"2", values[i]))
}
write("xl/worksheets/sheet1.xml", `<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">`+headerCells.String()+`</row>
<row r="2">`+valueCells.String()+`</row>
</sheetData>
</worksheet>`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS stock_log (
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lot VARCHAR(255) NOT NULL,
supplier VARCHAR(255) NULL,
date DATE NOT NULL,
price DECIMAL(12,2) NOT NULL,
quality VARCHAR(255) NULL,
comments TEXT NULL,
vendor VARCHAR(255) NULL,
qty DECIMAL(14,3) NULL,
INDEX idx_stock_log_lot_date (lot, date),
INDEX idx_stock_log_date (date),
INDEX idx_stock_log_vendor (vendor)
);

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS lot_partnumbers (
partnumber VARCHAR(255) NOT NULL,
lot_name VARCHAR(255) NOT NULL DEFAULT '',
description VARCHAR(10000) NULL,
PRIMARY KEY (partnumber, lot_name),
INDEX idx_lot_partnumbers_lot_name (lot_name)
);

View File

@@ -0,0 +1,25 @@
-- Add per-source pricelist bindings for configurations
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id,
ADD COLUMN IF NOT EXISTS competitor_pricelist_id BIGINT UNSIGNED NULL AFTER warehouse_pricelist_id,
ADD COLUMN IF NOT EXISTS disable_price_refresh BOOLEAN NOT NULL DEFAULT FALSE AFTER competitor_pricelist_id;
ALTER TABLE qt_configurations
ADD INDEX IF NOT EXISTS idx_qt_configurations_warehouse_pricelist_id (warehouse_pricelist_id),
ADD INDEX IF NOT EXISTS idx_qt_configurations_competitor_pricelist_id (competitor_pricelist_id);
-- Optional FK bindings (safe if re-run due IF NOT EXISTS on columns/indexes)
-- If your MariaDB version does not support IF NOT EXISTS for FK names, duplicate-FK errors are ignored by migration runner.
ALTER TABLE qt_configurations
ADD CONSTRAINT fk_qt_configurations_warehouse_pricelist_id
FOREIGN KEY (warehouse_pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
ALTER TABLE qt_configurations
ADD CONSTRAINT fk_qt_configurations_competitor_pricelist_id
FOREIGN KEY (competitor_pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;

View File

@@ -0,0 +1,25 @@
-- Allow placeholder mappings (partnumber without bound lot) and store import description.
ALTER TABLE lot_partnumbers
ADD COLUMN IF NOT EXISTS description VARCHAR(10000) NULL AFTER lot_name;
ALTER TABLE lot_partnumbers
MODIFY COLUMN lot_name VARCHAR(255) NOT NULL DEFAULT '';
-- Drop FK on lot_name if it exists to allow unresolved placeholders.
SET @lp_fk_name := (
SELECT kcu.CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE kcu
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'lot_partnumbers'
AND kcu.COLUMN_NAME = 'lot_name'
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1
);
SET @lp_drop_fk_sql := IF(
@lp_fk_name IS NULL,
'SELECT 1',
CONCAT('ALTER TABLE lot_partnumbers DROP FOREIGN KEY `', @lp_fk_name, '`')
);
PREPARE lp_stmt FROM @lp_drop_fk_sql;
EXECUTE lp_stmt;
DEALLOCATE PREPARE lp_stmt;

View File

@@ -8,8 +8,9 @@
<div class="flex justify-between items-center border-b pb-4 mb-4"> <div class="flex justify-between items-center border-b pb-4 mb-4">
<div class="flex gap-4"> <div class="flex gap-4">
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button> <button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button> <button onclick="loadTab('estimate')" id="btn-estimate" class="text-gray-600">Estimate</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button> <button onclick="loadTab('warehouse')" id="btn-warehouse" class="text-gray-600">Склад</button>
<button onclick="loadTab('competitor')" id="btn-competitor" class="text-gray-600">Конкуренты</button>
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button> <button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button> <button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div> </div>
@@ -58,8 +59,15 @@
<!-- Pricelists Tab Content (hidden by default) --> <!-- Pricelists Tab Content (hidden by default) -->
<div id="pricelists-tab-content" class="hidden"> <div id="pricelists-tab-content" class="hidden">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Прайслисты</h2> <div class="flex items-center gap-3">
<div id="pricelists-create-btn-container"></div> <h2 id="pricelists-title" class="text-xl font-semibold">Estimate</h2>
<button id="estimate-settings-btn" onclick="loadTab('component-settings')" class="hidden px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">
Настройка компонентов
</button>
</div>
<div class="flex items-center gap-3">
<div id="pricelists-create-btn-container"></div>
</div>
</div> </div>
<div class="bg-white rounded-lg shadow overflow-hidden"> <div class="bg-white rounded-lg shadow overflow-hidden">
@@ -84,6 +92,52 @@
</div> </div>
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div> <div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
<div id="stock-tools" class="mt-6 hidden space-y-6">
<div class="border rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Импорт stock_log</h3>
<div class="flex items-center gap-3">
<input type="file" id="stock-file-input" accept=".mxl,.xlsx" class="block w-full text-sm text-gray-700">
<button onclick="importStockFile()" class="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700">Импортировать</button>
</div>
<div id="stock-import-progress" class="hidden mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div class="flex justify-between text-sm mb-1">
<span id="stock-import-status">Запуск...</span>
<span id="stock-import-percent">0%</span>
</div>
<div class="w-full bg-blue-100 rounded-full h-3">
<div id="stock-import-bar" class="h-3 bg-blue-600 rounded-full transition-all duration-300" style="width:0%"></div>
</div>
<div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div>
</div>
</div>
<div class="border rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
<div class="flex items-center gap-2 mb-3">
<input id="mapping-partnumber" type="text" placeholder="partnumber" class="px-3 py-2 border rounded w-1/3">
<input id="mapping-lotname" type="text" placeholder="lot_name" class="px-3 py-2 border rounded w-1/3">
<button onclick="saveStockMapping()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Partnumber</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-mappings-body" class="bg-white divide-y divide-gray-200">
<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div>
</div>
</div>
</div> </div>
<!-- Sync Status Tab Content (hidden by default) --> <!-- Sync Status Tab Content (hidden by default) -->
@@ -239,6 +293,7 @@
<script> <script>
let currentTab = 'alerts'; let currentTab = 'alerts';
let currentPricelistSource = 'estimate';
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let perPage = 50; let perPage = 50;
@@ -252,6 +307,7 @@ let pricelistsCanWrite = false;
let isCreatingPricelist = false; let isCreatingPricelist = false;
let cachedDbUsername = null; let cachedDbUsername = null;
let syncUsersStatusTimer = null; let syncUsersStatusTimer = null;
let stockMappingsPage = 1;
async function loadTab(tab) { async function loadTab(tab) {
currentTab = tab; currentTab = tab;
@@ -263,36 +319,36 @@ async function loadTab(tab) {
} }
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600'; document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600'; document.getElementById('btn-estimate').className = (tab === 'estimate' || tab === 'component-settings') ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600'; document.getElementById('btn-warehouse').className = tab === 'warehouse' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-competitor').className = tab === 'competitor' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden'); document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab if (tab === 'estimate' || tab === 'warehouse' || tab === 'competitor') {
if (tab === 'components') { currentPricelistSource = tab === 'estimate' ? 'estimate' : (tab === 'warehouse' ? 'warehouse' : 'competitor');
document.getElementById('search-bar').className = 'mb-4';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden'; document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden'; document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = ''; document.getElementById('pricelists-tab-content').className = '';
document.getElementById('sync-status-tab-content').className = 'hidden'; document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = 'hidden'; document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected document.getElementById('pricelists-title').textContent = tab === 'estimate' ? 'Estimate' : (tab === 'warehouse' ? 'Склад' : 'Конкуренты');
checkPricelistWritePermission(); document.getElementById('estimate-settings-btn').classList.toggle('hidden', tab !== 'estimate');
loadPricelists(1); document.getElementById('stock-tools').classList.toggle('hidden', tab !== 'warehouse');
await checkPricelistWritePermission();
await loadPricelists(1, currentPricelistSource);
if (tab === 'warehouse') {
await loadStockMappings(1);
}
} else if (tab === 'component-settings') {
document.getElementById('search-bar').className = 'mb-4';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
await loadData();
} else if (tab === 'sync-status') { } else if (tab === 'sync-status') {
document.getElementById('search-bar').className = 'mb-4 hidden'; document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden'; document.getElementById('pagination').className = 'hidden';
@@ -307,6 +363,14 @@ async function loadTab(tab) {
} }
await loadUsersSyncStatus(); await loadUsersSyncStatus();
startSyncUsersStatusRefresh(); startSyncUsersStatusRefresh();
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
await loadData();
} else { } else {
document.getElementById('search-bar').className = 'mb-4 hidden'; document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden'; document.getElementById('pagination').className = 'hidden';
@@ -314,9 +378,6 @@ async function loadTab(tab) {
document.getElementById('pricelists-tab-content').className = 'hidden'; document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden'; document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = ''; document.getElementById('tab-content').className = '';
}
if (tab !== 'pricelists' && tab !== 'sync-status') {
await loadData(); await loadData();
} }
} }
@@ -356,7 +417,7 @@ async function loadData() {
totalPages = Math.ceil(data.total / perPage); totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []); renderAllConfigs(data.configurations || []);
updatePagination(data.total); updatePagination(data.total);
} else { } else if (currentTab === 'component-settings') {
let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage; let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) { if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch); url += '&search=' + encodeURIComponent(currentSearch);
@@ -373,6 +434,8 @@ async function loadData() {
componentsCache = data.components || []; componentsCache = data.components || [];
renderComponents(componentsCache, data.total); renderComponents(componentsCache, data.total);
updatePagination(data.total); updatePagination(data.total);
} else {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных для вкладки</div>';
} }
} catch(e) { } catch(e) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>'; document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
@@ -848,7 +911,7 @@ function recalculateAll() {
progressBar.className = 'bg-green-600 h-4 rounded-full'; progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => { setTimeout(() => {
progressContainer.style.display = 'none'; progressContainer.style.display = 'none';
if (currentTab === 'components') { if (currentTab === 'component-settings') {
loadData(); loadData();
} }
}, 2000); }, 2000);
@@ -966,11 +1029,164 @@ function renderAllConfigs(configs) {
document.getElementById('tab-content').innerHTML = html; document.getElementById('tab-content').innerHTML = html;
} }
async function importStockFile() {
const input = document.getElementById('stock-file-input');
if (!input.files || input.files.length === 0) {
showToast('Выберите файл .mxl или .xlsx', 'error');
return;
}
const file = input.files[0];
const fd = new FormData();
fd.append('file', file);
const box = document.getElementById('stock-import-progress');
const statusEl = document.getElementById('stock-import-status');
const percentEl = document.getElementById('stock-import-percent');
const barEl = document.getElementById('stock-import-bar');
const statsEl = document.getElementById('stock-import-stats');
box.classList.remove('hidden');
statusEl.textContent = 'Запуск импорта...';
percentEl.textContent = '0%';
barEl.style.width = '0%';
statsEl.textContent = '';
try {
const resp = await fetch('/api/admin/pricing/stock/import', {
method: 'POST',
body: fd
});
if (!resp.ok) {
let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Ошибка запуска импорта');
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
let data;
try { data = JSON.parse(line.slice(5).trim()); } catch (_) { continue; }
const current = Number(data.current || 0);
const total = Number(data.total || 100);
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
barEl.style.width = pct + '%';
percentEl.textContent = pct + '%';
statusEl.textContent = data.message || data.status || 'Обработка';
statsEl.textContent =
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0)} | ` +
`Конфликты: ${data.conflicts || 0}`;
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка импорта');
}
if (data.status === 'completed') {
showToast('Импорт stock_log завершен', 'success');
await loadPricelists(1, 'warehouse');
await loadStockMappings(stockMappingsPage);
}
}
}
} catch (e) {
showToast('Ошибка импорта: ' + e.message, 'error');
}
}
async function loadStockMappings(page = 1) {
stockMappingsPage = page;
const body = document.getElementById('stock-mappings-body');
const pagination = document.getElementById('stock-mappings-pagination');
if (!body || !pagination) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || [];
if (items.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
} else {
body.innerHTML = items.map(item => `
<tr>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber)}</td>
<td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(item.description || '—')}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.lot_name || '—')}</td>
<td class="px-4 py-2 text-right">
<button data-partnumber="${escapeHtml(item.partnumber)}" onclick="deleteStockMapping(this.dataset.partnumber)" class="text-red-600 hover:text-red-800 text-sm">Удалить</button>
</td>
</tr>
`).join('');
}
const totalPages = Math.ceil((data.total || 0) / (data.per_page || 20));
if (totalPages <= 1) {
pagination.innerHTML = '';
} else {
let html = '';
for (let i = 1; i <= totalPages; i++) {
const cls = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadStockMappings(${i})" class="px-3 py-1 rounded border ${cls}">${i}</button>`;
}
pagination.innerHTML = html;
}
} catch (e) {
body.innerHTML = `<tr><td colspan="4" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
pagination.innerHTML = '';
}
}
async function saveStockMapping() {
const partnumber = document.getElementById('mapping-partnumber').value.trim();
const lotName = document.getElementById('mapping-lotname').value.trim();
if (!partnumber || !lotName) {
showToast('Заполните partnumber и lot_name', 'error');
return;
}
try {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: partnumber, lot_name: lotName })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
document.getElementById('mapping-partnumber').value = '';
document.getElementById('mapping-lotname').value = '';
showToast('Сопоставление сохранено', 'success');
await loadStockMappings(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function deleteStockMapping(partnumber) {
if (!confirm('Удалить сопоставление для ' + partnumber + '?')) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(partnumber)}`, {
method: 'DELETE'
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
showToast('Сопоставление удалено', 'success');
await loadStockMappings(stockMappingsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission(); await checkPricelistWritePermission();
// Check URL params for initial tab // Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'alerts'; let initialTab = urlParams.get('tab') || 'alerts';
if (initialTab === 'pricelists') initialTab = 'estimate';
if (initialTab === 'components') initialTab = 'component-settings';
await loadTab(initialTab); await loadTab(initialTab);
// Add event listeners for preview updates // Add event listeners for preview updates
@@ -1081,10 +1297,10 @@ async function loadUsersSyncStatus() {
} }
} }
async function loadPricelists(page = 1) { async function loadPricelists(page = 1, source = currentPricelistSource) {
pricelistsPage = page; pricelistsPage = page;
try { try {
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`); const resp = await fetch(`/api/pricelists?page=${page}&per_page=20&source=${encodeURIComponent(source)}`);
const data = await resp.json(); const data = await resp.json();
renderPricelists(data.pricelists || []); renderPricelists(data.pricelists || []);
@@ -1169,7 +1385,7 @@ async function togglePricelistActive(id, isActive) {
} }
showToast('Статус прайслиста обновлен', 'success'); showToast('Статус прайслиста обновлен', 'success');
loadPricelists(pricelistsPage); loadPricelists(pricelistsPage, currentPricelistSource);
} catch (e) { } catch (e) {
showToast('Ошибка: ' + e.message, 'error'); showToast('Ошибка: ' + e.message, 'error');
} }
@@ -1185,7 +1401,7 @@ function renderPricelistsPagination(total, page, perPage) {
let html = ''; let html = '';
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'; const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`; html += `<button onclick="loadPricelists(${i}, '${currentPricelistSource}')" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
} }
document.getElementById('pricelists-pagination').innerHTML = html; document.getElementById('pricelists-pagination').innerHTML = html;
@@ -1250,6 +1466,9 @@ async function createPricelist() {
const resp = await fetch('/api/pricelists/create-with-progress', { const resp = await fetch('/api/pricelists/create-with-progress', {
method: 'POST' method: 'POST'
,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: currentPricelistSource })
}); });
if (!resp.ok) { if (!resp.ok) {
@@ -1341,7 +1560,7 @@ async function deletePricelist(id) {
} }
showToast('Прайслист удален', 'success'); showToast('Прайслист удален', 'success');
loadPricelists(pricelistsPage); loadPricelists(pricelistsPage, currentPricelistSource);
} catch (e) { } catch (e) {
showToast('Ошибка: ' + e.message, 'error'); showToast('Ошибка: ' + e.message, 'error');
} }
@@ -1361,7 +1580,7 @@ document.getElementById('pricelists-create-form').addEventListener('submit', asy
const pl = await createPricelist(); const pl = await createPricelist();
closePricelistsCreateModal(); closePricelistsCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success'); showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
loadPricelists(1); loadPricelists(1, currentPricelistSource);
} catch (e) { } catch (e) {
showToast('Ошибка: ' + e.message, 'error'); showToast('Ошибка: ' + e.message, 'error');
} finally { } finally {

View File

@@ -57,13 +57,15 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th id="th-qty" class="hidden px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Доступно</th>
<th id="th-partnumbers" class="hidden px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Partnumbers</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
</tr> </tr>
</thead> </thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200"> <tbody id="items-body" class="bg-white divide-y divide-gray-200">
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500">Загрузка...</td> <td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -80,6 +82,7 @@
let currentPage = 1; let currentPage = 1;
let searchQuery = ''; let searchQuery = '';
let searchTimeout = null; let searchTimeout = null;
let currentSource = '';
async function loadPricelistInfo() { async function loadPricelistInfo() {
try { try {
@@ -87,6 +90,8 @@
if (!resp.ok) throw new Error('Pricelist not found'); if (!resp.ok) throw new Error('Pricelist not found');
const pl = await resp.json(); const pl = await resp.json();
currentSource = pl.source || '';
toggleWarehouseColumns();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`; document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version; document.getElementById('pl-version').textContent = pl.version;
@@ -128,13 +133,15 @@
const resp = await fetch(url); const resp = await fetch(url);
const data = await resp.json(); const data = await resp.json();
currentSource = data.source || currentSource;
toggleWarehouseColumns();
renderItems(data.items || []); renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page); renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) { } catch (e) {
document.getElementById('items-body').innerHTML = ` document.getElementById('items-body').innerHTML = `
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-red-500"> <td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message} Ошибка загрузки: ${e.message}
</td> </td>
</tr> </tr>
@@ -142,6 +149,26 @@
} }
} }
function isWarehouseSource() {
return (currentSource || '').toLowerCase() === 'warehouse';
}
function itemsColspan() {
return isWarehouseSource() ? 7 : 5;
}
function toggleWarehouseColumns() {
const visible = isWarehouseSource();
document.getElementById('th-qty').classList.toggle('hidden', !visible);
document.getElementById('th-partnumbers').classList.toggle('hidden', !visible);
}
function formatQty(qty) {
if (typeof qty !== 'number') return '—';
if (Number.isInteger(qty)) return qty.toString();
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
}
function formatPriceSettings(item) { function formatPriceSettings(item) {
// Format price settings to match admin pricing interface style // Format price settings to match admin pricing interface style
let settings = []; let settings = [];
@@ -188,7 +215,7 @@
if (items.length === 0) { if (items.length === 0) {
document.getElementById('items-body').innerHTML = ` document.getElementById('items-body').innerHTML = `
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"> <td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-gray-500">
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'} ${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
</td> </td>
</tr> </tr>
@@ -196,10 +223,13 @@
return; return;
} }
const showWarehouse = isWarehouseSource();
const html = items.map(item => { const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-'; const description = item.lot_description || '-';
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description; const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
const qty = formatQty(item.available_qty);
const partnumbers = Array.isArray(item.partnumbers) && item.partnumbers.length > 0 ? item.partnumbers.join(', ') : '—';
return ` return `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
@@ -210,6 +240,8 @@
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span> <span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
</td> </td>
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td> <td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
${showWarehouse ? `<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${qty}</td>` : ''}
${showWarehouse ? `<td class="px-6 py-3 text-sm text-gray-600" title="${escapeHtml(partnumbers)}">${escapeHtml(partnumbers)}</td>` : ''}
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td> <td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td> <td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
</tr> </tr>