Add finance reconciliation probe
This commit is contained in:
@@ -74,6 +74,7 @@ builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportServ
|
|||||||
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||||
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
||||||
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
||||||
|
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
|
||||||
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
|
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
|
||||||
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
|
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
|
||||||
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
|
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ public interface IDashboardPageService
|
|||||||
public sealed class DashboardPageService : IDashboardPageService
|
public sealed class DashboardPageService : IDashboardPageService
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly IFinanceReconciliationService _financeReconciliationService;
|
||||||
|
|
||||||
public DashboardPageService(IDbContextFactory<AppDbContext> dbFactory)
|
public DashboardPageService(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
IFinanceReconciliationService financeReconciliationService)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
_financeReconciliationService = financeReconciliationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardPageState> LoadAsync()
|
public async Task<DashboardPageState> LoadAsync()
|
||||||
@@ -65,7 +69,8 @@ public sealed class DashboardPageService : IDashboardPageService
|
|||||||
return new DashboardPageState
|
return new DashboardPageState
|
||||||
{
|
{
|
||||||
DashboardRows = rows,
|
DashboardRows = rows,
|
||||||
ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new())
|
ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new()),
|
||||||
|
NetSalesReferenceRows = await _financeReconciliationService.BuildNetSalesReferenceRowsAsync(2025)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +119,7 @@ public sealed class DashboardPageState
|
|||||||
{
|
{
|
||||||
public List<DashboardRow> DashboardRows { get; set; } = [];
|
public List<DashboardRow> DashboardRows { get; set; } = [];
|
||||||
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
|
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
|
||||||
|
public List<NetSalesReferenceRow> NetSalesReferenceRows { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardRow
|
public sealed class DashboardRow
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IFinanceReconciliationService
|
||||||
|
{
|
||||||
|
Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<NetSalesReferenceDefinition> NetSalesReferences =
|
||||||
|
[
|
||||||
|
new("AT", "Trafag AT", 3443863m, null),
|
||||||
|
new("CH", "Trafag CH", null, null),
|
||||||
|
new("CN", "Trafag CN", null, null),
|
||||||
|
new("CZ", "Trafag CZ", 95458782m, null),
|
||||||
|
new("DE", "Trafag DE", 3635923m, null),
|
||||||
|
new("ES", "Trafag ES", 3102334m, null),
|
||||||
|
new("FR", "Trafag FR", 1450582m, 1471218m),
|
||||||
|
new("GFS", "Trafag GfS", 6495513m, null),
|
||||||
|
new("IN", "Trafag IN", 747341702m, 750936591m),
|
||||||
|
new("IT", "Trafag IT", 7669840m, null),
|
||||||
|
new("JP", "Trafag JP", 187739814m, null),
|
||||||
|
new("MS", "Trafag MS", 1850199m, null),
|
||||||
|
new("MSA", "Trafag MSA", 1445258m, null),
|
||||||
|
new("PL", "Trafag PL Poltraf", 11279297m, null),
|
||||||
|
new("RU", "Rrafag RU", null, null),
|
||||||
|
new("UK", "Trafag UK", 3538972m, 3749865m),
|
||||||
|
new("US", "Traga US", 3896728m, 3749865m)
|
||||||
|
];
|
||||||
|
|
||||||
|
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var centralRows = await db.CentralSalesRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year)
|
||||||
|
.Select(r => new NetSalesActualSourceRow(
|
||||||
|
r.Land,
|
||||||
|
r.Tsc,
|
||||||
|
r.DocumentEntry,
|
||||||
|
r.InvoiceNumber,
|
||||||
|
r.DocumentType,
|
||||||
|
r.CustomerNumber,
|
||||||
|
r.CustomerName,
|
||||||
|
r.SalesCurrency,
|
||||||
|
r.DocumentCurrency,
|
||||||
|
r.CompanyCurrency,
|
||||||
|
r.SalesPriceValue,
|
||||||
|
r.DocumentTotalForeignCurrency,
|
||||||
|
r.DocumentTotalLocalCurrency,
|
||||||
|
r.VatSumForeignCurrency,
|
||||||
|
r.VatSumLocalCurrency))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var groupedActuals = centralRows
|
||||||
|
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
BuildNetSalesActual,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var activeSiteKeys = (await db.Sites
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.IsActive)
|
||||||
|
.Select(s => new { s.Land, s.TSC })
|
||||||
|
.ToListAsync())
|
||||||
|
.Select(s => ResolveReferenceKey(s.Land, s.TSC))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return NetSalesReferences
|
||||||
|
.Where(reference => activeSiteKeys.Contains(reference.Key) || groupedActuals.ContainsKey(reference.Key))
|
||||||
|
.Select(reference =>
|
||||||
|
{
|
||||||
|
groupedActuals.TryGetValue(reference.Key, out var actual);
|
||||||
|
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
|
||||||
|
var selected = actual is null || !referenceValue.HasValue
|
||||||
|
? actual?.Candidates.FirstOrDefault()
|
||||||
|
: actual.Candidates
|
||||||
|
.OrderBy(candidate => Math.Abs(candidate.Value - referenceValue.Value))
|
||||||
|
.FirstOrDefault();
|
||||||
|
var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value;
|
||||||
|
var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue
|
||||||
|
? (decimal?)null
|
||||||
|
: selected.ValueExcludingIntercompany - referenceValue.Value;
|
||||||
|
|
||||||
|
return new NetSalesReferenceRow
|
||||||
|
{
|
||||||
|
Key = reference.Key,
|
||||||
|
Label = reference.Label,
|
||||||
|
ActualValue = selected?.Value,
|
||||||
|
IntercompanyDeduction = selected?.IntercompanyValue,
|
||||||
|
ActualValueExcludingIntercompany = selected?.ValueExcludingIntercompany,
|
||||||
|
ReferenceValue = referenceValue,
|
||||||
|
Difference = difference,
|
||||||
|
DifferenceExcludingIntercompany = intercompanyAdjustedDifference,
|
||||||
|
RowCount = actual?.RowCount ?? 0,
|
||||||
|
Currencies = actual?.Currencies ?? string.Empty,
|
||||||
|
ValueField = selected?.Label ?? string.Empty,
|
||||||
|
ActualCurrency = selected?.Currency ?? string.Empty,
|
||||||
|
ReferenceSource = reference.PowerBiValue.HasValue ? "Power BI" : "LC",
|
||||||
|
ReferenceCurrency = reference.PowerBiValue.HasValue ? "Power BI Original" : "LC",
|
||||||
|
Status = BuildReferenceStatus(difference),
|
||||||
|
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
||||||
|
{
|
||||||
|
Key = candidate.Key,
|
||||||
|
Label = candidate.Label,
|
||||||
|
Currency = candidate.Currency,
|
||||||
|
Value = candidate.Value,
|
||||||
|
IntercompanyValue = candidate.IntercompanyValue,
|
||||||
|
ValueExcludingIntercompany = candidate.ValueExcludingIntercompany,
|
||||||
|
Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null,
|
||||||
|
DifferenceExcludingIntercompany = referenceValue.HasValue
|
||||||
|
? candidate.ValueExcludingIntercompany - referenceValue.Value
|
||||||
|
: null
|
||||||
|
}).ToList() ?? []
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NetSalesActual BuildNetSalesActual(IEnumerable<NetSalesActualSourceRow> rows)
|
||||||
|
{
|
||||||
|
var rowList = rows.ToList();
|
||||||
|
var documentRows = rowList
|
||||||
|
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var candidates = new List<NetSalesCandidate>
|
||||||
|
{
|
||||||
|
new(
|
||||||
|
"SalesPriceValue",
|
||||||
|
"Sales Price/Value",
|
||||||
|
ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)),
|
||||||
|
rowList.Sum(row => row.SalesPriceValue),
|
||||||
|
rowList.Where(IsLikelyIntercompanyCustomer).Sum(row => row.SalesPriceValue))
|
||||||
|
};
|
||||||
|
|
||||||
|
var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency);
|
||||||
|
if (netDocumentForeignCurrency != 0m)
|
||||||
|
candidates.Add(new(
|
||||||
|
"NetDocumentForeignCurrency",
|
||||||
|
"DocTotalFC - VatSumFC",
|
||||||
|
ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)),
|
||||||
|
netDocumentForeignCurrency,
|
||||||
|
documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency)));
|
||||||
|
|
||||||
|
var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
|
||||||
|
if (netDocumentLocalCurrency != 0m)
|
||||||
|
candidates.Add(new(
|
||||||
|
"NetDocumentLocalCurrency",
|
||||||
|
"DocTotal - VatSum",
|
||||||
|
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
|
||||||
|
netDocumentLocalCurrency,
|
||||||
|
documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)));
|
||||||
|
|
||||||
|
return new NetSalesActual
|
||||||
|
{
|
||||||
|
RowCount = rowList.Count,
|
||||||
|
Currencies = string.Join(", ", rowList.Select(row => row.SalesCurrency)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)),
|
||||||
|
Candidates = candidates
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCurrencyLabel(IEnumerable<string> currencies)
|
||||||
|
{
|
||||||
|
var distinct = currencies
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Select(x => x.Trim().ToUpperInvariant())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return distinct.Count == 0 ? "-" : string.Join(", ", distinct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDocumentKey(string tsc, string documentType, int documentEntry, string invoiceNumber)
|
||||||
|
=> documentEntry > 0
|
||||||
|
? $"{tsc}|{documentType}|{documentEntry}"
|
||||||
|
: $"{tsc}|{documentType}|{invoiceNumber}";
|
||||||
|
|
||||||
|
private static bool IsLikelyIntercompanyCustomer(NetSalesActualSourceRow row)
|
||||||
|
{
|
||||||
|
var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty;
|
||||||
|
var customerName = row.CustomerName?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (row.Tsc.Equals("TRIT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
customerNumber.Equals("C_CH01_0302179", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
customerName.Equals("TRAFAG ITALIA S.R.L.", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
customerName.Equals("Trafag AG", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildReferenceStatus(decimal? difference)
|
||||||
|
{
|
||||||
|
if (!difference.HasValue)
|
||||||
|
return "Keine Daten";
|
||||||
|
|
||||||
|
return Math.Abs(difference.Value) <= 1m ? "OK" : "Pruefen";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveReferenceKey(string land, string tsc)
|
||||||
|
{
|
||||||
|
var normalizedLand = (land ?? string.Empty).Trim().ToUpperInvariant();
|
||||||
|
var normalizedTsc = (tsc ?? string.Empty).Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
if (normalizedLand.Contains("FRANK") || normalizedTsc.Contains("FR")) return "FR";
|
||||||
|
if (normalizedLand.Contains("IND") || normalizedTsc.Contains("IN")) return "IN";
|
||||||
|
if (normalizedLand.Contains("ITAL") || normalizedTsc.Contains("IT")) return "IT";
|
||||||
|
if (normalizedLand.Contains("ENGL") || normalizedLand.Contains("KINGDOM") || normalizedTsc.Contains("UK") || normalizedTsc.Contains("GB")) return "UK";
|
||||||
|
if (normalizedLand.Contains("USA") || normalizedLand.Contains("UNITED STATES") || normalizedTsc.Contains("US")) return "US";
|
||||||
|
if (normalizedLand.Contains("DEUT") || normalizedTsc.Contains("DE")) return "DE";
|
||||||
|
if (normalizedLand.Contains("SPAN") || normalizedTsc is "SE" or "ES") return "ES";
|
||||||
|
if (normalizedLand.Contains("SCHWE") || normalizedTsc.Contains("CH")) return "CH";
|
||||||
|
|
||||||
|
return normalizedTsc.Replace("TR", string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NetSalesReferenceRow
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public decimal? ActualValue { get; set; }
|
||||||
|
public decimal? IntercompanyDeduction { get; set; }
|
||||||
|
public decimal? ActualValueExcludingIntercompany { get; set; }
|
||||||
|
public decimal? ReferenceValue { get; set; }
|
||||||
|
public decimal? Difference { get; set; }
|
||||||
|
public decimal? DifferenceExcludingIntercompany { get; set; }
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public string Currencies { get; set; } = string.Empty;
|
||||||
|
public string ValueField { get; set; } = string.Empty;
|
||||||
|
public string ActualCurrency { get; set; } = string.Empty;
|
||||||
|
public string ReferenceSource { get; set; } = string.Empty;
|
||||||
|
public string ReferenceCurrency { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public List<NetSalesCandidateRow> Candidates { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NetSalesCandidateRow
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public decimal IntercompanyValue { get; set; }
|
||||||
|
public decimal ValueExcludingIntercompany { get; set; }
|
||||||
|
public decimal? Difference { get; set; }
|
||||||
|
public decimal? DifferenceExcludingIntercompany { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record NetSalesReferenceDefinition(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
decimal? LocalCurrencyValue,
|
||||||
|
decimal? PowerBiValue);
|
||||||
|
|
||||||
|
internal sealed class NetSalesActual
|
||||||
|
{
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public string Currencies { get; set; } = string.Empty;
|
||||||
|
public List<NetSalesCandidate> Candidates { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record NetSalesActualSourceRow(
|
||||||
|
string Land,
|
||||||
|
string Tsc,
|
||||||
|
int DocumentEntry,
|
||||||
|
string InvoiceNumber,
|
||||||
|
string DocumentType,
|
||||||
|
string CustomerNumber,
|
||||||
|
string CustomerName,
|
||||||
|
string SalesCurrency,
|
||||||
|
string DocumentCurrency,
|
||||||
|
string CompanyCurrency,
|
||||||
|
decimal SalesPriceValue,
|
||||||
|
decimal DocumentTotalForeignCurrency,
|
||||||
|
decimal DocumentTotalLocalCurrency,
|
||||||
|
decimal VatSumForeignCurrency,
|
||||||
|
decimal VatSumLocalCurrency);
|
||||||
|
|
||||||
|
internal sealed record NetSalesCandidate(string Key, string Label, string Currency, decimal Value, decimal IntercompanyValue)
|
||||||
|
{
|
||||||
|
public decimal ValueExcludingIntercompany => Value - IntercompanyValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\TrafagSalesExporter.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var databasePath = ResolveDatabasePath(builder.Configuration["FinanceProbe:DatabasePath"]);
|
||||||
|
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
|
options.UseSqlite($"Data Source={databasePath};Default Timeout=60"));
|
||||||
|
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapGet("/", () => Results.Redirect("/finance"));
|
||||||
|
app.MapGet("/finance", async (IFinanceReconciliationService finance) =>
|
||||||
|
{
|
||||||
|
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
|
||||||
|
var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath());
|
||||||
|
return Results.Content(BuildPage(rows, databasePath, excelReferences), "text/html; charset=utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
static string ResolveDatabasePath(string? configuredPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
return Path.GetFullPath(configuredPath);
|
||||||
|
|
||||||
|
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(start);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, "trafag_exporter.db");
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(Directory.GetCurrentDirectory(), "trafag_exporter.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? ResolveCheckedExcelPath()
|
||||||
|
{
|
||||||
|
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(start);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, "check.xlsx");
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, CheckedExcelReference> LoadCheckedExcelReferences(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
using var workbook = new XLWorkbook(path);
|
||||||
|
var worksheet = workbook.Worksheets.First();
|
||||||
|
var references = new Dictionary<string, CheckedExcelReference>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var row in worksheet.RowsUsed().Skip(1))
|
||||||
|
{
|
||||||
|
var label = row.Cell(1).GetString().Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(label) || label.Equals("Total TR Gruppe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
references[label] = new CheckedExcelReference
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
LocalCurrencyValue = ReadNullableDecimal(row.Cell(2)),
|
||||||
|
ChfValue = ReadNullableDecimal(row.Cell(3)),
|
||||||
|
PowerBiValue = ReadNullableDecimal(row.Cell(5)),
|
||||||
|
Status = row.Cell(6).GetString().Trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
static decimal? ReadNullableDecimal(IXLCell cell)
|
||||||
|
{
|
||||||
|
if (cell.IsEmpty())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return cell.TryGetValue<decimal>(out var value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildPage(
|
||||||
|
IReadOnlyList<NetSalesReferenceRow> rows,
|
||||||
|
string databasePath,
|
||||||
|
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences)
|
||||||
|
{
|
||||||
|
var generatedAt = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH"));
|
||||||
|
var okCount = rows.Count(r => r.Status == "OK");
|
||||||
|
var checkCount = rows.Count(r => r.Status == "Pruefen");
|
||||||
|
var missingCount = rows.Count(r => r.Status == "Keine Daten");
|
||||||
|
var excelCount = excelReferences.Count;
|
||||||
|
|
||||||
|
return $$"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Finance Probe</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #17202a;
|
||||||
|
--muted: #667085;
|
||||||
|
--line: #d8dee8;
|
||||||
|
--ok: #147a3d;
|
||||||
|
--check: #a15c00;
|
||||||
|
--missing: #667085;
|
||||||
|
--head: #22324a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 18px 24px 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
main { padding: 18px 24px 28px; }
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.metric span { color: var(--muted); }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
background: var(--head);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 10px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tbody tr:nth-child(even) { background: #fafbfc; }
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 78px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.OK { background: var(--ok); }
|
||||||
|
.Pruefen { background: var(--check); }
|
||||||
|
.KeineDaten { background: var(--missing); }
|
||||||
|
details { min-width: 360px; }
|
||||||
|
summary { cursor: pointer; color: #234d7d; }
|
||||||
|
.candidate-table {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.candidate-table th {
|
||||||
|
background: #eef2f7;
|
||||||
|
color: var(--text);
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.small { color: var(--muted); font-size: 12px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
main, header { padding-left: 12px; padding-right: 12px; }
|
||||||
|
.summary { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Finance Probe - Net Sales Actuals 2025</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Vergleich gegen geprüfte Referenzwerte aus check.xlsx / Power BI Stand 29.04.2026</span>
|
||||||
|
<span>DB: {{Html(databasePath)}}</span>
|
||||||
|
<span>Excel-Referenzen gelesen: {{excelCount}}</span>
|
||||||
|
<span>Aktualisiert: {{Html(generatedAt)}}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="summary">
|
||||||
|
<div class="metric"><strong>{{rows.Count}}</strong><span>Standorte</span></div>
|
||||||
|
<div class="metric"><strong>{{okCount}}</strong><span>OK</span></div>
|
||||||
|
<div class="metric"><strong>{{checkCount}}</strong><span>Pruefen</span></div>
|
||||||
|
<div class="metric"><strong>{{missingCount}}</strong><span>Keine Daten</span></div>
|
||||||
|
</section>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Firma</th>
|
||||||
|
<th>Gewaehlte Abgrenzung</th>
|
||||||
|
<th>Ist-Waehrung</th>
|
||||||
|
<th class="num">Ist 2025</th>
|
||||||
|
<th>Referenz-Waehrung</th>
|
||||||
|
<th class="num">Referenz</th>
|
||||||
|
<th class="num">Excel LC</th>
|
||||||
|
<th class="num">Excel CHF</th>
|
||||||
|
<th class="num">Excel Power BI</th>
|
||||||
|
<th>Excel Status</th>
|
||||||
|
<th class="num">Differenz</th>
|
||||||
|
<th class="num">Ohne IC Diff.</th>
|
||||||
|
<th>Waehrung</th>
|
||||||
|
<th class="num">Zeilen</th>
|
||||||
|
<th>Varianten</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{string.Join(Environment.NewLine, rows.Select(row => BuildRow(row, excelReferences)))}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary<string, CheckedExcelReference> excelReferences)
|
||||||
|
{
|
||||||
|
var statusClass = row.Status.Replace(" ", string.Empty);
|
||||||
|
excelReferences.TryGetValue(row.Label, out var excelReference);
|
||||||
|
|
||||||
|
return $$"""
|
||||||
|
<tr>
|
||||||
|
<td><span class="status {{Html(statusClass)}}">{{Html(row.Status)}}</span></td>
|
||||||
|
<td><strong>{{Html(row.Label)}}</strong><div class="small">{{Html(row.Key)}} / {{Html(row.ReferenceSource)}}</div></td>
|
||||||
|
<td>{{Html(row.ValueField)}}</td>
|
||||||
|
<td>{{Html(row.ActualCurrency)}}</td>
|
||||||
|
<td class="num">{{Amount(row.ActualValue)}}</td>
|
||||||
|
<td>{{Html(row.ReferenceCurrency)}}</td>
|
||||||
|
<td class="num">{{Amount(row.ReferenceValue)}}</td>
|
||||||
|
<td class="num">{{Amount(excelReference?.LocalCurrencyValue)}}</td>
|
||||||
|
<td class="num">{{Amount(excelReference?.ChfValue)}}</td>
|
||||||
|
<td class="num">{{Amount(excelReference?.PowerBiValue)}}</td>
|
||||||
|
<td>{{Html(excelReference?.Status)}}</td>
|
||||||
|
<td class="num">{{Amount(row.Difference)}}</td>
|
||||||
|
<td class="num">{{Amount(row.DifferenceExcludingIntercompany)}}</td>
|
||||||
|
<td>{{Html(row.Currencies)}}</td>
|
||||||
|
<td class="num">{{row.RowCount}}</td>
|
||||||
|
<td>{{BuildCandidateDetails(row)}}</td>
|
||||||
|
</tr>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildCandidateDetails(NetSalesReferenceRow row)
|
||||||
|
{
|
||||||
|
if (row.Candidates.Count == 0)
|
||||||
|
return "<span class=\"small\">Keine Varianten</span>";
|
||||||
|
|
||||||
|
var candidateRows = string.Join(Environment.NewLine, row.Candidates.Select(candidate => $$"""
|
||||||
|
<tr>
|
||||||
|
<td>{{Html(candidate.Label)}}</td>
|
||||||
|
<td>{{Html(candidate.Currency)}}</td>
|
||||||
|
<td class="num">{{Amount(candidate.Value)}}</td>
|
||||||
|
<td class="num">{{Amount(candidate.Difference)}}</td>
|
||||||
|
<td class="num">{{Amount(candidate.IntercompanyValue)}}</td>
|
||||||
|
<td class="num">{{Amount(candidate.DifferenceExcludingIntercompany)}}</td>
|
||||||
|
</tr>
|
||||||
|
"""));
|
||||||
|
|
||||||
|
return $$"""
|
||||||
|
<details>
|
||||||
|
<summary>{{row.Candidates.Count}} Varianten anzeigen</summary>
|
||||||
|
<table class="candidate-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Abgrenzung</th>
|
||||||
|
<th>Waehrung</th>
|
||||||
|
<th class="num">Wert</th>
|
||||||
|
<th class="num">Diff.</th>
|
||||||
|
<th class="num">IC</th>
|
||||||
|
<th class="num">Diff. ohne IC</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{{candidateRows}}</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Amount(decimal? value)
|
||||||
|
=> value.HasValue ? value.Value.ToString("#,##0.00", CultureInfo.GetCultureInfo("de-CH")) : "-";
|
||||||
|
|
||||||
|
static string Html(string? value)
|
||||||
|
=> WebUtility.HtmlEncode(value ?? string.Empty);
|
||||||
|
|
||||||
|
sealed class CheckedExcelReference
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public decimal? LocalCurrencyValue { get; set; }
|
||||||
|
public decimal? ChfValue { get; set; }
|
||||||
|
public decimal? PowerBiValue { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -35,9 +35,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="TrafagSalesExporter.Tests\**\*.cs" />
|
<Compile Remove="TrafagSalesExporter.Tests\**\*.cs" />
|
||||||
|
<Compile Remove="Tools\**\*.cs" />
|
||||||
<Content Remove="TrafagSalesExporter.Tests\**\*" />
|
<Content Remove="TrafagSalesExporter.Tests\**\*" />
|
||||||
|
<Content Remove="Tools\**\*" />
|
||||||
<EmbeddedResource Remove="TrafagSalesExporter.Tests\**\*" />
|
<EmbeddedResource Remove="TrafagSalesExporter.Tests\**\*" />
|
||||||
|
<EmbeddedResource Remove="Tools\**\*" />
|
||||||
<None Remove="TrafagSalesExporter.Tests\**\*" />
|
<None Remove="TrafagSalesExporter.Tests\**\*" />
|
||||||
|
<None Remove="Tools\**\*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
|
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter", "Traf
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter.Tests", "TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj", "{A72D1AFB-E49E-4920-B783-EFFE1132435D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter.Tests", "TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj", "{A72D1AFB-E49E-4920-B783-EFFE1132435D}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinanceProbe", "Tools\FinanceProbe\FinanceProbe.csproj", "{D7259652-1433-4EE4-A30C-DEFA623E7C10}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -41,10 +45,25 @@ Global
|
|||||||
{A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x64.Build.0 = Release|Any CPU
|
{A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x86.ActiveCfg = Release|Any CPU
|
{A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x86.Build.0 = Release|Any CPU
|
{A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{D7259652-1433-4EE4-A30C-DEFA623E7C10} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {DC174EA0-ECCB-4957-9D97-E7ABED992867}
|
SolutionGuid = {DC174EA0-ECCB-4957-9D97-E7ABED992867}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
Reference in New Issue
Block a user