- 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>
165 lines
4.9 KiB
Go
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
|
|
}
|