use std::collections::HashMap;
use chrono::{DateTime, Utc};
use rusqlite::Transaction;
use serde_json::json;
use super::BasicCpmPlanner;
use crate::audit::AuditEvent;
use crate::earned_value::{
BaselineOutcome, BaselineRequest, EarningRule, EvReport, EvSummary, MAX_BASELINE_REASON_CHARS,
SNAPSHOT_EXPORT_LIMIT, SnapshotFormat, SnapshotOutcome, SnapshotRequest, SnapshotSummary,
build_baseline, compute_ev, render_snapshots_markdown, trend_alerts,
};
use crate::ev_store;
use crate::locks::PlanState;
use crate::plan::{PlanGraph, PlanId, PlannerError};
use crate::plan_store::{backend, load_plan_state};
fn load_state(tx: &Transaction<'_>, plan_id: &PlanId) -> Result<PlanState, PlannerError> {
load_plan_state(tx, plan_id)?.ok_or_else(|| PlannerError::PlanNotFound {
plan_id: plan_id.0.clone(),
})
}
fn not_baselined(plan_id: &PlanId) -> PlannerError {
PlannerError::NotBaselined {
plan_id: plan_id.0.clone(),
}
}
fn decode(
plan_id: &PlanId,
rows: Vec<ev_store::EvSnapshot>,
) -> Result<Vec<SnapshotSummary>, PlannerError> {
rows.into_iter()
.map(|s| {
serde_json::from_value(s.summary).map_err(|e| {
backend(anyhow::anyhow!(
"plan {plan_id}: stored snapshot taken at {} does not decode: {e}",
s.taken_at
))
})
})
.collect()
}
fn history(tx: &Transaction<'_>, plan_id: &PlanId) -> Result<Vec<SnapshotSummary>, PlannerError> {
decode(plan_id, ev_store::snapshots(tx, plan_id)?)
}
fn alert_ratios(
tx: &Transaction<'_>,
plan_id: &PlanId,
number: u32,
position: Option<(DateTime<Utc>, DateTime<Utc>)>,
limit: usize,
) -> Result<Vec<EvSummary>, PlannerError> {
let rows = ev_store::alert_window(tx, plan_id, number, position, limit)?;
Ok(decode(plan_id, rows)?
.iter()
.map(SnapshotSummary::ratios)
.collect())
}
fn rules_of(graph: &PlanGraph) -> HashMap<String, EarningRule> {
graph
.deliverables
.iter()
.map(|d| (d.id.clone(), d.earning_rule.unwrap_or_default()))
.collect()
}
fn normalise_reason(reason: Option<&str>) -> Result<Option<String>, PlannerError> {
let Some(reason) = reason else {
return Ok(None);
};
if reason.chars().count() > MAX_BASELINE_REASON_CHARS {
return Err(PlannerError::InvalidGraph {
reason: format!(
"baseline reason is longer than {MAX_BASELINE_REASON_CHARS} characters"
),
});
}
let trimmed = reason.trim();
Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
}
impl BasicCpmPlanner {
pub(super) async fn take_baseline(
&self,
req: BaselineRequest,
) -> Result<BaselineOutcome, PlannerError> {
let reason = normalise_reason(req.reason.as_deref())?;
let now = self.now();
let start = req.start.unwrap_or(now);
let plan_id = req.plan_id;
let (outcome, previous) = self.store.write_tx(|tx| {
crate::portfolio::ensure_executable(tx, &plan_id)?;
let state = load_state(tx, &plan_id)?;
let latest = ev_store::latest_baseline(tx, &plan_id)?;
let previous = latest.as_ref().map(|b| b.baseline.number);
let calendar = req
.calendar
.or_else(|| latest.and_then(|b| b.baseline.calendar));
let number = match previous {
None => 1,
Some(n) if reason.is_none() => {
return Err(PlannerError::InvalidGraph {
reason: format!(
"plan {plan_id} already has baseline {n}; re-baselining requires a \
non-empty reason"
),
});
}
Some(n) => n.checked_add(1).ok_or_else(|| PlannerError::InvalidGraph {
reason: format!("plan {plan_id} has used every baseline number"),
})?,
};
let baseline =
build_baseline(&state.graph, &state.cached_result, start, calendar, number)?;
ev_store::insert_baseline(tx, &plan_id, &baseline, reason.as_deref(), now)?;
let outcome = BaselineOutcome {
plan_id: plan_id.0.clone(),
baseline_number: baseline.number,
start: baseline.start,
calendar: baseline.calendar,
bac: baseline.bac,
finish_hours: baseline.finish_hours,
deliverable_count: baseline.rows.len(),
reason: reason.clone(),
};
Ok((outcome, previous))
})?;
let event = AuditEvent::new("plan.ev.baselined").with_payload(json!({
"plan_id": outcome.plan_id,
"baseline_number": outcome.baseline_number,
"previous_baseline": previous,
"start": outcome.start,
"bac": outcome.bac,
"finish_hours": outcome.finish_hours,
"deliverable_count": outcome.deliverable_count,
"reason": outcome.reason,
}));
self.flush_audit(vec![event]).await;
Ok(outcome)
}
pub(super) fn ev_report(
&self,
plan_id: &PlanId,
as_of: Option<DateTime<Utc>>,
) -> Result<EvReport, PlannerError> {
let as_of = as_of.unwrap_or_else(|| self.now());
let (graph, statuses, baseline, actuals, ratios) = self.store.read_tx(|tx| {
let state = load_state(tx, plan_id)?;
let baseline = ev_store::latest_baseline(tx, plan_id)?
.ok_or_else(|| not_baselined(plan_id))?
.baseline;
let actuals = ev_store::load_actuals(tx, plan_id)?;
let ratios = alert_ratios(tx, plan_id, baseline.number, None, 2)?;
Ok((state.graph, state.statuses, baseline, actuals, ratios))
})?;
compute_ev(
&baseline,
&graph,
&statuses,
&rules_of(&graph),
&actuals,
as_of,
&ratios,
)
}
pub(super) fn take_snapshot(
&self,
req: SnapshotRequest,
) -> Result<SnapshotOutcome, PlannerError> {
let now = self.now();
let as_of = req.as_of.unwrap_or(now);
let plan_id = &req.plan_id;
self.store.write_tx(|tx| {
crate::portfolio::ensure_executable(tx, plan_id)?;
let state = load_state(tx, plan_id)?;
let baseline = ev_store::latest_baseline(tx, plan_id)?
.ok_or_else(|| not_baselined(plan_id))?
.baseline;
let actuals = ev_store::load_actuals(tx, plan_id)?;
let taken_at = match ev_store::latest_taken_at(tx, plan_id)? {
Some(last) if now <= last => last + chrono::Duration::microseconds(1),
_ => now,
};
let mut ratios =
alert_ratios(tx, plan_id, baseline.number, Some((as_of, taken_at)), 1)?;
let report = compute_ev(
&baseline,
&state.graph,
&state.statuses,
&rules_of(&state.graph),
&actuals,
as_of,
&ratios,
)?;
let mut summary = SnapshotSummary::from_report(&report, taken_at);
ratios.push(summary.ratios());
summary.alerts = trend_alerts(&ratios, baseline.number);
let stored = serde_json::to_value(&summary).map_err(backend)?;
ev_store::insert_snapshot(tx, plan_id, taken_at, as_of, &stored)?;
let history = history(tx, plan_id)?;
let newest = &history[history.len().saturating_sub(SNAPSHOT_EXPORT_LIMIT)..];
let export = match req.format {
SnapshotFormat::Json => serde_json::to_value(newest).map_err(backend)?,
SnapshotFormat::Markdown => json!(render_snapshots_markdown(newest)),
};
Ok(SnapshotOutcome {
summary,
format: req.format,
export,
snapshot_count: ev_store::snapshot_count(tx, plan_id)?,
})
})
}
}