package services import ( "encoding/json" "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 } var rows []row if err := s.db.Model(&models.PartnumberBook{}). 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 { var pns []string if strings.TrimSpace(r.PartnumbersJSON) != "" { if err := json.Unmarshal([]byte(r.PartnumbersJSON), &pns); err != nil { return nil, fmt.Errorf("decode partnumbers_json for book %d: %w", r.ID, err) } } result[i] = PartnumberBookSummary{ ID: r.PartnumberBook.ID, Version: r.PartnumberBook.Version, CreatedAt: r.PartnumberBook.CreatedAt, CreatedBy: r.PartnumberBook.CreatedBy, IsActive: r.PartnumberBook.IsActive, ItemCount: len(pns), } } return result, nil } // CreateSnapshot takes a snapshot of all active partnumber→LOT mappings, // serializes resolved LOT composition into qt_partnumber_book_items.lots_json, // 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. Build current canonical PN catalog. items, err := s.buildSnapshotItems() if err != nil { return nil, err } report(PartnumberBookProgress{Current: 10, Total: 100, Status: "running", Message: fmt.Sprintf("Строк в каталоге: %d", len(items))}) // 2. Filter to the current snapshot payload. report(PartnumberBookProgress{Current: 40, Total: 100, Status: "running", Message: fmt.Sprintf("Строк в снимке: %d", len(items))}) // 3. Generate version string (format: PNBOOK-YYYY-MM-DD-NNN). report(PartnumberBookProgress{Current: 50, Total: 100, Status: "running", Message: "Подготовка версии снимка..."}) version, err := s.generateVersion() if err != nil { return nil, err } partnumbersJSON, err := collectPartnumbersJSON(items) if err != nil { return nil, err } // 4. Persist inside a transaction. report(PartnumberBookProgress{Current: 60, Total: 100, Status: "running", Message: "Запись снимка в базу..."}) 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, PartnumbersJSON: partnumbersJSON, } if err := tx.Create(&book).Error; err != nil { return fmt.Errorf("creating partnumber book: %w", err) } report(PartnumberBookProgress{Current: 75, Total: 100, Status: "running", Message: "Обновление каталога PN..."}) if err := s.upsertCatalogItems(tx, items); err != nil { return err } return nil }) if err != nil { return nil, err } report(PartnumberBookProgress{Current: 90, 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 { if err := tx.Where("id IN ?", toDelete).Delete(&models.PartnumberBook{}).Error; err != nil { return fmt.Errorf("deleting old books: %w", err) } return nil }) } func (s *PartnumberBookService) upsertCatalogItems(tx *gorm.DB, items []models.PartnumberBookItem) error { if len(items) == 0 { return nil } partnumbers := make([]string, 0, len(items)) byPN := make(map[string]models.PartnumberBookItem) for _, item := range items { existing, ok := byPN[item.Partnumber] if ok { if existing.LotsJSON != item.LotsJSON || normalizeDescription(existing.Description) != normalizeDescription(item.Description) { return fmt.Errorf("conflicting snapshot items for partnumber %s", item.Partnumber) } continue } byPN[item.Partnumber] = item partnumbers = append(partnumbers, item.Partnumber) } var existing []models.PartnumberBookItem if err := tx.Where("partnumber IN ?", partnumbers).Find(&existing).Error; err != nil { return fmt.Errorf("loading existing partnumber book items: %w", err) } existingByPN := make(map[string]models.PartnumberBookItem, len(existing)) for _, item := range existing { existingByPN[item.Partnumber] = item } toCreate := make([]models.PartnumberBookItem, 0, len(byPN)) for _, pn := range partnumbers { if _, ok := existingByPN[pn]; ok { continue } toCreate = append(toCreate, byPN[pn]) } if len(toCreate) > 0 { const batchSize = 500 for i := 0; i < len(toCreate); i += batchSize { end := i + batchSize if end > len(toCreate) { end = len(toCreate) } if err := tx.Create(toCreate[i:end]).Error; err != nil { return fmt.Errorf("inserting partnumber book items (batch %d): %w", i/batchSize, err) } } } for _, item := range items { existingItem, ok := existingByPN[item.Partnumber] if !ok { continue } if existingItem.LotsJSON == item.LotsJSON && normalizeDescription(existingItem.Description) == normalizeDescription(item.Description) { continue } updates := map[string]any{ "lots_json": item.LotsJSON, "description": item.Description, } if err := tx.Model(&models.PartnumberBookItem{}). Where("id = ?", existingItem.ID). Updates(updates).Error; err != nil { return fmt.Errorf("updating partnumber book item for %s: %w", item.Partnumber, 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 } // buildSnapshotItems returns the canonical PN catalog for snapshotting. // Source of truth is qt_partnumber_book_items. func (s *PartnumberBookService) buildSnapshotItems() ([]models.PartnumberBookItem, error) { // 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 catalog []models.PartnumberBookItem if err := s.db.Order("partnumber ASC").Find(&catalog).Error; err != nil { return nil, fmt.Errorf("loading partnumber catalog: %w", err) } byPN := make(map[string]models.PartnumberBookItem, len(catalog)) result := make([]models.PartnumberBookItem, 0, len(catalog)) for _, item := range catalog { pn := strings.TrimSpace(item.Partnumber) if pn == "" || ignoredSet[pn] { continue } lots := parseSnapshotLots(item.LotsJSON) if len(lots) == 0 { continue } normalizedJSON, err := marshalSnapshotLots(lots) if err != nil { return nil, fmt.Errorf("normalize lots_json for partnumber %s: %w", pn, err) } item.Partnumber = pn item.LotsJSON = normalizedJSON if prev, ok := byPN[pn]; ok { if prev.LotsJSON != item.LotsJSON || normalizeDescription(prev.Description) != normalizeDescription(item.Description) { return nil, fmt.Errorf("multiple distinct mappings for partnumber %s in partnumber book snapshot", pn) } continue } byPN[pn] = item result = append(result, item) } return result, nil } func parseSnapshotLots(lotsJSON string) []models.PartnumberBookLot { var lots []models.PartnumberBookLot if err := json.Unmarshal([]byte(strings.TrimSpace(lotsJSON)), &lots); err != nil { return nil } out := make([]models.PartnumberBookLot, 0, len(lots)) for _, lot := range lots { name := strings.TrimSpace(lot.LotName) if name == "" || lot.Qty <= 0 { continue } out = append(out, models.PartnumberBookLot{LotName: name, Qty: lot.Qty}) } sort.Slice(out, func(i, j int) bool { return out[i].LotName < out[j].LotName }) return out } func marshalSnapshotLots(lots []models.PartnumberBookLot) (string, error) { sort.Slice(lots, func(i, j int) bool { return lots[i].LotName < lots[j].LotName }) b, err := json.Marshal(lots) if err != nil { return "", err } return string(b), nil } func collectPartnumbersJSON(items []models.PartnumberBookItem) (string, error) { partnumbers := make([]string, 0, len(items)) for _, item := range items { if strings.TrimSpace(item.Partnumber) == "" { continue } partnumbers = append(partnumbers, item.Partnumber) } sort.Strings(partnumbers) b, err := json.Marshal(partnumbers) if err != nil { return "", fmt.Errorf("marshal partnumbers_json: %w", err) } return string(b), nil } func normalizeDescription(v *string) string { if v == nil { return "" } return strings.TrimSpace(*v) } // 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 }