202 lines
7.2 KiB
C#
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);
|
|
}
|
|
}
|