fix: регистронезависимое сопоставление BOM↔корзина (фронт + CSV)
LOT из BOM-маппинга мог быть в смешанном регистре, а корзина — в каноничном UPPERCASE, из-за чего позиции дублировались (в таблице «Цена покупки» и в экспорте CSV). - localdb.NormalizeLotMappings: единая каноничная нормализация LOT-маппингов (UPPERCASE + схлопывание дублей с суммированием qty). Убраны две разошедшиеся копии normalizeLotMappings (handlers и services — последняя только тримила, что и было причиной бага в CSV). - export.go: BOM-ветка использует общую функцию + канонизирует LOT корзины для coverage/lookup. Удалена мёртвая computeMappingTotal. - index.html (renderPricingTab): сопоставление/дедуп LOT через каноничный ключ UPPERCASE; аксессоры _getRowBaseLot/_getRowAllocations возвращают канон. - Добавлен регресс-тест TestNormalizeLotMappings_*. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
@@ -100,7 +99,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
||||
}
|
||||
// Persist canonical LOT mapping only.
|
||||
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
|
||||
body.VendorSpec[i].LotMappings = localdb.NormalizeLotMappings(body.VendorSpec[i].LotMappings)
|
||||
body.VendorSpec[i].ResolvedLotName = ""
|
||||
body.VendorSpec[i].ResolutionSource = ""
|
||||
body.VendorSpec[i].ManualLotSuggestion = ""
|
||||
@@ -165,39 +164,6 @@ func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make(map[string]int, len(in))
|
||||
order := make([]string, 0, len(in))
|
||||
for _, m := range in {
|
||||
lot := models.NormalizeLotName(m.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := m.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
if _, exists := merged[lot]; !exists {
|
||||
order = append(order, lot)
|
||||
}
|
||||
merged[lot] += qty
|
||||
}
|
||||
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
||||
for _, lot := range order {
|
||||
out = append(out, localdb.VendorSpecLotMapping{
|
||||
LotName: lot,
|
||||
QuantityPerPN: merged[lot],
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
||||
// POST /api/configs/:uuid/vendor-spec/resolve
|
||||
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
|
||||
@@ -6,6 +6,42 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
// NormalizeLotMappings is the single canonical normalizer for vendor BOM LOT
|
||||
// mappings. LOT names are canonicalized to their uppercase form (see
|
||||
// models.NormalizeLotName) so that all BOM↔cart matching is case-insensitive,
|
||||
// duplicate LOTs are merged (summing quantity-per-PN), and quantities are at
|
||||
// least 1. Returns nil for an empty result. Both the persistence path
|
||||
// (handlers) and the CSV export path must use this — do not reimplement it.
|
||||
func NormalizeLotMappings(in []VendorSpecLotMapping) []VendorSpecLotMapping {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make(map[string]int, len(in))
|
||||
order := make([]string, 0, len(in))
|
||||
for _, m := range in {
|
||||
lot := models.NormalizeLotName(m.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := m.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
if _, exists := merged[lot]; !exists {
|
||||
order = append(order, lot)
|
||||
}
|
||||
merged[lot] += qty
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]VendorSpecLotMapping, 0, len(order))
|
||||
for _, lot := range order {
|
||||
out = append(out, VendorSpecLotMapping{LotName: lot, QuantityPerPN: merged[lot]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
|
||||
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
items := make(LocalConfigItems, len(cfg.Items))
|
||||
|
||||
@@ -6,6 +6,36 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestNormalizeLotMappings_CaseInsensitiveMerge(t *testing.T) {
|
||||
in := []VendorSpecLotMapping{
|
||||
{LotName: "cpu_intel_6960p", QuantityPerPN: 1},
|
||||
{LotName: "CPU_INTEL_6960P", QuantityPerPN: 2},
|
||||
{LotName: " ps_5200w_Titanium ", QuantityPerPN: 0},
|
||||
{LotName: "", QuantityPerPN: 5},
|
||||
}
|
||||
|
||||
out := NormalizeLotMappings(in)
|
||||
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 merged mappings, got %d: %+v", len(out), out)
|
||||
}
|
||||
if out[0].LotName != "CPU_INTEL_6960P" || out[0].QuantityPerPN != 3 {
|
||||
t.Fatalf("expected CPU_INTEL_6960P qty 3, got %+v", out[0])
|
||||
}
|
||||
if out[1].LotName != "PS_5200W_TITANIUM" || out[1].QuantityPerPN != 1 {
|
||||
t.Fatalf("expected PS_5200W_TITANIUM qty 1 (clamped), got %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLotMappings_Empty(t *testing.T) {
|
||||
if NormalizeLotMappings(nil) != nil {
|
||||
t.Fatal("expected nil for empty input")
|
||||
}
|
||||
if NormalizeLotMappings([]VendorSpecLotMapping{{LotName: " "}}) != nil {
|
||||
t.Fatal("expected nil when all entries blank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||
item := &models.PricelistItem{
|
||||
LotName: "CPU_A",
|
||||
|
||||
@@ -380,7 +380,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
||||
coveredLots := make(map[string]struct{})
|
||||
for _, row := range localCfg.VendorSpec {
|
||||
rowMappings := normalizeLotMappings(row.LotMappings)
|
||||
rowMappings := localdb.NormalizeLotMappings(row.LotMappings)
|
||||
for _, mapping := range rowMappings {
|
||||
coveredLots[mapping.LotName] = struct{}{}
|
||||
}
|
||||
@@ -424,21 +424,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName == "" {
|
||||
lot := models.NormalizeLotName(item.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := coveredLots[item.LotName]; ok {
|
||||
if _, ok := coveredLots[lot]; ok {
|
||||
continue
|
||||
}
|
||||
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||
estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity)
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
LotDisplay: lot,
|
||||
VendorPN: "—",
|
||||
Description: componentDescriptions[item.LotName],
|
||||
Description: componentDescriptions[lot],
|
||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||
Estimate: estimate,
|
||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
|
||||
Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
if opts.isDDP() {
|
||||
@@ -665,7 +666,7 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
|
||||
out := make([]string, 0)
|
||||
if includeBOM && localCfg != nil {
|
||||
for _, row := range localCfg.VendorSpec {
|
||||
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
||||
for _, mapping := range localdb.NormalizeLotMappings(row.LotMappings) {
|
||||
if _, ok := seen[mapping.LotName]; ok {
|
||||
continue
|
||||
}
|
||||
@@ -688,28 +689,6 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
lot := strings.TrimSpace(mapping.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := mapping.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
out = append(out, localdb.VendorSpecLotMapping{
|
||||
LotName: lot,
|
||||
QuantityPerPN: qty,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||
if row.TotalPrice != nil {
|
||||
return floatPtr(*row.TotalPrice)
|
||||
@@ -720,27 +699,6 @@ func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
||||
}
|
||||
|
||||
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
total := 0.0
|
||||
hasValue := false
|
||||
qty := exportPositiveInt(pnQty, 1)
|
||||
for _, mapping := range mappings {
|
||||
price := selector(priceMap[mapping.LotName])
|
||||
if price == nil || *price <= 0 {
|
||||
continue
|
||||
}
|
||||
total += *price * float64(qty*mapping.QuantityPerPN)
|
||||
hasValue = true
|
||||
}
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(total)
|
||||
}
|
||||
|
||||
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||
|
||||
Reference in New Issue
Block a user