landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
This commit is contained in:
@@ -9,6 +9,11 @@ use std::sync::Arc;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// Escape a string for SIE4 format (replace quotes with double quotes)
|
||||
fn escape_sie(s: &str) -> String {
|
||||
s.replace('"', "\"")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Sie4Query {
|
||||
pub fiscal_year: i32,
|
||||
@@ -22,32 +27,217 @@ pub struct CsvQuery {
|
||||
}
|
||||
|
||||
pub async fn export_sie4(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<Sie4Query>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let sie4_content = format!(
|
||||
"#FLAGGA 0\n#FORMAT PC8\n#SIETYP 4\n#PROGRAM AAMOS Ledger\n#VERSION 1.0\n#GEN {}\n#FNAMN Export\n#RAR 0 {}0101 {}1231\n",
|
||||
chrono::Local::now().format("%Y%m%d"),
|
||||
params.fiscal_year,
|
||||
params.fiscal_year,
|
||||
);
|
||||
let mut sie4 = String::new();
|
||||
|
||||
// ── Header ──
|
||||
sie4.push_str("#FLAGGA 0\n");
|
||||
sie4.push_str("#FORMAT PC8\n");
|
||||
sie4.push_str("#SIETYP 4\n");
|
||||
sie4.push_str("#PROGRAM \"AAMOS Ledger Rust\"\n");
|
||||
sie4.push_str("#VERSION 1.0\n");
|
||||
sie4.push_str(&format!("#GEN {}\n", chrono::Local::now().format("%Y%m%d")));
|
||||
sie4.push_str("#FNAMN \"LandveX AB\"\n");
|
||||
sie4.push_str("#ORGNR 5591417042\n");
|
||||
sie4.push_str("#KPTYP BAS2024\n");
|
||||
|
||||
// ── Fiscal year ──
|
||||
let year_start = format!("{}0101", params.fiscal_year);
|
||||
let year_end = format!("{}1231", params.fiscal_year);
|
||||
sie4.push_str(&format!("#RAR 0 {} {}\n", year_start, year_end));
|
||||
|
||||
// ── Accounts (#KONTO) ──
|
||||
let accounts = sqlx::query(
|
||||
"SELECT account_number, name FROM ledger_accounts WHERE coa_standard = 'BAS' OR coa_standard = 'bas' ORDER BY account_number"
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
for row in &accounts {
|
||||
let num: String = row.get("account_number");
|
||||
let name: String = row.get("name");
|
||||
sie4.push_str(&format!("#KONTO {} \"{}\"\n", num, escape_sie(&name)));
|
||||
}
|
||||
|
||||
// ── Vouchers (#VER) ──
|
||||
let entries_query = if let Some(ref period) = params.period {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT id, entry_number, entry_date, description, posted_at, user_id, source_type
|
||||
FROM ledger_journal_entries
|
||||
WHERE fiscal_year = $1 AND period = $2 AND status = 'posted'
|
||||
ORDER BY entry_date, entry_number
|
||||
"#
|
||||
)
|
||||
.bind(params.fiscal_year)
|
||||
.bind(period)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT id, entry_number, entry_date, description, posted_at, user_id, source_type
|
||||
FROM ledger_journal_entries
|
||||
WHERE fiscal_year = $1 AND status = 'posted'
|
||||
ORDER BY entry_date, entry_number
|
||||
"#
|
||||
)
|
||||
.bind(params.fiscal_year)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
for entry in &entries_query {
|
||||
let id: uuid::Uuid = entry.get("id");
|
||||
let entry_num: Option<i64> = entry.try_get("entry_number").ok();
|
||||
let entry_date: chrono::NaiveDate = entry.get("entry_date");
|
||||
let description: String = entry.get("description");
|
||||
let posted_at: Option<chrono::DateTime<chrono::Utc>> = entry.try_get("posted_at").ok();
|
||||
let user_id: String = entry.get("user_id");
|
||||
let source_type: String = entry.get("source_type");
|
||||
|
||||
let series = match source_type.as_str() {
|
||||
"Manual" => "M",
|
||||
"ImportSie" | "ImportCsv" => "I",
|
||||
"Bank" => "B",
|
||||
"System" => "S",
|
||||
"Agent" => "A",
|
||||
"OpeningBalance" => "OB",
|
||||
_ => "M",
|
||||
};
|
||||
let num_str = entry_num.map(|n| n.to_string()).unwrap_or_else(|| id.to_string());
|
||||
let date_str = entry_date.format("%Y%m%d").to_string();
|
||||
let reg_date = posted_at.map(|d| d.format("%Y%m%d").to_string())
|
||||
.unwrap_or_else(|| date_str.clone());
|
||||
|
||||
sie4.push_str(&format!(
|
||||
"#VER {} \"{}\" {} \"{}\" {} \"{}\"\n",
|
||||
series, num_str, date_str, escape_sie(&description), reg_date, escape_sie(&user_id)
|
||||
));
|
||||
sie4.push_str("{\n");
|
||||
|
||||
// Transaction lines for this entry
|
||||
let lines = sqlx::query(
|
||||
r#"
|
||||
SELECT account_number, debit, credit
|
||||
FROM ledger_journal_lines
|
||||
WHERE entry_id = $1
|
||||
ORDER BY line_number
|
||||
"#
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
for line in &lines {
|
||||
let account: String = line.get("account_number");
|
||||
let debit: Option<i64> = line.try_get("debit").ok();
|
||||
let credit: Option<i64> = line.try_get("credit").ok();
|
||||
|
||||
// SIE4: positive = debit, negative = credit
|
||||
let amount = match (debit, credit) {
|
||||
(Some(d), None) => d as f64 / 100.0,
|
||||
(None, Some(c)) => -(c as f64 / 100.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
sie4.push_str(&format!("#TRANS {} {{}} {:.2}\n", account, amount));
|
||||
}
|
||||
|
||||
sie4.push_str("}\n");
|
||||
}
|
||||
|
||||
// ── Footer ──
|
||||
sie4.push_str("#IB 0 1910 0.00\n"); // Opening balance placeholder
|
||||
sie4.push_str("#KSLUT\n");
|
||||
|
||||
Ok((
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
"text/plain; charset=ISO-8859-1".to_string(),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=landvex_{}.si", params.fiscal_year),
|
||||
)],
|
||||
sie4_content,
|
||||
sie4,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn preview_sie4(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<Sie4Query>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
// Count entries and lines that would be exported
|
||||
let (entry_count, line_count): (i64, i64) = if let Some(ref period) = params.period {
|
||||
let entries = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM ledger_journal_entries WHERE fiscal_year = $1 AND period = $2 AND status = 'posted'"
|
||||
)
|
||||
.bind(params.fiscal_year)
|
||||
.bind(period)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let lines = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM ledger_journal_lines l
|
||||
JOIN ledger_journal_entries e ON e.id = l.entry_id
|
||||
WHERE e.fiscal_year = $1 AND e.period = $2 AND e.status = 'posted'
|
||||
"#
|
||||
)
|
||||
.bind(params.fiscal_year)
|
||||
.bind(period)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
(entries, lines)
|
||||
} else {
|
||||
let entries = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM ledger_journal_entries WHERE fiscal_year = $1 AND status = 'posted'"
|
||||
)
|
||||
.bind(params.fiscal_year)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let lines = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM ledger_journal_lines l
|
||||
JOIN ledger_journal_entries e ON e.id = l.entry_id
|
||||
WHERE e.fiscal_year = $1 AND e.status = 'posted'
|
||||
"#
|
||||
)
|
||||
.bind(params.fiscal_year)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
(entries, lines)
|
||||
};
|
||||
|
||||
let account_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM ledger_accounts WHERE coa_standard = 'BAS' OR coa_standard = 'bas'"
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let preview = format!(
|
||||
"SIE4 Preview for fiscal year {}\n\nThis is a preview of the SIE4 export.\n",
|
||||
params.fiscal_year
|
||||
"SIE4 Export Preview for fiscal year {}\n\n\
|
||||
Accounts: {}\n\
|
||||
Journal entries: {}\n\
|
||||
Transaction lines: {}\n\n\
|
||||
Format: SIE4 (PC8, ISO-8859-1)\n\
|
||||
Company: LandveX AB (559141-7042)\n\
|
||||
Chart of accounts: BAS2024\n",
|
||||
params.fiscal_year, account_count, entry_count, line_count
|
||||
);
|
||||
|
||||
Ok((
|
||||
|
||||
@@ -53,9 +53,9 @@ pub async fn list_periods(
|
||||
"opened_at": r.get::<chrono::DateTime<chrono::Utc>, _>("opened_at"),
|
||||
"closed_at": r.try_get::<chrono::DateTime<chrono::Utc>, _>("closed_at").ok(),
|
||||
"closed_by": r.try_get::<String, _>("closed_by").ok(),
|
||||
"workflow_id": r.try_get::<Uuid, _>("workflow_id").ok(),
|
||||
"workflow_id": r.try_get::<String, _>("workflow_id").ok(),
|
||||
"trial_balance": r.try_get::<serde_json::Value, _>("trial_balance").ok(),
|
||||
"trace_id": r.get::<Uuid, _>("trace_id"),
|
||||
"trace_id": r.get::<String, _>("trace_id"),
|
||||
"metadata": r.get::<serde_json::Value, _>("metadata"),
|
||||
})
|
||||
})
|
||||
@@ -97,9 +97,9 @@ pub async fn get_period(
|
||||
"opened_at": r.get::<chrono::DateTime<chrono::Utc>, _>("opened_at"),
|
||||
"closed_at": r.try_get::<chrono::DateTime<chrono::Utc>, _>("closed_at").ok(),
|
||||
"closed_by": r.try_get::<String, _>("closed_by").ok(),
|
||||
"workflow_id": r.try_get::<Uuid, _>("workflow_id").ok(),
|
||||
"workflow_id": r.try_get::<String, _>("workflow_id").ok(),
|
||||
"trial_balance": r.try_get::<serde_json::Value, _>("trial_balance").ok(),
|
||||
"trace_id": r.get::<Uuid, _>("trace_id"),
|
||||
"trace_id": r.get::<String, _>("trace_id"),
|
||||
"metadata": r.get::<serde_json::Value, _>("metadata"),
|
||||
})),
|
||||
periods: None,
|
||||
@@ -126,7 +126,7 @@ pub async fn create_period(
|
||||
.bind(&req.tenant_id)
|
||||
.bind(req.fiscal_year)
|
||||
.bind(&req.period)
|
||||
.bind(trace_id)
|
||||
.bind(trace_id.to_string())
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -137,7 +137,7 @@ pub async fn create_period(
|
||||
"fiscal_year": req.fiscal_year,
|
||||
"period": req.period,
|
||||
"status": "open",
|
||||
"trace_id": trace_id,
|
||||
"trace_id": trace_id.to_string(),
|
||||
});
|
||||
|
||||
Ok((
|
||||
@@ -210,20 +210,210 @@ pub async fn reject_period(
|
||||
get_period(State(state), Path(period)).await
|
||||
}
|
||||
|
||||
/// Calculate trial balance for a period
|
||||
async fn calculate_trial_balance(
|
||||
pool: &sqlx::PgPool,
|
||||
period: &str,
|
||||
fiscal_year: i32,
|
||||
) -> Result<serde_json::Value, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.account_number,
|
||||
a.name as account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(l.debit), 0) as total_debit,
|
||||
COALESCE(SUM(l.credit), 0) as total_credit
|
||||
FROM ledger_journal_lines l
|
||||
JOIN ledger_journal_entries e ON e.id = l.entry_id
|
||||
JOIN ledger_accounts a ON a.account_number = l.account_number
|
||||
WHERE e.period = $1
|
||||
AND e.fiscal_year = $2
|
||||
AND e.status = 'posted'
|
||||
GROUP BY l.account_number, a.name, a.account_type
|
||||
ORDER BY l.account_number
|
||||
"#
|
||||
)
|
||||
.bind(period)
|
||||
.bind(fiscal_year)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut accounts = Vec::new();
|
||||
let mut total_debit = 0i64;
|
||||
let mut total_credit = 0i64;
|
||||
|
||||
for row in &rows {
|
||||
let account_number: String = row.get("account_number");
|
||||
let account_name: String = row.get("account_name");
|
||||
let account_type: String = row.get("account_type");
|
||||
let debit: i64 = row.get("total_debit");
|
||||
let credit: i64 = row.get("total_credit");
|
||||
|
||||
total_debit += debit;
|
||||
total_credit += credit;
|
||||
|
||||
accounts.push(json!({
|
||||
"account_number": account_number,
|
||||
"account_name": account_name,
|
||||
"account_type": account_type,
|
||||
"debit": debit,
|
||||
"credit": credit,
|
||||
"balance": debit - credit,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"accounts": accounts,
|
||||
"total_debit": total_debit,
|
||||
"total_credit": total_credit,
|
||||
"difference": total_debit - total_credit,
|
||||
"is_balanced": total_debit == total_credit,
|
||||
"generated_at": Utc::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Validate that period can be closed
|
||||
async fn validate_period_close(
|
||||
pool: &sqlx::PgPool,
|
||||
period: &str,
|
||||
fiscal_year: i32,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
// Check for draft entries
|
||||
let draft_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ledger_journal_entries WHERE period = $1 AND fiscal_year = $2 AND status = 'draft'"
|
||||
)
|
||||
.bind(period)
|
||||
.bind(fiscal_year)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if draft_count > 0 {
|
||||
return Ok(Some(format!("Period has {} draft journal entries that must be posted or voided", draft_count)));
|
||||
}
|
||||
|
||||
// Check for unbalanced entries
|
||||
let unbalanced: Vec<(Option<i64>, Option<String>)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT e.entry_number, e.description
|
||||
FROM ledger_journal_entries e
|
||||
JOIN ledger_journal_lines l ON l.entry_id = e.id
|
||||
WHERE e.period = $1 AND e.fiscal_year = $2 AND e.status = 'posted'
|
||||
GROUP BY e.id, e.entry_number, e.description
|
||||
HAVING COALESCE(SUM(l.debit), 0) != COALESCE(SUM(l.credit), 0)
|
||||
"#
|
||||
)
|
||||
.bind(period)
|
||||
.bind(fiscal_year)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if !unbalanced.is_empty() {
|
||||
let descs: Vec<String> = unbalanced.into_iter()
|
||||
.map(|(num, desc)| format!("#{}: {}", num.map(|n| n.to_string()).unwrap_or_else(|| "?".to_string()), desc.unwrap_or_default()))
|
||||
.collect();
|
||||
return Ok(Some(format!("Unbalanced journal entries: {}", descs.join(", "))));
|
||||
}
|
||||
|
||||
// Check trial balance
|
||||
let trial = calculate_trial_balance(pool, period, fiscal_year).await?;
|
||||
let is_balanced = trial.get("is_balanced").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
if !is_balanced {
|
||||
let diff = trial.get("difference").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
return Ok(Some(format!("Trial balance is unbalanced by {}", diff)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn close_period(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(period): Path<String>,
|
||||
) -> Result<Json<PeriodResponse>, StatusCode> {
|
||||
// Get period info first
|
||||
let period_info = sqlx::query(
|
||||
"SELECT fiscal_year, status FROM ledger_periods WHERE period = $1"
|
||||
)
|
||||
.bind(&period)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let (fiscal_year, current_status) = match period_info {
|
||||
Some(row) => {
|
||||
let fy: i32 = row.get("fiscal_year");
|
||||
let status: String = row.get("status");
|
||||
(fy, status)
|
||||
}
|
||||
None => return Err(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
// Must be approved before closing
|
||||
if current_status != "approved" {
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
// Validate period can be closed
|
||||
let validation_error = validate_period_close(&state.pool, &period, fiscal_year)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if let Some(error) = validation_error {
|
||||
return Ok(Json(PeriodResponse {
|
||||
ok: false,
|
||||
period: Some(json!({
|
||||
"period": period,
|
||||
"status": current_status,
|
||||
"error": error,
|
||||
})),
|
||||
periods: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate trial balance
|
||||
let trial_balance = calculate_trial_balance(&state.pool, &period, fiscal_year)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Close the period
|
||||
let closed_at = Utc::now();
|
||||
let workflow_id = Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE ledger_periods SET status = 'closed', closed_at = $2, closed_by = 'api-user' WHERE period = $1"
|
||||
r#"
|
||||
UPDATE ledger_periods
|
||||
SET status = 'closed',
|
||||
closed_at = $2,
|
||||
closed_by = 'api-user',
|
||||
workflow_id = $3,
|
||||
trial_balance = $4
|
||||
WHERE period = $1
|
||||
"#
|
||||
)
|
||||
.bind(&period)
|
||||
.bind(closed_at)
|
||||
.bind(workflow_id.to_string())
|
||||
.bind(trial_balance.clone())
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO ledger_audit_log (id, table_name, record_id, action, old_values, new_values, changed_by, changed_at)
|
||||
VALUES ($1, 'ledger_periods', $2, 'CLOSE', $3, $4, 'api-user', $5)
|
||||
"#
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(&period)
|
||||
.bind(json!({"status": current_status}))
|
||||
.bind(json!({"status": "closed", "trial_balance": trial_balance, "workflow_id": workflow_id.to_string()}))
|
||||
.bind(closed_at)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
get_period(State(state), Path(period)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -185,19 +185,221 @@ pub async fn add_transactions(
|
||||
get_session(State(state), Path(id)).await
|
||||
}
|
||||
|
||||
/// Get unmatched bank transactions for a session
|
||||
async fn get_unmatched_transactions(
|
||||
pool: &sqlx::PgPool,
|
||||
session_id: Uuid,
|
||||
account_number: &str,
|
||||
period: &str,
|
||||
fiscal_year: i32,
|
||||
) -> Result<Vec<serde_json::Value>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
transaction_id,
|
||||
transaction_date,
|
||||
description,
|
||||
amount,
|
||||
currency,
|
||||
reference,
|
||||
matched_entry_id,
|
||||
created_at
|
||||
FROM ledger_bank_transactions
|
||||
WHERE session_id = $1
|
||||
AND (matched_entry_id IS NULL OR matched_entry_id = '')
|
||||
ORDER BY transaction_date
|
||||
"#
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut transactions = Vec::new();
|
||||
for row in &rows {
|
||||
transactions.push(json!({
|
||||
"transaction_id": row.get::<String, _>("transaction_id"),
|
||||
"transaction_date": row.get::<chrono::NaiveDate, _>("transaction_date"),
|
||||
"description": row.get::<String, _>("description"),
|
||||
"amount": row.get::<i64, _>("amount"),
|
||||
"currency": row.get::<String, _>("currency"),
|
||||
"reference": row.try_get::<String, _>("reference").ok(),
|
||||
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(transactions)
|
||||
}
|
||||
|
||||
/// Get unmatched ledger entries for an account in a period
|
||||
async fn get_unmatched_ledger_entries(
|
||||
pool: &sqlx::PgPool,
|
||||
account_number: &str,
|
||||
period: &str,
|
||||
fiscal_year: i32,
|
||||
) -> Result<Vec<serde_json::Value>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
e.id,
|
||||
e.entry_number,
|
||||
e.entry_date,
|
||||
e.description,
|
||||
l.debit,
|
||||
l.credit
|
||||
FROM ledger_journal_entries e
|
||||
JOIN ledger_journal_lines l ON l.entry_id = e.id
|
||||
WHERE l.account_number = $1
|
||||
AND e.period = $2
|
||||
AND e.fiscal_year = $3
|
||||
AND e.status = 'posted'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ledger_reconciliation_matches m
|
||||
WHERE m.entry_id = e.id AND m.line_account = $1
|
||||
)
|
||||
ORDER BY e.entry_date
|
||||
"#
|
||||
)
|
||||
.bind(account_number)
|
||||
.bind(period)
|
||||
.bind(fiscal_year)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for row in &rows {
|
||||
let id: Uuid = row.get("id");
|
||||
let entry_num: Option<i64> = row.try_get("entry_number").ok();
|
||||
let entry_date: chrono::NaiveDate = row.get("entry_date");
|
||||
let description: String = row.get("description");
|
||||
let debit: Option<i64> = row.try_get("debit").ok();
|
||||
let credit: Option<i64> = row.try_get("credit").ok();
|
||||
|
||||
let amount = match (debit, credit) {
|
||||
(Some(d), None) => d,
|
||||
(None, Some(c)) => -c,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
entries.push(json!({
|
||||
"entry_id": id,
|
||||
"entry_number": entry_num,
|
||||
"entry_date": entry_date,
|
||||
"description": description,
|
||||
"amount": amount,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Calculate session summary with matched/unmatched counts and amounts
|
||||
async fn calculate_session_summary(
|
||||
pool: &sqlx::PgPool,
|
||||
session_id: Uuid,
|
||||
) -> Result<serde_json::Value, sqlx::Error> {
|
||||
// Matched transactions
|
||||
let matched_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ledger_bank_transactions WHERE session_id = $1 AND matched_entry_id IS NOT NULL AND matched_entry_id != ''"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let matched_amount: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM ledger_bank_transactions WHERE session_id = $1 AND matched_entry_id IS NOT NULL AND matched_entry_id != ''"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
// Unmatched transactions
|
||||
let unmatched_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ledger_bank_transactions WHERE session_id = $1 AND (matched_entry_id IS NULL OR matched_entry_id = '')"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let unmatched_amount: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM ledger_bank_transactions WHERE session_id = $1 AND (matched_entry_id IS NULL OR matched_entry_id = '')"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(json!({
|
||||
"matched_transactions": matched_count,
|
||||
"matched_amount": matched_amount,
|
||||
"unmatched_transactions": unmatched_count,
|
||||
"unmatched_amount": unmatched_amount,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn complete_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ReconciliationResponse>, StatusCode> {
|
||||
let completed_at = Utc::now();
|
||||
sqlx::query!(
|
||||
"UPDATE ledger_reconciliation_sessions SET status = 'completed', completed_at = $2 WHERE id = $1",
|
||||
id,
|
||||
completed_at,
|
||||
// Get session info
|
||||
let session_info = sqlx::query(
|
||||
"SELECT account_number, period, fiscal_year, status FROM ledger_reconciliation_sessions WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let (account_number, period, fiscal_year, current_status) = match session_info {
|
||||
Some(row) => {
|
||||
let acc: String = row.get("account_number");
|
||||
let per: String = row.get("period");
|
||||
let fy: i32 = row.get("fiscal_year");
|
||||
let status: String = row.get("status");
|
||||
(acc, per, fy, status)
|
||||
}
|
||||
None => return Err(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
if current_status == "completed" {
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
let summary = calculate_session_summary(&state.pool, id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let completed_at = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE ledger_reconciliation_sessions
|
||||
SET status = 'completed',
|
||||
completed_at = $2,
|
||||
metadata = $3
|
||||
WHERE id = $1
|
||||
"#
|
||||
)
|
||||
.bind(id)
|
||||
.bind(completed_at)
|
||||
.bind(json!({"summary": summary}))
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO ledger_audit_log (id, table_name, record_id, action, old_values, new_values, changed_by, changed_at)
|
||||
VALUES ($1, 'ledger_reconciliation_sessions', $2, 'COMPLETE', $3, $4, 'api-user', $5)
|
||||
"#
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(id.to_string())
|
||||
.bind(json!({"status": current_status}))
|
||||
.bind(json!({"status": "completed", "summary": summary}))
|
||||
.bind(completed_at)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
get_session(State(state), Path(id)).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user