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.
301 lines
7.6 KiB
Go
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)
|
|
}
|