Enhance tender estimated value resolution and add unit tests

- Updated the `ResolvedEstimatedValueAndCurrency` method to aggregate procurement lot values when the tender-level estimated value is not set, improving accuracy in value retrieval.
- Introduced the `AggregateProcurementLotEstimatedValue` function to sum estimated values from procurement lots and return the first found currency.
- Modified the `ToResponseWithLanguage` method to utilize the new estimated value resolution logic.
- Added unit tests for the new functionality, ensuring correct behavior for various scenarios in the `entity_test.go` and `budget_test.go` files.

This update improves the handling of estimated values in tenders, enhancing the overall reliability of the tender management system.
This commit is contained in:
Mazyar
2026-06-20 12:29:47 +03:30
parent 6fb57c41c1
commit 4cfca5aa55
7 changed files with 259 additions and 26 deletions
+19
View File
@@ -476,6 +476,16 @@ func (w *NoticeWorker) ToTender(n *notice.Notice) (*tender.Tender, error) {
awardedEntities = mergeAwardedEntitiesByLotID(t.AwardedEntities, awardedEntities) awardedEntities = mergeAwardedEntitiesByLotID(t.AwardedEntities, awardedEntities)
title, description = mergeTitleAndDescriptionForMultiLot(t.Title, t.Description, title, description) title, description = mergeTitleAndDescriptionForMultiLot(t.Title, t.Description, title, description)
if estimatedVal == 0 && t.EstimatedValue > 0 {
estimatedVal = t.EstimatedValue
}
if awardedVal == 0 && t.AwardedValue > 0 {
awardedVal = t.AwardedValue
}
if currency == "" && t.Currency != "" {
currency = t.Currency
}
if strings.TrimSpace(previousNoticePublicationID) != "" && previousNoticePublicationID != n.NoticePublicationID { if strings.TrimSpace(previousNoticePublicationID) != "" && previousNoticePublicationID != n.NoticePublicationID {
if n.EstimatedValue > 0 { if n.EstimatedValue > 0 {
estimatedVal = t.EstimatedValue + n.EstimatedValue estimatedVal = t.EstimatedValue + n.EstimatedValue
@@ -495,6 +505,15 @@ func (w *NoticeWorker) ToTender(n *notice.Notice) (*tender.Tender, error) {
} }
} }
if estimatedVal == 0 {
if lotValue, lotCurrency := tender.AggregateProcurementLotEstimatedValue(procurementLots); lotValue > 0 {
estimatedVal = lotValue
if currency == "" {
currency = lotCurrency
}
}
}
// Title and description stay in the notice language; English translation is handled by the AI service (translation worker). // Title and description stay in the notice language; English translation is handled by the AI service (translation worker).
t.Title = title t.Title = title
t.Description = description t.Description = description
+40 -5
View File
@@ -305,20 +305,55 @@ func (t *Tender) GetMainOrganization() *Organization {
// HasEstimatedValue returns true if the tender has an estimated value // HasEstimatedValue returns true if the tender has an estimated value
func (t *Tender) HasEstimatedValue() bool { func (t *Tender) HasEstimatedValue() bool {
return t.EstimatedValue > 0 value, _ := t.ResolvedEstimatedValueAndCurrency()
return value > 0
}
// AggregateProcurementLotEstimatedValue sums lot-level estimated values and returns the first lot currency found.
func AggregateProcurementLotEstimatedValue(lots []ProcurementLot) (value float64, currency string) {
for _, lot := range lots {
if lot.EstimatedValue <= 0 {
continue
}
value += lot.EstimatedValue
if currency == "" {
currency = strings.TrimSpace(lot.Currency)
}
}
return value, currency
}
// ResolvedEstimatedValueAndCurrency returns the tender-level estimated value when set,
// otherwise aggregates procurement lot values for API and display use.
func (t *Tender) ResolvedEstimatedValueAndCurrency() (float64, string) {
if t == nil {
return 0, ""
}
if t.EstimatedValue > 0 {
return t.EstimatedValue, strings.TrimSpace(t.Currency)
}
value, currency := AggregateProcurementLotEstimatedValue(t.ProcurementLots)
if value > 0 {
if currency == "" {
currency = strings.TrimSpace(t.Currency)
}
return value, currency
}
return 0, strings.TrimSpace(t.Currency)
} }
// GetFormattedEstimatedValue returns formatted estimated value with currency // GetFormattedEstimatedValue returns formatted estimated value with currency
func (t *Tender) GetFormattedEstimatedValue() string { func (t *Tender) GetFormattedEstimatedValue() string {
if !t.HasEstimatedValue() { value, currency := t.ResolvedEstimatedValueAndCurrency()
if value <= 0 {
return "" return ""
} }
if t.Currency != "" { if currency != "" {
return fmt.Sprintf("%.2f %s", t.EstimatedValue, t.Currency) return fmt.Sprintf("%.2f %s", value, currency)
} }
return fmt.Sprintf("%.2f", t.EstimatedValue) return fmt.Sprintf("%.2f", value)
} }
// UpdateStatus updates the tender status and updated timestamp // UpdateStatus updates the tender status and updated timestamp
+51
View File
@@ -0,0 +1,51 @@
package tender
import "testing"
func TestAggregateProcurementLotEstimatedValue(t *testing.T) {
value, currency := AggregateProcurementLotEstimatedValue([]ProcurementLot{
{EstimatedValue: 100000, Currency: "EUR"},
{EstimatedValue: 250000, Currency: "EUR"},
})
if value != 350000 {
t.Fatalf("expected total 350000, got %v", value)
}
if currency != "EUR" {
t.Fatalf("expected currency EUR, got %q", currency)
}
}
func TestResolvedEstimatedValueAndCurrencyUsesLotsWhenTopLevelMissing(t *testing.T) {
tender := &Tender{
Currency: "EUR",
ProcurementLots: []ProcurementLot{
{LotID: "LOT-0001", EstimatedValue: 500000, Currency: "EUR"},
},
}
value, currency := tender.ResolvedEstimatedValueAndCurrency()
if value != 500000 {
t.Fatalf("expected 500000, got %v", value)
}
if currency != "EUR" {
t.Fatalf("expected EUR, got %q", currency)
}
}
func TestResolvedEstimatedValueAndCurrencyPrefersTopLevel(t *testing.T) {
tender := &Tender{
EstimatedValue: 900000,
Currency: "USD",
ProcurementLots: []ProcurementLot{
{EstimatedValue: 100000, Currency: "EUR"},
},
}
value, currency := tender.ResolvedEstimatedValueAndCurrency()
if value != 900000 {
t.Fatalf("expected 900000, got %v", value)
}
if currency != "USD" {
t.Fatalf("expected USD, got %q", currency)
}
}
+4 -2
View File
@@ -277,6 +277,8 @@ func (t *Tender) ToResponseWithLanguage(language string) *TenderResponse {
documentURL = strings.TrimSpace(t.TenderURL) documentURL = strings.TrimSpace(t.TenderURL)
} }
estimatedValue, currency := t.ResolvedEstimatedValueAndCurrency()
response := &TenderResponse{ response := &TenderResponse{
ID: t.ID.Hex(), ID: t.ID.Hex(),
NoticePublicationID: t.NoticePublicationID, NoticePublicationID: t.NoticePublicationID,
@@ -307,8 +309,8 @@ func (t *Tender) ToResponseWithLanguage(language string) *TenderResponse {
MainClassificationDescription: mainDesc, MainClassificationDescription: mainDesc,
MainClassificationDisplay: classificationDisplay(mainCode, mainDesc), MainClassificationDisplay: classificationDisplay(mainCode, mainDesc),
AdditionalClassifications: append([]string(nil), t.AdditionalClassifications...), AdditionalClassifications: append([]string(nil), t.AdditionalClassifications...),
EstimatedValue: t.EstimatedValue, EstimatedValue: estimatedValue,
Currency: strings.TrimSpace(t.Currency), Currency: currency,
EstimatedValueVATBasis: vatBasisUnspecified, EstimatedValueVATBasis: vatBasisUnspecified,
Duration: t.Duration, Duration: t.Duration,
+58
View File
@@ -0,0 +1,58 @@
package ted
import "testing"
func TestCurrencyTextAmountAndCurrency(t *testing.T) {
t.Run("chardata", func(t *testing.T) {
amount, currency := (CurrencyText{CurrencyID: "EUR", Text: "1 234,56"}).AmountAndCurrency()
if amount != "1 234,56" {
t.Fatalf("unexpected amount %q", amount)
}
if currency != "EUR" {
t.Fatalf("unexpected currency %q", currency)
}
})
t.Run("value child", func(t *testing.T) {
amount, currency := (CurrencyText{CurrencyID: "EUR", Value: "500000"}).AmountAndCurrency()
if amount != "500000" {
t.Fatalf("unexpected amount %q", amount)
}
if currency != "EUR" {
t.Fatalf("unexpected currency %q", currency)
}
})
}
func TestContractNoticeGetBudgetSumsAllLots(t *testing.T) {
cn := ContractNotice{
ProcurementProjectLots: []ProcurementProjectLot{
{
ProcurementProject: &ProcurementProject{
RequestedTenderTotal: &RequestedTenderTotal{
EstimatedOverallContractAmount: CurrencyText{CurrencyID: "EUR", Text: "100000"},
},
},
},
{
ProcurementProject: &ProcurementProject{
RequestedTenderTotal: &RequestedTenderTotal{
EstimatedOverallContractAmount: CurrencyText{CurrencyID: "EUR", Text: "250000"},
},
},
},
},
}
amount, currency := cn.GetBudget()
value, ok := ParseEuropeanAmount(amount)
if !ok {
t.Fatalf("failed to parse budget amount %q", amount)
}
if value != 350000 {
t.Fatalf("expected 350000, got %v", value)
}
if currency != "EUR" {
t.Fatalf("expected EUR, got %q", currency)
}
}
+30 -3
View File
@@ -30,11 +30,11 @@ func mapProcurementLotFromTED(lot *ProcurementProjectLot) notice.ProcurementLot
} }
} }
if pp.RequestedTenderTotal != nil { if pp.RequestedTenderTotal != nil {
a := pp.RequestedTenderTotal.EstimatedOverallContractAmount amount, currencyID := pp.RequestedTenderTotal.AmountAndCurrency()
if v, ok := ParseEuropeanAmount(a.Text); ok { if v, ok := ParseEuropeanAmount(amount); ok {
out.EstimatedValue = v out.EstimatedValue = v
} }
out.Currency = a.CurrencyID out.Currency = currencyID
} }
if pp.PlannedPeriod != nil && pp.PlannedPeriod.DurationMeasure != nil { if pp.PlannedPeriod != nil && pp.PlannedPeriod.DurationMeasure != nil {
dm := pp.PlannedPeriod.DurationMeasure dm := pp.PlannedPeriod.DurationMeasure
@@ -167,6 +167,7 @@ func (s *TEDScraper) mapPriorInformationNoticeToTender(pin *PriorInformationNoti
for i := range pin.ProcurementProjectLot { for i := range pin.ProcurementProjectLot {
t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&pin.ProcurementProjectLot[i])) t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&pin.ProcurementProjectLot[i]))
} }
backfillNoticeEstimatedValueFromLots(t)
if duration, unit := pin.GetDuration(); duration != "" { if duration, unit := pin.GetDuration(); duration != "" {
t.Duration = duration t.Duration = duration
@@ -351,6 +352,7 @@ func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileNa
for i := range cn.ProcurementProjectLots { for i := range cn.ProcurementProjectLots {
t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&cn.ProcurementProjectLots[i])) t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&cn.ProcurementProjectLots[i]))
} }
backfillNoticeEstimatedValueFromLots(t)
// Set duration information // Set duration information
if duration, unit := cn.GetDuration(); duration != "" { if duration, unit := cn.GetDuration(); duration != "" {
@@ -506,6 +508,7 @@ func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, so
for i := range can.ProcurementProjectLot { for i := range can.ProcurementProjectLot {
t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&can.ProcurementProjectLot[i])) t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&can.ProcurementProjectLot[i]))
} }
backfillNoticeEstimatedValueFromLots(t)
// Set location information from main procurement project or first lot // Set location information from main procurement project or first lot
var realizedAddr *Address var realizedAddr *Address
@@ -663,3 +666,27 @@ func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *notice.
return org return org
} }
func backfillNoticeEstimatedValueFromLots(t *notice.Notice) {
if t == nil || t.EstimatedValue > 0 {
return
}
var total float64
var currency string
for _, lot := range t.ProcurementLots {
if lot.EstimatedValue <= 0 {
continue
}
total += lot.EstimatedValue
if currency == "" {
currency = strings.TrimSpace(lot.Currency)
}
}
if total <= 0 {
return
}
t.EstimatedValue = total
if strings.TrimSpace(t.Currency) == "" {
t.Currency = currency
}
}
+57 -16
View File
@@ -3,6 +3,7 @@ package ted
import ( import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -337,15 +338,11 @@ func (pin PriorInformationNotice) GetTenderDeadline() string {
// GetBudget returns the estimated contract amount and currency. // GetBudget returns the estimated contract amount and currency.
func (pin PriorInformationNotice) GetBudget() (amount string, currency string) { func (pin PriorInformationNotice) GetBudget() (amount string, currency string) {
if pin.firstLot() != nil && if amount, currency = budgetFromProcurementLots(pin.ProcurementProjectLot); amount != "" {
pin.firstLot().ProcurementProject != nil && return amount, currency
pin.firstLot().ProcurementProject.RequestedTenderTotal != nil {
contractAmount := pin.firstLot().ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount
return contractAmount.Text, contractAmount.CurrencyID
} }
if pin.ProcurementProject != nil && pin.ProcurementProject.RequestedTenderTotal != nil { if pin.ProcurementProject != nil && pin.ProcurementProject.RequestedTenderTotal != nil {
a := pin.ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount return pin.ProcurementProject.RequestedTenderTotal.AmountAndCurrency()
return a.Text, a.CurrencyID
} }
return "", "" return "", ""
} }
@@ -1551,12 +1548,34 @@ type ProcessJustification struct {
type RequestedTenderTotal struct { type RequestedTenderTotal struct {
XMLName xml.Name `xml:"RequestedTenderTotal"` XMLName xml.Name `xml:"RequestedTenderTotal"`
EstimatedOverallContractAmount CurrencyText `xml:"EstimatedOverallContractAmount"` EstimatedOverallContractAmount CurrencyText `xml:"EstimatedOverallContractAmount"`
TotalAmount CurrencyText `xml:"TotalAmount,omitempty"`
}
// AmountAndCurrency returns the best-effort amount text and currency from RequestedTenderTotal.
func (r *RequestedTenderTotal) AmountAndCurrency() (amount string, currency string) {
if r == nil {
return "", ""
}
if amount, currency = r.EstimatedOverallContractAmount.AmountAndCurrency(); amount != "" {
return amount, currency
}
return r.TotalAmount.AmountAndCurrency()
} }
// CurrencyText represents text with currency attribute // CurrencyText represents text with currency attribute
type CurrencyText struct { type CurrencyText struct {
CurrencyID string `xml:"currencyID,attr,omitempty"` CurrencyID string `xml:"currencyID,attr,omitempty"`
Text string `xml:",chardata"` Text string `xml:",chardata"`
Value string `xml:"Value,omitempty"`
}
// AmountAndCurrency returns the amount text and currency id from a CurrencyText node.
func (c CurrencyText) AmountAndCurrency() (amount string, currency string) {
amount = strings.TrimSpace(c.Text)
if amount == "" {
amount = strings.TrimSpace(c.Value)
}
return amount, strings.TrimSpace(c.CurrencyID)
} }
// MainCommodityClassification contains main commodity classification // MainCommodityClassification contains main commodity classification
@@ -1972,21 +1991,43 @@ func (cn ContractNotice) GetTenderDeadline() string {
} }
// GetBudget returns the estimated contract amount and currency. // GetBudget returns the estimated contract amount and currency.
// Lot-level BT-27 is preferred; procedure-level BT-27 is used when lots omit RequestedTenderTotal. // Lot-level BT-27 values are summed when present; procedure-level BT-27 is used when lots omit RequestedTenderTotal.
func (cn ContractNotice) GetBudget() (amount string, currency string) { func (cn ContractNotice) GetBudget() (amount string, currency string) {
if cn.firstLot() != nil && if amount, currency = budgetFromProcurementLots(cn.ProcurementProjectLots); amount != "" {
cn.firstLot().ProcurementProject != nil && return amount, currency
cn.firstLot().ProcurementProject.RequestedTenderTotal != nil {
contractAmount := cn.firstLot().ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount
return contractAmount.Text, contractAmount.CurrencyID
} }
if cn.ProcurementProject != nil && cn.ProcurementProject.RequestedTenderTotal != nil { if cn.ProcurementProject != nil && cn.ProcurementProject.RequestedTenderTotal != nil {
a := cn.ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount return cn.ProcurementProject.RequestedTenderTotal.AmountAndCurrency()
return a.Text, a.CurrencyID
} }
return "", "" return "", ""
} }
func budgetFromProcurementLots(lots []ProcurementProjectLot) (amount string, currency string) {
var total float64
for i := range lots {
pp := lots[i].ProcurementProject
if pp == nil || pp.RequestedTenderTotal == nil {
continue
}
lotAmount, lotCurrency := pp.RequestedTenderTotal.AmountAndCurrency()
if lotAmount == "" {
continue
}
value, ok := ParseEuropeanAmount(lotAmount)
if !ok || value <= 0 {
continue
}
total += value
if currency == "" {
currency = lotCurrency
}
}
if total <= 0 {
return "", ""
}
return strconv.FormatFloat(total, 'f', -1, 64), currency
}
// GetDuration returns the contract duration and unit // GetDuration returns the contract duration and unit
func (cn ContractNotice) GetDuration() (duration string, unit string) { func (cn ContractNotice) GetDuration() (duration string, unit string) {
if cn.firstLot() != nil && if cn.firstLot() != nil &&
@@ -2223,7 +2264,7 @@ func (can ContractAwardNotice) GetMainClassificationDescription() string {
func (can ContractAwardNotice) GetTotalAwardValue() (amount string, currency string) { func (can ContractAwardNotice) GetTotalAwardValue() (amount string, currency string) {
noticeResult := can.GetNoticeResult() noticeResult := can.GetNoticeResult()
if noticeResult != nil && noticeResult.TotalAmount != nil { if noticeResult != nil && noticeResult.TotalAmount != nil {
return noticeResult.TotalAmount.Text, noticeResult.TotalAmount.CurrencyID return noticeResult.TotalAmount.AmountAndCurrency()
} }
return "", "" return "", ""
} }