feat(veri): CSV dışa ve içe aktarma (harcamalar, plan kalemleri, varlık işlemleri)
- Excel uyumlu CSV indirme - İçe aktarma: önce deneme raporu (geçerli / zaten var / satır satır hata), onayla geçerli satırlar - Sütunlar adla, esnek tutar/tarih yazımı, ; ve , ayırıcı, Windows-1254 desteği - Varlıkta tarih sırasına göre eldekinden fazla satış denetimi - Ayarlar sayfasında Veri bölümü Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -385,3 +385,15 @@ GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
|
||||
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
|
||||
|
||||
# ─────────────────────────── CSV dışa / içe aktarma ───────────────────────────
|
||||
|
||||
### Harcamaları CSV olarak indir (tarih aralığı isteğe bağlı)
|
||||
GET {{baseUrl}}/export/entries.csv?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### İçe aktarma denemesi (hiçbir şey yazılmaz); commit=true → geçerli satırlar yazılır
|
||||
POST {{baseUrl}}/import/entries?commit=false
|
||||
Content-Type: text/csv; charset=utf-8
|
||||
|
||||
Tarih;Tutar;Kategori;Açıklama
|
||||
13.11.2026;45,90;Market;Ekmek
|
||||
|
||||
@@ -34,6 +34,16 @@ impl Group {
|
||||
}
|
||||
}
|
||||
|
||||
/// Türkçe ad (CSV dışa/içe aktarma; arayüz metinleri `frontend/src/lib/groups.ts`)
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Group::Needs => "Zorunlu",
|
||||
Group::Wants => "İstek",
|
||||
Group::Culture => "Kültürel",
|
||||
Group::Unexpected => "Beklenmeyen",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ekranda gösterim sırası
|
||||
pub fn order(self) -> usize {
|
||||
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
|
||||
|
||||
@@ -15,9 +15,11 @@ mod reports;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
mod transfer;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
@@ -89,6 +91,17 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
.route("/export/entries.csv", get(transfer::export_entries))
|
||||
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
||||
.route(
|
||||
"/export/asset-transactions.csv",
|
||||
get(transfer::export_asset_txs),
|
||||
)
|
||||
.route(
|
||||
"/import/{kind}",
|
||||
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
|
||||
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! CSV dışa / içe aktarma (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/export/entries.csv?from=&to=` | harcamalar |
|
||||
//! | GET | `/api/export/plan-items.csv` | plan kalemleri (tüm dönemler) |
|
||||
//! | GET | `/api/export/asset-transactions.csv` | varlık işlemleri |
|
||||
//! | POST | `/api/import/{tür}?commit=false\|true` | gövde: CSV metni → rapor; `commit=true` geçerli satırları yazar |
|
||||
//!
|
||||
//! `{tür}`: `entries` · `plan-items` · `asset-transactions`.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
services::{
|
||||
calendar::{format_date, today_tr},
|
||||
transfer,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
fn csv_response(name: &str, body: String) -> impl IntoResponse {
|
||||
let file = format!("kakeibo-{name}-{}.csv", format_date(today_tr()));
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{file}\""),
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RangeQuery {
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn export_entries(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<RangeQuery>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let body = transfer::export_entries(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
q.from.as_deref().filter(|x| !x.is_empty()),
|
||||
q.to.as_deref().filter(|x| !x.is_empty()),
|
||||
)
|
||||
.await?;
|
||||
Ok(csv_response("harcamalar", body))
|
||||
}
|
||||
|
||||
pub async fn export_plan_items(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let body = transfer::export_plan_items(&s.db, auth.household_id).await?;
|
||||
Ok(csv_response("plan-kalemleri", body))
|
||||
}
|
||||
|
||||
pub async fn export_asset_txs(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let body = transfer::export_asset_txs(&s.db, auth.household_id).await?;
|
||||
Ok(csv_response("varlik-islemleri", body))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportQuery {
|
||||
#[serde(default)]
|
||||
commit: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ImportReport")]
|
||||
pub struct ReportDto {
|
||||
/// Başlık hariç dolu satır
|
||||
total: u32,
|
||||
/// Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
|
||||
valid: u32,
|
||||
/// Veritabanında zaten olduğu için atlanan
|
||||
duplicates: u32,
|
||||
/// Yazılan (denemede 0)
|
||||
imported: u32,
|
||||
errors: Vec<ImportError>,
|
||||
/// İlk geçerli satırlar (gösterime hazır)
|
||||
preview: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ImportError {
|
||||
/// Dosyadaki satır numarası (başlık 1)
|
||||
line: u32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn import(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(kind): Path<String>,
|
||||
Query(q): Query<ImportQuery>,
|
||||
body: String,
|
||||
) -> AppResult<Json<ReportDto>> {
|
||||
let r = match kind.as_str() {
|
||||
"entries" => {
|
||||
transfer::import_entries(&s.db, auth.household_id, auth.user_id, &body, q.commit)
|
||||
.await?
|
||||
}
|
||||
"plan-items" => {
|
||||
transfer::import_plan_items(&s.db, auth.household_id, &body, q.commit).await?
|
||||
}
|
||||
"asset-transactions" => {
|
||||
transfer::import_asset_txs(&s.db, auth.household_id, auth.user_id, &body, q.commit)
|
||||
.await?
|
||||
}
|
||||
_ => return Err(AppError::NotFound),
|
||||
};
|
||||
Ok(Json(ReportDto {
|
||||
total: r.total as u32,
|
||||
valid: r.valid as u32,
|
||||
duplicates: r.duplicates as u32,
|
||||
imported: r.imported as u32,
|
||||
errors: r
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|(line, message)| ImportError {
|
||||
line: line as u32,
|
||||
message,
|
||||
})
|
||||
.collect(),
|
||||
preview: r.preview,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! CSV okuma/yazma yardımcıları (saf; veritabanı yok). Türkçe Excel uyumlu:
|
||||
//! yazarken UTF-8 BOM, `;` ayırıcı, CRLF, tutar `1250,50`, tarih `GG.AA.YYYY`.
|
||||
//! Okurken esnek: BOM, `;` / `,` / sekme (başlık satırından anlaşılır), tırnaklı alanlar,
|
||||
//! tutar `1.250,50` · `1250,50` · `1250.50`, tarih `13.11.2026` · `13/11/2026` · `2026-11-13`.
|
||||
|
||||
use crate::services::calendar::{format_date, parse_date};
|
||||
|
||||
pub const BOM: &str = "\u{FEFF}";
|
||||
|
||||
// ---------- Yazma ----------
|
||||
|
||||
fn quote(s: &str) -> String {
|
||||
if s.contains([';', '"', '\n', '\r']) {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Başlık + satırlar → CSV metni (BOM'lu)
|
||||
pub fn write(headers: &[&str], rows: &[Vec<String>]) -> String {
|
||||
let mut out = String::from(BOM);
|
||||
let line = |cells: Vec<String>| cells.iter().map(|c| quote(c)).collect::<Vec<_>>().join(";");
|
||||
out.push_str(&line(headers.iter().map(|h| h.to_string()).collect()));
|
||||
out.push_str("\r\n");
|
||||
for r in rows {
|
||||
out.push_str(&line(r.clone()));
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Kuruş → `1250,50`
|
||||
pub fn money(kurus: i64) -> String {
|
||||
let sign = if kurus < 0 { "-" } else { "" };
|
||||
let a = kurus.unsigned_abs();
|
||||
format!("{sign}{},{:02}", a / 100, a % 100)
|
||||
}
|
||||
|
||||
/// × 1e6 → `5,5` (gereksiz sıfırsız)
|
||||
pub fn e6(v: i64) -> String {
|
||||
let sign = if v < 0 { "-" } else { "" };
|
||||
let a = v.unsigned_abs();
|
||||
let frac = format!("{:06}", a % 1_000_000);
|
||||
let frac = frac.trim_end_matches('0');
|
||||
if frac.is_empty() {
|
||||
format!("{sign}{}", a / 1_000_000)
|
||||
} else {
|
||||
format!("{sign}{},{frac}", a / 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
/// `"2026-11-13"` → `13.11.2026`
|
||||
pub fn date(iso: &str) -> String {
|
||||
match iso.split('-').collect::<Vec<_>>().as_slice() {
|
||||
[y, m, d] => format!("{d}.{m}.{y}"),
|
||||
_ => iso.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Okuma ----------
|
||||
|
||||
/// CSV metni → satırlar (başlık dahil). Boş satırlar atlanır; her satırın dosyadaki numarası.
|
||||
pub fn parse(text: &str) -> Vec<(usize, Vec<String>)> {
|
||||
let text = text.strip_prefix(BOM).unwrap_or(text);
|
||||
let first = text.lines().next().unwrap_or("");
|
||||
let delim = [';', '\t', ',']
|
||||
.into_iter()
|
||||
.max_by_key(|d| first.matches(*d).count())
|
||||
.unwrap_or(';');
|
||||
|
||||
let mut rows = Vec::new();
|
||||
let mut row = Vec::new();
|
||||
let mut cell = String::new();
|
||||
let mut in_quotes = false;
|
||||
let mut line = 1;
|
||||
let mut row_line = 1;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' if in_quotes && chars.peek() == Some(&'"') => {
|
||||
cell.push('"');
|
||||
chars.next();
|
||||
}
|
||||
'"' if in_quotes => in_quotes = false,
|
||||
'"' if cell.is_empty() => in_quotes = true,
|
||||
c if c == delim && !in_quotes => row.push(std::mem::take(&mut cell)),
|
||||
'\r' if !in_quotes => {}
|
||||
'\n' if !in_quotes => {
|
||||
row.push(std::mem::take(&mut cell));
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push((row_line, std::mem::take(&mut row)));
|
||||
}
|
||||
row.clear();
|
||||
line += 1;
|
||||
row_line = line;
|
||||
}
|
||||
'\n' => {
|
||||
cell.push(c);
|
||||
line += 1;
|
||||
}
|
||||
c => cell.push(c),
|
||||
}
|
||||
}
|
||||
row.push(cell);
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push((row_line, row));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Başlık adı karşılaştırması: küçük harf (Türkçe İ/I), boşluksuz
|
||||
pub fn norm(s: &str) -> String {
|
||||
s.trim()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'İ' => 'i',
|
||||
'I' => 'ı',
|
||||
c => c.to_lowercase().next().unwrap_or(c),
|
||||
})
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Başlık satırında adı verilen sütunların sırası (ilk eşleşen ad)
|
||||
pub fn column(headers: &[String], names: &[&str]) -> Option<usize> {
|
||||
let hs: Vec<String> = headers.iter().map(|h| norm(h)).collect();
|
||||
names
|
||||
.iter()
|
||||
.find_map(|n| hs.iter().position(|h| *h == norm(n)))
|
||||
}
|
||||
|
||||
/// Ondalık metin → (tam, kesir) basamakları; Türkçe ve İngilizce yazımı tanır
|
||||
fn split_decimal(s: &str, max_frac: usize) -> Option<(bool, String, String)> {
|
||||
let s: String = s
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '₺' && *c != '\u{a0}')
|
||||
.collect();
|
||||
let (neg, s) = match s.strip_prefix('-') {
|
||||
Some(r) => (true, r.to_string()),
|
||||
None => (false, s),
|
||||
};
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (int, frac) = if let Some((i, f)) = s.rsplit_once(',') {
|
||||
// Virgül ondalık; noktalar binlik
|
||||
(i.replace('.', ""), f.to_string())
|
||||
} else if s.matches('.').count() == 1 {
|
||||
let (i, f) = s.split_once('.').unwrap();
|
||||
// Tek nokta + tam 3 rakam → binlik (1.000), değilse ondalık (12.50)
|
||||
if f.len() == 3 && i != "0" && !i.is_empty() {
|
||||
(format!("{i}{f}"), String::new())
|
||||
} else {
|
||||
(i.to_string(), f.to_string())
|
||||
}
|
||||
} else {
|
||||
(s.replace('.', ""), String::new())
|
||||
};
|
||||
let int = if int.is_empty() { "0".to_string() } else { int };
|
||||
if !int.bytes().all(|b| b.is_ascii_digit())
|
||||
|| !frac.bytes().all(|b| b.is_ascii_digit())
|
||||
|| frac.len() > max_frac
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((neg, int, frac))
|
||||
}
|
||||
|
||||
/// `1.250,50` → 125050 kuruş (en fazla 2 ondalık)
|
||||
pub fn parse_money(s: &str) -> Option<i64> {
|
||||
let (neg, int, frac) = split_decimal(s, 2)?;
|
||||
let v =
|
||||
int.parse::<i64>().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::<i64>().ok()?;
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// `5,5` → 5_500_000 (en fazla 6 ondalık; eksi yok)
|
||||
pub fn parse_e6(s: &str) -> Option<i64> {
|
||||
let (neg, int, frac) = split_decimal(s, 6)?;
|
||||
if neg {
|
||||
return None;
|
||||
}
|
||||
int.parse::<i64>()
|
||||
.ok()?
|
||||
.checked_mul(1_000_000)?
|
||||
.checked_add(format!("{frac:0<6}").parse::<i64>().ok()?)
|
||||
}
|
||||
|
||||
/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"`
|
||||
pub fn parse_date_any(s: &str) -> Option<String> {
|
||||
let s = s.trim();
|
||||
let iso = if s.contains('-') && s.len() == 10 && s.as_bytes()[4] == b'-' {
|
||||
s.to_string()
|
||||
} else {
|
||||
let p: Vec<&str> = s.split(['.', '/']).collect();
|
||||
match p.as_slice() {
|
||||
[d, m, y] if y.len() == 4 => format!("{y}-{m:0>2}-{d:0>2}"),
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
parse_date(&iso).map(format_date)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writes_excel_friendly() {
|
||||
let csv = write(
|
||||
&["Ad", "Not"],
|
||||
&[
|
||||
vec!["Market".into(), "a;b".into()],
|
||||
vec!["\"x\"".into(), String::new()],
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Ad;Not\r\nMarket;\"a;b\"\r\n\"\"\"x\"\"\";\r\n"
|
||||
);
|
||||
assert_eq!(money(125_050), "1250,50");
|
||||
assert_eq!(money(-5), "-0,05");
|
||||
assert_eq!(e6(5_500_000), "5,5");
|
||||
assert_eq!(e6(10_000_000), "10");
|
||||
assert_eq!(e6(125_000), "0,125");
|
||||
assert_eq!(date("2026-11-13"), "13.11.2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_quotes_delimiters_and_line_numbers() {
|
||||
let rows = parse(
|
||||
"\u{FEFF}Tarih;Tutar;Açıklama\r\n13.11.2026;1.250,50;\"Kafe; tatlı\"\r\n\r\n14.11.2026;10;\"iki\nsatır\"\n",
|
||||
);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(
|
||||
rows[1],
|
||||
(
|
||||
2,
|
||||
vec!["13.11.2026".into(), "1.250,50".into(), "Kafe; tatlı".into()]
|
||||
)
|
||||
);
|
||||
assert_eq!(rows[2].0, 4, "boş satır sayılır ama atlanır");
|
||||
assert_eq!(rows[2].1[2], "iki\nsatır");
|
||||
// Virgül ayraçlı dosya
|
||||
let rows = parse("date,amount\n2026-11-13,\"1,5\"\n");
|
||||
assert_eq!(rows[1].1, vec!["2026-11-13", "1,5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_by_name() {
|
||||
let h: Vec<String> = ["TARİH", "Tutar ", "Ödeme Türü"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(column(&h, &["Tarih"]), Some(0));
|
||||
assert_eq!(column(&h, &["ödeme türü"]), Some(2));
|
||||
assert_eq!(column(&h, &["Kategori"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_and_dates() {
|
||||
assert_eq!(parse_money("1.250,50"), Some(125_050));
|
||||
assert_eq!(parse_money("1250,5"), Some(125_050));
|
||||
assert_eq!(parse_money("1250.50"), Some(125_050));
|
||||
assert_eq!(parse_money("1.000"), Some(100_000));
|
||||
assert_eq!(parse_money("-45,90 ₺"), Some(-4_590));
|
||||
assert_eq!(parse_money("1,234"), None, "3 ondalık para olmaz");
|
||||
assert_eq!(parse_money("abc"), None);
|
||||
assert_eq!(parse_e6("5,5"), Some(5_500_000));
|
||||
assert_eq!(parse_e6("0.125"), Some(125_000));
|
||||
assert_eq!(parse_e6("-1"), None);
|
||||
assert_eq!(parse_date_any("13.11.2026").as_deref(), Some("2026-11-13"));
|
||||
assert_eq!(parse_date_any("3/1/2026").as_deref(), Some("2026-01-03"));
|
||||
assert_eq!(parse_date_any("2026-11-13").as_deref(), Some("2026-11-13"));
|
||||
assert_eq!(parse_date_any("30.02.2026"), None);
|
||||
assert_eq!(parse_date_any("13.11.26"), None);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod csv_io;
|
||||
pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
@@ -19,3 +20,4 @@ pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
pub mod transfer;
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
//! Uygulamanın kendi CSV biçimiyle dışa / içe aktarma (KAKEIBO_PROMPT §8.9):
|
||||
//! harcamalar, plan kalemleri, varlık işlemleri.
|
||||
//!
|
||||
//! İçe aktarma iki aşamalı: `commit = false` → yalnızca rapor (deneme); `true` → geçerli satırlar
|
||||
//! yazılır. Hatalı satırlar satır numarası ve nedeniyle raporlanır, diğerleri etkilenmez.
|
||||
//! **Aynı dosya iki kez yüklense de çift kayıt olmaz:** veritabanında aynısı olan satır atlanır
|
||||
//! (dosyada aynı satırdan iki tane, veritabanında bir tane varsa biri eklenir).
|
||||
//! Sütunlar başlık adıyla bulunur (sıra serbest); kategori / ödeme türü / varlık adla eşleşir.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
asset::{Asset, AssetKind, AssetTx, TxType},
|
||||
common::{new_meta, scope},
|
||||
entry::Entry,
|
||||
group::Group,
|
||||
period::Period,
|
||||
plan_item::{PlanItem, PlanKind},
|
||||
},
|
||||
services::{
|
||||
asset,
|
||||
catalog::{self, Catalog},
|
||||
csv_io::{self, column, norm},
|
||||
entry::MAX_DESCRIPTION,
|
||||
holding::{self, Move},
|
||||
period,
|
||||
plan::{self, NewItem},
|
||||
},
|
||||
};
|
||||
|
||||
/// Bir dosyada en fazla satır
|
||||
pub const MAX_ROWS: usize = 20_000;
|
||||
/// Önizlemede gösterilen geçerli satır sayısı
|
||||
const PREVIEW: usize = 10;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
/// Başlık hariç dolu satır sayısı
|
||||
pub total: usize,
|
||||
/// Eklenebilir satırlar (tekrarlar hariç)
|
||||
pub valid: usize,
|
||||
/// Veritabanında zaten olduğu için atlanan
|
||||
pub duplicates: usize,
|
||||
/// Yazılan (deneme: 0)
|
||||
pub imported: usize,
|
||||
/// (satır numarası, neden)
|
||||
pub errors: Vec<(usize, String)>,
|
||||
/// İlk geçerli satırlar, gösterime hazır hücreler
|
||||
pub preview: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// (satır numarası, hücreler)
|
||||
type Rows = Vec<(usize, Vec<String>)>;
|
||||
|
||||
fn rows_of(text: &str) -> AppResult<(Vec<String>, Rows)> {
|
||||
let mut rows = csv_io::parse(text);
|
||||
if rows.is_empty() {
|
||||
return Err(AppError::BadRequest("Dosya boş".into()));
|
||||
}
|
||||
if rows.len() - 1 > MAX_ROWS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Dosya en fazla {MAX_ROWS} satır olabilir; parçalara bölün"
|
||||
)));
|
||||
}
|
||||
let header = rows.remove(0).1;
|
||||
Ok((header, rows))
|
||||
}
|
||||
|
||||
/// Zorunlu sütunlar: (anahtar adlar) → sıra; eksikse anlaşılır hata
|
||||
fn require(header: &[String], cols: &[(&str, &[&str])]) -> AppResult<Vec<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
for (label, names) in cols {
|
||||
match column(header, names) {
|
||||
Some(i) => out.push(i),
|
||||
None => missing.push(*label),
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Gerekli sütun eksik: {}",
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cell(row: &[String], i: Option<usize>) -> &str {
|
||||
i.and_then(|i| row.get(i)).map_or("", |s| s.trim())
|
||||
}
|
||||
|
||||
fn period_for<'a>(periods: &'a [Period], date: &str) -> Result<&'a Period, String> {
|
||||
let p = periods
|
||||
.iter()
|
||||
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||
.ok_or_else(|| "Bu tarih için dönem yok".to_string())?;
|
||||
if !p.status.is_writable() {
|
||||
return Err(format!("Dönem kapalı ({})", p.name));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn group_from(s: &str) -> Option<Group> {
|
||||
let n = norm(s);
|
||||
Group::ALL
|
||||
.into_iter()
|
||||
.find(|g| norm(g.label()) == n || g.as_str() == n)
|
||||
}
|
||||
|
||||
/// Ada göre katalog kaydı (grup verilmişse o grupta). Aynı ad birden çok grupta → hata.
|
||||
fn find_by_name<'a>(
|
||||
items: &'a [catalog::Item],
|
||||
name: &str,
|
||||
group: Option<Group>,
|
||||
what: &str,
|
||||
) -> Result<&'a catalog::Item, String> {
|
||||
let n = norm(name);
|
||||
let matches: Vec<&catalog::Item> = items
|
||||
.iter()
|
||||
.filter(|c| norm(&c.name) == n && (group.is_none() || c.group == group))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[one] => Ok(one),
|
||||
[] => Err(format!("{what} bulunamadı: {name}")),
|
||||
_ => Err(format!(
|
||||
"\"{name}\" birden fazla grupta var; Grup sütununu doldurun"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tekrar sayacı: anahtar → veritabanında kaç tane var (eşleştikçe azalır)
|
||||
struct Existing(HashMap<String, usize>);
|
||||
|
||||
impl Existing {
|
||||
fn new(keys: impl Iterator<Item = String>) -> Self {
|
||||
let mut m = HashMap::new();
|
||||
for k in keys {
|
||||
*m.entry(k).or_insert(0) += 1;
|
||||
}
|
||||
Self(m)
|
||||
}
|
||||
/// Varsa bir tanesini "tüket" ve `true` dön
|
||||
fn take(&mut self, key: &str) -> bool {
|
||||
match self.0.get_mut(key) {
|
||||
Some(n) if *n > 0 => {
|
||||
*n -= 1;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Harcamalar =================
|
||||
|
||||
const E_HEADERS: [&str; 6] = [
|
||||
"Tarih",
|
||||
"Tutar",
|
||||
"Grup",
|
||||
"Kategori",
|
||||
"Ödeme türü",
|
||||
"Açıklama",
|
||||
];
|
||||
|
||||
fn entry_key(date: &str, amount: i64, category: Uuid, desc: Option<&str>) -> String {
|
||||
format!("{date}|{amount}|{category}|{}", desc.unwrap_or("").trim())
|
||||
}
|
||||
|
||||
pub async fn export_entries(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
from: Option<&str>,
|
||||
to: Option<&str>,
|
||||
) -> AppResult<String> {
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
|
||||
let mut f = scope(household_id);
|
||||
let mut range = Document::new();
|
||||
if let Some(d) = from {
|
||||
range.insert("$gte", d);
|
||||
}
|
||||
if let Some(d) = to {
|
||||
range.insert("$lte", d);
|
||||
}
|
||||
if !range.is_empty() {
|
||||
f.insert("date", range);
|
||||
}
|
||||
let mut c = db
|
||||
.collection::<Entry>(col::ENTRIES)
|
||||
.find(f)
|
||||
.sort(doc! { "date": 1, "_id": 1 })
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
while c.advance().await? {
|
||||
let e = c.deserialize_current()?;
|
||||
let cat = cats.iter().find(|x| x.id == e.category_id);
|
||||
let pm = e
|
||||
.payment_method_id
|
||||
.and_then(|id| pms.iter().find(|x| x.id == id));
|
||||
rows.push(vec![
|
||||
csv_io::date(&e.date),
|
||||
csv_io::money(e.amount),
|
||||
e.group.label().to_string(),
|
||||
cat.map_or(String::new(), |x| x.name.clone()),
|
||||
pm.map_or(String::new(), |x| x.name.clone()),
|
||||
e.description.unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
Ok(csv_io::write(&E_HEADERS, &rows))
|
||||
}
|
||||
|
||||
pub async fn import_entries(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
text: &str,
|
||||
commit: bool,
|
||||
) -> AppResult<Report> {
|
||||
let (header, rows) = rows_of(text)?;
|
||||
let req = require(
|
||||
&header,
|
||||
&[
|
||||
("Tarih", &["Tarih", "date"]),
|
||||
("Tutar", &["Tutar", "amount"]),
|
||||
("Kategori", &["Kategori", "category"]),
|
||||
],
|
||||
)?;
|
||||
let (c_date, c_amount, c_cat) = (Some(req[0]), Some(req[1]), Some(req[2]));
|
||||
let c_group = column(&header, &["Grup", "group"]);
|
||||
let c_pm = column(&header, &["Ödeme türü", "Ödeme", "payment_method"]);
|
||||
let c_desc = column(&header, &["Açıklama", "description"]);
|
||||
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
|
||||
let mut existing = {
|
||||
let mut c = db
|
||||
.collection::<Entry>(col::ENTRIES)
|
||||
.find(scope(household_id))
|
||||
.await?;
|
||||
let mut keys = Vec::new();
|
||||
while c.advance().await? {
|
||||
let e = c.deserialize_current()?;
|
||||
keys.push(entry_key(
|
||||
&e.date,
|
||||
e.amount,
|
||||
e.category_id,
|
||||
e.description.as_deref(),
|
||||
));
|
||||
}
|
||||
Existing::new(keys.into_iter())
|
||||
};
|
||||
|
||||
let mut report = Report {
|
||||
total: rows.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut docs = Vec::new();
|
||||
for (line, row) in &rows {
|
||||
let parsed = (|| -> Result<Document, String> {
|
||||
let date = csv_io::parse_date_any(cell(row, c_date))
|
||||
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
|
||||
let p = period_for(&periods, &date)?;
|
||||
let amount = csv_io::parse_money(cell(row, c_amount))
|
||||
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
|
||||
if amount <= 0 {
|
||||
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||
}
|
||||
period::validate_amount(amount).map_err(|e| e.to_string())?;
|
||||
let group = match cell(row, c_group) {
|
||||
"" => None,
|
||||
g => Some(group_from(g).ok_or_else(|| format!("Grup tanınmadı: {g}"))?),
|
||||
};
|
||||
let cat = find_by_name(&cats, cell(row, c_cat), group, "Kategori")?;
|
||||
let cat_group = cat.group.ok_or("Kategorinin grubu yok")?;
|
||||
let pm = match cell(row, c_pm) {
|
||||
"" => None,
|
||||
n => Some(find_by_name(&pms, n, None, "Ödeme türü")?.id),
|
||||
};
|
||||
let desc = match cell(row, c_desc) {
|
||||
"" => None,
|
||||
d if d.chars().count() > MAX_DESCRIPTION => {
|
||||
return Err(format!(
|
||||
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
|
||||
));
|
||||
}
|
||||
d => Some(d.to_string()),
|
||||
};
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", p.id);
|
||||
d.insert("date", &date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", cat_group.as_str());
|
||||
d.insert("category_id", cat.id);
|
||||
d.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
d.insert("description", desc.clone().map_or(Bson::Null, Bson::String));
|
||||
d.insert("created_by", user_id);
|
||||
d.insert("__key", entry_key(&date, amount, cat.id, desc.as_deref()));
|
||||
d.insert(
|
||||
"__preview",
|
||||
vec![
|
||||
csv_io::date(&date),
|
||||
csv_io::money(amount),
|
||||
cat.name.clone(),
|
||||
desc.unwrap_or_default(),
|
||||
],
|
||||
);
|
||||
Ok(d)
|
||||
})();
|
||||
match parsed {
|
||||
Err(e) => report.errors.push((*line, e)),
|
||||
Ok(mut d) => {
|
||||
let key = d.get_str("__key").unwrap_or_default().to_string();
|
||||
if existing.take(&key) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if report.preview.len() < PREVIEW
|
||||
&& let Ok(p) = d.get_array("__preview")
|
||||
{
|
||||
report.preview.push(
|
||||
p.iter()
|
||||
.map(|x| x.as_str().unwrap_or_default().to_string())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
d.remove("__key");
|
||||
d.remove("__preview");
|
||||
docs.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
report.valid = docs.len();
|
||||
if commit && !docs.is_empty() {
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_many(docs)
|
||||
.await?;
|
||||
report.imported = report.valid;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ================= Plan kalemleri =================
|
||||
|
||||
const P_HEADERS: [&str; 6] = ["Dönem başlangıcı", "Dönem", "Tür", "Ad", "Tutar", "Tarih"];
|
||||
|
||||
fn kind_label(k: PlanKind) -> &'static str {
|
||||
match k {
|
||||
PlanKind::Income => "Gelir",
|
||||
PlanKind::Expense => "Sabit gider",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from(s: &str) -> Option<PlanKind> {
|
||||
match norm(s).as_str() {
|
||||
"gelir" | "income" => Some(PlanKind::Income),
|
||||
"gider" | "sabitgider" | "expense" => Some(PlanKind::Expense),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_key(period: Uuid, kind: PlanKind, name: &str, amount: i64) -> String {
|
||||
format!("{period}|{}|{}|{amount}", kind.as_str(), norm(name))
|
||||
}
|
||||
|
||||
async fn all_plan_items(db: &Database, household_id: Uuid) -> AppResult<Vec<PlanItem>> {
|
||||
let mut c = db
|
||||
.collection::<PlanItem>(col::PLAN_ITEMS)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "period_id": 1, "kind": 1, "sort_order": 1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while c.advance().await? {
|
||||
out.push(c.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn export_plan_items(db: &Database, household_id: Uuid) -> AppResult<String> {
|
||||
let mut periods = period::list(db, household_id).await?;
|
||||
periods.reverse(); // eskiden yeniye
|
||||
let items = all_plan_items(db, household_id).await?;
|
||||
let mut rows = Vec::new();
|
||||
for p in &periods {
|
||||
// Uygulamadaki sıra: önce gelirler, sonra sabit giderler
|
||||
let mut list: Vec<&PlanItem> = items.iter().filter(|i| i.period_id == p.id).collect();
|
||||
list.sort_by_key(|i| (i.kind != PlanKind::Income, i.sort_order));
|
||||
for i in list {
|
||||
rows.push(vec![
|
||||
csv_io::date(&p.start_date),
|
||||
p.name.clone(),
|
||||
kind_label(i.kind).to_string(),
|
||||
i.name.clone(),
|
||||
csv_io::money(i.amount),
|
||||
i.date.as_deref().map(csv_io::date).unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Ok(csv_io::write(&P_HEADERS, &rows))
|
||||
}
|
||||
|
||||
pub async fn import_plan_items(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
text: &str,
|
||||
commit: bool,
|
||||
) -> AppResult<Report> {
|
||||
let (header, rows) = rows_of(text)?;
|
||||
let req = require(
|
||||
&header,
|
||||
&[
|
||||
("Dönem başlangıcı", &["Dönem başlangıcı", "period_start"]),
|
||||
("Tür", &["Tür", "kind"]),
|
||||
("Ad", &["Ad", "name"]),
|
||||
("Tutar", &["Tutar", "amount"]),
|
||||
],
|
||||
)?;
|
||||
let (c_period, c_kind, c_name, c_amount) =
|
||||
(Some(req[0]), Some(req[1]), Some(req[2]), Some(req[3]));
|
||||
let c_date = column(&header, &["Tarih", "date"]);
|
||||
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let mut existing = Existing::new(
|
||||
all_plan_items(db, household_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|i| plan_key(i.period_id, i.kind, &i.name, i.amount)),
|
||||
);
|
||||
let mut report = Report {
|
||||
total: rows.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut todo: Vec<(usize, Uuid, NewItem)> = Vec::new();
|
||||
for (line, row) in &rows {
|
||||
let parsed = (|| -> Result<(Uuid, NewItem, Vec<String>), String> {
|
||||
let start = csv_io::parse_date_any(cell(row, c_period))
|
||||
.ok_or_else(|| format!("Dönem başlangıcı geçersiz: {}", cell(row, c_period)))?;
|
||||
let p = period_for(&periods, &start)?;
|
||||
let kind = kind_from(cell(row, c_kind)).ok_or_else(|| {
|
||||
format!(
|
||||
"Tür \"Gelir\" ya da \"Sabit gider\" olmalı: {}",
|
||||
cell(row, c_kind)
|
||||
)
|
||||
})?;
|
||||
let name = cell(row, c_name);
|
||||
if name.is_empty() {
|
||||
return Err("Ad boş".into());
|
||||
}
|
||||
let amount = csv_io::parse_money(cell(row, c_amount))
|
||||
.filter(|a| *a > 0)
|
||||
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
|
||||
let date = match cell(row, c_date) {
|
||||
"" => None,
|
||||
d => Some(csv_io::parse_date_any(d).ok_or_else(|| format!("Tarih geçersiz: {d}"))?),
|
||||
};
|
||||
let preview = vec![
|
||||
p.name.clone(),
|
||||
kind_label(kind).into(),
|
||||
name.into(),
|
||||
csv_io::money(amount),
|
||||
];
|
||||
Ok((
|
||||
p.id,
|
||||
NewItem {
|
||||
kind,
|
||||
name: name.to_string(),
|
||||
amount,
|
||||
date,
|
||||
},
|
||||
preview,
|
||||
))
|
||||
})();
|
||||
match parsed {
|
||||
Err(e) => report.errors.push((*line, e)),
|
||||
Ok((pid, item, preview)) => {
|
||||
if existing.take(&plan_key(pid, item.kind, &item.name, item.amount)) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if report.preview.len() < PREVIEW {
|
||||
report.preview.push(preview);
|
||||
}
|
||||
todo.push((*line, pid, item));
|
||||
}
|
||||
}
|
||||
}
|
||||
report.valid = todo.len();
|
||||
if commit {
|
||||
// Ad uzunluğu, tarihin dönem içinde olması vb. son denetim `plan::create`'te
|
||||
for (line, pid, item) in todo {
|
||||
match plan::create(db, household_id, pid, item).await {
|
||||
Ok(_) => report.imported += 1,
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
|
||||
report.errors.push((line, m));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
report.valid = report.imported;
|
||||
report.errors.sort_by_key(|(l, _)| *l);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ================= Varlık işlemleri =================
|
||||
|
||||
const A_HEADERS: [&str; 8] = [
|
||||
"Tarih",
|
||||
"Varlık kodu",
|
||||
"Varlık",
|
||||
"İşlem",
|
||||
"Miktar",
|
||||
"Birim fiyat",
|
||||
"Tutar",
|
||||
"Not",
|
||||
];
|
||||
|
||||
fn tx_key(asset: Uuid, date: &str, t: TxType, q: i64, total: i64) -> String {
|
||||
format!("{asset}|{date}|{}|{q}|{total}", t.as_str())
|
||||
}
|
||||
|
||||
async fn household_assets(db: &Database, household_id: Uuid) -> AppResult<Vec<Asset>> {
|
||||
let mut c = db
|
||||
.collection::<Asset>(col::ASSETS)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "sort_order": 1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while c.advance().await? {
|
||||
out.push(c.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn household_txs(db: &Database, household_id: Uuid) -> AppResult<Vec<AssetTx>> {
|
||||
let mut c = db
|
||||
.collection::<AssetTx>(col::ASSET_TRANSACTIONS)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "date": 1, "_id": 1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while c.advance().await? {
|
||||
out.push(c.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn export_asset_txs(db: &Database, household_id: Uuid) -> AppResult<String> {
|
||||
let assets = household_assets(db, household_id).await?;
|
||||
let rows = household_txs(db, household_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let a = assets.iter().find(|a| a.id == t.asset_id);
|
||||
vec![
|
||||
csv_io::date(&t.date),
|
||||
a.map_or(String::new(), |a| a.code.clone()),
|
||||
a.map_or(String::new(), |a| a.name.clone()),
|
||||
match t.tx_type {
|
||||
TxType::Buy => "Alım",
|
||||
TxType::Sell => "Satım",
|
||||
}
|
||||
.to_string(),
|
||||
csv_io::e6(t.quantity_e6),
|
||||
csv_io::e6(t.unit_price_e6),
|
||||
csv_io::money(t.total),
|
||||
t.note.unwrap_or_default(),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(csv_io::write(&A_HEADERS, &rows))
|
||||
}
|
||||
|
||||
struct NewTxRow {
|
||||
line: usize,
|
||||
asset: Uuid,
|
||||
tx: asset::NewTx,
|
||||
quantity_e6: i64,
|
||||
}
|
||||
|
||||
pub async fn import_asset_txs(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
text: &str,
|
||||
commit: bool,
|
||||
) -> AppResult<Report> {
|
||||
let (header, rows) = rows_of(text)?;
|
||||
let req = require(
|
||||
&header,
|
||||
&[
|
||||
("Tarih", &["Tarih", "date"]),
|
||||
("İşlem", &["İşlem", "type"]),
|
||||
("Tutar", &["Tutar", "total"]),
|
||||
],
|
||||
)?;
|
||||
let (c_date, c_type, c_total) = (Some(req[0]), Some(req[1]), Some(req[2]));
|
||||
let c_code = column(&header, &["Varlık kodu", "Kod", "code"]);
|
||||
let c_name = column(&header, &["Varlık", "asset"]);
|
||||
if c_code.is_none() && c_name.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Gerekli sütun eksik: Varlık kodu (ya da Varlık)".into(),
|
||||
));
|
||||
}
|
||||
let c_qty = column(&header, &["Miktar", "quantity"]);
|
||||
let c_price = column(&header, &["Birim fiyat", "unit_price"]);
|
||||
let c_note = column(&header, &["Not", "note"]);
|
||||
|
||||
let assets = household_assets(db, household_id).await?;
|
||||
let existing_txs = household_txs(db, household_id).await?;
|
||||
let mut existing = Existing::new(
|
||||
existing_txs
|
||||
.iter()
|
||||
.map(|t| tx_key(t.asset_id, &t.date, t.tx_type, t.quantity_e6, t.total)),
|
||||
);
|
||||
|
||||
let mut report = Report {
|
||||
total: rows.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut todo: Vec<NewTxRow> = Vec::new();
|
||||
for (line, row) in &rows {
|
||||
let parsed = (|| -> Result<(NewTxRow, Vec<String>), String> {
|
||||
let a = {
|
||||
let code = norm(cell(row, c_code));
|
||||
let name = norm(cell(row, c_name));
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| {
|
||||
(!code.is_empty() && norm(&a.code) == code)
|
||||
|| (code.is_empty() && norm(&a.name) == name)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Varlık bulunamadı: {}",
|
||||
if code.is_empty() {
|
||||
cell(row, c_name)
|
||||
} else {
|
||||
cell(row, c_code)
|
||||
}
|
||||
)
|
||||
})?
|
||||
};
|
||||
if !a.is_active {
|
||||
return Err(format!("Varlık pasif: {}", a.name));
|
||||
}
|
||||
let date = csv_io::parse_date_any(cell(row, c_date))
|
||||
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
|
||||
let tx_type = match norm(cell(row, c_type)).as_str() {
|
||||
"alım" | "alis" | "alış" | "buy" => TxType::Buy,
|
||||
"satım" | "satış" | "satis" | "sell" => TxType::Sell,
|
||||
t => return Err(format!("İşlem \"Alım\" ya da \"Satım\" olmalı: {t}")),
|
||||
};
|
||||
let total = csv_io::parse_money(cell(row, c_total))
|
||||
.filter(|t| *t > 0)
|
||||
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_total)))?;
|
||||
let quantity_e6 = if a.kind == AssetKind::Cash {
|
||||
total * 10_000
|
||||
} else {
|
||||
csv_io::parse_e6(cell(row, c_qty))
|
||||
.filter(|q| *q > 0)
|
||||
.ok_or_else(|| format!("Miktar geçersiz: {}", cell(row, c_qty)))?
|
||||
};
|
||||
let unit_price_e6 = match cell(row, c_price) {
|
||||
"" => None,
|
||||
p => Some(
|
||||
csv_io::parse_e6(p)
|
||||
.filter(|p| *p > 0)
|
||||
.ok_or_else(|| format!("Birim fiyat geçersiz: {p}"))?,
|
||||
),
|
||||
};
|
||||
let note = match cell(row, c_note) {
|
||||
"" => None,
|
||||
n => Some(n.to_string()),
|
||||
};
|
||||
let preview = vec![
|
||||
csv_io::date(&date),
|
||||
a.name.clone(),
|
||||
if tx_type == TxType::Buy {
|
||||
"Alım"
|
||||
} else {
|
||||
"Satım"
|
||||
}
|
||||
.into(),
|
||||
if a.kind == AssetKind::Cash {
|
||||
csv_io::money(total)
|
||||
} else {
|
||||
format!("{} {}", csv_io::e6(quantity_e6), a.unit_label)
|
||||
},
|
||||
];
|
||||
Ok((
|
||||
NewTxRow {
|
||||
line: *line,
|
||||
asset: a.id,
|
||||
quantity_e6,
|
||||
tx: asset::NewTx {
|
||||
tx_type,
|
||||
date,
|
||||
quantity_e6,
|
||||
unit_price_e6,
|
||||
total,
|
||||
note,
|
||||
},
|
||||
},
|
||||
preview,
|
||||
))
|
||||
})();
|
||||
match parsed {
|
||||
Err(e) => report.errors.push((*line, e)),
|
||||
Ok((r, preview)) => {
|
||||
if existing.take(&tx_key(
|
||||
r.asset,
|
||||
&r.tx.date,
|
||||
r.tx.tx_type,
|
||||
r.quantity_e6,
|
||||
r.tx.total,
|
||||
)) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if report.preview.len() < PREVIEW {
|
||||
report.preview.push(preview);
|
||||
}
|
||||
todo.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tarih sırasıyla (aynı gün dosya sırası) eldekinden fazla satış denetimi:
|
||||
// mevcut işlemler + kabul edilen yeniler; aşan satır reddedilir, diğerleri sürer.
|
||||
todo.sort_by(|a, b| (a.tx.date.as_str(), a.line).cmp(&(b.tx.date.as_str(), b.line)));
|
||||
let mut accepted: Vec<NewTxRow> = Vec::new();
|
||||
for r in todo {
|
||||
let mut list: Vec<(String, u8, usize, Move)> = existing_txs
|
||||
.iter()
|
||||
.filter(|t| t.asset_id == r.asset)
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
(
|
||||
t.date.clone(),
|
||||
0,
|
||||
i,
|
||||
Move {
|
||||
tx_type: t.tx_type,
|
||||
quantity_e6: t.quantity_e6,
|
||||
total: t.total,
|
||||
},
|
||||
)
|
||||
})
|
||||
.chain(
|
||||
accepted
|
||||
.iter()
|
||||
.chain(std::iter::once(&r))
|
||||
.filter(|x| x.asset == r.asset)
|
||||
.map(|x| {
|
||||
(
|
||||
x.tx.date.clone(),
|
||||
1,
|
||||
x.line,
|
||||
Move {
|
||||
tx_type: x.tx.tx_type,
|
||||
quantity_e6: x.quantity_e6,
|
||||
total: x.tx.total,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
list.sort_by(|a, b| (a.0.as_str(), a.1, a.2).cmp(&(b.0.as_str(), b.1, b.2)));
|
||||
let moves: Vec<Move> = list.into_iter().map(|x| x.3).collect();
|
||||
if holding::holding(&moves).is_err() {
|
||||
report.errors.push((
|
||||
r.line,
|
||||
"Satış miktarı o tarihte eldeki miktarı aşıyor".into(),
|
||||
));
|
||||
} else {
|
||||
accepted.push(r);
|
||||
}
|
||||
}
|
||||
report.errors.sort_by_key(|(l, _)| *l);
|
||||
report.valid = accepted.len();
|
||||
if commit {
|
||||
report.valid = 0;
|
||||
for r in accepted {
|
||||
match asset::create_tx(db, household_id, user_id, r.asset, r.tx).await {
|
||||
Ok(_) => report.imported += 1,
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
|
||||
report.errors.push((r.line, m))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
report.valid = report.imported;
|
||||
report.errors.sort_by_key(|(l, _)| *l);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
@@ -101,6 +101,43 @@ impl TestApp {
|
||||
(status, json, set_cookie)
|
||||
}
|
||||
|
||||
/// Düz metin gövde gönderir (CSV içe aktarma)
|
||||
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.body(Body::from(text.to_string()))
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
|
||||
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! CSV dışa / içe aktarma: biçim, deneme → aktarma, satır hataları, tekrar atlama,
|
||||
//! varlıkta tarih sırasıyla eldekinden fazla satış denetimi.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by(app: &TestApp, s: &Session, path: &str, key: &str, value: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c[key] == value)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn open(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn import(app: &TestApp, s: &Session, kind: &str, csv: &str, commit: bool) -> Value {
|
||||
let (st, r) = app
|
||||
.post_text(&format!("/import/{kind}?commit={commit}"), s, csv)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
r
|
||||
}
|
||||
|
||||
fn errors(r: &Value) -> Vec<(u64, String)> {
|
||||
r["errors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e["line"].as_u64().unwrap(),
|
||||
e["message"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_export_and_import() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by(&app, &s, "/categories", "name", "Market").await;
|
||||
let nakit = id_by(&app, &s, "/payment-methods", "name", "Nakit").await;
|
||||
// Ekim kapalı, Kasım açık
|
||||
let oct = open(&app, &s, "2026-10-01", "2026-11-01").await;
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
app.post(&format!("/periods/{nov}/entries"), &s, json!({ "date": "2026-11-05", "amount": 125_050, "category_id": market, "payment_method_id": nakit, "description": "Kafe; tatlı" })).await;
|
||||
|
||||
let (st, ct, csv) = app.get_text("/export/entries.csv", &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert!(ct.starts_with("text/csv"), "{ct}");
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Tarih;Tutar;Grup;Kategori;Ödeme türü;Açıklama\r\n05.11.2026;1250,50;Zorunlu;Market;Nakit;\"Kafe; tatlı\"\r\n"
|
||||
);
|
||||
|
||||
// Aynı dosya geri yüklenirse çift kayıt olmaz
|
||||
let r = import(&app, &s, "entries", &csv, true).await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["total"].as_u64(),
|
||||
r["valid"].as_u64(),
|
||||
r["duplicates"].as_u64(),
|
||||
r["imported"].as_u64()
|
||||
),
|
||||
(Some(1), Some(0), Some(1), Some(0))
|
||||
);
|
||||
|
||||
// Sütun sırası serbest, ayrı yazımlar; hatalı satırlar raporlanır
|
||||
let file = "Açıklama;Kategori;Tutar;Tarih;Grup;Ödeme türü\r\n\
|
||||
Ekmek;market;45,90;06.11.2026;;\r\n\
|
||||
x;Market;10;31.02.2026;;\r\n\
|
||||
x;Market;10;15.09.2026;;\r\n\
|
||||
x;Market;10;15.10.2026;;\r\n\
|
||||
x;Uzay;10;06.11.2026;;\r\n\
|
||||
x;Market;0;06.11.2026;;\r\n\
|
||||
x;Market;10;06.11.2026;;Kripto\r\n\
|
||||
x;Market;10;06.11.2026;İstek;\r\n\
|
||||
Kitaplar;Kitap;1.200;2026-11-07;Kültürel;nakit\r\n\
|
||||
Kafe; tatlı;Market;1.250,50;05.11.2026;Zorunlu;Nakit\r\n";
|
||||
// Son satırda tırnaksız ';' → sütunlar kayar: açıkça hata olmalı
|
||||
let dry = import(&app, &s, "entries", file, false).await;
|
||||
assert_eq!(dry["total"], 10);
|
||||
assert_eq!(dry["valid"], 2);
|
||||
assert_eq!(dry["imported"], 0, "deneme yazmaz");
|
||||
assert_eq!(
|
||||
errors(&dry),
|
||||
vec![
|
||||
(3, "Tarih geçersiz: 31.02.2026".into()),
|
||||
(4, "Bu tarih için dönem yok".into()),
|
||||
(5, "Dönem kapalı (Ekim 2026)".into()),
|
||||
(6, "Kategori bulunamadı: Uzay".into()),
|
||||
(7, "Tutar sıfırdan büyük olmalı".into()),
|
||||
(8, "Ödeme türü bulunamadı: Kripto".into()),
|
||||
(9, "Kategori bulunamadı: Market".into()),
|
||||
(11, "Tarih geçersiz: 1.250,50".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
dry["preview"][0],
|
||||
json!(["06.11.2026", "45,90", "Market", "Ekmek"])
|
||||
);
|
||||
let (_, before) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
assert_eq!(before.as_array().unwrap().len(), 1);
|
||||
|
||||
let done = import(&app, &s, "entries", file, true).await;
|
||||
assert_eq!(done["imported"], 2);
|
||||
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
let list = list.as_array().unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
let book = list
|
||||
.iter()
|
||||
.find(|e| e["description"] == "Kitaplar")
|
||||
.unwrap();
|
||||
assert_eq!(book["amount"], 120_000, "1.200 → bin iki yüz");
|
||||
assert_eq!(book["group"], "culture");
|
||||
assert_eq!(
|
||||
book["payment_method_id"],
|
||||
nakit.as_str(),
|
||||
"ad büyük/küçük harf duyarsız"
|
||||
);
|
||||
|
||||
// Dosyada 2 aynı satır, veritabanında 1 → biri eklenir
|
||||
let twice = "Tarih;Tutar;Kategori;Açıklama\r\n06.11.2026;45,90;Market;Ekmek\r\n06.11.2026;45,90;Market;Ekmek\r\n";
|
||||
let r = import(&app, &s, "entries", twice, true).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["imported"].as_u64()),
|
||||
(Some(1), Some(1))
|
||||
);
|
||||
|
||||
// Biçim hataları
|
||||
let (st, b) = app
|
||||
.post_text(
|
||||
"/import/entries?commit=false",
|
||||
&s,
|
||||
"Tarih;Tutar\r\n01.11.2026;5\r\n",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b["error"]["message"], "Gerekli sütun eksik: Kategori");
|
||||
let (st, _) = app.post_text("/import/entries?commit=false", &s, "").await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let (st, _) = app.post_text("/import/bilinmeyen", &s, "a\r\n").await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Başka hane yalnızca kendi verisini dışa aktarır
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, _, csv) = app.get_text("/export/entries.csv", &other).await;
|
||||
assert_eq!(csv.lines().count(), 1, "yalnızca başlık");
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_roundtrip() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-11-05" }),
|
||||
)
|
||||
.await;
|
||||
let (_, _, csv) = app.get_text("/export/plan-items.csv", &s).await;
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Dönem başlangıcı;Dönem;Tür;Ad;Tutar;Tarih\r\n01.11.2026;Kasım 2026;Gelir;Maaş;45000,00;\r\n01.11.2026;Kasım 2026;Sabit gider;Kira;15000,00;05.11.2026\r\n"
|
||||
);
|
||||
|
||||
// Başka hane: aynı dönemi açıp içe aktarır
|
||||
let other = app.register("Diger", None).await;
|
||||
let onov = open(&app, &other, "2026-11-01", "2026-12-01").await;
|
||||
let with_errors = format!(
|
||||
"{csv}01.11.2026;Kasım 2026;Prim;Bonus;100,00;\r\n01.01.2026;;Gelir;Eski;1,00;\r\n"
|
||||
);
|
||||
let r = import(&app, &other, "plan-items", &with_errors, true).await;
|
||||
assert_eq!(r["imported"], 2);
|
||||
assert_eq!(
|
||||
errors(&r),
|
||||
vec![
|
||||
(4, "Tür \"Gelir\" ya da \"Sabit gider\" olmalı: Prim".into()),
|
||||
(5, "Bu tarih için dönem yok".into()),
|
||||
]
|
||||
);
|
||||
let (_, items) = app
|
||||
.get(&format!("/periods/{onov}/plan-items"), &other)
|
||||
.await;
|
||||
assert_eq!(items.as_array().unwrap().len(), 2);
|
||||
// Tekrar → atlanır
|
||||
let r = import(&app, &other, "plan-items", &csv, true).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["imported"].as_u64()),
|
||||
(Some(2), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_transactions_in_date_order() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
// Satış dosyada alımdan önce ama tarihi sonra → geçerli; fazla satış → hata
|
||||
let file = "Tarih;Varlık kodu;İşlem;Miktar;Birim fiyat;Tutar;Not\r\n\
|
||||
10.11.2026;XAU_GRAM;Satım;4;;26.000;\r\n\
|
||||
01.11.2026;XAU_GRAM;Alım;10;6.300;63.000;Kuyumcu\r\n\
|
||||
12.11.2026;XAU_GRAM;Satış;7;;45.000;\r\n\
|
||||
02.11.2026;TRY;Alım;;;5.000;\r\n\
|
||||
02.11.2026;BTC;Alım;1;;1;\r\n\
|
||||
03.11.2026;USD;Alım;abc;;100;\r\n";
|
||||
let dry = import(&app, &s, "asset-transactions", file, false).await;
|
||||
assert_eq!(dry["valid"], 3);
|
||||
assert_eq!(
|
||||
errors(&dry),
|
||||
vec![
|
||||
(4, "Satış miktarı o tarihte eldeki miktarı aşıyor".into()),
|
||||
(6, "Varlık bulunamadı: BTC".into()),
|
||||
(7, "Miktar geçersiz: abc".into()),
|
||||
]
|
||||
);
|
||||
let r = import(&app, &s, "asset-transactions", file, true).await;
|
||||
assert_eq!(r["imported"], 3);
|
||||
let (_, assets) = app.get("/assets", &s).await;
|
||||
let gram = assets
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "XAU_GRAM")
|
||||
.unwrap();
|
||||
assert_eq!(gram["quantity_e6"], 6_000_000);
|
||||
assert_eq!(gram["realized_pl"], 80_000);
|
||||
let tl = assets
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "TRY")
|
||||
.unwrap();
|
||||
assert_eq!(tl["cost"], 500_000);
|
||||
|
||||
// Dışa aktar → geri yükle: hepsi tekrar
|
||||
let (_, _, csv) = app.get_text("/export/asset-transactions.csv", &s).await;
|
||||
assert!(
|
||||
csv.contains("01.11.2026;XAU_GRAM;Gram Altın;Alım;10;6300;63000,00;Kuyumcu"),
|
||||
"{csv}"
|
||||
);
|
||||
let r = import(&app, &s, "asset-transactions", &csv, false).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["valid"].as_u64()),
|
||||
(Some(3), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user