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:
bwilliams committed 2026-10-21 14:31:12 +01:00
1 parent 43f9ef3223
commit 50ee510520
9 files changed
+191 -27

No files matched your search

+3 -4
View File
@@ -47,9 +47,9 @@ func Convert(tenders []Tender) []*types.Opportunity {
return opportunities return opportunities
} }
// convertOne builds the opportunity for one tender. // convertOne builds the opportunity for one tender. The portal's public
// pages give no contract period, so StartDate and EndDate are left nil.
func convertOne(tender Tender) *types.Opportunity { func convertOne(tender Tender) *types.Opportunity {
published := tender.Published
opportunity := &types.Opportunity{ opportunity := &types.Opportunity{
UUID: UUID(tender.ID), UUID: UUID(tender.ID),
UUIDType: uuidType, UUIDType: uuidType,
@@ -60,8 +60,7 @@ func convertOne(tender Tender) *types.Opportunity {
Classifications: classifications(tender), Classifications: classifications(tender),
MainCategory: mainCategories[tender.ContractType], MainCategory: mainCategories[tender.ContractType],
MultiLot: tender.Lots > 1, MultiLot: tender.Lots > 1,
StartDate: &published, ClosingDate: tender.Deadline,
EndDate: tender.Deadline,
Source: Source, Source: Source,
LatestReleaseDate: tender.Published, LatestReleaseDate: tender.Published,
} }
+10 -3
View File
@@ -269,11 +269,13 @@ func TestRequestGap(t *testing.T) {
func TestConvert(t *testing.T) { func TestConvert(t *testing.T) {
published := time.Date(2026, 10, 21, 11, 45, 0, 0, time.UTC) published := time.Date(2026, 10, 21, 11, 45, 0, 0, time.UTC)
deadline := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC)
tender := Tender{ tender := Tender{
ID: "100114946", ID: "100114946",
PID: "109912", PID: "109912",
Title: "Hazard Management Tool", Title: "Hazard Management Tool",
Published: published, Published: published,
Deadline: &deadline,
Buyer: "UK Atomic Energy Authority", Buyer: "UK Atomic Energy Authority",
HasDetails: true, HasDetails: true,
Description: "A tool", Description: "A tool",
@@ -308,10 +310,15 @@ func TestConvert(t *testing.T) {
opportunity.Classifications[0].Code != "48000000" || opportunity.Classifications[0].Code != "48000000" ||
!opportunity.Classifications[0].IsCPV: !opportunity.Classifications[0].IsCPV:
t.Errorf("classifications = %+v", opportunity.Classifications) t.Errorf("classifications = %+v", opportunity.Classifications)
case !opportunity.LatestReleaseDate.Equal(published), case !opportunity.LatestReleaseDate.Equal(published):
opportunity.StartDate == nil || !opportunity.StartDate.Equal(published): t.Errorf("latest release date = %v", opportunity.LatestReleaseDate)
// The portal gives no contract period; the deadline is the closing date.
case opportunity.StartDate != nil, opportunity.EndDate != nil,
opportunity.ClosingDate == nil ||
!opportunity.ClosingDate.Equal(deadline):
t.Errorf( t.Errorf(
"dates = %v, %v", opportunity.LatestReleaseDate, opportunity.StartDate, "start, end, closing = %v, %v, %v",
opportunity.StartDate, opportunity.EndDate, opportunity.ClosingDate,
) )
case opportunity.Marks.Hidden || opportunity.Marks.Skipped: case opportunity.Marks.Hidden || opportunity.Marks.Skipped:
t.Error("Convert set marks") t.Error("Convert set marks")
+62 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"slices" "slices"
"strings" "strings"
"time"
"git.avsdev.uk/Go/job-search/internal/findatender/ocds" "git.avsdev.uk/Go/job-search/internal/findatender/ocds"
"git.avsdev.uk/Go/job-search/internal/types" "git.avsdev.uk/Go/job-search/internal/types"
@@ -73,6 +74,65 @@ func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
return classifications 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 // ocdsIsSMESuitable reports whether any lot of the tender is marked as
// suitable for SMEs. A tender with no lots is not. // suitable for SMEs. A tender with no lots is not.
func ocdsIsSMESuitable(release *ocds.Release) bool { func ocdsIsSMESuitable(release *ocds.Release) bool {
@@ -160,8 +220,7 @@ func convertOne(release *ocds.Release) *types.Opportunity {
opportunity.SMESuitable = ocdsIsSMESuitable(release) opportunity.SMESuitable = ocdsIsSMESuitable(release)
opportunity.VCSESuitable = ocdsIsVCSESuitable(release) opportunity.VCSESuitable = ocdsIsVCSESuitable(release)
if release.Tender.TenderPeriod != nil { if release.Tender.TenderPeriod != nil {
opportunity.StartDate = release.Tender.TenderPeriod.StartDate opportunity.ClosingDate = release.Tender.TenderPeriod.EndDate
opportunity.EndDate = release.Tender.TenderPeriod.EndDate
} }
if release.Tender.Value != nil && release.Tender.Value.Amount != nil { if release.Tender.Value != nil && release.Tender.Value.Amount != nil {
opportunity.GrossValue = *release.Tender.Value.Amount 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.MultiLot = len(release.Tender.Lots) > 1
} }
opportunity.StartDate, opportunity.EndDate = ocdsGetContractPeriod(release)
opportunity.LatestReleaseDate = release.Date opportunity.LatestReleaseDate = release.Date
opportunity.Releases = append(opportunity.Releases, ocdsRelease(release)) opportunity.Releases = append(opportunity.Releases, ocdsRelease(release))
+83 -7
View File
@@ -285,8 +285,16 @@ func TestOcdsRelease(t *testing.T) {
} }
func TestConvertOne(t *testing.T) { func TestConvertOne(t *testing.T) {
start := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) start := time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 11, 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 amount := 125000.5
main := ocds.Classification{ main := ocds.Classification{
Scheme: "CPV", Scheme: "CPV",
@@ -308,12 +316,9 @@ func TestConvertOne(t *testing.T) {
Classification: &main, Classification: &main,
MainProcurementCategory: "services", MainProcurementCategory: "services",
ProcurementMethod: "open", ProcurementMethod: "open",
Lots: []ocds.Lot{lotWith(&ocds.Suitability{ Lots: []ocds.Lot{lot},
SME: boolPtr(true),
})},
TenderPeriod: &ocds.Period{ TenderPeriod: &ocds.Period{
StartDate: &start, EndDate: &closing,
EndDate: &end,
}, },
Value: &ocds.Value{ Value: &ocds.Value{
Amount: &amount, Amount: &amount,
@@ -342,6 +347,7 @@ func TestConvertOne(t *testing.T) {
{"VCSESuitable", got.VCSESuitable, false}, {"VCSESuitable", got.VCSESuitable, false},
{"StartDate", got.StartDate, &start}, {"StartDate", got.StartDate, &start},
{"EndDate", got.EndDate, &end}, {"EndDate", got.EndDate, &end},
{"ClosingDate", got.ClosingDate, &closing},
{"GrossValue", got.GrossValue, 125000.5}, {"GrossValue", got.GrossValue, 125000.5},
{"HasDocuments", got.HasDocuments, true}, {"HasDocuments", got.HasDocuments, true},
{"Classifications", len(got.Classifications), 1}, {"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) { func TestConvertOneMinimal(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
-- When bids must be in by (the tender period's end). start_date and
-- end_date are the contract period.
ALTER TABLE "opportunities" ADD COLUMN "closing_date" DATETIME;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "opportunities" DROP COLUMN "closing_date";
-- +goose StatementEnd
+5 -4
View File
@@ -42,13 +42,13 @@ const upsertOpportunity = `
INSERT INTO opportunities ( INSERT INTO opportunities (
uuid, uuid_type, title, status, description, buyer_name, buyer_id, uuid, uuid_type, title, status, description, buyer_name, buyer_id,
main_category, procurement_method, multi_lot, sme_suitable, main_category, procurement_method, multi_lot, sme_suitable,
vcse_suitable, start_date, end_date, gross_value, has_documents, source, vcse_suitable, start_date, end_date, closing_date, gross_value,
latest_release_date has_documents, source, latest_release_date
) VALUES ( ) VALUES (
:uuid, :uuid_type, :title, :status, :description, :buyer_name, :buyer_id, :uuid, :uuid_type, :title, :status, :description, :buyer_name, :buyer_id,
:main_category, :procurement_method, :multi_lot, :sme_suitable, :main_category, :procurement_method, :multi_lot, :sme_suitable,
:vcse_suitable, :start_date, :end_date, :gross_value, :has_documents, :vcse_suitable, :start_date, :end_date, :closing_date, :gross_value,
:source, :latest_release_date :has_documents, :source, :latest_release_date
) )
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
uuid_type = excluded.uuid_type, uuid_type = excluded.uuid_type,
@@ -64,6 +64,7 @@ ON CONFLICT (uuid) DO UPDATE SET
vcse_suitable = excluded.vcse_suitable, vcse_suitable = excluded.vcse_suitable,
start_date = excluded.start_date, start_date = excluded.start_date,
end_date = excluded.end_date, end_date = excluded.end_date,
closing_date = excluded.closing_date,
gross_value = excluded.gross_value, gross_value = excluded.gross_value,
has_documents = excluded.has_documents, has_documents = excluded.has_documents,
source = excluded.source, source = excluded.source,
+1
View File
@@ -192,6 +192,7 @@ func prepareOpportunity(opportunity *types.Opportunity) error {
opportunity.LatestReleaseDate = latest.UTC() opportunity.LatestReleaseDate = latest.UTC()
opportunity.StartDate = utcPtr(opportunity.StartDate) opportunity.StartDate = utcPtr(opportunity.StartDate)
opportunity.EndDate = utcPtr(opportunity.EndDate) opportunity.EndDate = utcPtr(opportunity.EndDate)
opportunity.ClosingDate = utcPtr(opportunity.ClosingDate)
return nil return nil
} }
+13 -6
View File
@@ -37,7 +37,7 @@ func opportunity(
releaseDay int, releaseDay int,
releaseIDs ...string, releaseIDs ...string,
) *types.Opportunity { ) *types.Opportunity {
start, end := day(20), day(28) start, end, closing := day(20), day(28), day(15)
t := &types.Opportunity{ t := &types.Opportunity{
UUID: uuid, UUID: uuid,
UUIDType: "ocid", UUIDType: "ocid",
@@ -52,6 +52,7 @@ func opportunity(
SMESuitable: true, SMESuitable: true,
StartDate: &start, StartDate: &start,
EndDate: &end, EndDate: &end,
ClosingDate: &closing,
GrossValue: 125000.50, GrossValue: 125000.50,
HasDocuments: true, HasDocuments: true,
Source: "FindATender", Source: "FindATender",
@@ -144,8 +145,11 @@ func TestSaveAndGetRoundTrip(t *testing.T) {
t.Errorf("opportunity fields differ:\n got %+v\nwant %+v", got, in) t.Errorf("opportunity fields differ:\n got %+v\nwant %+v", got, in)
} }
if got.StartDate == nil || !got.StartDate.Equal(day(20)) || if got.StartDate == nil || !got.StartDate.Equal(day(20)) ||
got.EndDate == nil || !got.EndDate.Equal(day(28)) { got.EndDate == nil || !got.EndDate.Equal(day(28)) ||
t.Errorf("dates: got %v / %v", got.StartDate, got.EndDate) got.ClosingDate == nil || !got.ClosingDate.Equal(day(15)) {
t.Errorf(
"dates: got %v / %v / %v", got.StartDate, got.EndDate, got.ClosingDate,
)
} }
if want := day(1).Add(time.Minute); !got.LatestReleaseDate.Equal(want) { if want := day(1).Add(time.Minute); !got.LatestReleaseDate.Equal(want) {
t.Errorf("LatestReleaseDate = %v, want %v", got.LatestReleaseDate, want) t.Errorf("LatestReleaseDate = %v, want %v", got.LatestReleaseDate, want)
@@ -188,11 +192,14 @@ func TestSaveAndGetRoundTrip(t *testing.T) {
func TestStartAndEndDateMayBeNil(t *testing.T) { func TestStartAndEndDateMayBeNil(t *testing.T) {
s := openStore(t) s := openStore(t)
in := opportunity("ocds-1", "No dates", 1, "r1") in := opportunity("ocds-1", "No dates", 1, "r1")
in.StartDate, in.EndDate = nil, nil in.StartDate, in.EndDate, in.ClosingDate = nil, nil, nil
mustSave(t, s, in) mustSave(t, s, in)
got := mustGet(t, s, "ocds-1") got := mustGet(t, s, "ocds-1")
if got.StartDate != nil || got.EndDate != nil { if got.StartDate != nil || got.EndDate != nil || got.ClosingDate != nil {
t.Errorf("got %v / %v, want nil", got.StartDate, got.EndDate) t.Errorf(
"got %v / %v / %v, want nil",
got.StartDate, got.EndDate, got.ClosingDate,
)
} }
} }
+3 -1
View File
@@ -12,7 +12,8 @@ var ErrWonAndLost = errors.New("types: an opportunity cannot be both won and los
// Opportunity is a procurement opportunity (an OCDS contracting process, // Opportunity is a procurement opportunity (an OCDS contracting process,
// identified by UUID) as of its latest release, with its classifications, // identified by UUID) as of its latest release, with its classifications,
// releases and the user's marks. Fields tagged db:"-" are stored in their // releases and the user's marks. Fields tagged db:"-" are stored in their
// own tables. // own tables. StartDate and EndDate are the contract's period; ClosingDate
// is when bids must be in by.
type Opportunity struct { type Opportunity struct {
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
UUIDType string `db:"uuid_type" json:"uuid_type"` UUIDType string `db:"uuid_type" json:"uuid_type"`
@@ -29,6 +30,7 @@ type Opportunity struct {
VCSESuitable bool `db:"vcse_suitable" json:"vcse_suitable"` VCSESuitable bool `db:"vcse_suitable" json:"vcse_suitable"`
StartDate *time.Time `db:"start_date" json:"start_date" extensions:"x-nullable"` StartDate *time.Time `db:"start_date" json:"start_date" extensions:"x-nullable"`
EndDate *time.Time `db:"end_date" json:"end_date" extensions:"x-nullable"` EndDate *time.Time `db:"end_date" json:"end_date" extensions:"x-nullable"`
ClosingDate *time.Time `db:"closing_date" json:"closing_date" extensions:"x-nullable"`
GrossValue float64 `db:"gross_value" json:"gross_value"` GrossValue float64 `db:"gross_value" json:"gross_value"`
HasDocuments bool `db:"has_documents" json:"has_documents"` HasDocuments bool `db:"has_documents" json:"has_documents"`
Source string `db:"source" json:"source"` Source string `db:"source" json:"source"`