Files
PriceForge/internal/services/partnumber_book.go
Michael Chus a4457a0a28 Add partnumber book snapshots for QuoteForge integration
- Migrations 026-028: qt_partnumber_books + qt_partnumber_book_items
  tables; is_primary_pn on lot_partnumbers; version VARCHAR(30);
  description VARCHAR(10000) on items (required by QuoteForge sync)
- Service: CreateSnapshot expands bundles, filters empty lot_name and
  ignored PNs, copies description, activates new book atomically,
  applies GFS retention (7d/5w/12m/10y) with explicit item deletion
- Task type TaskTypePartnumberBookCreate; handlers ListPartnumberBooks
  and CreatePartnumberBook; routes GET/POST /api/admin/pricing/partnumber-books
- UI: snapshot list + "Создать снапшот сопоставлений" button with
  progress polling on /vendor-mappings page
- Bible: history, api, background-tasks, vendor-mapping updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 22:16:16 +03:00

382 lines
12 KiB
Go

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
}