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. 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 } partnumbersJSON, err := collectPartnumbersJSON(items) 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, PartnumbersJSON: partnumbersJSON, } if err := tx.Create(&book).Error; err != nil { return fmt.Errorf("creating partnumber book: %w", err) } if err := s.upsertCatalogItems(tx, items); err != nil { return 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 { 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 || existing.IsPrimaryPN != item.IsPrimaryPN || 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 && existingItem.IsPrimaryPN == item.IsPrimaryPN && normalizeDescription(existingItem.Description) == normalizeDescription(item.Description) { continue } updates := map[string]any{ "lots_json": item.LotsJSON, "is_primary_pn": item.IsPrimaryPN, "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 } // 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 snapshot items. // Each PN produces one row with lots_json = [{lot_name, qty}, ...]. // 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 byPN := make(map[string]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 } var lots []models.PartnumberBookLot if bundleSet[m.LotName] { // Bundle composition is stored directly in JSON. components := bundleItems[m.LotName] if len(components) == 0 { continue } sort.Slice(components, func(i, j int) bool { return components[i].LotName < components[j].LotName }) for _, c := range components { if strings.TrimSpace(c.LotName) == "" || c.Qty <= 0 { continue } lots = append(lots, models.PartnumberBookLot{ LotName: c.LotName, Qty: c.Qty, }) } } else { lots = append(lots, models.PartnumberBookLot{ LotName: m.LotName, Qty: 1, }) } if len(lots) == 0 { continue } lotsJSON, err := json.Marshal(lots) if err != nil { return nil, fmt.Errorf("marshal lots_json for partnumber %s: %w", m.Partnumber, err) } item := models.PartnumberBookItem{ Partnumber: m.Partnumber, LotsJSON: string(lotsJSON), IsPrimaryPN: m.IsPrimaryPN, Description: m.Description, } if prev, ok := byPN[item.Partnumber]; ok { if prev.LotsJSON != item.LotsJSON || prev.IsPrimaryPN != item.IsPrimaryPN || normalizeDescription(prev.Description) != normalizeDescription(item.Description) { return nil, fmt.Errorf("multiple distinct mappings for partnumber %s in partnumber book snapshot", item.Partnumber) } continue } byPN[item.Partnumber] = item result = append(result, item) } return result, 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 }