package services import ( "fmt" "strings" "time" "git.mchus.pro/mchus/priceforge/internal/dbutil" "git.mchus.pro/mchus/priceforge/internal/lotmatch" "git.mchus.pro/mchus/priceforge/internal/models" pricelistsvc "git.mchus.pro/mchus/priceforge/internal/services/pricelist" "git.mchus.pro/mchus/priceforge/internal/warehouse" "gorm.io/gorm" "gorm.io/gorm/clause" ) type StockImportProgress struct { Status string `json:"status"` Message string `json:"message,omitempty"` Current int `json:"current,omitempty"` Total int `json:"total,omitempty"` RowsTotal int `json:"rows_total,omitempty"` ValidRows int `json:"valid_rows,omitempty"` Inserted int `json:"inserted,omitempty"` Deleted int64 `json:"deleted,omitempty"` Unmapped int `json:"unmapped,omitempty"` Conflicts int `json:"conflicts,omitempty"` AutoMapped int `json:"auto_mapped,omitempty"` ParseErrors int `json:"parse_errors,omitempty"` QtyParseErrors int `json:"qty_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 AutoMapped int ParseErrors int QtyParseErrors int Ignored int MappingSuggestions []StockMappingSuggestion ImportDate time.Time WarehousePLID uint WarehousePLVer string } type StockMappingSuggestion struct { Partnumber string `json:"partnumber"` Description string `json:"description,omitempty"` Reason string `json:"reason,omitempty"` } type StockMappingRow struct { Partnumber string `json:"partnumber"` LotName string `json:"lot_name"` Description *string `json:"description,omitempty"` } type stockIgnoreRule struct { Target string MatchType string Pattern string } type StockImportService struct { db *gorm.DB pricelistSvc *pricelistsvc.Service } func NewStockImportService(db *gorm.DB, pricelistSvc *pricelistsvc.Service) *StockImportService { return &StockImportService{ db: db, pricelistSvc: pricelistSvc, } } type stockImportRow struct { Folder string Article string Description string Vendor string Price float64 Qty float64 QtyRaw string QtyInvalid bool } type weightedPricePoint struct { price float64 weight float64 } func (s *StockImportService) Import( filename string, content []byte, fileModTime time.Time, createdBy string, createPricelist bool, onProgress func(StockImportProgress), ) (*StockImportResult, error) { fmt.Printf("[StockImport] Starting import: filename=%s, size=%d bytes, create_pricelist=%v\n", filename, len(content), createPricelist) if s.db == nil { fmt.Println("[StockImport] ERROR: database is nil (offline mode)") return nil, fmt.Errorf("offline mode: stock import unavailable") } if len(content) == 0 { fmt.Println("[StockImport] ERROR: empty file") return nil, fmt.Errorf("empty file") } report := func(p StockImportProgress) { if onProgress != nil { onProgress(p) } } report(StockImportProgress{Status: "starting", Message: "Запуск импорта", Current: 0, Total: 100}) fmt.Println("[StockImport] Progress: starting") rows, err := parseStockRows(filename, content) if err != nil { fmt.Printf("[StockImport] ERROR parsing file: %v\n", err) return nil, err } if len(rows) == 0 { fmt.Println("[StockImport] ERROR: no rows parsed") return nil, fmt.Errorf("no rows parsed") } fmt.Printf("[StockImport] Parsed %d rows\n", len(rows)) report(StockImportProgress{Status: "parsing", Message: "Файл распарсен", RowsTotal: len(rows), Current: 10, Total: 100}) importDate := detectImportDate(content, filename, fileModTime) report(StockImportProgress{ Status: "parsing", Message: "Дата импорта определена", ImportDate: importDate.Format("2006-01-02"), Current: 15, Total: 100, }) partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db) if err != nil { return nil, err } var ( records []models.StockLog unmapped int conflicts int autoMapped int parseErrors int qtyParseErrors int ignored int suggestionsByPN = make(map[string]StockMappingSuggestion) autoMappingsToAdd = make(map[string]models.PartnumberBookItem) // key -> mapping seenRowsToUpsert = make(map[string]models.VendorPartnumberSeen) ) ignoredSeenIndex, err := s.loadIgnoredSeenIndex() if err != nil { return nil, err } for _, row := range rows { if strings.TrimSpace(row.Article) == "" { parseErrors++ continue } if row.QtyInvalid { qtyParseErrors++ parseErrors++ continue } partnumber := strings.TrimSpace(row.Article) vendorRaw := strings.TrimSpace(row.Vendor) seenKey := normalizeKey(partnumber) if seenKey == "" { continue } seen := models.VendorPartnumberSeen{ SourceType: "stock", Vendor: vendorRaw, Partnumber: partnumber, LastSeenAt: time.Now(), } if trimmed := strings.TrimSpace(row.Description); trimmed != "" { seen.Description = &trimmed } if existing, ok := seenRowsToUpsert[seenKey]; ok { if strings.TrimSpace(existing.Vendor) == "" && strings.TrimSpace(seen.Vendor) != "" { existing.Vendor = strings.TrimSpace(seen.Vendor) } if (existing.Description == nil || strings.TrimSpace(*existing.Description) == "") && seen.Description != nil && strings.TrimSpace(*seen.Description) != "" { existing.Description = seen.Description } seenRowsToUpsert[seenKey] = existing } else { seenRowsToUpsert[seenKey] = seen } if isIgnoredBySeenIndex(ignoredSeenIndex, vendorRaw, partnumber) { ignored++ continue } key := normalizeKey(partnumber) description := strings.TrimSpace(row.Description) // Check if already mapped mappedLots := partnumberMatcher.MatchLotsWithVendor(partnumber, vendorRaw) if len(mappedLots) == 0 { // Try to auto-map based on prefix match if matchedLot := partnumberMatcher.FindPrefixMatch(partnumber); matchedLot != "" { // Collect for batch insert later descPtr := &description if description == "" { descPtr = nil } autoMappingsToAdd[strings.ToLower(vendorRaw)+"|"+key] = models.PartnumberBookItem{ Partnumber: partnumber, LotsJSON: fmt.Sprintf(`[{"lot_name":%q,"qty":1}]`, matchedLot), Description: descPtr, } autoMapped++ } else { // No prefix match found unmapped++ suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ Partnumber: partnumber, Description: description, Reason: "unmapped", }) } } var comments *string if trimmed := strings.TrimSpace(row.Description); trimmed != "" { comments = &trimmed } var vendor *string if trimmed := strings.TrimSpace(row.Vendor); trimmed != "" { vendor = &trimmed } qty := row.Qty records = append(records, models.StockLog{ Partnumber: partnumber, Date: importDate, Price: row.Price, Comments: comments, Vendor: vendor, Qty: &qty, }) } if err := s.upsertSeenRows(seenRowsToUpsert); err != nil { return nil, err } // Batch create auto-mappings (unique by vendor+partnumber) if len(autoMappingsToAdd) > 0 { mappingsToInsert := make([]models.PartnumberBookItem, 0, len(autoMappingsToAdd)) for _, mapping := range autoMappingsToAdd { mappingsToInsert = append(mappingsToInsert, mapping) } if len(mappingsToInsert) > 0 { query := dbutil.WithTimeout(s.db, 30*time.Second) if err := query.Execute(func(db *gorm.DB) error { return db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(mappingsToInsert, 100).Error }); err != nil { // Keep import robust: mappings will remain unmapped and visible in suggestions. } } } suggestions := collectSortedSuggestions(suggestionsByPN, 200) if len(records) == 0 { return nil, fmt.Errorf("no valid rows after filtering") } report(StockImportProgress{ Status: "mapping", Message: "Валидация строк завершена", RowsTotal: len(rows), ValidRows: len(records), Unmapped: unmapped, Conflicts: conflicts, AutoMapped: autoMapped, ParseErrors: parseErrors, QtyParseErrors: qtyParseErrors, Current: 40, Total: 100, }) deleted, inserted, err := s.replaceStockLogs(records) if err != nil { fmt.Printf("[StockImport] ERROR replacing stock logs: %v\n", err) return nil, err } fmt.Printf("[StockImport] Stock logs updated: deleted=%d, inserted=%d\n", deleted, inserted) var warehousePLID uint var warehousePLVer string if createPricelist { report(StockImportProgress{ Status: "writing", Message: "Данные stock_log обновлены", Inserted: inserted, Deleted: deleted, Current: 50, Total: 100, ImportDate: importDate.Format("2006-01-02"), }) // Build warehouse pricelist items items, err := s.buildWarehousePricelistItems() if err != nil { fmt.Printf("[StockImport] ERROR building warehouse pricelist items: %v\n", err) return nil, err } if len(items) == 0 { fmt.Println("[StockImport] WARNING: no items for warehouse pricelist, skipping pricelist creation") // Don't fail, just skip pricelist creation } else { if createdBy == "" { createdBy = "unknown" } report(StockImportProgress{Status: "creating_pricelist", Message: "Создание warehouse прайслиста", Current: 60, Total: 100}) if s.pricelistSvc == nil { fmt.Println("[StockImport] ERROR: pricelist service unavailable") return nil, fmt.Errorf("pricelist service unavailable") } fmt.Printf("[StockImport] Creating warehouse pricelist with %d items\n", len(items)) pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) { current := 60 + int(float64(p.Current)/float64(p.Total)*30) if current >= 100 { current = 99 } report(StockImportProgress{ Status: "creating_pricelist", Message: p.Message, Current: current, Total: 100, }) }) if err != nil { fmt.Printf("[StockImport] ERROR creating warehouse pricelist: %v\n", err) return nil, err } warehousePLID = pl.ID warehousePLVer = pl.Version fmt.Printf("[StockImport] Warehouse pricelist created: id=%d, version=%s\n", warehousePLID, warehousePLVer) } } else { fmt.Println("[StockImport] Skipping pricelist creation (createPricelist=false)") report(StockImportProgress{ Status: "writing", Message: "Данные stock_log обновлены", Inserted: inserted, Deleted: deleted, Current: 100, Total: 100, ImportDate: importDate.Format("2006-01-02"), }) } result := &StockImportResult{ RowsTotal: len(rows), ValidRows: len(records), Inserted: inserted, Deleted: deleted, Unmapped: unmapped, Conflicts: conflicts, AutoMapped: autoMapped, ParseErrors: parseErrors, QtyParseErrors: qtyParseErrors, Ignored: ignored, MappingSuggestions: suggestions, ImportDate: importDate, WarehousePLID: warehousePLID, WarehousePLVer: warehousePLVer, } fmt.Printf("[StockImport] Import completed successfully: inserted=%d, deleted=%d, unmapped=%d, conflicts=%d\n", inserted, deleted, unmapped, conflicts) report(StockImportProgress{ Status: "completed", Message: "Импорт завершен", RowsTotal: result.RowsTotal, ValidRows: result.ValidRows, Inserted: result.Inserted, Deleted: result.Deleted, Unmapped: result.Unmapped, Conflicts: result.Conflicts, AutoMapped: result.AutoMapped, ParseErrors: result.ParseErrors, QtyParseErrors: result.QtyParseErrors, 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 } func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64, int, error) { var deleted int64 // Use longer timeout for large batch operations (up to 60 seconds) query := dbutil.WithTimeout(s.db, 60*time.Second) query.RetryAttempts = 0 // Don't retry transactions err := query.Execute(func(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { res := tx.Exec("DELETE FROM stock_log") if res.Error != nil { return res.Error } deleted = res.RowsAffected if err := tx.CreateInBatches(records, 500).Error; err != nil { return err } return nil }) }) if err != nil { return 0, 0, err } return deleted, len(records), nil } func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) { warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db) if err != nil { return nil, err } items := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems)) for _, item := range warehouseItems { items = append(items, pricelistsvc.CreateItemInput{ LotName: item.LotName, Price: item.Price, PriceMethod: item.PriceMethod, PricePeriodDays: 0, }) } return items, nil }