1use chrono::{Datelike, NaiveDate};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::hash::{Hash, Hasher};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct GenerationPeriod {
26 pub index: usize,
28 pub label: String,
30 pub start_date: NaiveDate,
32 pub end_date: NaiveDate,
34 pub months: u32,
36}
37
38impl GenerationPeriod {
39 pub fn compute_periods(
51 start_date: NaiveDate,
52 total_months: u32,
53 fiscal_year_months: u32,
54 ) -> Vec<GenerationPeriod> {
55 assert!(fiscal_year_months > 0, "fiscal_year_months must be > 0");
56 assert!(total_months > 0, "total_months must be > 0");
57
58 let mut periods = Vec::new();
59 let mut remaining = total_months;
60 let mut cursor = start_date;
61 let mut index: usize = 0;
62
63 while remaining > 0 {
64 let months = remaining.min(fiscal_year_months);
65 let end = add_months(cursor, months)
66 .pred_opt()
67 .expect("valid predecessor date");
68 let label = format!("FY{}", cursor.year());
69
70 periods.push(GenerationPeriod {
71 index,
72 label,
73 start_date: cursor,
74 end_date: end,
75 months,
76 });
77
78 cursor = add_months(cursor, months);
79 remaining -= months;
80 index += 1;
81 }
82
83 periods
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct SessionState {
97 pub rng_seed: u64,
99 pub period_cursor: usize,
101 pub balance_state: BalanceState,
103 pub document_id_state: DocumentIdState,
105 pub entity_counts: EntityCounts,
107 pub generation_log: Vec<PeriodLog>,
109 pub config_hash: String,
112}
113
114impl SessionState {
115 pub fn new(rng_seed: u64, config_hash: String) -> Self {
117 Self {
118 rng_seed,
119 period_cursor: 0,
120 balance_state: BalanceState::default(),
121 document_id_state: DocumentIdState::default(),
122 entity_counts: EntityCounts::default(),
123 generation_log: Vec::new(),
124 config_hash,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct BalanceState {
136 pub gl_balances: HashMap<String, f64>,
138 pub ar_total: f64,
140 pub ap_total: f64,
142 pub fa_net_book_value: f64,
144 pub retained_earnings: f64,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154pub struct DocumentIdState {
155 pub next_po_number: u64,
157 pub next_so_number: u64,
159 pub next_je_number: u64,
161 pub next_invoice_number: u64,
163 pub next_payment_number: u64,
165 pub next_gr_number: u64,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178pub struct EntityCounts {
179 pub vendors: usize,
181 pub customers: usize,
183 pub employees: usize,
185 pub materials: usize,
187 pub fixed_assets: usize,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct PeriodLog {
198 pub period_label: String,
200 pub journal_entries: usize,
202 pub documents: usize,
204 pub anomalies: usize,
206 pub duration_secs: f64,
208}
209
210pub fn advance_seed(seed: u64, period_index: usize) -> u64 {
219 let mut hasher = std::collections::hash_map::DefaultHasher::new();
220 seed.hash(&mut hasher);
221 period_index.hash(&mut hasher);
222 hasher.finish()
223}
224
225pub fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
238 let total_months = date.year() as i64 * 12 + (date.month() as i64 - 1) + months as i64;
239 let target_year = (total_months / 12) as i32;
240 let target_month = (total_months % 12) as u32 + 1;
241
242 let max_day = last_day_of_month(target_year, target_month);
244 let day = date.day().min(max_day);
245
246 NaiveDate::from_ymd_opt(target_year, target_month, day).expect("valid date after add_months")
247}
248
249fn last_day_of_month(year: i32, month: u32) -> u32 {
251 if month == 12 {
252 31
253 } else {
254 NaiveDate::from_ymd_opt(year, month + 1, 1)
255 .expect("valid next-month date")
256 .pred_opt()
257 .expect("valid predecessor")
258 .day()
259 }
260}
261
262#[cfg(test)]
267#[allow(clippy::unwrap_used, clippy::approx_constant)]
268mod tests {
269 use super::*;
270 use chrono::NaiveDate;
271
272 fn date(y: i32, m: u32, d: u32) -> NaiveDate {
273 NaiveDate::from_ymd_opt(y, m, d).unwrap()
274 }
275
276 #[test]
277 fn test_compute_periods_single_year() {
278 let periods = GenerationPeriod::compute_periods(date(2024, 1, 1), 12, 12);
279 assert_eq!(periods.len(), 1);
280 assert_eq!(periods[0].index, 0);
281 assert_eq!(periods[0].label, "FY2024");
282 assert_eq!(periods[0].start_date, date(2024, 1, 1));
283 assert_eq!(periods[0].end_date, date(2024, 12, 31));
284 assert_eq!(periods[0].months, 12);
285 }
286
287 #[test]
288 fn test_compute_periods_three_years() {
289 let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 36, 12);
290 assert_eq!(periods.len(), 3);
291
292 assert_eq!(periods[0].label, "FY2022");
293 assert_eq!(periods[0].start_date, date(2022, 1, 1));
294 assert_eq!(periods[0].end_date, date(2022, 12, 31));
295 assert_eq!(periods[0].months, 12);
296
297 assert_eq!(periods[1].label, "FY2023");
298 assert_eq!(periods[1].start_date, date(2023, 1, 1));
299 assert_eq!(periods[1].end_date, date(2023, 12, 31));
300 assert_eq!(periods[1].months, 12);
301
302 assert_eq!(periods[2].label, "FY2024");
303 assert_eq!(periods[2].start_date, date(2024, 1, 1));
304 assert_eq!(periods[2].end_date, date(2024, 12, 31));
305 assert_eq!(periods[2].months, 12);
306 }
307
308 #[test]
309 fn test_compute_periods_partial() {
310 let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 18, 12);
311 assert_eq!(periods.len(), 2);
312
313 assert_eq!(periods[0].label, "FY2022");
314 assert_eq!(periods[0].months, 12);
315 assert_eq!(periods[0].end_date, date(2022, 12, 31));
316
317 assert_eq!(periods[1].label, "FY2023");
318 assert_eq!(periods[1].months, 6);
319 assert_eq!(periods[1].start_date, date(2023, 1, 1));
320 assert_eq!(periods[1].end_date, date(2023, 6, 30));
321 }
322
323 #[test]
324 fn test_advance_seed_deterministic() {
325 let a = advance_seed(42, 0);
326 let b = advance_seed(42, 0);
327 assert_eq!(a, b, "same inputs must produce same seed");
328 }
329
330 #[test]
331 fn test_advance_seed_differs_by_index() {
332 let a = advance_seed(42, 0);
333 let b = advance_seed(42, 1);
334 assert_ne!(a, b, "different indices must produce different seeds");
335 }
336
337 #[test]
338 fn test_session_state_serde_roundtrip() {
339 let mut state = SessionState::new(12345, "abc123hash".to_string());
340 state.period_cursor = 2;
341 state.balance_state.ar_total = 50_000.0;
342 state.balance_state.retained_earnings = 100_000.0;
343 state
344 .balance_state
345 .gl_balances
346 .insert("1100".to_string(), 50_000.0);
347 state.document_id_state.next_je_number = 500;
348 state.entity_counts.vendors = 42;
349 state.generation_log.push(PeriodLog {
350 period_label: "FY2024".to_string(),
351 journal_entries: 1000,
352 documents: 2500,
353 anomalies: 25,
354 duration_secs: 3.14,
355 });
356
357 let json = serde_json::to_string(&state).expect("serialize");
358 let restored: SessionState = serde_json::from_str(&json).expect("deserialize");
359
360 assert_eq!(restored.rng_seed, 12345);
361 assert_eq!(restored.period_cursor, 2);
362 assert_eq!(restored.balance_state.ar_total, 50_000.0);
363 assert_eq!(restored.balance_state.retained_earnings, 100_000.0);
364 assert_eq!(
365 restored.balance_state.gl_balances.get("1100"),
366 Some(&50_000.0)
367 );
368 assert_eq!(restored.document_id_state.next_je_number, 500);
369 assert_eq!(restored.entity_counts.vendors, 42);
370 assert_eq!(restored.generation_log.len(), 1);
371 assert_eq!(restored.generation_log[0].journal_entries, 1000);
372 assert_eq!(restored.config_hash, "abc123hash");
373 }
374
375 #[test]
376 fn test_balance_state_default() {
377 let bs = BalanceState::default();
378 assert!(bs.gl_balances.is_empty());
379 assert_eq!(bs.ar_total, 0.0);
380 assert_eq!(bs.ap_total, 0.0);
381 assert_eq!(bs.fa_net_book_value, 0.0);
382 assert_eq!(bs.retained_earnings, 0.0);
383 }
384
385 #[test]
386 fn test_add_months_basic() {
387 assert_eq!(add_months(date(2024, 1, 1), 1), date(2024, 2, 1));
388 assert_eq!(add_months(date(2024, 1, 1), 12), date(2025, 1, 1));
389 assert_eq!(add_months(date(2024, 11, 1), 2), date(2025, 1, 1));
390 }
391
392 #[test]
393 fn test_add_months_day_clamping() {
394 assert_eq!(add_months(date(2024, 1, 31), 1), date(2024, 2, 29));
396 assert_eq!(add_months(date(2023, 1, 31), 1), date(2023, 2, 28));
398 }
399
400 #[test]
401 fn test_document_id_state_default() {
402 let d = DocumentIdState::default();
403 assert_eq!(d.next_po_number, 0);
404 assert_eq!(d.next_so_number, 0);
405 assert_eq!(d.next_je_number, 0);
406 assert_eq!(d.next_invoice_number, 0);
407 assert_eq!(d.next_payment_number, 0);
408 assert_eq!(d.next_gr_number, 0);
409 }
410
411 #[test]
412 fn test_entity_counts_default() {
413 let e = EntityCounts::default();
414 assert_eq!(e.vendors, 0);
415 assert_eq!(e.customers, 0);
416 assert_eq!(e.employees, 0);
417 assert_eq!(e.materials, 0);
418 assert_eq!(e.fixed_assets, 0);
419 }
420}