Start and end dates are now the contract's, with a new closing date
Find a Tender never sends tender.tenderPeriod.startDate, so an opportunity's start date was always empty, and its end date was the bid deadline. Start and end are now the contract period: from the contracts' periods, else the awards' contract periods, else the lots' contract periods, each date on its own, taking the earliest start and the latest end of several. The bid deadline (tenderPeriod.endDate) moves to a new closing_date column (migration 00009), in the API as closing_date. EU Supply's pages give no contract period, so its start and end are left empty and its response deadline is the closing date.
This commit is contained in:
1 parent
43f9ef3223
commit
50ee510520
9 files changed
+191
-27
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
@@ -73,6 +74,65 @@ func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
|
||||
return classifications
|
||||
}
|
||||
|
||||
// ocdsGetContractPeriod returns when the contract starts and ends. Each is
|
||||
// taken from the first of these that has it: the contracts' periods (as
|
||||
// signed), the awards' contract periods, then the lots' contract periods
|
||||
// (as planned). Where there are several (one per lot, say), it is the
|
||||
// earliest start and the latest end. Either is nil if none has it.
|
||||
func ocdsGetContractPeriod(release *ocds.Release) (start, end *time.Time) {
|
||||
var contracts, awards, lots []*ocds.Period
|
||||
for i := range release.Contracts {
|
||||
contracts = append(contracts, release.Contracts[i].Period)
|
||||
}
|
||||
for i := range release.Awards {
|
||||
awards = append(awards, release.Awards[i].ContractPeriod)
|
||||
}
|
||||
if release.Tender != nil {
|
||||
for i := range release.Tender.Lots {
|
||||
lots = append(lots, release.Tender.Lots[i].ContractPeriod)
|
||||
}
|
||||
}
|
||||
|
||||
for _, periods := range [][]*ocds.Period{contracts, awards, lots} {
|
||||
if start == nil {
|
||||
start = earliestStart(periods)
|
||||
}
|
||||
if end == nil {
|
||||
end = latestEnd(periods)
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// earliestStart returns the earliest start date of periods, or nil if none
|
||||
// has one.
|
||||
func earliestStart(periods []*ocds.Period) *time.Time {
|
||||
var earliest *time.Time
|
||||
for _, period := range periods {
|
||||
if period == nil || period.StartDate == nil {
|
||||
continue
|
||||
}
|
||||
if earliest == nil || period.StartDate.Before(*earliest) {
|
||||
earliest = period.StartDate
|
||||
}
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
|
||||
// latestEnd returns the latest end date of periods, or nil if none has one.
|
||||
func latestEnd(periods []*ocds.Period) *time.Time {
|
||||
var latest *time.Time
|
||||
for _, period := range periods {
|
||||
if period == nil || period.EndDate == nil {
|
||||
continue
|
||||
}
|
||||
if latest == nil || period.EndDate.After(*latest) {
|
||||
latest = period.EndDate
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
// ocdsIsSMESuitable reports whether any lot of the tender is marked as
|
||||
// suitable for SMEs. A tender with no lots is not.
|
||||
func ocdsIsSMESuitable(release *ocds.Release) bool {
|
||||
@@ -160,8 +220,7 @@ func convertOne(release *ocds.Release) *types.Opportunity {
|
||||
opportunity.SMESuitable = ocdsIsSMESuitable(release)
|
||||
opportunity.VCSESuitable = ocdsIsVCSESuitable(release)
|
||||
if release.Tender.TenderPeriod != nil {
|
||||
opportunity.StartDate = release.Tender.TenderPeriod.StartDate
|
||||
opportunity.EndDate = release.Tender.TenderPeriod.EndDate
|
||||
opportunity.ClosingDate = release.Tender.TenderPeriod.EndDate
|
||||
}
|
||||
if release.Tender.Value != nil && release.Tender.Value.Amount != nil {
|
||||
opportunity.GrossValue = *release.Tender.Value.Amount
|
||||
@@ -170,6 +229,7 @@ func convertOne(release *ocds.Release) *types.Opportunity {
|
||||
opportunity.MultiLot = len(release.Tender.Lots) > 1
|
||||
}
|
||||
|
||||
opportunity.StartDate, opportunity.EndDate = ocdsGetContractPeriod(release)
|
||||
opportunity.LatestReleaseDate = release.Date
|
||||
opportunity.Releases = append(opportunity.Releases, ocdsRelease(release))
|
||||
|
||||
|
||||
@@ -285,8 +285,16 @@ func TestOcdsRelease(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConvertOne(t *testing.T) {
|
||||
start := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)
|
||||
start := time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2028, 11, 30, 0, 0, 0, 0, time.UTC)
|
||||
closing := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)
|
||||
lot := lotWith(&ocds.Suitability{
|
||||
SME: boolPtr(true),
|
||||
})
|
||||
lot.ContractPeriod = &ocds.Period{
|
||||
StartDate: &start,
|
||||
EndDate: &end,
|
||||
}
|
||||
amount := 125000.5
|
||||
main := ocds.Classification{
|
||||
Scheme: "CPV",
|
||||
@@ -308,12 +316,9 @@ func TestConvertOne(t *testing.T) {
|
||||
Classification: &main,
|
||||
MainProcurementCategory: "services",
|
||||
ProcurementMethod: "open",
|
||||
Lots: []ocds.Lot{lotWith(&ocds.Suitability{
|
||||
SME: boolPtr(true),
|
||||
})},
|
||||
Lots: []ocds.Lot{lot},
|
||||
TenderPeriod: &ocds.Period{
|
||||
StartDate: &start,
|
||||
EndDate: &end,
|
||||
EndDate: &closing,
|
||||
},
|
||||
Value: &ocds.Value{
|
||||
Amount: &amount,
|
||||
@@ -342,6 +347,7 @@ func TestConvertOne(t *testing.T) {
|
||||
{"VCSESuitable", got.VCSESuitable, false},
|
||||
{"StartDate", got.StartDate, &start},
|
||||
{"EndDate", got.EndDate, &end},
|
||||
{"ClosingDate", got.ClosingDate, &closing},
|
||||
{"GrossValue", got.GrossValue, 125000.5},
|
||||
{"HasDocuments", got.HasDocuments, true},
|
||||
{"Classifications", len(got.Classifications), 1},
|
||||
@@ -354,6 +360,76 @@ func TestConvertOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The contract period is taken from the contracts, else the awards, else
|
||||
// the lots, each field on its own; of several, the earliest start and the
|
||||
// latest end.
|
||||
func TestOcdsGetContractPeriod(t *testing.T) {
|
||||
day := func(d int) *time.Time {
|
||||
when := time.Date(2026, 10, d, 0, 0, 0, 0, time.UTC)
|
||||
return &when
|
||||
}
|
||||
period := func(start, end *time.Time) *ocds.Period {
|
||||
return &ocds.Period{
|
||||
StartDate: start,
|
||||
EndDate: end,
|
||||
}
|
||||
}
|
||||
lots := func(periods ...*ocds.Period) *ocds.Tender {
|
||||
tender := &ocds.Tender{}
|
||||
for _, p := range periods {
|
||||
tender.Lots = append(tender.Lots, ocds.Lot{
|
||||
ContractPeriod: p,
|
||||
})
|
||||
}
|
||||
return tender
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
release *ocds.Release
|
||||
start, end *time.Time
|
||||
}{
|
||||
{"nothing", &ocds.Release{}, nil, nil},
|
||||
{
|
||||
"lots: earliest start, latest end",
|
||||
&ocds.Release{
|
||||
Tender: lots(period(day(5), day(20)), period(day(3), day(25)), nil),
|
||||
},
|
||||
day(3), day(25),
|
||||
},
|
||||
{
|
||||
"awards before lots",
|
||||
&ocds.Release{
|
||||
Tender: lots(period(day(1), day(30))),
|
||||
Awards: []ocds.Award{
|
||||
{
|
||||
ContractPeriod: period(day(4), day(10)),
|
||||
},
|
||||
},
|
||||
},
|
||||
day(4), day(10),
|
||||
},
|
||||
{
|
||||
"contracts first, the rest only for what they lack",
|
||||
&ocds.Release{
|
||||
Tender: lots(period(day(1), day(30))),
|
||||
Contracts: []ocds.Contract{
|
||||
{
|
||||
Period: period(day(6), nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
day(6), day(30),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
start, end := ocdsGetContractPeriod(tt.release)
|
||||
if !reflect.DeepEqual(start, tt.start) || !reflect.DeepEqual(end, tt.end) {
|
||||
t.Errorf("%s: %v, %v; want %v, %v", tt.name, start, end, tt.start, tt.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOneMinimal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in new issue
Block a user