Files
PriceForge/internal/repository/parts_log.go
Mikhail Chusavitin 5f8aec456b Unified Quote Journal (parts_log) v3
- New unified append-only quote log table parts_log replaces three
  separate log tables (stock_log, partnumber_log_competitors, lot_log)
- Migrations 042-049: extend supplier, create parts_log/import_formats/
  ignore_rules, rework qt_lot_metadata composite PK, add lead_time_weeks
  to pricelist_items, backfill data, migrate ignore rules
- New services: PartsLogBackfillService, ImportFormatService,
  UnifiedImportService; new world pricelist type (all supplier types)
- qt_lot_metadata PK changed to (lot_name, pricelist_type); all queries
  now filter WHERE pricelist_type='estimate'
- Fix pre-existing bug: qt_component_usage_stats column names
  quotes_last30d/quotes_last7d (no underscore) — added explicit gorm tags
- Bible: full table inventory, baseline schema snapshot, updated pricelist/
  data-rules/api/history/architecture docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:25:54 +03:00

165 lines
4.9 KiB
Go

package repository
import (
"fmt"
"time"
"git.mchus.pro/mchus/priceforge/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PartsLogRepository provides data access for the unified quote journal.
type PartsLogRepository struct {
db *gorm.DB
}
func NewPartsLogRepository(db *gorm.DB) *PartsLogRepository {
return &PartsLogRepository{db: db}
}
// InsertBatch inserts rows into parts_log using INSERT IGNORE (dedup by supplier_code+partnumber+quote_date).
func (r *PartsLogRepository) InsertBatch(rows []models.PartsLog) (int, error) {
if len(rows) == 0 {
return 0, nil
}
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(rows, 500)
if result.Error != nil {
return 0, fmt.Errorf("insert parts_log batch: %w", result.Error)
}
return int(result.RowsAffected), nil
}
// CountBySupplier returns the number of rows per supplier_code.
type SupplierCount struct {
SupplierCode string `gorm:"column:supplier_code"`
Count int64 `gorm:"column:cnt"`
}
func (r *PartsLogRepository) CountBySupplier() ([]SupplierCount, error) {
var result []SupplierCount
err := r.db.Model(&models.PartsLog{}).
Select("supplier_code, COUNT(*) AS cnt").
Group("supplier_code").
Scan(&result).Error
if err != nil {
return nil, fmt.Errorf("count parts_log by supplier: %w", err)
}
return result, nil
}
// FindUnresolved returns rows where lot_name is NULL and partnumber is not a known lot_name.
// limit caps the batch size for incremental backfill.
func (r *PartsLogRepository) FindUnresolved(limit int) ([]models.PartsLog, error) {
var rows []models.PartsLog
err := r.db.Where("lot_name IS NULL").
Limit(limit).
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("find unresolved parts_log: %w", err)
}
return rows, nil
}
// UpdateLotResolution sets lot_name and lot_category for a given row ID.
func (r *PartsLogRepository) UpdateLotResolution(id uint64, lotName, lotCategory string) error {
return r.db.Model(&models.PartsLog{}).
Where("id = ?", id).
Updates(map[string]any{
"lot_name": lotName,
"lot_category": lotCategory,
}).Error
}
// UpdateLotResolutionBatch sets lot_name and lot_category for multiple rows in one query per lot.
type LotResolutionUpdate struct {
ID uint64
LotName string
LotCategory string
}
func (r *PartsLogRepository) UpdateLotResolutionBatch(updates []LotResolutionUpdate) error {
if len(updates) == 0 {
return nil
}
// Execute individual updates — GORM batch update by IDs with different values
// requires per-row updates; use a transaction for atomicity.
return r.db.Transaction(func(tx *gorm.DB) error {
for _, u := range updates {
if err := tx.Model(&models.PartsLog{}).
Where("id = ?", u.ID).
Updates(map[string]any{
"lot_name": u.LotName,
"lot_category": u.LotCategory,
}).Error; err != nil {
return err
}
}
return nil
})
}
// QuotesByLot returns price/qty rows for a lot within the given date window and supplier types.
// Used by the pricelist generator.
type QuoteRow struct {
LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"`
Qty *float64 `gorm:"column:qty"`
LeadTimeWeeks *int `gorm:"column:lead_time_weeks"`
SupplierType string `gorm:"column:supplier_type"`
}
func (r *PartsLogRepository) QuotesByLot(
lotNames []string,
supplierTypes []string,
since time.Time,
) ([]QuoteRow, error) {
if len(lotNames) == 0 || len(supplierTypes) == 0 {
return nil, nil
}
var rows []QuoteRow
err := r.db.Table("parts_log pl").
Select("pl.lot_name, pl.price, pl.qty, pl.lead_time_weeks, s.supplier_type").
Joins("INNER JOIN supplier s ON s.supplier_code = pl.supplier_code").
Where("pl.lot_name IN ?", lotNames).
Where("s.supplier_type IN ?", supplierTypes).
Where("pl.quote_date >= ?", since).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("quotes by lot: %w", err)
}
return rows, nil
}
// LastKnownPricesByLot returns the most recent price per lot regardless of date.
// Used for on_missing_quotes='keep' fallback.
func (r *PartsLogRepository) LastKnownPricesByLot(
lotNames []string,
supplierTypes []string,
) (map[string]float64, error) {
if len(lotNames) == 0 {
return map[string]float64{}, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"`
}
var rows []row
query := r.db.Table("parts_log pl").
Select("pl.lot_name, pl.price").
Joins("INNER JOIN supplier s ON s.supplier_code = pl.supplier_code").
Where("pl.lot_name IN ?", lotNames).
Where("pl.id IN (SELECT MAX(id) FROM parts_log WHERE lot_name IN ? GROUP BY lot_name)", lotNames)
if len(supplierTypes) > 0 {
query = query.Where("s.supplier_type IN ?", supplierTypes)
}
if err := query.Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("last known prices by lot: %w", err)
}
result := make(map[string]float64, len(rows))
for _, r := range rows {
result[r.LotName] = r.Price
}
return result, nil
}