books/backend/Books.Api/AiBookkeeper/AccountMappingService.cs

127 lines
4.9 KiB
C#
Raw Normal View History

using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Maps standard account numbers to company-specific accounts.
/// Uses the standardAccountNumber field on accounts for matching.
/// </summary>
public class AccountMappingService(
IAccountRepository accountRepository,
ILogger<AccountMappingService> logger) : IAccountMappingService
{
public async Task<AccountReadModelDto?> FindByStandardAccountNumberAsync(
string companyId,
string standardAccountNumber,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(standardAccountNumber))
{
return null;
}
var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
// First, try exact match on standardAccountNumber
var exactMatch = accounts.FirstOrDefault(a =>
a.StandardAccountNumber != null &&
a.StandardAccountNumber.Equals(standardAccountNumber, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
{
logger.LogDebug(
"Found exact match for standard account {StandardAccount}: {AccountNumber} - {AccountName}",
standardAccountNumber, exactMatch.AccountNumber, exactMatch.Name);
return exactMatch;
}
// Try prefix match (standard account numbers can be hierarchical)
var prefixMatch = accounts
.Where(a =>
a.StandardAccountNumber != null &&
standardAccountNumber.StartsWith(a.StandardAccountNumber, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.StandardAccountNumber?.Length ?? 0)
.FirstOrDefault();
if (prefixMatch != null)
{
logger.LogDebug(
"Found prefix match for standard account {StandardAccount}: {AccountNumber} - {AccountName}",
standardAccountNumber, prefixMatch.AccountNumber, prefixMatch.Name);
return prefixMatch;
}
logger.LogDebug("No match found for standard account {StandardAccount}", standardAccountNumber);
return null;
}
public async Task<List<MappedSuggestedLine>> MapSuggestedLinesAsync(
string companyId,
List<SuggestedLine> suggestedLines,
CancellationToken cancellationToken = default)
{
var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
// Index accounts by both StandardAccountNumber and direct AccountNumber
var accountsByStandardNumber = accounts
.Where(a => !string.IsNullOrEmpty(a.StandardAccountNumber))
.GroupBy(a => a.StandardAccountNumber!)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var accountsByNumber = accounts
.ToDictionary(a => a.AccountNumber, a => a, StringComparer.OrdinalIgnoreCase);
var result = new List<MappedSuggestedLine>();
foreach (var line in suggestedLines)
{
AccountReadModelDto? mappedAccount = null;
if (!string.IsNullOrEmpty(line.StandardAccountNumber))
{
// First, try direct account number match (AI service returns company account numbers)
if (accountsByNumber.TryGetValue(line.StandardAccountNumber, out var directMatch))
{
mappedAccount = directMatch;
}
// Then try Erhvervsstyrelsen standard account number match
else if (accountsByStandardNumber.TryGetValue(line.StandardAccountNumber, out var exactMatch))
{
mappedAccount = exactMatch;
}
else
{
// Try prefix match on standard numbers
mappedAccount = accountsByStandardNumber
.Where(kvp => line.StandardAccountNumber.StartsWith(kvp.Key, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(kvp => kvp.Key.Length)
.Select(kvp => kvp.Value)
.FirstOrDefault();
}
}
result.Add(new MappedSuggestedLine
{
Original = line,
MappedAccount = mappedAccount
});
if (mappedAccount != null)
{
logger.LogDebug(
"Mapped standard account {StandardAccount} to {AccountNumber} - {AccountName}",
line.StandardAccountNumber, mappedAccount.AccountNumber, mappedAccount.Name);
}
else
{
logger.LogDebug(
"Could not map standard account {StandardAccount}",
line.StandardAccountNumber);
}
}
return result;
}
}