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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0fa7b0b1b6
commit
905f9a4952
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComponentFilter for searching with filters
|
||||
@@ -40,6 +42,60 @@ func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// componentUniverse returns a query over the set of LOTs the configurator is allowed
|
||||
// to choose from. That set is the latest active `world` pricelist — the widest list we
|
||||
// have — plus, as a safety net, the latest active `estimate` pricelist, deduplicated by
|
||||
// UPPER(lot_name).
|
||||
//
|
||||
// Scoping this to `estimate` alone used to silently drop BOM lot_mappings: the resolver
|
||||
// matches a partnumber against the book, which knows nothing about pricelists, and the
|
||||
// frontend then rejected any LOT the estimate pricelist did not carry. Prices for
|
||||
// world-only LOTs already come from the world fallback (see
|
||||
// decisions/2026-07-10-world-pricelist-fallback.md).
|
||||
//
|
||||
// The estimate row wins a collision, so its category/description/price_quality stay
|
||||
// authoritative. A world-only row reports price_quality 0 so it renders at the red end of
|
||||
// the quality scale — the one place QF sets this field rather than only reading it.
|
||||
// Either pricelist may be absent; only both missing is an error.
|
||||
func (l *LocalDB) componentUniverse() (*gorm.DB, error) {
|
||||
estimateID, estimateErr := l.latestActivePricelistID("estimate")
|
||||
worldID, worldErr := l.latestActivePricelistID("world")
|
||||
if estimateErr != nil && worldErr != nil {
|
||||
return nil, fmt.Errorf("no active estimate or world pricelist")
|
||||
}
|
||||
|
||||
// Always a derived table, so every caller can apply its own Select/Count/Where
|
||||
// uniformly and none of them has to know which pricelists actually exist.
|
||||
const estimateSelect = `
|
||||
SELECT lot_name, lot_category, lot_description, price_quality
|
||||
FROM local_pricelist_items
|
||||
WHERE pricelist_id = ?`
|
||||
const worldSelect = `
|
||||
SELECT w.lot_name, w.lot_category, w.lot_description, 0 AS price_quality
|
||||
FROM local_pricelist_items w
|
||||
WHERE w.pricelist_id = ?`
|
||||
const worldOnlyTail = `
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM local_pricelist_items e
|
||||
WHERE e.pricelist_id = ?
|
||||
AND UPPER(e.lot_name) = UPPER(w.lot_name)
|
||||
)`
|
||||
|
||||
var inner *gorm.DB
|
||||
switch {
|
||||
case worldErr != nil:
|
||||
inner = l.db.Raw(estimateSelect, estimateID)
|
||||
case estimateErr != nil:
|
||||
inner = l.db.Raw(worldSelect, worldID)
|
||||
default:
|
||||
inner = l.db.Raw(
|
||||
estimateSelect+"\nUNION ALL"+worldSelect+worldOnlyTail,
|
||||
estimateID, worldID, estimateID)
|
||||
}
|
||||
|
||||
return l.db.Table("(?) AS c", inner), nil
|
||||
}
|
||||
|
||||
// pricelistItemRow is used for scanning rows from local_pricelist_items.
|
||||
type pricelistItemRow struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
@@ -57,25 +113,23 @@ func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// SearchLocalComponents searches components in the latest active estimate
|
||||
// pricelist by lot_name.
|
||||
// SearchLocalComponents searches the component universe by lot_name.
|
||||
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := l.db.Table("local_pricelist_items").
|
||||
Where("pricelist_id = ?", pricelistID)
|
||||
if query != "" {
|
||||
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||
}
|
||||
|
||||
var rows []pricelistItemRow
|
||||
if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
if err := db.Select("lot_name, lot_category, lot_description, price_quality").
|
||||
Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := make([]LocalComponent, len(rows))
|
||||
@@ -85,25 +139,24 @@ func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalCompone
|
||||
return components, nil
|
||||
}
|
||||
|
||||
// SearchLocalComponentsByCategory searches components in the latest active
|
||||
// estimate pricelist filtered by category.
|
||||
// SearchLocalComponentsByCategory searches the component universe filtered by category.
|
||||
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := l.db.Table("local_pricelist_items").
|
||||
Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
|
||||
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(category))
|
||||
if query != "" {
|
||||
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||
}
|
||||
|
||||
var rows []pricelistItemRow
|
||||
if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
if err := db.Select("lot_name, lot_category, lot_description, price_quality").
|
||||
Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := make([]LocalComponent, len(rows))
|
||||
@@ -113,17 +166,14 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit
|
||||
return components, nil
|
||||
}
|
||||
|
||||
// ListComponents returns components from the latest active estimate pricelist
|
||||
// with optional category/search filtering and pagination.
|
||||
// ListComponents returns components from the component universe with optional
|
||||
// category/search filtering and pagination.
|
||||
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
db := l.db.Table("local_pricelist_items").
|
||||
Where("pricelist_id = ?", pricelistID)
|
||||
|
||||
if filter.Category != "" {
|
||||
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
|
||||
}
|
||||
@@ -147,18 +197,17 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
||||
return components, total, nil
|
||||
}
|
||||
|
||||
// GetLocalComponent returns a single component by lot_name from the latest
|
||||
// active estimate pricelist.
|
||||
// GetLocalComponent returns a single component by lot_name from the component universe.
|
||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var row pricelistItemRow
|
||||
if err := l.db.Table("local_pricelist_items").
|
||||
if err := db.
|
||||
Select("lot_name, lot_category, lot_description, price_quality").
|
||||
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
||||
Where("UPPER(lot_name) = ?", strings.ToUpper(lotName)).
|
||||
First(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,13 +216,13 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
}
|
||||
|
||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
|
||||
// from the latest active estimate pricelist.
|
||||
// from the component universe.
|
||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -187,9 +236,9 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
|
||||
upperToOrig[u] = n
|
||||
}
|
||||
var rows []pricelistItemRow
|
||||
if err := l.db.Table("local_pricelist_items").
|
||||
if err := db.
|
||||
Select("lot_name, lot_category").
|
||||
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper).
|
||||
Where("UPPER(lot_name) IN ?", upper).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -203,17 +252,16 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategories returns distinct categories from the latest
|
||||
// active estimate pricelist.
|
||||
// GetLocalComponentCategories returns distinct categories from the component universe.
|
||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var categories []string
|
||||
if err := l.db.Table("local_pricelist_items").
|
||||
Where("pricelist_id = ? AND lot_category != ''", pricelistID).
|
||||
if err := db.
|
||||
Where("lot_category != ''").
|
||||
Distinct("lot_category").
|
||||
Order("lot_category").
|
||||
Pluck("lot_category", &categories).Error; err != nil {
|
||||
@@ -222,14 +270,14 @@ func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// CountComponents returns the number of distinct lot names in the latest
|
||||
// active estimate pricelist (used to check if data is available).
|
||||
// CountComponents returns the number of lot names in the component universe
|
||||
// (used to check if data is available).
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
db, err := l.componentUniverse()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var count int64
|
||||
l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count)
|
||||
db.Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user