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 { catalog *Catalog store *Store rng *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{ catalog: catalog, store: store, rng: rand.New(rand.NewSource(seed)), //nolint:gosec // seeded for repeatable mock data opts: applyOptions(opts), } } // Make returns a new release with the given date (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(date time.Time) ocds.Release { date = date.UTC().Truncate(time.Second) if g.rng.Float64() < 0.35 { if previous, ok := g.store.RandomOpen(g.rng, date.AddDate(0, 0, -60)); ok { return g.advance(previous, date) } } return g.newProcedure(date) } // 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(date time.Time) ocds.Release { buyer := g.catalog.buyer(g.rng) ensurePartyID(&buyer) buyer.Roles = []string{"buyer"} method := g.catalog.method(g.rng) tag, status := "tender", "active" if g.rng.Float64() < 0.2 { tag, status = "planning", "planned" } ocid := g.newOCID() tender := g.catalog.subject(g.rng) tender.ID = ocds.ID(ocid) tender.ProcurementMethod = method.Method tender.ProcurementMethodDetails = method.Details factor := 0.5 + g.rng.Float64()*1.5 if tender.Value == nil { amount := float64((g.rng.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(date.AddDate(0, 0, 30+g.rng.Intn(30))), } } return ocds.Release{ OCID: ocid, ID: g.store.NextNoticeID(date.Year()), Date: date, 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 previous'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( previous ocds.Release, date time.Time, ) ocds.Release { release := clone(previous) release.ID = g.store.NextNoticeID(date.Year()) release.Date = date release.Awards = nil if release.Tender == nil { release.Tender = &ocds.Tender{ ID: ocds.ID(release.OCID), } } next := "award" switch previous.Tag[0] { case "planning": next = "tender" case "tender": if g.rng.Float64() < 0.25 { next = "tenderUpdate" } } release.Tag = []string{next} switch next { case "tender": setStatus(release.Tender, "active") release.Tender.TenderPeriod = &ocds.Period{ EndDate: ptr(date.AddDate(0, 0, 30+g.rng.Intn(30))), } case "tenderUpdate": // A typical update: the deadline is extended by two weeks. end := date.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.catalog.supplier(g.rng) 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.rng.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(date), 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.rng.Intn(1<<24)) if !g.store.HasOCID(id) { return id } } } // gap returns a random duration from minGap to maxGap inclusive, or minGap if // maxGap is not larger. func (g *Generator) gap(minGap, maxGap time.Duration) time.Duration { if maxGap <= minGap { return minGap } return minGap + time.Duration(g.rng.Int63n(int64(maxGap-minGap)+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, minGap, maxGap time.Duration, ) (int, error) { start := now.Add(-backfill) if last, ok := g.store.Last(); ok && last.After(start) { start = last } count := 0 when := start.Add(g.gap(minGap, maxGap)) for ; !when.After(now); when = when.Add(g.gap(minGap, maxGap)) { 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 minGap and maxGap // until ctx ends. func (g *Generator) Run(ctx context.Context, minGap, maxGap time.Duration) { logger := g.opts.logger("generator") for { gap := g.gap(minGap, maxGap) 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) }