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
@@ -391,7 +391,7 @@
Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-/CSV-Datei gelesen und in `CentralSalesRecords` übernommen.
</MudAlert>
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-/CSV-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx bzw. .csv."
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade, SharePoint-Dateien und SharePoint-Ordner. Bei Ordnern wird die neueste passende Excel-/CSV-Datei geladen."
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
Disabled="_uploadingManualImport" Class="mb-3">
+70
View File
@@ -2,6 +2,76 @@
Stand: 2026-05-05
## Nachtrag 2026-05-08 Manual Excel/CSV / SharePoint-Ordner
Aktueller Stand fuer manuelle Quellen:
- `MANUAL_EXCEL` ist fachlich Manual Excel/CSV.
- Unterstuetzt werden `.xlsx` und `.csv`; altes `.xls` ist nicht der Zielpfad.
- Lokale Datei als Quelle:
- App liest die Datei.
- App erzeugt eine neue Exportdatei im selben lokalen Ordner.
- SharePoint-Datei als Quelle:
- App laedt die Datei temporaer herunter.
- App erzeugt eine neue Exportdatei und laedt sie in denselben SharePoint-Ordner hoch.
- SharePoint-Ordner als Quelle:
- App waehlt automatisch die neueste passende `.xlsx`/`.csv` fuer den Standort.
- Primaeres Muster: `ddMMyy_TSC.xlsx` oder `ddMMyy_TSC.csv`.
- Fallback: SharePoint `LastModifiedDateTime`.
England / UK:
- Standort `England`, `TSC = TRUK`, `SourceSystem = MANUAL_EXCEL`.
- Quelle ist ein SharePoint-Ordner:
```text
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
```
- Beispielauswahl:
- `010526_TRUK.xlsx` ist neuer als `010426_TRUK.xlsx`.
- Exportdateien werden wieder in `Import/Finance/UK_B1` geschrieben.
- Befund am 2026-05-08: England zeigte lokal faelschlich auf die Deutschland-Alphaplan-Datei; lokale DB wurde korrigiert.
- `DatabaseSeedService` repariert kuenftig einen leeren England/TRUK-Manual-Pfad auf den UK_B1-Ordner.
Spanien / Sage:
- Spanien nutzt `MANUAL_EXCEL` als technischen Importpfad fuer den Sage-Export.
- Die Datei `Spain_Sales_2025.csv` konnte gelesen werden (`4'341` Zeilen).
- Fehler war danach der Exportpfad: die SharePoint-URL wurde als lokaler Dateipfad interpretiert.
- Fix: SharePoint-Manual-Quellen liefern keinen `ReferenceFilePath` mehr, sondern erzeugen eine neue Exportdatei im Quellordner.
Deutschland / Alphaplan:
- Deutschland nutzt `MANUAL_EXCEL` als technischen Importpfad fuer Alphaplan-Excel.
- Grafisches Mapping ist vorhanden.
- Offener Punkt: konkreter Alphaplan-Datei-/SharePoint-Pfad muss im Standort hinterlegt sein, sonst kommt `Standort 'Deutschland' hat keine manuelle Excel-Datei.`
Verifikation:
- Tests `55/55` erfolgreich.
## Nachtrag 2026-05-08 FinanceProbe fuer mehr Laender
FinanceProbe wurde erweitert:
- `FinanceReferences` werden vollstaendig angezeigt, nicht nur bei aktivem Standort oder vorhandenen Ist-Daten.
- Dadurch sind alle Soll-Laender aus der Finance-Konfiguration im Meeting sichtbar.
- Neue Sektion `Datenabdeckung je Standort` zeigt je Standort:
- Quelle/System
- Manual-/SharePoint-Pfad
- Aktivstatus
- Anzahl 2025-Zeilen in `CentralSalesRecords`
- Summe `SalesPriceValue`
- Waehrungen und Datumsbereich
- letzter Exportstatus/Fehler
- CH/AT-Erkennung im Finance-Service wurde geschaerft, damit `ZSCHWEIZ`-Zeilen mit Land `AT` Oesterreich zugeordnet werden koennen.
Wichtig:
- `Keine Daten` bedeutet jetzt nicht zwingend fehlende Referenz, sondern oft: Referenz ist vorhanden, aber Ist-Daten wurden noch nicht exportiert/importiert.
- Fuer neue Laender reicht es, `FinanceReferences` zu pflegen und Daten nach `CentralSalesRecords` zu bringen; die Probe zeigt sie dann automatisch.
## Nachtrag 2026-05-07 Mapper-Konsolidierung / Finance-Konfiguration
Architekturstand:
@@ -2,6 +2,47 @@
Stand: 2026-05-05
## Nachtrag 2026-05-08 Manual Excel/CSV SharePoint-Automatik
Erledigt:
- SharePoint-Ordner koennen bei Manual Excel/CSV als Quelle hinterlegt werden.
- Bei Ordnern wird automatisch die neueste passende `.xlsx`/`.csv` ausgewaehlt.
- Dateinamenmuster fuer bevorzugte Auswahl: `ddMMyy_TSC.xlsx` bzw. `ddMMyy_TSC.csv`.
- Manual-Export schreibt die erzeugte Exportdatei in den Quellordner zurueck:
- lokal: gleicher lokaler Ordner
- SharePoint: gleicher SharePoint-Ordner
- England/TRUK ist lokal auf den SharePoint-Ordner `Import/Finance/UK_B1` korrigiert.
- Spanien-Fehler nach erfolgreichem Einlesen der SharePoint-CSV ist behoben.
Naechste konkrete Schritte:
1. App neu starten, damit die Seed-/Repair-Logik aktiv ist.
2. England/TRUK exportieren und pruefen, ob die App `010526_TRUK.xlsx` statt `010426_TRUK.xlsx` auswaehlt.
3. Im SharePoint-Ordner `Import/Finance/UK_B1` pruefen, ob die neue Exportdatei dort wieder abgelegt wird.
4. Deutschland/Alphaplan: im Standort den korrekten Alphaplan-Excel- oder SharePoint-Pfad hinterlegen.
5. Deutschland exportieren und Mapping gegen die Alphaplan-Datei validieren.
6. Falls UK-Dateinamen spaeter ein anderes Muster bekommen, Auswahlregel erweitern.
## Nachtrag 2026-05-08 FinanceProbe
Erledigt:
- FinanceProbe zeigt alle Finance-Referenzen 2025.
- Datenabdeckung je Standort wurde ergaenzt.
- CH/AT-Zuordnung wurde fuer `ZSCHWEIZ` geschaerft.
Naechste fachliche Schritte:
1. Nach Export von England, Schweiz/Oesterreich, Spanien und Deutschland die FinanceProbe neu laden.
2. In der Sektion `Datenabdeckung je Standort` pruefen, ob Zeilen 2025 und Periode plausibel sind.
3. Fuer Laender mit `Keine Daten` entscheiden:
- Datenquelle fehlt
- Standort deaktiviert
- Mapping/Export noch nicht gelaufen
- Referenz ist nur zukuenftig relevant
4. Fuer AT/CH nach `ZSCHWEIZ`-Export pruefen, ob `LAND1` korrekt `AT` bzw. `CH` liefert.
## Nachtrag 2026-05-07 nach Mapper-/Finance-Aufraeumung
Erledigt:
@@ -11,4 +11,10 @@ public sealed class DataSourceFetchResult
/// SiteExportService erzeugt dann keine neue Excel-Datei.
/// </summary>
public string? ReferenceFilePath { get; init; }
public string? LocalOutputDirectoryOverride { get; init; }
public string? SharePointUploadFolderOverride { get; init; }
public string? SharePointUploadLandOverride { get; init; }
}
@@ -29,12 +29,15 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
var manualImportPath = site.ManualImportFilePath.Trim();
string filePath;
string? localOutputDirectory = null;
string? sharePointUploadFolder = null;
string? tempManualImportPath = null;
try
{
if (File.Exists(manualImportPath))
{
filePath = manualImportPath;
localOutputDirectory = Path.GetDirectoryName(Path.GetFullPath(manualImportPath));
}
else if (LooksLikeSharePointReference(manualImportPath))
{
@@ -55,10 +58,22 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden",
siteId: site.Id, land: site.Land, details: manualImportPath);
var sharePointFileReference = manualImportPath;
if (LooksLikeSharePointFolderReference(manualImportPath))
{
var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, manualImportPath, site.TSC);
sharePointFileReference = latestFile.FileReference;
await _appEventLogService.WriteAsync("Export", "Neueste SharePoint-Datei ausgewaehlt",
siteId: site.Id, land: site.Land, details: sharePointFileReference);
}
tempManualImportPath = await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, manualImportPath);
filePath = manualImportPath;
spConfig.SiteUrl, sharePointFileReference);
filePath = sharePointFileReference;
sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl);
}
else
{
@@ -75,7 +90,9 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
return new DataSourceFetchResult
{
Records = records,
ReferenceFilePath = filePath
LocalOutputDirectoryOverride = localOutputDirectory,
SharePointUploadFolderOverride = sharePointUploadFolder,
SharePointUploadLandOverride = sharePointUploadFolder is null ? null : string.Empty
};
}
finally
@@ -90,4 +107,25 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
private static bool LooksLikeSharePointFolderReference(string path)
=> LooksLikeSharePointReference(path) &&
string.IsNullOrWhiteSpace(Path.GetExtension(path.TrimEnd('/')));
private static string ResolveSharePointParentFolder(string fileReference, string siteUrl)
{
var remotePath = fileReference.Trim('/').Trim();
if (Uri.TryCreate(fileReference, UriKind.Absolute, out var fileUri) &&
Uri.TryCreate(siteUrl, UriKind.Absolute, out var siteUri))
{
var absolutePath = Uri.UnescapeDataString(fileUri.AbsolutePath);
var sitePath = siteUri.AbsolutePath.TrimEnd('/');
if (absolutePath.StartsWith(sitePath, StringComparison.OrdinalIgnoreCase))
absolutePath = absolutePath[sitePath.Length..];
remotePath = absolutePath.Trim('/').Trim();
}
var lastSlash = remotePath.LastIndexOf('/');
return lastSlash <= 0 ? string.Empty : remotePath[..lastSlash];
}
}
@@ -13,6 +13,7 @@ public class DatabaseSeedService : IDatabaseSeedService
EnsureSourceSystemDefinitions(db);
EnsureCentralHanaServerRecords(db);
EnsureSpainManualExcelSite(db);
EnsureUkManualExcelFolder(db);
EnsureSapODataDachSite(db);
EnsureFinanceReferenceDefaults(db);
EnsureBudgetExchangeRateDefaults(db);
@@ -287,6 +288,36 @@ public class DatabaseSeedService : IDatabaseSeedService
db.SaveChanges();
}
private static void EnsureUkManualExcelFolder(AppDbContext db)
{
var existing = db.Sites
.OrderBy(x => x.Id)
.FirstOrDefault(x =>
x.TSC == "TRUK" ||
x.Land == "England" ||
x.Land == "UK");
if (existing is null)
return;
var changed = false;
if (string.IsNullOrWhiteSpace(existing.SourceSystem))
{
existing.SourceSystem = "MANUAL_EXCEL";
changed = true;
}
if (string.Equals(existing.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(existing.ManualImportFilePath))
{
existing.ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1";
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureSapODataDachSite(AppDbContext db)
{
if (db.Sites.Count() <= 1)
@@ -60,16 +60,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules),
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 financeReferences
.Where(reference => activeSiteKeys.Contains(reference.Key) || groupedActuals.ContainsKey(reference.Key))
.Select(reference => BuildReferenceRow(reference, groupedActuals))
.OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -282,6 +273,8 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
var normalizedLand = (land ?? string.Empty).Trim().ToUpperInvariant();
var normalizedTsc = (tsc ?? string.Empty).Trim().ToUpperInvariant();
if (normalizedLand is "AT" or "AUT" || normalizedLand.Contains("OESTER") || normalizedLand.Contains("OSTER") || normalizedLand.Contains("AUSTRIA")) return "AT";
if (normalizedLand is "CH" or "CHE" || normalizedLand.Contains("SCHWE") || normalizedLand.Contains("SWITZER")) return "CH";
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";
@@ -289,7 +282,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
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);
}
@@ -4,5 +4,8 @@ public interface ISharePointUploadService
{
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath);
Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference);
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc);
Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl);
}
public sealed record SharePointFileReference(string FileReference, DateTimeOffset? LastModifiedUtc);
@@ -1,6 +1,9 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using System.Globalization;
using System.Text.RegularExpressions;
namespace TrafagSalesExporter.Services;
@@ -82,6 +85,64 @@ public class SharePointUploadService : ISharePointUploadService
return tempPath;
}
public async Task<SharePointFileReference> ResolveLatestFileInFolderAsync(
string tenantId,
string clientId,
string clientSecret,
string siteUrl,
string folderReference,
string siteTsc)
{
var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
var normalizedClientSecret = Normalize(clientSecret);
var normalizedSiteUrl = Normalize(siteUrl);
var normalizedReference = Normalize(folderReference);
var normalizedTsc = Normalize(siteTsc).ToUpperInvariant();
if (string.IsNullOrWhiteSpace(normalizedReference))
throw new InvalidOperationException("SharePoint-Ordnerreferenz fehlt.");
var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var siteUri = new Uri(normalizedSiteUrl);
var sitePath = siteUri.AbsolutePath.TrimEnd('/');
var site = await graphClient.Sites[$"{siteUri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var folderPath = ResolveRemotePath(normalizedReference, siteUri);
var children = await graphClient.Drives[drive.Id].Root.ItemWithPath(folderPath).Children.GetAsync();
var candidates = children?.Value?
.Where(item => item.File is not null)
.Where(item => IsSupportedManualImportFile(item.Name))
.Where(item => MatchesTsc(item.Name, normalizedTsc))
.Select(item => new
{
Item = item,
FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null
})
.OrderByDescending(x => x.FileDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.ThenByDescending(x => x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.ToList() ?? [];
var selected = candidates.FirstOrDefault()
?? throw new InvalidOperationException(
string.IsNullOrWhiteSpace(normalizedTsc)
? $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei gefunden."
: $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei fuer '{normalizedTsc}' gefunden.");
return new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), selected.Item.Name).Trim('/'),
selected.Item.LastModifiedDateTime);
}
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var normalizedTenantId = Normalize(tenantId);
@@ -143,6 +204,41 @@ public class SharePointUploadService : ISharePointUploadService
return fileReference.Trim('/').Trim();
}
private static bool IsSupportedManualImportFile(string? fileName)
{
var extension = Path.GetExtension(fileName ?? string.Empty);
return extension.Equals(".xlsx", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".csv", StringComparison.OrdinalIgnoreCase);
}
private static bool MatchesTsc(string? fileName, string normalizedTsc)
{
if (string.IsNullOrWhiteSpace(normalizedTsc))
return true;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
return nameWithoutExtension.EndsWith($"_{normalizedTsc}", StringComparison.OrdinalIgnoreCase);
}
private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate)
{
fileDate = default;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
var pattern = string.IsNullOrWhiteSpace(normalizedTsc)
? @"^(?<date>\d{6})_[A-Z0-9]+$"
: $"^(?<date>\\d{{6}})_{Regex.Escape(normalizedTsc)}$";
var match = Regex.Match(nameWithoutExtension, pattern, RegexOptions.IgnoreCase);
if (!match.Success)
return false;
return DateTime.TryParseExact(
match.Groups["date"].Value,
"ddMMyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out fileDate);
}
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var maskedSecret = string.IsNullOrEmpty(clientSecret)
@@ -56,8 +56,6 @@ public class SiteExportService : ISiteExportService
details: $"Quelle={sourceSystem} | TSC={site.TSC}");
var (settings, spConfig, sourceDefinition, rules) = await LoadExportConfigAsync(site, sourceSystem);
var outputDir = ResolveSiteOutputDirectory(settings, site);
var adapter = _dataSourceResolver.Resolve(sourceDefinition.ConnectionKind);
var fetchResult = await adapter.FetchAsync(new DataSourceFetchContext
{
@@ -69,6 +67,7 @@ public class SiteExportService : ISiteExportService
});
var records = fetchResult.Records;
var outputDir = fetchResult.LocalOutputDirectoryOverride ?? ResolveSiteOutputDirectory(settings, site);
updateStatus?.Invoke("Transformationen anwenden...");
await _appEventLogService.WriteAsync("Export", "Transformationen anwenden",
@@ -94,7 +93,7 @@ public class SiteExportService : ISiteExportService
details: $"Records={records.Count}");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus);
await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus);
await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus, fetchResult);
sw.Stop();
log.Status = "OK";
@@ -156,7 +155,11 @@ public class SiteExportService : ISiteExportService
}
private async Task UploadToSharePointIfConfiguredAsync(
Site site, SharePointConfig? spConfig, string filePath, Action<string>? updateStatus)
Site site,
SharePointConfig? spConfig,
string filePath,
Action<string>? updateStatus,
DataSourceFetchResult fetchResult)
{
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
@@ -165,12 +168,16 @@ public class SiteExportService : ISiteExportService
return;
updateStatus?.Invoke("SharePoint Upload...");
var uploadFolder = string.IsNullOrWhiteSpace(fetchResult.SharePointUploadFolderOverride)
? spConfig.ExportFolder
: fetchResult.SharePointUploadFolderOverride;
var uploadLand = fetchResult.SharePointUploadLandOverride ?? site.Land;
await _appEventLogService.WriteAsync("Export", "SharePoint Upload gestartet",
siteId: site.Id, land: site.Land,
details: $"{spConfig.SiteUrl} | {spConfig.ExportFolder}");
details: $"{spConfig.SiteUrl} | {uploadFolder}");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
spConfig.SiteUrl, uploadFolder, uploadLand, filePath);
}
private static string NormalizeSourceSystem(string? sourceSystem)
@@ -409,13 +409,14 @@ public sealed class StandortePageService : IStandortePageService
var trimmedPath = manualImportFilePath.Trim();
if (string.IsNullOrWhiteSpace(trimmedPath))
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
if (!IsSupportedManualImportFile(trimmedPath))
var isSharePointReference = LooksLikeSharePointReference(trimmedPath);
if (!isSharePointReference && !IsSupportedManualImportFile(trimmedPath))
throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv angeben.");
if (File.Exists(trimmedPath))
return File.GetLastWriteTimeUtc(trimmedPath);
if (!LooksLikeSharePointReference(trimmedPath))
if (!isSharePointReference)
throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {trimmedPath}");
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -429,8 +430,16 @@ public sealed class StandortePageService : IStandortePageService
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
var sharePointFileReference = trimmedPath;
if (!IsSupportedManualImportFile(trimmedPath))
{
var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath, string.Empty);
sharePointFileReference = latestFile.FileReference;
}
var tempPath = await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath);
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, sharePointFileReference);
try
{
return File.GetLastWriteTimeUtc(tempPath);
@@ -448,7 +457,7 @@ public sealed class StandortePageService : IStandortePageService
var deleteAfterRead = !string.Equals(filePath, manualImportFilePath?.Trim(), StringComparison.OrdinalIgnoreCase);
try
{
return string.Equals(Path.GetExtension(manualImportFilePath?.Trim()), ".csv", StringComparison.OrdinalIgnoreCase)
return string.Equals(Path.GetExtension(filePath), ".csv", StringComparison.OrdinalIgnoreCase)
? LoadCsvHeaders(filePath)
: LoadExcelHeaders(filePath);
}
@@ -482,8 +491,16 @@ public sealed class StandortePageService : IStandortePageService
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
var sharePointFileReference = trimmedPath;
if (!IsSupportedManualImportFile(trimmedPath))
{
var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath, string.Empty);
sharePointFileReference = latestFile.FileReference;
}
return await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath);
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, sharePointFileReference);
}
private static void ApplyServer(HanaServer target, HanaServer source)
@@ -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;
}
@@ -0,0 +1,161 @@
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources;
namespace TrafagSalesExporter.Tests;
public class ManualExcelDataSourceAdapterTests
{
[Fact]
public async Task FetchAsync_Uses_Local_File_Directory_As_OutputDirectory()
{
var filePath = CreateSpainCsv();
try
{
var adapter = new ManualExcelDataSourceAdapter(
new FakeSharePointUploadService(filePath),
new ManualExcelImportService(),
new NoopAppEventLogService());
var result = await adapter.FetchAsync(CreateContext(filePath));
Assert.Single(result.Records);
Assert.Null(result.ReferenceFilePath);
Assert.Equal(Path.GetDirectoryName(Path.GetFullPath(filePath)), result.LocalOutputDirectoryOverride);
}
finally
{
File.Delete(filePath);
}
}
[Fact]
public async Task FetchAsync_Uses_SharePoint_Source_Folder_As_UploadFolder()
{
var filePath = CreateSpainCsv();
try
{
var adapter = new ManualExcelDataSourceAdapter(
new FakeSharePointUploadService(filePath),
new ManualExcelImportService(),
new NoopAppEventLogService());
var result = await adapter.FetchAsync(CreateContext("https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/Spanien/Spain_Sales_2025.csv"));
Assert.Single(result.Records);
Assert.Null(result.ReferenceFilePath);
Assert.Equal("Import/Finance/Spanien", result.SharePointUploadFolderOverride);
Assert.Equal(string.Empty, result.SharePointUploadLandOverride);
}
finally
{
File.Delete(filePath);
}
}
[Fact]
public async Task FetchAsync_Uses_Latest_SharePoint_File_When_Path_Is_Folder()
{
var filePath = CreateSpainCsv();
var sharePointService = new FakeSharePointUploadService(
filePath,
latestFileReference: "Import/Finance/UK_B1/010526_TRUK.xlsx");
try
{
var adapter = new ManualExcelDataSourceAdapter(
sharePointService,
new ManualExcelImportService(),
new NoopAppEventLogService());
var result = await adapter.FetchAsync(CreateContext("https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1", "TRUK", "England"));
Assert.Single(result.Records);
Assert.Equal("Import/Finance/UK_B1", result.SharePointUploadFolderOverride);
Assert.Equal("Import/Finance/UK_B1/010526_TRUK.xlsx", sharePointService.LastDownloadedReference);
Assert.Equal("TRUK", sharePointService.LastResolvedTsc);
}
finally
{
File.Delete(filePath);
}
}
private static DataSourceFetchContext CreateContext(string manualImportPath, string tsc = "TRES", string land = "Spanien") => new()
{
Site = new Site
{
Id = 7,
TSC = tsc,
Land = land,
ManualImportFilePath = manualImportPath
},
SourceDefinition = new SourceSystemDefinition
{
Code = "MANUAL_EXCEL",
ConnectionKind = SourceSystemConnectionKinds.ManualExcel
},
Settings = new ExportSettings(),
SharePointConfig = new SharePointConfig
{
TenantId = "tenant",
ClientId = "client",
ClientSecret = "secret",
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform"
}
};
private static string CreateSpainCsv()
{
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv");
var csv = string.Join(Environment.NewLine,
"\"TSC\";\"Land\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"InvoiceDate\";\"DocumentType\"",
"\"TRES\";\"Spanien\";\"20241332\";\"20\";\"52871\";\"ECL1.0AP\";\"TRANS\";\"1.000000\";\"302208\";\"INTRONIK AUTOMATIZACION E INST. SL\";\"ESPANA\";\"160.760000\";\"EUR\";\"PC240330\";\"265.000000\";\"EUR\";\"EUR\";\"EUR\";\"EXW\";\"1\";\"2025-01-02 00:00:00\";\"Invoice\"");
File.WriteAllText(filePath, csv);
return filePath;
}
private sealed class FakeSharePointUploadService : ISharePointUploadService
{
private readonly string _sourceFilePath;
private readonly string _latestFileReference;
public FakeSharePointUploadService(string sourceFilePath, string? latestFileReference = null)
{
_sourceFilePath = sourceFilePath;
_latestFileReference = latestFileReference ?? "Import/Finance/Spanien/Spain_Sales_2025.csv";
}
public string LastDownloadedReference { get; private set; } = string.Empty;
public string LastResolvedTsc { get; private set; } = string.Empty;
public Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath)
=> Task.CompletedTask;
public Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference)
{
LastDownloadedReference = fileReference;
var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv");
File.Copy(_sourceFilePath, tempPath);
return Task.FromResult(tempPath);
}
public Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc)
{
LastResolvedTsc = siteTsc;
return Task.FromResult(new SharePointFileReference(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)));
}
public Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
=> Task.CompletedTask;
}
private sealed class NoopAppEventLogService : IAppEventLogService
{
public Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
=> Task.CompletedTask;
public Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
=> Task.CompletedTask;
}
}
@@ -43,3 +43,14 @@ Wichtige Praezisierung aus dem Code:
## Einsatz
Die SVG-Dateien koennen direkt im Browser geoeffnet, in Markdown verlinkt oder in Praesentationen eingefuegt werden.
## Nachtrag Manual Excel/CSV 2026-05-08
Die Diagramme zeigen Manual Excel/CSV als Quelle. Die aktuelle Detailregel dazu ist:
- Eine konkrete lokale Datei wird gelesen; die erzeugte Exportdatei wird im gleichen lokalen Ordner abgelegt.
- Eine konkrete SharePoint-Datei wird gelesen; die erzeugte Exportdatei wird im gleichen SharePoint-Ordner abgelegt.
- Eine SharePoint-Ordnerreferenz wird als dynamische Quelle behandelt.
- Bei SharePoint-Ordnern wird die neueste passende `.xlsx`/`.csv` gesucht.
- Fuer England/TRUK gilt das Dateimuster `ddMMyy_TRUK.xlsx`, z. B. `010526_TRUK.xlsx`.
- Die Ordnerlogik ist generisch fuer Manual-Quellen, nicht hart nur fuer England implementiert.
@@ -0,0 +1,236 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1780" height="1240" viewBox="0 0 1780 1240" role="img" aria-labelledby="title desc">
<title id="title">Finance-Abgleich 2025: Was passt, was ist offen</title>
<desc id="desc">Statusuebersicht zum Net-Sales-Abgleich 2025 gegen check.xlsx auf Basis der Financechef-Entscheide.</desc>
<defs>
<style>
.bg { fill: #f6f7f9; }
.panel { fill: #ffffff; stroke: #c9d1dc; stroke-width: 1.2; }
.title { font: 700 34px Arial, sans-serif; fill: #172033; }
.subtitle { font: 400 17px Arial, sans-serif; fill: #536074; }
.h2 { font: 700 20px Arial, sans-serif; fill: #172033; }
.h3 { font: 700 15px Arial, sans-serif; fill: #172033; }
.txt { font: 400 13.5px Arial, sans-serif; fill: #344054; }
.small { font: 400 12px Arial, sans-serif; fill: #667085; }
.table-head { fill: #22324a; }
.th { font: 700 13px Arial, sans-serif; fill: #ffffff; }
.td { font: 400 12.5px Arial, sans-serif; fill: #263241; }
.num { font: 400 12.5px Arial, sans-serif; fill: #263241; }
.line { stroke: #d8dee8; stroke-width: 1; }
.ok { fill: #e7f5ec; stroke: #168a48; stroke-width: 1.5; }
.warn { fill: #fff6e6; stroke: #d99020; stroke-width: 1.5; }
.miss { fill: #f0f2f5; stroke: #98a2b3; stroke-width: 1.5; }
.bad { fill: #fff0f0; stroke: #c93d3d; stroke-width: 1.5; }
.pill-ok { fill: #168a48; }
.pill-warn { fill: #d99020; }
.pill-miss { fill: #667085; }
.pill-bad { fill: #c93d3d; }
.pill-text { font: 700 11px Arial, sans-serif; fill: #ffffff; }
.rule { fill: #eef3f8; stroke: #c3cedb; stroke-width: 1; }
.arrow { stroke: #637083; stroke-width: 2; fill: none; marker-end: url(#arrow); }
</style>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#637083"/>
</marker>
</defs>
<rect class="bg" width="1780" height="1240"/>
<text class="title" x="54" y="62">Finance-Abgleich 2025 - was passt, was noch nicht</text>
<text class="subtitle" x="54" y="92">Stand: letzter lokaler FinanceProbe-Abgleich gegen check.xlsx von Rhino, plus fachliche Entscheide vom Financechef.</text>
<g transform="translate(54,124)">
<rect class="panel" width="1672" height="144" rx="8"/>
<text class="h2" x="24" y="34">Verbindliche Finance-Regeln</text>
<g transform="translate(24,54)">
<rect class="rule" width="250" height="58" rx="6"/>
<text class="h3" x="14" y="22">Fuehrende Waehrung</text>
<text class="txt" x="14" y="43">Immer Hauswaehrung</text>
</g>
<g transform="translate(294,54)">
<rect class="rule" width="250" height="58" rx="6"/>
<text class="h3" x="14" y="22">CHF-Ausweis</text>
<text class="txt" x="14" y="43">Budgetkurse, keine Tageskurse</text>
</g>
<g transform="translate(564,54)">
<rect class="rule" width="250" height="58" rx="6"/>
<text class="h3" x="14" y="22">Summierung</text>
<text class="txt" x="14" y="43">Pro Artikel / Belegposition</text>
</g>
<g transform="translate(834,54)">
<rect class="rule" width="250" height="58" rx="6"/>
<text class="h3" x="14" y="22">Net Sales Basis</text>
<text class="txt" x="14" y="43">Nettofakturawert</text>
</g>
<g transform="translate(1104,54)">
<rect class="rule" width="250" height="58" rx="6"/>
<text class="h3" x="14" y="22">Periode 2025</text>
<text class="txt" x="14" y="43">Buchungsdatum</text>
</g>
<g transform="translate(1374,54)">
<rect class="rule" width="250" height="58" rx="6"/>
<text class="h3" x="14" y="22">IC / Gutschriften</text>
<text class="txt" x="14" y="43">Separat sichtbar ausweisen</text>
</g>
</g>
<g transform="translate(54,294)">
<rect class="panel" width="1672" height="132" rx="8"/>
<text class="h2" x="24" y="34">Kurzfazit</text>
<g transform="translate(24,58)">
<rect class="ok" width="260" height="48" rx="7"/>
<text class="h3" x="16" y="20">Passt rechnerisch</text>
<text class="txt" x="16" y="39">Indien, aber Waehrungsfeld pruefen</text>
</g>
<g transform="translate(312,58)">
<rect class="warn" width="430" height="48" rx="7"/>
<text class="h3" x="16" y="20">Daten vorhanden, aber noch nicht fachlich sauber</text>
<text class="txt" x="16" y="39">ES, FR, IT, US brauchen Regel-/Mapping-Klaerung</text>
</g>
<g transform="translate(770,58)">
<rect class="miss" width="410" height="48" rx="7"/>
<text class="h3" x="16" y="20">Noch keine belastbaren Ist-Daten</text>
<text class="txt" x="16" y="39">AT, CH, DE, UK und weitere Referenzlaender</text>
</g>
<g transform="translate(1208,58)">
<rect class="bad" width="390" height="48" rx="7"/>
<text class="h3" x="16" y="20">Technische Anpassung offen</text>
<text class="txt" x="16" y="39">FinanceProbe/Export muss strikt Finance-Regeln zeigen</text>
</g>
</g>
<g transform="translate(54,454)">
<rect class="panel" width="1672" height="530" rx="8"/>
<text class="h2" x="24" y="34">Laenderstatus gegen Rhino check.xlsx</text>
<rect class="table-head" x="24" y="56" width="1624" height="34" rx="4"/>
<text class="th" x="42" y="78">Status</text>
<text class="th" x="156" y="78">Land</text>
<text class="th" x="302" y="78">Ist aktuell</text>
<text class="th" x="432" y="78">Soll Rhino</text>
<text class="th" x="562" y="78">Differenz</text>
<text class="th" x="700" y="78">Was passt</text>
<text class="th" x="1020" y="78">Was noch nicht passt / naechster Schritt</text>
<g transform="translate(24,90)">
<line class="line" x1="0" y1="48" x2="1624" y2="48"/>
<line class="line" x1="0" y1="96" x2="1624" y2="96"/>
<line class="line" x1="0" y1="144" x2="1624" y2="144"/>
<line class="line" x1="0" y1="192" x2="1624" y2="192"/>
<line class="line" x1="0" y1="240" x2="1624" y2="240"/>
<line class="line" x1="0" y1="288" x2="1624" y2="288"/>
<line class="line" x1="0" y1="336" x2="1624" y2="336"/>
<line class="line" x1="0" y1="384" x2="1624" y2="384"/>
<g transform="translate(0,0)">
<rect class="pill-ok" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">PASST</text>
<text class="td" x="132" y="20">IN</text>
<text class="num" x="278" y="20">750'936'591.38</text>
<text class="num" x="408" y="20">750'936'591.00</text>
<text class="num" x="538" y="20">0.38</text>
<text class="td" x="676" y="20">Zahl passt fast exakt gegen Soll.</text>
<text class="td" x="996" y="20">Anzeige/Mapping muss INR Hauswaehrung zeigen; aktuell gemischte Waehrungen sichtbar.</text>
</g>
<g transform="translate(0,48)">
<rect class="pill-warn" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">PRUEFEN</text>
<text class="td" x="132" y="20">ES</text>
<text class="num" x="278" y="20">3'082'320.18</text>
<text class="num" x="408" y="20">3'102'334.00</text>
<text class="num" x="538" y="20">-20'013.82</text>
<text class="td" x="676" y="20">Sage-Datei ist lesbar, 4'341 Zeilen.</text>
<text class="td" x="996" y="20">Datumsabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften klaeren.</text>
</g>
<g transform="translate(0,96)">
<rect class="pill-warn" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">PRUEFEN</text>
<text class="td" x="132" y="20">FR</text>
<text class="num" x="278" y="20">1'414'138.88</text>
<text class="num" x="408" y="20">1'471'218.00</text>
<text class="num" x="538" y="20">-57'079.12</text>
<text class="td" x="676" y="20">B1/HANA-Daten vorhanden.</text>
<text class="td" x="996" y="20">Muss auf Nettofakturawert Hauswaehrung + Buchungsdatum ausgerichtet werden.</text>
</g>
<g transform="translate(0,144)">
<rect class="pill-warn" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">PRUEFEN</text>
<text class="td" x="132" y="20">IT</text>
<text class="num" x="278" y="20">11'866'896.53</text>
<text class="num" x="408" y="20">7'669'840.00</text>
<text class="num" x="538" y="20">4'197'056.53</text>
<text class="td" x="676" y="20">Hauswaehrung/EUR-Daten vorhanden.</text>
<text class="td" x="996" y="20">IC/2nd-party separat markieren; nicht still abziehen. Abgrenzung weiter klaeren.</text>
</g>
<g transform="translate(0,192)">
<rect class="pill-warn" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">PRUEFEN</text>
<text class="td" x="132" y="20">US</text>
<text class="num" x="278" y="20">3'795'763.33</text>
<text class="num" x="408" y="20">3'749'865.00</text>
<text class="num" x="538" y="20">45'898.33</text>
<text class="td" x="676" y="20">B1/HANA-Daten vorhanden.</text>
<text class="td" x="996" y="20">Hauswaehrung, Buchungsdatum und Nettofakturawert gegen Quelle pruefen.</text>
</g>
<g transform="translate(0,240)">
<rect class="pill-miss" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">FEHLT</text>
<text class="td" x="132" y="20">UK</text>
<text class="num" x="278" y="20">-</text>
<text class="num" x="408" y="20">3'749'865.00</text>
<text class="num" x="538" y="20">-</text>
<text class="td" x="676" y="20">UK_B1 SharePoint-Ordner ist konfiguriert.</text>
<text class="td" x="996" y="20">Noch keine 2025-Zeilen in CentralSalesRecords; Export/Import neu laufen lassen.</text>
</g>
<g transform="translate(0,288)">
<rect class="pill-miss" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">FEHLT</text>
<text class="td" x="132" y="20">DE</text>
<text class="num" x="278" y="20">-</text>
<text class="num" x="408" y="20">3'635'923.00</text>
<text class="num" x="538" y="20">-</text>
<text class="td" x="676" y="20">Alphaplan-Mapping ist vorhanden.</text>
<text class="td" x="996" y="20">Finalen Alphaplan-Exportpfad/Import pruefen; Sample ist nicht Jahresdatei.</text>
</g>
<g transform="translate(0,336)">
<rect class="pill-miss" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">FEHLT</text>
<text class="td" x="132" y="20">AT / CH</text>
<text class="num" x="278" y="20">-</text>
<text class="num" x="408" y="20">AT 3'443'863</text>
<text class="num" x="538" y="20">-</text>
<text class="td" x="676" y="20">ZSCHWEIZ OData-Pfad ist vorbereitet.</text>
<text class="td" x="996" y="20">Export muss AT/CH nach LAND1 trennen; danach FinanceProbe erneut pruefen.</text>
</g>
<g transform="translate(0,384)">
<rect class="pill-miss" x="16" y="12" width="78" height="24" rx="12"/>
<text class="pill-text" x="55" y="28" text-anchor="middle">FEHLT</text>
<text class="td" x="132" y="20">CN/CZ/GFS/JP/MS/MSA/PL/RU</text>
<text class="num" x="278" y="20">-</text>
<text class="num" x="408" y="20">teils vorhanden</text>
<text class="num" x="538" y="20">-</text>
<text class="td" x="676" y="20">Sollwerte/Referenzen sichtbar.</text>
<text class="td" x="996" y="20">Noch keine Ist-Daten; Quelle, Standort oder Importprozess festlegen.</text>
</g>
</g>
</g>
<g transform="translate(54,1010)">
<rect class="panel" width="1672" height="160" rx="8"/>
<text class="h2" x="24" y="34">Konkrete Luecken zur Finance-Regel</text>
<path class="arrow" d="M 38 62 H 250"/>
<text class="h3" x="270" y="66">Ist-Wert muss konsequent aus Nettofakturawert Hauswaehrung kommen</text>
<text class="txt" x="270" y="88">Aktuell zeigt die Probe bei einigen Laendern noch alternative technische Kandidaten oder gemischte Waehrungen.</text>
<path class="arrow" d="M 38 108 H 250"/>
<text class="h3" x="270" y="112">Buchungsdatum und Belegpositionslogik muessen in Quelle/Mapping sichtbar sein</text>
<text class="txt" x="270" y="134">Gutschriften separat, IC/2nd-party als Klassifikation; 3rd-party ist Default. Keine stille Bereinigung im Hintergrund.</text>
</g>
<text class="small" x="54" y="1210">Hinweis: Diese SVG ist eine fachliche Statusdoku, keine Codeaenderung. Zahlen stammen aus dem letzten lokalen Abgleich gegen Rhino check.xlsx.</text>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+69
View File
@@ -1,5 +1,74 @@
# Last Change 2026-05-04
## Manual Excel/CSV SharePoint-Ordner und Quellordner-Export 2026-05-08
Umgesetzte Anpassungen:
- Manual Excel/CSV Quellen erzeugen nun immer eine neue Exportdatei; die Quelldatei wird nicht als Exportdatei weitergereicht.
- Lokale Manual-Dateien schreiben die neue Exportdatei in denselben lokalen Ordner wie die Quelldatei.
- SharePoint-Manual-Dateien schreiben die neue Exportdatei in denselben SharePoint-Ordner wie die Quelldatei.
- SharePoint-Referenzen ohne Dateiendung werden als Ordner behandelt.
- Bei SharePoint-Ordnern sucht die App die neueste passende Excel-/CSV-Datei fuer den Standort.
- Fuer datierte Dateien wird das Muster `ddMMyy_TSC.xlsx` bzw. `ddMMyy_TSC.csv` ausgewertet.
- Beispiel England/UK:
- Ordner: `https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1`
- `010526_TRUK.xlsx` wird vor `010426_TRUK.xlsx` gewaehlt.
- Falls kein Datum aus dem Dateinamen gelesen werden kann, faellt die Auswahl auf das SharePoint-Aenderungsdatum zurueck.
Technischer Befund aus den Logs:
- Spanien konnte die SharePoint-Datei lesen (`4'341` Zeilen), fiel danach aber auf einen ungueltigen lokalen Pfad, weil die URL als lokale Exportdatei behandelt wurde.
- Fehlerpfad war sinngemaess `...\https:\trafagag.sharepoint.com\...\Spain_Sales_2025.csv`.
- Deutschland hatte keinen manuellen Dateipfad hinterlegt.
- England/TRUK zeigte lokal versehentlich auf die Deutschland-Alphaplan-Datei; die lokale DB wurde auf den UK_B1-Ordner korrigiert.
Codeaenderungen:
- `DataSourceFetchResult` enthaelt optionale Overrides fuer lokalen Output-Ordner und SharePoint-Zielordner.
- `ManualExcelDataSourceAdapter` erkennt SharePoint-Dateien vs. SharePoint-Ordner und waehlt bei Ordnern die neueste passende Datei.
- `SharePointUploadService` kann den neuesten passenden Datei-Eintrag in einem SharePoint-Ordner aufloesen.
- `SiteExportService` nutzt fuer Manual-Quellen den Quellordner als Zielordner.
- `StandortePageService` erlaubt fuer Manual-Importe nun auch SharePoint-Ordnerreferenzen.
- Standort-UI-Hilfetext wurde entsprechend angepasst.
- `DatabaseSeedService` repariert England/TRUK auf den UK_B1-Ordner, wenn der Manual-Pfad leer ist.
Letzte technische Verifikation:
```text
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore --verbosity minimal
```
Ergebnis:
- Tests erfolgreich, `55/55`
- Bekannte MudBlazor-Analyzerwarnungen zu `Dense` bleiben bestehen.
## FinanceProbe erweitert fuer alle Finance-Referenzen 2026-05-08
Umgesetzte Anpassungen:
- FinanceProbe zeigt nun alle aktiven `FinanceReferences` fuer 2025, auch wenn noch kein aktiver/importierter Standort dazu Daten liefert.
- Damit werden auch Laender wie AT, CH, CN, CZ, GFS, JP, MS, MSA, PL und RU sichtbar als `Keine Daten`, bis Ist-Daten vorhanden sind.
- Zusaetzliche Sektion `Datenabdeckung je Standort`:
- Standort / TSC
- Quellsystem und Anschlussart
- Manual-Datei- oder SharePoint-Pfad
- Aktivstatus
- Anzahl 2025-Zeilen in `CentralSalesRecords`
- Summe `SalesPriceValue`
- Waehrungen
- importierte Periode
- letzter Exportstatus und Hinweis
- Referenzschluessel-Erkennung wurde fuer CH/AT praezisiert:
- `AT`, `AUT`, `Oesterreich`/`Austria` -> `AT`
- `CH`, `CHE`, `Schweiz`/`Switzerland` -> `CH`
- Damit koennen Zeilen aus `ZSCHWEIZ` mit `LAND1 = AT` fachlich Oesterreich zugeordnet werden.
Verifikation:
- `Tools/FinanceProbe` Build erfolgreich.
- Haupttests wurden mit separatem Output/Obj-Pfad ausgefuehrt, damit die laufende App nicht stoert.
## Mapper-/Finance-Konfiguration konsolidiert 2026-05-07
Umgesetzte Aufraeumarbeiten: