192 lines
5.5 KiB
Go
192 lines
5.5 KiB
Go
package mock
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
|
)
|
|
|
|
// Method is a procurement method and its details, as published on a tender.
|
|
type Method struct {
|
|
Method string `json:"procurementMethod"`
|
|
Details string `json:"procurementMethodDetails,omitempty"`
|
|
}
|
|
|
|
// embeddedCatalog is data/catalog.json, compiled into the binary. Rebuild after
|
|
// changing the file.
|
|
//
|
|
//go:embed data/catalog.json
|
|
var embeddedCatalog []byte
|
|
|
|
// Catalog is the pool the generator draws from. It is read from
|
|
// ./data/catalog.json. Classifications holds every classification the subjects
|
|
// use, keyed "{scheme}-{id}" (e.g. "CPV-90513000"), and subjects and their
|
|
// items refer to entries by that key.
|
|
type Catalog struct {
|
|
Buyers []ocds.Organization `json:"buyers"`
|
|
Suppliers []ocds.Organization `json:"suppliers"`
|
|
Subjects []Subject `json:"subjects"`
|
|
Methods []Method `json:"methods"`
|
|
Classifications map[string]ocds.Classification `json:"classifications"`
|
|
}
|
|
|
|
// Subject is a tender as stored in the catalog: an ocds.Tender whose main
|
|
// classification and additionalClassifications, on the tender and on each
|
|
// item, are keys into Catalog.Classifications. The outer fields shadow the
|
|
// embedded ones of the same JSON name, so everything else is read as
|
|
// ocds.Tender.
|
|
type Subject struct {
|
|
ocds.Tender
|
|
Classification string `json:"classification,omitempty"`
|
|
AdditionalClassifications []string `json:"additionalClassifications,omitempty"`
|
|
Items []SubjectItem `json:"items,omitempty"`
|
|
}
|
|
|
|
// SubjectItem is an ocds.Item whose additionalClassifications are keys into
|
|
// Catalog.Classifications.
|
|
type SubjectItem struct {
|
|
ocds.Item
|
|
AdditionalClassifications []string `json:"additionalClassifications,omitempty"`
|
|
}
|
|
|
|
// LoadCatalog parses the embedded catalog and checks it is usable: every
|
|
// pool has at least one entry and every classification key resolves.
|
|
func LoadCatalog() (*Catalog, error) {
|
|
return parseCatalog(embeddedCatalog, "embedded catalog")
|
|
}
|
|
|
|
// parseCatalog decodes and validates catalog JSON; name labels any error.
|
|
func parseCatalog(b []byte, name string) (*Catalog, error) {
|
|
var c Catalog
|
|
if err := json.Unmarshal(b, &c); err != nil {
|
|
return nil, fmt.Errorf("decoding %s: %w", name, err)
|
|
}
|
|
if err := c.validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid %s: %w", name, err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// validate checks that every pool has at least one entry and that every
|
|
// classification key used by a subject or its items is in Classifications.
|
|
// It reports all the problems found, listing at most five missing keys.
|
|
func (c *Catalog) validate() error {
|
|
var errs []error
|
|
|
|
for _, pool := range []struct {
|
|
name string
|
|
n int
|
|
}{
|
|
{"buyers", len(c.Buyers)},
|
|
{"suppliers", len(c.Suppliers)},
|
|
{"subjects", len(c.Subjects)},
|
|
{"methods", len(c.Methods)},
|
|
{"classifications", len(c.Classifications)},
|
|
} {
|
|
if pool.n == 0 {
|
|
errs = append(errs, fmt.Errorf("no %s", pool.name))
|
|
}
|
|
}
|
|
|
|
missing := map[string]bool{}
|
|
check := func(keys ...string) {
|
|
for _, k := range keys {
|
|
if _, ok := c.Classifications[k]; k != "" && !ok {
|
|
missing[k] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, s := range c.Subjects {
|
|
check(s.Classification)
|
|
check(s.AdditionalClassifications...)
|
|
for _, it := range s.Items {
|
|
check(it.AdditionalClassifications...)
|
|
}
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
keys := make([]string, 0, len(missing))
|
|
for k := range missing {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
if len(keys) > 5 {
|
|
keys = append(keys[:5], "...")
|
|
}
|
|
errs = append(errs, fmt.Errorf(
|
|
"%d classification key(s) not found in classifications: %s",
|
|
len(missing), strings.Join(keys, ", "),
|
|
))
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// ---------- picking (always returns a copy the caller may modify) ----------
|
|
|
|
func (c *Catalog) buyer(r *rand.Rand) ocds.Organization {
|
|
return clone(c.Buyers[r.Intn(len(c.Buyers))])
|
|
}
|
|
|
|
func (c *Catalog) supplier(r *rand.Rand) ocds.Organization {
|
|
return clone(c.Suppliers[r.Intn(len(c.Suppliers))])
|
|
}
|
|
|
|
func (c *Catalog) method(r *rand.Rand) Method {
|
|
return c.Methods[r.Intn(len(c.Methods))]
|
|
}
|
|
|
|
// subject picks a subject and returns it as an ocds.Tender, with its
|
|
// classification keys resolved to full classifications.
|
|
func (c *Catalog) subject(r *rand.Rand) ocds.Tender {
|
|
s := clone(c.Subjects[r.Intn(len(c.Subjects))])
|
|
t := s.Tender
|
|
t.Classification = c.resolveOne(s.Classification)
|
|
t.AdditionalClassifications = c.resolve(s.AdditionalClassifications)
|
|
t.Items = nil
|
|
for _, it := range s.Items {
|
|
item := it.Item
|
|
item.AdditionalClassifications = c.resolve(it.AdditionalClassifications)
|
|
t.Items = append(t.Items, item)
|
|
}
|
|
return t
|
|
}
|
|
|
|
// resolveOne returns a copy of the classification with key, or nil if there
|
|
// is none.
|
|
func (c *Catalog) resolveOne(key string) *ocds.Classification {
|
|
if cl, ok := c.Classifications[key]; ok {
|
|
return &cl
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolve returns copies of the classifications with keys, skipping keys
|
|
// that have none.
|
|
func (c *Catalog) resolve(keys []string) []ocds.Classification {
|
|
var out []ocds.Classification
|
|
for _, k := range keys {
|
|
if cl, ok := c.Classifications[k]; ok {
|
|
out = append(out, cl)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// clone deep-copies a value through JSON, which is all the generated types need.
|
|
func clone[T any](v T) T {
|
|
var out T
|
|
b, err := json.Marshal(v)
|
|
if err == nil {
|
|
json.Unmarshal(b, &out) //nolint:errcheck,gosec // b was just marshalled from a T
|
|
}
|
|
return out
|
|
}
|