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("/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)
|
||||
|
||||
@@ -997,6 +997,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
||||
"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,
|
||||
@@ -1029,6 +1031,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
||||
"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,
|
||||
@@ -1078,7 +1082,7 @@ func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Partnumber string `json:"partnumber" binding:"required"`
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
LotName string `json:"lot_name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Current int `json:"current,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
RowsTotal int `json:"rows_total,omitempty"`
|
||||
ValidRows int `json:"valid_rows,omitempty"`
|
||||
Inserted int `json:"inserted,omitempty"`
|
||||
Deleted int64 `json:"deleted,omitempty"`
|
||||
Unmapped int `json:"unmapped,omitempty"`
|
||||
Conflicts int `json:"conflicts,omitempty"`
|
||||
FallbackMatches int `json:"fallback_matches,omitempty"`
|
||||
ParseErrors int `json:"parse_errors,omitempty"`
|
||||
ImportDate string `json:"import_date,omitempty"`
|
||||
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Current int `json:"current,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
RowsTotal int `json:"rows_total,omitempty"`
|
||||
ValidRows int `json:"valid_rows,omitempty"`
|
||||
Inserted int `json:"inserted,omitempty"`
|
||||
Deleted int64 `json:"deleted,omitempty"`
|
||||
Unmapped int `json:"unmapped,omitempty"`
|
||||
Conflicts int `json:"conflicts,omitempty"`
|
||||
FallbackMatches int `json:"fallback_matches,omitempty"`
|
||||
ParseErrors int `json:"parse_errors,omitempty"`
|
||||
Ignored int `json:"ignored,omitempty"`
|
||||
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
|
||||
ImportDate string `json:"import_date,omitempty"`
|
||||
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
|
||||
}
|
||||
|
||||
type StockImportResult struct {
|
||||
RowsTotal int
|
||||
ValidRows int
|
||||
Inserted int
|
||||
Deleted int64
|
||||
Unmapped int
|
||||
Conflicts int
|
||||
FallbackMatches int
|
||||
ParseErrors int
|
||||
ImportDate time.Time
|
||||
WarehousePLID uint
|
||||
WarehousePLVer string
|
||||
RowsTotal int
|
||||
ValidRows int
|
||||
Inserted int
|
||||
Deleted int64
|
||||
Unmapped int
|
||||
Conflicts int
|
||||
FallbackMatches int
|
||||
ParseErrors int
|
||||
Ignored int
|
||||
MappingSuggestions []StockMappingSuggestion
|
||||
ImportDate time.Time
|
||||
WarehousePLID uint
|
||||
WarehousePLVer string
|
||||
}
|
||||
|
||||
type pendingMapping struct {
|
||||
Partnumber string
|
||||
Description string
|
||||
type StockMappingSuggestion struct {
|
||||
Partnumber string `json:"partnumber"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type stockIgnoreRule struct {
|
||||
Target string
|
||||
MatchType string
|
||||
Pattern string
|
||||
}
|
||||
|
||||
type StockImportService struct {
|
||||
@@ -128,26 +139,42 @@ func (s *StockImportService) Import(
|
||||
conflicts int
|
||||
fallbackMatches 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 {
|
||||
if strings.TrimSpace(row.Article) == "" {
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
if shouldIgnoreStockRow(row, ignoreRules) {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
lot, matchType, resolveErr := resolver.resolve(row.Article)
|
||||
if resolveErr != nil {
|
||||
trimmedPN := strings.TrimSpace(row.Article)
|
||||
if trimmedPN != "" {
|
||||
key := normalizeKey(trimmedPN)
|
||||
if key != "" {
|
||||
candidate := pendingMapping{
|
||||
reason := "unmapped"
|
||||
if errors.Is(resolveErr, errResolveConflict) {
|
||||
reason = "conflict"
|
||||
}
|
||||
candidate := StockMappingSuggestion{
|
||||
Partnumber: trimmedPN,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: reason,
|
||||
}
|
||||
if prev, ok := pendingByPN[key]; !ok || (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") {
|
||||
pendingByPN[key] = candidate
|
||||
if prev, ok := suggestionsByPN[key]; !ok ||
|
||||
(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 {
|
||||
pending := make([]pendingMapping, 0, len(pendingByPN))
|
||||
for _, m := range pendingByPN {
|
||||
pending = append(pending, m)
|
||||
}
|
||||
if err := s.upsertPendingMappings(pending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("no valid rows after mapping")
|
||||
@@ -256,35 +275,39 @@ func (s *StockImportService) Import(
|
||||
warehousePLVer = pl.Version
|
||||
|
||||
result := &StockImportResult{
|
||||
RowsTotal: len(rows),
|
||||
ValidRows: len(records),
|
||||
Inserted: inserted,
|
||||
Deleted: deleted,
|
||||
Unmapped: unmapped,
|
||||
Conflicts: conflicts,
|
||||
FallbackMatches: fallbackMatches,
|
||||
ParseErrors: parseErrors,
|
||||
ImportDate: importDate,
|
||||
WarehousePLID: warehousePLID,
|
||||
WarehousePLVer: warehousePLVer,
|
||||
RowsTotal: len(rows),
|
||||
ValidRows: len(records),
|
||||
Inserted: inserted,
|
||||
Deleted: deleted,
|
||||
Unmapped: unmapped,
|
||||
Conflicts: conflicts,
|
||||
FallbackMatches: fallbackMatches,
|
||||
ParseErrors: parseErrors,
|
||||
Ignored: ignored,
|
||||
MappingSuggestions: suggestions,
|
||||
ImportDate: importDate,
|
||||
WarehousePLID: warehousePLID,
|
||||
WarehousePLVer: warehousePLVer,
|
||||
}
|
||||
|
||||
report(StockImportProgress{
|
||||
Status: "completed",
|
||||
Message: "Импорт завершен",
|
||||
RowsTotal: result.RowsTotal,
|
||||
ValidRows: result.ValidRows,
|
||||
Inserted: result.Inserted,
|
||||
Deleted: result.Deleted,
|
||||
Unmapped: result.Unmapped,
|
||||
Conflicts: result.Conflicts,
|
||||
FallbackMatches: result.FallbackMatches,
|
||||
ParseErrors: result.ParseErrors,
|
||||
ImportDate: result.ImportDate.Format("2006-01-02"),
|
||||
PricelistID: result.WarehousePLID,
|
||||
PricelistVer: result.WarehousePLVer,
|
||||
Current: 100,
|
||||
Total: 100,
|
||||
Status: "completed",
|
||||
Message: "Импорт завершен",
|
||||
RowsTotal: result.RowsTotal,
|
||||
ValidRows: result.ValidRows,
|
||||
Inserted: result.Inserted,
|
||||
Deleted: result.Deleted,
|
||||
Unmapped: result.Unmapped,
|
||||
Conflicts: result.Conflicts,
|
||||
FallbackMatches: result.FallbackMatches,
|
||||
ParseErrors: result.ParseErrors,
|
||||
Ignored: result.Ignored,
|
||||
MappingSuggestions: result.MappingSuggestions,
|
||||
ImportDate: result.ImportDate.Format("2006-01-02"),
|
||||
PricelistID: result.WarehousePLID,
|
||||
PricelistVer: result.WarehousePLVer,
|
||||
Current: 100,
|
||||
Total: 100,
|
||||
})
|
||||
|
||||
return result, nil
|
||||
@@ -382,16 +405,18 @@ func (s *StockImportService) UpsertMapping(partnumber, lotName, description stri
|
||||
partnumber = strings.TrimSpace(partnumber)
|
||||
lotName = strings.TrimSpace(lotName)
|
||||
description = strings.TrimSpace(description)
|
||||
if partnumber == "" || lotName == "" {
|
||||
return fmt.Errorf("partnumber and lot_name are required")
|
||||
if partnumber == "" {
|
||||
return fmt.Errorf("partnumber is required")
|
||||
}
|
||||
|
||||
var lotCount int64
|
||||
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if lotCount == 0 {
|
||||
return fmt.Errorf("lot not found: %s", lotName)
|
||||
if lotName != "" {
|
||||
var lotCount int64
|
||||
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if lotCount == 0 {
|
||||
return fmt.Errorf("lot not found: %s", lotName)
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -434,55 +459,153 @@ func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) {
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
func (s *StockImportService) upsertPendingMappings(rows []pendingMapping) error {
|
||||
if s.db == nil || len(rows) == 0 {
|
||||
func (s *StockImportService) ListIgnoreRules(page, perPage int) ([]models.StockIgnoreRule, int64, error) {
|
||||
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 s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, row := range rows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
items := make([]StockMappingSuggestion, 0, len(src))
|
||||
for _, item := range src {
|
||||
items = append(items, item)
|
||||
}
|
||||
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)
|
||||
var existing []models.LotPartnumber
|
||||
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
case "prefix":
|
||||
if strings.HasPrefix(candidate, rule.Pattern) {
|
||||
return true
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
var descPtr *string
|
||||
if desc != "" {
|
||||
descPtr = &desc
|
||||
}
|
||||
if err := tx.Create(&models.LotPartnumber{
|
||||
Partnumber: pn,
|
||||
LotName: "",
|
||||
Description: descPtr,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if desc == "" {
|
||||
continue
|
||||
}
|
||||
needsDescription := true
|
||||
for _, item := range existing {
|
||||
if item.Description != nil && strings.TrimSpace(*item.Description) != "" {
|
||||
needsDescription = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsDescription {
|
||||
if err := tx.Model(&models.LotPartnumber{}).
|
||||
Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).
|
||||
Update("description", desc).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case "suffix":
|
||||
if strings.HasSuffix(candidate, rule.Pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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 (
|
||||
|
||||
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 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="mapping-partnumber" type="text" placeholder="partnumber" class="px-3 py-2 border rounded w-1/3">
|
||||
<input id="mapping-lotname" type="text" placeholder="lot_name" class="px-3 py-2 border rounded w-1/3">
|
||||
<button onclick="saveStockMapping()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||
<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 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">LOT</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -137,6 +169,40 @@
|
||||
</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>
|
||||
|
||||
@@ -309,6 +375,10 @@ 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;
|
||||
@@ -341,6 +411,8 @@ async function loadTab(tab) {
|
||||
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';
|
||||
@@ -1046,7 +1118,9 @@ async function importStockFile() {
|
||||
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%';
|
||||
@@ -1082,16 +1156,19 @@ async function importStockFile() {
|
||||
statusEl.textContent = data.message || data.status || 'Обработка';
|
||||
statsEl.textContent =
|
||||
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
|
||||
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0)} | ` +
|
||||
`Конфликты: ${data.conflicts || 0}`;
|
||||
`Пропущено: ${(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
stockMappingsPage = page;
|
||||
const body = document.getElementById('stock-mappings-body');
|
||||
const pagination = document.getElementById('stock-mappings-pagination');
|
||||
if (!body || !pagination) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20`);
|
||||
const 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 || [];
|
||||
@@ -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>';
|
||||
} else {
|
||||
body.innerHTML = items.map(item => `
|
||||
<tr class="cursor-pointer hover:bg-gray-50" onclick="selectStockMappingRow('${escapeHtml((item.partnumber || item.Partnumber || '')).replace(/'/g, "\\'")}')">
|
||||
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
|
||||
<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 text-sm font-mono">${escapeHtml(item.lot_name || item.LotName || '—')}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<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>
|
||||
<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('');
|
||||
@@ -1143,19 +1376,23 @@ async function loadStockMappings(page = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectStockMappingRow(partnumber) {
|
||||
const normalized = (partnumber || '').trim().toLowerCase();
|
||||
const row = stockMappingsCache.find(item => ((item.partnumber || item.Partnumber || '').trim().toLowerCase() === normalized));
|
||||
if (!row) return;
|
||||
document.getElementById('mapping-partnumber').value = (row.partnumber || row.Partnumber || '').trim();
|
||||
document.getElementById('mapping-lotname').value = (row.lot_name || row.LotName || '').trim();
|
||||
function applyStockMappingsSearch() {
|
||||
stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
|
||||
loadStockMappings(1);
|
||||
}
|
||||
|
||||
async function saveStockMapping() {
|
||||
const partnumber = document.getElementById('mapping-partnumber').value.trim();
|
||||
const lotName = document.getElementById('mapping-lotname').value.trim();
|
||||
if (!partnumber || !lotName) {
|
||||
showToast('Заполните partnumber и lot_name', 'error');
|
||||
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 {
|
||||
@@ -1166,10 +1403,103 @@ async function saveStockMapping() {
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
|
||||
document.getElementById('mapping-partnumber').value = '';
|
||||
document.getElementById('mapping-lotname').value = '';
|
||||
showToast('Сопоставление сохранено', 'success');
|
||||
await loadStockMappings(1);
|
||||
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');
|
||||
}
|
||||
@@ -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 () => {
|
||||
await checkPricelistWritePermission();
|
||||
// Check URL params for initial tab
|
||||
@@ -1198,6 +1555,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user