3 Commits

15 changed files with 2570 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 alertService *alerts.Service
var pricelistService *pricelist.Service
var stockImportService *services.StockImportService
var syncService *sync.Service
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)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
stockImportService = services.NewStockImportService(mariaDB, pricelistService)
} else {
// In offline mode, we still need to create services that don't require DB
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)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil, nil)
stockImportService = nil
}
// 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)
quoteHandler := handlers.NewQuoteHandler(quoteService)
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)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
@@ -1314,6 +1326,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/lots", pricingHandler.ListLots)
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("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules)
pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule)
pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)

View File

@@ -1,17 +1,20 @@
package handlers
import (
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models"
"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/pricing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -41,12 +44,14 @@ func calculateAverage(prices []float64) float64 {
}
type PricingHandler struct {
db *gorm.DB
pricingService *pricing.Service
alertService *alerts.Service
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
db *gorm.DB
pricingService *pricing.Service
alertService *alerts.Service
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
stockImportService *services.StockImportService
dbUsername string
}
func NewPricingHandler(
@@ -56,14 +61,18 @@ func NewPricingHandler(
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository,
stockImportService *services.StockImportService,
dbUsername string,
) *PricingHandler {
return &PricingHandler{
db: db,
pricingService: pricingService,
alertService: alertService,
componentRepo: componentRepo,
priceRepo: priceRepo,
statsRepo: statsRepo,
db: db,
pricingService: pricingService,
alertService: alertService,
componentRepo: componentRepo,
priceRepo: priceRepo,
statsRepo: statsRepo,
stockImportService: stockImportService,
dbUsername: dbUsername,
}
}
@@ -936,3 +945,275 @@ func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []stri
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,
"ignored": result.Ignored,
"mapping_suggestions": result.MappingSuggestions,
"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,
"ignored": p.Ignored,
"mapping_suggestions": p.MappingSuggestions,
"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"`
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})
}
func (h *PricingHandler) ListStockIgnoreRules(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"))
rows, total, err := h.stockImportService.ListIgnoreRules(page, perPage)
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) UpsertStockIgnoreRule(c *gin.Context) {
if h.stockImportService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Правила игнорирования доступны только в онлайн режиме",
"offline": true,
})
return
}
var req struct {
Target string `json:"target" binding:"required"`
MatchType string `json:"match_type" binding:"required"`
Pattern string `json:"pattern" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stockImportService.UpsertIgnoreRule(req.Target, req.MatchType, req.Pattern); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ignore rule saved"})
}
func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) {
if h.stockImportService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Правила игнорирования доступны только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
deleted, err := h.stockImportService.DeleteIgnoreRule(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
func (h *PricingHandler) ListLots(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Список LOT доступен только в онлайн режиме",
"offline": true,
})
return
}
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "500"))
if perPage < 1 {
perPage = 500
}
if perPage > 5000 {
perPage = 5000
}
search := strings.TrimSpace(c.Query("search"))
query := h.db.Model(&models.Lot{}).Select("lot_name")
if search != "" {
query = query.Where("lot_name LIKE ?", "%"+search+"%")
}
var lots []models.Lot
if err := query.Order("lot_name ASC").Limit(perPage).Find(&lots).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]string, 0, len(lots))
for _, lot := range lots {
if strings.TrimSpace(lot.LotName) == "" {
continue
}
items = append(items, lot.LotName)
}
c.JSON(http.StatusOK, gin.H{"items": items})
}

View File

@@ -37,3 +37,44 @@ type Supplier struct {
func (Supplier) TableName() string {
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" json:"partnumber"`
LotName string `gorm:"column:lot_name;size:255;primaryKey" json:"lot_name"`
Description *string `gorm:"column:description;size:10000" json:"description,omitempty"`
}
func (LotPartnumber) TableName() string {
return "lot_partnumbers"
}
// StockIgnoreRule contains import ignore pattern rules.
type StockIgnoreRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
}
func (StockIgnoreRule) TableName() string {
return "stock_ignore_rules"
}

View File

