Колонка «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>
177 lines
6.1 KiB
Go
177 lines
6.1 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|