Add SharePoint manual source handling and finance status

This commit is contained in:
2026-05-11 08:43:52 +02:00
parent 57cb09bc50
commit 819a023163
16 changed files with 983 additions and 28 deletions
@@ -16,13 +16,14 @@ builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliati
var app = builder.Build();
app.MapGet("/", () => Results.Redirect("/finance"));
app.MapGet("/finance", async (IFinanceReconciliationService finance) =>
app.MapGet("/finance", async (IFinanceReconciliationService finance, IDbContextFactory<AppDbContext> dbFactory) =>
{
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath());
var spainCsv = LoadSpainSalesCsvProbe(ResolveSpainSalesCsvPath());
var germanySample = LoadGermanyExcelProbe(ResolveGermanySamplePath());
return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample), "text/html; charset=utf-8");
var coverage = await LoadSiteCoverageAsync(dbFactory, 2025);
return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample, coverage), "text/html; charset=utf-8");
});
app.Run();
@@ -215,6 +216,79 @@ static GermanyExcelProbe? LoadGermanyExcelProbe(string? path)
};
}
static async Task<List<SiteCoverageRow>> LoadSiteCoverageAsync(IDbContextFactory<AppDbContext> dbFactory, int year)
{
await using var db = await dbFactory.CreateDbContextAsync();
var sites = await db.Sites
.AsNoTracking()
.OrderBy(s => s.Land)
.ThenBy(s => s.TSC)
.Select(s => new
{
s.Id,
s.Land,
s.TSC,
s.SourceSystem,
s.ManualImportFilePath,
s.IsActive
})
.ToListAsync();
var sourceSystems = await db.SourceSystemDefinitions
.AsNoTracking()
.ToDictionaryAsync(s => s.Code, StringComparer.OrdinalIgnoreCase);
var centralBaseRows = await db.CentralSalesRecords
.AsNoTracking()
.Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Select(r => new
{
r.SiteId,
r.SalesPriceValue,
Date = r.InvoiceDate ?? r.ExtractionDate,
Currency = string.IsNullOrWhiteSpace(r.CompanyCurrency) ? r.SalesCurrency : r.CompanyCurrency
})
.ToListAsync();
var centralRows = centralBaseRows
.GroupBy(r => r.SiteId)
.ToDictionary(g => g.Key, g => new
{
Rows = g.Count(),
Sales = g.Sum(r => r.SalesPriceValue),
MinDate = g.Min(r => r.Date),
MaxDate = g.Max(r => r.Date),
Currencies = g.Select(r => r.Currency).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
});
var latestLogs = await db.ExportLogs
.AsNoTracking()
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Id).First())
.ToDictionaryAsync(l => l.SiteId);
return sites.Select(site =>
{
sourceSystems.TryGetValue(site.SourceSystem, out var sourceSystem);
centralRows.TryGetValue(site.Id, out var central);
latestLogs.TryGetValue(site.Id, out var latestLog);
return new SiteCoverageRow
{
Land = site.Land,
Tsc = site.TSC,
SourceSystem = site.SourceSystem,
SourceDisplayName = sourceSystem?.DisplayName ?? site.SourceSystem,
ConnectionKind = sourceSystem?.ConnectionKind ?? string.Empty,
IsActive = site.IsActive,
ManualImportPath = site.ManualImportFilePath,
RowCount = central?.Rows ?? 0,
SalesPriceValue = central?.Sales,
MinDate = central?.MinDate,
MaxDate = central?.MaxDate,
Currencies = central is null ? string.Empty : string.Join(", ", central.Currencies.Where(x => !string.IsNullOrWhiteSpace(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase)),
LastExportStatus = latestLog?.Status ?? string.Empty,
LastExportAt = latestLog?.Timestamp,
LastExportError = latestLog?.ErrorMessage ?? string.Empty
};
}).ToList();
}
static decimal ReadProbeDecimal(IXLCell cell)
{
if (cell.TryGetValue<decimal>(out var decimalValue))
@@ -336,7 +410,8 @@ static string BuildPage(
string databasePath,
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences,
SpainSalesCsvProbe? spainCsv,
GermanyExcelProbe? germanySample)
GermanyExcelProbe? germanySample,
IReadOnlyList<SiteCoverageRow> coverage)
{
var generatedAt = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH"));
var okCount = rows.Count(r => r.Status == "OK");
@@ -345,6 +420,7 @@ static string BuildPage(
var excelCount = excelReferences.Count;
var executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample);
var detailRows = BuildDetailRows(rows, excelReferences, spainCsv);
var coverageRows = BuildCoverageRows(coverage);
var spainCsvSection = BuildSpainCsvSection(spainCsv);
var germanySampleSection = BuildGermanySampleSection(germanySample, excelReferences);
@@ -540,6 +616,7 @@ static string BuildPage(
<nav aria-label="Finance Probe Navigation">
<a href="#briefing">Meeting Ampel</a>
<a href="#all-sites">Detail alle Laender</a>
<a href="#coverage">Datenabdeckung</a>
<a href="#germany-sample">Germany Excel</a>
<a href="#spain-csv">Spain CSV</a>
</nav>
@@ -551,6 +628,7 @@ static string BuildPage(
<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>
<div class="metric"><strong>{{coverage.Count}}</strong><span>Konfigurierte Standorte</span></div>
</section>
<div id="all-sites" class="table-wrap">
<table>
@@ -579,6 +657,7 @@ static string BuildPage(
</tbody>
</table>
</div>
{{coverageRows}}
{{germanySampleSection}}
{{spainCsvSection}}
</main>
@@ -587,6 +666,85 @@ static string BuildPage(
""";
}
static string BuildCoverageRows(IReadOnlyList<SiteCoverageRow> coverage)
{
if (coverage.Count == 0)
return string.Empty;
var rows = string.Join(Environment.NewLine, coverage.Select(row =>
{
var sourceDetail = row.ConnectionKind switch
{
"MANUAL_EXCEL" when !string.IsNullOrWhiteSpace(row.ManualImportPath) => row.ManualImportPath,
"MANUAL_EXCEL" => "Kein Manual-Dateipfad hinterlegt",
_ => row.SourceDisplayName
};
var period = row.RowCount == 0
? "-"
: $"{row.MinDate:dd.MM.yyyy} - {row.MaxDate:dd.MM.yyyy}";
var lastExport = row.LastExportAt.HasValue
? $"{row.LastExportAt:dd.MM.yyyy HH:mm} / {row.LastExportStatus}"
: "-";
var issue = BuildCoverageIssue(row);
return $$"""
<tr>
<td><strong>{{Html(row.Land)}}</strong><div class="small">{{Html(row.Tsc)}}</div></td>
<td>{{Html(row.SourceSystem)}}<div class="small">{{Html(row.ConnectionKind)}}</div></td>
<td class="wrap">{{Html(sourceDetail)}}</td>
<td>{{(row.IsActive ? "Ja" : "Nein")}}</td>
<td class="num">{{row.RowCount}}</td>
<td class="num">{{Amount(row.SalesPriceValue)}}</td>
<td>{{Html(row.Currencies)}}</td>
<td>{{Html(period)}}</td>
<td>{{Html(lastExport)}}</td>
<td class="wrap">{{Html(issue)}}</td>
</tr>
""";
}));
return $$"""
<section id="coverage" style="margin-top:18px;">
<h2 style="font-size:18px;margin:0 0 8px;">Datenabdeckung je Standort</h2>
<p class="briefing-note">Diese Tabelle zeigt, welche Standorte in der App konfiguriert sind, welche Quelle sie nutzen und ob fuer 2025 bereits Daten in `CentralSalesRecords` liegen.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Standort</th>
<th>System</th>
<th>Quelle / Pfad</th>
<th>Aktiv</th>
<th class="num">Zeilen 2025</th>
<th class="num">SalesPriceValue</th>
<th>Waehrung</th>
<th>Periode</th>
<th>Letzter Export</th>
<th>Hinweis</th>
</tr>
</thead>
<tbody>{{rows}}</tbody>
</table>
</div>
</section>
""";
}
static string BuildCoverageIssue(SiteCoverageRow row)
{
if (!row.IsActive)
return "Standort ist deaktiviert.";
if (row.ConnectionKind == "MANUAL_EXCEL" && string.IsNullOrWhiteSpace(row.ManualImportPath))
return "Manual Excel/CSV-Pfad fehlt.";
if (!string.IsNullOrWhiteSpace(row.LastExportError))
return row.LastExportError;
if (row.RowCount == 0)
return "Keine 2025-Daten in CentralSalesRecords. Export pruefen.";
if (!string.IsNullOrWhiteSpace(row.LastExportStatus) && !row.LastExportStatus.Equals("OK", StringComparison.OrdinalIgnoreCase))
return $"Letzter Exportstatus: {row.LastExportStatus}.";
return "Daten vorhanden.";
}
static string BuildDetailRows(
IReadOnlyList<NetSalesReferenceRow> rows,
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences,
@@ -989,3 +1147,22 @@ sealed class GermanyExcelProbe
public decimal SalesPriceValueIn2025 { get; set; }
public string Currencies { get; set; } = string.Empty;
}
sealed class SiteCoverageRow
{
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceSystem { get; set; } = string.Empty;
public string SourceDisplayName { get; set; } = string.Empty;
public string ConnectionKind { get; set; } = string.Empty;
public bool IsActive { get; set; }
public string ManualImportPath { get; set; } = string.Empty;
public int RowCount { get; set; }
public decimal? SalesPriceValue { get; set; }
public DateTime? MinDate { get; set; }
public DateTime? MaxDate { get; set; }
public string Currencies { get; set; } = string.Empty;
public string LastExportStatus { get; set; } = string.Empty;
public DateTime? LastExportAt { get; set; }
public string LastExportError { get; set; } = string.Empty;
}