1use crate::Record;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
18pub struct BudgetSourceAnomaly {
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub code: Option<String>,
22
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub field: Option<String>,
26
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub bound_field: Option<String>,
30
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub action: Option<String>,
34
35 #[serde(
37 default,
38 deserialize_with = "number_or_numeric_string",
39 skip_serializing_if = "Option::is_none"
40 )]
41 pub reported_value: Option<f64>,
42
43 #[serde(
45 default,
46 deserialize_with = "number_or_numeric_string",
47 skip_serializing_if = "Option::is_none"
48 )]
49 pub served_value: Option<f64>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub likely_cause: Option<String>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub affected_fields: Option<Vec<String>>,
58
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub message: Option<String>,
62
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub source: Option<BudgetSourceAnomalySource>,
66
67 #[serde(flatten, default)]
69 pub extra: HashMap<String, Value>,
70}
71
72pub fn budget_data_through_period(record: &Record) -> Option<u8> {
77 record
78 .get("data_through_period")
79 .and_then(Value::as_u64)
80 .and_then(|n| u8::try_from(n).ok())
81}
82
83pub(crate) fn number_or_numeric_string<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
85where
86 D: serde::Deserializer<'de>,
87{
88 match Option::<Value>::deserialize(deserializer)? {
89 None | Some(Value::Null) => Ok(None),
90 Some(Value::Number(n)) => Ok(n.as_f64()),
91 Some(Value::String(s)) => s
92 .trim()
93 .parse::<f64>()
94 .map(Some)
95 .map_err(serde::de::Error::custom),
96 Some(other) => Err(serde::de::Error::custom(format!(
97 "expected a number or numeric string, got {other}"
98 ))),
99 }
100}
101
102impl BudgetSourceAnomaly {
103 pub fn from_record(record: &Record) -> serde_json::Result<Vec<Self>> {
107 match record.get("source_anomalies") {
108 None | Some(Value::Null) => Ok(Vec::new()),
109 Some(v) => serde_json::from_value(v.clone()),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
116pub struct BudgetSourceAnomalySource {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub dataset: Option<String>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub fiscal_year: Option<i64>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub rows: Option<Vec<BudgetSourceAnomalyRow>>,
128
129 #[serde(flatten, default)]
131 pub extra: HashMap<String, Value>,
132}
133
134#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
136pub struct BudgetSourceAnomalyRow {
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub fiscal_period: Option<i64>,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub piid: Option<String>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub parent_piid: Option<String>,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub tas: Option<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub reporting_agency_id: Option<String>,
156
157 #[serde(
159 default,
160 deserialize_with = "number_or_numeric_string",
161 skip_serializing_if = "Option::is_none"
162 )]
163 pub transaction_obligated_amount: Option<f64>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub file_c_source: Option<String>,
168
169 #[serde(flatten, default)]
171 pub extra: HashMap<String, Value>,
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178
179 fn record(v: Value) -> Record {
180 v.as_object().cloned().expect("object")
181 }
182
183 #[test]
184 fn decodes_a_capped_contract_anomaly_with_source_rows() {
185 let r = record(json!({
186 "federal_account_symbol": "020-4159",
187 "source_anomalies": [{
188 "code": "contract_exceeds_obligations",
189 "field": "contract_obligated",
190 "bound_field": "obligated_total",
191 "action": "capped",
192 "reported_value": 73_470_455_401.8,
193 "served_value": 4_748_670_061.77,
194 "likely_cause": "unit error",
195 "affected_fields": ["contract_obligated", "contract_share_of_obligated_capped"],
196 "message": "reported contracts exceed obligations",
197 "new_key": 1,
198 "source": {
199 "dataset": "file_c",
200 "fiscal_year": 2025,
201 "rows": [{
202 "fiscal_period": 6,
203 "piid": "P1",
204 "parent_piid": null,
205 "tas": "020-2025/2025-4159",
206 "reporting_agency_id": "020",
207 "transaction_obligated_amount": 68_721_785_340.03,
208 "file_c_source": "award_financial"
209 }]
210 }
211 }]
212 }));
213 let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
214 assert_eq!(got.len(), 1);
215 let a = &got[0];
216 assert_eq!(a.code.as_deref(), Some("contract_exceeds_obligations"));
217 assert_eq!(a.action.as_deref(), Some("capped"));
218 assert_eq!(a.reported_value, Some(73_470_455_401.8));
219 assert_eq!(a.served_value, Some(4_748_670_061.77));
220 assert_eq!(a.affected_fields.as_ref().map(Vec::len), Some(2));
221 assert_eq!(a.extra.get("new_key"), Some(&json!(1)));
222 let src = a.source.as_ref().expect("source");
223 assert_eq!(src.fiscal_year, Some(2025));
224 let row = &src.rows.as_ref().expect("rows")[0];
225 assert_eq!(row.fiscal_period, Some(6));
226 assert_eq!(row.parent_piid, None);
227 assert_eq!(row.reporting_agency_id.as_deref(), Some("020"));
228 assert_eq!(row.transaction_obligated_amount, Some(68_721_785_340.03));
229 }
230
231 #[test]
232 fn sparse_anomaly_and_unknown_code_decode() {
233 let r = record(json!({"source_anomalies": [{"code": "something_new"}]}));
234 let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
235 assert_eq!(got[0].code.as_deref(), Some("something_new"));
236 assert!(got[0].source.is_none());
237 }
238
239 #[test]
240 fn clean_absent_and_null_are_empty() {
241 for v in [
242 json!({"source_anomalies": []}),
243 json!({}),
244 json!({"source_anomalies": null}),
245 ] {
246 assert!(BudgetSourceAnomaly::from_record(&record(v))
247 .expect("decode")
248 .is_empty());
249 }
250 }
251
252 #[test]
253 fn amounts_sent_as_strings_decode() {
254 let r = record(json!({"source_anomalies": [{
255 "reported_value": "73470455401.80",
256 "served_value": null,
257 "source": {"rows": [{"transaction_obligated_amount": "-12.5"}]}
258 }]}));
259 let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
260 assert_eq!(got[0].reported_value, Some(73_470_455_401.8));
261 assert_eq!(got[0].served_value, None);
262 let rows = got[0]
263 .source
264 .as_ref()
265 .and_then(|s| s.rows.as_ref())
266 .expect("rows");
267 assert_eq!(rows[0].transaction_obligated_amount, Some(-12.5));
268 }
269
270 #[test]
271 fn non_list_is_an_error() {
272 let r = record(json!({"source_anomalies": "oops"}));
273 assert!(BudgetSourceAnomaly::from_record(&r).is_err());
274 }
275}