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"`