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:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
@@ -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
}