Refine stock import UX with suggestions, ignore rules, and inline mapping controls
This commit is contained in:
@@ -1326,10 +1326,14 @@ 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.GET("/lots", pricingHandler.ListLots)
|
||||||
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
|
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
|
||||||
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
|
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
|
||||||
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
|
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
|
||||||
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping)
|
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.GET("/alerts", pricingHandler.ListAlerts)
|
||||||
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
|
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
|
||||||
|
|||||||
@@ -997,6 +997,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
|||||||
"conflicts": result.Conflicts,
|
"conflicts": result.Conflicts,
|
||||||
"fallback_matches": result.FallbackMatches,
|
"fallback_matches": result.FallbackMatches,
|
||||||
"parse_errors": result.ParseErrors,
|
"parse_errors": result.ParseErrors,
|
||||||
|
"ignored": result.Ignored,
|
||||||
|
"mapping_suggestions": result.MappingSuggestions,
|
||||||
"import_date": result.ImportDate.Format("2006-01-02"),
|
"import_date": result.ImportDate.Format("2006-01-02"),
|
||||||
"warehouse_pricelist_id": result.WarehousePLID,
|
"warehouse_pricelist_id": result.WarehousePLID,
|
||||||
"warehouse_pricelist_version": result.WarehousePLVer,
|
"warehouse_pricelist_version": result.WarehousePLVer,
|
||||||
@@ -1029,6 +1031,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
|||||||
"conflicts": p.Conflicts,
|
"conflicts": p.Conflicts,
|
||||||
"fallback_matches": p.FallbackMatches,
|
"fallback_matches": p.FallbackMatches,
|
||||||
"parse_errors": p.ParseErrors,
|
"parse_errors": p.ParseErrors,
|
||||||
|
"ignored": p.Ignored,
|
||||||
|
"mapping_suggestions": p.MappingSuggestions,
|
||||||
"import_date": p.ImportDate,
|
"import_date": p.ImportDate,
|
||||||
"warehouse_pricelist_id": p.PricelistID,
|
"warehouse_pricelist_id": p.PricelistID,
|
||||||
"warehouse_pricelist_version": p.PricelistVer,
|
"warehouse_pricelist_version": p.PricelistVer,
|
||||||
@@ -1078,7 +1082,7 @@ func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Partnumber string `json:"partnumber" binding:"required"`
|
Partnumber string `json:"partnumber" binding:"required"`
|
||||||
LotName string `json:"lot_name" binding:"required"`
|
LotName string `json:"lot_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -1109,3 +1113,107 @@ func (h *PricingHandler) DeleteStockMapping(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,3 +65,16 @@ type LotPartnumber struct {
|
|||||||
func (LotPartnumber) TableName() string {
|
func (LotPartnumber) TableName() string {
|
||||||
return "lot_partnumbers"
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,40 +21,51 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type StockImportProgress struct {
|
type StockImportProgress struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Current int `json:"current,omitempty"`
|
Current int `json:"current,omitempty"`
|
||||||
Total int `json:"total,omitempty"`
|
Total int `json:"total,omitempty"`
|
||||||
RowsTotal int `json:"rows_total,omitempty"`
|
RowsTotal int `json:"rows_total,omitempty"`
|
||||||
ValidRows int `json:"valid_rows,omitempty"`
|
ValidRows int `json:"valid_rows,omitempty"`
|
||||||
Inserted int `json:"inserted,omitempty"`
|
Inserted int `json:"inserted,omitempty"`
|
||||||
Deleted int64 `json:"deleted,omitempty"`
|
Deleted int64 `json:"deleted,omitempty"`
|
||||||
Unmapped int `json:"unmapped,omitempty"`
|
Unmapped int `json:"unmapped,omitempty"`
|
||||||
Conflicts int `json:"conflicts,omitempty"`
|
Conflicts int `json:"conflicts,omitempty"`
|
||||||
FallbackMatches int `json:"fallback_matches,omitempty"`
|
FallbackMatches int `json:"fallback_matches,omitempty"`
|
||||||
ParseErrors int `json:"parse_errors,omitempty"`
|
ParseErrors int `json:"parse_errors,omitempty"`
|
||||||
ImportDate string `json:"import_date,omitempty"`
|
Ignored int `json:"ignored,omitempty"`
|
||||||
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
|
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
|
||||||
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
|
ImportDate string `json:"import_date,omitempty"`
|
||||||
|
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
|
||||||
|
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StockImportResult struct {
|
type StockImportResult struct {
|
||||||
RowsTotal int
|
RowsTotal int
|
||||||
ValidRows int
|
ValidRows int
|
||||||
Inserted int
|
Inserted int
|
||||||
Deleted int64
|
Deleted int64
|
||||||
Unmapped int
|
Unmapped int
|
||||||
Conflicts int
|
Conflicts int
|
||||||
FallbackMatches int
|
FallbackMatches int
|
||||||
ParseErrors int
|
ParseErrors int
|
||||||
ImportDate time.Time
|
Ignored int
|
||||||
WarehousePLID uint
|
MappingSuggestions []StockMappingSuggestion
|
||||||
WarehousePLVer string
|
ImportDate time.Time
|
||||||
|
WarehousePLID uint
|
||||||
|
WarehousePLVer string
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingMapping struct {
|
type StockMappingSuggestion struct {
|
||||||
Partnumber string
|
Partnumber string `json:"partnumber"`
|
||||||
Description string
|
Description string `json:"description,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type stockIgnoreRule struct {
|
||||||
|
Target string
|
||||||
|
MatchType string
|
||||||
|
Pattern string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StockImportService struct {
|
type StockImportService struct {
|
||||||
@@ -128,26 +139,42 @@ func (s *StockImportService) Import(
|
|||||||
conflicts int
|
conflicts int
|
||||||
fallbackMatches int
|
fallbackMatches int
|
||||||
parseErrors int
|
parseErrors int
|
||||||
pendingByPN = make(map[string]pendingMapping)
|
ignored int
|
||||||
|
suggestionsByPN = make(map[string]StockMappingSuggestion)
|
||||||
)
|
)
|
||||||
|
ignoreRules, err := s.loadIgnoreRules()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if strings.TrimSpace(row.Article) == "" {
|
if strings.TrimSpace(row.Article) == "" {
|
||||||
parseErrors++
|
parseErrors++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if shouldIgnoreStockRow(row, ignoreRules) {
|
||||||
|
ignored++
|
||||||
|
continue
|
||||||
|
}
|
||||||
lot, matchType, resolveErr := resolver.resolve(row.Article)
|
lot, matchType, resolveErr := resolver.resolve(row.Article)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
trimmedPN := strings.TrimSpace(row.Article)
|
trimmedPN := strings.TrimSpace(row.Article)
|
||||||
if trimmedPN != "" {
|
if trimmedPN != "" {
|
||||||
key := normalizeKey(trimmedPN)
|
key := normalizeKey(trimmedPN)
|
||||||
if key != "" {
|
if key != "" {
|
||||||
candidate := pendingMapping{
|
reason := "unmapped"
|
||||||
|
if errors.Is(resolveErr, errResolveConflict) {
|
||||||
|
reason = "conflict"
|
||||||
|
}
|
||||||
|
candidate := StockMappingSuggestion{
|
||||||
Partnumber: trimmedPN,
|
Partnumber: trimmedPN,
|
||||||
Description: strings.TrimSpace(row.Description),
|
Description: strings.TrimSpace(row.Description),
|
||||||
|
Reason: reason,
|
||||||
}
|
}
|
||||||
if prev, ok := pendingByPN[key]; !ok || (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") {
|
if prev, ok := suggestionsByPN[key]; !ok ||
|
||||||
pendingByPN[key] = candidate
|
(strings.TrimSpace(prev.Description) == "" && candidate.Description != "") ||
|
||||||
|
(prev.Reason != "conflict" && candidate.Reason == "conflict") {
|
||||||
|
suggestionsByPN[key] = candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,15 +208,7 @@ func (s *StockImportService) Import(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(pendingByPN) > 0 {
|
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
|
||||||
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 {
|
if len(records) == 0 {
|
||||||
return nil, fmt.Errorf("no valid rows after mapping")
|
return nil, fmt.Errorf("no valid rows after mapping")
|
||||||
@@ -256,35 +275,39 @@ func (s *StockImportService) Import(
|
|||||||
warehousePLVer = pl.Version
|
warehousePLVer = pl.Version
|
||||||
|
|
||||||
result := &StockImportResult{
|
result := &StockImportResult{
|
||||||
RowsTotal: len(rows),
|
RowsTotal: len(rows),
|
||||||
ValidRows: len(records),
|
ValidRows: len(records),
|
||||||
Inserted: inserted,
|
Inserted: inserted,
|
||||||
Deleted: deleted,
|
Deleted: deleted,
|
||||||
Unmapped: unmapped,
|
Unmapped: unmapped,
|
||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
FallbackMatches: fallbackMatches,
|
FallbackMatches: fallbackMatches,
|
||||||
ParseErrors: parseErrors,
|
ParseErrors: parseErrors,
|
||||||
ImportDate: importDate,
|
Ignored: ignored,
|
||||||
WarehousePLID: warehousePLID,
|
MappingSuggestions: suggestions,
|
||||||
WarehousePLVer: warehousePLVer,
|
ImportDate: importDate,
|
||||||
|
WarehousePLID: warehousePLID,
|
||||||
|
WarehousePLVer: warehousePLVer,
|
||||||
}
|
}
|
||||||
|
|
||||||
report(StockImportProgress{
|
report(StockImportProgress{
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
Message: "Импорт завершен",
|
Message: "Импорт завершен",
|
||||||
RowsTotal: result.RowsTotal,
|
RowsTotal: result.RowsTotal,
|
||||||
ValidRows: result.ValidRows,
|
ValidRows: result.ValidRows,
|
||||||
Inserted: result.Inserted,
|
Inserted: result.Inserted,
|
||||||
Deleted: result.Deleted,
|
Deleted: result.Deleted,
|
||||||
Unmapped: result.Unmapped,
|
Unmapped: result.Unmapped,
|
||||||
Conflicts: result.Conflicts,
|
Conflicts: result.Conflicts,
|
||||||
FallbackMatches: result.FallbackMatches,
|
FallbackMatches: result.FallbackMatches,
|
||||||
ParseErrors: result.ParseErrors,
|
ParseErrors: result.ParseErrors,
|
||||||
ImportDate: result.ImportDate.Format("2006-01-02"),
|
Ignored: result.Ignored,
|
||||||
PricelistID: result.WarehousePLID,
|
MappingSuggestions: result.MappingSuggestions,
|
||||||
PricelistVer: result.WarehousePLVer,
|
ImportDate: result.ImportDate.Format("2006-01-02"),
|
||||||
Current: 100,
|
PricelistID: result.WarehousePLID,
|
||||||
Total: 100,
|
PricelistVer: result.WarehousePLVer,
|
||||||
|
Current: 100,
|
||||||
|
Total: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -382,16 +405,18 @@ func (s *StockImportService) UpsertMapping(partnumber, lotName, description stri
|
|||||||
partnumber = strings.TrimSpace(partnumber)
|
partnumber = strings.TrimSpace(partnumber)
|
||||||
lotName = strings.TrimSpace(lotName)
|
lotName = strings.TrimSpace(lotName)
|
||||||
description = strings.TrimSpace(description)
|
description = strings.TrimSpace(description)
|
||||||
if partnumber == "" || lotName == "" {
|
if partnumber == "" {
|
||||||
return fmt.Errorf("partnumber and lot_name are required")
|
return fmt.Errorf("partnumber is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var lotCount int64
|
if lotName != "" {
|
||||||
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
|
var lotCount int64
|
||||||
return err
|
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)
|
if lotCount == 0 {
|
||||||
|
return fmt.Errorf("lot not found: %s", lotName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -434,55 +459,153 @@ func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) {
|
|||||||
return res.RowsAffected, res.Error
|
return res.RowsAffected, res.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StockImportService) upsertPendingMappings(rows []pendingMapping) error {
|
func (s *StockImportService) ListIgnoreRules(page, perPage int) ([]models.StockIgnoreRule, int64, error) {
|
||||||
if s.db == nil || len(rows) == 0 {
|
if s.db == nil {
|
||||||
|
return nil, 0, fmt.Errorf("offline mode: ignore rules 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.StockIgnoreRule{})
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var rows []models.StockIgnoreRule
|
||||||
|
if err := query.Order("id DESC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return rows, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockImportService) UpsertIgnoreRule(target, matchType, pattern string) error {
|
||||||
|
if s.db == nil {
|
||||||
|
return fmt.Errorf("offline mode: ignore rules unavailable")
|
||||||
|
}
|
||||||
|
target = normalizeIgnoreTarget(target)
|
||||||
|
matchType = normalizeIgnoreMatchType(matchType)
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
if target == "" || matchType == "" || pattern == "" {
|
||||||
|
return fmt.Errorf("target, match_type and pattern are required")
|
||||||
|
}
|
||||||
|
return s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.StockIgnoreRule{
|
||||||
|
Target: target,
|
||||||
|
MatchType: matchType,
|
||||||
|
Pattern: pattern,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockImportService) DeleteIgnoreRule(id uint) (int64, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return 0, fmt.Errorf("offline mode: ignore rules unavailable")
|
||||||
|
}
|
||||||
|
res := s.db.Delete(&models.StockIgnoreRule{}, id)
|
||||||
|
return res.RowsAffected, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockImportService) loadIgnoreRules() ([]stockIgnoreRule, error) {
|
||||||
|
var rows []models.StockIgnoreRule
|
||||||
|
if err := s.db.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules := make([]stockIgnoreRule, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
target := normalizeIgnoreTarget(row.Target)
|
||||||
|
matchType := normalizeIgnoreMatchType(row.MatchType)
|
||||||
|
pattern := normalizeKey(row.Pattern)
|
||||||
|
if target == "" || matchType == "" || pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rules = append(rules, stockIgnoreRule{
|
||||||
|
Target: target,
|
||||||
|
MatchType: matchType,
|
||||||
|
Pattern: pattern,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectSortedSuggestions(src map[string]StockMappingSuggestion, limit int) []StockMappingSuggestion {
|
||||||
|
if len(src) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
items := make([]StockMappingSuggestion, 0, len(src))
|
||||||
for _, row := range rows {
|
for _, item := range src {
|
||||||
pn := strings.TrimSpace(row.Partnumber)
|
items = append(items, item)
|
||||||
if pn == "" {
|
}
|
||||||
continue
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return strings.ToLower(items[i].Partnumber) < strings.ToLower(items[j].Partnumber)
|
||||||
|
})
|
||||||
|
if limit > 0 && len(items) > limit {
|
||||||
|
return items[:limit]
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldIgnoreStockRow(row stockImportRow, rules []stockIgnoreRule) bool {
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
partnumber := normalizeKey(row.Article)
|
||||||
|
description := normalizeKey(row.Description)
|
||||||
|
for _, rule := range rules {
|
||||||
|
candidate := ""
|
||||||
|
if rule.Target == "partnumber" {
|
||||||
|
candidate = partnumber
|
||||||
|
} else {
|
||||||
|
candidate = description
|
||||||
|
}
|
||||||
|
if candidate == "" || rule.Pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch rule.MatchType {
|
||||||
|
case "exact":
|
||||||
|
if candidate == rule.Pattern {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
desc := strings.TrimSpace(row.Description)
|
case "prefix":
|
||||||
var existing []models.LotPartnumber
|
if strings.HasPrefix(candidate, rule.Pattern) {
|
||||||
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil {
|
return true
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if len(existing) == 0 {
|
case "suffix":
|
||||||
var descPtr *string
|
if strings.HasSuffix(candidate, rule.Pattern) {
|
||||||
if desc != "" {
|
return true
|
||||||
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
|
}
|
||||||
})
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIgnoreTarget(v string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "partnumber":
|
||||||
|
return "partnumber"
|
||||||
|
case "description":
|
||||||
|
return "description"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIgnoreMatchType(v string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "exact":
|
||||||
|
return "exact"
|
||||||
|
case "prefix":
|
||||||
|
return "prefix"
|
||||||
|
case "suffix":
|
||||||
|
return "suffix"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
10
migrations/018_add_stock_ignore_rules.sql
Normal file
10
migrations/018_add_stock_ignore_rules.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -110,23 +110,55 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div>
|
<div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
<div class="border rounded-lg p-4">
|
||||||
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
|
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<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="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
|
||||||
<input id="mapping-lotname" type="text" placeholder="lot_name" class="px-3 py-2 border rounded w-1/3">
|
<datalist id="stock-lot-options"></datalist>
|
||||||
<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>
|
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<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 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-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>
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -137,6 +169,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div>
|
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,6 +375,10 @@ let cachedDbUsername = null;
|
|||||||
let syncUsersStatusTimer = null;
|
let syncUsersStatusTimer = null;
|
||||||
let stockMappingsPage = 1;
|
let stockMappingsPage = 1;
|
||||||
let stockMappingsCache = [];
|
let stockMappingsCache = [];
|
||||||
|
let stockIgnoreRulesPage = 1;
|
||||||
|
let stockMappingsSearch = '';
|
||||||
|
let stockMappingsSearchTimer = null;
|
||||||
|
let stockImportSuggestions = [];
|
||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
@@ -341,6 +411,8 @@ async function loadTab(tab) {
|
|||||||
await loadPricelists(1, currentPricelistSource);
|
await loadPricelists(1, currentPricelistSource);
|
||||||
if (tab === 'warehouse') {
|
if (tab === 'warehouse') {
|
||||||
await loadStockMappings(1);
|
await loadStockMappings(1);
|
||||||
|
await loadStockIgnoreRules(1);
|
||||||
|
await loadStockLotOptions();
|
||||||
}
|
}
|
||||||
} else if (tab === 'component-settings') {
|
} else if (tab === 'component-settings') {
|
||||||
document.getElementById('search-bar').className = 'mb-4';
|
document.getElementById('search-bar').className = 'mb-4';
|
||||||
@@ -1046,7 +1118,9 @@ async function importStockFile() {
|
|||||||
const percentEl = document.getElementById('stock-import-percent');
|
const percentEl = document.getElementById('stock-import-percent');
|
||||||
const barEl = document.getElementById('stock-import-bar');
|
const barEl = document.getElementById('stock-import-bar');
|
||||||
const statsEl = document.getElementById('stock-import-stats');
|
const statsEl = document.getElementById('stock-import-stats');
|
||||||
|
const suggestionsBox = document.getElementById('stock-import-suggestions');
|
||||||
box.classList.remove('hidden');
|
box.classList.remove('hidden');
|
||||||
|
suggestionsBox.classList.add('hidden');
|
||||||
statusEl.textContent = 'Запуск импорта...';
|
statusEl.textContent = 'Запуск импорта...';
|
||||||
percentEl.textContent = '0%';
|
percentEl.textContent = '0%';
|
||||||
barEl.style.width = '0%';
|
barEl.style.width = '0%';
|
||||||
@@ -1082,16 +1156,19 @@ async function importStockFile() {
|
|||||||
statusEl.textContent = data.message || data.status || 'Обработка';
|
statusEl.textContent = data.message || data.status || 'Обработка';
|
||||||
statsEl.textContent =
|
statsEl.textContent =
|
||||||
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
|
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
|
||||||
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0)} | ` +
|
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` +
|
||||||
`Конфликты: ${data.conflicts || 0}`;
|
`Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`;
|
||||||
|
|
||||||
if (data.status === 'error') {
|
if (data.status === 'error') {
|
||||||
throw new Error(data.message || 'Ошибка импорта');
|
throw new Error(data.message || 'Ошибка импорта');
|
||||||
}
|
}
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
|
stockImportSuggestions = data.mapping_suggestions || [];
|
||||||
|
renderStockImportSuggestions(stockImportSuggestions);
|
||||||
showToast('Импорт stock_log завершен', 'success');
|
showToast('Импорт stock_log завершен', 'success');
|
||||||
await loadPricelists(1, 'warehouse');
|
await loadPricelists(1, 'warehouse');
|
||||||
await loadStockMappings(stockMappingsPage);
|
await loadStockMappings(stockMappingsPage);
|
||||||
|
await loadStockIgnoreRules(stockIgnoreRulesPage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1100,13 +1177,151 @@ async function importStockFile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function loadStockMappings(page = 1) {
|
||||||
stockMappingsPage = page;
|
stockMappingsPage = page;
|
||||||
const body = document.getElementById('stock-mappings-body');
|
const body = document.getElementById('stock-mappings-body');
|
||||||
const pagination = document.getElementById('stock-mappings-pagination');
|
const pagination = document.getElementById('stock-mappings-pagination');
|
||||||
if (!body || !pagination) return;
|
if (!body || !pagination) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20`);
|
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();
|
const data = await resp.json();
|
||||||
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
|
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
@@ -1115,12 +1330,30 @@ async function loadStockMappings(page = 1) {
|
|||||||
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
|
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
body.innerHTML = items.map(item => `
|
body.innerHTML = items.map(item => `
|
||||||
<tr class="cursor-pointer hover:bg-gray-50" onclick="selectStockMappingRow('${escapeHtml((item.partnumber || item.Partnumber || '')).replace(/'/g, "\\'")}')">
|
<tr>
|
||||||
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
|
<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 text-sm text-gray-600">${escapeHtml(item.description || item.Description || '—')}</td>
|
||||||
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.lot_name || item.LotName || '—')}</td>
|
<td class="px-4 py-2">
|
||||||
<td class="px-4 py-2 text-right">
|
<div class="flex justify-end items-center gap-3">
|
||||||
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" class="text-red-600 hover:text-red-800 text-sm">Удалить</button>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -1143,19 +1376,23 @@ async function loadStockMappings(page = 1) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectStockMappingRow(partnumber) {
|
function applyStockMappingsSearch() {
|
||||||
const normalized = (partnumber || '').trim().toLowerCase();
|
stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
|
||||||
const row = stockMappingsCache.find(item => ((item.partnumber || item.Partnumber || '').trim().toLowerCase() === normalized));
|
loadStockMappings(1);
|
||||||
if (!row) return;
|
|
||||||
document.getElementById('mapping-partnumber').value = (row.partnumber || row.Partnumber || '').trim();
|
|
||||||
document.getElementById('mapping-lotname').value = (row.lot_name || row.LotName || '').trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveStockMapping() {
|
function onStockMappingsSearchInput() {
|
||||||
const partnumber = document.getElementById('mapping-partnumber').value.trim();
|
clearTimeout(stockMappingsSearchTimer);
|
||||||
const lotName = document.getElementById('mapping-lotname').value.trim();
|
stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250);
|
||||||
if (!partnumber || !lotName) {
|
}
|
||||||
showToast('Заполните partnumber и lot_name', 'error');
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1166,10 +1403,103 @@ async function saveStockMapping() {
|
|||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
|
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
|
||||||
document.getElementById('mapping-partnumber').value = '';
|
|
||||||
document.getElementById('mapping-lotname').value = '';
|
|
||||||
showToast('Сопоставление сохранено', 'success');
|
showToast('Сопоставление сохранено', 'success');
|
||||||
await loadStockMappings(1);
|
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) {
|
} catch (e) {
|
||||||
showToast('Ошибка: ' + e.message, 'error');
|
showToast('Ошибка: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -1190,6 +1520,33 @@ async function deleteStockMapping(partnumber) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await checkPricelistWritePermission();
|
await checkPricelistWritePermission();
|
||||||
// Check URL params for initial tab
|
// Check URL params for initial tab
|
||||||
@@ -1198,6 +1555,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if (initialTab === 'pricelists') initialTab = 'estimate';
|
if (initialTab === 'pricelists') initialTab = 'estimate';
|
||||||
if (initialTab === 'components') initialTab = 'component-settings';
|
if (initialTab === 'components') initialTab = 'component-settings';
|
||||||
await loadTab(initialTab);
|
await loadTab(initialTab);
|
||||||
|
const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
|
||||||
|
if (stockMappingsSearchEl) {
|
||||||
|
stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput);
|
||||||
|
}
|
||||||
|
|
||||||
// Add event listeners for preview updates
|
// Add event listeners for preview updates
|
||||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||||
|
|||||||
Reference in New Issue
Block a user