package services import ( "errors" "fmt" "log/slog" "sort" "strconv" "strings" "time" "git.mchus.pro/mchus/priceforge/internal/models" "gorm.io/gorm" ) // Retention policy: how many snapshots to keep per tier. const ( retainDaily = 7 retainWeekly = 5 retainMonthly = 12 retainYearly = 10 ) // PartnumberBookService creates versioned snapshots of the partnumber→LOT mapping // for consumption by QuoteForge via qt_partnumber_books / qt_partnumber_book_items. type PartnumberBookService struct { db *gorm.DB } func NewPartnumberBookService(db *gorm.DB) *PartnumberBookService { return &PartnumberBookService{db: db} } type PartnumberBookProgress struct { Current int Total int Status string Message string } // PartnumberBookSummary is returned by ListBooks. type PartnumberBookSummary struct { ID uint64 `json:"id"` Version string `json:"version"` CreatedAt time.Time `json:"created_at"` CreatedBy string `json:"created_by"` IsActive bool `json:"is_active"` ItemCount int `json:"item_count"` } // ListBooks returns all partnumber books ordered newest-first with item counts. func (s *PartnumberBookService) ListBooks() ([]PartnumberBookSummary, error) { if s.db == nil { return nil, fmt.Errorf("offline mode") } type row struct { models.PartnumberBook ItemCount int `gorm:"column:item_count"` } var rows []row if err := s.db.Model(&models.PartnumberBook{}). Select("qt_partnumber_books.*, COUNT(qt_partnumber_book_items.id) AS item_count"). Joins("LEFT JOIN qt_partnumber_book_items ON qt_partnumber_book_items.book_id = qt_partnumber_books.id"). Group("qt_partnumber_books.id"). Order("qt_partnumber_books.created_at DESC"). Find(&rows).Error; err != nil { return nil, fmt.Errorf("listing partnumber books: %w", err) } result := make([]PartnumberBookSummary, len(rows)) for i, r := range rows { result[i] = PartnumberBookSummary{ ID: r.PartnumberBook.ID, Version: r.PartnumberBook.Version, CreatedAt: r.PartnumberBook.CreatedAt, CreatedBy: r.PartnumberBook.CreatedBy, IsActive: r.PartnumberBook.IsActive, ItemCount: r.ItemCount, } } return result, nil } // CreateSnapshot takes a snapshot of all active partnumber→LOT mappings, // expands bundles, writes qt_partnumber_book_items, activates the new book, // deactivates all previous books, then applies the retention policy. func (s *PartnumberBookService) CreateSnapshot(createdBy string, onProgress func(PartnumberBookProgress)) (*models.PartnumberBook, error) { if s.db == nil { return nil, fmt.Errorf("offline mode: cannot create partnumber book") } report := func(p PartnumberBookProgress) { if onProgress != nil { onProgress(p) } } report(PartnumberBookProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"}) // 1. Load all partnumber mappings. var mappings []models.LotPartnumber if err := s.db.Find(&mappings).Error; err != nil { return nil, fmt.Errorf("loading lot_partnumbers: %w", err) } report(PartnumberBookProgress{Current: 10, Total: 100, Status: "running", Message: fmt.Sprintf("Загружено маппингов: %d", len(mappings))}) // 2. Identify bundle LOT names. bundleSet, err := s.loadBundleSet() if err != nil { return nil, err } // 3. Expand bundles into book items. items, err := s.expandMappings(mappings, bundleSet) if err != nil { return nil, err } report(PartnumberBookProgress{Current: 40, Total: 100, Status: "running", Message: fmt.Sprintf("Строк в снимке: %d", len(items))}) // 4. Generate version string (format: PNBOOK-YYYY-MM-DD-NNN). version, err := s.generateVersion() if err != nil { return nil, err } // 5. Persist inside a transaction. var book models.PartnumberBook err = s.db.Transaction(func(tx *gorm.DB) error { // Deactivate all existing books. if err := tx.Model(&models.PartnumberBook{}). Where("is_active = ?", true). Update("is_active", false).Error; err != nil { return fmt.Errorf("deactivating old books: %w", err) } // Create header. book = models.PartnumberBook{ Version: version, CreatedBy: createdBy, IsActive: true, } if err := tx.Create(&book).Error; err != nil { return fmt.Errorf("creating partnumber book: %w", err) } // Attach book_id and bulk-insert items. for i := range items { items[i].BookID = book.ID } const batchSize = 500 for i := 0; i < len(items); i += batchSize { end := i + batchSize if end > len(items) { end = len(items) } if err := tx.Create(items[i:end]).Error; err != nil { return fmt.Errorf("inserting partnumber book items (batch %d): %w", i/batchSize, err) } } return nil }) if err != nil { return nil, err } report(PartnumberBookProgress{Current: 80, Total: 100, Status: "running", Message: "Применение политики хранения..."}) // 6. Apply retention policy (non-fatal: log errors but don't fail the snapshot). if deleted, retErr := s.applyRetention(); retErr != nil { slog.Warn("partnumber book retention cleanup failed", "error", retErr) } else if deleted > 0 { slog.Info("partnumber book retention: deleted old snapshots", "count", deleted) } report(PartnumberBookProgress{Current: 100, Total: 100, Status: "completed", Message: fmt.Sprintf("Снимок %s создан (%d строк)", version, len(items))}) slog.Info("partnumber book snapshot created", "version", version, "items", len(items), "book_id", book.ID) return &book, nil } // applyRetention implements GFS (Grandfather-Father-Son) retention: // // 7 daily (most recent snapshot per calendar day, last 7 days) // 5 weekly (most recent per ISO week, beyond daily window) // // 12 monthly (most recent per month, beyond weekly window) // 10 yearly (most recent per year, beyond monthly window) // // Everything outside these windows is deleted. // The active book is never deleted. func (s *PartnumberBookService) applyRetention() (int, error) { var all []models.PartnumberBook if err := s.db.Order("created_at DESC").Find(&all).Error; err != nil { return 0, fmt.Errorf("loading all books for retention: %w", err) } keep := retentionKeepSet(all) var toDelete []uint64 for _, b := range all { if b.IsActive { continue // never delete the active book } if !keep[b.ID] { toDelete = append(toDelete, b.ID) } } if len(toDelete) == 0 { return 0, nil } return len(toDelete), s.db.Transaction(func(tx *gorm.DB) error { // Delete items explicitly — do not rely on FK CASCADE. if err := tx.Where("book_id IN ?", toDelete).Delete(&models.PartnumberBookItem{}).Error; err != nil { return fmt.Errorf("deleting book items: %w", err) } if err := tx.Where("id IN ?", toDelete).Delete(&models.PartnumberBook{}).Error; err != nil { return fmt.Errorf("deleting old books: %w", err) } return nil }) } // retentionKeepSet returns the set of book IDs to keep according to GFS policy. // Books are expected to be sorted newest-first. func retentionKeepSet(books []models.PartnumberBook) map[uint64]bool { keep := make(map[uint64]bool) // Sort newest first (should already be, but be defensive). sorted := make([]models.PartnumberBook, len(books)) copy(sorted, books) sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) }) dailySeen := make(map[string]int) // "YYYY-MM-DD" → count kept weeklySeen := make(map[string]int) // "YYYY-WW" → count kept monthlySeen := make(map[string]int) // "YYYY-MM" → count kept yearlySeen := make(map[string]int) // "YYYY" → count kept for _, b := range sorted { t := b.CreatedAt dayKey := t.Format("2006-01-02") year, week := t.ISOWeek() weekKey := fmt.Sprintf("%d-%02d", year, week) monthKey := t.Format("2006-01") yearKey := t.Format("2006") if dailySeen[dayKey] < retainDaily { keep[b.ID] = true dailySeen[dayKey]++ continue } if weeklySeen[weekKey] < retainWeekly { keep[b.ID] = true weeklySeen[weekKey]++ continue } if monthlySeen[monthKey] < retainMonthly { keep[b.ID] = true monthlySeen[monthKey]++ continue } if yearlySeen[yearKey] < retainYearly { keep[b.ID] = true yearlySeen[yearKey]++ continue } // Falls outside all retention windows → will be deleted. } return keep } // loadBundleSet returns a set of all bundle_lot_name values. func (s *PartnumberBookService) loadBundleSet() (map[string]bool, error) { var bundleNames []string if err := s.db.Model(&models.LotBundle{}).Pluck("bundle_lot_name", &bundleNames).Error; err != nil { return nil, fmt.Errorf("loading bundle names: %w", err) } set := make(map[string]bool, len(bundleNames)) for _, n := range bundleNames { set[n] = true } return set, nil } // expandMappings converts lot_partnumbers rows into book items, expanding bundles. // Rows with empty lot_name and ignored partnumbers are excluded. func (s *PartnumberBookService) expandMappings(mappings []models.LotPartnumber, bundleSet map[string]bool) ([]models.PartnumberBookItem, error) { // Pre-load all bundle items keyed by bundle_lot_name. var rawItems []models.LotBundleItem if err := s.db.Find(&rawItems).Error; err != nil { return nil, fmt.Errorf("loading bundle items: %w", err) } bundleItems := make(map[string][]models.LotBundleItem, len(rawItems)) for _, bi := range rawItems { bundleItems[bi.BundleLotName] = append(bundleItems[bi.BundleLotName], bi) } // Build ignored partnumber set from qt_vendor_partnumber_seen. var ignoredPNs []string if err := s.db.Model(&models.VendorPartnumberSeen{}). Where("is_ignored = ?", true). Pluck("partnumber", &ignoredPNs).Error; err != nil { return nil, fmt.Errorf("loading ignored partnumbers: %w", err) } ignoredSet := make(map[string]bool, len(ignoredPNs)) for _, pn := range ignoredPNs { ignoredSet[pn] = true } var result []models.PartnumberBookItem for _, m := range mappings { // Skip ignored partnumbers. if ignoredSet[m.Partnumber] { continue } // Skip mappings without a resolved lot_name. if strings.TrimSpace(m.LotName) == "" { continue } if bundleSet[m.LotName] { // Expand bundle: emit one item per component LOT. // Description from lot_partnumbers is carried to all expanded rows. components := bundleItems[m.LotName] if len(components) == 0 { // Bundle with no items — skip silently. continue } for _, c := range components { result = append(result, models.PartnumberBookItem{ Partnumber: m.Partnumber, LotName: c.LotName, IsPrimaryPN: m.IsPrimaryPN, Description: m.Description, }) } } else { // Direct mapping. result = append(result, models.PartnumberBookItem{ Partnumber: m.Partnumber, LotName: m.LotName, IsPrimaryPN: m.IsPrimaryPN, Description: m.Description, }) } } return result, nil } // generateVersion produces a version string in format PNBOOK-YYYY-MM-DD-NNN. func (s *PartnumberBookService) generateVersion() (string, error) { today := time.Now().Format("2006-01-02") prefix := "PNBOOK-" + today var last models.PartnumberBook err := s.db.Model(&models.PartnumberBook{}). Select("version"). Where("version LIKE ?", prefix+"-%"). Order("version DESC"). Limit(1). Take(&last).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return prefix + "-001", nil } return "", fmt.Errorf("loading latest today's book version: %w", err) } parts := strings.Split(last.Version, "-") n, err := strconv.Atoi(parts[len(parts)-1]) if err != nil { return "", fmt.Errorf("invalid book version format: %s", last.Version) } return fmt.Sprintf("%s-%03d", prefix, n+1), nil }