Files
QuoteForge/internal/localdb/component_universe_test.go
Mikhail ChusavitinandClaude Opus 4.8 905f9a4952 feat: PN code в импорте BOM, объединённый список LOT (world ∪ estimate)
Колонка «PN code» в таблице импорта BOM: HPE-спецификации хранят партномер
в двух столбцах, канонический вид — P52534-B21#B19. Склейка попадает
в vendor_partnumber и сравнивается с книгой партномеров как есть; при
загрузке разбирается обратно на две колонки. Схему БД менять не пришлось.

Список допустимых LOT собирается из world ∪ estimate (componentUniverse),
а не только из estimate. Прежняя область видимости молча теряла
lot_mappings: резолвер сопоставлял PN по книге, которая про прайслисты
не знает, а фронт отбрасывал LOT, которого нет в estimate — при этом
в корзину он всё равно попадал. World-only позиции отдают price_quality 0.

Кнопка «Пересопоставить» — перерешать BOM по актуальной книге, так как
при открытии конфигурации сопоставления остаются замороженными.

Итоги на вкладке «Ценообразование»: звёздочки убраны (переносились
на новую строку), все три суммы красные, при наведении — попап с долей
цен из прайслиста WORLD. Колонка «PN вендора» больше не переносится.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:01:31 +03:00

