books/backend/Books.Api/AiBookkeeper/AccountMappingService.cs
Nicolaj Hartmann 1f75c5d791 Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:

Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess

Commands & Handlers:
- Full CQRS command structure for all domains

Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories

GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries

Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)

Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00

126 lines
4.9 KiB
C#

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;
}
}