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>
126 lines
4.9 KiB
C#
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;
|
|
}
|
|
}
|