293 lines
6.9 KiB
Go
293 lines
6.9 KiB
Go
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)
|
|
}
|