From 50ee510520c69af2b18c3862dd41bde3985ccb6f Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Wed, 21 Oct 2026 14:31:12 +0100 Subject: [PATCH] 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. --- internal/eusupply/convert.go | 7 +- internal/eusupply/eusupply_test.go | 13 ++- internal/findatender/type_convert.go | 64 ++++++++++++- internal/findatender/type_convert_test.go | 90 +++++++++++++++++-- .../00009_add_opportunity_closing_date.sql | 11 +++ internal/store/queries.go | 9 +- internal/store/store.go | 1 + internal/store/store_test.go | 19 ++-- internal/types/opportunities.go | 4 +- 9 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 internal/store/migrations/00009_add_opportunity_closing_date.sql diff --git a/internal/eusupply/convert.go b/internal/eusupply/convert.go index ecd00d8..7335da5 100644 --- a/internal/eusupply/convert.go +++ b/internal/eusupply/convert.go @@ -47,9 +47,9 @@ func Convert(tenders []Tender) []*types.Opportunity { 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 { - published := tender.Published opportunity := &types.Opportunity{ UUID: UUID(tender.ID), UUIDType: uuidType, @@ -60,8 +60,7 @@ func convertOne(tender Tender) *types.Opportunity { Classifications: classifications(tender), MainCategory: mainCategories[tender.ContractType], MultiLot: tender.Lots > 1, - StartDate: &published, - EndDate: tender.Deadline, + ClosingDate: tender.Deadline, Source: Source, LatestReleaseDate: tender.Published, } diff --git a/internal/eusupply/eusupply_test.go b/internal/eusupply/eusupply_test.go index 38570a1..44f7588 100644 --- a/internal/eusupply/eusupply_test.go +++ b/internal/eusupply/eusupply_test.go @@ -269,11 +269,13 @@ func TestRequestGap(t *testing.T) { func TestConvert(t *testing.T) { 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{ ID: "100114946", PID: "109912", Title: "Hazard Management Tool", Published: published, + Deadline: &deadline, Buyer: "UK Atomic Energy Authority", HasDetails: true, Description: "A tool", @@ -308,10 +310,15 @@ func TestConvert(t *testing.T) { opportunity.Classifications[0].Code != "48000000" || !opportunity.Classifications[0].IsCPV: t.Errorf("classifications = %+v", opportunity.Classifications) - case !opportunity.LatestReleaseDate.Equal(published), - opportunity.StartDate == nil || !opportunity.StartDate.Equal(published): + case !opportunity.LatestReleaseDate.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( - "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: t.Error("Convert set marks") diff --git a/internal/findatender/type_convert.go b/internal/findatender/type_convert.go index 6952288..0745fb8 100644 --- a/internal/findatender/type_convert.go +++ b/internal/findatender/type_convert.go @@ -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)) diff --git a/internal/findatender/type_convert_test.go b/internal/findatender/type_convert_test.go index 87013a4..474f18f 100644 --- a/internal/findatender/type_convert_test.go +++ b/internal/findatender/type_convert_test.go @@ -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 diff --git a/internal/store/migrations/00009_add_opportunity_closing_date.sql b/internal/store/migrations/00009_add_opportunity_closing_date.sql new file mode 100644 index 0000000..4e1557d --- /dev/null +++ b/internal/store/migrations/00009_add_opportunity_closing_date.sql @@ -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 diff --git a/internal/store/queries.go b/internal/store/queries.go index 58e5a37..d871765 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -42,13 +42,13 @@ const upsertOpportunity = ` INSERT INTO opportunities ( uuid, uuid_type, title, status, description, buyer_name, buyer_id, main_category, procurement_method, multi_lot, sme_suitable, - vcse_suitable, start_date, end_date, gross_value, has_documents, source, - latest_release_date + vcse_suitable, start_date, end_date, closing_date, gross_value, + has_documents, source, latest_release_date ) VALUES ( :uuid, :uuid_type, :title, :status, :description, :buyer_name, :buyer_id, :main_category, :procurement_method, :multi_lot, :sme_suitable, - :vcse_suitable, :start_date, :end_date, :gross_value, :has_documents, - :source, :latest_release_date + :vcse_suitable, :start_date, :end_date, :closing_date, :gross_value, + :has_documents, :source, :latest_release_date ) ON CONFLICT (uuid) DO UPDATE SET uuid_type = excluded.uuid_type, @@ -64,6 +64,7 @@ ON CONFLICT (uuid) DO UPDATE SET vcse_suitable = excluded.vcse_suitable, start_date = excluded.start_date, end_date = excluded.end_date, + closing_date = excluded.closing_date, gross_value = excluded.gross_value, has_documents = excluded.has_documents, source = excluded.source, diff --git a/internal/store/store.go b/internal/store/store.go index ea89dfd..c6fc514 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -192,6 +192,7 @@ func prepareOpportunity(opportunity *types.Opportunity) error { opportunity.LatestReleaseDate = latest.UTC() opportunity.StartDate = utcPtr(opportunity.StartDate) opportunity.EndDate = utcPtr(opportunity.EndDate) + opportunity.ClosingDate = utcPtr(opportunity.ClosingDate) return nil } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 46f459b..6a28d74 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -37,7 +37,7 @@ func opportunity( releaseDay int, releaseIDs ...string, ) *types.Opportunity { - start, end := day(20), day(28) + start, end, closing := day(20), day(28), day(15) t := &types.Opportunity{ UUID: uuid, UUIDType: "ocid", @@ -52,6 +52,7 @@ func opportunity( SMESuitable: true, StartDate: &start, EndDate: &end, + ClosingDate: &closing, GrossValue: 125000.50, HasDocuments: true, Source: "FindATender", @@ -144,8 +145,11 @@ func TestSaveAndGetRoundTrip(t *testing.T) { t.Errorf("opportunity fields differ:\n got %+v\nwant %+v", got, in) } if got.StartDate == nil || !got.StartDate.Equal(day(20)) || - got.EndDate == nil || !got.EndDate.Equal(day(28)) { - t.Errorf("dates: got %v / %v", got.StartDate, got.EndDate) + got.EndDate == nil || !got.EndDate.Equal(day(28)) || + 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) { t.Errorf("LatestReleaseDate = %v, want %v", got.LatestReleaseDate, want) @@ -188,11 +192,14 @@ func TestSaveAndGetRoundTrip(t *testing.T) { func TestStartAndEndDateMayBeNil(t *testing.T) { s := openStore(t) 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) got := mustGet(t, s, "ocds-1") - if got.StartDate != nil || got.EndDate != nil { - t.Errorf("got %v / %v, want nil", got.StartDate, got.EndDate) + if got.StartDate != nil || got.EndDate != nil || got.ClosingDate != nil { + t.Errorf( + "got %v / %v / %v, want nil", + got.StartDate, got.EndDate, got.ClosingDate, + ) } } diff --git a/internal/types/opportunities.go b/internal/types/opportunities.go index c55dc8c..9b4b00c 100644 --- a/internal/types/opportunities.go +++ b/internal/types/opportunities.go @@ -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, // identified by UUID) as of its latest release, with its classifications, // 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 { UUID string `db:"uuid" json:"uuid"` UUIDType string `db:"uuid_type" json:"uuid_type"` @@ -29,6 +30,7 @@ type Opportunity struct { VCSESuitable bool `db:"vcse_suitable" json:"vcse_suitable"` StartDate *time.Time `db:"start_date" json:"start_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"` HasDocuments bool `db:"has_documents" json:"has_documents"` Source string `db:"source" json:"source"`