@@ -65,8 +65,10 @@ type PricelistItem struct {
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
// Virtual fields for display
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
Category string `gorm:"-" json:"category,omitempty"`
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
Category string `gorm:"-" json:"category,omitempty"`
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
}
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
}
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.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
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.
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
today := time.Now().Format("2006-01-02")
prefix := versionPrefixBySource(source)
var last models.Pricelist
err := r.db.Model(&models.Pricelist{}).
Select("version").
Where("source = ? AND version LIKE ?", source, today+"-%").
Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
Order("version DESC").
Limit(1).
Take(&last).Error
if err != nil {
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)
}
@@ -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.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.

View File

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS stock_ignore_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target VARCHAR(20) NOT NULL,
match_type VARCHAR(20) NOT NULL,
pattern VARCHAR(500) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_stock_ignore_rule (target, match_type, pattern),
KEY idx_stock_ignore_target (target)
);

View File

@@ -8,8 +8,9 @@
<div class="flex justify-between items-center border-b pb-4 mb-4">
<div class="flex gap-4">
<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('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('estimate')" id="btn-estimate" class="text-gray-600">Estimate</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('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
@@ -58,8 +59,15 @@
<!-- Pricelists Tab Content (hidden by default) -->
<div id="pricelists-tab-content" class="hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Прайслисты</h2>
<div id="pricelists-create-btn-container"></div>
<div class="flex items-center gap-3">
<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 class="bg-white rounded-lg shadow overflow-hidden">
@@ -84,6 +92,118 @@
</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 id="stock-import-suggestions" class="hidden mt-4 p-3 bg-amber-50 border border-amber-200 rounded">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-amber-900">Новые партномера без сопоставления</div>
<div class="flex items-center gap-2">
<button onclick="addAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-blue-200 text-blue-700 hover:bg-blue-50" title="Добавить все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Добавить все</span>
</button>
<button onclick="ignoreAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-amber-200 text-amber-700 hover:bg-amber-100" title="Игнорировать все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
<span>Игнорировать все</span>
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-amber-200">
<thead class="bg-amber-100">
<tr>
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">LOT</th>
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">Partnumber</th>
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Описание</th>
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Причина</th>
<th class="px-3 py-2 text-right text-xs font-medium text-amber-800 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-import-suggestions-body" class="bg-white divide-y divide-amber-100"></tbody>
</table>
</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="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
<datalist id="stock-lot-options"></datalist>
<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 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-4 py-2 w-1/4 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-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 class="border rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Игнорирование при импорте</h3>
<div class="flex items-center gap-2 mb-3">
<select id="ignore-target" class="px-3 py-2 border rounded">
<option value="partnumber">Partnumber</option>
<option value="description">Описание</option>
</select>
<select id="ignore-match-type" class="px-3 py-2 border rounded">
<option value="exact">Равно</option>
<option value="prefix">Начинается с</option>
<option value="suffix">Заканчивается на</option>
</select>
<input id="ignore-pattern" type="text" placeholder="Шаблон" class="px-3 py-2 border rounded w-1/3">
<button onclick="saveStockIgnoreRule()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Добавить</button>
<button onclick="loadStockIgnoreRules(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">Поле</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">Шаблон</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-ignore-rules-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-ignore-rules-pagination" class="flex justify-center space-x-2 mt-3"></div>
</div>
</div>
</div>
<!-- Sync Status Tab Content (hidden by default) -->
@@ -239,6 +359,7 @@
<script>
let currentTab = 'alerts';
let currentPricelistSource = 'estimate';
let currentPage = 1;
let totalPages = 1;
let perPage = 50;
@@ -252,6 +373,12 @@ let pricelistsCanWrite = false;
let isCreatingPricelist = false;
let cachedDbUsername = null;
let syncUsersStatusTimer = null;
let stockMappingsPage = 1;
let stockMappingsCache = [];
let stockIgnoreRulesPage = 1;
let stockMappingsSearch = '';
let stockMappingsSearchTimer = null;
let stockImportSuggestions = [];
async function loadTab(tab) {
currentTab = tab;
@@ -263,36 +390,38 @@ async function loadTab(tab) {
}
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-pricelists').className = tab === 'pricelists' ? '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-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-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab
if (tab === 'components') {
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') {
if (tab === 'estimate' || tab === 'warehouse' || tab === 'competitor') {
currentPricelistSource = tab === 'estimate' ? 'estimate' : (tab === 'warehouse' ? 'warehouse' : 'competitor');
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = '';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected
checkPricelistWritePermission();
loadPricelists(1);
document.getElementById('pricelists-title').textContent = tab === 'estimate' ? 'Estimate' : (tab === 'warehouse' ? 'Склад' : 'Конкуренты');
document.getElementById('estimate-settings-btn').classList.toggle('hidden', tab !== 'estimate');
document.getElementById('stock-tools').classList.toggle('hidden', tab !== 'warehouse');
await checkPricelistWritePermission();
await loadPricelists(1, currentPricelistSource);
if (tab === 'warehouse') {
await loadStockMappings(1);
await loadStockIgnoreRules(1);
await loadStockLotOptions();
}
} 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') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
@@ -307,6 +436,14 @@ async function loadTab(tab) {
}
await loadUsersSyncStatus();
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 {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
@@ -314,9 +451,6 @@ async function loadTab(tab) {
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
}
if (tab !== 'pricelists' && tab !== 'sync-status') {
await loadData();
}
}
@@ -356,7 +490,7 @@ async function loadData() {
totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []);
updatePagination(data.total);
} else {
} else if (currentTab === 'component-settings') {
let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
@@ -373,6 +507,8 @@ async function loadData() {
componentsCache = data.components || [];
renderComponents(componentsCache, data.total);
updatePagination(data.total);
} else {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных для вкладки</div>';
}
} catch(e) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
@@ -848,7 +984,7 @@ function recalculateAll() {
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
progressContainer.style.display = 'none';
if (currentTab === 'components') {
if (currentTab === 'component-settings') {
loadData();
}
}, 2000);
@@ -966,12 +1102,463 @@ function renderAllConfigs(configs) {
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');
const suggestionsBox = document.getElementById('stock-import-suggestions');
box.classList.remove('hidden');
suggestionsBox.classList.add('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.ignored || 0)} | ` +
`Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`;
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка импорта');
}
if (data.status === 'completed') {
stockImportSuggestions = data.mapping_suggestions || [];
renderStockImportSuggestions(stockImportSuggestions);
showToast('Импорт stock_log завершен', 'success');
await loadPricelists(1, 'warehouse');
await loadStockMappings(stockMappingsPage);
await loadStockIgnoreRules(stockIgnoreRulesPage);
}
}
}
} catch (e) {
showToast('Ошибка импорта: ' + e.message, 'error');
}
}
function formatSuggestionReason(reason) {
if (reason === 'conflict') return 'Конфликт';
return 'Не найден LOT';
}
function renderStockImportSuggestions(items) {
const box = document.getElementById('stock-import-suggestions');
const body = document.getElementById('stock-import-suggestions-body');
if (!box || !body) return;
if (!items || items.length === 0) {
box.classList.add('hidden');
body.innerHTML = '';
return;
}
body.innerHTML = items.map(item => `
<tr>
<td class="px-3 py-2 w-1/4 text-sm font-mono">
<input data-role="suggestion-lot" data-partnumber="${escapeHtml(item.partnumber || '')}" list="stock-lot-options" autocomplete="off" type="text" class="px-2 py-1 border rounded w-full font-mono" placeholder="Выберите LOT">
</td>
<td class="px-3 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || '')}</td>
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(item.description || '—')}</td>
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(formatSuggestionReason(item.reason || 'unmapped'))}</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="addSuggestionMapping(this)" class="text-blue-600 hover:text-blue-800" title="Добавить">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-description="${encodeURIComponent(item.description || '')}">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="ignoreSuggestion(this)" class="text-amber-600 hover:text-amber-800" title="Игнорировать">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
</button>
</div>
</td>
</tr>
`).join('');
box.classList.remove('hidden');
}
async function addSuggestionMapping(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
const description = decodeURIComponent(button?.querySelector('svg')?.dataset?.description || '');
const input = document.querySelector(`input[data-role="suggestion-lot"][data-partnumber="${CSS.escape(partnumber)}"]`);
const lotName = (input?.value || '').trim();
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, description: description })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
renderStockImportSuggestions(stockImportSuggestions);
await loadStockMappings(1);
showToast('Сопоставление добавлено', 'success');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function ignoreSuggestion(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
try {
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка добавления в игнорирование');
stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
renderStockImportSuggestions(stockImportSuggestions);
await loadStockIgnoreRules(1);
showToast('Добавлено в игнорирование', 'success');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function addAllSuggestions() {
const descriptionByPartnumber = {};
for (const s of stockImportSuggestions) {
const pn = (s.partnumber || '').trim().toLowerCase();
if (!pn) continue;
descriptionByPartnumber[pn] = s.description || '';
}
const inputs = Array.from(document.querySelectorAll('input[data-role="suggestion-lot"]'));
const tasks = inputs
.map(input => ({
partnumber: (input.dataset.partnumber || '').trim(),
lot: (input.value || '').trim(),
description: descriptionByPartnumber[((input.dataset.partnumber || '').trim().toLowerCase())] || ''
}))
.filter(x => x.partnumber);
if (tasks.length === 0) {
showToast('Нет строк для добавления', 'error');
return;
}
let ok = 0;
for (const t of tasks) {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: t.partnumber, lot_name: t.lot, description: t.description })
});
if (resp.ok) ok++;
}
stockImportSuggestions = stockImportSuggestions.filter(item => !tasks.some(t => t.partnumber.toLowerCase() === (item.partnumber || '').toLowerCase()));
renderStockImportSuggestions(stockImportSuggestions);
await loadStockMappings(1);
showToast(`Добавлено: ${ok}`, 'success');
}
async function ignoreAllSuggestions() {
if (!stockImportSuggestions.length) return;
let ok = 0;
for (const s of stockImportSuggestions) {
const partnumber = (s.partnumber || '').trim();
if (!partnumber) continue;
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
});
if (resp.ok) ok++;
}
stockImportSuggestions = [];
renderStockImportSuggestions(stockImportSuggestions);
await loadStockIgnoreRules(1);
showToast(`Добавлено в игнорирование: ${ok}`, 'success');
}
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 query = encodeURIComponent(stockMappingsSearch);
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20&search=${query}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || [];
stockMappingsCache = 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 w-1/4 text-sm font-mono">
<input data-role="lot" type="text" list="stock-lot-options" autocomplete="off" placeholder="Выберите LOT" value="${escapeHtml(item.lot_name || item.LotName || '')}" class="px-2 py-1 border rounded w-full font-mono">
</td>
<td class="px-4 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
<td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(item.description || item.Description || '—')}</td>
<td class="px-4 py-2">
<div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="saveInlineStockMapping(this)" class="text-blue-600 hover:text-blue-800" title="Сохранить">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); ignoreStockMapping(this.dataset.partnumber)" class="text-amber-600 hover:text-amber-800" title="Игнорировать">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" class="text-red-600 hover:text-red-800" title="Удалить">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12m4-12v12m4-12v12"></path>
</svg>
</button>
</div>
</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 = '';
}
}
function applyStockMappingsSearch() {
stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
loadStockMappings(1);
}
function onStockMappingsSearchInput() {
clearTimeout(stockMappingsSearchTimer);
stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250);
}
async function saveInlineStockMapping(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
const row = button.closest('tr');
const lotName = (row?.querySelector('input[data-role="lot"]')?.value || '').trim();
if (!lotName) {
showToast('LOT не может быть пустым', '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 || 'Ошибка сохранения');
showToast('Сопоставление сохранено', 'success');
await loadStockMappings(stockMappingsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function loadStockLotOptions() {
const datalist = document.getElementById('stock-lot-options');
if (!datalist) return;
try {
const resp = await fetch('/api/admin/pricing/lots?per_page=5000');
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки LOT');
const items = data.items || [];
datalist.innerHTML = items.map(lot => `<option value="${escapeHtml(lot)}"></option>`).join('');
} catch (_) {
datalist.innerHTML = '';
}
}
async function loadStockIgnoreRules(page = 1) {
stockIgnoreRulesPage = page;
const body = document.getElementById('stock-ignore-rules-body');
const pagination = document.getElementById('stock-ignore-rules-pagination');
if (!body || !pagination) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/ignore-rules?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 {
const matchLabel = { exact: 'Равно', prefix: 'Начинается с', suffix: 'Заканчивается на' };
body.innerHTML = items.map(item => `
<tr>
<td class="px-4 py-2 text-sm">${escapeHtml(item.target)}</td>
<td class="px-4 py-2 text-sm">${escapeHtml(matchLabel[item.match_type] || item.match_type)}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.pattern)}</td>
<td class="px-4 py-2 text-right">
<button onclick="deleteStockIgnoreRule(${item.id})" 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="loadStockIgnoreRules(${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 saveStockIgnoreRule() {
const target = document.getElementById('ignore-target').value;
const matchType = document.getElementById('ignore-match-type').value;
const pattern = document.getElementById('ignore-pattern').value.trim();
if (!pattern) {
showToast('Заполните шаблон', 'error');
return;
}
try {
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: target, match_type: matchType, pattern: pattern })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
document.getElementById('ignore-pattern').value = '';
showToast('Правило добавлено', 'success');
await loadStockIgnoreRules(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function deleteStockIgnoreRule(id) {
if (!confirm('Удалить правило игнорирования?')) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/ignore-rules/${id}`, {
method: 'DELETE'
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
showToast('Правило удалено', 'success');
await loadStockIgnoreRules(stockIgnoreRulesPage);
} 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');
}
}
async function ignoreStockMapping(partnumber) {
const pn = (partnumber || '').trim();
if (!pn) return;
if (!confirm('Добавить партномер в игнорирование и удалить из сопоставлений?')) return;
try {
const addResp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: pn })
});
const addData = await addResp.json();
if (!addResp.ok) throw new Error(addData.error || 'Ошибка добавления в игнорирование');
const delResp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(pn)}`, {
method: 'DELETE'
});
const delData = await delResp.json();
if (!delResp.ok) throw new Error(delData.error || 'Ошибка удаления сопоставления');
showToast('Партномер добавлен в игнорирование', 'success');
await loadStockMappings(stockMappingsPage);
await loadStockIgnoreRules(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab
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);
const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
if (stockMappingsSearchEl) {
stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput);
}
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);
@@ -1081,10 +1668,10 @@ async function loadUsersSyncStatus() {
}
}
async function loadPricelists(page = 1) {
async function loadPricelists(page = 1, source = currentPricelistSource) {
pricelistsPage = page;
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();
renderPricelists(data.pricelists || []);
@@ -1169,7 +1756,7 @@ async function togglePricelistActive(id, isActive) {
}
showToast('Статус прайслиста обновлен', 'success');
loadPricelists(pricelistsPage);
loadPricelists(pricelistsPage, currentPricelistSource);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
@@ -1185,7 +1772,7 @@ function renderPricelistsPagination(total, page, perPage) {
let html = '';
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';
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;
@@ -1250,6 +1837,9 @@ async function createPricelist() {
const resp = await fetch('/api/pricelists/create-with-progress', {
method: 'POST'
,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: currentPricelistSource })
});
if (!resp.ok) {
@@ -1341,7 +1931,7 @@ async function deletePricelist(id) {
}
showToast('Прайслист удален', 'success');
loadPricelists(pricelistsPage);
loadPricelists(pricelistsPage, currentPricelistSource);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
@@ -1361,7 +1951,7 @@ document.getElementById('pricelists-create-form').addEventListener('submit', asy
const pl = await createPricelist();
closePricelistsCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
loadPricelists(1);
loadPricelists(1, currentPricelistSource);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
} 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 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-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
</tr>
</thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
<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>
</tbody>
</table>
@@ -80,6 +82,7 @@
let currentPage = 1;
let searchQuery = '';
let searchTimeout = null;
let currentSource = '';
async function loadPricelistInfo() {
try {
@@ -87,6 +90,8 @@
if (!resp.ok) throw new Error('Pricelist not found');
const pl = await resp.json();
currentSource = pl.source || '';
toggleWarehouseColumns();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version;
@@ -128,13 +133,15 @@
const resp = await fetch(url);
const data = await resp.json();
currentSource = data.source || currentSource;
toggleWarehouseColumns();
renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('items-body').innerHTML = `
<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}
</td>
</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) {
// Format price settings to match admin pricing interface style
let settings = [];
@@ -188,7 +215,7 @@
if (items.length === 0) {
document.getElementById('items-body').innerHTML = `
<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 ? 'Ничего не найдено' : 'Позиции не найдены'}
</td>
</tr>
@@ -196,10 +223,13 @@
return;
}
const showWarehouse = isWarehouseSource();
const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_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 `
<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>
</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-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
</tr>