package mock import ( "context" "fmt" "hash/fnv" "log" "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 } // NewGenerator returns a generator drawing from cat and storing releases in // store. The same seed and store contents give the same releases. func NewGenerator(cat *Catalog, store *Store, seed int64) *Generator { return &Generator{ cat: cat, store: store, rnd: rand.New(rand.NewSource(seed)), //nolint:gosec // seeded for repeatable mock data } } // 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) { for { d := g.gap(min, max) log.Printf("generator: next release in %s", 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 { log.Printf("generator: storing release: %v", err) continue } log.Printf("generator: published %s (%s) %s", r.ID, r.Tag[0], 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)) // never fails p.ID = fmt.Sprintf("GB-FTS-%d", h.Sum32()%1000000) }