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:
2026-11-13 02:21:44 +03:00
parent 33f64a0459
commit 4d764689e2
21 changed files with 1961 additions and 5 deletions
+805
View File
@@ -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)
}