package adapters import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "github.com/xuri/excelize/v2" "github.com/missionworkforce/missionmatch/internal/modules/ontology" ) // PSCSource ingests the federal Product and Service Code manual, the cleanest fully enumerable procurement // taxonomy. It reads the published XLSX (a URL or a local file) and yields one KindPSC record per code. type PSCSource struct { url string client *http.Client } // NewPSCSource builds the adapter. url is the PSC manual XLSX location: an http or https URL // (acquisition.gov publishes it), or a local file path or file:// URL for air gapped operation where // pulling acquisition.gov at runtime is undesirable. func NewPSCSource(url string, client *http.Client) *PSCSource { if client == nil { client = &http.Client{Timeout: 60 * time.Second} } return &PSCSource{url: url, client: client} } // Name is the provenance label. func (s *PSCSource) Name() string { return "PSC Manual" } // Fetch loads the XLSX and yields one record per code. func (s *PSCSource) Fetch(ctx context.Context, yield func(ontology.VocabRecord) error) error { data, err := s.load(ctx) if err != nil { return err } recs, err := parsePSC(data) if err != nil { return err } for _, r := range recs { if err := yield(r); err != nil { return err } } return nil } // load reads the PSC manual bytes from an http or https URL, or from a local file (a plain path or a // file:// URL) for air gapped operation. Choosing the source is configuration, the same cloud portability // posture the forecast feed uses. func (s *PSCSource) load(ctx context.Context) ([]byte, error) { if strings.HasPrefix(s.url, "http://") || strings.HasPrefix(s.url, "https://") { return s.download(ctx) } path := strings.TrimPrefix(s.url, "file://") b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("psc: read local file: %w", err) } return b, nil } func (s *PSCSource) download(ctx context.Context) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) if err != nil { return nil, fmt.Errorf("psc: new request: %w", err) } resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("psc: get: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("psc: provider returned %s", resp.Status) } return io.ReadAll(resp.Body) } // parsePSC reads the PSC manual XLSX bytes and returns one VocabRecord per active code. It matches the // exact PSC CODE and PRODUCT AND SERVICE CODE NAME header cells (the real manual has several columns that // loosely contain CODE or NAME, so a substring match picks the wrong column) and skips codes that carry an // end date, keeping only active codes. func parsePSC(data []byte) ([]ontology.VocabRecord, error) { f, err := excelize.OpenReader(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("psc: open xlsx: %w", err) } defer f.Close() sheet := f.GetSheetName(0) rows, err := f.GetRows(sheet) if err != nil { return nil, fmt.Errorf("psc: read rows: %w", err) } if len(rows) < 2 { return nil, fmt.Errorf("psc: sheet has no data rows") } codeCol, nameCol, endCol := -1, -1, -1 for i, h := range rows[0] { switch strings.TrimSpace(strings.ToUpper(h)) { case "PSC CODE": codeCol = i case "PRODUCT AND SERVICE CODE NAME": nameCol = i case "END DATE": endCol = i } } if codeCol < 0 || nameCol < 0 { return nil, fmt.Errorf("psc: could not find the PSC CODE and PRODUCT AND SERVICE CODE NAME columns in the header %v", rows[0]) } out := make([]ontology.VocabRecord, 0, len(rows)-1) for _, row := range rows[1:] { if codeCol >= len(row) || nameCol >= len(row) { continue } code := strings.TrimSpace(row[codeCol]) name := strings.TrimSpace(row[nameCol]) if code == "" || name == "" { continue } // Skip expired codes (those carrying an end date), keeping only active PSC codes. if endCol >= 0 && endCol < len(row) && strings.TrimSpace(row[endCol]) != "" { continue } raw, _ := json.Marshal(map[string]string{"code": code, "name": name}) out = append(out, ontology.VocabRecord{ SourceName: "PSC Manual", SourceRecordID: code, Kind: ontology.KindPSC, Term: name, Code: code, Category: "procurement", Raw: raw, }) } return out, nil }