BudgetApp/BudgetApp.Services/UploadService.cs
Martin Svrcina e14e552388 Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:52:43 +01:00

202 lines
7.2 KiB
C#

using BudgetApp.Enums;
using BudgetApp.PublicModels;
using BudgetApp.Storage;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text;
namespace BudgetApp.Services
{
public class UploadService(BudgetContext context) : IUploadService
{
private readonly BudgetContext _context = context ?? throw new ArgumentNullException(nameof(context));
public async Task<BaseResponse> UploadCsv(IFormFile file, CancellationToken cancellationToken = default)
{
await SetUpdateStatusState(UpdateStatusState.Processing);
var response = new BaseResponse();
var errors = new List<string>();
var transactionsToInsert = new List<Transaction>();
var existingIds = await _context.Transactions
.AsNoTracking()
.Select(t => t.TransactionId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var knownTransactionIds = new HashSet<string>(existingIds, StringComparer.OrdinalIgnoreCase);
using (var stream = file.OpenReadStream())
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
{
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var rawLine = await reader.ReadLineAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(rawLine))
{
continue;
}
if (!rawLine.StartsWith("2168", StringComparison.Ordinal))
{
continue; // Non-transaction rows (e.g., headers) are ignored.
}
var cols = rawLine.Split(';');
if (cols.Length <= 23)
{
errors.Add("Skipping row with insufficient number of columns.");
continue;
}
var transactionId = cols[23].Trim('\"', ' ');
if (string.IsNullOrWhiteSpace(transactionId))
{
errors.Add("Skipping row without transaction identifier.");
continue;
}
if (!knownTransactionIds.Add(transactionId))
{
continue; // Already processed or exists in database.
}
if (!DateOnly.TryParseExact(cols[1].Trim('\"', ' '), "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
{
errors.Add($"Skipping row with invalid date value: '{cols[1]}'.");
knownTransactionIds.Remove(transactionId);
continue;
}
if (!TryParseDecimal(cols[2], out var amount))
{
errors.Add($"Skipping row with invalid amount value: '{cols[2]}'.");
knownTransactionIds.Remove(transactionId);
continue;
}
if (!TryParseDecimal(cols[4], out var balance))
{
errors.Add($"Skipping row with invalid balance value: '{cols[4]}'.");
knownTransactionIds.Remove(transactionId);
continue;
}
var transaction = new Transaction
{
Amount = amount,
Balance = balance,
Category = cols[16].Trim('\"', ' '),
Date = date,
Note = cols[15].Trim('\"', ' '),
OperationDescription = string.Join(" | ",
SafeGetColumn(cols, 12),
SafeGetColumn(cols, 13),
SafeGetColumn(cols, 14)).Trim(' ', '|'),
TransactionId = transactionId
};
transactionsToInsert.Add(transaction);
}
}
if (transactionsToInsert.Count > 0)
{
await _context.Transactions.AddRangeAsync(transactionsToInsert, cancellationToken).ConfigureAwait(false);
await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
if (errors.Count > 0)
{
response.Success = false;
response.Error = string.Join(" ", errors.Distinct());
}
await SetUpdateStatusState(UpdateStatusState.Done);
return response;
}
private static string SafeGetColumn(string[] columns, int index)
{
return index >= 0 && index < columns.Length
? columns[index].Trim('\"', ' ')
: string.Empty;
}
private static bool TryParseDecimal(string input, out decimal value)
{
value = 0m;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
var sanitized = NormalizeDecimalValue(input);
return decimal.TryParse(sanitized, NumberStyles.Number | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value);
}
private static string NormalizeDecimalValue(string input)
{
var normalized = input
.Replace("\u00A0", string.Empty) // non-breaking space
.Trim();
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
if (char.IsDigit(ch) || ch is '-' or '+' || ch == ',' || ch == '.')
{
builder.Append(ch);
}
}
var cleaned = builder.ToString();
var lastComma = cleaned.LastIndexOf(',');
var lastDot = cleaned.LastIndexOf('.');
if (lastComma >= 0 && lastDot >= 0)
{
if (lastComma > lastDot)
{
cleaned = cleaned.Replace(".", string.Empty);
cleaned = cleaned.Replace(',', '.');
}
else
{
cleaned = cleaned.Replace(",", string.Empty);
}
}
else if (lastComma >= 0)
{
cleaned = cleaned.Replace(".", string.Empty);
cleaned = cleaned.Replace(',', '.');
}
else
{
cleaned = cleaned.Replace(",", string.Empty);
}
return cleaned;
}
private async Task SetUpdateStatusState(UpdateStatusState state)
{
var updateStatus = await _context.UpdateStatuses
.SingleAsync();
updateStatus.State = state;
await _context.SaveChangesAsync();
}
}
public interface IUploadService
{
Task<BaseResponse> UploadCsv(IFormFile file, CancellationToken cancellationToken = default);
}
}