Files
job-search/internal/mock/findatender/generator.go
T
bwilliams 465358609a Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names
Short and single-letter names in application code become full words (tests
keep theirs). Wrapped calls, signatures and composite literals put one item
per line, and one-line function bodies are expanded. Exported names and
groups of constants get doc comments, and comments refer to the renamed
identifiers.
2026-10-15 16:12:56 +01:00

301 lines
7.6 KiB
Go

package mock
import (
"context"
"fmt"
"hash/fnv"
"math"
"math/rand"
"time"
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
)
const ocidPrefix = "ocds-mock-"
// Generator makes new releases from the catalog. About a third of the time it
// moves an existing procedure on (planning -> tender -> award) so the same ocid
// appears in several releases, as it does on the real service.
type Generator struct {
cat *Catalog
store *Store
rnd *rand.Rand
opts options
}
// NewGenerator returns a generator drawing from catalog and storing releases in
// store. The same seed and store contents give the same releases. It logs to
// slog.Default() unless given WithLogger.
func NewGenerator(catalog *Catalog, store *Store, seed int64, opts ...Option) *Generator {
return &Generator{
cat: catalog,
store: store,
rnd: rand.New(rand.NewSource(seed)), //nolint:gosec // seeded for repeatable mock data
opts: applyOptions(opts),
}
}
// Make returns a new release dated at (to the second). About a third of the
// time it moves on a procedure still open from the last 60 days; otherwise it
// starts a new one.
func (g *Generator) Make(at time.Time) ocds.Release {
at = at.UTC().Truncate(time.Second)
if g.rnd.Float64() < 0.35 {
if prev, ok := g.store.RandomOpen(g.rnd, at.AddDate(0, 0, -60)); ok {
return g.advance(prev, at)
}
}
return g.newProcedure(at)
}
// newProcedure returns the first release of a new procedure: a tender (or,
// one time in five, a planning notice) from a random catalog buyer and
// subject, using a random procurement method. Values are scaled by a random
// factor, and a tender without lots gets a single lot covering it all.
func (g *Generator) newProcedure(at time.Time) ocds.Release {
buyer := g.cat.buyer(g.rnd)
ensurePartyID(&buyer)
buyer.Roles = []string{"buyer"}
method := g.cat.method(g.rnd)
tag, status := "tender", "active"
if g.rnd.Float64() < 0.2 {
tag, status = "planning", "planned"
}
ocid := g.newOCID()
tender := g.cat.subject(g.rnd)
tender.ID = ocds.ID(ocid)
tender.ProcurementMethod = method.Method
tender.ProcurementMethodDetails = method.Details
factor := 0.5 + g.rnd.Float64()*1.5
if tender.Value == nil {
amount := float64((g.rnd.Intn(5000) + 10) * 1000)
tender.Value = &ocds.Value{
Amount: &amount,
Currency: "GBP",
}
} else {
scaleValue(tender.Value, factor)
}
for i := range tender.Lots {
scaleValue(tender.Lots[i].Value, factor)
}
if len(tender.Lots) == 0 {
value := clone(*tender.Value)
tender.Lots = []ocds.Lot{{
ID: "1",
Title: tender.Title,
Value: &value,
}}
}
setStatus(&tender, status)
if status == "active" {
tender.TenderPeriod = &ocds.Period{
EndDate: ptr(at.AddDate(0, 0, 30+g.rnd.Intn(30))),
}
}
return ocds.Release{
OCID: ocid,
ID: g.store.NextNoticeID(at.Year()),
Date: at,
Tag: []string{tag},
InitiationType: "tender",
Parties: []ocds.Organization{buyer},
Buyer: &ocds.OrganizationReference{
ID: ocds.ID(buyer.ID),
Name: buyer.Name,
},
Tender: &tender,
Language: "en",
}
}
// advance returns the next release in prev's procedure: planning moves to
// tender, and a tender either has its deadline extended (tenderUpdate) or is
// awarded to a supplier from the catalog.
func (g *Generator) advance(prev ocds.Release, at time.Time) ocds.Release {
release := clone(prev)
release.ID = g.store.NextNoticeID(at.Year())
release.Date = at
release.Awards = nil
if release.Tender == nil {
release.Tender = &ocds.Tender{
ID: ocds.ID(release.OCID),
}
}
next := "award"
switch prev.Tag[0] {
case "planning":
next = "tender"
case "tender":
if g.rnd.Float64() < 0.25 {
next = "tenderUpdate"
}
}
release.Tag = []string{next}
switch next {
case "tender":
setStatus(release.Tender, "active")
release.Tender.TenderPeriod = &ocds.Period{
EndDate: ptr(at.AddDate(0, 0, 30+g.rnd.Intn(30))),
}
case "tenderUpdate":
// A typical update: the deadline is extended by two weeks.
end := at.AddDate(0, 0, 30)
if period := release.Tender.TenderPeriod; period != nil && period.EndDate != nil {
end = *period.EndDate
}
release.Tender.TenderPeriod = &ocds.Period{
EndDate: ptr(end.AddDate(0, 0, 14)),
}
case "award":
setStatus(release.Tender, "complete")
supplier := g.cat.supplier(g.rnd)
ensurePartyID(&supplier)
supplier.Roles = []string{"supplier"}
release.Parties = append(release.Parties, supplier)
var value *ocds.Value
if release.Tender.Value != nil {
scaled := clone(*release.Tender.Value)
scaleValue(&scaled, 0.8+g.rnd.Float64()*0.2)
value = &scaled
}
var lotIDs []string
for _, lot := range release.Tender.Lots {
if lot.ID != "" {
lotIDs = append(lotIDs, lot.ID)
}
}
release.Awards = []ocds.Award{{
ID: "1",
Status: "active",
Date: ptr(at),
Value: value,
Suppliers: []ocds.OrganizationReference{
{
ID: ocds.ID(supplier.ID),
Name: supplier.Name,
},
},
RelatedLots: lotIDs,
}}
}
return release
}
// newOCID returns a random ocid not already in the store.
func (g *Generator) newOCID() string {
for {
id := fmt.Sprintf("%s%06x", ocidPrefix, g.rnd.Intn(1<<24))
if !g.store.HasOCID(id) {
return id
}
}
}
// gap returns a random duration from min to max inclusive, or min if max is
// not larger.
func (g *Generator) gap(min, max time.Duration) time.Duration {
if max <= min {
return min
}
return min + time.Duration(g.rnd.Int63n(int64(max-min)+1))
}
// CatchUp fills the period since the last stored release (or the last backfill
// period, whichever is shorter) so restarts leave no gap in the timeline.
func (g *Generator) CatchUp(
now time.Time, backfill, min, max time.Duration,
) (int, error) {
start := now.Add(-backfill)
if last, ok := g.store.Last(); ok && last.After(start) {
start = last
}
count := 0
for when := start.Add(g.gap(min, max)); !when.After(now); when = when.Add(g.gap(min, max)) {
if err := g.store.AppendNoSync(g.Make(when)); err != nil {
return count, err
}
count++
}
return count, g.store.Sync()
}
// Run publishes a new release after a random gap between min and max until ctx ends.
func (g *Generator) Run(ctx context.Context, min, max time.Duration) {
logger := g.opts.logger("generator")
for {
gap := g.gap(min, max)
logger.Info("next release", "in", gap.Round(time.Second))
timer := time.NewTimer(gap)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
release := g.Make(time.Now())
if err := g.store.Append(release); err != nil {
logger.Error("storing release failed", "error", err)
continue
}
logger.Info(
"published release",
"release_id", release.ID,
"tag", release.Tag[0],
"ocid", release.OCID,
)
}
}
// ---------- helpers ----------
func ptr[T any](value T) *T { return &value }
func setStatus(tender *ocds.Tender, status string) {
tender.Status = status
for i := range tender.Lots {
tender.Lots[i].Status = status
}
}
// scaleValue multiplies value's amounts by factor, rounded to the nearest 100.
func scaleValue(value *ocds.Value, factor float64) {
if value == nil {
return
}
for _, amount := range []**float64{&value.Amount, &value.AmountGross} {
if *amount != nil {
*amount = ptr(math.Round(**amount*factor/100) * 100)
}
}
}
// ensurePartyID gives party an id derived from its name if it has none, so the
// same organisation always gets the same id.
func ensurePartyID(party *ocds.Organization) {
if party.ID != "" {
return
}
hasher := fnv.New32a()
hasher.Write([]byte(party.Name)) //nolint:gosec // never fails
party.ID = fmt.Sprintf("GB-FTS-%d", hasher.Sum32()%1000000)
}