feat: world-прайслист как заглушка для отсутствующих цен

Там, где для LOT нет цены в estimate/warehouse/competitor, подставляется
цена из world-прайслиста. Такие ячейки в таблицах «Цена покупки»/«Цена продажи»
подсвечиваются (amber), участвуют в «Итого» и убирают красную «*».
В CSV-экспорте добавлена колонка «Заглушка (world)» с перечнем столбцов,
где сработал фолбэк.

Добавлены Tx-версии GetLatestLocalPricelistBySource/GetLocalPricesForLots,
чтобы резолв прайслистов внутри транзакции не дедлочил single-connection пул.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-10 10:25:46 +03:00
co-authored by Claude Opus 4.8
parent 498cbf5490
commit 70a3ff255f
10 changed files with 586 additions and 52 deletions
+81 -34
View File
@@ -88,15 +88,18 @@ type ProjectPricingExportConfig struct {
}
type ProjectPricingExportRow struct {
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
EstimateWorld bool
StockWorld bool
CompetitorWorld bool
}
// ToCSV writes project export data in the new structured CSV format.
@@ -411,14 +414,17 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
bomTotal = vendorRowTotal(row)
}
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: lotQty,
BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: lotQty,
BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
EstimateWorld: priceMap[mapping.LotName].EstimateWorld,
StockWorld: priceMap[mapping.LotName].StockWorld,
CompetitorWorld: priceMap[mapping.LotName].CompetitorWorld,
})
}
}
@@ -433,13 +439,16 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
}
estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: lot,
VendorPN: "—",
Description: componentDescriptions[lot],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
LotDisplay: lot,
VendorPN: "—",
Description: componentDescriptions[lot],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
EstimateWorld: priceMap[lot].EstimateWorld && priceMap[lot].Estimate != nil && *priceMap[lot].Estimate > 0,
StockWorld: priceMap[lot].StockWorld,
CompetitorWorld: priceMap[lot].CompetitorWorld,
})
}
if opts.isDDP() {
@@ -466,13 +475,16 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
EstimateWorld: priceMap[item.LotName].EstimateWorld && priceMap[item.LotName].Estimate != nil && *priceMap[item.LotName].Estimate > 0,
StockWorld: priceMap[item.LotName].StockWorld,
CompetitorWorld: priceMap[item.LotName].CompetitorWorld,
})
}
@@ -585,9 +597,12 @@ func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
}
type pricingLevels struct {
Estimate *float64
Stock *float64
Competitor *float64
Estimate *float64
Stock *float64
Competitor *float64
EstimateWorld bool
StockWorld bool
CompetitorWorld bool
}
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
@@ -621,20 +636,35 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg
}
}
var worldID *uint
if latest, err := s.localDB.GetLatestLocalPricelistBySource("world"); err == nil && latest != nil {
worldID = &latest.ServerID
}
estimatePrices := s.batchLookupPrices(estimateID, lots)
stockPrices := s.batchLookupPrices(warehouseID, lots)
competitorPrices := s.batchLookupPrices(competitorID, lots)
worldPrices := s.batchLookupPrices(worldID, lots)
for _, lot := range lots {
level := pricingLevels{}
if p, ok := estimatePrices[lot]; ok {
level.Estimate = floatPtr(p)
} else if p, ok := worldPrices[lot]; ok && p > 0 {
level.Estimate = floatPtr(p)
level.EstimateWorld = true
}
if p, ok := stockPrices[lot]; ok {
level.Stock = floatPtr(p)
} else if p, ok := worldPrices[lot]; ok && p > 0 {
level.Stock = floatPtr(p)
level.StockWorld = true
}
if p, ok := competitorPrices[lot]; ok {
level.Competitor = floatPtr(p)
} else if p, ok := worldPrices[lot]; ok && p > 0 {
level.Competitor = floatPtr(p)
level.CompetitorWorld = true
}
result[lot] = level
}
@@ -786,6 +816,7 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
headers = append(headers, "Ручная цена")
}
headers = append(headers, "Заглушка (world)")
return headers
}
@@ -815,6 +846,21 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(row.ManualPrice))
}
var worldCols []string
if row.EstimateWorld {
worldCols = append(worldCols, "Estimate")
}
if row.StockWorld {
worldCols = append(worldCols, "Stock")
}
if row.CompetitorWorld {
worldCols = append(worldCols, "Конкуренты")
}
comment := ""
if len(worldCols) > 0 {
comment = "world: " + strings.Join(worldCols, ", ")
}
record = append(record, comment)
return record
}
@@ -844,6 +890,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(opts.ManualPrice))
}
record = append(record, "")
return record
}