- 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>
239 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|