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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user