Files
PriceForge/internal/services/vendor_mapping_test.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

239 lines
6.8 KiB
Go

package services
import (
"fmt"
"testing"
"git.mchus.pro/mchus/priceforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestUpsertMappingWithOriginalVendor_DeletesEmptyOriginalVendorRow(t *testing.T) {
db, err := gorm.Open(sqlite.Open(testSQLiteDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
stmts := []string{
`CREATE TABLE lot (
lot_name TEXT PRIMARY KEY,
lot_description TEXT,
lot_category TEXT
)`,
`CREATE TABLE qt_vendor_partnumber_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
partnumber TEXT NOT NULL UNIQUE,
description TEXT NULL,
last_seen_at DATETIME NOT NULL,
is_ignored INTEGER NOT NULL DEFAULT 0,
is_pattern INTEGER NOT NULL DEFAULT 0,
ignored_at DATETIME NULL,
ignored_by TEXT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)`,
`CREATE TABLE qt_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("exec schema: %v", err)
}
}
if err := db.Create(&models.Lot{LotName: "MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5"}).Error; err != nil {
t.Fatalf("insert lot: %v", err)
}
if err := db.Create(&models.PartnumberBookItem{
Partnumber: "7DG9-CTO1WW",
LotsJSON: `[{"lot_name":"MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5","qty":1}]`,
}).Error; err != nil {
t.Fatalf("insert original mapping: %v", err)
}
svc := NewVendorMappingService(db)
if err := svc.UpsertMappingWithOriginalVendor(
"",
"Lenovo",
"7DG9-CTO1WW",
"MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5",
"",
nil,
); err != nil {
t.Fatalf("upsert mapping: %v", err)
}
var rows []models.PartnumberBookItem
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
t.Fatalf("list mappings: %v", err)
}
if len(rows) != 1 {
t.Fatalf("got %d mapping rows, want 1", len(rows))
}
if rows[0].Partnumber != "7DG9-CTO1WW" {
t.Fatalf("got partnumber %q, want %q", rows[0].Partnumber, "7DG9-CTO1WW")
}
var seen models.VendorPartnumberSeen
if err := db.Where("partnumber = ?", "7DG9-CTO1WW").First(&seen).Error; err != nil {
t.Fatalf("load seen row: %v", err)
}
if seen.Vendor != "Lenovo" {
t.Fatalf("got vendor %q, want %q", seen.Vendor, "Lenovo")
}
}
func TestUpsertMappingWithOriginalVendor_PreservesDescriptionFromEmptyOriginalVendorRow(t *testing.T) {
db, err := gorm.Open(sqlite.Open(testSQLiteDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
stmts := []string{
`CREATE TABLE lot (
lot_name TEXT PRIMARY KEY,
lot_description TEXT,
lot_category TEXT
)`,
`CREATE TABLE qt_vendor_partnumber_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
partnumber TEXT NOT NULL UNIQUE,
description TEXT NULL,
last_seen_at DATETIME NOT NULL,
is_ignored INTEGER NOT NULL DEFAULT 0,
is_pattern INTEGER NOT NULL DEFAULT 0,
ignored_at DATETIME NULL,
ignored_by TEXT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)`,
`CREATE TABLE qt_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("exec schema: %v", err)
}
}
const lotName = "MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5"
const wantDescription = "Existing description"
if err := db.Create(&models.Lot{LotName: lotName}).Error; err != nil {
t.Fatalf("insert lot: %v", err)
}
if err := db.Create(&models.PartnumberBookItem{
Partnumber: "7DG9-CTO1WW",
LotsJSON: fmt.Sprintf(`[{"lot_name":%q,"qty":1}]`, lotName),
Description: stringPtr(wantDescription),
}).Error; err != nil {
t.Fatalf("insert original mapping: %v", err)
}
svc := NewVendorMappingService(db)
if err := svc.UpsertMappingWithOriginalVendor("", "Lenovo", "7DG9-CTO1WW", lotName, "", nil); err != nil {
t.Fatalf("upsert mapping: %v", err)
}
var row models.PartnumberBookItem
if err := db.Where("partnumber = ?", "7DG9-CTO1WW").First(&row).Error; err != nil {
t.Fatalf("load new mapping: %v", err)
}
if row.Description == nil || *row.Description != wantDescription {
got := "<nil>"
if row.Description != nil {
got = *row.Description
}
t.Fatalf("got description %q, want %q", got, wantDescription)
}
}
func stringPtr(v string) *string {
return &v
}
func testSQLiteDSN(t *testing.T) string {
return fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
}
func TestPurgeSeenLotNames_RemovesPollutedSeenRowsButKeepsMappedPartnumbers(t *testing.T) {
db, err := gorm.Open(sqlite.Open(testSQLiteDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
stmts := []string{
`CREATE TABLE lot (
lot_name TEXT PRIMARY KEY,
lot_description TEXT,
lot_category TEXT
)`,
`CREATE TABLE qt_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT NULL
)`,
`CREATE TABLE qt_vendor_partnumber_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
partnumber TEXT NOT NULL UNIQUE,
description TEXT NULL,
last_seen_at DATETIME NOT NULL,
is_ignored INTEGER NOT NULL DEFAULT 0,
is_pattern INTEGER NOT NULL DEFAULT 0,
ignored_at DATETIME NULL,
ignored_by TEXT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("exec schema: %v", err)
}
}
now := "2026-03-07 00:00:00"
if err := db.Exec(`INSERT INTO lot (lot_name) VALUES (?), (?)`, "LOT_NAME_1", "LOT_NAME_2").Error; err != nil {
t.Fatalf("insert lots: %v", err)
}
if err := db.Exec(`INSERT INTO qt_partnumber_book_items (partnumber, lots_json) VALUES (?, ?)`, "LOT_NAME_2", `[{"lot_name":"LOT_NAME_2","qty":1}]`).Error; err != nil {
t.Fatalf("insert mapped pn: %v", err)
}
if err := db.Exec(`INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, last_seen_at) VALUES (?, ?, ?, ?), (?, ?, ?, ?)`,
"stock", "", "LOT_NAME_1", now,
"stock", "", "LOT_NAME_2", now,
).Error; err != nil {
t.Fatalf("insert seen rows: %v", err)
}
if err := purgeSeenLotNames(db); err != nil {
t.Fatalf("purge seen lot names: %v", err)
}
var rows []models.VendorPartnumberSeen
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
t.Fatalf("load seen rows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("got %d seen rows, want 1", len(rows))
}
if rows[0].Partnumber != "LOT_NAME_2" {
t.Fatalf("got partnumber %q, want %q", rows[0].Partnumber, "LOT_NAME_2")
}
}