Add finance reconciliation probe

This commit is contained in:
2026-05-04 09:32:50 +02:00
parent 4a1561d85f
commit 15dec06f31
7 changed files with 715 additions and 2 deletions
+1
View File
@@ -74,6 +74,7 @@ builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportServ
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
@@ -12,10 +12,14 @@ public interface IDashboardPageService
public sealed class DashboardPageService : IDashboardPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IFinanceReconciliationService _financeReconciliationService;
public DashboardPageService(IDbContextFactory<AppDbContext> dbFactory)
public DashboardPageService(
IDbContextFactory<AppDbContext> dbFactory,
IFinanceReconciliationService financeReconciliationService)
{
_dbFactory = dbFactory;
_financeReconciliationService = financeReconciliationService;
}
public async Task<DashboardPageState> LoadAsync()
@@ -65,7 +69,8 @@ public sealed class DashboardPageService : IDashboardPageService
return new DashboardPageState
{
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<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
public List<NetSalesReferenceRow> NetSalesReferenceRows { get; set; } = [];
}
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>
<Compile Remove="TrafagSalesExporter.Tests\**\*.cs" />
<Compile Remove="Tools\**\*.cs" />
<Content Remove="TrafagSalesExporter.Tests\**\*" />
<Content Remove="Tools\**\*" />
<EmbeddedResource Remove="TrafagSalesExporter.Tests\**\*" />
<EmbeddedResource Remove="Tools\**\*" />
<None Remove="TrafagSalesExporter.Tests\**\*" />
<None Remove="Tools\**\*" />
</ItemGroup>
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
@@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter", "Traf
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter.Tests", "TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj", "{A72D1AFB-E49E-4920-B783-EFFE1132435D}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{D7259652-1433-4EE4-A30C-DEFA623E7C10} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DC174EA0-ECCB-4957-9D97-E7ABED992867}
EndGlobalSection