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:
Mikhail Chusavitin
2026-07-24 17:01:31 +03:00
co-authored by Claude Opus 4.8
parent 0fa7b0b1b6
commit 905f9a4952
7 changed files with 547 additions and 119 deletions
+84 -36
View File
@@ -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
}