// Package forecast normalizes agency contract forecasts (planned, not yet posted opportunities) from // CSV exports into a flat record. Most agencies, and the governmentwide GSA Forecast of Contracting // Opportunities, publish forecasts as a CSV with their own column names and no clean API, so the parser // is mapping driven: each source supplies a Mapping from the normalized fields to its candidate CSV // header names, and the same parser handles every source without new code. Forecasts are planning data // subject to change, never a posted solicitation. Money and dates are parsed only from a real single // value, never fabricated from a range or a category. package forecast import ( "encoding/csv" "errors" "fmt" "io" "regexp" "strconv" "strings" "time" "github.com/shopspring/decimal" ) // Record is one normalized forecast row. Optional money and dates are pointers (nil = absent). Raw keeps // the original row keyed by header for provenance. type Record struct { SourceID string Title string Agency string Organization string NAICS string PlaceState string SetAside string // acquisition strategy or set aside status ContractType string AwardStatus string EstimatedValue *decimal.Decimal EstimatedAward *time.Time Raw map[string]string } // Field is a normalized forecast field the parser maps a source column onto. type Field string const ( FieldSourceID Field = "source_id" FieldTitle Field = "title" FieldAgency Field = "agency" FieldOrganization Field = "organization" FieldNAICS Field = "naics" FieldPlaceState Field = "place_state" FieldSetAside Field = "set_aside" FieldContractType Field = "contract_type" FieldAwardStatus Field = "award_status" FieldEstimatedValue Field = "estimated_value" FieldEstimatedAward Field = "estimated_award_date" ) // Mapping gives, for each field, the candidate CSV header names to look for. Matching is case // insensitive and the first candidate that exists wins, so one source definition tolerates header drift // between exports. type Mapping map[Field][]string // Parse reads a CSV and maps each row to a Record using the mapping. The first row is the header. A row // with no identity (no source id and no title) is skipped, and a malformed row is skipped rather than // fatal, so one bad line never loses the whole export. The count of skipped rows is returned for the // caller to log. func Parse(r io.Reader, m Mapping) (records []Record, skipped int, err error) { cr := csv.NewReader(r) cr.FieldsPerRecord = -1 // tolerate ragged rows from a hand exported CSV cr.TrimLeadingSpace = true header, err := cr.Read() if err != nil { return nil, 0, fmt.Errorf("forecast: read header: %w", err) } col := resolveColumns(header, m) for { row, e := cr.Read() if errors.Is(e, io.EOF) { break } if e != nil { skipped++ continue } rec, ok := mapRow(row, header, col) if !ok { skipped++ continue } records = append(records, rec) } return records, skipped, nil } // resolveColumns maps each field to a column index using the first matching candidate header, or minus // one when no candidate header is present in this export. func resolveColumns(header []string, m Mapping) map[Field]int { pos := make(map[string]int, len(header)) for i, h := range header { pos[normHeader(h)] = i } out := make(map[Field]int, len(m)) for field, candidates := range m { idx := -1 for _, c := range candidates { if i, ok := pos[normHeader(c)]; ok { idx = i break } } out[field] = idx } return out } func mapRow(row, header []string, col map[Field]int) (Record, bool) { get := func(f Field) string { i := col[f] if i < 0 || i >= len(row) { return "" } return strings.TrimSpace(row[i]) } rec := Record{ SourceID: get(FieldSourceID), Title: get(FieldTitle), Agency: get(FieldAgency), Organization: get(FieldOrganization), NAICS: get(FieldNAICS), PlaceState: get(FieldPlaceState), SetAside: get(FieldSetAside), ContractType: get(FieldContractType), AwardStatus: get(FieldAwardStatus), EstimatedValue: parseValue(get(FieldEstimatedValue)), EstimatedAward: parseDate(get(FieldEstimatedAward)), } if rec.SourceID == "" && rec.Title == "" { return Record{}, false } rec.Raw = make(map[string]string, len(header)) for i, h := range header { if i < len(row) { rec.Raw[h] = strings.TrimSpace(row[i]) } } return rec, true } func normHeader(h string) string { return strings.ToLower(strings.TrimSpace(h)) } var ( quarterRe = regexp.MustCompile(`Q([1-4])`) ordinalQRe = regexp.MustCompile(`([1-4])(?:ST|ND|RD|TH)?\s+QUARTER`) yearRe = regexp.MustCompile(`(20\d\d)`) // numberRe is anchored to the whole trimmed field so a value is only read when the entire field is a // single dollar amount. Without the anchors, descriptive text like "Approx. 500K" would still yield // one match and parseValue would read a figure the forecast never stated. numberRe = regexp.MustCompile(`^\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*(K|M|B|THOUSAND|MILLION|BILLION)?$`) ) // parseDate normalizes the estimated award date. It accepts an explicit date in several layouts, a month // and year, and a federal fiscal quarter (mapped to the quarter's first month, since a forecast states a // quarter not a day). TBD and similar placeholders, and anything unrecognized, return nil rather than a // fabricated date. func parseDate(s string) *time.Time { s = strings.TrimSpace(s) if s == "" { return nil } up := strings.ToUpper(s) switch up { case "TBD", "N/A", "NA", "UNKNOWN", "PENDING": return nil } layouts := []string{ "2006-01-02", "01/02/2006", "1/2/2006", time.RFC3339, "January 2, 2006", "Jan 2, 2006", "2006-01", "01/2006", "1/2006", "January 2006", "Jan 2006", "Jan-2006", } for _, l := range layouts { if t, err := time.Parse(l, s); err == nil { d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return &d } } return parseFiscalQuarter(up) } // parseFiscalQuarter maps a stated federal fiscal quarter to the quarter's first calendar month, the // honest sortable approximation of a quarter (the original text is retained in the record's Raw). The // federal fiscal year starts in October, so FY Q1 is October of the prior calendar year. func parseFiscalQuarter(up string) *time.Time { q := 0 if mm := quarterRe.FindStringSubmatch(up); mm != nil { q, _ = strconv.Atoi(mm[1]) } else if mm := ordinalQRe.FindStringSubmatch(up); mm != nil { q, _ = strconv.Atoi(mm[1]) } ym := yearRe.FindStringSubmatch(up) if q == 0 || ym == nil { return nil } fy, _ := strconv.Atoi(ym[1]) var month int var year int switch q { case 1: month, year = 10, fy-1 case 2: month, year = 1, fy case 3: month, year = 4, fy case 4: month, year = 7, fy } d := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) return &d } // parseValue parses the estimated value only when the field is a single clean dollar amount (optionally // with a K, M, or B suffix). A range, a category, or two numbers yields nil, because representing a range // as one point would fabricate a figure the forecast does not state. The raw text stays in the record. func parseValue(s string) *decimal.Decimal { up := strings.ToUpper(strings.TrimSpace(s)) if up == "" { return nil } matches := numberRe.FindAllStringSubmatch(up, -1) if len(matches) != 1 { return nil } digits := strings.ReplaceAll(matches[0][1], ",", "") d, err := decimal.NewFromString(digits) if err != nil { return nil } switch matches[0][2] { case "K", "THOUSAND": d = d.Mul(decimal.NewFromInt(1000)) case "M", "MILLION": d = d.Mul(decimal.NewFromInt(1000000)) case "B", "BILLION": d = d.Mul(decimal.NewFromInt(1000000000)) } return &d }