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:
Mikhail Chusavitin
2026-06-29 08:59:50 +03:00
parent cc72052c8a
commit b23eb1d75a
5 changed files with 111 additions and 119 deletions
+36
View File
@@ -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))
+30
View File
@@ -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",