Files
job-search/internal/mock/findatender/generator.go
T
bwilliams 043e307663 Logging: structured slog messages throughout, and WithLogger options so callers choose the logger
Log messages are lower-case event names with their details as key/value
attributes ("error" for errors), in place of formatted strings.

Loggable package take a WithLogger option as part of their "New*" call,
defaulting to slog.Default(). Each tags its lines with its `component`.

Some debug loggers print directly rather than using slog when in
terminal logging mode.
2026-10-15 16:10:52 +01:00

296 lines
7.1 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 cat 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(cat *Catalog, store *Store, seed int64, opts ...Option) *Generator {
return &Generator{
cat: cat,
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"}
m := g.cat.method(g.rnd)
tag, status := "tender", "active"
if g.rnd.Float64() < 0.2 {
tag, status = "planning", "planned"
}
ocid := g.newOCID()
t := g.cat.subject(g.rnd)
t.ID = ocds.ID(ocid)
t.ProcurementMethod = m.Method
t.ProcurementMethodDetails = m.Details
factor := 0.5 + g.rnd.Float64()*1.5
if t.Value == nil {
amount := float64((g.rnd.Intn(5000) + 10) * 1000)
t.Value = &ocds.Value{
Amount: &amount,
Currency: "GBP",
}
} else {
scaleValue(t.Value, factor)
}
for i := range t.Lots {
scaleValue(t.Lots[i].Value, factor)
}
if len(t.Lots) == 0 {
v := clone(*t.Value)
t.Lots = []ocds.Lot{{
ID: "1",
Title: t.Title,
Value: &v,
}}
}
setStatus(&t, status)
if status == "active" {
t.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: &t,
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 {
r := clone(prev)
r.ID = g.store.NextNoticeID(at.Year())
r.Date = at
r.Awards = nil
if r.Tender == nil {
r.Tender = &ocds.Tender{
ID: ocds.ID(r.OCID),
}
}
next := "award"
switch prev.Tag[0] {
case "planning":
next = "tender"
case "tender":
if g.rnd.Float64() < 0.25 {
next = "tenderUpdate"
}
}
r.Tag = []string{next}
switch next {
case "tender":
setStatus(r.Tender, "active")
r.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 tp := r.Tender.TenderPeriod; tp != nil && tp.EndDate != nil {
end = *tp.EndDate
}
r.Tender.TenderPeriod = &ocds.Period{
EndDate: ptr(end.AddDate(0, 0, 14)),
}
case "award":
setStatus(r.Tender, "complete")
sup := g.cat.supplier(g.rnd)
ensurePartyID(&sup)
sup.Roles = []string{"supplier"}
r.Parties = append(r.Parties, sup)
var value *ocds.Value
if r.Tender.Value != nil {
v := clone(*r.Tender.Value)
scaleValue(&v, 0.8+g.rnd.Float64()*0.2)
value = &v
}
var lotIDs []string
for _, lot := range r.Tender.Lots {
if lot.ID != "" {
lotIDs = append(lotIDs, lot.ID)
}
}
r.Awards = []ocds.Award{{
ID: "1",
Status: "active",
Date: ptr(at),
Value: value,
Suppliers: []ocds.OrganizationReference{
{
ID: ocds.ID(sup.ID),
Name: sup.Name,
},
},
RelatedLots: lotIDs,
}}
}
return r
}
// 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
}
n := 0
for t := start.Add(g.gap(min, max)); !t.After(now); t = t.Add(g.gap(min, max)) {
if err := g.store.AppendNoSync(g.Make(t)); err != nil {
return n, err
}
n++
}
return n, 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 {
d := g.gap(min, max)
logger.Info("next release", "in", d.Round(time.Second))
timer := time.NewTimer(d)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
r := g.Make(time.Now())
if err := g.store.Append(r); err != nil {
logger.Error("storing release failed", "error", err)
continue
}
logger.Info("published release", "release_id", r.ID, "tag", r.Tag[0], "ocid", r.OCID)
}
}
// ---------- helpers ----------
func ptr[T any](v T) *T { return &v }
func setStatus(t *ocds.Tender, status string) {
t.Status = status
for i := range t.Lots {
t.Lots[i].Status = status
}
}
// scaleValue multiplies v's amounts by f, rounded to the nearest 100.
func scaleValue(v *ocds.Value, f float64) {
if v == nil {
return
}
for _, a := range []**float64{&v.Amount, &v.AmountGross} {
if *a != nil {
*a = ptr(math.Round(**a*f/100) * 100)
}
}
}
// ensurePartyID gives p an id derived from its name if it has none, so the
// same organisation always gets the same id.
func ensurePartyID(p *ocds.Organization) {
if p.ID != "" {
return
}
h := fnv.New32a()
h.Write([]byte(p.Name)) //nolint:gosec // never fails
p.ID = fmt.Sprintf("GB-FTS-%d", h.Sum32()%1000000)
}