diff --git a/cmd/worker/workers/notice.go b/cmd/worker/workers/notice.go index 804fd7b..922e021 100644 --- a/cmd/worker/workers/notice.go +++ b/cmd/worker/workers/notice.go @@ -476,6 +476,16 @@ func (w *NoticeWorker) ToTender(n *notice.Notice) (*tender.Tender, error) { awardedEntities = mergeAwardedEntitiesByLotID(t.AwardedEntities, awardedEntities) 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 n.EstimatedValue > 0 { 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). t.Title = title t.Description = description diff --git a/internal/tender/entity.go b/internal/tender/entity.go index 3bb9d45..0f0f9f6 100644 --- a/internal/tender/entity.go +++ b/internal/tender/entity.go @@ -305,20 +305,55 @@ func (t *Tender) GetMainOrganization() *Organization { // HasEstimatedValue returns true if the tender has an estimated value 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 func (t *Tender) GetFormattedEstimatedValue() string { - if !t.HasEstimatedValue() { + value, currency := t.ResolvedEstimatedValueAndCurrency() + if value <= 0 { return "" } - if t.Currency != "" { - return fmt.Sprintf("%.2f %s", t.EstimatedValue, t.Currency) + if 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 diff --git a/internal/tender/entity_test.go b/internal/tender/entity_test.go new file mode 100644 index 0000000..f9a413e --- /dev/null +++ b/internal/tender/entity_test.go @@ -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) + } +} diff --git a/internal/tender/form.go b/internal/tender/form.go index 386c773..20be21c 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -277,6 +277,8 @@ func (t *Tender) ToResponseWithLanguage(language string) *TenderResponse { documentURL = strings.TrimSpace(t.TenderURL) } + estimatedValue, currency := t.ResolvedEstimatedValueAndCurrency() + response := &TenderResponse{ ID: t.ID.Hex(), NoticePublicationID: t.NoticePublicationID, @@ -307,8 +309,8 @@ func (t *Tender) ToResponseWithLanguage(language string) *TenderResponse { MainClassificationDescription: mainDesc, MainClassificationDisplay: classificationDisplay(mainCode, mainDesc), AdditionalClassifications: append([]string(nil), t.AdditionalClassifications...), - EstimatedValue: t.EstimatedValue, - Currency: strings.TrimSpace(t.Currency), + EstimatedValue: estimatedValue, + Currency: currency, EstimatedValueVATBasis: vatBasisUnspecified, Duration: t.Duration, diff --git a/ted/budget_test.go b/ted/budget_test.go new file mode 100644 index 0000000..230f9de --- /dev/null +++ b/ted/budget_test.go @@ -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) + } +} diff --git a/ted/mapper.go b/ted/mapper.go index 2af0bb6..6582b5e 100644 --- a/ted/mapper.go +++ b/ted/mapper.go @@ -30,11 +30,11 @@ func mapProcurementLotFromTED(lot *ProcurementProjectLot) notice.ProcurementLot } } if pp.RequestedTenderTotal != nil { - a := pp.RequestedTenderTotal.EstimatedOverallContractAmount - if v, ok := ParseEuropeanAmount(a.Text); ok { + amount, currencyID := pp.RequestedTenderTotal.AmountAndCurrency() + if v, ok := ParseEuropeanAmount(amount); ok { out.EstimatedValue = v } - out.Currency = a.CurrencyID + out.Currency = currencyID } if pp.PlannedPeriod != nil && pp.PlannedPeriod.DurationMeasure != nil { dm := pp.PlannedPeriod.DurationMeasure @@ -167,6 +167,7 @@ func (s *TEDScraper) mapPriorInformationNoticeToTender(pin *PriorInformationNoti for i := range pin.ProcurementProjectLot { t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&pin.ProcurementProjectLot[i])) } + backfillNoticeEstimatedValueFromLots(t) if duration, unit := pin.GetDuration(); duration != "" { t.Duration = duration @@ -351,6 +352,7 @@ func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileNa for i := range cn.ProcurementProjectLots { t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&cn.ProcurementProjectLots[i])) } + backfillNoticeEstimatedValueFromLots(t) // Set duration information if duration, unit := cn.GetDuration(); duration != "" { @@ -506,6 +508,7 @@ func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, so for i := range can.ProcurementProjectLot { t.ProcurementLots = append(t.ProcurementLots, mapProcurementLotFromTED(&can.ProcurementProjectLot[i])) } + backfillNoticeEstimatedValueFromLots(t) // Set location information from main procurement project or first lot var realizedAddr *Address @@ -663,3 +666,27 @@ func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *notice. 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 + } +} diff --git a/ted/model.go b/ted/model.go index 0885e4d..e7e5047 100644 --- a/ted/model.go +++ b/ted/model.go @@ -3,6 +3,7 @@ package ted import ( "encoding/xml" "fmt" + "strconv" "strings" "time" ) @@ -337,15 +338,11 @@ func (pin PriorInformationNotice) GetTenderDeadline() string { // GetBudget returns the estimated contract amount and currency. func (pin PriorInformationNotice) GetBudget() (amount string, currency string) { - if pin.firstLot() != nil && - pin.firstLot().ProcurementProject != nil && - pin.firstLot().ProcurementProject.RequestedTenderTotal != nil { - contractAmount := pin.firstLot().ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount - return contractAmount.Text, contractAmount.CurrencyID + if amount, currency = budgetFromProcurementLots(pin.ProcurementProjectLot); amount != "" { + return amount, currency } if pin.ProcurementProject != nil && pin.ProcurementProject.RequestedTenderTotal != nil { - a := pin.ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount - return a.Text, a.CurrencyID + return pin.ProcurementProject.RequestedTenderTotal.AmountAndCurrency() } return "", "" } @@ -1551,12 +1548,34 @@ type ProcessJustification struct { type RequestedTenderTotal struct { XMLName xml.Name `xml:"RequestedTenderTotal"` 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 type CurrencyText struct { CurrencyID string `xml:"currencyID,attr,omitempty"` 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 @@ -1972,21 +1991,43 @@ func (cn ContractNotice) GetTenderDeadline() string { } // 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) { - if cn.firstLot() != nil && - cn.firstLot().ProcurementProject != nil && - cn.firstLot().ProcurementProject.RequestedTenderTotal != nil { - contractAmount := cn.firstLot().ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount - return contractAmount.Text, contractAmount.CurrencyID + if amount, currency = budgetFromProcurementLots(cn.ProcurementProjectLots); amount != "" { + return amount, currency } if cn.ProcurementProject != nil && cn.ProcurementProject.RequestedTenderTotal != nil { - a := cn.ProcurementProject.RequestedTenderTotal.EstimatedOverallContractAmount - return a.Text, a.CurrencyID + return cn.ProcurementProject.RequestedTenderTotal.AmountAndCurrency() } 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 func (cn ContractNotice) GetDuration() (duration string, unit string) { if cn.firstLot() != nil && @@ -2223,7 +2264,7 @@ func (can ContractAwardNotice) GetMainClassificationDescription() string { func (can ContractAwardNotice) GetTotalAwardValue() (amount string, currency string) { noticeResult := can.GetNoticeResult() if noticeResult != nil && noticeResult.TotalAmount != nil { - return noticeResult.TotalAmount.Text, noticeResult.TotalAmount.CurrencyID + return noticeResult.TotalAmount.AmountAndCurrency() } return "", "" }