177 lines
6.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package localdb
import (
"path/filepath"
"testing"
"time"
)
// seedUniversePricelist stores one active pricelist of the given source with the
// supplied items and returns its local id.
func seedUniversePricelist(t *testing.T, local *LocalDB, serverID uint, source, version string, created time.Time, items []LocalPricelistItem) uint {
t.Helper()
pl := &LocalPricelist{
ServerID: serverID,
Source: source,
Version: version,
Name: version,
CreatedAt: created,
SyncedAt: created,
IsActive: true,
}
if err := local.SaveLocalPricelist(pl); err != nil {
t.Fatalf("save %s pricelist: %v", source, err)
}
stored, err := local.GetLocalPricelistByServerID(serverID)
if err != nil {
t.Fatalf("load %s pricelist: %v", source, err)
}
for i := range items {
items[i].PricelistID = stored.ID
}
if err := local.SaveLocalPricelistItems(items); err != nil {
t.Fatalf("save %s items: %v", source, err)
}
return stored.ID
}
func quality(v int) *int { return &v }
func newUniverseDB(t *testing.T, name string) *LocalDB {
t.Helper()
local, err := New(filepath.Join(t.TempDir(), name))
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
return local
}
// The component universe is world estimate: a LOT the estimate pricelist does not
// carry must still be selectable, otherwise BOM lot_mappings resolved from the
// partnumber book get silently dropped on save.
func TestComponentUniverseUnionsWorldAndEstimate(t *testing.T) {
local := newUniverseDB(t, "universe_union.db")
base := time.Now().Add(-time.Minute)
seedUniversePricelist(t, local, 1, "estimate", "E-1", base, []LocalPricelistItem{
{LotName: "CPU_A", LotCategory: "CPU", LotDescription: "from estimate", Price: 100, PriceQuality: quality(8)},
{LotName: "SHARED_LOT", LotCategory: "MEM", LotDescription: "from estimate", Price: 50, PriceQuality: quality(9)},
})
seedUniversePricelist(t, local, 2, "world", "W-1", base, []LocalPricelistItem{
{LotName: "SHARED_LOT", LotCategory: "WRONG", LotDescription: "from world", Price: 55, PriceQuality: quality(1)},
{LotName: "NIC_WORLD_ONLY", LotCategory: "NIC", LotDescription: "from world", Price: 70, PriceQuality: quality(7)},
})
components, total, err := local.ListComponents(ComponentFilter{}, 0, 100)
if err != nil {
t.Fatalf("ListComponents: %v", err)
}
if total != 3 {
t.Fatalf("expected 3 components (dedup on SHARED_LOT), got %d", total)
}
byLot := make(map[string]LocalComponent, len(components))
for _, c := range components {
if _, dup := byLot[c.LotName]; dup {
t.Fatalf("duplicate lot in universe: %s", c.LotName)
}
byLot[c.LotName] = c
}
worldOnly, ok := byLot["NIC_WORLD_ONLY"]
if !ok {
t.Fatal("world-only LOT missing from the component universe")
}
if worldOnly.PriceQuality == nil || *worldOnly.PriceQuality != 0 {
t.Fatalf("world-only LOT must report price_quality 0, got %v", worldOnly.PriceQuality)
}
// The estimate row wins a collision, so its metadata stays authoritative.
shared := byLot["SHARED_LOT"]
if shared.Category != "MEM" || shared.LotDescription != "from estimate" {
t.Fatalf("estimate row must win a collision, got category=%q description=%q", shared.Category, shared.LotDescription)
}
if shared.PriceQuality == nil || *shared.PriceQuality != 9 {
t.Fatalf("estimate price_quality must survive a collision, got %v", shared.PriceQuality)
}
if got := local.CountComponents(); got != 3 {
t.Fatalf("CountComponents: expected 3, got %d", got)
}
single, err := local.GetLocalComponent("nic_world_only")
if err != nil {
t.Fatalf("GetLocalComponent for a world-only LOT: %v", err)
}
if single.LotName != "NIC_WORLD_ONLY" {
t.Fatalf("GetLocalComponent returned %q", single.LotName)
}
categories, err := local.GetLocalComponentCategories()
if err != nil {
t.Fatalf("GetLocalComponentCategories: %v", err)
}
if len(categories) != 3 {
t.Fatalf("expected CPU/MEM/NIC, got %v", categories)
}
cats, err := local.GetLocalComponentCategoriesByLotNames([]string{"NIC_WORLD_ONLY", "SHARED_LOT"})
if err != nil {
t.Fatalf("GetLocalComponentCategoriesByLotNames: %v", err)
}
if cats["NIC_WORLD_ONLY"] != "NIC" || cats["SHARED_LOT"] != "MEM" {
t.Fatalf("unexpected categories: %v", cats)
}
found, err := local.SearchLocalComponents("world_only", 10)
if err != nil || len(found) != 1 {
t.Fatalf("SearchLocalComponents: got %d rows, err=%v", len(found), err)
}
inCategory, err := local.SearchLocalComponentsByCategory("NIC", "", 10)
if err != nil || len(inCategory) != 1 {
t.Fatalf("SearchLocalComponentsByCategory: got %d rows, err=%v", len(inCategory), err)
}
}
// Only one of the two pricelists existing must not break the universe.
func TestComponentUniverseWithSingleSource(t *testing.T) {
t.Run("estimate only", func(t *testing.T) {
local := newUniverseDB(t, "universe_estimate_only.db")
seedUniversePricelist(t, local, 1, "estimate", "E-1", time.Now(), []LocalPricelistItem{
{LotName: "CPU_A", LotCategory: "CPU", Price: 100, PriceQuality: quality(8)},
})
if got := local.CountComponents(); got != 1 {
t.Fatalf("expected 1 component, got %d", got)
}
})
t.Run("world only", func(t *testing.T) {
local := newUniverseDB(t, "universe_world_only.db")
seedUniversePricelist(t, local, 2, "world", "W-1", time.Now(), []LocalPricelistItem{
{LotName: "CPU_A", LotCategory: "CPU", Price: 100, PriceQuality: quality(8)},
})
components, _, err := local.ListComponents(ComponentFilter{}, 0, 10)
if err != nil {
t.Fatalf("ListComponents: %v", err)
}
if len(components) != 1 {
t.Fatalf("expected 1 component, got %d", len(components))
}
if components[0].PriceQuality == nil || *components[0].PriceQuality != 0 {
t.Fatalf("world-only LOT must report price_quality 0, got %v", components[0].PriceQuality)
}
})
t.Run("neither", func(t *testing.T) {
local := newUniverseDB(t, "universe_empty.db")
if _, _, err := local.ListComponents(ComponentFilter{}, 0, 10); err == nil {
t.Fatal("expected an error when no estimate and no world pricelist exists")
}
if got := local.CountComponents(); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
})
}