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>
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package localdb
|
|
|
|
import (
|
|
"time"
|
|
|
|
"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))
|
|
for i, item := range cfg.Items {
|
|
items[i] = LocalConfigItem{
|
|
LotName: models.NormalizeLotName(item.LotName),
|
|
Quantity: item.Quantity,
|
|
UnitPrice: item.UnitPrice,
|
|
}
|
|
}
|
|
|
|
local := &LocalConfiguration{
|
|
UUID: cfg.UUID,
|
|
ProjectUUID: cfg.ProjectUUID,
|
|
IsActive: true,
|
|
Name: cfg.Name,
|
|
Items: items,
|
|
TotalPrice: cfg.TotalPrice,
|
|
CustomPrice: cfg.CustomPrice,
|
|
Notes: cfg.Notes,
|
|
IsTemplate: cfg.IsTemplate,
|
|
ServerCount: cfg.ServerCount,
|
|
ServerModel: cfg.ServerModel,
|
|
SupportCode: cfg.SupportCode,
|
|
Article: cfg.Article,
|
|
PricelistID: cfg.PricelistID,
|
|
WarehousePricelistID: cfg.WarehousePricelistID,
|
|
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
|
ConfigType: cfg.ConfigType,
|
|
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
|
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
|
OnlyInStock: cfg.OnlyInStock,
|
|
Line: cfg.Line,
|
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
|
CreatedAt: cfg.CreatedAt,
|
|
UpdatedAt: time.Now(),
|
|
SyncStatus: "pending",
|
|
OriginalUserID: derefUint(cfg.UserID),
|
|
OriginalUsername: cfg.OwnerUsername,
|
|
}
|
|
|
|
if cfg.ID > 0 {
|
|
serverID := cfg.ID
|
|
local.ServerID = &serverID
|
|
}
|
|
|
|
return local
|
|
}
|
|
|
|
// LocalToConfiguration converts LocalConfiguration to models.Configuration
|
|
func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|
items := make(models.ConfigItems, len(local.Items))
|
|
for i, item := range local.Items {
|
|
items[i] = models.ConfigItem{
|
|
LotName: item.LotName,
|
|
Quantity: item.Quantity,
|
|
UnitPrice: item.UnitPrice,
|
|
}
|
|
}
|
|
|
|
cfg := &models.Configuration{
|
|
UUID: local.UUID,
|
|
OwnerUsername: local.OriginalUsername,
|
|
ProjectUUID: local.ProjectUUID,
|
|
Name: local.Name,
|
|
Items: items,
|
|
TotalPrice: local.TotalPrice,
|
|
CustomPrice: local.CustomPrice,
|
|
Notes: local.Notes,
|
|
IsTemplate: local.IsTemplate,
|
|
ServerCount: local.ServerCount,
|
|
ServerModel: local.ServerModel,
|
|
SupportCode: local.SupportCode,
|
|
Article: local.Article,
|
|
PricelistID: local.PricelistID,
|
|
WarehousePricelistID: local.WarehousePricelistID,
|
|
CompetitorPricelistID: local.CompetitorPricelistID,
|
|
ConfigType: local.ConfigType,
|
|
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
|
DisablePriceRefresh: local.DisablePriceRefresh,
|
|
OnlyInStock: local.OnlyInStock,
|
|
Line: local.Line,
|
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
|
CreatedAt: local.CreatedAt,
|
|
}
|
|
|
|
if local.ServerID != nil {
|
|
cfg.ID = *local.ServerID
|
|
}
|
|
if local.OriginalUserID != 0 {
|
|
userID := local.OriginalUserID
|
|
cfg.UserID = &userID
|
|
}
|
|
if local.CurrentVersion != nil {
|
|
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
func derefUint(v *uint) uint {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return *v
|
|
}
|
|
|
|
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
|
|
if len(spec) == 0 {
|
|
return nil
|
|
}
|
|
out := make(VendorSpec, 0, len(spec))
|
|
for _, item := range spec {
|
|
row := VendorSpecItem{
|
|
SortOrder: item.SortOrder,
|
|
VendorPartnumber: item.VendorPartnumber,
|
|
Quantity: item.Quantity,
|
|
Description: item.Description,
|
|
UnitPrice: item.UnitPrice,
|
|
TotalPrice: item.TotalPrice,
|
|
ResolvedLotName: item.ResolvedLotName,
|
|
ResolutionSource: item.ResolutionSource,
|
|
ManualLotSuggestion: item.ManualLotSuggestion,
|
|
LotQtyPerPN: item.LotQtyPerPN,
|
|
}
|
|
if len(item.LotAllocations) > 0 {
|
|
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
|
for _, alloc := range item.LotAllocations {
|
|
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
|
|
LotName: alloc.LotName,
|
|
Quantity: alloc.Quantity,
|
|
})
|
|
}
|
|
}
|
|
if len(item.LotMappings) > 0 {
|
|
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
|
|
for _, mapping := range item.LotMappings {
|
|
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
|
|
LotName: mapping.LotName,
|
|
QuantityPerPN: mapping.QuantityPerPN,
|
|
})
|
|
}
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
|
|
if len(spec) == 0 {
|
|
return nil
|
|
}
|
|
out := make(models.VendorSpec, 0, len(spec))
|
|
for _, item := range spec {
|
|
row := models.VendorSpecItem{
|
|
SortOrder: item.SortOrder,
|
|
VendorPartnumber: item.VendorPartnumber,
|
|
Quantity: item.Quantity,
|
|
Description: item.Description,
|
|
UnitPrice: item.UnitPrice,
|
|
TotalPrice: item.TotalPrice,
|
|
ResolvedLotName: item.ResolvedLotName,
|
|
ResolutionSource: item.ResolutionSource,
|
|
ManualLotSuggestion: item.ManualLotSuggestion,
|
|
LotQtyPerPN: item.LotQtyPerPN,
|
|
}
|
|
if len(item.LotAllocations) > 0 {
|
|
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
|
for _, alloc := range item.LotAllocations {
|
|
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
|
|
LotName: alloc.LotName,
|
|
Quantity: alloc.Quantity,
|
|
})
|
|
}
|
|
}
|
|
if len(item.LotMappings) > 0 {
|
|
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
|
|
for _, mapping := range item.LotMappings {
|
|
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
|
|
LotName: mapping.LotName,
|
|
QuantityPerPN: mapping.QuantityPerPN,
|
|
})
|
|
}
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ProjectToLocal(project *models.Project) *LocalProject {
|
|
local := &LocalProject{
|
|
UUID: project.UUID,
|
|
OwnerUsername: project.OwnerUsername,
|
|
Code: project.Code,
|
|
Variant: project.Variant,
|
|
Name: project.Name,
|
|
TrackerURL: project.TrackerURL,
|
|
IsActive: project.IsActive,
|
|
IsSystem: project.IsSystem,
|
|
CreatedAt: project.CreatedAt,
|
|
UpdatedAt: project.UpdatedAt,
|
|
SyncStatus: "pending",
|
|
}
|
|
if project.ID > 0 {
|
|
serverID := project.ID
|
|
local.ServerID = &serverID
|
|
}
|
|
return local
|
|
}
|
|
|
|
func LocalToProject(local *LocalProject) *models.Project {
|
|
project := &models.Project{
|
|
UUID: local.UUID,
|
|
OwnerUsername: local.OwnerUsername,
|
|
Code: local.Code,
|
|
Variant: local.Variant,
|
|
Name: local.Name,
|
|
TrackerURL: local.TrackerURL,
|
|
IsActive: local.IsActive,
|
|
IsSystem: local.IsSystem,
|
|
CreatedAt: local.CreatedAt,
|
|
UpdatedAt: local.UpdatedAt,
|
|
}
|
|
if local.ServerID != nil {
|
|
project.ID = *local.ServerID
|
|
}
|
|
return project
|
|
}
|
|
|
|
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
|
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
|
name := pl.Notification
|
|
if name == "" {
|
|
name = pl.Version
|
|
}
|
|
|
|
return &LocalPricelist{
|
|
ServerID: pl.ID,
|
|
Source: pl.Source,
|
|
Version: pl.Version,
|
|
Name: name,
|
|
CreatedAt: pl.CreatedAt,
|
|
SyncedAt: time.Now(),
|
|
IsUsed: false,
|
|
}
|
|
}
|
|
|
|
// LocalToPricelist converts LocalPricelist to models.Pricelist
|
|
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
|
return &models.Pricelist{
|
|
ID: local.ServerID,
|
|
Source: local.Source,
|
|
Version: local.Version,
|
|
Notification: local.Name,
|
|
CreatedAt: local.CreatedAt,
|
|
IsActive: true,
|
|
}
|
|
}
|
|
|
|
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
|
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
|
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
|
|
partnumbers = append(partnumbers, item.Partnumbers...)
|
|
return &LocalPricelistItem{
|
|
PricelistID: localPricelistID,
|
|
LotName: models.NormalizeLotName(item.LotName),
|
|
LotCategory: item.LotCategory,
|
|
Price: item.Price,
|
|
AvailableQty: item.AvailableQty,
|
|
Partnumbers: partnumbers,
|
|
}
|
|
}
|
|
|
|
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
|
|
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
|
|
partnumbers := make([]string, 0, len(local.Partnumbers))
|
|
partnumbers = append(partnumbers, local.Partnumbers...)
|
|
return &models.PricelistItem{
|
|
ID: local.ID,
|
|
PricelistID: serverPricelistID,
|
|
LotName: local.LotName,
|
|
LotCategory: local.LotCategory,
|
|
Price: local.Price,
|
|
AvailableQty: local.AvailableQty,
|
|
Partnumbers: partnumbers,
|
|
}
|
|
}
|
|
|
|
// LocalToComponent converts LocalComponent to models.LotMetadata
|
|
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
|
return &models.LotMetadata{
|
|
LotName: local.LotName,
|
|
Model: local.Model,
|
|
Lot: &models.Lot{
|
|
LotName: local.LotName,
|
|
LotDescription: local.LotDescription,
|
|
},
|
|
}
|
|
}
|