tail-fin-pcc 0.7.5

Taiwan government procurement (PCC) adapter for tail-fin: tender search, company lookup, budget tracking
Documentation
use serde_json::Value;
use tail_fin_common::TailFinError;

use crate::types::*;

pub fn parse_search_result(data: &Value) -> Result<SearchResult, TailFinError> {
    let query = data
        .get("query")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let page = data.get("page").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
    let total_records = data
        .get("total_records")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let total_pages = data
        .get("total_pages")
        .and_then(|v| v.as_u64())
        .unwrap_or(0) as u32;

    let records = data
        .get("records")
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(parse_record).collect())
        .unwrap_or_default();

    Ok(SearchResult {
        query,
        page,
        total_records,
        total_pages,
        records,
    })
}

fn parse_record(v: &Value) -> Option<TenderRecord> {
    let brief = v.get("brief").unwrap_or(v);
    let companies_obj = brief.get("companies").unwrap_or(&Value::Null);

    Some(TenderRecord {
        date: v
            .get("date")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        filename: v
            .get("filename")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        job_number: v
            .get("job_number")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        unit_id: v
            .get("unit_id")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        unit_name: v
            .get("unit_name")
            .and_then(|v| v.as_str())
            .or_else(|| v.get("機關資料:機關名稱").and_then(|v| v.as_str()))
            .unwrap_or("")
            .to_string(),
        title: brief
            .get("title")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        tender_type: brief
            .get("type")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        companies: CompanyBrief {
            ids: companies_obj
                .get("ids")
                .and_then(|v| v.as_array())
                .map(|a| {
                    a.iter()
                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
                        .collect()
                })
                .unwrap_or_default(),
            names: companies_obj
                .get("names")
                .and_then(|v| v.as_array())
                .map(|a| {
                    a.iter()
                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
                        .collect()
                })
                .unwrap_or_default(),
        },
        url: v
            .get("url")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        tender_api_url: v
            .get("tender_api_url")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
    })
}

pub fn parse_info(data: &Value) -> Result<PccInfo, TailFinError> {
    Ok(PccInfo {
        latest_date: data
            .get("最新資料時間")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        oldest_date: data
            .get("最舊資料時間")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        total_records: data.get("公告數").and_then(|v| v.as_u64()).unwrap_or(0),
    })
}

pub fn parse_units(data: &Value) -> Vec<Unit> {
    data.as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|v| {
                    Some(Unit {
                        unit_id: v.get("unit_id").and_then(|v| v.as_str())?.to_string(),
                        name: v
                            .get("name")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string(),
                        url: v
                            .get("url")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string(),
                    })
                })
                .collect()
        })
        .unwrap_or_default()
}

pub fn parse_budgets(data: &Value) -> Vec<SpecialBudget> {
    data.get("budgets")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| {
                    let url = v
                        .get("search_api_url")
                        .and_then(|v| v.as_str())?
                        .to_string();
                    // Extract budget name from URL query param
                    let name = url
                        .split("query=")
                        .nth(1)
                        .map(|s| urlencoding::decode(s).unwrap_or_default().to_string())
                        .unwrap_or_default();
                    Some(SpecialBudget {
                        name,
                        search_api_url: url,
                    })
                })
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_search_result() {
        let data = serde_json::json!({
            "query": "電腦",
            "page": 1,
            "total_records": 100,
            "total_pages": 2,
            "records": [{
                "date": "20240101",
                "filename": "BDM-1-123",
                "job_number": "abc123",
                "unit_id": "3.76",
                "unit_name": "彰化縣政府",
                "brief": {
                    "type": "決標公告",
                    "title": "電腦設備採購",
                    "companies": {
                        "ids": ["12345678"],
                        "names": ["台灣電腦公司"]
                    }
                },
                "url": "https://web.pcc.gov.tw/...",
                "tender_api_url": "https://pcc.g0v.ronny.tw/api/tender?unit_id=3.76&job_number=abc123"
            }]
        });

        let result = parse_search_result(&data).unwrap();
        assert_eq!(result.query, "電腦");
        assert_eq!(result.total_records, 100);
        assert_eq!(result.records.len(), 1);
        assert_eq!(result.records[0].title, "電腦設備採購");
        assert_eq!(result.records[0].companies.names, vec!["台灣電腦公司"]);
    }

    #[test]
    fn test_parse_search_empty() {
        let data = serde_json::json!({
            "query": "xxx",
            "page": 1,
            "total_records": 0,
            "total_pages": 0,
            "records": []
        });
        let result = parse_search_result(&data).unwrap();
        assert_eq!(result.total_records, 0);
        assert!(result.records.is_empty());
    }

    #[test]
    fn test_parse_info() {
        let data = serde_json::json!({
            "最新資料時間": "2024-01-01T00:00:00+08:00",
            "最舊資料時間": "2010-01-01T00:00:00+08:00",
            "公告數": 5000000
        });
        let info = parse_info(&data).unwrap();
        assert_eq!(info.total_records, 5000000);
        assert!(info.latest_date.contains("2024"));
    }

    #[test]
    fn test_parse_units() {
        let data = serde_json::json!([
            { "unit_id": "3.76", "name": "彰化縣政府", "url": "https://..." },
            { "unit_id": "A.17", "name": "台北市政府", "url": "https://..." }
        ]);
        let units = parse_units(&data);
        assert_eq!(units.len(), 2);
        assert_eq!(units[0].unit_id, "3.76");
    }

    #[test]
    fn test_parse_budgets() {
        let data = serde_json::json!({
            "budgets": [
                { "search_api_url": "https://pcc.g0v.ronny.tw/api/searchbyspecialbudget?query=%E5%89%8D%E7%9E%BB" }
            ]
        });
        let budgets = parse_budgets(&data);
        assert_eq!(budgets.len(), 1);
        assert_eq!(budgets[0].name, "前瞻");
    }
}