use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GenerationPeriod {
pub index: usize,
pub label: String,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub months: u32,
}
impl GenerationPeriod {
pub fn compute_periods(
start_date: NaiveDate,
total_months: u32,
fiscal_year_months: u32,
) -> Vec<GenerationPeriod> {
assert!(fiscal_year_months > 0, "fiscal_year_months must be > 0");
assert!(total_months > 0, "total_months must be > 0");
let mut periods = Vec::new();
let mut remaining = total_months;
let mut cursor = start_date;
let mut index: usize = 0;
while remaining > 0 {
let months = remaining.min(fiscal_year_months);
let end = add_months(cursor, months)
.pred_opt()
.expect("valid predecessor date");
let label = format!("FY{}", cursor.year());
periods.push(GenerationPeriod {
index,
label,
start_date: cursor,
end_date: end,
months,
});
cursor = add_months(cursor, months);
remaining -= months;
index += 1;
}
periods
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionState {
pub rng_seed: u64,
pub period_cursor: usize,
pub balance_state: BalanceState,
pub document_id_state: DocumentIdState,
pub entity_counts: EntityCounts,
pub generation_log: Vec<PeriodLog>,
pub config_hash: String,
}
impl SessionState {
pub fn new(rng_seed: u64, config_hash: String) -> Self {
Self {
rng_seed,
period_cursor: 0,
balance_state: BalanceState::default(),
document_id_state: DocumentIdState::default(),
entity_counts: EntityCounts::default(),
generation_log: Vec::new(),
config_hash,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BalanceState {
pub gl_balances: HashMap<String, f64>,
pub ar_total: f64,
pub ap_total: f64,
pub fa_net_book_value: f64,
pub retained_earnings: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DocumentIdState {
pub next_po_number: u64,
pub next_so_number: u64,
pub next_je_number: u64,
pub next_invoice_number: u64,
pub next_payment_number: u64,
pub next_gr_number: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EntityCounts {
pub vendors: usize,
pub customers: usize,
pub employees: usize,
pub materials: usize,
pub fixed_assets: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodLog {
pub period_label: String,
pub journal_entries: usize,
pub documents: usize,
pub anomalies: usize,
pub duration_secs: f64,
}
pub fn advance_seed(seed: u64, period_index: usize) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut hasher);
period_index.hash(&mut hasher);
hasher.finish()
}
pub fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
let total_months = date.year() as i64 * 12 + (date.month() as i64 - 1) + months as i64;
let target_year = (total_months / 12) as i32;
let target_month = (total_months % 12) as u32 + 1;
let max_day = last_day_of_month(target_year, target_month);
let day = date.day().min(max_day);
NaiveDate::from_ymd_opt(target_year, target_month, day).expect("valid date after add_months")
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
if month == 12 {
31
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1)
.expect("valid next-month date")
.pred_opt()
.expect("valid predecessor")
.day()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::approx_constant)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn test_compute_periods_single_year() {
let periods = GenerationPeriod::compute_periods(date(2024, 1, 1), 12, 12);
assert_eq!(periods.len(), 1);
assert_eq!(periods[0].index, 0);
assert_eq!(periods[0].label, "FY2024");
assert_eq!(periods[0].start_date, date(2024, 1, 1));
assert_eq!(periods[0].end_date, date(2024, 12, 31));
assert_eq!(periods[0].months, 12);
}
#[test]
fn test_compute_periods_three_years() {
let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 36, 12);
assert_eq!(periods.len(), 3);
assert_eq!(periods[0].label, "FY2022");
assert_eq!(periods[0].start_date, date(2022, 1, 1));
assert_eq!(periods[0].end_date, date(2022, 12, 31));
assert_eq!(periods[0].months, 12);
assert_eq!(periods[1].label, "FY2023");
assert_eq!(periods[1].start_date, date(2023, 1, 1));
assert_eq!(periods[1].end_date, date(2023, 12, 31));
assert_eq!(periods[1].months, 12);
assert_eq!(periods[2].label, "FY2024");
assert_eq!(periods[2].start_date, date(2024, 1, 1));
assert_eq!(periods[2].end_date, date(2024, 12, 31));
assert_eq!(periods[2].months, 12);
}
#[test]
fn test_compute_periods_partial() {
let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 18, 12);
assert_eq!(periods.len(), 2);
assert_eq!(periods[0].label, "FY2022");
assert_eq!(periods[0].months, 12);
assert_eq!(periods[0].end_date, date(2022, 12, 31));
assert_eq!(periods[1].label, "FY2023");
assert_eq!(periods[1].months, 6);
assert_eq!(periods[1].start_date, date(2023, 1, 1));
assert_eq!(periods[1].end_date, date(2023, 6, 30));
}
#[test]
fn test_advance_seed_deterministic() {
let a = advance_seed(42, 0);
let b = advance_seed(42, 0);
assert_eq!(a, b, "same inputs must produce same seed");
}
#[test]
fn test_advance_seed_differs_by_index() {
let a = advance_seed(42, 0);
let b = advance_seed(42, 1);
assert_ne!(a, b, "different indices must produce different seeds");
}
#[test]
fn test_session_state_serde_roundtrip() {
let mut state = SessionState::new(12345, "abc123hash".to_string());
state.period_cursor = 2;
state.balance_state.ar_total = 50_000.0;
state.balance_state.retained_earnings = 100_000.0;
state
.balance_state
.gl_balances
.insert("1100".to_string(), 50_000.0);
state.document_id_state.next_je_number = 500;
state.entity_counts.vendors = 42;
state.generation_log.push(PeriodLog {
period_label: "FY2024".to_string(),
journal_entries: 1000,
documents: 2500,
anomalies: 25,
duration_secs: 3.14,
});
let json = serde_json::to_string(&state).expect("serialize");
let restored: SessionState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.rng_seed, 12345);
assert_eq!(restored.period_cursor, 2);
assert_eq!(restored.balance_state.ar_total, 50_000.0);
assert_eq!(restored.balance_state.retained_earnings, 100_000.0);
assert_eq!(
restored.balance_state.gl_balances.get("1100"),
Some(&50_000.0)
);
assert_eq!(restored.document_id_state.next_je_number, 500);
assert_eq!(restored.entity_counts.vendors, 42);
assert_eq!(restored.generation_log.len(), 1);
assert_eq!(restored.generation_log[0].journal_entries, 1000);
assert_eq!(restored.config_hash, "abc123hash");
}
#[test]
fn test_balance_state_default() {
let bs = BalanceState::default();
assert!(bs.gl_balances.is_empty());
assert_eq!(bs.ar_total, 0.0);
assert_eq!(bs.ap_total, 0.0);
assert_eq!(bs.fa_net_book_value, 0.0);
assert_eq!(bs.retained_earnings, 0.0);
}
#[test]
fn test_add_months_basic() {
assert_eq!(add_months(date(2024, 1, 1), 1), date(2024, 2, 1));
assert_eq!(add_months(date(2024, 1, 1), 12), date(2025, 1, 1));
assert_eq!(add_months(date(2024, 11, 1), 2), date(2025, 1, 1));
}
#[test]
fn test_add_months_day_clamping() {
assert_eq!(add_months(date(2024, 1, 31), 1), date(2024, 2, 29));
assert_eq!(add_months(date(2023, 1, 31), 1), date(2023, 2, 28));
}
#[test]
fn test_document_id_state_default() {
let d = DocumentIdState::default();
assert_eq!(d.next_po_number, 0);
assert_eq!(d.next_so_number, 0);
assert_eq!(d.next_je_number, 0);
assert_eq!(d.next_invoice_number, 0);
assert_eq!(d.next_payment_number, 0);
assert_eq!(d.next_gr_number, 0);
}
#[test]
fn test_entity_counts_default() {
let e = EntityCounts::default();
assert_eq!(e.vendors, 0);
assert_eq!(e.customers, 0);
assert_eq!(e.employees, 0);
assert_eq!(e.materials, 0);
assert_eq!(e.fixed_assets, 0);
}
}