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<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
|
||||
|
||||
Reference in New Issue
Block a user