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

View File

@@ -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.