Files
PriceForge/internal/services/partnumber_book.go
2026-03-07 23:11:42 +03:00

461 lines
14 KiB
Go

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
}