package adapters import ( "context" "encoding/json" "hash/fnv" "io" "log" "strconv" "time" oppintel "github.com/missionworkforce/missionmatch/internal/modules/opportunity_intelligence" "github.com/missionworkforce/missionmatch/internal/platform/forecast" ) // ForecastSource adapts a generic forecast feed (the GSA Forecast of Contracting Opportunities, or any // agency forecast CSV) to the OpportunitySource protocol. Every record is a forecast kind, marked as // planning data so the UI never presents it as a posted solicitation, and kept distinct from active SAM // solicitations by its kind. type ForecastSource struct { src *forecast.Source now func() time.Time } // NewForecastSource wraps a configured forecast source. func NewForecastSource(src *forecast.Source) *ForecastSource { return &ForecastSource{src: src, now: time.Now} } // Name is the provenance label, taken from the underlying feed. func (f *ForecastSource) Name() string { return f.src.Name() } // Fetch pulls the forecast export and yields each row as a forecast record. Rows the parser could not // read are logged as a skipped count, not yielded. func (f *ForecastSource) Fetch(ctx context.Context, yield func(oppintel.OpportunityRecord) error) error { recs, skipped, err := f.src.Records(ctx) if err != nil { return err } if skipped > 0 { log.Printf("forecast %s skipped %d unparseable rows", f.src.Name(), skipped) } now := f.now().UTC() for _, r := range recs { if yerr := yield(fromForecast(f.src.Name(), r, now)); yerr != nil { return yerr } } return nil } // fromForecast converts a normalized forecast row into a unified forecast record. The estimated value is // set only when the export stated a single real figure, with value_source forecast; an absent value // stays nil. When the export has no id of its own, a stable id is derived so a re pull upserts the same // row rather than duplicating it. func fromForecast(sourceName string, r forecast.Record, fetchedAt time.Time) oppintel.OpportunityRecord { id := r.SourceID if id == "" { id = derivedForecastID(sourceName, r) } raw, err := json.Marshal(r.Raw) if err != nil { raw = json.RawMessage("{}") } rec := oppintel.OpportunityRecord{ SourceName: sourceName, SourceRecordID: id, DedupeKey: dedupe(id, ""), Kind: oppintel.KindForecast, Title: r.Title, Agency: r.Agency, SubAgency: r.Organization, NAICS: r.NAICS, Place: oppintel.Place{State: r.PlaceState}, SetAside: r.SetAside, ContractVehicle: r.ContractType, EstimatedAwardDate: r.EstimatedAward, IsPlanning: true, FetchedAt: fetchedAt, Raw: raw, } if r.EstimatedValue != nil { rec.EstimatedValue = r.EstimatedValue rec.ValueSource = "forecast" } return rec } // derivedForecastID builds a stable id from the row's identifying fields when the export carries none, so // the same forecast upserts in place across pulls instead of creating a duplicate each time. func derivedForecastID(sourceName string, r forecast.Record) string { h := fnv.New64a() _, _ = io.WriteString(h, sourceName+"|"+r.Title+"|"+r.Agency+"|"+r.NAICS+"|"+r.PlaceState) if r.EstimatedAward != nil { _, _ = io.WriteString(h, "|"+r.EstimatedAward.Format("2006-01")) } return "fc_" + strconv.FormatUint(h.Sum64(), 16) }