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)]
267mod tests {
268 use super::*;
269 use chrono::NaiveDate;
270
271 fn date(y: i32, m: u32, d: u32) -> NaiveDate {
272 NaiveDate::from_ymd_opt(y, m, d).unwrap()
273 }
274
275 #[test]
276 fn test_compute_periods_single_year() {
277 let periods = GenerationPeriod::compute_periods(date(2024, 1, 1), 12, 12);
278 assert_eq!(periods.len(), 1);
279 assert_eq!(periods[0].index, 0);
280 assert_eq!(periods[0].label, "FY2024");
281 assert_eq!(periods[0].start_date, date(2024, 1, 1));
282 assert_eq!(periods[0].end_date, date(2024, 12, 31));
283 assert_eq!(periods[0].months, 12);
284 }
285
286 #[test]
287 fn test_compute_periods_three_years() {
288 let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 36, 12);
289 assert_eq!(periods.len(), 3);
290
291 assert_eq!(periods[0].label, "FY2022");
292 assert_eq!(periods[0].start_date, date(2022, 1, 1));
293 assert_eq!(periods[0].end_date, date(2022, 12, 31));
294 assert_eq!(periods[0].months, 12);
295
296 assert_eq!(periods[1].label, "FY2023");
297 assert_eq!(periods[1].start_date, date(2023, 1, 1));
298 assert_eq!(periods[1].end_date, date(2023, 12, 31));
299 assert_eq!(periods[1].months, 12);
300
301 assert_eq!(periods[2].label, "FY2024");
302 assert_eq!(periods[2].start_date, date(2024, 1, 1));
303 assert_eq!(periods[2].end_date, date(2024, 12, 31));
304 assert_eq!(periods[2].months, 12);
305 }
306
307 #[test]
308 fn test_compute_periods_partial() {
309 let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 18, 12);
310 assert_eq!(periods.len(), 2);
311
312 assert_eq!(periods[0].label, "FY2022");
313 assert_eq!(periods[0].months, 12);
314 assert_eq!(periods[0].end_date, date(2022, 12, 31));
315
316 assert_eq!(periods[1].label, "FY2023");
317 assert_eq!(periods[1].months, 6);
318 assert_eq!(periods[1].start_date, date(2023, 1, 1));
319 assert_eq!(periods[1].end_date, date(2023, 6, 30));
320 }
321
322 #[test]
323 fn test_advance_seed_deterministic() {
324 let a = advance_seed(42, 0);
325 let b = advance_seed(42, 0);
326 assert_eq!(a, b, "same inputs must produce same seed");
327 }
328
329 #[test]
330 fn test_advance_seed_differs_by_index() {
331 let a = advance_seed(42, 0);
332 let b = advance_seed(42, 1);
333 assert_ne!(a, b, "different indices must produce different seeds");
334 }
335
336 #[test]
337 fn test_session_state_serde_roundtrip() {
338 let mut state = SessionState::new(12345, "abc123hash".to_string());
339 state.period_cursor = 2;
340 state.balance_state.ar_total = 50_000.0;
341 state.balance_state.retained_earnings = 100_000.0;
342 state
343 .balance_state
344 .gl_balances
345 .insert("1100".to_string(), 50_000.0);
346 state.document_id_state.next_je_number = 500;
347 state.entity_counts.vendors = 42;
348 state.generation_log.push(PeriodLog {
349 period_label: "FY2024".to_string(),
350 journal_entries: 1000,
351 documents: 2500,
352 anomalies: 25,
353 duration_secs: 3.14,
354 });
355
356 let json = serde_json::to_string(&state).expect("serialize");
357 let restored: SessionState = serde_json::from_str(&json).expect("deserialize");
358
359 assert_eq!(restored.rng_seed, 12345);
360 assert_eq!(restored.period_cursor, 2);
361 assert_eq!(restored.balance_state.ar_total, 50_000.0);
362 assert_eq!(restored.balance_state.retained_earnings, 100_000.0);
363 assert_eq!(
364 restored.balance_state.gl_balances.get("1100"),
365 Some(&50_000.0)
366 );
367 assert_eq!(restored.document_id_state.next_je_number, 500);
368 assert_eq!(restored.entity_counts.vendors, 42);
369 assert_eq!(restored.generation_log.len(), 1);
370 assert_eq!(restored.generation_log[0].journal_entries, 1000);
371 assert_eq!(restored.config_hash, "abc123hash");
372 }
373
374 #[test]
375 fn test_balance_state_default() {
376 let bs = BalanceState::default();
377 assert!(bs.gl_balances.is_empty());
378 assert_eq!(bs.ar_total, 0.0);
379 assert_eq!(bs.ap_total, 0.0);
380 assert_eq!(bs.fa_net_book_value, 0.0);
381 assert_eq!(bs.retained_earnings, 0.0);
382 }
383
384 #[test]
385 fn test_add_months_basic() {
386 assert_eq!(add_months(date(2024, 1, 1), 1), date(2024, 2, 1));
387 assert_eq!(add_months(date(2024, 1, 1), 12), date(2025, 1, 1));
388 assert_eq!(add_months(date(2024, 11, 1), 2), date(2025, 1, 1));
389 }
390
391 #[test]
392 fn test_add_months_day_clamping() {
393 assert_eq!(add_months(date(2024, 1, 31), 1), date(2024, 2, 29));
395 assert_eq!(add_months(date(2023, 1, 31), 1), date(2023, 2, 28));
397 }
398
399 #[test]
400 fn test_document_id_state_default() {
401 let d = DocumentIdState::default();
402 assert_eq!(d.next_po_number, 0);
403 assert_eq!(d.next_so_number, 0);
404 assert_eq!(d.next_je_number, 0);
405 assert_eq!(d.next_invoice_number, 0);
406 assert_eq!(d.next_payment_number, 0);
407 assert_eq!(d.next_gr_number, 0);
408 }
409
410 #[test]
411 fn test_entity_counts_default() {
412 let e = EntityCounts::default();
413 assert_eq!(e.vendors, 0);
414 assert_eq!(e.customers, 0);
415 assert_eq!(e.employees, 0);
416 assert_eq!(e.materials, 0);
417 assert_eq!(e.fixed_assets, 0);
418 }
419}