Колонка «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>
284 lines
8.3 KiB
Go
284 lines
8.3 KiB
Go
package localdb
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ComponentFilter for searching with filters
|
|
type ComponentFilter struct {
|
|
Category string
|
|
Search string
|
|
HasPrice bool
|
|
}
|
|
|
|
// ComponentSyncResult contains statistics from component sync
|
|
type ComponentSyncResult struct {
|
|
TotalSynced int
|
|
NewCount int
|
|
UpdateCount int
|
|
Duration time.Duration
|
|
}
|
|
|
|
// latestActivePricelistID returns the local DB id of the most recently created
|
|
// active pricelist for the given source ("estimate", "warehouse", etc.).
|
|
func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
|
|
var id uint
|
|
err := l.db.Table("local_pricelists").
|
|
Select("id").
|
|
Where("is_active = ? AND source = ?", true, source).
|
|
Order("created_at DESC, id DESC").
|
|
Limit(1).
|
|
Scan(&id).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if id == 0 {
|
|
return 0, fmt.Errorf("no active %s pricelist", source)
|
|
}
|
|
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"`
|
|
Category string `gorm:"column:lot_category"`
|
|
Description string `gorm:"column:lot_description"`
|
|
PriceQuality *int `gorm:"column:price_quality"`
|
|
}
|
|
|
|
func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
|
return LocalComponent{
|
|
LotName: r.LotName,
|
|
Category: r.Category,
|
|
LotDescription: r.Description,
|
|
PriceQuality: r.PriceQuality,
|
|
}
|
|
}
|
|
|
|
// SearchLocalComponents searches the component universe by lot_name.
|
|
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
components := make([]LocalComponent, len(rows))
|
|
for i, r := range rows {
|
|
components[i] = r.toLocalComponent()
|
|
}
|
|
return components, nil
|
|
}
|
|
|
|
// SearchLocalComponentsByCategory searches the component universe filtered by category.
|
|
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
components := make([]LocalComponent, len(rows))
|
|
for i, r := range rows {
|
|
components[i] = r.toLocalComponent()
|
|
}
|
|
return components, nil
|
|
}
|
|
|
|
// 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) {
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if filter.Category != "" {
|
|
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
|
|
}
|
|
if filter.Search != "" {
|
|
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(filter.Search)+"%")
|
|
}
|
|
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var rows []pricelistItemRow
|
|
if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
components := make([]LocalComponent, len(rows))
|
|
for i, r := range rows {
|
|
components[i] = r.toLocalComponent()
|
|
}
|
|
return components, total, nil
|
|
}
|
|
|
|
// GetLocalComponent returns a single component by lot_name from the component universe.
|
|
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var row pricelistItemRow
|
|
if err := db.
|
|
Select("lot_name, lot_category, lot_description, price_quality").
|
|
Where("UPPER(lot_name) = ?", strings.ToUpper(lotName)).
|
|
First(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
c := row.toLocalComponent()
|
|
return &c, nil
|
|
}
|
|
|
|
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
|
|
// 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
|
|
}
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return result, nil
|
|
}
|
|
|
|
// Build uppercase → original mapping so result keys match what the caller passed.
|
|
upperToOrig := make(map[string]string, len(lotNames))
|
|
upper := make([]string, len(lotNames))
|
|
for i, n := range lotNames {
|
|
u := strings.ToUpper(n)
|
|
upper[i] = u
|
|
upperToOrig[u] = n
|
|
}
|
|
var rows []pricelistItemRow
|
|
if err := db.
|
|
Select("lot_name, lot_category").
|
|
Where("UPPER(lot_name) IN ?", upper).
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
orig := upperToOrig[strings.ToUpper(r.LotName)]
|
|
if orig == "" {
|
|
orig = r.LotName
|
|
}
|
|
result[orig] = r.Category
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetLocalComponentCategories returns distinct categories from the component universe.
|
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var categories []string
|
|
if err := db.
|
|
Where("lot_category != ''").
|
|
Distinct("lot_category").
|
|
Order("lot_category").
|
|
Pluck("lot_category", &categories).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return categories, nil
|
|
}
|
|
|
|
// CountComponents returns the number of lot names in the component universe
|
|
// (used to check if data is available).
|
|
func (l *LocalDB) CountComponents() int64 {
|
|
db, err := l.componentUniverse()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var count int64
|
|
db.Count(&count)
|
|
return count
|
|
}
|