461 lines
14 KiB
Go
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
|
|
}
|