Compare commits

...

63 Commits

Author SHA1 Message Date
Claude fa4e3c2ffc Split ManagementCockpitService god class into focused analyzers
Extract the three independent responsibilities of the 1120-line
ManagementCockpitService into dedicated classes: ExcelCockpitAnalyzer
(file-based cockpit), CentralCockpitAnalyzer (central database cockpit)
and FinanceSummaryAnalyzer (finance summary), with shared currency
conversion and value-field logic in CockpitValueAggregator.

ManagementCockpitService becomes a thin facade that preserves the
IManagementCockpitService contract and both constructors, so no callers,
DI registrations or tests need to change. Pure code move, no behaviour
change.

https://claude.ai/code/session_01Q8k7LD7JG8oMReySL3Ckhc
2026-05-21 20:09:30 +00:00
admin 83acd5a148 Update markdown documentation status 2026-05-20 15:50:59 +02:00
admin a044040ada Improve cockpit user guide documents 2026-05-20 15:46:29 +02:00
admin 0bff161465 Document HR cockpit feature list 2026-05-20 15:32:56 +02:00
admin 1350e59e6a Document cockpit updates 2026-05-20 15:28:47 +02:00
admin 06fb56075f Expand HR KPI cockpit and add user guides 2026-05-20 15:27:03 +02:00
admin 610e771b9b Add finance summary view and HR guide 2026-05-20 15:17:10 +02:00
admin de0b12ba37 Document final Excel verification 2026-05-20 14:24:04 +02:00
admin d66074b740 Add configurable finance rules and dashboard basis indicators 2026-05-20 13:10:33 +02:00
admin 5e305ae396 Add DE finance rules and IIS path base 2026-05-20 12:21:06 +02:00
admin 6246c886ca Make SQLite WAL publish files optional 2026-05-20 10:44:02 +02:00
admin 0d8500f4d7 Add manual import guide tab 2026-05-20 10:09:21 +02:00
admin a1fdea56ba Improve keyuser export workflow 2026-05-20 09:52:55 +02:00
admin b5e0545fbf Add keyuser process SVG 2026-05-20 09:43:29 +02:00
admin 5fb05c500b Add technical architecture SVG 2026-05-20 09:36:43 +02:00
admin 930f124aae Add provisional Germany Alphaplan import 2026-05-20 09:26:28 +02:00
admin 1b898a1efe Document IIS deployment diagnostics 2026-05-20 08:35:31 +02:00
admin 1dc336dc47 Enable IIS detailed startup diagnostics 2026-05-20 08:29:02 +02:00
admin e3b9d8d0c0 Switch IIS hosting to out-of-process 2026-05-20 08:25:10 +02:00
admin 6f8528ac54 Apply confirmed Italy finance method 2026-05-20 08:10:02 +02:00
admin b2aa7b046f Document IIS deployment handoff 2026-05-19 15:37:25 +02:00
admin 5087a7c271 Enable IIS publish diagnostics 2026-05-19 15:31:27 +02:00
admin 15335703fe Exclude local build artifacts from web publish 2026-05-19 15:28:01 +02:00
admin e9b616ff26 Align Trafag publish output with BiDashboard 2026-05-19 15:22:58 +02:00
admin f128d3528a Publish web app without apphost 2026-05-19 15:18:48 +02:00
admin 8d10372614 Configure Trafag web publish profile 2026-05-19 14:31:15 +02:00
admin 383796df87 Document finance workflow and security toggle 2026-05-19 14:26:04 +02:00
admin b23f73ecd6 Add finance filter help sheet 2026-05-19 14:00:52 +02:00
admin ebbc5a13a8 Add finance filter columns to consolidated export 2026-05-19 13:51:25 +02:00
admin 9c544afa20 Protect finance cockpit with login 2026-05-19 09:40:15 +02:00
admin 5c654ad848 Document finance formulas by country 2026-05-19 08:07:29 +02:00
admin f855e060d1 Filter empty actual finance rows 2026-05-19 08:01:04 +02:00
admin 8f1b1b88de Align main finance comparison with probe 2026-05-18 21:39:10 +02:00
admin bc6bfdfa27 Document finance reconciliation handoff 2026-05-18 21:30:05 +02:00
admin f721d95b32 Add updated finance Excel and Spain cache 2026-05-18 21:28:21 +02:00
admin 3d40d76d8e Use GBP local reference for UK finance 2026-05-18 21:07:23 +02:00
admin fb85e2e57a Correct Sage finance calculations 2026-05-18 20:57:22 +02:00
admin cf0d3e21f1 Document architecture hardcoding review 2026-05-15 12:02:24 +02:00
admin 9daf54b8d9 Document AI role and HR exclusions 2026-05-15 11:55:40 +02:00
admin 83e556e89e Refine cockpit navigation and HR access 2026-05-15 11:14:46 +02:00
admin e20693243d Update HR KPI and finance dashboard docs 2026-05-15 10:25:01 +02:00
admin 001e2a73d5 Commit pending finance and Power BI work 2026-05-13 07:33:00 +02:00
admin 1cd0ad998f Refactor HR KPI cockpit architecture 2026-05-13 07:30:43 +02:00
admin 20be752adc Add HR KPI cockpit 2026-05-13 07:10:13 +02:00
admin 819a023163 Add SharePoint manual source handling and finance status 2026-05-11 08:43:52 +02:00
admin 57cb09bc50 Document program processes and source systems 2026-05-08 09:00:19 +02:00
admin bbd1f62062 Fix site deletion with dependent logs 2026-05-07 15:48:12 +02:00
admin dc3fd77c86 Consolidate mapping and finance configuration 2026-05-07 15:20:54 +02:00
admin dea171862c Ensure ZSCHWEIZ OData mapping seed 2026-05-07 14:55:30 +02:00
admin 34be4a5b49 Clarify SAP OData source mapping 2026-05-07 14:39:26 +02:00
admin 306bfca5d2 Ignore nested Trafag workspace 2026-05-07 14:18:43 +02:00
admin ce935d9eb5 Add Power BI reference files 2026-05-07 14:10:22 +02:00
admin 8477894758 Add Sage Spain export artifacts 2026-05-07 14:09:32 +02:00
admin 6717843f18 Add finance probe Spain reconciliation updates 2026-05-07 14:08:54 +02:00
admin 7442d45d9c Add configurable HANA mapping for ZSCHWEIZ 2026-05-07 14:04:17 +02:00
admin c862a559f6 Add manual Excel column mapping 2026-05-04 16:08:56 +02:00
admin 749a3209d9 Document finance reconciliation questions 2026-05-04 15:00:57 +02:00
admin 15dec06f31 Add finance reconciliation probe 2026-05-04 09:32:50 +02:00
admin 4a1561d85f Add AD auth and B1 currency fields 2026-04-29 11:07:35 +02:00
admin 3ac03a4782 Enhance management cockpit analysis 2026-04-29 07:00:29 +02:00
admin 49c03b9673 Update handoff docs for adapter and HANA refactor 2026-04-17 14:48:53 +02:00
admin ad2c6dbd53 Refactor HANA access to async and parameterized queries 2026-04-17 14:43:15 +02:00
admin 70a54c98d7 Merge pull request #61 from metacube2/claude/review-trafag-tool-JONMq
Refactor SiteExportService to use adapter pattern for data sources
2026-04-17 14:17:09 +02:00
202 changed files with 201281 additions and 1377 deletions
+1
View File
@@ -8,3 +8,4 @@ TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal TrafagSalesExporter/*.db-wal
Trafag/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
$ErrorActionPreference = 'Stop'
$exe = Join-Path $PSScriptRoot 'bin\x86\Release\net48\SapProbe.exe'
$log = Join-Path $PSScriptRoot 'sap_probe_last_run.log'
if (-not (Test-Path -LiteralPath $exe)) {
Write-Host "SapProbe.exe was not found:"
Write-Host $exe
Read-Host "Press Enter to close"
exit 2
}
if (Test-Path -LiteralPath $log) {
Remove-Item -LiteralPath $log -Force
}
Start-Transcript -Path $log -Force | Out-Null
try {
& $exe @args
$exitCode = $LASTEXITCODE
Write-Host ''
Write-Host "Exit code: $exitCode"
}
finally {
Stop-Transcript | Out-Null
}
if (Test-Path -LiteralPath $log) {
$content = Get-Content -LiteralPath $log -Raw
$content = [regex]::Replace($content, '(?m)^Password for .*$','Password prompt: [masked input omitted]')
Set-Content -LiteralPath $log -Value $content -Encoding UTF8
}
Write-Host ''
Write-Host "Log file: $log"
Read-Host "Press Enter to close"
exit $exitCode
@@ -1,11 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <PlatformTarget>x86</PlatformTarget>
<Nullable>enable</Nullable> <Prefer32Bit>true</Prefer32Bit>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>SapProbe</AssemblyName>
<RootNamespace>SapProbe</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" /> <Reference Include="sapnco">
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="sapnco_utils">
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco_utils\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco_utils.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,36 @@
**********************
nStart der Windows PowerShell-Aufzeichnung
Startzeit: 20260427082528
Benutzername: TRAFAGCH\koi
RunAs-Benutzer: TRAFAGCH\koi
Konfigurationsname:
Computer: NB61258 (Microsoft Windows NT 10.0.26200.0)
Hostanwendung: C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Users\koi\source\repos\Ai\TrafagSalesExporter\.tmp_sap_probe\RunSapProbeInteractive.ps1 abap-activate Z_TEST3 --dry-run
Prozess-ID: 452
PSVersion: 5.1.26100.8115
PSEdition: Desktop
PSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.26100.8115
BuildVersion: 10.0.26100.8115
CLRVersion: 4.0.30319.42000
WSManStackVersion: 3.0
PSRemotingProtocolVersion: 2.3
SerializationVersion: 1.1.0.1
**********************
SAP NCo CLI
Architecture : x86
NCo Assembly : sapnco, Version=3.1.0.42, Culture=neutral, PublicKeyToken=50436dca5c7f7d23
Password prompt: [masked input omitted]
Target : travt762.sap.trafag.com / SYSNR 00 / CLIENT 100 / USER KOI
Ping : OK
Program : Z_TEST3
Lines : 69
Activation : RPY_PROGRAM_INSERT with SAVE_INACTIVE blank
Dry run : no SAP repository changes were written.
Exit code: 0
**********************
Ende der Windows PowerShell-Aufzeichnung
Endzeit: 20260427082529
**********************
+19 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Finanze/Sales Management Cockpit</title> <title>Trafag Finanze/Sales Management Cockpit</title>
<base href="/" /> <base href="@BaseHref" />
<link href="css/app.css" rel="stylesheet" /> <link href="css/app.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" /> <link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
@@ -17,3 +17,21 @@
<script src="js/download.js"></script> <script src="js/download.js"></script>
</body> </body>
</html> </html>
@code {
[Inject]
private IConfiguration Configuration { get; set; } = default!;
private string BaseHref
{
get
{
var pathBase = Configuration["ASPNETCORE_PATHBASE"]?.Trim();
if (string.IsNullOrWhiteSpace(pathBase) || pathBase == "/")
return "/";
pathBase = "/" + pathBase.Trim('/');
return $"{pathBase}/";
}
}
}
@@ -0,0 +1,47 @@
@using TrafagSalesExporter.Services
@inject IFinanceCockpitAccessService FinanceAccess
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IUiTextService UiText
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Cockpit", "Finance Cockpit")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("Finance Cockpit ist geschuetzt. Bitte separat anmelden.", "Finance Cockpit is protected. Please sign in separately.")
</MudAlert>
@if (!FinanceAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
</MudAlert>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</MudButton>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private Task UnlockAsync()
{
if (!FinanceAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
{
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
return Task.CompletedTask;
}
_password = string.Empty;
Navigation.Refresh(forceReload: false);
return Task.CompletedTask;
}
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,870 @@
@using Microsoft.AspNetCore.Components
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@MetricGrid(Result.Metrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="7">
@TrafficLightPanel(Result.TrafficLights)
</MudItem>
<MudItem xs="12" md="5">
@MetricGrid(Result.PeriodComparisonMetrics)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@HeadcountByOrganisationTable(Result.HeadcountByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@CriticalBalancesTable(Result.CriticalTimeBalances)
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
@MetricGrid(Result.TurnoverMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@TurnoverRelevantTable(Result.FluctuationRelevantLeavers)
</MudItem>
<MudItem xs="12" md="6">
@LeaverExclusionTable(Result.Leavers)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@GroupValueTable(T("Austritte nach Austrittsart", "Leavers by exit type"), Result.LeaversByType, T("Austritte", "Leavers"))
</MudItem>
<MudItem xs="12" md="6">
@GroupValueTable(T("Austritte nach Organisation", "Leavers by organisation"), Result.LeaversByOrganisation, T("Austritte", "Leavers"))
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="4">
@TurnoverGauge(Result.TurnoverVisuals)
</MudItem>
<MudItem xs="12" md="4">
@TurnoverFunnel(Result.TurnoverVisuals.FunnelSteps)
</MudItem>
<MudItem xs="12" md="4">
@TurnoverDonut(Result.TurnoverVisuals.ExclusionReasons)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@HorizontalBars(Result.TurnoverVisuals.RelevantByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@MonthlyBars(Result.TurnoverVisuals)
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
@TrafficLightPanel(Result.TrafficLights)
@MetricGrid(Result.PeriodComparisonMetrics)
</MudTabPanel>
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
@MetricGrid(Result.AbsenceMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@GroupValueTable(T("Absenzen nach Organisation", "Absences by organisation"), Result.AbsenceByOrganisation, T("Krankheitstage", "Sick days"))
</MudItem>
<MudItem xs="12" md="6">
@TopAbsencesTable(Result.Absences)
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText>
<MudTable Items="Result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kurz", "Short")</MudTh>
<MudTh>@T("Lang", "Long")</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
<MudTh>@T("Quote", "Rate")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Personalnummer</MudTd>
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
<MudTd>@context.KrankenquoteMa.ToString("P1")</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
@MetricGrid(Result.TimeVacationMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@CriticalBalancesTable(Result.CriticalTimeBalances)
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische Restferien", "Critical vacation balance")</MudText>
<MudTable Items="Result.Employees.OrderByDescending(x => x.UrlaubRest).Take(25)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Rest", "Left")</MudTh>
<MudTh>@T("Ausstehend", "Open")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.UrlaubRest.ToString("N1")</MudTd>
<MudTd>@context.FerienAusstehend.ToString("N1")</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.RestferienAmpel)" Variant="Variant.Outlined">
@context.RestferienAmpel
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
@EmployeesTable(Result.Employees)
</MudTabPanel>
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
@FileStatusTable(Result.FileStatuses)
<MudGrid Class="mt-4">
<MudItem xs="12">
@DataQualityTable(Result.DataQualityIssues)
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline">
@GuidePanel()
</MudTabPanel>
</MudTabs>
@code {
[Parameter, EditorRequired] public HrKpiResult Result { get; set; } = new();
private string T(string german, string english) => UiText.Text(german, english);
private static Color MetricColor(string severity)
=> severity == "Warning" ? Color.Warning : Color.Default;
private static Color TrafficLightColor(string value)
=> value switch
{
"Rot" => Color.Error,
"Gelb" => Color.Warning,
_ => Color.Success
};
private static Color MapQualityColor(string severity)
=> severity switch
{
"Error" => Color.Error,
"Warning" => Color.Warning,
_ => Color.Info
};
private static string DisplayPersonName(string name, int? personalnummer, bool managementView)
=> managementView
? (personalnummer.HasValue ? $"Personalnr. {personalnummer.Value}" : "Person anonymisiert")
: name;
private static string FormatDate(DateTime? value)
=> value?.ToString("dd.MM.yyyy") ?? "-";
private RenderFragment<IReadOnlyList<HrKpiMetric>> MetricGrid => metrics => @<MudGrid Class="mb-4">
@foreach (var metric in metrics)
{
<MudItem xs="12" sm="6" md="3" lg="2">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@metric.Label</MudText>
<MudText Typo="Typo.h5">@metric.Value</MudText>
<MudText Typo="Typo.body2" Color="@MetricColor(metric.Severity)">@metric.Detail</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HeadcountByOrganisationTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Headcount nach Organisation", "Headcount by organisation")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Headcount", "Headcount")</MudTh>
<MudTh>FTE</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
<MudTd>@context.Value.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiTrafficLight>> TrafficLightPanel => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("HR-Ampel", "HR status")</MudText>
<MudGrid>
@foreach (var item in items)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Class="pa-3" Elevation="0">
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(item.Status)" Variant="Variant.Filled">
@item.Status
</MudChip>
<MudText Typo="Typo.subtitle2">@item.Area</MudText>
</MudStack>
<MudText Typo="Typo.h6">@item.Value</MudText>
<MudText Typo="Typo.body2">@item.Detail</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiDataQualityIssue>> DataQualityTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Schwere", "Severity")</MudTh>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Pruefpunkt", "Check")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
<MudTh>@T("Hinweis", "Note")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@MapQualityColor(context.Severity)" Variant="Variant.Outlined">
@context.Severity
</MudChip>
</MudTd>
<MudTd>@context.Area</MudTd>
<MudTd>@context.Issue</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
<MudTd>@context.Detail</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Datenqualitaetswarnungen.", "No data quality warnings.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>;
private RenderFragment<(string Title, IReadOnlyList<HrKpiGroupValue> Items, string ValueLabel)> GroupValueTableTuple => data => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@data.Title</MudText>
<MudTable Items="data.Items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Gruppe", "Group")</MudTh>
<MudTh>@data.ValueLabel</MudTh>
<MudTh>%</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@(context.Value != 0 ? context.Value.ToString("N1") : context.Count.ToString("N0"))</MudTd>
<MudTd>@context.Percent.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment GroupValueTable(string title, IReadOnlyList<HrKpiGroupValue> items, string valueLabel)
=> GroupValueTableTuple((title, items, valueLabel));
private RenderFragment<IReadOnlyList<HrAbsenceRow>> TopAbsencesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hoechste Absenzen", "Highest absences")</MudText>
<MudTable Items="items.OrderByDescending(x => x.KrankheitstageGesamt).Take(25)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kurz", "Short")</MudTh>
<MudTh>@T("Lang", "Long")</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Saldo", "Balance")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.StundenSaldo.ToString("N1")</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.GlzAmpel)" Variant="Variant.Outlined">
@context.GlzAmpel
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrLeaverRow>> TurnoverRelevantTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Fluktuationsrelevante Austritte", "Turnover relevant leavers")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Austritt", "Exit")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Austrittsart", "Exit type")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@FormatDate(context.Austrittsdatum)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.Austrittsart</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrLeaverRow>> LeaverExclusionTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
<MudTable Items="BuildLeaverExclusionRows(items)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Grund", "Reason")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> EmployeesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Mitarbeitende", "Employees")</MudText>
<MudTable Items="items.Take(250)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kostenstelle", "Cost center")</MudTh>
<MudTh>FTE</MudTh>
<MudTh>@T("Alter", "Age")</MudTh>
<MudTh>@T("Dienstjahre", "Service years")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Personalnummer</MudTd>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KostenstelleText</MudTd>
<MudTd>@context.Fte.ToString("N2")</MudTd>
<MudTd>@context.AlterJahre</MudTd>
<MudTd>@context.Dienstjahre</MudTd>
<MudTd>@context.Mitarbeitertyp</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiFileStatus>> FileStatusTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Dateistatus", "File status")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Stand", "Modified")</MudTh>
<MudTh>@T("Alter", "Age")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText Typo="Typo.body2">@context.Label</MudText>
<MudText Typo="Typo.caption">@context.Path</MudText>
</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
@(context.Message ?? "-")
</MudChip>
</MudTd>
<MudTd>@FormatDate(context.LastModified)</MudTd>
<MudTd>@(context.AgeDays.HasValue ? $"{context.AgeDays:N0} Tage / {context.FreshnessStatus}" : "-")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment GuidePanel() => @<MudGrid>
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ablauf fuer HR", "HR workflow")</MudText>
<div class="hr-guide-steps">
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.Download" Size="Size.Large" />
<span>1</span>
<strong>@T("Rexx exportieren", "Export from Rexx")</strong>
<p>@T("Die benoetigten Rexx-Abfragen manuell herunterladen. Excel/XLSX verwenden, nicht PDF.", "Download the required Rexx queries manually. Use Excel/XLSX, not PDF.")</p>
</div>
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.FolderCopy" Size="Size.Large" />
<span>2</span>
<strong>@T("Dateien ablegen", "Place files")</strong>
<p>@T("Downloads in den Datenordner kopieren und exakt wie unten benennen.", "Copy downloads into the data folder and name them exactly as listed below.")</p>
</div>
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Large" />
<span>3</span>
<strong>@T("Cockpit laden", "Load cockpit")</strong>
<p>@T("Im HR-KPI-Cockpit den Datenordner kontrollieren und Laden klicken.", "Check the data folder in the HR KPI cockpit and click Load.")</p>
</div>
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.FactCheck" Size="Size.Large" />
<span>4</span>
<strong>@T("Datenstatus pruefen", "Check data status")</strong>
<p>@T("Im Reiter Datenstatus muessen die erwarteten Dateien gruen erscheinen.", "In the Data status tab, the expected files should be green.")</p>
</div>
</div>
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenordner", "Data folder")</MudText>
<MudText Typo="Typo.body1">@Result.Options.DataFolder</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Der Standardordner ist konfigurierbar. Fuer einen anderen Ordner oben im HR-KPI-Filter den Datenordner anpassen und neu laden.",
"The default folder is configurable. To use another folder, change the data folder in the HR KPI filter above and reload.")
</MudAlert>
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mt-2">
@T("HR-Dateien enthalten Personendaten. Nicht per E-Mail weiterleiten und keine Kopien in ungeschuetzten Ordnern liegen lassen.",
"HR files contain personal data. Do not forward them by email and do not leave copies in unprotected folders.")
</MudAlert>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Neue Auswertungen im Cockpit", "New cockpit views")</MudText>
<MudGrid>
<MudItem xs="12" md="6">
<ul class="mb-0">
<li>@T("Managementsicht anonymisiert Personendaten fuer Fuehrungsberichte.", "Management view anonymizes personal data for management reports.")</li>
<li>@T("Dateistatus zeigt Pfad, Zeilen, Aenderungsdatum, Alter und Frische.", "File status shows path, rows, modification date, age and freshness.")</li>
<li>@T("HR-Ampel fasst Fluktuation, Krankheit, GLZ, Restferien und Datenqualitaet zusammen.", "HR status summarizes turnover, sickness, time balance, vacation balance and data quality.")</li>
<li>@T("GLZ- und Restferien-Ampeln koennen gefiltert werden.", "Time-balance and vacation status can be filtered.")</li>
<li>@T("Periodenvergleich zeigt die wichtigsten Vorjahreswerte, soweit Daten vorhanden sind.", "Period comparison shows key prior-year values where data is available.")</li>
</ul>
</MudItem>
<MudItem xs="12" md="6">
<ul class="mb-0">
<li>@T("Datenqualitaet markiert fehlende Dateien, alte Dateien und auffaellige Werte.", "Data quality flags missing files, old files and suspicious values.")</li>
<li>@T("Austritte werden nach Austrittsart und Organisation gruppiert.", "Leavers are grouped by exit type and organisation.")</li>
<li>@T("Absenzen werden nach Organisation ausgewertet.", "Absences are evaluated by organisation.")</li>
<li>@T("Top-Absenzen und kritische Detailtabellen helfen bei der operativen Pruefung.", "Top absences and critical detail tables support operational checks.")</li>
<li>@T("Drucken/PDF erzeugt eine weitergebbare Ansicht aus dem Browser.", "Print/PDF creates a shareable browser view.")</li>
</ul>
</MudItem>
</MudGrid>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Erwartete Dateien", "Expected files")</MudText>
<MudTable Items="Result.FileStatuses" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Inhalt", "Content")</MudTh>
<MudTh>@T("Datei/Pfad", "File/path")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Path</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
@(context.Exists ? T("gefunden", "found") : T("fehlt", "missing"))
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>;
private static IEnumerable<HrKpiGroupValue> BuildLeaverExclusionRows(IReadOnlyList<HrLeaverRow> items)
=> items
.GroupBy(x => x.FluktuationAusschlussgrund ?? "Relevant")
.Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count() })
.OrderByDescending(x => x.Count);
private RenderFragment<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@visual.RateTitle</MudText>
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
<div class="hr-gauge-track"></div>
<div class="hr-gauge-needle"></div>
<div class="hr-gauge-center">
<div class="hr-gauge-value">@visual.YearRateLabel</div>
<div class="hr-gauge-caption">0-20%</div>
</div>
</div>
<div class="hr-gauge-scale">
<span>0%</span>
<span>8%</span>
<span>12%</span>
<span>20%+</span>
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverFunnel => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Austritts-Funnel", "Leaver funnel")</MudText>
<div class="hr-funnel">
@foreach (var item in items)
{
<div class="hr-funnel-row">
<div class="hr-funnel-label">@item.Label</div>
<div class="hr-funnel-bar-wrap">
<div class="hr-funnel-bar" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
<span>@item.Count.ToString("N0")</span>
</div>
</div>
</div>
}
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverDonut => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
<div class="hr-donut-wrap">
<div class="hr-donut" style="@BuildDonutStyle(items)">
<div class="hr-donut-hole">@items.Sum(x => x.Count).ToString("N0")</div>
</div>
<div class="hr-donut-legend">
@foreach (var item in items.Take(7))
{
<div class="hr-legend-row">
<span class="hr-legend-dot" style="@($"background:{item.Color}")"></span>
<span>@item.Label</span>
<strong>@item.Count.ToString("N0")</strong>
</div>
}
</div>
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HorizontalBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte nach Organisation", "Relevant leavers by organisation")</MudText>
<div class="hr-bars">
@foreach (var item in items)
{
<div class="hr-bar-row">
<div class="hr-bar-label">@item.Label</div>
<div class="hr-bar-track">
<div class="hr-bar-fill" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
</div>
<div class="hr-bar-value">@item.Count.ToString("N0")</div>
</div>
}
</div>
</MudPaper>;
private RenderFragment<HrTurnoverVisuals> MonthlyBars => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@visual.TimelineTitle</MudText>
<div class="hr-month-bars">
@foreach (var item in visual.MonthlyRelevantLeavers)
{
<div class="hr-month">
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
<div class="hr-month-value">@item.Count</div>
<div class="hr-month-label">@item.Label</div>
</div>
}
</div>
</MudPaper>;
private static string BuildDonutStyle(IReadOnlyList<HrKpiGroupValue> items)
{
var total = items.Sum(x => x.Count);
if (total <= 0)
return "background:#e0e0e0";
var current = 0m;
var segments = new List<string>();
foreach (var item in items)
{
var start = current;
current += item.Count / (decimal)total * 100m;
segments.Add($"{item.Color} {start.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}% {current.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%");
}
return $"background:conic-gradient({string.Join(", ", segments)})";
}
}
<style>
.hr-viz-panel {
min-height: 100%;
}
.hr-guide-steps {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 12px;
}
.hr-guide-step {
min-height: 175px;
padding: 16px;
border: 1px solid var(--mud-palette-lines-default);
border-top: 5px solid var(--mud-palette-primary);
display: flex;
flex-direction: column;
gap: 8px;
background: var(--mud-palette-surface);
}
.hr-guide-step span {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-grid;
place-items: center;
color: var(--mud-palette-primary-text);
background: var(--mud-palette-primary);
font-weight: 700;
}
.hr-guide-step p {
margin: 0;
color: var(--mud-palette-text-secondary);
}
@@media (max-width: 1100px) {
.hr-guide-steps {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
@@media (max-width: 700px) {
.hr-guide-steps {
grid-template-columns: 1fr;
}
}
.hr-gauge {
--gauge-color: #2e7d32;
--gauge-deg: 0deg;
position: relative;
height: 170px;
display: grid;
place-items: end center;
overflow: hidden;
}
.hr-gauge-track {
width: 260px;
height: 130px;
border-radius: 260px 260px 0 0;
background: conic-gradient(from 270deg at 50% 100%, #2e7d32 0deg 72deg, #f9a825 72deg 108deg, #c62828 108deg 180deg, transparent 180deg 360deg);
position: absolute;
bottom: 0;
}
.hr-gauge-track::after {
content: "";
position: absolute;
left: 34px;
right: 34px;
bottom: 0;
height: 96px;
border-radius: 192px 192px 0 0;
background: var(--mud-palette-surface);
}
.hr-gauge-needle {
position: absolute;
bottom: 4px;
width: 4px;
height: 112px;
background: #263238;
transform-origin: bottom center;
transform: rotate(calc(var(--gauge-deg) - 90deg));
border-radius: 4px;
z-index: 2;
}
.hr-gauge-center {
z-index: 3;
text-align: center;
margin-bottom: 4px;
}
.hr-gauge-value {
font-size: 2rem;
font-weight: 700;
color: var(--gauge-color);
}
.hr-gauge-caption,
.hr-gauge-scale {
color: var(--mud-palette-text-secondary);
font-size: .8rem;
}
.hr-gauge-scale {
display: flex;
justify-content: space-between;
max-width: 280px;
margin: 4px auto 0;
}
.hr-funnel-row,
.hr-bar-row,
.hr-legend-row {
display: grid;
grid-template-columns: minmax(110px, 1fr) 2fr auto;
gap: 10px;
align-items: center;
margin: 9px 0;
}
.hr-funnel-bar-wrap,
.hr-bar-track {
background: rgba(0,0,0,.08);
border-radius: 4px;
height: 24px;
overflow: hidden;
}
.hr-funnel-bar,
.hr-bar-fill {
height: 100%;
border-radius: 4px;
color: white;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
min-width: 26px;
}
.hr-donut-wrap {
display: grid;
grid-template-columns: 150px 1fr;
gap: 18px;
align-items: center;
}
.hr-donut {
width: 150px;
height: 150px;
border-radius: 50%;
position: relative;
}
.hr-donut::after {
content: "";
position: absolute;
inset: 34px;
border-radius: 50%;
background: var(--mud-palette-surface);
}
.hr-donut-hole {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 2;
font-weight: 700;
font-size: 1.35rem;
}
.hr-legend-row {
grid-template-columns: auto 1fr auto;
margin: 6px 0;
font-size: .9rem;
}
.hr-legend-dot {
width: 11px;
height: 11px;
border-radius: 50%;
display: inline-block;
}
.hr-bars {
display: grid;
gap: 7px;
}
.hr-bar-row {
grid-template-columns: minmax(130px, 1.2fr) 2fr auto;
}
.hr-month-bars {
height: 240px;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 8px;
align-items: end;
}
.hr-month {
height: 100%;
display: grid;
grid-template-rows: 1fr auto auto;
align-items: end;
text-align: center;
color: var(--mud-palette-text-secondary);
font-size: .8rem;
}
.hr-month-bar {
width: 100%;
min-height: 2px;
border-radius: 4px 4px 0 0;
}
.hr-month-value {
color: var(--mud-palette-text-primary);
font-weight: 600;
}
@@media (max-width: 700px) {
.hr-donut-wrap {
grid-template-columns: 1fr;
justify-items: center;
}
.hr-funnel-row,
.hr-bar-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
</style>
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable @implements IDisposable
@using System.Security.Claims
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
<MudThemeProvider Theme="_theme" /> <MudThemeProvider Theme="_theme" />
@@ -23,6 +24,11 @@
<MudSelectItem Value="@("de")">DE</MudSelectItem> <MudSelectItem Value="@("de")">DE</MudSelectItem>
<MudSelectItem Value="@("en")">EN</MudSelectItem> <MudSelectItem Value="@("en")">EN</MudSelectItem>
</MudSelect> </MudSelect>
<AuthorizeView>
<Authorized Context="authState">
<MudText Typo="Typo.caption" Class="mr-3">@ShortName(authState.User)</MudText>
</Authorized>
</AuthorizeView>
<img src="trafag.jpg" alt="Trafag" class="app-logo" /> <img src="trafag.jpg" alt="Trafag" class="app-logo" />
</MudAppBar> </MudAppBar>
@@ -67,6 +73,13 @@
private string T(string german, string english) => UiText.Text(german, english); private string T(string german, string english) => UiText.Text(german, english);
private static string ShortName(ClaimsPrincipal user)
{
var name = user.Identity?.Name ?? string.Empty;
var separator = name.LastIndexOf('\\');
return separator >= 0 && separator < name.Length - 1 ? name[(separator + 1)..] : name;
}
public void Dispose() public void Dispose()
{ {
UiText.Changed -= HandleLanguageChanged; UiText.Changed -= HandleLanguageChanged;
@@ -1,26 +1,68 @@
@using TrafagSalesExporter.Security
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
@inject IConfiguration Configuration
@inject NavigationManager Navigation
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
@T("Dashboard", "Dashboard") <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
</MudNavLink> @T("Export Dashboard", "Export dashboard")
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn"> </MudNavLink>
@T("Standorte", "Sites") <MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
</MudNavLink> @T("Management Analyse", "Management analysis")
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform"> </MudNavLink>
@T("Transformationen", "Transformations") @if (ShowFinanceComparison)
</MudNavLink> {
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics"> <MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
@T("Management Cockpit", "Management Cockpit") @T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings"> }
@T("Settings", "Settings") <MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
</MudNavLink> @T("Manuelle Importe", "Manual imports")
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List"> </MudNavLink>
@T("Logs", "Logs") <MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
@T("Standorte", "Sites")
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
@T("Transformationen", "Transformations")
</MudNavLink>
<MudNavLink Href="/finance-rules" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Rule">
@T("Finance Regeln", "Finance rules")
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@T("Settings", "Settings")
</MudNavLink>
</Authorized>
</AuthorizeView>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
@T("Logs", "Logs")
</MudNavLink>
</MudNavGroup>
@if (FinanceAccess.IsEnabled && FinanceAccess.IsUnlocked)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Lock" OnClick="LockFinanceCockpit" Class="ml-3">
@T("Finance sperren", "Lock finance")
</MudButton>
}
</MudNavGroup>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI (Login)", "HR KPI (login)")
</MudNavLink> </MudNavLink>
</MudNavMenu> </MudNavMenu>
@code { @code {
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
private void LockFinanceCockpit()
{
FinanceAccess.Lock();
Navigation.NavigateTo("/");
}
private string T(string german, string english) => UiText.Text(german, english); private string T(string german, string english) => UiText.Text(german, english);
} }
@@ -8,9 +8,9 @@
@inject IUiTextService UiText @inject IUiTextService UiText
@implements IDisposable @implements IDisposable
<PageTitle>@T("Dashboard", "Dashboard")</PageTitle> <PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4"> <MudStack Row AlignItems="AlignItems.Center" Spacing="4">
@@ -37,9 +37,29 @@
</MudStack> </MudStack>
</MudPaper> </MudPaper>
@if (_readinessWarnings.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4">
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText>
@foreach (var warning in _readinessWarnings)
{
<MudText Typo="Typo.caption">@warning</MudText>
}
</MudAlert>
}
@if (_consolidatedStale)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
@T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.",
"At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.")
</MudAlert>
}
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading"> <MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent> <HeaderContent>
<MudTh>@T("Land", "Country")</MudTh> <MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Basis", "Basis")</MudTh>
<MudTh>TSC</MudTh> <MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh> <MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh> <MudTh>@T("Server", "Server")</MudTh>
@@ -52,6 +72,14 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Land</MudTd> <MudTd>@context.Land</MudTd>
<MudTd>
<MudTooltip Text="@context.DataBasis">
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
</MudStack>
</MudTooltip>
</MudTd>
<MudTd>@context.TSC</MudTd> <MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd> <MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd> <MudTd>@context.ServerName</MudTd>
@@ -155,6 +183,8 @@
@code { @code {
private List<DashboardRow> _dashboardRows = new(); private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new(); private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<string> _readinessWarnings = new();
private bool _consolidatedStale;
private bool _loading = true; private bool _loading = true;
private bool _anyRunning; private bool _anyRunning;
private CancellationTokenSource? _pollingCts; private CancellationTokenSource? _pollingCts;
@@ -171,6 +201,8 @@
var state = await DashboardPageActions.LoadAsync(); var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows; _dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows; _consolidatedRows = state.ConsolidatedRows;
_readinessWarnings = state.ReadinessWarnings;
_consolidatedStale = state.IsConsolidatedStale;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false; _loading = false;
@@ -178,17 +210,36 @@
private async Task ExportAll() private async Task ExportAll()
{ {
if (_readinessWarnings.Count > 0)
{
Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.",
"There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning);
}
_anyRunning = true; _anyRunning = true;
await LoadDataAsync(); await LoadDataAsync();
StartPolling(); StartPolling();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await Orchestrator.ExportAllAsync(); try
await InvokeAsync(async () =>
{ {
await LoadDataAsync(); await Orchestrator.ExportAllAsync();
StateHasChanged(); await InvokeAsync(() =>
}); Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
}
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
}); });
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info); Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
} }
@@ -200,22 +251,33 @@
StartPolling(); StartPolling();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync(); try
await InvokeAsync(async () =>
{ {
await LoadDataAsync(); var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
StateHasChanged();
});
if (!string.IsNullOrWhiteSpace(filePath)) if (!string.IsNullOrWhiteSpace(filePath))
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success)); Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
}
else
{
await InvokeAsync(() =>
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
}
} }
else catch (Exception ex)
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden.", "Consolidated file could not be created."), Severity.Warning)); Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
} }
}); });
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info); Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
@@ -228,22 +290,36 @@
StartPolling(); StartPolling();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
var result = await Orchestrator.ExportSiteByIdAsync(siteId); try
await InvokeAsync(async () =>
{ {
await LoadDataAsync(); var result = await Orchestrator.ExportSiteByIdAsync(siteId);
StateHasChanged();
});
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath)) if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success)); Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
await InvokeAsync(() =>
Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.",
"The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info));
}
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
}
} }
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage)) catch (Exception ex)
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error)); Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
} }
}); });
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info); Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
@@ -366,6 +442,39 @@
return Task.CompletedTask; return Task.CompletedTask;
} }
private static string FormatException(Exception ex)
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
private static string GetDataBasisIcon(string dataBasis)
{
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.TableView;
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.Description;
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.CloudSync;
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.Storage;
return Icons.Material.Filled.Source;
}
private static Color GetDataBasisColor(string dataBasis)
{
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
return Color.Success;
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
return Color.Info;
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
return Color.Primary;
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
return Color.Secondary;
return Color.Default;
}
} }
@code { @code {
@@ -0,0 +1,216 @@
@page "/finance-cockpit/vergleich"
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceReconciliationService FinanceReconciliationService
@inject IUiTextService UiText
<PageTitle>@T("Soll/Ist Vergleich", "Actual/reference comparison")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Soll/Ist Vergleich", "Actual/reference comparison")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudText Typo="Typo.caption">@T("Verbindliche Finance-Sicht aus CentralSalesRecords", "Authoritative finance view from CentralSalesRecords")</MudText>
</div>
<MudSpacer />
<MudButton Variant="@(_hideRowsWithoutActual ? Variant.Filled : Variant.Outlined)"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.FilterAlt"
OnClick="ToggleActualFilter">
@T("Ohne Ist", "Without empty actuals")
</MudButton>
<MudText Typo="Typo.caption">
@string.Format(T("{0:N0}/{1:N0} Zeilen", "{0:N0}/{1:N0} rows"), FilteredNetSalesReferenceRows.Count, _netSalesReferenceRows.Count)
</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadAsync" Disabled="_loading">
@(_loading ? T("Lade...", "Loading...") : T("Aktualisieren", "Refresh"))
</MudButton>
</MudStack>
<MudTable Items="FilteredNetSalesReferenceRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Ampel", "Status")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
<MudTh>@T("Referenz", "Reference")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Berechnung", "Calculation")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Varianten", "Variants")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@StatusColor(context.Status)" Variant="Variant.Filled">
@StatusText(context.Status)
</MudChip>
</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@context.Label</MudText>
<MudText Typo="Typo.caption">@context.Key</MudText>
</MudTd>
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
<MudTd>@FormatAmount(context.Difference)</MudTd>
<MudTd>@FormatCurrency(context)</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudText>
<MudText Typo="Typo.caption">@BuildCalculationHint(context)</MudText>
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>
@if (context.Candidates.Count > 0)
{
<details>
<summary>@context.Candidates.Count @T("Varianten anzeigen", "show variants")</summary>
<table class="finance-variant-table">
<thead>
<tr>
<th>@T("Abgrenzung", "Scope")</th>
<th>@T("Waehrung", "Currency")</th>
<th>@T("Wert", "Value")</th>
<th>@T("Diff.", "Diff.")</th>
<th>@T("IC", "IC")</th>
<th>@T("Diff ohne IC", "Diff excl. IC")</th>
</tr>
</thead>
<tbody>
@foreach (var candidate in context.Candidates)
{
<tr class="@(candidate.IsPreferred ? "preferred-variant" : string.Empty)">
<td>@candidate.Label</td>
<td>@candidate.Currency</td>
<td class="num">@FormatAmount(candidate.Value)</td>
<td class="num">@FormatAmount(candidate.Difference)</td>
<td class="num">@FormatAmount(candidate.IntercompanyValue)</td>
<td class="num">@FormatAmount(candidate.DifferenceExcludingIntercompany)</td>
</tr>
}
</tbody>
</table>
</details>
}
else
{
<span>-</span>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
</NoRecordsContent>
</MudTable>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Diese Seite nutzt dieselbe FinanceReconciliationService-Logik wie das lokale Testprogramm. Vergleich: Jahr 2025 aus Buchungsdatum, sonst Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Abgleich und veraendert die Originaldaten nicht.", "This page uses the same FinanceReconciliationService logic as the local test program. Comparison: year 2025 from posting date, otherwise invoice date, otherwise extraction date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current reconciliation and does not change the original data.")
</MudAlert>
</MudPaper>
<style>
.finance-variant-table {
border-collapse: collapse;
margin-top: 8px;
min-width: 720px;
font-size: 0.82rem;
}
.finance-variant-table th,
.finance-variant-table td {
border: 1px solid var(--mud-palette-lines-default);
padding: 4px 6px;
vertical-align: top;
}
.finance-variant-table th {
background: var(--mud-palette-background-grey);
font-weight: 600;
text-align: left;
}
.finance-variant-table .num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.preferred-variant {
background: rgba(33, 150, 243, 0.08);
}
</style>
@code {
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
private bool _hideRowsWithoutActual = true;
private bool _loading = true;
private List<NetSalesReferenceRow> FilteredNetSalesReferenceRows
=> _hideRowsWithoutActual
? _netSalesReferenceRows.Where(row => row.ActualValue.HasValue).ToList()
: _netSalesReferenceRows;
private void ToggleActualFilter()
{
_hideRowsWithoutActual = !_hideRowsWithoutActual;
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_netSalesReferenceRows = await FinanceReconciliationService.BuildNetSalesReferenceRowsAsync(2025);
_loading = false;
}
private static string FormatAmount(decimal? value)
=> value.HasValue ? value.Value.ToString("N2") : "-";
private static string FormatCurrency(NetSalesReferenceRow row)
{
if (!string.IsNullOrWhiteSpace(row.ActualCurrency))
return row.ReferenceCurrency == "LC"
? $"{row.ActualCurrency} / Soll LC"
: row.ActualCurrency;
return string.IsNullOrWhiteSpace(row.Currencies) ? "-" : row.Currencies;
}
private string BuildCalculationHint(NetSalesReferenceRow row)
{
if (row.Key.Equals("UK", StringComparison.OrdinalIgnoreCase))
return T("Sage Netto in GBP; Credit Notes negativ; Soll ist Local Currency.", "Sage net in GBP; credit notes negative; reference is local currency.");
if (row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase))
return T("Sage ImporteNeto; REC/Credit Notes negativ; Zuschlaege/Nebenkosten noch pruefen.", "Sage ImporteNeto; REC/credit notes negative; surcharges/charges still to check.");
if (row.Key.Equals("IT", StringComparison.OrdinalIgnoreCase))
return T("Bestaetigte IT-Regel: Trafag Italia ausgeschlossen; doppelte Zeilen ohne Supplier country nur einmal.", "Confirmed IT rule: Trafag Italia excluded; duplicate rows without supplier country counted once.");
if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase))
return T("Alphaplan Excel; Finance-Regeln gemäss Deutschland-Rueckmeldung: Weiterberechnungen ausgeschlossen, GS negativ, GS2510095 2024.", "Alphaplan Excel; finance rules per Germany response: recharges excluded, credit notes negative, GS2510095 in 2024.");
if (row.Key.Equals("FR", StringComparison.OrdinalIgnoreCase) ||
row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) ||
row.Key.Equals("US", StringComparison.OrdinalIgnoreCase))
return T("Passt gegen Soll; Sales Price/Value ist bevorzugte Variante.", "Matches reference; Sales Price/Value is the preferred variant.");
return row.ReferenceCurrency == "LC"
? T("Vergleich gegen Local Currency Referenz.", "Compared against local currency reference.")
: T("Vergleich gegen Check-/Sollwert.", "Compared against check/reference value.");
}
private Color StatusColor(string status)
=> status == "OK" ? Color.Success
: status == "Pruefen" ? Color.Warning
: Color.Default;
private string StatusText(string status)
=> status == "OK" ? "OK"
: status == "Pruefen" ? T("Pruefen", "Check")
: T("Keine Daten", "No data");
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,167 @@
@page "/finance-rules"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceRulesPageService FinanceRulesPageActions
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Finance Regeln", "Finance rules")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Regeln", "Finance rules")</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
@T("Diese Regeln wirken nur auf die Finance-Sicht im zentralen Excel und im Abgleich. Rohdaten und Spaltenmapping bleiben unveraendert.",
"These rules only affect the finance view in the central Excel and reconciliation. Raw data and column mappings remain unchanged.")
</MudAlert>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
@T("Regel hinzufuegen", "Add rule")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
@T("Alle speichern", "Save all")
</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Restore" OnClick="LoadDefaults">
@T("Default-Regeln laden", "Load default rules")
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>Land</MudTh>
<MudTh>Jahr</MudTh>
<MudTh>Regeltyp</MudTh>
<MudTh>Feld</MudTh>
<MudTh>Vergleich</MudTh>
<MudTh>Wert</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Notiz</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd><MudTextField @bind-Value="context.ScopeKey" Placeholder="DE" Style="width:80px" /></MudTd>
<MudTd><MudNumericField T="int?" @bind-Value="context.Year" Style="width:90px" /></MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.RuleType" Dense>
@foreach (var type in FinanceRuleTypes.All)
{
<MudSelectItem Value="@type">@GetRuleTypeLabel(type)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.FieldName" Dense Disabled="@UsesNoField(context)">
<MudSelectItem Value="@string.Empty">-</MudSelectItem>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.MatchType" Dense>
@foreach (var type in FinanceRuleMatchTypes.All)
{
<MudSelectItem Value="@type">@GetMatchTypeLabel(type)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.MatchValue" Disabled="@UsesNoMatchValue(context)" /></MudTd>
<MudTd><MudNumericField T="int" @bind-Value="context.SortOrder" Style="width:80px" /></MudTd>
<MudTd><MudTextField @bind-Value="context.Notes" /></MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(property => property.Name)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private List<FinanceRule> _rules = [];
protected override async Task OnInitializedAsync()
{
_rules = await FinanceRulesPageActions.LoadAsync();
}
private void AddRule()
{
_rules.Add(new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
SortOrder = _rules.Count == 0 ? 100 : _rules.Max(rule => rule.SortOrder) + 10,
IsActive = true
});
}
private void RemoveRule(FinanceRule rule) => _rules.Remove(rule);
private void LoadDefaults()
{
_rules = FinanceRuleEngine.CreateDefaultRules()
.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
})
.ToList();
}
private async Task SaveAllAsync()
{
_rules = await FinanceRulesPageActions.SaveAllAsync(_rules);
Snackbar.Add(T("Finance-Regeln gespeichert.", "Finance rules saved."), Severity.Success);
}
private static bool UsesNoField(FinanceRule rule)
=> rule.RuleType == FinanceRuleTypes.ForceYear ||
rule.MatchType == FinanceRuleMatchTypes.Always;
private static bool UsesNoMatchValue(FinanceRule rule)
=> rule.MatchType is FinanceRuleMatchTypes.Always or FinanceRuleMatchTypes.IsBlank;
private string GetRuleTypeLabel(string type)
=> type switch
{
FinanceRuleTypes.Exclude => T("Ausschliessen", "Exclude"),
FinanceRuleTypes.NegateAmount => T("Betrag negativ", "Negate amount"),
FinanceRuleTypes.ForceYear => T("Jahr erzwingen", "Force year"),
FinanceRuleTypes.DeduplicateBlankSupplierCountry => T("Duplikate ohne Supplier Country", "Deduplicate blank supplier country"),
_ => type
};
private string GetMatchTypeLabel(string type)
=> type switch
{
FinanceRuleMatchTypes.Always => T("Immer", "Always"),
FinanceRuleMatchTypes.Equal => T("gleich", "equals"),
FinanceRuleMatchTypes.Contains => T("enthaelt", "contains"),
FinanceRuleMatchTypes.StartsWith => T("beginnt mit", "starts with"),
FinanceRuleMatchTypes.IsBlank => T("ist leer", "is blank"),
_ => type
};
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,263 @@
@page "/hr-kpi"
@using Microsoft.Extensions.Options
@using TrafagSalesExporter.Components.HrKpi
@using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService
@inject IOptions<HrKpiDataSourceOptions> DataSourceOptions
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText>
@if (!CanShowHrKpi)
{
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("HR KPI enthaelt sensible Personaldaten. Bitte separat anmelden.", "HR KPI contains sensitive HR data. Please sign in separately.")
</MudAlert>
@if (!HrKpiAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("HR-KPI-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in HrKpiAccess konfigurieren.", "HR KPI access is not configured yet. Please configure Username and PasswordHash in HrKpiAccess.")
</MudAlert>
}
<MudTextField @bind-Value="_hrUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_hrPassword" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockHrKpiAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("HR KPI entsperren", "Unlock HR KPI")
</MudButton>
</MudStack>
</MudPaper>
}
else
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="5">
<MudTextField @bind-Value="_dataFolder"
Label="@T("Datenordner fuer Rexx/SAP-Dateien", "Data folder for Rexx/SAP files")"
HelperText="@T("Standard ist C:\\temp. Der Ordner kann hier fuer den aktuellen Lauf angepasst oder dauerhaft in appsettings.json unter HrKpi:DataFolder geaendert werden.", "Default is C:\\temp. The folder can be changed here for the current run or permanently in appsettings.json under HrKpi:DataFolder.")" />
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
@foreach (var option in _result?.ExitYearOptions ?? [])
{
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
@foreach (var option in _result?.OrganisationOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudSwitch T="bool" @bind-Value="_managementView" Color="Color.Primary"
Label="@T("Managementsicht", "Management view")" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
@foreach (var option in _result?.EntryYearOptions ?? [])
{
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable>
@foreach (var option in _result?.KostenstelleOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable>
@foreach (var option in _result?.MitarbeitertypOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense>
@foreach (var option in _fluktuationOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable>
@foreach (var option in _ampelOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable>
@foreach (var option in _restferienOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LockHrKpi"
StartIcon="@Icons.Material.Filled.Lock" FullWidth>
@T("Sperren", "Lock")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="PrintAsync"
StartIcon="@Icons.Material.Filled.Print" FullWidth>
@T("Drucken/PDF", "Print/PDF")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
}
@if (CanShowHrKpi && _result is not null)
{
@if (_result.Notices.Count > 0)
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
@foreach (var notice in _result.Notices)
{
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
}
</MudPaper>
}
<HrKpiDashboardTabs Result="_result" />
}
@code {
private string _dataFolder = HrKpiDataSourceOptions.DefaultFolder;
private int? _year;
private DateTime? _fromDate;
private DateTime? _toDate;
private int? _entryYear;
private string? _organisation;
private string? _kostenstelle;
private string? _mitarbeitertyp;
private string _fluktuationFilter = "Alle";
private string? _glzAmpel;
private string? _restferienAmpel;
private string? _searchText;
private bool _managementView;
private string? _hrUsername;
private string? _hrPassword;
private bool _loading;
private HrKpiResult? _result;
private readonly List<(string Key, string Label)> _fluktuationOptions =
[
("Alle", "Alle"),
("Fluktuationsrelevant", "Relevant"),
("Arbeitnehmerkuendigung", "Arbeitnehmerkuendigung"),
("Ausgeschlossen", "Ausgeschlossen")
];
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
protected override async Task OnInitializedAsync()
{
_dataFolder = DataSourceOptions.Value.Normalize().DataFolder;
if (CanShowHrKpi)
{
await LoadAsync();
}
}
private async Task LoadAsync()
{
if (!CanShowHrKpi)
{
return;
}
_loading = true;
try
{
_result = await HrKpiService.BuildAsync(new HrKpiOptions
{
DataFolder = _dataFolder,
Year = _year,
FromDate = _fromDate,
ToDate = _toDate,
EntryYear = _entryYear,
Organisationseinheit = _organisation,
KostenstelleText = _kostenstelle,
Mitarbeitertyp = _mitarbeitertyp,
FluktuationFilter = _fluktuationFilter,
GlzAmpel = _glzAmpel,
RestferienAmpel = _restferienAmpel,
SearchText = _searchText,
ManagementView = _managementView
});
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task UnlockHrKpiAsync()
{
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
{
Snackbar.Add(T("HR-KPI-Anmeldung fehlgeschlagen.", "HR KPI sign-in failed."), Severity.Error);
return;
}
_hrPassword = string.Empty;
await LoadAsync();
}
private void LockHrKpi()
{
HrKpiAccess.Lock();
_result = null;
_hrPassword = string.Empty;
}
private async Task PrintAsync()
{
await JsRuntime.InvokeVoidAsync("print");
}
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -5,13 +5,145 @@
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText @inject IUiTextService UiText
<PageTitle>@T("Management Cockpit", "Management Cockpit")</PageTitle> <PageTitle>@T("Management Analyse", "Management analysis")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Management Cockpit", "Management Cockpit")</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Management Analyse", "Management analysis")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid> <MudGrid>
<MudItem xs="12" md="8"> <MudItem xs="12" md="3">
<MudSelect T="int" @bind-Value="_selectedFinanceYear" Label="@T("Finance-Jahr", "Finance year")" Dense>
@foreach (var year in _financeYearOptions)
{
<MudSelectItem Value="@year">@year</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFinanceCountryKey" Label="@T("Land", "Country")" Dense Clearable>
@foreach (var option in _financeCountryOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFinanceCurrency" Label="@T("Waehrung", "Currency")" Dense Clearable>
@foreach (var option in _financeCurrencyOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AnalyzeFinanceSummary"
StartIcon="@Icons.Material.Filled.FactCheck" Disabled="_analyzingFinance" FullWidth>
@(_analyzingFinance ? T("Lade...", "Loading...") : T("Finance Summary laden", "Load finance summary"))
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@if (_financeResult is not null)
{
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Finance Summary", "Finance summary")" Icon="@Icons.Material.Filled.Dashboard">
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Net Sales Actual", "Net sales actual")</MudText>
<MudText Typo="Typo.h5">@FormatValue(_financeResult.NetSalesActual, _financeResult.DisplayCurrency)</MudText>
<MudText Typo="Typo.body2">@T("gefiltertes Endergebnis", "filtered final result")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Enthaltene Zeilen", "Included rows")</MudText>
<MudText Typo="Typo.h5">@_financeResult.IncludedRows.ToString("N0")</MudText>
<MudText Typo="Typo.body2">@T("Finance Include = TRUE", "Finance Include = TRUE")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Ausgeschlossen", "Excluded")</MudText>
<MudText Typo="Typo.h5">@_financeResult.ExcludedRows.ToString("N0")</MudText>
<MudText Typo="Typo.body2">@T("Finance-Regeln", "Finance rules")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Laender / Waehrungen", "Countries / currencies")</MudText>
<MudText Typo="Typo.h5">@($"{_financeResult.CountryCount:N0} / {_financeResult.CurrencyCount:N0}")</MudText>
<MudText Typo="Typo.body2">@($"{_financeResult.Filter.Year}")</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudGrid Class="mb-4">
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Summen wie im Excel-Blatt Finance Summary", "Totals matching the Finance Summary Excel sheet")</MudText>
<MudTable Items="_financeResult.Rows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Net Sales Actual", "Net sales actual")</MudTh>
<MudTh>@T("Enthalten", "Included")</MudTh>
<MudTh>@T("Ausgeschlossen", "Excluded")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Year</MudTd>
<MudTd>@context.CountryKey</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@context.IncludedRows.ToString("N0")</MudTd>
<MudTd>@context.ExcludedRows.ToString("N0")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">
@T("Keine Finance-Summary-Daten fuer diese Filter.", "No finance summary data for these filters.")
</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _financeResult.Notices)
{
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
}
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahresvergleich mit aktuellem Filter", "Year comparison with current filter")</MudText>
<MudTable Items="_financeResult.YearRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Net Sales Actual", "Net sales actual")</MudTh>
<MudTh>@T("Enthalten", "Included")</MudTh>
<MudTh>@T("Ausgeschlossen", "Excluded")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Year</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@context.IncludedRows.ToString("N0")</MudTd>
<MudTd>@context.ExcludedRows.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Rohdaten Diagnose", "Raw-data diagnostics")" Icon="@Icons.Material.Filled.QueryStats">
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="@T("Vorhandene Excel-Datei", "Available Excel file")" Dense> <MudSelect T="string" @bind-Value="_selectedFilePath" Label="@T("Vorhandene Excel-Datei", "Available Excel file")" Dense>
@foreach (var file in _files) @foreach (var file in _files)
{ {
@@ -19,7 +151,23 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFileValueField" Label="@T("Summenfeld", "Value field")" Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFileTargetCurrency" Label="@T("Anzeige-Waehrung", "Display currency")" Dense>
@foreach (var option in _currencyOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2"> <MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles" <MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles"> StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
@@ -37,10 +185,14 @@
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText> <MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3"> <MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords` und zeigt nur fachlich neutrale Rohkennzahlen. Kein Intercompany-Filter, keine CHF-Umrechnung, kein Budget, keine Spartenlogik.", "This view works directly on `CentralSalesRecords` and shows only neutral raw metrics. No intercompany filter, no CHF conversion, no budget, no divisional logic.") @T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.")
</MudAlert>
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.",
"This analysis is a plausibility/raw-data view. For the authoritative finance reconciliation, use `Actual/reference comparison` or the `Finance | ...` columns in the final Excel.")
</MudAlert> </MudAlert>
<MudGrid> <MudGrid>
<MudItem xs="12" md="4"> <MudItem xs="12" md="2">
<MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense> <MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense>
@foreach (var year in _centralYears) @foreach (var year in _centralYears)
{ {
@@ -48,7 +200,13 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="2">
<MudTextField @bind-Value="_centralLandFilter" Label="@T("Landfilter", "Country filter")" />
</MudItem>
<MudItem xs="12" md="2">
<MudTextField @bind-Value="_centralTscFilter" Label="TSC" />
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable> <MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable>
@foreach (var month in Enumerable.Range(1, 12)) @foreach (var month in Enumerable.Range(1, 12))
{ {
@@ -56,11 +214,52 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="3">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral" <MudSelect T="string" @bind-Value="_selectedCentralValueField" Label="@T("Summenfeld", "Value field")" Dense>
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0"> @foreach (var option in _valueFieldOptions)
@(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis")) {
</MudButton> <MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string"
SelectedValues="_selectedCentralAdditionalValueFields"
SelectedValuesChanged="SetSelectedCentralAdditionalValueFields"
MultiSelection="true"
Label="@T("Weitere Summenfelder", "Additional value fields")"
Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_selectedCentralTargetCurrency" Label="@T("Anzeige-Waehrung", "Display currency")" Dense>
@foreach (var option in _currencyOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0">
@(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis"))
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" OnClick="ClearCentralScope"
StartIcon="@Icons.Material.Filled.FilterAltOff">
@T("Global", "Global")
</MudButton>
@if (!string.IsNullOrWhiteSpace(_centralLandFilter) || !string.IsNullOrWhiteSpace(_centralTscFilter))
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Outlined">
@T("Gefiltert", "Filtered"): @($"{(_centralLandFilter ?? "-")} / {(_centralTscFilter ?? "-")}")
</MudChip>
}
</MudStack>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
@@ -70,8 +269,8 @@
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Land", "Country")</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem> <MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Land", "Country")</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem> <MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Umsatz", "Sales")</MudText><MudText Typo="Typo.h6">@_result.Summary.SalesValueTotal.ToString("N2")</MudText></MudPaper></MudItem> <MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@_result.Summary.ValueFieldLabel</MudText><MudText Typo="Typo.h6">@FormatValue(_result.Summary.AggregatedValueTotal, _result.Summary.DisplayCurrency)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Geschaetzte Marge", "Estimated margin")</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem> <MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Nicht umgerechnet", "Not converted")</MudText><MudText Typo="Typo.h6">@_result.Summary.MissingExchangeRateCount.ToString("N0")</MudText></MudPaper></MudItem>
</MudGrid> </MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
@@ -90,7 +289,7 @@
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText>
@foreach (var item in _result.TopCustomers) @foreach (var item in _result.TopCustomers)
{ {
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText> <MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
@@ -99,7 +298,7 @@
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Produktgruppen", "Top product groups")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Top Produktgruppen", "Top product groups")</MudText>
@foreach (var item in _result.TopProductGroups) @foreach (var item in _result.TopProductGroups)
{ {
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText> <MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
@@ -108,7 +307,7 @@
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Sales Owner", "Top sales owner")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Top Sales Owner", "Top sales owner")</MudText>
@foreach (var item in _result.TopSalesEmployees) @foreach (var item in _result.TopSalesEmployees)
{ {
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText> <MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
@@ -130,50 +329,10 @@
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Rechnungen", "Invoices")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Rechnungen", "Invoices")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Standorte", "Sites")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Standorte", "Sites")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Laender", "Countries")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Laender", "Countries")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Waehrungen", "Currencies")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CurrencyCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@_centralResult.Summary.ValueFieldLabel</MudText><MudText Typo="Typo.h6">@FormatValue(_centralResult.Summary.ValueTotal, _centralResult.Summary.DisplayCurrency)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Nicht umgerechnet", "Not converted")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.MissingExchangeRateCount.ToString("N0")</MudText></MudPaper></MudItem>
</MudGrid> </MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Cockpit Manometer", "Cockpit gauges")</MudText>
<MudText Typo="Typo.caption" Class="d-block mb-3">
@T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.")
</MudText>
<MudGrid>
@foreach (var gauge in BuildCentralGauges(_centralResult))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 cockpit-gauge-card" Elevation="0">
<MudText Typo="Typo.caption" Class="d-block mb-1">@gauge.Title</MudText>
<div class="cockpit-gauge-wrap">
<svg viewBox="0 0 220 140" class="cockpit-gauge" role="img" aria-label="@gauge.Title">
<path d="@GaugeArcPath"
fill="none"
stroke="#d7e2ea"
stroke-width="16"
stroke-linecap="round" />
<path d="@GaugeArcPath"
fill="none"
stroke="@gauge.Color"
stroke-width="16"
stroke-linecap="round"
pathLength="100"
stroke-dasharray="@BuildGaugeDashArray(gauge.Percent)" />
<line x1="110" y1="110" x2="@BuildGaugeNeedleX(gauge.Percent)" y2="@BuildGaugeNeedleY(gauge.Percent)"
stroke="#23313d"
stroke-width="5"
stroke-linecap="round" />
<circle cx="110" cy="110" r="8" fill="#23313d" />
<text x="110" y="76" text-anchor="middle" class="cockpit-gauge-value">@gauge.DisplayValue</text>
<text x="110" y="96" text-anchor="middle" class="cockpit-gauge-subtitle">@gauge.Subtitle</text>
</svg>
</div>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _centralResult.Notices) @foreach (var notice in _centralResult.Notices)
@@ -185,18 +344,26 @@
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahresumsatz 2025/2026", "Yearly sales 2025/2026")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Jahreswerte", "Yearly values")</MudText>
<MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped> <MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh> <MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh> <MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Year</MudTd> <MudTd>@context.Year</MudTd>
<MudTd>@context.Currency</MudTd> <MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd> <MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd> <MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@@ -204,18 +371,26 @@
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Monatsumsatz", "Monthly sales")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Monatswerte", "Monthly values")</MudText>
<MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped> <MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>@T("Monat", "Month")</MudTh> <MudTh>@T("Monat", "Month")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh> <MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd> <MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd> <MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd> <MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@@ -226,18 +401,26 @@
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Tagesumsatz im ausgewaehlten Monat", "Daily sales in selected month")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Tageswerte im ausgewaehlten Monat", "Daily values in selected month")</MudText>
<MudTable Items="_centralResult.DailyTotals" Dense Hover Striped> <MudTable Items="_centralResult.DailyTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>@T("Tag", "Day")</MudTh> <MudTh>@T("Tag", "Day")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh> <MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd> <MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd> <MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd> <MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
<NoRecordsContent> <NoRecordsContent>
@@ -248,18 +431,18 @@
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Quelle", "Sales by source")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Werte nach Quelle", "Values by source")</MudText>
<MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped> <MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh> <MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh> <MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
<MudTh>@T("Rechnungen", "Invoices")</MudTh> <MudTh>@T("Rechnungen", "Invoices")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd> <MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd> <MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd> <MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@@ -268,19 +451,19 @@
</MudGrid> </MudGrid>
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Land", "Sales by country")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Werte nach Land", "Values by country")</MudText>
<MudTable Items="_centralResult.CountryTotals" Dense Hover Striped> <MudTable Items="_centralResult.CountryTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>@T("Land", "Country")</MudTh> <MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh> <MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
<MudTh>@T("Rechnungen", "Invoices")</MudTh> <MudTh>@T("Rechnungen", "Invoices")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd> <MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd> <MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd> <MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd> <MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
@@ -288,58 +471,54 @@
</MudPaper> </MudPaper>
} }
<style> </MudTabPanel>
.cockpit-gauge-card { </MudTabs>
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fa 100%); }
border: 1px solid #dce7ee;
border-radius: 18px;
min-height: 220px;
}
.cockpit-gauge-wrap {
display: flex;
justify-content: center;
align-items: center;
}
.cockpit-gauge {
width: 100%;
max-width: 240px;
height: auto;
}
.cockpit-gauge-value {
font-size: 22px;
font-weight: 700;
fill: #153047;
}
.cockpit-gauge-subtitle {
font-size: 11px;
fill: #607587;
}
</style>
@code { @code {
private List<ManagementCockpitFileOption> _files = []; private List<ManagementCockpitFileOption> _files = [];
private List<int> _centralYears = []; private List<int> _centralYears = [];
private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110"; private List<int> _financeYearOptions = [];
private List<string> _financeCountryOptions = [];
private List<string> _financeCurrencyOptions = [];
private List<ManagementCockpitValueFieldOption> _valueFieldOptions = [];
private readonly List<CurrencySelectOption> _currencyOptions =
[
new(ManagementCockpitCurrencyOptions.Eur, "EUR"),
new(ManagementCockpitCurrencyOptions.Usd, "USD"),
new(ManagementCockpitCurrencyOptions.Native, "Original")
];
private string? _selectedFilePath; private string? _selectedFilePath;
private ManagementCockpitResult? _result; private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult; private ManagementCockpitCentralResult? _centralResult;
private ManagementFinanceSummaryResult? _financeResult;
private int _selectedFinanceYear;
private string? _selectedFinanceCountryKey;
private string? _selectedFinanceCurrency;
private int _selectedCentralYear; private int _selectedCentralYear;
private int? _selectedCentralMonth; private int? _selectedCentralMonth;
private string? _centralLandFilter;
private string? _centralTscFilter;
private string _selectedFileValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private IEnumerable<string> _selectedCentralAdditionalValueFields = [];
private string _selectedFileTargetCurrency = ManagementCockpitCurrencyOptions.Eur;
private string _selectedCentralTargetCurrency = ManagementCockpitCurrencyOptions.Native;
private bool _loadingFiles; private bool _loadingFiles;
private bool _analyzing; private bool _analyzing;
private bool _analyzingCentral; private bool _analyzingCentral;
private bool _analyzingFinance;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear); var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear);
_files = state.Files; _files = state.Files;
_valueFieldOptions = state.ValueFieldOptions;
_centralYears = state.CentralYears; _centralYears = state.CentralYears;
_selectedFilePath = state.SelectedFilePath; _selectedFilePath = state.SelectedFilePath;
_selectedCentralYear = state.SelectedCentralYear; _selectedCentralYear = state.SelectedCentralYear;
_selectedFinanceYear = _selectedCentralYear;
await AnalyzeFinanceSummary();
} }
private async Task ReloadFiles() private async Task ReloadFiles()
@@ -371,7 +550,13 @@
_analyzing = true; _analyzing = true;
try try
{ {
_result = await CockpitPageService.AnalyzeAsync(_selectedFilePath); _result = await CockpitPageService.AnalyzeAsync(_selectedFilePath, new ManagementCockpitAnalysisOptions
{
ValueField = _selectedFileValueField,
TargetCurrency = _selectedFileTargetCurrency
});
_centralLandFilter = _result.Summary.Land;
_centralTscFilter = _result.Summary.Tsc;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -391,7 +576,14 @@
_analyzingCentral = true; _analyzingCentral = true;
try try
{ {
_centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth); _centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth, new ManagementCockpitAnalysisOptions
{
ValueField = _selectedCentralValueField,
AdditionalValueFields = _selectedCentralAdditionalValueFields.ToList(),
TargetCurrency = _selectedCentralTargetCurrency,
LandFilter = _centralLandFilter,
TscFilter = _centralTscFilter
});
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -403,6 +595,37 @@
} }
} }
private async Task AnalyzeFinanceSummary()
{
_analyzingFinance = true;
try
{
_financeResult = await CockpitPageService.AnalyzeFinanceSummaryAsync(
_selectedFinanceYear,
_selectedFinanceCountryKey,
_selectedFinanceCurrency);
_financeYearOptions = _financeResult.YearOptions;
_financeCountryOptions = _financeResult.CountryOptions;
_financeCurrencyOptions = _financeResult.CurrencyOptions;
_selectedFinanceYear = _financeResult.Filter.Year;
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Finance Summary konnte nicht erzeugt werden: {0}", "Could not build finance summary: {0}"), ex.Message), Severity.Error);
}
finally
{
_analyzingFinance = false;
}
}
private void ClearCentralScope()
{
_centralLandFilter = null;
_centralTscFilter = null;
}
private static Severity MapSeverity(string severity) => severity switch private static Severity MapSeverity(string severity) => severity switch
{ {
"Warning" => Severity.Warning, "Warning" => Severity.Warning,
@@ -418,180 +641,31 @@
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}"; return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
} }
private List<CentralGaugeModel> BuildCentralGauges(ManagementCockpitCentralResult result) private static string FormatValue(decimal value, string currency)
=> string.IsNullOrWhiteSpace(currency) || currency == "-"
? value.ToString("N2")
: $"{value:N2} {currency}";
private void SetSelectedCentralAdditionalValueFields(IEnumerable<string> values)
{ {
var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount; _selectedCentralAdditionalValueFields = values
var sourceDominance = result.SourceSystemTotals.Count == 0 .Where(value => !string.IsNullOrWhiteSpace(value))
? 0m .Distinct(StringComparer.OrdinalIgnoreCase)
: result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var countryDominance = result.CountryTotals.Count == 0
? 0m
: result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var periodCoverage = BuildPeriodCoveragePercent(result);
var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals);
var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals);
var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m);
var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result);
return
[
new CentralGaugeModel
{
Title = T("Rechnungsdichte", "Invoice density"),
Percent = invoiceDensity,
DisplayValue = $"{invoiceDensity:F0}%",
Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"),
Color = "#1f8a70"
},
new CentralGaugeModel
{
Title = T("Quellen-Dominanz", "Source dominance"),
Percent = sourceDominance,
DisplayValue = $"{sourceDominance:F0}%",
Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"),
Color = "#d9822b"
},
new CentralGaugeModel
{
Title = T("Land-Dominanz", "Country dominance"),
Percent = countryDominance,
DisplayValue = $"{countryDominance:F0}%",
Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"),
Color = "#c4496b"
},
new CentralGaugeModel
{
Title = T("Perioden-Abdeckung", "Period coverage"),
Percent = periodCoverage,
DisplayValue = $"{periodCoverage:F0}%",
Subtitle = BuildPeriodGaugeSubtitle(result),
Color = "#3d7ff0"
},
new CentralGaugeModel
{
Title = T("Top-Land Umsatz", "Top country sales"),
Percent = topCountrySalesShare,
DisplayValue = $"{topCountrySalesShare:F0}%",
Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"),
Color = "#7f56d9"
},
new CentralGaugeModel
{
Title = T("Top-Quelle Umsatz", "Top source sales"),
Percent = topSourceSalesShare,
DisplayValue = $"{topSourceSalesShare:F0}%",
Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"),
Color = "#0f9fb5"
},
new CentralGaugeModel
{
Title = T("Waehrungs-Komplexitaet", "Currency complexity"),
Percent = currencyComplexity,
DisplayValue = result.Summary.CurrencyCount.ToString("N0"),
Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"),
Color = "#b54708"
},
new CentralGaugeModel
{
Title = T("Monat gegen Peak", "Month vs peak"),
Percent = peakVsAverageMonth,
DisplayValue = $"{peakVsAverageMonth:F0}%",
Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"),
Color = "#d92d20"
}
];
}
private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result)
{
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
return 0m;
if (result.Filter.Month.HasValue)
{
var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value);
var coveredDays = result.DailyTotals
.Select(x => x.Day)
.Where(x => x.HasValue)
.Distinct()
.Count();
return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth;
}
var coveredMonths = result.MonthlyTotals
.Select(x => x.Month)
.Where(x => x.HasValue)
.Distinct()
.Count();
return coveredMonths * 100m / 12m;
}
private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result)
=> result.Filter.Month.HasValue
? T("Tage mit Daten im Monat", "Days with data in month")
: T("Monate mit Daten im Jahr", "Months with data in year");
private static decimal BuildTopSalesSharePercent(IEnumerable<ManagementCockpitDimensionValueRow> rows)
{
var materialized = rows.ToList();
if (materialized.Count == 0)
return 0m;
var total = materialized.Sum(x => x.SalesValue);
if (total == 0)
return 0m;
return materialized.Max(x => x.SalesValue) * 100m / total;
}
private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result)
{
var monthRows = result.MonthlyTotals.ToList();
if (monthRows.Count == 0)
return 0m;
var groupedMonths = monthRows
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Sum(x => x.SalesValue))
.ToList(); .ToList();
if (groupedMonths.Count == 0)
return 0m;
var peak = groupedMonths.Max();
if (peak == 0)
return 0m;
var average = groupedMonths.Average();
return Math.Min(100m, average * 100m / peak);
} }
private static string BuildGaugeDashArray(decimal percent) private static string FormatAdditionalValue(ManagementCockpitTimeValueRow row, string fieldKey)
=> $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100";
private static string BuildGaugeNeedleX(decimal percent)
=> GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static string BuildGaugeNeedleY(decimal percent)
=> GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d)
{ {
var clamped = Math.Clamp((double)percent, 0d, 100d); if (!row.AdditionalValues.TryGetValue(fieldKey, out var value))
var angle = Math.PI * (1d - clamped / 100d); return "-";
var x = 110d + radius * Math.Cos(angle);
var y = 110d - radius * Math.Sin(angle); var formattedValue = FormatValue(value.Value, value.Currency);
return (x, y); return value.MissingExchangeRateCount == 0
? formattedValue
: $"{formattedValue} / {value.MissingExchangeRateCount} ohne Kurs";
} }
private sealed class CentralGaugeModel private sealed record CurrencySelectOption(string Key, string Label);
{
public string Title { get; set; } = string.Empty;
public decimal Percent { get; set; }
public string DisplayValue { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public string Color { get; set; } = "#3d7ff0";
}
} }
@code { @code {
@@ -0,0 +1,367 @@
@page "/manual-imports"
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IStandortePageService StandortePageService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Manuelle Importe", "Manual imports")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Manuelle Importe", "Manual imports")</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
@T("Diese Seite ist fuer Keyuser: Hier werden Excel-/CSV-Dateien fuer manuelle Laender wie DE, UK und ES hinterlegt und aktiviert. Technische Spaltenmappings bleiben in Admin -> Standorte.",
"This page is for key users: Excel/CSV files for manual countries such as DE, UK and ES are maintained and activated here. Technical column mappings remain in Admin -> Sites.")
</MudAlert>
<MudTabs Elevation="0" Rounded="false" PanelClass="manual-import-tab-panel">
<MudTabPanel Text="@T("Importdateien", "Import files")" Icon="@Icons.Material.Filled.UploadFile">
<MudTable Items="_rows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh>@T("Datei / SharePoint-Ordner", "File / SharePoint folder")</MudTh>
<MudTh>@T("Letzter Upload", "Last upload")</MudTh>
<MudTh>@T("Aktionen", "Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd><MudSwitch @bind-Value="context.IsActive" Color="Color.Primary" /></MudTd>
<MudTd>
<MudTextField @bind-Value="context.ManualImportFilePath"
Placeholder="@T("lokaler Pfad, UNC, SharePoint-Datei oder SharePoint-Ordner", "local path, UNC, SharePoint file or SharePoint folder")"
Margin="Margin.Dense" />
</MudTd>
<MudTd>@(context.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
StartIcon="@Icons.Material.Filled.FactCheck"
OnClick="() => ValidatePathAsync(context)" Disabled="_busySiteId == context.Id">
@T("Pfad pruefen", "Check path")
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="() => SaveAsync(context)" Disabled="_busySiteId == context.Id">
@T("Speichern", "Save")
</MudButton>
</MudStack>
<InputFile OnChange="args => UploadAsync(context, args)" accept=".xlsx,.csv" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine manuellen Excel-/CSV-Standorte gefunden.", "No manual Excel/CSV sites found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.Route">
<div class="workflow-shell">
<div class="workflow-step import">
<MudIcon Icon="@Icons.Material.Filled.UploadFile" Size="Size.Large" />
<span class="workflow-index">1</span>
<h3>@T("Excel bereitstellen", "Provide Excel")</h3>
<p>@T("Datei hochladen oder SharePoint-/UNC-Pfad eintragen.", "Upload a file or enter a SharePoint/UNC path.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step save">
<MudIcon Icon="@Icons.Material.Filled.Save" Size="Size.Large" />
<span class="workflow-index">2</span>
<h3>@T("Speichern und aktivieren", "Save and activate")</h3>
<p>@T("Pfad pruefen, Standort aktiv setzen und speichern.", "Check the path, set the site active, and save.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step export">
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Large" />
<span class="workflow-index">3</span>
<h3>@T("Standort exportieren", "Export site")</h3>
<p>@T("Im Export Dashboard den Standort starten. Die Daten landen in CentralSalesRecords.", "Start the site in the export dashboard. Data is written to CentralSalesRecords.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step central">
<MudIcon Icon="@Icons.Material.Filled.TableView" Size="Size.Large" />
<span class="workflow-index">4</span>
<h3>@T("Zentrale Excel erzeugen", "Build final Excel")</h3>
<p>@T("Danach `Zentrale Datei neu erzeugen` ausfuehren.", "Then run `Rebuild consolidated file`.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step check">
<MudIcon Icon="@Icons.Material.Filled.CompareArrows" Size="Size.Large" />
<span class="workflow-index">5</span>
<h3>@T("Finance pruefen", "Check finance")</h3>
<p>@T("Im Endexcel `Finance | ...` oder im Reiter `Soll/Ist Vergleich` kontrollieren.", "Check the `Finance | ...` columns in the final Excel or the `Actual/reference comparison` tab.")</p>
</div>
</div>
<div class="workflow-notes">
<div class="workflow-note good">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" />
<div>
<strong>@T("Richtige Reihenfolge", "Correct order")</strong>
<p>@T("Ein Standortexport aktualisiert die Datenbasis. Die zentrale Excel muss danach neu erzeugt werden.", "A site export updates the data basis. The final Excel must be rebuilt afterwards.")</p>
</div>
</div>
<div class="workflow-note warn">
<MudIcon Icon="@Icons.Material.Filled.Warning" />
<div>
<strong>@T("DE bleibt fachlich offen", "DE remains open")</strong>
<p>@T("Alphaplan ist technisch importierbar. Kundenlaender und Filter fuer den offiziellen DE-Istwert muessen noch bestaetigt werden.", "Alphaplan is technically importable. Customer countries and filters for the official DE actual still need confirmation.")</p>
</div>
</div>
<div class="workflow-note info">
<MudIcon Icon="@Icons.Material.Filled.Info" />
<div>
<strong>@T("Server-Hinweis", "Server note")</strong>
<p>@T("Der Server braucht kein Microsoft Excel. XLSX/CSV wird direkt von der Anwendung gelesen.", "The server does not need Microsoft Excel. XLSX/CSV is read directly by the application.")</p>
</div>
</div>
</div>
</MudTabPanel>
</MudTabs>
<style>
.manual-import-tab-panel {
padding-top: 18px;
}
.workflow-shell {
display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 12px;
align-items: stretch;
}
.workflow-step {
position: relative;
min-height: 190px;
padding: 18px 16px;
border: 1px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
display: flex;
flex-direction: column;
gap: 8px;
}
.workflow-step.import { border-top: 5px solid var(--mud-palette-info); }
.workflow-step.save { border-top: 5px solid var(--mud-palette-primary); }
.workflow-step.export { border-top: 5px solid var(--mud-palette-success); }
.workflow-step.central { border-top: 5px solid var(--mud-palette-secondary); }
.workflow-step.check { border-top: 5px solid var(--mud-palette-warning); }
.workflow-step h3 {
margin: 6px 0 0 0;
font-size: 1rem;
font-weight: 700;
}
.workflow-step p,
.workflow-note p {
margin: 0;
color: var(--mud-palette-text-secondary);
font-size: .9rem;
line-height: 1.35;
}
.workflow-index {
position: absolute;
top: 14px;
right: 14px;
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--mud-palette-dark);
color: var(--mud-palette-dark-text);
font-weight: 700;
}
.workflow-arrow {
display: none;
}
.workflow-notes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.workflow-note {
display: grid;
grid-template-columns: 34px 1fr;
gap: 10px;
padding: 14px;
border: 1px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
}
.workflow-note.good { border-left: 5px solid var(--mud-palette-success); }
.workflow-note.warn { border-left: 5px solid var(--mud-palette-warning); }
.workflow-note.info { border-left: 5px solid var(--mud-palette-info); }
@@media (max-width: 1100px) {
.workflow-shell {
grid-template-columns: 1fr;
}
.workflow-arrow {
display: flex;
justify-content: center;
transform: rotate(90deg);
}
.workflow-notes {
grid-template-columns: 1fr;
}
}
</style>
@code {
private List<ManualImportRow> _rows = [];
private bool _loading = true;
private int? _busySiteId;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
await using var db = await DbFactory.CreateDbContextAsync();
var manualSourceCodes = await db.SourceSystemDefinitions
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.ManualExcel)
.Select(x => x.Code)
.ToListAsync();
_rows = await db.Sites
.Where(site => manualSourceCodes.Contains(site.SourceSystem))
.OrderBy(site => site.Land)
.ThenBy(site => site.TSC)
.Select(site => new ManualImportRow
{
Id = site.Id,
Land = site.Land,
TSC = site.TSC,
IsActive = site.IsActive,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc
})
.ToListAsync();
_loading = false;
}
private async Task SaveAsync(ManualImportRow row)
{
_busySiteId = row.Id;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var site = await db.Sites.FirstAsync(x => x.Id == row.Id);
site.IsActive = row.IsActive;
site.ManualImportFilePath = row.ManualImportFilePath.Trim();
site.ManualImportLastUploadedAtUtc = row.ManualImportLastUploadedAtUtc;
await db.SaveChangesAsync();
Snackbar.Add(T("Import-Einstellungen gespeichert.", "Import settings saved."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Speichern fehlgeschlagen", "Save failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private async Task ValidatePathAsync(ManualImportRow row)
{
_busySiteId = row.Id;
try
{
row.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(row.ManualImportFilePath);
Snackbar.Add(T("Datei oder SharePoint-Referenz ist erreichbar.", "File or SharePoint reference is reachable."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Pfadpruefung fehlgeschlagen", "Path check failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private async Task UploadAsync(ManualImportRow row, InputFileChangeEventArgs args)
{
var file = args.File;
if (file is null)
return;
_busySiteId = row.Id;
try
{
var extension = Path.GetExtension(file.Name);
if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(T("Bitte eine .xlsx- oder .csv-Datei auswaehlen.", "Please choose a .xlsx or .csv file."));
}
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
Directory.CreateDirectory(uploadDirectory);
var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name)
.Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_'));
if (string.IsNullOrWhiteSpace(safeBaseName))
safeBaseName = "manual_import";
var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}");
await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024))
await using (var targetStream = File.Create(targetPath))
{
await sourceStream.CopyToAsync(targetStream);
}
row.ManualImportFilePath = targetPath;
row.ManualImportLastUploadedAtUtc = DateTime.UtcNow;
await SaveAsync(row);
Snackbar.Add(T("Datei hochgeladen.", "File uploaded."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Upload fehlgeschlagen", "Upload failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private string T(string german, string english) => UiText.Text(german, english);
private sealed class ManualImportRow
{
public int Id { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public bool IsActive { get; set; }
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
}
}
@@ -1,4 +1,5 @@
@page "/settings" @page "/settings"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@inject ISettingsPageService SettingsPageActions @inject ISettingsPageService SettingsPageActions
@@ -1,4 +1,5 @@
@page "/standorte" @page "/standorte"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using System.Text.Json @using System.Text.Json
@using System.Reflection @using System.Reflection
@@ -190,15 +191,22 @@
<MudDivider Class="my-4" /> <MudDivider Class="my-4" />
@if (IsSapSite()) @if (IsMappedSourceSite())
{ {
<MudText Typo="Typo.h6" Class="mb-2">SAP Gateway</MudText> <MudText Typo="Typo.h6" Class="mb-2">@GetMappingSectionTitle()</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3"> <MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert. Quellen und Feldmappings werden grafisch gepflegt. Bei SAP OData sind Quellen Entity Sets; bei HANA sind Quellen Tabellen oder Views im gewaehlten Schema.
</MudAlert> </MudAlert>
<MudText Typo="Typo.body2">Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)</MudText> @if (IsSapSite())
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL Override" {
HelperText="Optional. Wenn leer, wird die zentrale SAP Service URL des Quellsystems verwendet." /> <MudText Typo="Typo.body2">Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)</MudText>
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL Override"
HelperText="Optional. Wenn leer, wird die zentrale SAP Service URL des Quellsystems verwendet." />
}
else
{
<MudText Typo="Typo.body2">Zentrale HANA-Verbindung: @GetCentralHanaSummary(_editingSite.SourceSystem)</MudText>
}
<MudStack Row Spacing="2" Class="mb-3"> <MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets" <MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets"> StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets">
@@ -209,7 +217,7 @@
} }
else else
{ {
@("Quellen refreshen") @(IsSapSite() ? "Entity Sets refreshen" : "Tabellen/Views refreshen")
} }
</MudButton> </MudButton>
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue) @if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
@@ -221,16 +229,16 @@
</MudStack> </MudStack>
<MudDivider Class="my-4" /> <MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2"> <MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">SAP Quellen</MudText> <MudText Typo="Typo.h6">Quellen</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapSource">Quelle hinzufügen</MudButton> <MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapSource">Quelle hinzufügen</MudButton>
</MudStack> </MudStack>
<MudText Typo="Typo.caption" Class="mb-2"> <MudText Typo="Typo.caption" Class="mb-2">
Pro Quelle Alias und Entity Set definieren. Joins verwenden links/rechts kommagetrennte Schlüsselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP`. Pro Quelle Alias und Entity Set bzw. HANA Tabelle/View definieren. Joins verwenden links/rechts kommagetrennte Schluesselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP` / `=HANA`.
</MudText> </MudText>
<MudTable Items="_sapSources" Dense Hover Striped> <MudTable Items="_sapSources" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Alias</MudTh> <MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh> <MudTh>@(IsSapSite() ? "Entity Set" : "Tabelle/View")</MudTh>
<MudTh>Primär</MudTh> <MudTh>Primär</MudTh>
<MudTh>Aktiv</MudTh> <MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh> <MudTh>Aktionen</MudTh>
@@ -343,7 +351,7 @@
</MudStack> </MudStack>
</MudStack> </MudStack>
<MudText Typo="Typo.caption" Class="mb-2"> <MudText Typo="Typo.caption" Class="mb-2">
Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar. Source Expressions werden aus den hinzugefuegten Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswaehlbar.
</MudText> </MudText>
<MudTable Items="_sapMappings" Dense Hover Striped> <MudTable Items="_sapMappings" Dense Hover Striped>
<HeaderContent> <HeaderContent>
@@ -378,18 +386,18 @@
} }
else if (IsManualExcelSite()) else if (IsManualExcelSite())
{ {
<MudText Typo="Typo.h6" Class="mb-2">Manueller Excel-Import</MudText> <MudText Typo="Typo.h6" Class="mb-2">Manueller Excel-/CSV-Import</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3"> <MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. 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> </MudAlert>
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad" <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." HelperText="Unterstuetzt lokale Pfade, UNC-Pfade, SharePoint-Dateien und SharePoint-Ordner. Bei Ordnern wird die neueste passende Excel-/CSV-Datei geladen."
Class="mb-2" /> Class="mb-2" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync" <MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
Disabled="_uploadingManualImport" Class="mb-3"> Disabled="_uploadingManualImport" Class="mb-3">
Pfad pruefen Pfad pruefen
</MudButton> </MudButton>
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" /> <InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx,.csv" />
@if (_uploadingManualImport) @if (_uploadingManualImport)
{ {
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText> <MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
@@ -407,6 +415,63 @@
{ {
<MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText> <MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText>
} }
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">Excel-Spaltenmapping</MudText>
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Schema"
OnClick="LoadManualExcelHeadersAsync" Disabled="_loadingManualExcelHeaders">
@if (_loadingManualExcelHeaders)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade Spalten...")
}
else
{
@("Spalten aus Excel laden")
}
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.AutoFixHigh"
OnClick="AutoMatchManualExcelMappings">
Auto-Match
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddManualExcelMapping">Mapping hinzufügen</MudButton>
</MudStack>
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
Wenn hier Mappings gepflegt sind, werden diese vor dem Standardformat verwendet. Konstanten sind mit `=Wert` moeglich, z. B. `=Manual Excel`.
</MudText>
<MudTable Items="_manualExcelMappings" Dense Hover Striped>
<HeaderContent>
<MudTh>Zielfeld</MudTh>
<MudTh>Excel-Spalte / Konstante</MudTh>
<MudTh>Pflicht</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudSelect @bind-Value="context.TargetField" Dense>
@foreach (var field in _salesRecordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.SourceHeader" Dense>
@foreach (var header in GetAvailableManualExcelHeaders(context.SourceHeader))
{
<MudSelectItem Value="@header">@header</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveManualExcelMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
} }
else else
{ {
@@ -421,8 +486,8 @@
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton> <MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport || _loadingManualExcelHeaders">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders">Speichern</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -438,6 +503,8 @@
private List<SapSourceDefinition> _sapSources = []; private List<SapSourceDefinition> _sapSources = [];
private List<SapJoinDefinition> _sapJoins = []; private List<SapJoinDefinition> _sapJoins = [];
private List<SapFieldMapping> _sapMappings = []; private List<SapFieldMapping> _sapMappings = [];
private List<ManualExcelColumnMapping> _manualExcelMappings = [];
private List<string> _manualExcelHeaders = [];
private readonly string[] _salesRecordFields = typeof(SalesRecord) private readonly string[] _salesRecordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance) .GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name) .Select(p => p.Name)
@@ -452,6 +519,7 @@
private bool _savingSite; private bool _savingSite;
private bool _loadingSchemas; private bool _loadingSchemas;
private bool _uploadingManualImport; private bool _uploadingManualImport;
private bool _loadingManualExcelHeaders;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -564,6 +632,8 @@
_sapSources = []; _sapSources = [];
_sapJoins = []; _sapJoins = [];
_sapMappings = []; _sapMappings = [];
_manualExcelMappings = [];
_manualExcelHeaders = [];
_siteDialogVisible = true; _siteDialogVisible = true;
} }
@@ -581,6 +651,8 @@
_sapSources = editorState.SapSources; _sapSources = editorState.SapSources;
_sapJoins = editorState.SapJoins; _sapJoins = editorState.SapJoins;
_sapMappings = editorState.SapMappings; _sapMappings = editorState.SapMappings;
_manualExcelMappings = editorState.ManualExcelMappings;
_manualExcelHeaders = BuildHeadersFromManualExcelMappings();
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings(); _sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
_sapSourceFieldMap = BuildSourceFieldMapFromJoins(); _sapSourceFieldMap = BuildSourceFieldMapFromJoins();
_siteDialogVisible = true; _siteDialogVisible = true;
@@ -594,7 +666,7 @@
_savingSite = true; _savingSite = true;
try try
{ {
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), _sapSources, _sapJoins, _sapMappings, _sapEntitySetsCache); await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsMappedSourceSite(), IsManualExcelSite(), _sapSources, _sapJoins, _sapMappings, _manualExcelMappings, _sapEntitySetsCache);
_siteDialogVisible = false; _siteDialogVisible = false;
await LoadDataAsync(); await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success); Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -618,9 +690,16 @@
if (result != true) return; if (result != true) return;
await StandortePageService.DeleteSiteAsync(site); try
await LoadDataAsync(); {
Snackbar.Add("Standort gelöscht", Severity.Info); await StandortePageService.DeleteSiteAsync(site);
await LoadDataAsync();
Snackbar.Add("Standort geloescht", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add($"Standort konnte nicht geloescht werden: {ex.Message}", Severity.Error);
}
} }
private static string GetServerNode(HanaServer? server) private static string GetServerNode(HanaServer? server)
@@ -687,11 +766,17 @@
private bool IsSapSite() private bool IsSapSite()
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase); => string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
private bool IsMappedSourceSite()
=> IsSapSite() || UsesHanaConnection();
private bool IsManualExcelSite() private bool IsManualExcelSite()
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase); => string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
private bool UsesHanaConnection() => IsHanaSourceSystem(_editingSite.SourceSystem); private bool UsesHanaConnection() => IsHanaSourceSystem(_editingSite.SourceSystem);
private string GetMappingSectionTitle()
=> IsSapSite() ? "SAP OData Mapping" : "HANA Quellen und Feldmapping";
private string GetSourceSystemLabel(SourceSystemDefinition definition) private string GetSourceSystemLabel(SourceSystemDefinition definition)
=> string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Code : $"{definition.DisplayName} ({definition.Code})"; => string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Code : $"{definition.DisplayName} ({definition.Code})";
@@ -810,7 +895,7 @@
private void CloseSiteDialog() private void CloseSiteDialog()
{ {
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport) if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders)
return; return;
_siteDialogVisible = false; _siteDialogVisible = false;
@@ -829,9 +914,10 @@
try try
{ {
var extension = Path.GetExtension(file.Name); var extension = Path.GetExtension(file.Name);
if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase))
{ {
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx auswählen."); throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv auswaehlen.");
} }
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports"); var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
@@ -877,6 +963,170 @@
} }
} }
private async Task LoadManualExcelHeadersAsync()
{
if (_loadingManualExcelHeaders)
return;
_loadingManualExcelHeaders = true;
try
{
_manualExcelHeaders = await StandortePageService.LoadManualExcelHeadersAsync(_editingSite.ManualImportFilePath);
Snackbar.Add($"{_manualExcelHeaders.Count} Excel-Spalten geladen.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Spalten laden fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_loadingManualExcelHeaders = false;
}
}
private void AddManualExcelMapping()
{
_manualExcelMappings.Add(new ManualExcelColumnMapping
{
TargetField = _salesRecordFields.First(),
SourceHeader = GetAvailableManualExcelHeaders(null).FirstOrDefault() ?? string.Empty,
IsActive = true,
SortOrder = _manualExcelMappings.Count
});
}
private void RemoveManualExcelMapping(ManualExcelColumnMapping mapping)
=> _manualExcelMappings.Remove(mapping);
private void AutoMatchManualExcelMappings()
{
if (_manualExcelHeaders.Count == 0)
{
Snackbar.Add("Bitte zuerst 'Spalten aus Excel laden' ausfuehren.", Severity.Warning);
return;
}
var suggestions = BuildManualExcelAutoMatchSuggestions();
var addedOrUpdated = 0;
foreach (var (targetField, sourceHeader) in suggestions)
{
var existing = _manualExcelMappings.FirstOrDefault(m =>
string.Equals(m.TargetField, targetField, StringComparison.OrdinalIgnoreCase));
if (existing is null)
{
_manualExcelMappings.Add(new ManualExcelColumnMapping
{
TargetField = targetField,
SourceHeader = sourceHeader,
IsActive = true,
IsRequired = IsImportantManualExcelField(targetField),
SortOrder = _manualExcelMappings.Count
});
}
else
{
existing.SourceHeader = sourceHeader;
existing.IsActive = true;
}
addedOrUpdated++;
}
Snackbar.Add(
addedOrUpdated == 0 ? "Keine passenden Spalten gefunden." : $"{addedOrUpdated} Mapping-Vorschlaege gesetzt.",
addedOrUpdated == 0 ? Severity.Info : Severity.Success);
}
private List<(string TargetField, string SourceHeader)> BuildManualExcelAutoMatchSuggestions()
{
var headerByNormalized = _manualExcelHeaders
.GroupBy(NormalizeHeader, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var aliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[nameof(SalesRecord.ExtractionDate)] = ["Export-Datum", "Extraction Date"],
[nameof(SalesRecord.InvoiceNumber)] = ["Belegnummer", "Invoice Number"],
[nameof(SalesRecord.PositionOnInvoice)] = ["Position", "Position on invoice"],
[nameof(SalesRecord.Material)] = ["ArtikelNummer", "Material", "Groesse"],
[nameof(SalesRecord.Name)] = ["ArtikelBezeichnung", "Name"],
[nameof(SalesRecord.ProductGroup)] = ["Warengruppen-Bezeichnung", "Product Group"],
[nameof(SalesRecord.Quantity)] = ["Anz. VE", "Quantity"],
[nameof(SalesRecord.SupplierNumber)] = ["Lieferanten Nummer", "Supplier number"],
[nameof(SalesRecord.SupplierName)] = ["Name Lieferant", "Supplier name"],
[nameof(SalesRecord.SupplierCountry)] = ["Land Lieferant", "Supplier country"],
[nameof(SalesRecord.CustomerNumber)] = ["AdressNummer-Kunde", "Customer number"],
[nameof(SalesRecord.CustomerName)] = ["Name Kunde", "Customer name"],
[nameof(SalesRecord.CustomerCountry)] = ["Land Kunde", "Customer country"],
[nameof(SalesRecord.CustomerIndustry)] = ["Branche", "Customer Industry"],
[nameof(SalesRecord.StandardCost)] = ["EinstandsPreis", "Standard cost"],
[nameof(SalesRecord.StandardCostCurrency)] = ["Währung", "Waehrung", "Standard Cost Currency"],
[nameof(SalesRecord.PurchaseOrderNumber)] = ["BestellNummer", "Purchase Order number"],
[nameof(SalesRecord.SalesPriceValue)] = ["NettoPreisGesamtX", "Sales Price/Value"],
[nameof(SalesRecord.SalesCurrency)] = ["Währung", "Waehrung", "Sales Currency"],
[nameof(SalesRecord.DocumentCurrency)] = ["Währung", "Waehrung", "Document Currency"],
[nameof(SalesRecord.CompanyCurrency)] = ["Währung", "Waehrung", "Company Currency"],
[nameof(SalesRecord.Incoterms2020)] = ["Versandbedingung", "Incoterms 2020"],
[nameof(SalesRecord.SalesResponsibleEmployee)] = ["AdressNummer_V", "Sales responsible employee"],
[nameof(SalesRecord.InvoiceDate)] = ["Belegdatum-Rechnung", "invoice date"],
[nameof(SalesRecord.OrderDate)] = ["BelegDatum Auftrag", "order date"]
};
var result = new List<(string TargetField, string SourceHeader)>();
foreach (var (targetField, sourceAliases) in aliases)
{
foreach (var alias in sourceAliases)
{
if (headerByNormalized.TryGetValue(NormalizeHeader(alias), out var actualHeader))
{
result.Add((targetField, actualHeader));
break;
}
}
}
result.Add((nameof(SalesRecord.DocumentType), "=Manual Excel"));
return result;
}
private IEnumerable<string> GetAvailableManualExcelHeaders(string? currentValue)
{
var values = new List<string>(_manualExcelHeaders);
values.Add("=Manual Excel");
if (!string.IsNullOrWhiteSpace(currentValue) && !values.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
values.Insert(0, currentValue);
return values
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x.StartsWith('=') ? 1 : 0)
.ThenBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private List<string> BuildHeadersFromManualExcelMappings()
=> _manualExcelMappings
.Select(m => m.SourceHeader)
.Where(x => !string.IsNullOrWhiteSpace(x) && !x.Trim().StartsWith('='))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsImportantManualExcelField(string targetField)
=> targetField is nameof(SalesRecord.InvoiceNumber) or
nameof(SalesRecord.SalesPriceValue) or
nameof(SalesRecord.InvoiceDate);
private static string NormalizeHeader(string value)
{
var chars = value
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray();
return new string(chars);
}
private static List<string> ParseSapEntitySets(string json) private static List<string> ParseSapEntitySets(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -953,7 +1203,7 @@
.ToList(); .ToList();
if (activeSources.Count == 0) if (activeSources.Count == 0)
throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set."); throw new InvalidOperationException("Es gibt keine aktiven Quellen mit Alias und Entity Set/Tabelle.");
var result = await StandortePageService.RefreshSapSourceFieldsAsync(_editingSite, activeSources, _sapMappings); var result = await StandortePageService.RefreshSapSourceFieldsAsync(_editingSite, activeSources, _sapMappings);
_sapAvailableSourceExpressions = result.SourceExpressions; _sapAvailableSourceExpressions = result.SourceExpressions;
@@ -1,4 +1,5 @@
@page "/transformations" @page "/transformations"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection @using System.Reflection
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
+54 -6
View File
@@ -1,6 +1,54 @@
<Router AppAssembly="typeof(Program).Assembly"> @using Microsoft.AspNetCore.Components.Authorization
<Found Context="routeData"> @inject NavigationManager Navigation
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" /> @inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found> <CascadingAuthenticationState>
</Router> <Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
@if (RequiresFinanceUnlock() && FinanceAccess.IsEnabled && !FinanceAccess.IsUnlocked)
{
<LayoutView Layout="typeof(Layout.MainLayout)">
<FinanceCockpitUnlockPanel />
</LayoutView>
}
else
{
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<LayoutView Layout="typeof(Layout.MainLayout)">
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
Zugriff verweigert. Bitte mit einem berechtigten Windows-/Domain-Benutzer anmelden.
</MudAlert>
</LayoutView>
</NotAuthorized>
<Authorizing>
<LayoutView Layout="typeof(Layout.MainLayout)">
<MudProgressCircular Indeterminate="true" />
</LayoutView>
</Authorizing>
</AuthorizeRouteView>
}
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>
@code {
private bool RequiresFinanceUnlock()
{
var path = Navigation.ToBaseRelativePath(Navigation.Uri)
.Split('?', '#')[0]
.Trim('/')
.ToLowerInvariant();
return path is "" or
"management-cockpit" or
"finance-cockpit/vergleich" or
"standorte" or
"transformations" or
"finance-rules" or
"settings" or
"logs" or
"source-viewer";
}
}
@@ -1,9 +1,12 @@
@using System.Net.Http @using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using TrafagSalesExporter.Components @using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.FinanceCockpit
@using TrafagSalesExporter.Components.Layout @using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
Binary file not shown.
+4
View File
@@ -16,8 +16,12 @@ public class AppDbContext : DbContext
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>(); public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>(); public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>(); public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
public DbSet<FinanceReference> FinanceReferences => Set<FinanceReference>();
public DbSet<FinanceIntercompanyRule> FinanceIntercompanyRules => Set<FinanceIntercompanyRule>();
public DbSet<FinanceRule> FinanceRules => Set<FinanceRule>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>(); public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>(); public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>(); public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>();
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>(); public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
} }
+950 -1
View File
@@ -1,6 +1,955 @@
# TrafagSalesExporter Handoff # TrafagSalesExporter Handoff
Stand: 2026-04-15 Stand: 2026-05-20
## Aktueller Handoff-Zusatz 2026-05-20
Seit den aelteren Handoff-Eintraegen wurden folgende Punkte umgesetzt und dokumentiert:
- `Management Analyse` hat einen fuehrenden Reiter `Finance Summary`.
- Finance Summary nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel-Blatt `Finance Summary`.
- Filter fuer Jahr, Land und Waehrung wirken auf das Finance-Endergebnis.
- DE 2026 wirft keinen Fehler mehr, sondern zeigt wegen DE/Alphaplan-2025-Zwang einen leeren Zustand mit Hinweis.
- HR KPI Cockpit wurde erweitert:
- Anleitung-Reiter
- Datenordner anpassbar
- Dateifrische / Datenstatus
- Ampeln
- Periodenvergleich
- Datenqualitaet
- Austritte nach Typ/Organisation
- Absenzen nach Organisation / Top-Absenzen
- Managementsicht
- Drucken/PDF
- Anwenderdokus:
- `docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx`
- `docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx`
- Markdown-Dokumentenstatus:
- `docs/MD_DOKUMENTENSTATUS_2026-05-20.md`
Validierung:
- `dotnet test TrafagSalesExporter.sln --verbosity minimal`
- Ergebnis am 2026-05-20: `77/77` Tests gruen.
Hinweis:
- Aeltere Abschnitte in diesem Handoff bleiben als Historie erhalten. Fuer aktuellen Status immer zuerst diesen Zusatz, `NEXT_STEPS_2026-04-15.md`, `lastchange.md` und `docs/MD_DOKUMENTENSTATUS_2026-05-20.md` lesen.
## Nachtrag 2026-05-11 UK_B1 Mapping / aktueller Arbeitsstand
Letzter Benutzerwunsch:
- UK/England soll weiter ueber `UK_B1` laufen.
- Das Mapping soll so angepasst werden, dass die Finance-Zahl plausibel wird.
- Danach soll alles nachvollziehbar dokumentiert sein.
Wichtiger Befund:
- FinanceProbe zeigte fuer UK/England:
- `TSC = TRUK`
- `1'881` Zeilen
- Ist `395'605.82 GBP`
- Soll `3'749'865.00 GBP`
- In der lokalen DB waren fuer `TRUK` keine `ManualExcelColumnMappings` vorhanden.
- Der Fallback-Importer hat `Sales Price/Value` direkt als Positionswert importiert.
- Im UK-B1-Export ist `Sales Price/Value` aber ein Stueckpreis.
- Korrekte Positionslogik:
```text
SalesPriceValue = [Sales Price/Value] * [Quantity]
```
Probe auf existierenden Zentraldaten:
```text
Summe SalesPriceValue bisher: 395'605.82 GBP
Summe SalesPriceValue * Quantity: 3'533'348.89 GBP
check.xlsx Soll: 3'749'865.00 GBP
Restdifferenz: -216'516.11 GBP
```
Geaenderte Dateien im aktuellen Worktree:
- `Services/ManualExcelImportService.cs`
- grafische Manual-Excel-Mappings koennen einfache Multiplikationsausdruecke lesen:
```text
=[Header A]*[Header B]
```
- Konstanten wie `=GBP` funktionieren weiterhin.
- `Services/DatabaseSeedService.cs`
- repariert England/TRUK auf:
```text
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
```
- seedet fuer `TRUK` ein grafisches Mapping, insbesondere:
```text
SalesPriceValue <- =[Sales Price/Value]*[Quantity]
SalesCurrency <- =GBP
DocumentCurrency<- =GBP
CompanyCurrency <- =GBP
PostingDate <- invoice date
InvoiceDate <- invoice date
```
- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs`
- neuer Test fuer berechnetes Manual-Excel-Mapping.
Aktueller Teststand:
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal`
- Tests erfolgreich: `59/59`.
- Bekannte Warnungen: bestehende MudBlazor-Analyzerwarnungen zu `Dense`.
Zusatzfix:
- `DatabaseSeedService` prueft vor `EnsureUkManualExcelMapping(...)`, ob `ManualExcelColumnMappings` sauber auf `Sites` referenziert.
- Falls die Tabelle noch auf `Sites_repair_old` oder eine andere `Sites_*`-Reparaturtabelle zeigt, wird der UK-Mapping-Seed fuer diesen Start uebersprungen.
- Dadurch kann die Schema-Reparatur sauber durchlaufen.
Naechster praktischer Schritt:
1. SharePoint-/Graph-Zugriff reparieren.
2. FinanceProbe ist bereits auf `http://127.0.0.1:5099` gestartet.
3. `/run/export/TRUK` erneut ausfuehren.
4. `/finance` erneut pruefen.
Praktischer Stand:
- Lokale DB ist aktualisiert:
- `TRUK` Pfad = `UK_B1`
- `18` aktive Manual-Excel-Mapping-Zeilen
- `/finance` antwortet mit HTTP `200`.
- `/run/export/TRUK` scheitert aktuell an Auth/Netzwerk:
```text
ClientSecretCredential authentication failed
127.0.0.1:9 connection refused
```
- Deshalb enthaelt `CentralSalesRecords` fuer UK noch den alten Importstand, bis SharePoint wieder erreichbar ist.
Wichtig:
- Das ist keine Sonderlogik, die UK-Zahlen schoenrechnet.
- Der Mapper setzt die allgemeine fachliche Regel "pro Artikel / Belegposition" um.
- Die Formel ist im grafischen Mapping sichtbar und nicht hart als UK-Spezialberechnung im Importcode versteckt.
- Falls nach neuem Export noch eine Restdifferenz bleibt, muss die UK-Datei auf weitere Netto-/Discount-/Frachtspalten geprueft werden.
## 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-11 FinanceProbe KI-Steuerung
FinanceProbe kann jetzt nicht nur vergleichen, sondern im Testkontext auch Exporte ausloesen:
- `/run/export/{siteKey}`: einzelner Standort nach `Id`, `TSC` oder `Land`
- `/run/export-all`: alle aktiven Standorte plus zentrale Datei
- `/run/consolidated`: zentrale Datei aus `CentralSalesRecords`
Die Routen liefern eine HTML-Run-Summary mit Exportlogs, Finance-Abgleich und Datenabdeckung.
Wichtig:
- Das ist eine temporaere Test-/KI-Steuerung.
- Nicht als produktive API betrachten.
- Echte SAP/HANA/SharePoint-Zugriffe funktionieren nur mit vorhandenen Credentials und Netzverbindung auf dem Rechner.
## Nachtrag 2026-05-07 Mapper-Konsolidierung / Finance-Konfiguration
Architekturstand:
- `MappedSalesRecordComposer` ist die gemeinsame Engine fuer grafisches Mapping.
- SAP OData und generisches HANA-Mapping nutzen denselben Composer fuer Joins und Zielfeldmapping.
- SAP OData laedt weiter ueber `SapGatewayService`.
- HANA-Tabellen/Views laden weiter ueber `HanaQueryService`.
- Der alte B1-HANA-Pfad ohne Mapping bleibt als Legacy-Pfad fuer bestehende BI1/SAGE-Standorte erhalten.
- `ConsolidatedExportService.ExportAsync()` erzeugt die zentrale Datei nur noch aus `CentralSalesRecords`; es gibt keinen zweiten Records-Parameter mehr.
- Manual Excel/CSV akzeptiert im Standort-Editor und Upload `.xlsx` und `.csv`.
Neue Konfigurationstabellen:
- `FinanceReferences`: Soll-/check.xlsx-Referenzen je Jahr und Land/Key.
- `FinanceIntercompanyRules`: IC-/2nd-party-Regeln nach Scope, Kundennummer oder Namensbestandteil.
- Budgetkurse 2025 liegen als `CurrencyExchangeRates` mit `Notes = Budget 2025`.
- Config-Export/-Import nimmt `FinanceReferences` und `FinanceIntercompanyRules` mit.
Offen:
- Manual-Excel-Import hat noch zwei Modi: Header-Automatik und grafisches Mapping.
- Der alte B1-HANA-Spezialpfad ist bewusst noch vorhanden, sollte aber mittelfristig durch gepflegte HANA-Mappings abgeloest werden.
Verifikation:
- Hauptprojekt Build erfolgreich.
- Tests `52/52` erfolgreich.
## Nachtrag 2026-05-07 SAP OData / ZSCHWEIZ / Schweiz-Oesterreich
Aktueller Architekturentscheid:
- `ZSCHWEIZ` wird ueber SAP OData/Gateway gelesen.
- Direkter HANA-Spezialcode fuer `ZSCHWEIZ` wurde vermieden.
- Der grafische Quellen- und Feldmapper wird fuer SAP OData verwendet.
- Fuer direkte HANA-Tabellen/Views gibt es ebenfalls grafisches Mapping; das ist aber nicht der geplante Pfad fuer `ZSCHWEIZ`.
Quellsysteme:
- `SAP` = `SAP OData`, Anschlussart `SAP_GATEWAY`.
- `SAP_HANA` = `SAP HANA Tables/Views`, Anschlussart `HANA`.
- `BI1` und `SAGE` bleiben HANA-basierte Quellsysteme.
- `MANUAL_EXCEL` bleibt Excel/CSV.
ABAP/SAP:
- Datei `report.abap` enthaelt Report `ZTRAFAG_SCHWEIZ_EXPORT`.
- Ziel-Tabelle in SAP: `ZSCHWEIZ`.
- `BUKRS 1100` wird als Schweiz (`TRCH`, `CH`) geschrieben.
- `BUKRS 1200` wird als Oesterreich (`TRAT`, `AT`) geschrieben.
- `CUSTOMER_LAND` enthaelt das urspruengliche Kundenland.
- Der Report schreibt paketweise per Upsert.
App-Seed:
- Standort `ZSCHWEIZ` / `Schweiz/Oesterreich` wird inaktiv angelegt bzw. repariert.
- `SourceSystem = SAP`.
- Quelle `Z`, EntitySet `ZSCHWEIZSet`.
- Quelle und Feldmapping werden beim App-Start per Upsert nachgezogen; eine teilweise vorhandene ZSCHWEIZ-Konfiguration bleibt dadurch nicht leer.
- Initiales Mapping:
- `Tsc <- Z.TSC`
- `Land <- Z.LAND1`
- `InvoiceNumber <- Z.VBELN`
- `PositionOnInvoice <- Z.POSNR`
- `SalesPriceValue <- Z.NETWR_HC`
- `SalesCurrency <- Z.HWAER`
- `CustomerCountry <- Z.CUSTOMER_LAND`
Wichtig fuer naechsten Einstieg:
- Wenn die zentrale SAP-Service-URL noch auf `ZPOWERBI_EINKAUF_SRV` zeigt, muss beim Standort `ZSCHWEIZ` ein Service-URL-Override fuer den `ZSCHWEIZ`-OData-Service gesetzt werden.
- Feldinfos kommen ueber `$metadata`; manuelle Feldliste ist nur noetig, wenn Gateway/Metadata nicht funktioniert.
- Nach URL-Setzung: `Entity Sets refreshen`, `Felder aus Quellen laden`, Mapping kontrollieren, Standort aktivieren, Export testen.
Verifikation:
- Hauptprojekt Build erfolgreich.
- Tests `52/52` erfolgreich.
## Nachtrag 2026-05-05 Aktueller Handoff FinanceProbe / Laenderabgleich
Der aktuelle Arbeitsstand fuer den naechsten Einstieg ist der lokale FinanceProbe:
```text
http://localhost:55417/finance
```
Der FinanceProbe wurde als Meeting-Ansicht erweitert:
- `Meeting Ampel 2025`
- `Detail alle Laender`
- `Germany Excel sample check`
- `Spain CSV direct check`
Ampel-Bedeutung:
- Gruen: Ist/Soll passt rechnerisch gegen Referenz.
- Gelb: technische Daten vorhanden, aber Differenz oder fachliche Abgrenzung offen.
- Grau: keine belastbaren Ist-Daten im aktuellen Import.
Wichtige Waehrungsregel fuer Management-Aussage:
- Wenn die Quelle CHF liefert, kann CHF direkt als CHF gezeigt werden.
- Wenn die Quelle EUR/USD/GBP/INR usw. liefert, ist es Mandanten- bzw. Originalwaehrung.
- CHF-Ausweis braucht dann eine separate FX-Regel oder einen offiziell bestaetigten Kurs.
### Spanien
Vorhandene finale Kandidatendatei:
```text
sagespain/v2/Spain_Sales_2025.csv
```
FinanceProbe liest diese Datei direkt.
Aktueller Stand:
- Zeilen: `4'341`
- Ist `SalesPriceValue`: `3'082'320.18` EUR
- Soll aus `check.xlsx`: `3'102'333.61`
- Differenz: `-20'013.43`
- Status: Gelb / Pruefen
Technisch:
- `ManualExcelImportService` kann jetzt semikolongetrennte CSV-Dateien lesen.
- Spanien-v2-CSV kann damit als `MANUAL_EXCEL` importiert werden.
- In der Detailtabelle wird Spanien nicht mehr als `Keine Daten` gezeigt, sondern als `Pruefen` mit dem v2-CSV-Wert.
Offen fachlich:
- Periodenlogik: `FechaFactura` vs. andere Datumsfelder
- Serien: `REG`, `LAT`, `PRO`, `REC`
- Behandlung von Gutschriften / `REC`
- offizielle Sage-Auswertung mit identischem Filter zur Sollzahl
### Deutschland
Vorhandenes Beispielfile:
```text
DE_Beispiel_Export_Daten.xlsx
```
Wichtig:
- Das File ist ein Beispielfile, keine finale DE-Jahresdatei.
- Es darf nicht als finale Ist-Zahl gegen die Jahresreferenz verwendet werden.
Technischer Check:
- relevante Spalte: `NettoPreisGesamtX`
- Mapping-Ziel: `SalesPriceValue`
- Betragszeilen: `2`
- Summe: `8'290.70` EUR
- Waehrung: `EUR`
Interpretation:
- Deutschland-Format ist technisch verstanden.
- Mapping funktioniert.
- Finale DE-Zahl fehlt noch.
- Fuer Abschluss/Meeting wird ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf benoetigt.
### Geaenderte wichtige Dateien
- `Tools/FinanceProbe/Program.cs`
- Management-Ampel
- Spanien-v2-CSV-Direktcheck
- Deutschland-Beispielfile-Check
- `Services/ManualExcelImportService.cs`
- CSV-Support fuer manuelle Quellen
- `Services/DatabaseSeedService.cs`
- deaktivierter Spanien-Standort als Seed/Fallback
- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs`
- Tests fuer CSV/Mapping
- `SAGE_SPAIN_EXPORT_2026-05-05.md`
- Spanien-Doku
- `lastchange.md`
- chronologischer Abschlussstand
### Letzte Verifikation
```text
dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore
```
Ergebnis:
- FinanceProbe Build erfolgreich
- Tests erfolgreich
- `50/50` Tests gruen
- FinanceProbe liefert `HTTP 200`
## Nachtrag 2026-04-29 Dashboard-Referenzcheck Net Sales 2025
Das Dashboard zeigt jetzt oberhalb der Exportaktionen einen Referenzcheck fuer `Net Sales Actuals 2025`.
Zweck:
- schnelle Gegenpruefung, ob die gezogenen Werte gegen `check.xlsx` / Power-BI-Referenz plausibel sind
- automatische Ermittlung des Summenfelds, das am besten zum Referenzwert passt
- sichtbar machen, ob aktuell `SalesPriceValue`, `DocTotalFC - VatSumFC` oder `DocTotal - VatSum` als Vergleichsbasis genutzt wird
- `DocumentTotal*` wird nur dedupliziert pro Beleg verwendet, weil es ein Belegkopfwert ist und in der positionsbasierten Datei pro Position wiederholt wird
Logik:
- Ist-Wert = bester Kandidat aus:
- Summe `CentralSalesRecords.SalesPriceValue`
- Summe `DocumentTotalForeignCurrency - VatSumForeignCurrency`
- Summe `DocumentTotalLocalCurrency - VatSumLocalCurrency`
- Belegkopfwerte werden vor dem Summieren per `TSC` + `DocumentType` + `DocumentEntry` dedupliziert; falls `DocumentEntry` fehlt, per `InvoiceNumber`
- Jahr = `InvoiceDate`, falls vorhanden, sonst `ExtractionDate`
- Vergleichsjahr = `2025`
- Referenzwerte sind aus `check.xlsx` / Power BI Stand 2026-04-29 im Code hinterlegt
- wenn ein Power-BI-Referenzwert vorhanden ist, wird dieser als Vergleich verwendet
- sonst wird der LC-Referenzwert verwendet
Angezeigt werden:
- Firma
- Ist 2025
- Referenz
- Summenfeld
- Referenzquelle (`Power BI` oder `LC`)
- Differenz
- Waehrungen
- Zeilen
- Status `OK`, `Pruefen` oder `Keine Daten`
Verifikation:
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich
- lokaler Dashboard-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200`
Naechster Bedienablauf, damit die korrekten Summen kommen:
1. App starten bzw. offen lassen: `http://localhost:55416`
2. Im Dashboard neue Daten ziehen:
- entweder `Alle exportieren`
- oder einzelne Standorte per `Export`
3. Danach `Zentrale Datei neu erzeugen` ausfuehren.
4. Oben im Dashboard den Block `Net Sales Actuals 2025 Referenz` pruefen.
5. Entscheidend ist die Spalte `Summenfeld`:
- `Sales Price/Value` = Positionssumme
- `DocTotalFC - VatSumFC` = Netto-Belegsumme in Belegwaehrung, dedupliziert pro Beleg
- `DocTotal - VatSum` = Netto-Belegsumme in Hauswaehrung, dedupliziert pro Beleg
6. `Status = OK` bedeutet: Abweichung zur Referenz maximal 1.
7. `Status = Pruefen` bedeutet: Feld, Datenquelle, Zeitraum oder Standortkonfiguration fachlich kontrollieren.
Wichtig:
- Mit alten zentralen Daten bleiben die neuen B1-Felder leer bzw. `0`.
- Fuer die echte Pruefung von `DocEntry`, `DocTotal*`, `VatSum*`, `DocRate` und `OADM.MainCurncy` muss zuerst neu exportiert werden.
- Fuer neue Jahre ist aktuell noch kein dynamischer Referenzjahres-Schalter eingebaut; der harte Referenzcheck ist Stand jetzt auf `2025`, weil `check.xlsx` die 2025-Referenzen enthaelt.
## Nachtrag 2026-04-29 Export-all-Abbruch / SQLite-FK-Reparatur
Beim Klick auf `Export all` kam:
- `An error occurred while saving the entity changes. See the inner exception for details.`
Ursache:
- die bestehende SQLite-DB hatte in `ExportLogs`, `AppEventLogs` und `CentralSalesRecords` noch Foreign Keys auf `"Sites_repair_old"`
- diese Reparatur-Zwischentabelle existiert nicht mehr
- beim Speichern neuer Logs oder zentraler Datensaetze konnte SQLite deshalb nicht mehr korrekt speichern
Korrektur:
- `DatabaseSchemaMaintenanceService` erkennt jetzt nicht nur `Sites_old`, sondern auch alte Reparaturtabellen wie `Sites_repair_old`
- betroffene Tabellen werden beim App-Start automatisch neu aufgebaut
- `AppEventLogService` und `ExportLogService` fangen eigene Log-Speicherfehler ab, damit Logging-Probleme nicht den ganzen Export abbrechen
- Dashboard-Fehlerausgaben zeigen jetzt auch die Inner Exception, falls vorhanden
Verifikation:
- App neu gestartet
- DB-Schema direkt geprueft:
- `AppEventLogs` -> `FOREIGN KEY (SiteId) REFERENCES Sites (Id)`
- `ExportLogs` -> `FOREIGN KEY (SiteId) REFERENCES Sites (Id)`
- `CentralSalesRecords` -> `FOREIGN KEY (SiteId) REFERENCES Sites (Id)`
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich
Direkt danach beobachtete Exportfehler:
- Frankreich/Italien/USA: `invalid schema name ... line 40` durch HANA-Query-Quoting
- Ursache: Query nutzte `"schema"."Tabelle"`
- Korrektur: wieder `schema."Tabelle"` wie im alten funktionierenden Stand
- Indien: `authentication failed`
- Konfiguration/Credentials pruefen, kein Codefehler aus dieser Aenderung
- England/Spanien/Deutschland: `MANUAL_EXCEL`, aber keine manuelle Excel-Datei hinterlegt
- entweder Datei hinterlegen oder Standort deaktivieren/Quellsystem korrigieren
## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder aus HANA
Der HANA/B1-Export wurde um Beleg- und Hauswaehrungsfelder erweitert.
Grund:
- `p.StockPrice` muss fachlich in der B1-Hauswaehrung bewertet werden
- die Hauswaehrung kommt aus `OADM.MainCurncy`
- bisher wurde `StandardCostCurrency` aus `p.Currency` bzw. `h.DocCur` abgeleitet
- fuer Power-BI-/Cockpit-Gegenpruefung muessen Belegwaehrung, Hauswaehrung, Netto-/Steuerbetraege und Kurs sichtbar sein
Neue Felder in `SalesRecord` und `CentralSalesRecord`:
- `DocumentEntry`
- `DocumentCurrency`
- `DocumentTotalForeignCurrency`
- `DocumentTotalLocalCurrency`
- `VatSumForeignCurrency`
- `VatSumLocalCurrency`
- `DocumentRate`
- `CompanyCurrency`
B1-Feldmapping:
- `DocumentEntry` = `OINV/ORIN.DocEntry`
- `DocumentCurrency` = `OINV/ORIN.DocCur`
- `DocumentTotalForeignCurrency` = `OINV/ORIN.DocTotalFC`
- `DocumentTotalLocalCurrency` = `OINV/ORIN.DocTotal`
- `VatSumForeignCurrency` = `OINV/ORIN.VatSumFC`
- `VatSumLocalCurrency` = `OINV/ORIN.VatSum`
- `DocumentRate` = `OINV/ORIN.DocRate`
- `CompanyCurrency` = `OADM.MainCurncy`
- `StandardCostCurrency` = `OADM.MainCurncy`
Technische Umsetzung:
- `HanaQueryService` liest `OADM` jetzt per `CROSS JOIN`
- Invoice- und Credit-Note-Query liefern die neuen Felder
- bei Gutschriften werden Dokument- und Steuerbetraege mit negativem Vorzeichen uebernommen
- `CentralSalesRecords`-Schema wurde erweitert
- bestehende SQLite-DBs erhalten die neuen Spalten per `DatabaseSchemaMaintenanceService`
- `CentralSalesRecordService` persistiert und liest die neuen Felder
- `ExcelExportService` schreibt die neuen Spalten in Standort- und `Sales_All_*.xlsx`-Dateien
- `ManualExcelImportService` kann die neuen Spalten wieder einlesen
- `ConfigTransferService` erhaelt die neuen Felder beim Remapping zentraler Laufzeitdaten
Wichtig fuer Power BI:
- die neuen `DocumentTotal*`- und `VatSum*`-Felder sind Belegkopfwerte
- sie werden in der positionsbasierten Excel pro Positionszeile wiederholt
- diese Felder duerfen daher nicht blind positionsweise summiert werden
- fuer Belegkopfsummen in Power BI zuerst nach `DocumentType`, `Invoice Number`, `TSC` und ggf. `Land` deduplizieren
- besser: nach `TSC` + `DocumentType` + `DocumentEntry` deduplizieren, weil `DocEntry` aus B1 jetzt mitgezogen wird
- positionsbasierte Auswertungen sollen weiterhin mit positionsbezogenen Feldern wie `Sales Price/Value`, `Quantity` oder `Standard cost` arbeiten
Wichtig zum aktuellen Datenbestand:
- alte zentrale Daten wurden vor der Erweiterung exportiert und haben fuer die neuen B1-Felder noch `0`
- nach einem neuen Export/Rebuild der zentralen Daten koennen `DocEntry`, `DocTotal*`, `VatSum*`, `DocRate` und `OADM.MainCurncy` fachlich verglichen werden
Verifikation:
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich
- `48/48` Tests gruen
- `ManualExcelImportServiceTests` pruefen die neuen Excel-Spalten
- `CentralSalesRecordServiceTests` pruefen Persistenz und Ruecklesen der neuen B1-Felder
## Nachtrag 2026-04-29 Clean-Code-/DI-Befund
Der aktuelle Code ist DI-orientiert und deutlich besser strukturiert als zu Beginn des Refactorings, aber noch nicht durchgehend ein Clean-Code-Ideal.
Positiv:
- Services werden weitgehend ueber Interfaces und DI verdrahtet
- `DataSourceAdapter` trennt die Quellsysteme
- Page-Services reduzieren direkte DB-Logik in mehreren Razor-Seiten
- `Scoped` fuer UI-nahe Services und `Singleton` fuer gemeinsame Infrastruktur/Orchestrierung ist bewusst gewaehlt
- Testabdeckung fuer zentrale Fachlogik ist vorhanden und waechst
Weiterhin offene Clean-Code-Risiken:
- `DatabaseInitializationService` ist weiterhin produktiver Reparatur-/Migrationspfad
- `Settings.razor` und `Standorte.razor` enthalten noch viel Workflow-/UI-Logik
- `ManagementCockpitService` und `ConfigTransferService` sind breit und sollten spaeter weiter aufgeteilt werden
- Retry-/Robustheitslayer fuer externe Systeme fehlt
- Secret-Store fehlt
- Auth-Rollenmodell ist aktuell pragmatisch, aber noch grob
Bewertung:
- Architektur: brauchbar bis gut
- DI: grundsaetzlich sauber
- Clean Code: mittel bis gut, mit klaren Altlasten
Dieser Befund wurde bewusst nur dokumentiert. Die strukturelle Bereinigung wird spaeter priorisiert.
## Nachtrag 2026-04-29 Authentifizierung / AD-Zugriffsschutz
Nach Rueckmeldung der IT wurde ein Zugriffsschutz fuer die Blazor-App eingebaut.
Vorher konnte jeder Benutzer mit Netzwerkzugriff auf die App-URL die Anwendung oeffnen. Das war kritisch, weil die App Verkaufsdaten, Standort-/Quellsystemkonfiguration, SharePoint-Konfiguration, Config Import/Export und Secrets bzw. Zugangsdatenfelder beruehrt.
Neuer Stand:
- die App ist grundsaetzlich authentifizierungspflichtig
- produktives Ziel ist Windows Authentication / Active Directory
- Berechtigungen laufen ueber AD-Gruppen
- es gibt keine eigene Benutzer-/Passwortverwaltung in der App
- es gibt keinen versteckten produktiven Backdoor
Neue Security-Dateien:
- `Security/SecurityOptions.cs`
- `Security/SecurityPolicies.cs`
- `Security/DevelopmentAuthenticationHandler.cs`
Geaenderte zentrale Dateien:
- `Program.cs`
- `Components/Routes.razor`
- `Components/_Imports.razor`
- `Components/Layout/NavMenu.razor`
- `Components/Layout/MainLayout.razor`
- `appsettings.json`
- `appsettings.Development.json`
Aktuelles Rollenmodell:
- `Security:AccessGroups` steuert Zugriff auf die App
- `Security:AdminGroups` steuert Admin-Berechtigung
- Default-Gruppen sind `TRAFAG\\TrafagSalesExporter-Users` und `TRAFAG\\TrafagSalesExporter-Admins`
- echte Gruppennamen muessen von der IT bestaetigt oder angepasst werden
Admin-geschuetzte Seiten:
- `Settings`
- `Standorte`
- `Transformations`
Dashboard, Management Cockpit und Logs bleiben fuer berechtigte angemeldete Benutzer sichtbar.
Development:
- `appsettings.Development.json` aktiviert bei `ASPNETCORE_ENVIRONMENT=Development` einen lokalen Development-Auth-Handler
- Default-User: `DEV\\TrafagDeveloper`
- `DevelopmentUserIsAdmin=true`, damit lokal weiter programmiert werden kann
- produktiv darf die App nicht mit `Development` laufen
IIS-/IT-Hinweise:
1. Windows Authentication aktivieren
2. Anonymous Authentication deaktivieren
3. `ASPNETCORE_ENVIRONMENT` produktiv nicht auf `Development` setzen
4. AD-Gruppen fuer Benutzer und Admins anlegen oder bestehende Gruppen eintragen
5. `Security:AccessGroups` und `Security:AdminGroups` in produktiver Konfiguration korrekt setzen
Aktueller IIS-Deployment-Nachtrag 2026-05-20:
- Vollstaendige Detaildoku: `docs/DEPLOYMENT_IIS_HANDOFF_2026-05-19.md`
- Fuehrendes Projekt bleibt `TrafagSalesExporter`.
- Publish-Ausgabe heisst fuer IIS weiterhin `BiDashboard.dll`, ohne EXE/AppHost.
- Publish-Ziel: `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`
- Browser-URL: `https://trch-webapp-bidashboard.trafagch.local/BiDashboard/`
- `diag.txt` unter `/BiDashboard/diag.txt` ist erreichbar und beweist, dass IIS auf den richtigen Publish-Ordner zeigt.
- Der verbleibende `500` entsteht beim ASP.NET-Core-Start oder im ASP.NET-Core-IIS-Modul.
- `web.config` steht aktuell auf `hostingModel="outofprocess"`, `stdoutLogEnabled="true"` und `ASPNETCORE_DETAILEDERRORS=true`.
- Wenn `logs` leer bleibt, muss der Serveradmin im Event Viewer pruefen: `IIS AspNetCore Module V2`, `.NET Runtime`, `Application Error`.
- Server muss kein Microsoft Excel installiert haben; XLSX wird ueber ClosedXML/OpenXML gelesen.
Verifikation:
```text
dotnet build .\TrafagSalesExporter.csproj --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
```
Ergebnis:
- Build erfolgreich
- Tests erfolgreich
- `48/48` Tests gruen
- Auth-Policy-Tests fuer AccessGroup, AdminGroup und Development-Admin vorhanden
- lokaler Development-Auth-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200`
- bekannte MudBlazor-Analyzer-Warnungen zu `Dense` bleiben
## Nachtrag 2026-04-29 Management-Cockpit-Auswertung
Seit dem letzten dokumentierten Stand vom 2026-04-17 wurde das `Management Cockpit` weiter ausgebaut. Dieser Abschnitt rekonstruiert den aktuellen Stand aus dem Code, weil die Aenderungen nach einem PC-Absturz nicht direkt nachdokumentiert wurden.
### Neue Auswertlogik
Das Cockpit ist nicht mehr nur auf Umsatz als feste Kennzahl beschraenkt.
Neu gibt es auswählbare Summenfelder:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
Diese Auswahl gilt fuer:
- dateibasierte Analyse vorhandener Excel-Exporte
- zentrale Roh-Auswertung aus `CentralSalesRecords`
### Anzeige-Waehrung und Wechselkurse
Fuer betragliche Summenfelder kann jetzt eine Anzeige-Waehrung gewaehlt werden:
- `EUR`
- `USD`
- `Original`
Die Umrechnung nutzt `CurrencyExchangeRateService`.
Wichtig:
- nicht-betragliche Werte wie `Quantity` werden nicht umgerechnet
- bei `Original` bleiben Werte in der jeweiligen Quellwaehrung
- bei fehlendem Wechselkurs wird der betroffene Wert mit `0` in die Zielwaehrung eingerechnet
- fehlende Kurse werden als Anzahl `Nicht umgerechnet` bzw. in Hinweisen/Finding sichtbar gemacht
- Wechselkurse werden pro Quellwaehrung, Zielwaehrung und Datum gecacht, damit grosse Auswertungen nicht unnoetig oft die gleiche Rate aufloesen
### Zusätzliche Summenfelder in der zentralen Sicht
Die zentrale Roh-Auswertung kann neben dem Haupt-Summenfeld weitere Summenfelder anzeigen.
Diese Zusatzwerte werden aktuell in den Zeitreihen ausgegeben:
- Jahreswerte
- Monatswerte
- Tageswerte im gewaehlten Monat
Beispiel:
- Hauptwert: `Sales Price/Value`
- Zusatzwerte: `Quantity`, `Quantity * Standard cost`
Damit kann die zentrale Sicht Umsatz, Mengen und Kostennaeherung nebeneinander darstellen.
### UI-Stand
`Components/Pages/ManagementCockpit.razor` hat neue Controls:
- Summenfeld fuer Excel-Dateianalyse
- Anzeige-Waehrung fuer Excel-Dateianalyse
- Summenfeld fuer zentrale Roh-Auswertung
- weitere Summenfelder fuer zentrale Roh-Auswertung per Mehrfachauswahl
- Anzeige-Waehrung fuer zentrale Roh-Auswertung
Die Tabellen wurden von festem Text `Umsatz` auf generische `Werte` / `Jahreswerte` / `Monatswerte` umgestellt.
Die vorher dokumentierte Manometer-/Gauge-Sicht ist im aktuellen Arbeitsstand nicht mehr aktiv sichtbar. Stattdessen liegt der Fokus wieder auf Kennzahlen, Hinweisen und tabellarischen Auswertungen.
### Technische Umsetzung
Betroffene Dateien:
- `Components/Pages/ManagementCockpit.razor`
- `Models/ManagementCockpitModels.cs`
- `Services/IManagementCockpitService.cs`
- `Services/ManagementCockpitPageService.cs`
- `Services/ManagementCockpitService.cs`
- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs`
Neue bzw. erweiterte Modelle:
- `ManagementCockpitValueFieldKeys`
- `ManagementCockpitCurrencyOptions`
- `ManagementCockpitValueFieldOption`
- `ManagementCockpitAnalysisOptions`
- `ManagementCockpitAggregatedFieldValue`
Neue Felder in Ergebnissen:
- gewaehltes Summenfeld
- Anzeige-Waehrung
- Anzahl fehlender Wechselkurse
- Zusatzwerte pro Zeitreihe
### Testabdeckung
Die `ManagementCockpitServiceTests` wurden erweitert um Tests fuer:
- Umrechnung zentraler Werte in EUR
- Caching von Wechselkursauflösungen
- Mengen-Summe ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Jahres- und Monatswerten
Noch offen:
- UI manuell pruefen
- genaue fachliche Zielwaehrung fuer Standardberichte bestaetigen
- entscheiden, ob `CHF` ebenfalls als direkte Anzeige-Waehrung angeboten werden soll
- klaeren, ob fehlende Wechselkurse langfristig mit `0`, Originalwert oder separater Fehlergruppe dargestellt werden sollen
## Nachtrag 2026-04-17 Refactoring- und HANA-Stand
Der Stand aus den frueheren Nachtraegen ist fuer Architektur und HANA-Zugriff nicht mehr vollstaendig.
Inzwischen gilt zusaetzlich:
### 1. DataSourceAdapter-Pattern ist eingefuehrt
Die Quellsysteme `HANA`, `SAP_GATEWAY` und `MANUAL_EXCEL` laufen nicht mehr ueber einen grossen `if/else`-Block im `SiteExportService`.
Neu:
- `Services/DataSources/IDataSourceAdapter.cs`
- `Services/DataSources/IDataSourceAdapterResolver.cs`
- `Services/DataSources/DataSourceAdapterResolver.cs`
- `Services/DataSources/DataSourceFetchContext.cs`
- `Services/DataSources/DataSourceFetchResult.cs`
- `Services/DataSources/DataSourceCredentials.cs`
- `Services/DataSources/HanaDataSourceAdapter.cs`
- `Services/DataSources/SapGatewayDataSourceAdapter.cs`
- `Services/DataSources/ManualExcelDataSourceAdapter.cs`
Neuer Zuschnitt:
- `SiteExportService` ist jetzt deutlich schlanker und nur noch Export-Pipeline
- Adapter loesen Quellsystem-spezifisches Laden auf
- fuer ein weiteres Quellsystem ist kein Umbau im `SiteExportService` mehr noetig
### 2. Page-Services sind nicht mehr Singleton
UI-nahe Services laufen jetzt pro Blazor-Circuit als `Scoped`.
Betroffen:
- `ISettingsPageService`
- `IStandortePageService`
- `IStandorteSapEditorService`
- `IManagementCockpitPageService`
- `IDashboardPageService`
- `ILogsPageService`
- `ITransformationsPageService`
Wichtig:
- `ExportOrchestrationService` bleibt bewusst `Singleton`, weil Exportstatus ueber Circuits geteilt werden muss
- stateless Infrastruktur-Services bleiben weiter `Singleton`
### 3. Datenbank-Initialisierung ist aufgeteilt
Der fruehere monolithische `DatabaseInitializationService` ist inzwischen in grobe Verantwortungsbloecke getrennt:
- `DatabaseInitializationService` als Orchestrator
- `DatabaseSchemaMaintenanceService` fuer Schema-/Repair-Logik
- `DatabaseSeedService` fuer Defaultdaten und Stammdaten-Seeding
- `DatabaseInitializationService.SchemaSql.cs` als SQL-Definitionsblock
Das reduziert das groesste Architektur-Risiko deutlich, auch wenn die Startmigrationen weiterhin ein sensibler Teil des Systems bleiben.
### 4. Weitere Razor-Seiten sind entlastet
Neben den frueher bereits entlasteten Seiten laufen jetzt auch diese Seiten ueber Page-Services statt direkten `DbContext`-Zugriffen:
- `Dashboard.razor` ueber `DashboardPageService`
- `Logs.razor` ueber `LogsPageService`
- `Transformations.razor` ueber `TransformationsPageService`
Der Rest an direkter Persistenzlogik in Razor ist damit deutlich kleiner geworden.
### 5. Kritische HANA-Risiken wurden entschärft
#### SQL-Injection-Schutz
Im `HanaQueryService` wurden die kritischen interpolierten SQL-Stellen bereinigt:
- `tsc` und `dateFilter` laufen jetzt parametriert in `HanaCommand`
- `schema` wird als Identifier streng validiert und gequotet
Damit ist der akute Injection-Pfad in den HANA-Verkaufsabfragen geschlossen.
#### Async statt `.GetAwaiter().GetResult()`
Die blockierenden HANA-Aufrufe wurden auf echte Async-Methoden umgestellt:
- `IHanaQueryService` ist jetzt async-basiert
- `HanaQueryService` nutzt `OpenAsync`, `ExecuteReaderAsync`, `ReadAsync`, `ExecuteScalarAsync`
- Aufrufer wie `HanaDataSourceAdapter`, `StandortePageService` und `SettingsPageService` verwenden keine `Task.Run`-Workarounds mehr fuer HANA
Damit ist das fruehere Deadlock-/Blocking-Risiko in diesem Pfad deutlich reduziert.
### 6. Test- und Build-Stand
Verifiziert wurde zuletzt mit:
```text
dotnet build .\TrafagSalesExporter.csproj --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
```
Ergebnis:
- Projekt-Build erfolgreich
- `36/36` Tests gruen
Bekannt:
- `dotnet build .\TrafagSalesExporter.sln` endet in dieser Umgebung weiterhin mit Exitcode `1` ohne konkrete Compilerfehler
- das Hauptprojekt und das Testprojekt bauen aber erfolgreich
- bekannte Warnungen bleiben:
- `MSB3270` wegen HANA-Assembly-Architektur
- MudBlazor-Analyzer zu `Dense`
### 7. Aktuelles Architektururteil
Der Zustand ist jetzt deutlich professioneller als zu Beginn des Refactorings:
- Datenquellen sauberer getrennt
- UI konsistenter ueber Page-Services geschnitten
- groesster Start-/Schema-Block zerlegt
- HANA-Pfad sicherer und sauberer asynchron
Aber noch nicht vollendet:
- keine gezielten Adapter-/Resolver-Unit-Tests
- keine Retry-Strategie fuer SharePoint / SAP / HANA-Netzpfade
- kein Secret-Store
- `DatabaseInitializationService` bleibt trotz Zerlegung ein sensibler produktiver Migrationspfad
## Nachtrag 2026-04-17 ## Nachtrag 2026-04-17
+216 -4
View File
@@ -1,6 +1,101 @@
# TrafagSalesExporter LLM System Guide # TrafagSalesExporter LLM System Guide
Stand: 2026-04-17 Stand: 2026-05-05
## Aktueller Projektstand 2026-05-05
Fuer den aktuellen Finance-/Laenderabgleich zuerst diese Dateien lesen:
- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md)
- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md)
- [lastchange.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/lastchange.md)
- [SAGE_SPAIN_EXPORT_2026-05-05.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md)
Lokaler FinanceProbe:
```text
http://localhost:55417/finance
```
## Aktueller Zusatzstand 2026-05-07 SAP OData / ZSCHWEIZ
Schweiz/Oesterreich werden ueber eine neue SAP-Tabelle `ZSCHWEIZ` bereitgestellt.
Wichtige Punkte:
- ABAP-Report: `report.abap`
- SAP-Tabelle: `ZSCHWEIZ`
- OData EntitySet: `ZSCHWEIZSet`
- App-Standort: `ZSCHWEIZ` / `Schweiz/Oesterreich`
- Geplanter App-Pfad: `SAP` = `SAP OData`, nicht direkter HANA-Spezialcode
Quellsystem-Codes:
- `SAP`: SAP OData/Gateway, DisplayName `SAP OData`
- `SAP_HANA`: direkte HANA-Tabellen/Views, DisplayName `SAP HANA Tables/Views`
- `BI1`: HANA
- `SAGE`: HANA
- `MANUAL_EXCEL`: Excel/CSV
Mapper:
- SAP OData nutzt `SapSourceDefinition`, `SapJoinDefinition`, `SapFieldMapping`.
- Direkte HANA-Tabellen/Views koennen dieselben Mapping-Tabellen ebenfalls nutzen.
- Gemeinsame Mapping-Engine ist `MappedSalesRecordComposer`.
- `SapCompositionService` und `HanaQueryService.GetMappedSalesRecordsAsync` unterscheiden sich nur noch in der Quellenbeschaffung; Join und `SalesRecord`-Mapping sind zentral.
- Bei HANA mit gepflegten Quellen/Mappings nutzt `HanaDataSourceAdapter` den generischen Mapping-Pfad.
- Ohne HANA-Mapping bleibt der alte B1-HANA-Standardpfad fuer `OINV/INV1/ORIN/RIN1` aktiv.
Finance-Konfiguration:
- `FinanceReferences` enthaelt Soll-/check.xlsx-Referenzen.
- `FinanceIntercompanyRules` enthaelt 2nd-party/IC-Regeln.
- Budgetkurse werden als `CurrencyExchangeRates` mit `Notes = Budget 2025` gepflegt.
- Config-Export/-Import umfasst Finance-Referenzen und IC-Regeln.
ZSCHWEIZ-Seed:
- Quelle Alias `Z`
- EntitySet `ZSCHWEIZSet`
- Mapping auf `SalesRecord` ist vorbefuellt und grafisch editierbar.
- Beim App-Start wird die ZSCHWEIZ-Quelle samt Feldmapping per Upsert angelegt oder repariert.
- Wenn Gateway `$metadata` liefert, koennen Felder in der UI per `Felder aus Quellen laden` gelesen werden.
ABAP-Fachlogik:
- `BUKRS 1100` = Schweiz, `TSC TRCH`, `LAND1 CH`
- `BUKRS 1200` = Oesterreich, `TSC TRAT`, `LAND1 AT`
- `CUSTOMER_LAND` = Kundenland aus `KNA1-LAND1`
- Netto-/Steuerwerte werden in Belegwaehrung und Hauswaehrung geschrieben.
Aktuelle FinanceProbe-Funktionen:
- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx`
- `Detail alle Laender`
- `Spain CSV direct check`
- `Germany Excel sample check`
Spanien:
- Datei: `sagespain/v2/Spain_Sales_2025.csv`
- Ist: `3'082'320.18` EUR
- Soll: `3'102'333.61`
- Differenz: `-20'013.43`
- Status: Gelb / Pruefen
- Technisch lesbar, fachliche Differenz noch offen
Deutschland:
- Datei: `DE_Beispiel_Export_Daten.xlsx`
- Sample-Summe `NettoPreisGesamtX`: `8'290.70` EUR
- Nur Beispielfile, keine finale Jahreszahl
- Mapping technisch verstanden, finaler DE-Jahresfile fehlt
Letzte Verifikation:
- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore`
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore`
- Ergebnis: Build OK, Tests `50/50`, FinanceProbe `HTTP 200`
Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen. Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen.
@@ -25,6 +120,7 @@ Zielbild:
## Technologie-Stack ## Technologie-Stack
- UI: Blazor Server + MudBlazor - UI: Blazor Server + MudBlazor
- Authentifizierung: ASP.NET Core Authentication/Authorization, produktiv Windows Authentication / Active Directory
- Datenbank: SQLite (`trafag_exporter.db`) - Datenbank: SQLite (`trafag_exporter.db`)
- Excel lesen/schreiben: ClosedXML - Excel lesen/schreiben: ClosedXML
- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll` - SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll`
@@ -42,6 +138,14 @@ Wichtige Dateien:
`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus. `Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus.
Zusaetzlich registriert `Program.cs` den Zugriffsschutz:
- `AddCascadingAuthenticationState`
- Windows Authentication fuer produktive Umgebungen
- Development-Authentication-Handler nur bei `ASPNETCORE_ENVIRONMENT=Development` und `Security:DevelopmentBypass=true`
- globale Fallback-Policy fuer authentifizierte/berechtigte User
- Policy `AdminOnly` fuer administrative Seiten
## Hauptseiten ## Hauptseiten
Navigation: Navigation:
@@ -71,6 +175,13 @@ Kurzrollen:
- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export - `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export
- `Logs`: technische Ereignisprotokolle - `Logs`: technische Ereignisprotokolle
Security:
- alle Routen erfordern Authentifizierung
- `Settings`, `Standorte` und `Transformations` sind `AdminOnly`
- Admin-Navigation wird nur fuer Admins angezeigt
- eingeloggter Benutzer wird im App-Bar angezeigt
## Kernmodelle ## Kernmodelle
Wichtige Entity-Klassen: Wichtige Entity-Klassen:
@@ -90,6 +201,18 @@ Wichtige Entity-Klassen:
- [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs) - [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs)
- [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs) - [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs)
`SalesRecord` / `CentralSalesRecord` enthalten neben den positionsnahen Feldern auch B1-Belegwaehrungsfelder:
- `DocumentCurrency` aus `DocCur`
- `DocumentTotalForeignCurrency` aus `DocTotalFC`
- `DocumentTotalLocalCurrency` aus `DocTotal`
- `VatSumForeignCurrency` aus `VatSumFC`
- `VatSumLocalCurrency` aus `VatSum`
- `DocumentRate` aus `DocRate`
- `CompanyCurrency` aus `OADM.MainCurncy`
Wichtig: diese Dokumentwerte sind Belegkopfwerte und werden in der positionsbasierten Excel pro Position wiederholt. Fuer Belegkopfsummen muessen Auswertungen nach Beleg deduplizieren.
Wichtige Relationen: Wichtige Relationen:
- `Site -> HanaServer` optional - `Site -> HanaServer` optional
@@ -228,12 +351,32 @@ Zwei Betriebsarten:
1. Dateibasiert 1. Dateibasiert
- vorhandene `.xlsx` waehlen - vorhandene `.xlsx` waehlen
- Datei mit ClosedXML lesen - Datei mit ClosedXML lesen
- Summenfeld waehlen
- Anzeige-Waehrung waehlen
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen - Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
2. Zentraldatenbasiert 2. Zentraldatenbasiert
- direkt aus `CentralSalesRecords` - direkt aus `CentralSalesRecords`
- Jahr/Monat Filter - Jahr/Monat Filter
- Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik - Summenfeld waehlen
- optionale weitere Summenfelder fuer Zeitreihen waehlen
- Anzeige-Waehrung waehlen
- Rohsicht ohne Intercompany-, Budget- oder Spartelogik
Aktuelle Summenfelder:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
Aktuelle Anzeige-Waehrungen:
- `EUR`
- `USD`
- `Original`
Die Waehrungsumrechnung nutzt `CurrencyExchangeRateService`. Bei `Original` bleiben Werte in Quellwaehrungen gruppiert. Nicht-betragliche Summenfelder wie `Quantity` haben keine Waehrung. Fehlende Wechselkurse werden gezaehlt und in Hinweisen bzw. Findings sichtbar; betroffene Werte werden in der Zielwaehrung mit `0` einbezogen.
## Quellsystemlogik ## Quellsystemlogik
@@ -323,11 +466,14 @@ Vorhanden:
- `ExchangeRateImportService` fuer ECB-Tageskurse - `ExchangeRateImportService` fuer ECB-Tageskurse
- `NormalizeCurrencyCode` - `NormalizeCurrencyCode`
- `ConvertCurrency` - `ConvertCurrency`
- `ManagementCockpitService` kann betragliche Cockpit-Kennzahlen in `EUR` oder `USD` umrechnen
Wichtig: Wichtig:
- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um - die Rohsicht im `Management Cockpit` kann jetzt Anzeige-Waehrungen nutzen
- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht - `CHF` ist im Cockpit aktuell nicht als direkte Anzeige-Waehrung in der UI angeboten
- CHF bleibt weiterhin Teil des allgemeinen Transformationssystems
- fachlich ist noch zu klaeren, ob CHF als Standard- oder zusaetzliche Cockpit-Anzeige-Waehrung gebraucht wird
## SharePoint-Rolle im Gesamtsystem ## SharePoint-Rolle im Gesamtsystem
@@ -380,6 +526,49 @@ Bereits gehaertete Fehlerbilder:
- Legacy-Credential-Spalten in `HanaServers` - Legacy-Credential-Spalten in `HanaServers`
- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad - verschobene Spalten im `Sites_old -> Sites`-Kopierpfad
## Authentifizierung / Autorisierung
Dateien:
- [Security/SecurityOptions.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityOptions.cs)
- [Security/SecurityPolicies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityPolicies.cs)
- [Security/DevelopmentAuthenticationHandler.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs)
- [Components/Routes.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Routes.razor)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
- [Components/Layout/MainLayout.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/MainLayout.razor)
Produktives Ziel:
- Windows Authentication / Active Directory
- keine eigene Benutzerverwaltung
- Zugriff ueber AD-Gruppen
- Adminrechte ueber separate AD-Gruppe
Konfiguration in `appsettings.json`:
- `Security:AccessGroups`
- `Security:AdminGroups`
- `Security:DevelopmentBypass`
- `Security:DevelopmentUserIsAdmin`
- `Security:DevelopmentUserName`
Default-Gruppen:
- `TRAFAG\\TrafagSalesExporter-Users`
- `TRAFAG\\TrafagSalesExporter-Admins`
Development:
- `appsettings.Development.json` aktiviert einen lokalen Development-Auth-Handler
- dieser ist nur fuer lokale Entwicklung gedacht
- produktiv darf `ASPNETCORE_ENVIRONMENT` nicht `Development` sein
IIS-Betrieb:
- Windows Authentication aktivieren
- Anonymous Authentication deaktivieren
- AD-Gruppennamen in produktiver Konfiguration setzen
## Config Import / Export ## Config Import / Export
Dateien: Dateien:
@@ -429,6 +618,29 @@ Aktuell vorhandene Schwerpunkte:
- ConfigTransferService - ConfigTransferService
- DatabaseInitializationService - DatabaseInitializationService
`ManagementCockpitServiceTests` decken inzwischen auch ab:
- zentrale Analyse nach Jahr/Monat
- Tages-, Monats-, Jahres-, Quellen- und Laenderwerte
- waehlbare Summenfelder
- Waehrungsumrechnung in EUR
- Wechselkurs-Caching
- Mengen-Auswertung ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Zeitreihen
`SecurityPolicyFactoryTests` decken inzwischen ab:
- App-Zugriff fuer User in `AccessGroups`
- Ablehnung fuer User ausserhalb der Access-Gruppen
- Development-Auth-Zugriff im lokalen Modus
- Admin-Zugriff fuer User in `AdminGroups`
- Ablehnung normaler User fuer `AdminOnly`
- Development-Admin-Claim
`CentralSalesRecordServiceTests` decken inzwischen ab:
- Persistenz und Ruecklesen der B1-Belegwaehrungsfelder in `CentralSalesRecords`
Wichtig: Wichtig:
- es gibt aktuell keine echten UI-Komponententests mit `bUnit` - es gibt aktuell keine echten UI-Komponententests mit `bUnit`
@@ -14,6 +14,7 @@ public class CentralSalesRecord
public string SourceSystem { get; set; } = string.Empty; public string SourceSystem { get; set; } = string.Empty;
public DateTime ExtractionDate { get; set; } public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; } public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty; public string Material { get; set; } = string.Empty;
@@ -32,8 +33,16 @@ public class CentralSalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty; public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; } public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty; public string SalesCurrency { get; set; } = string.Empty;
public string DocumentCurrency { get; set; } = string.Empty;
public decimal DocumentTotalForeignCurrency { get; set; }
public decimal DocumentTotalLocalCurrency { get; set; }
public decimal VatSumForeignCurrency { get; set; }
public decimal VatSumLocalCurrency { get; set; }
public decimal DocumentRate { get; set; }
public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; } public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; } public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
@@ -9,12 +9,16 @@ public class ConfigTransferPackage
public ConfigTransferExportSettings? ExportSettings { get; set; } public ConfigTransferExportSettings? ExportSettings { get; set; }
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = []; public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = [];
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = []; public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
public List<ConfigTransferFinanceReference> FinanceReferences { get; set; } = [];
public List<ConfigTransferFinanceIntercompanyRule> FinanceIntercompanyRules { get; set; } = [];
public List<FinanceRule> FinanceRules { get; set; } = [];
public List<ConfigTransferHanaServer> HanaServers { get; set; } = []; public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
public List<ConfigTransferSite> Sites { get; set; } = []; public List<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = []; public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = []; public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = []; public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = []; public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
public List<ConfigTransferManualExcelColumnMapping> ManualExcelColumnMappings { get; set; } = [];
} }
public class ConfigTransferSourceSystemDefinition public class ConfigTransferSourceSystemDefinition
@@ -60,6 +64,26 @@ public class ConfigTransferCurrencyExchangeRate
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
} }
public class ConfigTransferFinanceReference
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public int Year { get; set; } = 2025;
public decimal? LocalCurrencyValue { get; set; }
public decimal? CheckValue { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class ConfigTransferFinanceIntercompanyRule
{
public string ScopeKey { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerNameContains { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class ConfigTransferHanaServer public class ConfigTransferHanaServer
{ {
public string Key { get; set; } = Guid.NewGuid().ToString("N"); public string Key { get; set; } = Guid.NewGuid().ToString("N");
@@ -124,3 +148,13 @@ public class ConfigTransferSapFieldMapping
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int SortOrder { get; set; } public int SortOrder { get; set; }
} }
public class ConfigTransferManualExcelColumnMapping
{
public string SiteKey { get; set; } = string.Empty;
public string TargetField { get; set; } = string.Empty;
public string SourceHeader { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Models;
public class FinanceIntercompanyRule
{
public int Id { get; set; }
public string ScopeKey { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerNameContains { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class FinanceReference
{
public int Id { get; set; }
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public int Year { get; set; } = 2025;
public decimal? LocalCurrencyValue { get; set; }
public decimal? CheckValue { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
+50
View File
@@ -0,0 +1,50 @@
namespace TrafagSalesExporter.Models;
public class FinanceRule
{
public int Id { get; set; }
public string ScopeKey { get; set; } = string.Empty;
public int? Year { get; set; }
public string RuleType { get; set; } = FinanceRuleTypes.Exclude;
public string FieldName { get; set; } = string.Empty;
public string MatchType { get; set; } = FinanceRuleMatchTypes.Contains;
public string MatchValue { get; set; } = string.Empty;
public decimal? NumericValue { get; set; }
public string Notes { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
public static class FinanceRuleTypes
{
public const string Exclude = "Exclude";
public const string NegateAmount = "NegateAmount";
public const string ForceYear = "ForceYear";
public const string DeduplicateBlankSupplierCountry = "DeduplicateBlankSupplierCountry";
public static readonly string[] All =
[
Exclude,
NegateAmount,
ForceYear,
DeduplicateBlankSupplierCountry
];
}
public static class FinanceRuleMatchTypes
{
public const string Always = "Always";
public const string Equal = "Equals";
public const string Contains = "Contains";
public const string StartsWith = "StartsWith";
public const string IsBlank = "IsBlank";
public static readonly string[] All =
[
Always,
Equal,
Contains,
StartsWith,
IsBlank
];
}
+217
View File
@@ -0,0 +1,217 @@
namespace TrafagSalesExporter.Models;
public sealed class HrKpiOptions
{
public string DataFolder { get; set; } = HrKpiDataSourceOptions.DefaultFolder;
public int? Year { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int? EntryYear { get; set; }
public string? Organisationseinheit { get; set; }
public string? KostenstelleText { get; set; }
public string? Mitarbeitertyp { get; set; }
public string? FluktuationFilter { get; set; }
public string? GlzAmpel { get; set; }
public string? RestferienAmpel { get; set; }
public string? SearchText { get; set; }
public bool ManagementView { get; set; }
}
public sealed class HrKpiDataSourceOptions
{
public const string SectionName = "HrKpi";
public const string DefaultFolder = @"C:\temp";
public string DataFolder { get; set; } = DefaultFolder;
public string MainFile { get; set; } = "Saldiperstichdatum.xlsx";
public string TimeFile { get; set; } = "Exportkommengehen.xlsx";
public string SapFile { get; set; } = "HR_KPI_Export.xlsx";
public string AbsenceFile { get; set; } = "Abwesenheitinstunden.xlsx";
public string LeaverFile { get; set; } = "Personalausgeschieden.xlsx";
public HrKpiDataSourceOptions Normalize()
=> new()
{
DataFolder = NormalizeText(DataFolder, DefaultFolder),
MainFile = NormalizeText(MainFile, "Saldiperstichdatum.xlsx"),
TimeFile = NormalizeText(TimeFile, "Exportkommengehen.xlsx"),
SapFile = NormalizeText(SapFile, "HR_KPI_Export.xlsx"),
AbsenceFile = NormalizeText(AbsenceFile, "Abwesenheitinstunden.xlsx"),
LeaverFile = NormalizeText(LeaverFile, "Personalausgeschieden.xlsx")
};
private static string NormalizeText(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
public sealed class HrKpiResult
{
public HrKpiOptions Options { get; set; } = new();
public List<HrKpiFileStatus> FileStatuses { get; set; } = [];
public List<string> Notices { get; set; } = [];
public List<string> OrganisationOptions { get; set; } = [];
public List<string> KostenstelleOptions { get; set; } = [];
public List<int> ExitYearOptions { get; set; } = [];
public List<int> EntryYearOptions { get; set; } = [];
public List<string> MitarbeitertypOptions { get; set; } = [];
public List<HrKpiMetric> Metrics { get; set; } = [];
public List<HrKpiMetric> TurnoverMetrics { get; set; } = [];
public List<HrKpiMetric> AbsenceMetrics { get; set; } = [];
public List<HrKpiMetric> TimeVacationMetrics { get; set; } = [];
public List<HrKpiMetric> PeriodComparisonMetrics { get; set; } = [];
public List<HrKpiTrafficLight> TrafficLights { get; set; } = [];
public List<HrKpiDataQualityIssue> DataQualityIssues { get; set; } = [];
public List<HrKpiGroupValue> LeaversByType { get; set; } = [];
public List<HrKpiGroupValue> LeaversByOrganisation { get; set; } = [];
public List<HrKpiGroupValue> AbsenceByOrganisation { get; set; } = [];
public List<HrKpiEmployeeRow> CriticalAbsences { get; set; } = [];
public List<HrKpiEmployeeRow> Employees { get; set; } = [];
public List<HrAbsenceRow> Absences { get; set; } = [];
public List<HrLeaverRow> Leavers { get; set; } = [];
public List<HrKpiGroupValue> HeadcountByOrganisation { get; set; } = [];
public List<HrKpiEmployeeRow> CriticalTimeBalances { get; set; } = [];
public List<HrLeaverRow> FluctuationRelevantLeavers { get; set; } = [];
public HrTurnoverVisuals TurnoverVisuals { get; set; } = new();
}
public sealed class HrKpiFileStatus
{
public string Label { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public bool Exists { get; set; }
public int RowCount { get; set; }
public string? Message { get; set; }
public DateTime? LastModified { get; set; }
public int? AgeDays { get; set; }
public string FreshnessStatus { get; set; } = "Unbekannt";
}
public sealed class HrKpiTrafficLight
{
public string Area { get; set; } = string.Empty;
public string Status { get; set; } = "Gruen";
public string Value { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}
public sealed class HrKpiDataQualityIssue
{
public string Severity { get; set; } = "Info";
public string Area { get; set; } = string.Empty;
public string Issue { get; set; } = string.Empty;
public int Count { get; set; }
public string Detail { get; set; } = string.Empty;
}
public sealed class HrKpiMetric
{
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
public string Severity { get; set; } = "Normal";
}
public sealed class HrKpiGroupValue
{
public string Label { get; set; } = string.Empty;
public decimal Value { get; set; }
public int Count { get; set; }
public string Color { get; set; } = "#607d8b";
public decimal Percent { get; set; }
}
public sealed class HrTurnoverVisuals
{
public string RateTitle { get; set; } = "Fluktuation Auswahl";
public decimal YearRatePercent { get; set; }
public string YearRateLabel { get; set; } = "0.0%";
public string GaugeColor { get; set; } = "#2e7d32";
public decimal GaugeRotationDegrees { get; set; }
public string TimelineTitle { get; set; } = "Relevante Austritte";
public List<HrKpiGroupValue> FunnelSteps { get; set; } = [];
public List<HrKpiGroupValue> ExclusionReasons { get; set; } = [];
public List<HrKpiGroupValue> RelevantByOrganisation { get; set; } = [];
public List<HrKpiGroupValue> MonthlyRelevantLeavers { get; set; } = [];
}
public sealed class HrKpiEmployeeRow
{
public int? Personalnummer { get; set; }
public string NameVoll { get; set; } = string.Empty;
public string Vorname { get; set; } = string.Empty;
public string Nachname { get; set; } = string.Empty;
public string Organisationseinheit { get; set; } = string.Empty;
public string KostenstelleText { get; set; } = string.Empty;
public int? Kostenstelle { get; set; }
public string Stelle { get; set; } = string.Empty;
public string Leitung { get; set; } = string.Empty;
public DateTime? Eintrittsdatum { get; set; }
public DateTime? Geburtsdatum { get; set; }
public int? AlterJahre { get; set; }
public string Altersgruppe { get; set; } = "Unbekannt";
public string GeschlechtText { get; set; } = "Unbekannt";
public decimal? BeschaeftigungsgradProzent { get; set; }
public decimal Fte { get; set; }
public bool IstTeilzeit { get; set; }
public int? Dienstjahre { get; set; }
public bool IstAktiv { get; set; }
public string Mitarbeitertyp { get; set; } = "Festangestellt";
public decimal StundenSaldo { get; set; }
public string GlzAmpel { get; set; } = "Gruen";
public decimal UrlaubRest { get; set; }
public decimal Urlaubsanspruch { get; set; }
public decimal FerienAusstehend { get; set; }
public decimal Ferientage { get; set; }
public string RestferienAmpel { get; set; } = "Gruen";
public decimal Bruttolohn { get; set; }
public string LohnWaehrung { get; set; } = string.Empty;
public decimal BuTage { get; set; }
public decimal NbuTage { get; set; }
public string Buchungskreis { get; set; } = string.Empty;
public string Personalbereich { get; set; } = string.Empty;
public string Personalteilbereich { get; set; } = string.Empty;
public string Mitarbeitergruppe { get; set; } = string.Empty;
public string Mitarbeiterkreis { get; set; } = string.Empty;
public string Planstelle { get; set; } = string.Empty;
public string SollStelle { get; set; } = string.Empty;
public DateTime Periode { get; set; } = new(DateTime.Today.Year, DateTime.Today.Month, 1);
}
public sealed class HrAbsenceRow
{
public int? Personalnummer { get; set; }
public string Name { get; set; } = string.Empty;
public string Organisationseinheit { get; set; } = string.Empty;
public string Stelle { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public decimal KrankheitKurzStd { get; set; }
public decimal KrankheitLangStd { get; set; }
public decimal KrankheitGesamtStd { get; set; }
public decimal KrankheitstageGesamt { get; set; }
public decimal KrankheitstageKurz { get; set; }
public decimal KrankheitstageLang { get; set; }
public decimal KrankenquoteMa { get; set; }
}
public sealed class HrLeaverRow
{
public int? Personalnummer { get; set; }
public string NameVoll { get; set; } = string.Empty;
public string Vorname { get; set; } = string.Empty;
public string Nachname { get; set; } = string.Empty;
public string Organisationseinheit { get; set; } = string.Empty;
public string Stelle { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? Austrittsdatum { get; set; }
public DateTime? Eintrittsdatum { get; set; }
public decimal? VerweildauerMonate { get; set; }
public string Austrittsart { get; set; } = string.Empty;
public string AustrittsartNormalisiert { get; set; } = string.Empty;
public string Mitarbeitertyp { get; set; } = "Festangestellt";
public bool IstArbeitnehmerkuendigung { get; set; }
public bool IstFluktuationAusgeschlossen { get; set; }
public bool IstFluktuationsrelevant { get; set; }
public string? FluktuationAusschlussgrund { get; set; }
public DateTime? Austrittsmonat { get; set; }
public int? Austrittsjahr { get; set; }
}
@@ -7,6 +7,37 @@ public class ManagementCockpitFileOption
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
} }
public static class ManagementCockpitValueFieldKeys
{
public const string SalesPriceValue = nameof(SalesPriceValue);
public const string Quantity = nameof(Quantity);
public const string StandardCost = nameof(StandardCost);
public const string StandardCostTotal = nameof(StandardCostTotal);
}
public static class ManagementCockpitCurrencyOptions
{
public const string Native = "NATIVE";
public const string Eur = "EUR";
public const string Usd = "USD";
}
public class ManagementCockpitValueFieldOption
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public bool IsCurrencyAmount { get; set; }
}
public class ManagementCockpitAnalysisOptions
{
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public List<string> AdditionalValueFields { get; set; } = [];
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
public string? LandFilter { get; set; }
public string? TscFilter { get; set; }
}
public class ManagementCockpitSummary public class ManagementCockpitSummary
{ {
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
@@ -15,6 +46,11 @@ public class ManagementCockpitSummary
public int RowCount { get; set; } public int RowCount { get; set; }
public int InvoiceCount { get; set; } public int InvoiceCount { get; set; }
public int CustomerCount { get; set; } public int CustomerCount { get; set; }
public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string ValueFieldLabel { get; set; } = "Sales Price/Value";
public string DisplayCurrency { get; set; } = string.Empty;
public int MissingExchangeRateCount { get; set; }
public decimal AggregatedValueTotal { get; set; }
public decimal SalesValueTotal { get; set; } public decimal SalesValueTotal { get; set; }
public decimal EstimatedCostTotal { get; set; } public decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; } public decimal EstimatedMarginTotal { get; set; }
@@ -53,6 +89,10 @@ public class ManagementCockpitCentralFilter
{ {
public int Year { get; set; } public int Year { get; set; }
public int? Month { get; set; } public int? Month { get; set; }
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
public string? Land { get; set; }
public string? Tsc { get; set; }
} }
public class ManagementCockpitCentralSummary public class ManagementCockpitCentralSummary
@@ -62,6 +102,11 @@ public class ManagementCockpitCentralSummary
public int SiteCount { get; set; } public int SiteCount { get; set; }
public int CountryCount { get; set; } public int CountryCount { get; set; }
public int CurrencyCount { get; set; } public int CurrencyCount { get; set; }
public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string ValueFieldLabel { get; set; } = "Sales Price/Value";
public string DisplayCurrency { get; set; } = string.Empty;
public decimal ValueTotal { get; set; }
public int MissingExchangeRateCount { get; set; }
public DateTime? PeriodStart { get; set; } public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; } public DateTime? PeriodEnd { get; set; }
} }
@@ -74,9 +119,19 @@ public class ManagementCockpitTimeValueRow
public int? Day { get; set; } public int? Day { get; set; }
public string Currency { get; set; } = string.Empty; public string Currency { get; set; } = string.Empty;
public decimal SalesValue { get; set; } public decimal SalesValue { get; set; }
public Dictionary<string, ManagementCockpitAggregatedFieldValue> AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int RowCount { get; set; } public int RowCount { get; set; }
} }
public class ManagementCockpitAggregatedFieldValue
{
public string FieldKey { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal Value { get; set; }
public int MissingExchangeRateCount { get; set; }
}
public class ManagementCockpitDimensionValueRow public class ManagementCockpitDimensionValueRow
{ {
public string Label { get; set; } = string.Empty; public string Label { get; set; } = string.Empty;
@@ -91,9 +146,44 @@ public class ManagementCockpitCentralResult
public ManagementCockpitCentralFilter Filter { get; set; } = new(); public ManagementCockpitCentralFilter Filter { get; set; } = new();
public ManagementCockpitCentralSummary Summary { get; set; } = new(); public ManagementCockpitCentralSummary Summary { get; set; } = new();
public List<string> Notices { get; set; } = []; public List<string> Notices { get; set; } = [];
public List<ManagementCockpitValueFieldOption> AdditionalValueFields { get; set; } = [];
public List<ManagementCockpitTimeValueRow> YearlyTotals { get; set; } = []; public List<ManagementCockpitTimeValueRow> YearlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> MonthlyTotals { get; set; } = []; public List<ManagementCockpitTimeValueRow> MonthlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> DailyTotals { get; set; } = []; public List<ManagementCockpitTimeValueRow> DailyTotals { get; set; } = [];
public List<ManagementCockpitDimensionValueRow> SourceSystemTotals { get; set; } = []; public List<ManagementCockpitDimensionValueRow> SourceSystemTotals { get; set; } = [];
public List<ManagementCockpitDimensionValueRow> CountryTotals { get; set; } = []; public List<ManagementCockpitDimensionValueRow> CountryTotals { get; set; } = [];
} }
public class ManagementFinanceSummaryFilter
{
public int Year { get; set; }
public string? CountryKey { get; set; }
public string? Currency { get; set; }
}
public class ManagementFinanceSummaryRow
{
public int Year { get; set; }
public string CountryKey { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public int IncludedRows { get; set; }
public int ExcludedRows { get; set; }
public decimal NetSalesActual { get; set; }
}
public class ManagementFinanceSummaryResult
{
public ManagementFinanceSummaryFilter Filter { get; set; } = new();
public List<string> Notices { get; set; } = [];
public List<int> YearOptions { get; set; } = [];
public List<string> CountryOptions { get; set; } = [];
public List<string> CurrencyOptions { get; set; } = [];
public List<ManagementFinanceSummaryRow> Rows { get; set; } = [];
public List<ManagementFinanceSummaryRow> YearRows { get; set; } = [];
public int IncludedRows { get; set; }
public int ExcludedRows { get; set; }
public int CountryCount { get; set; }
public int CurrencyCount { get; set; }
public decimal NetSalesActual { get; set; }
public string DisplayCurrency { get; set; } = string.Empty;
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ManualExcelColumnMapping
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string SourceHeader { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -4,6 +4,7 @@ public class SalesRecord
{ {
public DateTime ExtractionDate { get; set; } public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; } public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty; public string Material { get; set; } = string.Empty;
@@ -22,8 +23,16 @@ public class SalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty; public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; } public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty; public string SalesCurrency { get; set; } = string.Empty;
public string DocumentCurrency { get; set; } = string.Empty;
public decimal DocumentTotalForeignCurrency { get; set; }
public decimal DocumentTotalLocalCurrency { get; set; }
public decimal VatSumForeignCurrency { get; set; }
public decimal VatSumLocalCurrency { get; set; }
public decimal DocumentRate { get; set; }
public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; } public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; } public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
File diff suppressed because it is too large Load Diff
+49
View File
@@ -1,16 +1,52 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Security;
using TrafagSalesExporter.Services; using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources; using TrafagSalesExporter.Services.DataSources;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass;
if (useDevelopmentAuthentication)
{
builder.Services
.AddAuthentication(DevelopmentAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, DevelopmentAuthenticationHandler>(
DevelopmentAuthenticationHandler.SchemeName,
options => { });
}
else
{
builder.Services.AddAuthentication(IISDefaults.AuthenticationScheme);
}
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = SecurityPolicyFactory.BuildAccessPolicy(securitySettings, useDevelopmentAuthentication);
options.AddPolicy(SecurityPolicies.AdminOnly, SecurityPolicyFactory.BuildAdminPolicy(securitySettings, useDevelopmentAuthentication));
});
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -20,6 +56,7 @@ builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>(); builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>(); builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>(); builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>(); builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
@@ -37,11 +74,13 @@ builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>(); builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>(); builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>(); builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>(); builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>(); builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>(); builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>(); builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>(); builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>(); builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>(); builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>(); builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
@@ -67,8 +106,16 @@ builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageS
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>(); builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>(); builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>(); builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
var app = builder.Build(); var app = builder.Build();
var pathBase = app.Configuration["ASPNETCORE_PATHBASE"];
if (!string.IsNullOrWhiteSpace(pathBase))
{
app.UsePathBase(pathBase.Trim());
}
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -82,6 +129,8 @@ if (!app.Environment.IsDevelopment())
} }
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>() app.MapRazorComponents<TrafagSalesExporter.Components.App>()
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishProtocol>FileSystem</PublishProtocol>
<WebPublishMethod>FileSystem</WebPublishMethod>
<PublishUrl>\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\</PublishUrl>
<PublishDir>\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\</PublishDir>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<ProjectGuid>19995fb6-e1d1-45af-8fe3-b46bb3c80732</ProjectGuid>
<SelfContained>false</SelfContained>
<UseAppHost>false</UseAppHost>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
</Project>
+39
View File
@@ -0,0 +1,39 @@

Sage SQL CSV export
===================
Server instance: localhost
Database filter: (all accessible user databases)
From date: 2025-01-01
To date: 2026-01-01
Files:
- candidate_objects.csv: SQL tables/views that look relevant for sales/invoices.
- export_summary.csv: export status and row counts.
- *.csv: exported samples or selected full exports.
Recommended workflow:
1. Run discovery first:
.\Export-SageSqlCsv.ps1 -DiscoverOnly
2. Send candidate_objects.csv to Trafag/IT for selection.
3. Export selected objects:
.\Export-SageSqlCsv.ps1 -Database "DATABASE_NAME" -ObjectName "schema.table_or_view"
4. If the selected object is very large, add:
-FromDate "2025-01-01" -ToDate "2026-01-01" -MaxRowsPerObject 100000
The script only reads data. It does not change SQL Server or Sage.
@@ -0,0 +1,234 @@
# Sage Spain Export
Stand: 2026-05-05
## Aktueller Kurzstatus
- Spanien-v2-Export ist technisch lauffaehig und im Testprogramm sichtbar.
- Datei: `sagespain/v2/Spain_Sales_2025.csv`
- Ist 2025: `3'082'320.18` EUR
- Soll aus `check.xlsx`: `3'102'333.61`
- Differenz: `-20'013.43`
- Status FinanceProbe: Gelb / Pruefen
- Finale Aussage: technisch importierbar, aber fachlich noch nicht abgestimmt.
FinanceProbe lokal:
```text
http://localhost:55417/finance
```
Relevante Abschnitte:
- `Meeting Ampel 2025`
- `Detail alle Laender`
- `Spain CSV direct check`
Wichtig:
- Spanien wird in der Detailtabelle nicht mehr als `Keine Daten` gezeigt, wenn `Spain_Sales_2025.csv` vorhanden ist.
- Stattdessen wird der v2-CSV-Wert mit Status `Pruefen` angezeigt.
- Die CSV-Datei kann spaeter als `MANUAL_EXCEL`-Quelle importiert werden.
## Ziel
Spanien soll Verkaufsdaten aus `Sage 200c` liefern koennen, damit der Standort in `TrafagSalesExporter` wie die anderen Laender in die zentrale Auswertung und Finance-Abgrenzung aufgenommen werden kann.
## Systemstand Spanien
Ermittelt mit `scripts/Get-SageSqlEnvironment.ps1`.
- Windows Server: `Microsoft Windows Server 2019 Standard`, Build `17763`
- Server: `WIN-4BJQJ9S1PVJ`
- Sage: `Sage 200c`
- Sage-Version: `2026.56.000`
- SQL Server: `Microsoft SQL Server 2019 Standard Edition (64-bit)`
- SQL Build: `15.0.2155.2`
- SQL Full Version: `Microsoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64)`
- SQL Instance: Default Instance `MSSQLSERVER`, erreichbar als `localhost`
- Datenbank: `Sage`
- Collation: `Latin1_General_CI_AI`
## Discovery
Ermittelt mit `scripts/Export-SageSqlCsv.ps1`.
Relevante Kandidaten:
- `dbo.CabeceraAlbaranCliente`
- `dbo.LineasAlbaranCliente`
- `dbo.EstadisVenta`
- `dbo.EstadisVentaTallas`
- `dbo.FacturasTB`
- `dbo.MovimientosFacturas`
- `dbo.Vis_RTDV_EfectosFactura`
Beobachtung:
- `CabeceraAlbaranCliente` ist der Verkaufs-/Albaran-Belegkopf.
- `LineasAlbaranCliente` enthaelt die Verkaufspositionen.
- `EstadisVenta` enthaelt Statistikdaten, aber im gelieferten Export keine 2025-Zeilen.
- `FacturasTB` und `MovimientosFacturas` wirken eher Finanz-/Steuer-/Buchungsdaten und enthalten gemischte Bewegungen.
## Export v2
Finaler Export-Kandidat wurde mit `SageSpainFinalExportPackage.zip` bzw. danach `v2.zip` erstellt.
Script:
- `scripts/Export-SageSpainSalesCsv.ps1`
Output von Spanien:
- `sagespain/v2/Spain_Sales_2025.csv`
- `sagespain/v2/Spain_Sales_2025_summary.txt`
Quelle:
- Header: `dbo.CabeceraAlbaranCliente`
- Lines: `dbo.LineasAlbaranCliente`
- Join:
- `CodigoEmpresa`
- `EjercicioAlbaran`
- `SerieAlbaran`
- `NumeroAlbaran`
Filter:
- `CabeceraAlbaranCliente.FechaFactura >= 2025-01-01`
- `CabeceraAlbaranCliente.FechaFactura < 2026-01-01`
Export-Spalten sind bereits auf das Zielmodell der App ausgerichtet, u. a.:
- `TSC`
- `Land`
- `InvoiceNumber`
- `PositionOnInvoice`
- `Material`
- `Name`
- `ProductGroup`
- `Quantity`
- `CustomerNumber`
- `CustomerName`
- `CustomerCountry`
- `StandardCost`
- `StandardCostCurrency`
- `PurchaseOrderNumber`
- `SalesPriceValue`
- `SalesCurrency`
- `DocumentCurrency`
- `CompanyCurrency`
- `InvoiceDate`
- `DocumentType`
## Ergebnis Export v2
Aus `Spain_Sales_2025_summary.txt`:
- Zeilen: `4'341`
- `SalesPriceValue` Summe: `3'082'320.18`
- `SalesPriceValue` = `LineasAlbaranCliente.ImporteNeto`
- Waehrung: `EUR`
Aufteilung:
- Invoices: `3'140'921.50`
- Credit Notes / REC: `-58'601.32`
- Total: `3'082'320.18`
Nach Serie:
- `REG`: `2'407'451.30`
- `LAT`: `480'199.20`
- `PRO`: `253'271.00`
- `REC`: `-58'601.32`
## Abgleich gegen check.xlsx
Sollwert fuer Spanien aus `check.xlsx`:
- `3'102'333.61`
Aktueller Export v2:
- `3'082'320.18`
Differenz:
- `-20'013.43`
Fruehere breite Positionssumme aus `LineasAlbaranCliente.ImporteNeto` ohne Join-/Rechnungsdatumsfilter lag bei:
- `3'094'474.32`
- Differenz zur Sollzahl: `-7'859.29`
## Offene fachliche Klaerung
Spanien / Finance muss noch klaeren, woher die Differenz kommt.
Zu pruefen:
1. Ist `FechaFactura` das korrekte Periodendatum?
2. Oder muss `FechaAlbaran` bzw. `FechaRegistro` verwendet werden?
3. Muessen Zeilen ohne `EjercicioFactura = 2025` in die Sollzahl?
4. Sind alle Serien `REG`, `LAT`, `PRO`, `REC` enthalten?
5. Muessen `REC`-Abos negativ abgezogen werden?
6. Gibt es weitere Serien oder Dokumenttypen ausserhalb `CabeceraAlbaranCliente` / `LineasAlbaranCliente`?
7. Gibt es eine offizielle Sage-Auswertung, die `3'102'333.61` erzeugt und deren Filter genannt werden koennen?
## Einbau ins Hauptprogramm
Umgesetzt:
- `ManualExcelImportService` kann jetzt neben `.xlsx` auch semikolongetrennte `.csv`-Dateien lesen.
- Der CSV-Reader unterstuetzt quotierte Felder und mehrzeilige Texte.
- Das Spanien-v2-CSV ist damit als `MANUAL_EXCEL`-Quelle importierbar.
- `Tools/FinanceProbe` hat einen direkten `Spain CSV direct check`.
- Die Probe sucht automatisch nach `Spain_Sales_2025.csv`, bevorzugt unter `sagespain/v2`.
- Angezeigt werden Zeilen, `SalesPriceValue`, Sollwert `3'102'333.61`, Differenz, Aufteilung nach `DocumentType` und `InvoiceSeries`.
- Spanien wird in der FinanceProbe-Detailtabelle mit dem v2-CSV-Wert angezeigt, nicht mehr als `Keine Daten`.
- In der Management-Ampel bleibt Spanien gelb, bis die Differenz fachlich geklaert ist.
- `DatabaseSeedService` stellt einen deaktivierten Spanien-Standort bereit, falls noch kein Spanien-Standort existiert:
- `TSC = TRES`
- `Land = Spanien`
- `SourceSystem = MANUAL_EXCEL`
- `IsActive = false`
Wichtig:
- Das Programm setzt den Dateipfad nicht automatisch, weil der Pfad pro Umgebung unterschiedlich ist.
- In der UI muss beim Standort Spanien die Datei `Spain_Sales_2025.csv` hinterlegt werden.
- Danach kann Spanien wie ein manueller Standort exportiert werden; die Daten landen in `CentralSalesRecords`.
## Naechster Schritt
1. App starten.
2. `Standorte` oeffnen.
3. Spanien pruefen bzw. aktivieren.
4. `SourceSystem = MANUAL_EXCEL`.
5. `Spain_Sales_2025.csv` als manuelle Datei hinterlegen.
6. Standort Spanien exportieren.
7. Finance-Probe / Dashboard erneut pruefen.
8. Differenz zu `check.xlsx` fachlich mit Spanien/Finance klaeren.
## Abgrenzung Deutschland
Am selben Tag wurde auch ein Deutschland-Beispielfile gefunden:
```text
DE_Beispiel_Export_Daten.xlsx
```
Dieses File ist nicht Teil des Spanien-Exports, aber im FinanceProbe als separater `Germany Excel sample check` sichtbar.
Deutschland-Sample:
- relevante Spalte: `NettoPreisGesamtX`
- Summe: `8'290.70` EUR
- Betragszeilen: `2`
- Bewertung: technisch lesbar, aber kein finaler DE-Jahresfile
Fuer die Gesamtampel heisst das:
- Spanien: technische v2-Datei vorhanden, Differenz offen
- Deutschland: Format verstanden, aber finale Jahresdatei fehlt
Binary file not shown.
@@ -0,0 +1,16 @@
$scriptPath = Join-Path $PSScriptRoot "Export-SageSqlCsv.ps1"
& $scriptPath `
-Database "Sage" `
-ObjectName @(
"dbo.CabeceraAlbaranCliente",
"dbo.LineasAlbaranCliente",
"dbo.EstadisVenta",
"dbo.EstadisVentaTallas",
"dbo.FacturasTB",
"dbo.MovimientosFacturas",
"dbo.Vis_RTDV_EfectosFactura"
) `
-FromDate "2025-01-01" `
-ToDate "2026-01-01" `
-MaxRowsPerObject 10000
@@ -0,0 +1,15 @@
$scriptPath = Join-Path $PSScriptRoot "Export-SageSqlCsv.ps1"
& $scriptPath `
-Database "Sage" `
-ObjectName @(
"dbo.CabeceraAlbaranCliente",
"dbo.LineasAlbaranCliente",
"dbo.EstadisVenta",
"dbo.EstadisVentaTallas",
"dbo.FacturasTB",
"dbo.MovimientosFacturas",
"dbo.Vis_RTDV_EfectosFactura"
) `
-FromDate "2025-01-01" `
-ToDate "2026-01-01"
@@ -0,0 +1,410 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "",
[string[]]$ObjectName = @(),
[datetime]$FromDate = "2025-01-01",
[datetime]$ToDate = "2026-01-01",
[string]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop"),
[int]$SampleRows = 500,
[int]$MaxRowsPerObject = 0,
[switch]$DiscoverOnly,
[switch]$ExportCandidates,
[switch]$IncludeSystemDatabases
)
$ErrorActionPreference = "Stop"
function New-Connection {
param([string]$DbName)
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $DbName
$builder["Integrated Security"] = $true
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Invoke-DataTable {
param(
[string]$DbName,
[string]$Sql,
[hashtable]$Parameters = @{}
)
$conn = New-Connection $DbName
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 300
foreach ($key in $Parameters.Keys) {
$param = $cmd.Parameters.Add("@$key", [System.Data.SqlDbType]::NVarChar, 4000)
$param.Value = [string]$Parameters[$key]
}
$table = New-Object System.Data.DataTable
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$table.Load($reader)
}
finally {
$conn.Dispose()
}
return $table
}
function Convert-ToCsvValue {
param($Value)
if ($null -eq $Value -or $Value -is [System.DBNull]) {
return ""
}
if ($Value -is [datetime]) {
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else {
$text = [string]$Value
}
$text = $text.Replace('"', '""')
return '"' + $text + '"'
}
function Export-QueryToCsv {
param(
[string]$DbName,
[string]$Sql,
[string]$Path
)
$conn = New-Connection $DbName
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 0
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
$rowCount = 0
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetName($i)
}
$writer.WriteLine(($headers -join ";"))
while ($reader.Read()) {
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetValue($i)
}
$writer.WriteLine(($values -join ";"))
$rowCount++
}
}
finally {
$writer.Dispose()
$conn.Dispose()
}
return $rowCount
}
function Quote-NamePart {
param([string]$Name)
return "[" + $Name.Replace("]", "]]") + "]"
}
function Split-SqlObjectName {
param([string]$Name)
$parts = $Name.Split(".", 2)
if ($parts.Count -eq 1) {
return [pscustomobject]@{ SchemaName = "dbo"; ObjectName = $parts[0] }
}
return [pscustomobject]@{ SchemaName = $parts[0].Trim("[", "]"); ObjectName = $parts[1].Trim("[", "]") }
}
function Get-UserDatabases {
$sql = @"
SELECT name
FROM sys.databases
WHERE state_desc = 'ONLINE'
AND HAS_DBACCESS(name) = 1
$(if ($IncludeSystemDatabases) { "" } else { "AND database_id > 4" })
ORDER BY name;
"@
Invoke-DataTable "master" $sql | ForEach-Object { $_.name }
}
function Get-CandidateObjects {
param([string]$DbName)
$sql = @"
WITH object_columns AS (
SELECT
s.name AS SchemaName,
o.name AS ObjectName,
o.type_desc AS ObjectType,
c.name AS ColumnName,
t.name AS TypeName,
c.max_length,
c.precision,
c.scale
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
JOIN sys.columns c ON c.object_id = o.object_id
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE o.type IN ('U', 'V')
AND o.is_ms_shipped = 0
),
scored AS (
SELECT
SchemaName,
ObjectName,
ObjectType,
SUM(CASE WHEN LOWER(ObjectName) LIKE '%fact%' OR LOWER(ObjectName) LIKE '%invoice%' OR LOWER(ObjectName) LIKE '%venta%' OR LOWER(ObjectName) LIKE '%sales%' OR LOWER(ObjectName) LIKE '%albar%' OR LOWER(ObjectName) LIKE '%pedido%' THEN 5 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%fecha%' OR LOWER(ColumnName) LIKE '%date%' THEN 2 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%cliente%' OR LOWER(ColumnName) LIKE '%customer%' THEN 2 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%articulo%' OR LOWER(ColumnName) LIKE '%item%' OR LOWER(ColumnName) LIKE '%producto%' THEN 2 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%importe%' OR LOWER(ColumnName) LIKE '%neto%' OR LOWER(ColumnName) LIKE '%total%' OR LOWER(ColumnName) LIKE '%amount%' THEN 3 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%cantidad%' OR LOWER(ColumnName) LIKE '%quantity%' OR LOWER(ColumnName) LIKE '%unidades%' THEN 2 ELSE 0 END) AS Score,
COUNT(*) AS ColumnCount,
STRING_AGG(CONVERT(nvarchar(max), ColumnName), ', ') WITHIN GROUP (ORDER BY ColumnName) AS Columns
FROM object_columns
GROUP BY SchemaName, ObjectName, ObjectType
)
SELECT TOP (80)
DB_NAME() AS DatabaseName,
SchemaName,
ObjectName,
ObjectType,
Score,
ColumnCount,
Columns
FROM scored
WHERE Score > 0
ORDER BY Score DESC, ObjectName;
"@
Invoke-DataTable $DbName $sql
}
function Get-DateColumns {
param(
[string]$DbName,
[string]$SchemaName,
[string]$ObjectNameValue
)
$sql = @"
SELECT c.name AS ColumnName
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
JOIN sys.columns c ON c.object_id = o.object_id
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE s.name = @schema
AND o.name = @object
AND (
t.name IN ('date', 'datetime', 'datetime2', 'smalldatetime')
OR LOWER(c.name) LIKE '%fecha%'
OR LOWER(c.name) LIKE '%date%'
)
ORDER BY
CASE
WHEN LOWER(c.name) LIKE '%fact%' OR LOWER(c.name) LIKE '%invoice%' THEN 0
WHEN LOWER(c.name) LIKE '%fecha%' OR LOWER(c.name) LIKE '%date%' THEN 1
ELSE 2
END,
c.column_id;
"@
Invoke-DataTable $DbName $sql @{ schema = $SchemaName; object = $ObjectNameValue } |
ForEach-Object { $_.ColumnName }
}
function Build-SelectSql {
param(
[string]$SchemaName,
[string]$ObjectNameValue,
[string]$DateColumn,
[int]$TopRows
)
$topClause = if ($TopRows -gt 0) { "TOP ($TopRows)" } else { "" }
$qualified = "$(Quote-NamePart $SchemaName).$(Quote-NamePart $ObjectNameValue)"
if ([string]::IsNullOrWhiteSpace($DateColumn)) {
return "SELECT $topClause * FROM $qualified;"
}
$from = $FromDate.ToString("yyyy-MM-dd")
$to = $ToDate.ToString("yyyy-MM-dd")
$dateColumnSql = Quote-NamePart $DateColumn
return @"
SELECT $topClause *
FROM $qualified
WHERE TRY_CONVERT(date, $dateColumnSql) >= CONVERT(date, '$from')
AND TRY_CONVERT(date, $dateColumnSql) < CONVERT(date, '$to')
ORDER BY TRY_CONVERT(date, $dateColumnSql);
"@
}
function Normalize-FileName {
param([string]$Value)
return ($Value -replace '[\\/:*?"<>|]', '_')
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $OutputDirectory "Sage_SQL_CSV_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$databases = if ([string]::IsNullOrWhiteSpace($Database)) {
@(Get-UserDatabases)
}
else {
@($Database)
}
$summary = New-Object System.Collections.Generic.List[object]
$allCandidates = New-Object System.Collections.Generic.List[object]
foreach ($db in $databases) {
Write-Host "Scanning database: $db"
try {
$candidates = @(Get-CandidateObjects $db)
foreach ($candidate in $candidates) {
$allCandidates.Add($candidate)
}
}
catch {
$summary.Add([pscustomobject]@{
Database = $db
Object = ""
Action = "Discovery failed"
Rows = 0
File = ""
Error = $_.Exception.Message
})
}
}
$candidatePath = Join-Path $runDirectory "candidate_objects.csv"
if ($allCandidates.Count -gt 0) {
$allCandidates | Export-Csv -LiteralPath $candidatePath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
}
if (-not $DiscoverOnly) {
$objectsToExport = New-Object System.Collections.Generic.List[object]
foreach ($name in $ObjectName) {
if ([string]::IsNullOrWhiteSpace($name)) {
continue
}
if ([string]::IsNullOrWhiteSpace($Database)) {
throw "When -ObjectName is used, pass -Database as well."
}
$parsed = Split-SqlObjectName $name
$objectsToExport.Add([pscustomobject]@{
DatabaseName = $Database
SchemaName = $parsed.SchemaName
ObjectName = $parsed.ObjectName
})
}
if ($ExportCandidates) {
foreach ($candidate in ($allCandidates | Sort-Object DatabaseName, @{Expression="Score"; Descending=$true} | Select-Object -First 25)) {
$objectsToExport.Add([pscustomobject]@{
DatabaseName = $candidate.DatabaseName
SchemaName = $candidate.SchemaName
ObjectName = $candidate.ObjectName
})
}
}
foreach ($object in $objectsToExport) {
$db = $object.DatabaseName
$schema = $object.SchemaName
$objectNameValue = $object.ObjectName
try {
$dateColumn = @(Get-DateColumns $db $schema $objectNameValue | Select-Object -First 1)[0]
$limit = if ($MaxRowsPerObject -gt 0) { $MaxRowsPerObject } elseif ($ObjectName.Count -gt 0) { 0 } else { $SampleRows }
$sql = Build-SelectSql $schema $objectNameValue $dateColumn $limit
$fileName = Normalize-FileName "$db.$schema.$objectNameValue.csv"
$path = Join-Path $runDirectory $fileName
Write-Host "Exporting $db.$schema.$objectNameValue -> $path"
$rows = Export-QueryToCsv $db $sql $path
$summary.Add([pscustomobject]@{
Database = $db
Object = "$schema.$objectNameValue"
Action = "Exported"
Rows = $rows
File = $path
DateColumn = $dateColumn
Error = ""
})
}
catch {
$summary.Add([pscustomobject]@{
Database = $db
Object = "$schema.$objectNameValue"
Action = "Export failed"
Rows = 0
File = ""
DateColumn = ""
Error = $_.Exception.Message
})
}
}
}
$summaryPath = Join-Path $runDirectory "export_summary.csv"
$summary | Export-Csv -LiteralPath $summaryPath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
$readmePath = Join-Path $runDirectory "README.txt"
@"
Sage SQL CSV export
===================
Server instance: $ServerInstance
Database filter: $(if ($Database) { $Database } else { "(all accessible user databases)" })
From date: $($FromDate.ToString("yyyy-MM-dd"))
To date: $($ToDate.ToString("yyyy-MM-dd"))
Files:
- candidate_objects.csv: SQL tables/views that look relevant for sales/invoices.
- export_summary.csv: export status and row counts.
- *.csv: exported samples or selected full exports.
Recommended workflow:
1. Run discovery first:
.\Export-SageSqlCsv.ps1 -DiscoverOnly
2. Send candidate_objects.csv to Trafag/IT for selection.
3. Export selected objects:
.\Export-SageSqlCsv.ps1 -Database "DATABASE_NAME" -ObjectName "schema.table_or_view"
4. If the selected object is very large, add:
-FromDate "2025-01-01" -ToDate "2026-01-01" -MaxRowsPerObject 100000
The script only reads data. It does not change SQL Server or Sage.
"@ | Set-Content -LiteralPath $readmePath -Encoding UTF8
Write-Host ""
Write-Host "Created folder:"
Write-Host " $runDirectory"
Write-Host ""
Write-Host "Main files:"
Write-Host " $candidatePath"
Write-Host " $summaryPath"
@@ -0,0 +1,23 @@
Sage SQL CSV export
===================
Server instance: localhost
Database filter: Sage
From date: 2025-01-01
To date: 2026-01-01
Files:
- candidate_objects.csv: SQL tables/views that look relevant for sales/invoices.
- export_summary.csv: export status and row counts.
- *.csv: exported samples or selected full exports.
Recommended workflow:
1. Run discovery first:
.\Export-SageSqlCsv.ps1 -DiscoverOnly
2. Send candidate_objects.csv to Trafag/IT for selection.
3. Export selected objects:
.\Export-SageSqlCsv.ps1 -Database "DATABASE_NAME" -ObjectName "schema.table_or_view"
4. If the selected object is very large, add:
-FromDate "2025-01-01" -ToDate "2026-01-01" -MaxRowsPerObject 100000
The script only reads data. It does not change SQL Server or Sage.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"CodigoEmpresa";"EstadisClave1";"EstadisClave2";"EstadisClave3";"Ejercicio";"Periodo";"Origen";"CodigoZona";"CodigoJefeZona_";"CodigoJefeVenta_";"CodigoComisionista";"CodigoComisionista2_";"CodigoComisionista3_";"CodigoComisionista4_";"CodigoCliente";"CodigoFamilia";"CodigoSubfamilia";"CodigoArticulo";"CodigoColor_";"GrupoTalla_";"UnidadesTalla01_";"UnidadesTalla02_";"UnidadesTalla03_";"UnidadesTalla04_";"UnidadesTalla05_";"UnidadesTalla06_";"UnidadesTalla07_";"UnidadesTalla08_";"UnidadesTalla09_";"UnidadesTalla10_";"UnidadesTalla11_";"UnidadesTalla12_";"UnidadesTalla13_";"UnidadesTalla14_";"UnidadesTalla15_";"UnidadesTalla16_";"UnidadesTalla17_";"UnidadesTalla18_";"UnidadesTalla19_";"UnidadesTalla20_";"UnidadesTalla21_";"UnidadesTalla22_";"UnidadesTalla23_";"UnidadesTalla24_";"UnidadesTalla25_";"UnidadesTalla26_";"UnidadesTalla27_";"UnidadesTalla28_";"UnidadesTalla29_";"UnidadesTalla30_";"UnidadesTalla31_";"UnidadesTalla32_";"UnidadesTalla33_";"UnidadesTalla34_";"UnidadesTalla35_";"UnidadesTalla36_";"UnidadesTalla37_";"UnidadesTalla38_";"UnidadesTalla39_";"UnidadesTalla40_";"ImporteTalla01_";"ImporteTalla02_";"ImporteTalla03_";"ImporteTalla04_";"ImporteTalla05_";"ImporteTalla06_";"ImporteTalla07_";"ImporteTalla08_";"ImporteTalla09_";"ImporteTalla10_";"ImporteTalla11_";"ImporteTalla12_";"ImporteTalla13_";"ImporteTalla14_";"ImporteTalla15_";"ImporteTalla16_";"ImporteTalla17_";"ImporteTalla18_";"ImporteTalla19_";"ImporteTalla20_";"ImporteTalla21_";"ImporteTalla22_";"ImporteTalla23_";"ImporteTalla24_";"ImporteTalla25_";"ImporteTalla26_";"ImporteTalla27_";"ImporteTalla28_";"ImporteTalla29_";"ImporteTalla30_";"ImporteTalla31_";"ImporteTalla32_";"ImporteTalla33_";"ImporteTalla34_";"ImporteTalla35_";"ImporteTalla36_";"ImporteTalla37_";"ImporteTalla38_";"ImporteTalla39_";"ImporteTalla40_";"UnidadesTotalTallas_";"ImporteTotalTallas_"
1 CodigoEmpresa EstadisClave1 EstadisClave2 EstadisClave3 Ejercicio Periodo Origen CodigoZona CodigoJefeZona_ CodigoJefeVenta_ CodigoComisionista CodigoComisionista2_ CodigoComisionista3_ CodigoComisionista4_ CodigoCliente CodigoFamilia CodigoSubfamilia CodigoArticulo CodigoColor_ GrupoTalla_ UnidadesTalla01_ UnidadesTalla02_ UnidadesTalla03_ UnidadesTalla04_ UnidadesTalla05_ UnidadesTalla06_ UnidadesTalla07_ UnidadesTalla08_ UnidadesTalla09_ UnidadesTalla10_ UnidadesTalla11_ UnidadesTalla12_ UnidadesTalla13_ UnidadesTalla14_ UnidadesTalla15_ UnidadesTalla16_ UnidadesTalla17_ UnidadesTalla18_ UnidadesTalla19_ UnidadesTalla20_ UnidadesTalla21_ UnidadesTalla22_ UnidadesTalla23_ UnidadesTalla24_ UnidadesTalla25_ UnidadesTalla26_ UnidadesTalla27_ UnidadesTalla28_ UnidadesTalla29_ UnidadesTalla30_ UnidadesTalla31_ UnidadesTalla32_ UnidadesTalla33_ UnidadesTalla34_ UnidadesTalla35_ UnidadesTalla36_ UnidadesTalla37_ UnidadesTalla38_ UnidadesTalla39_ UnidadesTalla40_ ImporteTalla01_ ImporteTalla02_ ImporteTalla03_ ImporteTalla04_ ImporteTalla05_ ImporteTalla06_ ImporteTalla07_ ImporteTalla08_ ImporteTalla09_ ImporteTalla10_ ImporteTalla11_ ImporteTalla12_ ImporteTalla13_ ImporteTalla14_ ImporteTalla15_ ImporteTalla16_ ImporteTalla17_ ImporteTalla18_ ImporteTalla19_ ImporteTalla20_ ImporteTalla21_ ImporteTalla22_ ImporteTalla23_ ImporteTalla24_ ImporteTalla25_ ImporteTalla26_ ImporteTalla27_ ImporteTalla28_ ImporteTalla29_ ImporteTalla30_ ImporteTalla31_ ImporteTalla32_ ImporteTalla33_ ImporteTalla34_ ImporteTalla35_ ImporteTalla36_ ImporteTalla37_ ImporteTalla38_ ImporteTalla39_ ImporteTalla40_ UnidadesTotalTallas_ ImporteTotalTallas_
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"oppCoId";"effeForecast";"effeNumber";"invoExercise";"invoSeries";"invoNumber";"effeOrder";"statusDelete";"customerCode";"customer";"effeAmount";"expirationDate";"invoDate";"emissionDate";"accountCode";"counterPart";"comment";"canalCode";"statusRemitted";"remittedType";"remittedDate";"remittedBank";"remittedNumber";"statusRisk";"statusUnpaid";"salesPersonId";"salesPerson";"effectType";"effectClass";"effeId";"invoId"
1 oppCoId effeForecast effeNumber invoExercise invoSeries invoNumber effeOrder statusDelete customerCode customer effeAmount expirationDate invoDate emissionDate accountCode counterPart comment canalCode statusRemitted remittedType remittedDate remittedBank remittedNumber statusRisk statusUnpaid salesPersonId salesPerson effectType effectClass effeId invoId
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
"Database";"Object";"Action";"Rows";"File";"DateColumn";"Error"
"Sage";"dbo.CabeceraAlbaranCliente";"Exported";"1973";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.CabeceraAlbaranCliente.csv";"FechaFactura";""
"Sage";"dbo.LineasAlbaranCliente";"Exported";"4814";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.LineasAlbaranCliente.csv";"FechaRegistro";""
"Sage";"dbo.EstadisVenta";"Exported";"16976";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVenta.csv";;""
"Sage";"dbo.EstadisVentaTallas";"Exported";"0";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVentaTallas.csv";;""
"Sage";"dbo.FacturasTB";"Exported";"3788";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.FacturasTB.csv";"FechaFactura";""
"Sage";"dbo.MovimientosFacturas";"Exported";"6517";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.MovimientosFacturas.csv";"FechaFactura";""
"Sage";"dbo.Vis_RTDV_EfectosFactura";"Exported";"0";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.Vis_RTDV_EfectosFactura.csv";"expirationDate";""
1 Database Object Action Rows File DateColumn Error
2 Sage dbo.CabeceraAlbaranCliente Exported 1973 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.CabeceraAlbaranCliente.csv FechaFactura
3 Sage dbo.LineasAlbaranCliente Exported 4814 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.LineasAlbaranCliente.csv FechaRegistro
4 Sage dbo.EstadisVenta Exported 16976 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVenta.csv
5 Sage dbo.EstadisVentaTallas Exported 0 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVentaTallas.csv
6 Sage dbo.FacturasTB Exported 3788 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.FacturasTB.csv FechaFactura
7 Sage dbo.MovimientosFacturas Exported 6517 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.MovimientosFacturas.csv FechaFactura
8 Sage dbo.Vis_RTDV_EfectosFactura Exported 0 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.Vis_RTDV_EfectosFactura.csv expirationDate
Binary file not shown.
@@ -0,0 +1,221 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[datetime]$FromDate = "2025-01-01",
[datetime]$ToDate = "2026-01-01",
[string]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop")
)
$ErrorActionPreference = "Stop"
function New-Connection {
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $Database
$builder["Integrated Security"] = $true
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Convert-ToCsvValue {
param($Value)
if ($null -eq $Value -or $Value -is [System.DBNull]) {
return ""
}
if ($Value -is [datetime]) {
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else {
$text = [string]$Value
}
$text = $text.Replace('"', '""')
return '"' + $text + '"'
}
function Export-QueryToCsv {
param(
[string]$Sql,
[string]$Path
)
$conn = New-Connection
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 0
$fromParameter = $cmd.Parameters.Add("@FromDate", [System.Data.SqlDbType]::Date)
$fromParameter.Value = $FromDate.Date
$toParameter = $cmd.Parameters.Add("@ToDate", [System.Data.SqlDbType]::Date)
$toParameter.Value = $ToDate.Date
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
$rowCount = 0
$salesSum = [decimal]0
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetName($i)
}
$writer.WriteLine(($headers -join ";"))
$salesIndex = -1
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
if ($reader.GetName($i) -eq "SalesPriceValue") {
$salesIndex = $i
break
}
}
while ($reader.Read()) {
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetValue($i)
}
$writer.WriteLine(($values -join ";"))
$rowCount++
if ($salesIndex -ge 0 -and -not $reader.IsDBNull($salesIndex)) {
$salesSum += [decimal]$reader.GetValue($salesIndex)
}
}
}
finally {
$writer.Dispose()
$conn.Dispose()
}
return [pscustomobject]@{
Rows = $rowCount
SalesPriceValueSum = $salesSum
}
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $OutputDirectory "Sage_Spain_Sales_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$csvPath = Join-Path $runDirectory "Spain_Sales_2025.csv"
$summaryPath = Join-Path $runDirectory "Spain_Sales_2025_summary.txt"
$sql = @"
SELECT
'TRES' AS TSC,
'Spanien' AS Land,
'Sage' AS SourceSystem,
c.CodigoEmpresa AS CompanyCode,
c.EjercicioAlbaran AS DeliveryYear,
c.SerieAlbaran AS DeliverySeries,
c.NumeroAlbaran AS DeliveryNumber,
c.EjercicioFactura AS InvoiceYear,
c.SerieFactura AS InvoiceSeries,
c.NumeroFactura AS InvoiceNumber,
l.Orden AS PositionOnInvoice,
l.LineasPosicion AS SourceLineId,
l.CodigoArticulo AS Material,
l.DescripcionArticulo AS Name,
l.Descripcion2Articulo AS Description2,
l.DescripcionLinea AS DescriptionLine,
l.CodigoFamilia AS ProductGroup,
l.CodigoSubfamilia AS ProductSubGroup,
CAST(l.Unidades AS decimal(19, 6)) AS Quantity,
c.CodigoCliente AS CustomerNumber,
c.Nombre AS CustomerName,
c.CodigoNacion AS CustomerCountryCode,
c.Nacion AS CustomerCountry,
CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost,
CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue,
'EUR' AS StandardCostCurrency,
CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(l.ImporteNeto)
ELSE l.ImporteNeto
END AS decimal(19, 6)) AS SalesPriceValue,
'EUR' AS SalesCurrency,
'EUR' AS DocumentCurrency,
'EUR' AS CompanyCurrency,
c.CodigoDivisa AS SageCurrencyCode,
CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(c.BaseImponible)
ELSE c.BaseImponible
END AS decimal(19, 6)) AS DocumentNetAmount,
CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount,
CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount,
c.FechaFactura AS InvoiceDate,
c.FechaAlbaran AS DeliveryDate,
l.FechaRegistro AS LineRegistrationDate,
c.EjercicioPedido AS OrderYear,
c.SeriePedido AS OrderSeries,
c.NumeroPedido AS OrderNumber,
c.SuPedido AS PurchaseOrderNumber,
c.CodigoExportacion_ AS Incoterms2020,
c.CondicionExportacion_ AS IncotermsText,
c.CodigoComisionista AS SalesResponsibleEmployee,
c.StatusAbono AS CreditStatus,
c.NoFacturable AS NonBillable,
c.TipoNuevaFra AS InvoiceType,
c.StatusFacturado AS BillingStatus,
CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN 'Credit Note'
ELSE 'Invoice'
END AS DocumentType
FROM dbo.CabeceraAlbaranCliente c
JOIN dbo.LineasAlbaranCliente l
ON l.CodigoEmpresa = c.CodigoEmpresa
AND l.EjercicioAlbaran = c.EjercicioAlbaran
AND l.SerieAlbaran = c.SerieAlbaran
AND l.NumeroAlbaran = c.NumeroAlbaran
WHERE c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate
ORDER BY
c.FechaFactura,
c.SerieFactura,
c.NumeroFactura,
l.Orden;
"@
$result = Export-QueryToCsv -Sql $sql -Path $csvPath
@"
Sage Spain Sales CSV export
===========================
Created: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Server instance: $ServerInstance
Database: $Database
From date: $($FromDate.ToString("yyyy-MM-dd"))
To date: $($ToDate.ToString("yyyy-MM-dd"))
Output:
$csvPath
Rows:
$($result.Rows)
SalesPriceValue sum:
$($result.SalesPriceValueSum)
Source:
dbo.CabeceraAlbaranCliente joined with dbo.LineasAlbaranCliente
Filter:
CabeceraAlbaranCliente.FechaFactura >= FromDate
CabeceraAlbaranCliente.FechaFactura < ToDate
Notes:
- Currency is set to EUR because Sage exports EnEuros_=-1 and CodigoDivisa is empty in the analysed rows.
- SalesPriceValue uses LineasAlbaranCliente.ImporteNeto; credit notes are forced negative.
- DocumentNetAmount uses CabeceraAlbaranCliente.BaseImponible; credit notes are forced negative.
- Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero.
"@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8
Write-Host "Created:"
Write-Host " $csvPath"
Write-Host " $summaryPath"
Write-Host "Rows: $($result.Rows)"
Write-Host "SalesPriceValue sum: $($result.SalesPriceValueSum)"
@@ -0,0 +1,32 @@
Sage Spain final sales export candidate
======================================
Run on the Spain Sage SQL Server machine.
PowerShell commands:
Set-ExecutionPolicy -Scope Process Bypass
.\Export-SageSpainSalesCsv.ps1
Output folder on Desktop:
Sage_Spain_Sales_Export_YYYYMMDD_HHMMSS
Files created:
- Spain_Sales_2025.csv
- Spain_Sales_2025_summary.txt
The script only reads SQL Server data. It does not change Sage or SQL Server.
Default source:
- Database: Sage
- Header: dbo.CabeceraAlbaranCliente
- Lines: dbo.LineasAlbaranCliente
- Date filter: CabeceraAlbaranCliente.FechaFactura from 2025-01-01 to 2026-01-01
- Sales value: LineasAlbaranCliente.ImporteNeto
If the SQL instance or database name differs:
.\Export-SageSpainSalesCsv.ps1 -ServerInstance "localhost" -Database "Sage"
@@ -0,0 +1,134 @@
{
"CapturedAt": "2026-05-05T10:05:13.9281781+02:00",
"ComputerName": "WIN-4BJQJ9S1PVJ",
"UserName": "WIN-4BJQJ9S1PVJ\\Administrador",
"Windows": {
"Caption": "Microsoft Windows Server 2019 Standard",
"Version": "10.0.17763",
"BuildNumber": "17763",
"InstallDate": "\/Date(1601446676000)\/"
},
"SageUninstallEntries": [
{
"DisplayName": "JRE 2.5",
"DisplayVersion": null,
"Publisher": "Sage Logic Control",
"InstallDate": null,
"InstallLocation": null,
"UninstallString": "\"C:\\Windows\\unins000.exe\"",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\JRE_is1"
},
{
"DisplayName": "Sage 200c",
"DisplayVersion": "2026.56.000",
"Publisher": "Sage Spain",
"InstallDate": null,
"InstallLocation": null,
"UninstallString": "C:\\Program Files (x86)\\Sage\\Sage 200c\\Setup\\Uninstall\\Sage.Uninstall.exe",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sage 200c"
},
{
"DisplayName": "Sage Renta Componentes",
"DisplayVersion": "1.00.0000",
"Publisher": "Sage Spain",
"InstallDate": "20201021",
"InstallLocation": "C:\\Windows\\SysWOW64\\",
"UninstallString": "MsiExec.exe /X{0ADD979C-205B-4264-B903-6F953F362917}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{0ADD979C-205B-4264-B903-6F953F362917}"
},
{
"DisplayName": "Sage SGE Runtime",
"DisplayVersion": "1.00.0000",
"Publisher": "Sage Spain",
"InstallDate": "20201021",
"InstallLocation": "C:\\Windows\\SysWOW64\\",
"UninstallString": "MsiExec.exe /X{1FFF90A6-3F93-4123-9C3B-54EBBDD22757}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1FFF90A6-3F93-4123-9C3B-54EBBDD22757}"
},
{
"DisplayName": "Sage Live Update Service",
"DisplayVersion": "1.0.8.0",
"Publisher": "Sage",
"InstallDate": "20230314",
"InstallLocation": "",
"UninstallString": "MsiExec.exe /I{6D538240-299A-47CC-8782-2062AD2F2189}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{6D538240-299A-47CC-8782-2062AD2F2189}"
},
{
"DisplayName": "Sage API OnPremise Service",
"DisplayVersion": "1.2.8.0",
"Publisher": "Sage",
"InstallDate": "20201021",
"InstallLocation": "",
"UninstallString": "MsiExec.exe /I{9881C355-CB1B-4007-AB3A-B12F222318DB}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9881C355-CB1B-4007-AB3A-B12F222318DB}"
}
],
"SageFileVersions": [
],
"SqlRegistryInstances": [
{
"InstanceName": "MSSQLSERVER",
"InstanceId": "MSSQL15.MSSQLSERVER",
"Edition": "Standard Edition",
"Version": "15.0.2000.5",
"PatchLevel": "15.0.2155.2",
"ProductCode": "{A60B3D8E-5311-4BF1-AF7A-D1AC15F9152E}",
"SQLPath": "C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL",
"SetupPath": "HKLM:\\SOFTWARE\\Microsoft\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\Setup"
}
],
"SqlServices": [
{
"Name": "MSSQLFDLauncher",
"DisplayName": "SQL Full-text Filter Daemon Launcher (MSSQLSERVER)",
"State": "Running",
"StartMode": "Manual",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\fdlauncher.exe\" -s MSSQL15.MSSQLSERVER"
},
{
"Name": "MSSQLSERVER",
"DisplayName": "SQL Server (MSSQLSERVER)",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\sqlservr.exe\" -sMSSQLSERVER"
},
{
"Name": "SQLBrowser",
"DisplayName": "SQL Server Browser",
"State": "Stopped",
"StartMode": "Disabled",
"PathName": "\"C:\\Program Files (x86)\\Microsoft SQL Server\\90\\Shared\\sqlbrowser.exe\""
},
{
"Name": "SQLSERVERAGENT",
"DisplayName": "Agente SQL Server (MSSQLSERVER)",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\SQLAGENT.EXE\" -i MSSQLSERVER"
},
{
"Name": "SQLTELEMETRY",
"DisplayName": "Servicio CEIP de SQL Server (MSSQLSERVER)",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\sqlceip.exe\" -Service "
},
{
"Name": "SQLWriter",
"DisplayName": "SQL Server VSS Writer",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\90\\Shared\\sqlwriter.exe\""
}
],
"SqlcmdPath": "C:\\Program Files\\Microsoft SQL Server\\Client SDK\\ODBC\\170\\Tools\\Binn\\SQLCMD.EXE",
"SqlQueryResults": [
{
"Instance": "localhost",
"Success": true,
"Output": "FullVersion|ProductVersion|ProductLevel|Edition|EngineEdition|MachineName|ServerName|InstanceName|Collation\r\n-----------|--------------|------------|-------|-------------|-----------|----------|------------|---------\r\nMicrosoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64) \r\n\tOct 7 2025 21:11:52 \r\n\tCopyright (C) 2019 Microsoft Corporation\r\n\tStandard Edition (64-bit) on Windows Server 2019 Standard 10.0 \u003cX64\u003e (Build 17763: ) (Hypervisor)\r\n|15.0.2155.2|RTM|Standard Edition (64-bit)|2|WIN-4BJQJ9S1PVJ|WIN-4BJQJ9S1PVJ|NULL|Latin1_General_CI_AI"
}
]
}
@@ -0,0 +1,123 @@

============================================================
Capture metadata
============================================================
Timestamp: 2026-05-05 10:05:13
Computer: WIN-4BJQJ9S1PVJ
User: WIN-4BJQJ9S1PVJ\Administrador
Output text: C:\Users\Administrador\Desktop\Sage_SQL_Environment_20260505_100511.txt
Output json: C:\Users\Administrador\Desktop\Sage_SQL_Environment_20260505_100511.json
============================================================
Windows / machine
============================================================
Manufacturer: Xen
Model: HVM domU
OS: Microsoft Windows Server 2019 Standard
OS Version: 10.0.17763
OS Build: 17763
Install date: 09/30/2020 08:17:56
============================================================
Sage entries from installed programs
============================================================
DisplayName : JRE 2.5
DisplayVersion :
Publisher : Sage Logic Control
InstallDate :
InstallLocation :
UninstallString : "C:\Windows\unins000.exe"
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\JRE_is1
DisplayName : Sage 200c
DisplayVersion : 2026.56.000
Publisher : Sage Spain
InstallDate :
InstallLocation :
UninstallString : C:\Program Files (x86)\Sage\Sage 200c\Setup\Uninstall\Sage.Uninstall.exe
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Sage 200c
DisplayName : Sage Renta Componentes
DisplayVersion : 1.00.0000
Publisher : Sage Spain
InstallDate : 20201021
InstallLocation : C:\Windows\SysWOW64\
UninstallString : MsiExec.exe /X{0ADD979C-205B-4264-B903-6F953F362917}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{0ADD979C-205B-4264-B903-6F953F3
62917}
DisplayName : Sage SGE Runtime
DisplayVersion : 1.00.0000
Publisher : Sage Spain
InstallDate : 20201021
InstallLocation : C:\Windows\SysWOW64\
UninstallString : MsiExec.exe /X{1FFF90A6-3F93-4123-9C3B-54EBBDD22757}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{1FFF90A6-3F93-4123-9C3B-54EBBDD
22757}
DisplayName : Sage Live Update Service
DisplayVersion : 1.0.8.0
Publisher : Sage
InstallDate : 20230314
InstallLocation :
UninstallString : MsiExec.exe /I{6D538240-299A-47CC-8782-2062AD2F2189}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{6D538240-299A-47CC-8782-2062AD2
F2189}
DisplayName : Sage API OnPremise Service
DisplayVersion : 1.2.8.0
Publisher : Sage
InstallDate : 20201021
InstallLocation :
UninstallString : MsiExec.exe /I{9881C355-CB1B-4007-AB3A-B12F222318DB}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{9881C355-CB1B-4007-AB3A-B12F222
318DB}
============================================================
Sage file versions
============================================================
Skipped. Re-run with -ScanProgramFiles for file version scan.
============================================================
SQL Server instances from registry
============================================================
InstanceName : MSSQLSERVER
InstanceId : MSSQL15.MSSQLSERVER
Edition : Standard Edition
Version : 15.0.2000.5
PatchLevel : 15.0.2155.2
ProductCode : {A60B3D8E-5311-4BF1-AF7A-D1AC15F9152E}
SQLPath : C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL
SetupPath : HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL15.MSSQLSERVER\Setup
============================================================
SQL Server services
============================================================
Name DisplayName State StartMode PathName
---- ----------- ----- --------- --------
MSSQLFDLauncher SQL Full-text Filter Daemon Launcher (MSSQLSERVER) Running Manual "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\fdlauncher....
MSSQLSERVER SQL Server (MSSQLSERVER) Running Auto "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\sqlservr.ex...
SQLBrowser SQL Server Browser Stopped Disabled "C:\Program Files (x86)\Microsoft SQL Server\90\Shared\sqlbrowser.exe"
SQLSERVERAGENT Agente SQL Server (MSSQLSERVER) Running Auto "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\SQLAGENT.EX...
SQLTELEMETRY Servicio CEIP de SQL Server (MSSQLSERVER) Running Auto "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\sqlceip.exe...
SQLWriter SQL Server VSS Writer Running Auto "C:\Program Files\Microsoft SQL Server\90\Shared\sqlwriter.exe"
============================================================
SQL Server live query
============================================================
sqlcmd path: C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn\SQLCMD.EXE
Instance: localhost
Success: True
FullVersion|ProductVersion|ProductLevel|Edition|EngineEdition|MachineName|ServerName|InstanceName|Collation
-----------|--------------|------------|-------|-------------|-----------|----------|------------|---------
Microsoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64)
Oct 7 2025 21:11:52
Copyright (C) 2019 Microsoft Corporation
Standard Edition (64-bit) on Windows Server 2019 Standard 10.0 <X64> (Build 17763: ) (Hypervisor)
|15.0.2155.2|RTM|Standard Edition (64-bit)|2|WIN-4BJQJ9S1PVJ|WIN-4BJQJ9S1PVJ|NULL|Latin1_General_CI_AI
@@ -0,0 +1,43 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace TrafagSalesExporter.Security;
public sealed class DevelopmentAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Development";
public const string AdminClaimType = "TrafagSalesExporter.Admin";
private readonly IConfiguration _configuration;
public DevelopmentAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: base(options, logger, encoder)
{
_configuration = configuration;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var settings = _configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var claims = new List<Claim>
{
new(ClaimTypes.Name, settings.DevelopmentUserName),
new(ClaimTypes.NameIdentifier, settings.DevelopmentUserName)
};
if (settings.DevelopmentUserIsAdmin)
claims.Add(new Claim(AdminClaimType, "true"));
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class FinanceCockpitAccessOptions
{
public const string SectionName = "FinanceCockpitAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "finance";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class HrKpiAccessOptions
{
public const string SectionName = "HrKpiAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "hr";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Security;
public sealed class SecurityOptions
{
public const string SectionName = "Security";
public bool Enabled { get; set; } = true;
public bool DevelopmentBypass { get; set; }
public bool DevelopmentUserIsAdmin { get; set; }
public string DevelopmentUserName { get; set; } = "DEV\\TrafagDeveloper";
public List<string> AccessGroups { get; set; } = [];
public List<string> AdminGroups { get; set; } = [];
}
@@ -0,0 +1,6 @@
namespace TrafagSalesExporter.Security;
public static class SecurityPolicies
{
public const string AdminOnly = nameof(AdminOnly);
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
namespace TrafagSalesExporter.Security;
public static class SecurityPolicyFactory
{
public static AuthorizationPolicy BuildAccessPolicy(SecurityOptions settings, bool useDevelopmentAuthentication)
{
if (!settings.Enabled)
{
return new AuthorizationPolicyBuilder()
.RequireAssertion(_ => true)
.Build();
}
var builder = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser();
if (!useDevelopmentAuthentication &&
settings.AccessGroups.Count > 0)
{
builder.RequireAssertion(context =>
settings.AccessGroups.Any(group => context.User.IsInRole(group)));
}
return builder.Build();
}
public static AuthorizationPolicy BuildAdminPolicy(SecurityOptions settings, bool useDevelopmentAuthentication)
{
if (!settings.Enabled)
{
return new AuthorizationPolicyBuilder()
.RequireAssertion(_ => true)
.Build();
}
var builder = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser();
builder.RequireAssertion(context =>
useDevelopmentAuthentication && context.User.HasClaim(DevelopmentAuthenticationHandler.AdminClaimType, "true") ||
settings.AdminGroups.Any(group => context.User.IsInRole(group)));
return builder.Build();
}
}
@@ -7,45 +7,61 @@ namespace TrafagSalesExporter.Services;
public class AppEventLogService : IAppEventLogService public class AppEventLogService : IAppEventLogService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ILogger<AppEventLogService> _logger;
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory) public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory, ILogger<AppEventLogService> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_logger = logger;
} }
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null) public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
{ {
using var db = await _dbFactory.CreateDbContextAsync(); try
db.AppEventLogs.Add(new AppEventLog
{ {
Timestamp = DateTime.Now, using var db = await _dbFactory.CreateDbContextAsync();
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(), db.AppEventLogs.Add(new AppEventLog
Category = category?.Trim() ?? string.Empty, {
SiteId = siteId, Timestamp = DateTime.Now,
Land = land?.Trim() ?? string.Empty, Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
Message = message?.Trim() ?? string.Empty, Category = category?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty SiteId = siteId,
}); Land = land?.Trim() ?? string.Empty,
await db.SaveChangesAsync(); Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "AppEventLog konnte nicht gespeichert werden: {Category} - {Message}", category, message);
}
} }
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null) public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
{ {
using var db = await _dbFactory.CreateDbContextAsync(); try
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.DebugLoggingEnabled)
return;
db.AppEventLogs.Add(new AppEventLog
{ {
Timestamp = DateTime.Now, using var db = await _dbFactory.CreateDbContextAsync();
Level = "Debug", var settings = await db.ExportSettings.FirstOrDefaultAsync();
Category = category?.Trim() ?? string.Empty, if (settings is null || !settings.DebugLoggingEnabled)
SiteId = siteId, return;
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty, db.AppEventLogs.Add(new AppEventLog
Details = details?.Trim() ?? string.Empty {
}); Timestamp = DateTime.Now,
await db.SaveChangesAsync(); Level = "Debug",
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Debug-AppEventLog konnte nicht gespeichert werden: {Category} - {Message}", category, message);
}
} }
} }
@@ -64,6 +64,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
{ {
ExtractionDate = r.ExtractionDate, ExtractionDate = r.ExtractionDate,
Tsc = r.Tsc, Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber, InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice, PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material, Material = r.Material,
@@ -82,8 +83,16 @@ public class CentralSalesRecordService : ICentralSalesRecordService
PurchaseOrderNumber = r.PurchaseOrderNumber, PurchaseOrderNumber = r.PurchaseOrderNumber,
SalesPriceValue = r.SalesPriceValue, SalesPriceValue = r.SalesPriceValue,
SalesCurrency = r.SalesCurrency, SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency,
DocumentRate = r.DocumentRate,
CompanyCurrency = r.CompanyCurrency,
Incoterms2020 = r.Incoterms2020, Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee, SalesResponsibleEmployee = r.SalesResponsibleEmployee,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate, InvoiceDate = r.InvoiceDate,
OrderDate = r.OrderDate, OrderDate = r.OrderDate,
Land = r.Land, Land = r.Land,
@@ -154,18 +163,20 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Transaction = transaction; command.Transaction = transaction;
command.CommandText = """ command.CommandText = """
INSERT INTO CentralSalesRecords ( INSERT INTO CentralSalesRecords (
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, InvoiceNumber, PositionOnInvoice, StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, DocumentEntry, InvoiceNumber, PositionOnInvoice,
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry, Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost, CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020, StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency,
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, PostingDate, InvoiceDate, OrderDate, Land, DocumentType
) )
VALUES ( VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice, $storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry, $material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost, $customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020, $standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType $documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency,
$vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $postingDate, $invoiceDate, $orderDate, $land, $documentType
); );
"""; """;
@@ -174,6 +185,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$sourceSystem", SqliteType.Text); command.Parameters.Add("$sourceSystem", SqliteType.Text);
command.Parameters.Add("$extractionDate", SqliteType.Text); command.Parameters.Add("$extractionDate", SqliteType.Text);
command.Parameters.Add("$tsc", SqliteType.Text); command.Parameters.Add("$tsc", SqliteType.Text);
command.Parameters.Add("$documentEntry", SqliteType.Integer);
command.Parameters.Add("$invoiceNumber", SqliteType.Text); command.Parameters.Add("$invoiceNumber", SqliteType.Text);
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer); command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
command.Parameters.Add("$material", SqliteType.Text); command.Parameters.Add("$material", SqliteType.Text);
@@ -192,8 +204,16 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text); command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
command.Parameters.Add("$salesPriceValue", SqliteType.Real); command.Parameters.Add("$salesPriceValue", SqliteType.Real);
command.Parameters.Add("$salesCurrency", SqliteType.Text); command.Parameters.Add("$salesCurrency", SqliteType.Text);
command.Parameters.Add("$documentCurrency", SqliteType.Text);
command.Parameters.Add("$documentTotalForeignCurrency", SqliteType.Real);
command.Parameters.Add("$documentTotalLocalCurrency", SqliteType.Real);
command.Parameters.Add("$vatSumForeignCurrency", SqliteType.Real);
command.Parameters.Add("$vatSumLocalCurrency", SqliteType.Real);
command.Parameters.Add("$documentRate", SqliteType.Real);
command.Parameters.Add("$companyCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text); command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text); command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$postingDate", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text); command.Parameters.Add("$invoiceDate", SqliteType.Text);
command.Parameters.Add("$orderDate", SqliteType.Text); command.Parameters.Add("$orderDate", SqliteType.Text);
command.Parameters.Add("$land", SqliteType.Text); command.Parameters.Add("$land", SqliteType.Text);
@@ -209,6 +229,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$sourceSystem"].Value = sourceSystem; command.Parameters["$sourceSystem"].Value = sourceSystem;
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O"); command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty; command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
command.Parameters["$documentEntry"].Value = record.DocumentEntry;
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty; command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice; command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
command.Parameters["$material"].Value = record.Material ?? string.Empty; command.Parameters["$material"].Value = record.Material ?? string.Empty;
@@ -227,8 +248,16 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty; command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue; command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty; command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
command.Parameters["$documentCurrency"].Value = record.DocumentCurrency ?? string.Empty;
command.Parameters["$documentTotalForeignCurrency"].Value = record.DocumentTotalForeignCurrency;
command.Parameters["$documentTotalLocalCurrency"].Value = record.DocumentTotalLocalCurrency;
command.Parameters["$vatSumForeignCurrency"].Value = record.VatSumForeignCurrency;
command.Parameters["$vatSumLocalCurrency"].Value = record.VatSumLocalCurrency;
command.Parameters["$documentRate"].Value = record.DocumentRate;
command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty; command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty; command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$postingDate"].Value = record.PostingDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value; command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value; command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$land"].Value = record.Land ?? string.Empty; command.Parameters["$land"].Value = record.Land ?? string.Empty;
@@ -26,12 +26,26 @@ public class ConfigTransferService : IConfigTransferService
.ThenBy(x => x.ToCurrency) .ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom) .ThenByDescending(x => x.ValidFrom)
.ToListAsync(); .ToListAsync();
var financeReferences = await db.FinanceReferences
.OrderBy(x => x.Year)
.ThenBy(x => x.Key)
.ToListAsync();
var financeIntercompanyRules = await db.FinanceIntercompanyRules
.OrderBy(x => x.ScopeKey)
.ThenBy(x => x.CustomerNumber)
.ThenBy(x => x.CustomerNameContains)
.ToListAsync();
var financeRules = await db.FinanceRules
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Id)
.ToListAsync();
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync(); var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync(); var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var manualExcelMappings = await db.ManualExcelColumnMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N")); var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N")); var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
@@ -78,6 +92,37 @@ public class ConfigTransferService : IConfigTransferService
Notes = rate.Notes, Notes = rate.Notes,
IsActive = rate.IsActive IsActive = rate.IsActive
}).ToList(), }).ToList(),
FinanceReferences = financeReferences.Select(reference => new ConfigTransferFinanceReference
{
Key = reference.Key,
Label = reference.Label,
Year = reference.Year,
LocalCurrencyValue = reference.LocalCurrencyValue,
CheckValue = reference.CheckValue,
Notes = reference.Notes,
IsActive = reference.IsActive
}).ToList(),
FinanceIntercompanyRules = financeIntercompanyRules.Select(rule => new ConfigTransferFinanceIntercompanyRule
{
ScopeKey = rule.ScopeKey,
CustomerNumber = rule.CustomerNumber,
CustomerNameContains = rule.CustomerNameContains,
Notes = rule.Notes,
IsActive = rule.IsActive
}).ToList(),
FinanceRules = financeRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}).ToList(),
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{ {
Key = serverKeyMap[server.Id], Key = serverKeyMap[server.Id],
@@ -148,6 +193,15 @@ public class ConfigTransferService : IConfigTransferService
IsRequired = m.IsRequired, IsRequired = m.IsRequired,
IsActive = m.IsActive, IsActive = m.IsActive,
SortOrder = m.SortOrder SortOrder = m.SortOrder
}).ToList(),
ManualExcelColumnMappings = manualExcelMappings.Select(m => new ConfigTransferManualExcelColumnMapping
{
SiteKey = siteKeyMap[m.SiteId],
TargetField = m.TargetField,
SourceHeader = m.SourceHeader,
IsRequired = m.IsRequired,
IsActive = m.IsActive,
SortOrder = m.SortOrder
}).ToList() }).ToList()
}; };
@@ -167,12 +221,16 @@ public class ConfigTransferService : IConfigTransferService
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync(); var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
var existingServers = await db.HanaServers.ToListAsync(); var existingServers = await db.HanaServers.ToListAsync();
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync(); var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
var existingFinanceRules = await db.FinanceRules.ToListAsync();
var existingSites = await db.Sites.ToListAsync(); var existingSites = await db.Sites.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync(); var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync(); var existingRules = await db.FieldTransformationRules.ToListAsync();
var existingSapSources = await db.SapSourceDefinitions.ToListAsync(); var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync(); var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
var existingSapMappings = await db.SapFieldMappings.ToListAsync(); var existingSapMappings = await db.SapFieldMappings.ToListAsync();
var existingManualExcelMappings = await db.ManualExcelColumnMappings.ToListAsync();
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty; var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary( var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary(
@@ -187,9 +245,16 @@ public class ConfigTransferService : IConfigTransferService
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem)); x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem));
if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings); if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings);
if (existingManualExcelMappings.Count > 0) db.ManualExcelColumnMappings.RemoveRange(existingManualExcelMappings);
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins); if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources); if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules); if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
if (package.FinanceReferences.Count > 0 && existingFinanceReferences.Count > 0)
db.FinanceReferences.RemoveRange(existingFinanceReferences);
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules);
if (package.FinanceRules.Count > 0 && existingFinanceRules.Count > 0)
db.FinanceRules.RemoveRange(existingFinanceRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates); if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites); if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers); if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
@@ -250,6 +315,49 @@ public class ConfigTransferService : IConfigTransferService
})); }));
} }
if (package.FinanceReferences.Count > 0)
{
db.FinanceReferences.AddRange(package.FinanceReferences.Select(reference => new FinanceReference
{
Key = reference.Key,
Label = reference.Label,
Year = reference.Year,
LocalCurrencyValue = reference.LocalCurrencyValue,
CheckValue = reference.CheckValue,
Notes = reference.Notes,
IsActive = reference.IsActive
}));
}
if (package.FinanceIntercompanyRules.Count > 0)
{
db.FinanceIntercompanyRules.AddRange(package.FinanceIntercompanyRules.Select(rule => new FinanceIntercompanyRule
{
ScopeKey = rule.ScopeKey,
CustomerNumber = rule.CustomerNumber,
CustomerNameContains = rule.CustomerNameContains,
Notes = rule.Notes,
IsActive = rule.IsActive
}));
}
if (package.FinanceRules.Count > 0)
{
db.FinanceRules.AddRange(package.FinanceRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}));
}
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers) foreach (var server in package.HanaServers)
{ {
@@ -314,6 +422,7 @@ public class ConfigTransferService : IConfigTransferService
SourceSystem = record.SourceSystem, SourceSystem = record.SourceSystem,
ExtractionDate = record.ExtractionDate, ExtractionDate = record.ExtractionDate,
Tsc = record.Tsc, Tsc = record.Tsc,
DocumentEntry = record.DocumentEntry,
InvoiceNumber = record.InvoiceNumber, InvoiceNumber = record.InvoiceNumber,
PositionOnInvoice = record.PositionOnInvoice, PositionOnInvoice = record.PositionOnInvoice,
Material = record.Material, Material = record.Material,
@@ -332,8 +441,16 @@ public class ConfigTransferService : IConfigTransferService
PurchaseOrderNumber = record.PurchaseOrderNumber, PurchaseOrderNumber = record.PurchaseOrderNumber,
SalesPriceValue = record.SalesPriceValue, SalesPriceValue = record.SalesPriceValue,
SalesCurrency = record.SalesCurrency, SalesCurrency = record.SalesCurrency,
DocumentCurrency = record.DocumentCurrency,
DocumentTotalForeignCurrency = record.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = record.DocumentTotalLocalCurrency,
VatSumForeignCurrency = record.VatSumForeignCurrency,
VatSumLocalCurrency = record.VatSumLocalCurrency,
DocumentRate = record.DocumentRate,
CompanyCurrency = record.CompanyCurrency,
Incoterms2020 = record.Incoterms2020, Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee, SalesResponsibleEmployee = record.SalesResponsibleEmployee,
PostingDate = record.PostingDate,
InvoiceDate = record.InvoiceDate, InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate, OrderDate = record.OrderDate,
Land = record.Land, Land = record.Land,
@@ -407,6 +524,21 @@ public class ConfigTransferService : IConfigTransferService
})); }));
} }
if (package.ManualExcelColumnMappings.Count > 0)
{
db.ManualExcelColumnMappings.AddRange(package.ManualExcelColumnMappings
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new ManualExcelColumnMapping
{
SiteId = siteIdMap[x.SiteKey],
TargetField = x.TargetField,
SourceHeader = x.SourceHeader,
IsRequired = x.IsRequired,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
@@ -23,7 +23,7 @@ public class ConsolidatedExportService : IConsolidatedExportService
_sharePointService = sharePointService; _sharePointService = sharePointService;
} }
public async Task<string?> ExportAsync(List<SalesRecord> records) public async Task<string?> ExportAsync()
{ {
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync(); var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
if (consolidatedRecords.Count == 0) if (consolidatedRecords.Count == 0)
@@ -46,6 +46,7 @@ public sealed class DashboardPageService : IDashboardPageService
{ {
SiteId = s.Id, SiteId = s.Id,
Land = s.Land, Land = s.Land,
DataBasis = ResolveDataBasis(s, sourceSystem),
TSC = s.TSC, TSC = s.TSC,
Schema = s.Schema, Schema = s.Schema,
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase) ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
@@ -62,13 +63,45 @@ public sealed class DashboardPageService : IDashboardPageService
}; };
}).ToList(); }).ToList();
var consolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new());
var latestSuccessfulSiteRun = logs
.Where(log => log.Status == "OK")
.Select(log => (DateTime?)log.Timestamp)
.OrderByDescending(timestamp => timestamp)
.FirstOrDefault();
var latestConsolidatedRun = consolidatedRows
.Select(row => row.LastModified)
.OrderByDescending(timestamp => timestamp)
.FirstOrDefault();
return new DashboardPageState return new DashboardPageState
{ {
DashboardRows = rows, DashboardRows = rows,
ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new()) ConsolidatedRows = consolidatedRows,
ReadinessWarnings = BuildReadinessWarnings(sites, sourceSystems),
IsConsolidatedStale = latestSuccessfulSiteRun.HasValue &&
(!latestConsolidatedRun.HasValue || latestSuccessfulSiteRun.Value > latestConsolidatedRun.Value),
LatestSuccessfulSiteRun = latestSuccessfulSiteRun,
LatestConsolidatedRun = latestConsolidatedRun
}; };
} }
private static List<string> BuildReadinessWarnings(List<Site> activeSites, List<SourceSystemDefinition> sourceSystems)
{
var warnings = new List<string>();
foreach (var site in activeSites.OrderBy(x => x.Land).ThenBy(x => x.TSC))
{
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
if (!string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(site.ManualImportFilePath))
warnings.Add($"{site.Land} / {site.TSC}: manuelle Excel-/CSV-Datei fehlt.");
}
return warnings;
}
private static string ResolveDashboardSapServiceUrl(Site site, List<SourceSystemDefinition> sourceSystems) private static string ResolveDashboardSapServiceUrl(Site site, List<SourceSystemDefinition> sourceSystems)
{ {
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl)) if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
@@ -78,6 +111,32 @@ public sealed class DashboardPageService : IDashboardPageService
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl; return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
} }
private static string ResolveDataBasis(Site site, SourceSystemDefinition? sourceSystem)
{
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
{
var path = site.ManualImportFilePath ?? string.Empty;
var extension = Path.GetExtension(path).TrimStart('.').ToUpperInvariant();
if (extension is "CSV")
return "CSV-Datei";
if (extension is "XLS" or "XLSX" or "XLSM")
return "Excel-Datei";
if (!string.IsNullOrWhiteSpace(path))
return "Excel/CSV-Datei";
return "Manuelle Datei";
}
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return "SAP Service";
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
return "Server";
return string.IsNullOrWhiteSpace(site.SourceSystem) ? "-" : site.SourceSystem;
}
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings) private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
{ {
var outputDirectory = ResolveConsolidatedOutputDirectory(settings); var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
@@ -114,12 +173,17 @@ public sealed class DashboardPageState
{ {
public List<DashboardRow> DashboardRows { get; set; } = []; public List<DashboardRow> DashboardRows { get; set; } = [];
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = []; public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
public List<string> ReadinessWarnings { get; set; } = [];
public bool IsConsolidatedStale { get; set; }
public DateTime? LatestSuccessfulSiteRun { get; set; }
public DateTime? LatestConsolidatedRun { get; set; }
} }
public sealed class DashboardRow public sealed class DashboardRow
{ {
public int SiteId { get; set; } public int SiteId { get; set; }
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
public string DataBasis { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty; public string TSC { get; set; } = string.Empty;
public string Schema { get; set; } = string.Empty; public string Schema { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty; public string ServerName { get; set; } = string.Empty;
@@ -9,4 +9,5 @@ public sealed class DataSourceFetchContext
public required ExportSettings Settings { get; init; } public required ExportSettings Settings { get; init; }
public SharePointConfig? SharePointConfig { get; init; } public SharePointConfig? SharePointConfig { get; init; }
public Action<string>? UpdateStatus { get; init; } public Action<string>? UpdateStatus { get; init; }
public int? PreferredImportYear { get; init; }
} }
@@ -11,4 +11,10 @@ public sealed class DataSourceFetchResult
/// SiteExportService erzeugt dann keine neue Excel-Datei. /// SiteExportService erzeugt dann keine neue Excel-Datei.
/// </summary> /// </summary>
public string? ReferenceFilePath { get; init; } public string? ReferenceFilePath { get; init; }
public string? LocalOutputDirectoryOverride { get; init; }
public string? SharePointUploadFolderOverride { get; init; }
public string? SharePointUploadLandOverride { get; init; }
} }
@@ -29,14 +29,32 @@ public sealed class HanaDataSourceAdapter : IDataSourceAdapter
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition); var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition);
var sourceMappings = await db.SapSourceDefinitions
.Where(s => s.SiteId == site.Id)
.OrderBy(s => s.SortOrder)
.ThenBy(s => s.Id)
.ToListAsync();
var joins = await db.SapJoinDefinitions
.Where(j => j.SiteId == site.Id)
.OrderBy(j => j.SortOrder)
.ThenBy(j => j.Id)
.ToListAsync();
var fieldMappings = await db.SapFieldMappings
.Where(m => m.SiteId == site.Id)
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Id)
.ToListAsync();
context.UpdateStatus?.Invoke("HANA Abfrage..."); context.UpdateStatus?.Invoke("HANA Abfrage...");
await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet", await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet",
siteId: site.Id, land: site.Land, siteId: site.Id, land: site.Land,
details: exportServer.GetConnectionStringPreview()); details: exportServer.GetConnectionStringPreview());
var records = await Task.Run(() => _hanaService.GetSalesRecords( var records = sourceMappings.Count > 0 && fieldMappings.Count > 0
exportServer, site.Schema, site.TSC, site.Land, context.Settings.DateFilter)); ? await _hanaService.GetMappedSalesRecordsAsync(
exportServer, site.Schema, site, sourceMappings, joins, fieldMappings, context.Settings.DateFilter)
: await _hanaService.GetSalesRecordsAsync(
exportServer, site.Schema, site.TSC, site.Land, context.Settings.DateFilter);
return new DataSourceFetchResult { Records = records }; return new DataSourceFetchResult { Records = records };
} }
@@ -29,12 +29,15 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
var manualImportPath = site.ManualImportFilePath.Trim(); var manualImportPath = site.ManualImportFilePath.Trim();
string filePath; string filePath;
string? tempManualImportPath = null; string? localOutputDirectory = null;
string? sharePointUploadFolder = null;
var tempManualImportPaths = new List<string>();
try try
{ {
if (File.Exists(manualImportPath)) if (File.Exists(manualImportPath))
{ {
filePath = manualImportPath; filePath = manualImportPath;
localOutputDirectory = Path.GetDirectoryName(Path.GetFullPath(manualImportPath));
} }
else if (LooksLikeSharePointReference(manualImportPath)) else if (LooksLikeSharePointReference(manualImportPath))
{ {
@@ -55,10 +58,31 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden", await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden",
siteId: site.Id, land: site.Land, details: manualImportPath); siteId: site.Id, land: site.Land, details: manualImportPath);
tempManualImportPath = await _sharePointService.DownloadToTempFileAsync( var sharePointFileReference = manualImportPath;
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, var sharePointFileReferences = new List<string>();
spConfig.SiteUrl, manualImportPath); if (LooksLikeSharePointFolderReference(manualImportPath))
filePath = manualImportPath; {
var files = await _sharePointService.ResolveManualImportFilesInFolderAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, manualImportPath, site.TSC, context.PreferredImportYear);
sharePointFileReferences.AddRange(files.Select(file => file.FileReference));
sharePointFileReference = sharePointFileReferences.FirstOrDefault() ?? manualImportPath;
await _appEventLogService.WriteAsync("Export", "Neueste SharePoint-Datei ausgewaehlt",
siteId: site.Id, land: site.Land, details: string.Join(" | ", sharePointFileReferences));
}
else
{
sharePointFileReferences.Add(sharePointFileReference);
}
foreach (var fileReference in sharePointFileReferences)
{
tempManualImportPaths.Add(await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, fileReference));
}
filePath = sharePointFileReference;
sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl);
} }
else else
{ {
@@ -66,22 +90,29 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
$"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}"); $"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}");
} }
var readPath = tempManualImportPath ?? filePath;
context.UpdateStatus?.Invoke("Manuelle Excel lesen..."); context.UpdateStatus?.Invoke("Manuelle Excel lesen...");
await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen",
siteId: site.Id, land: site.Land, details: filePath); siteId: site.Id, land: site.Land, details: filePath);
var records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site); var records = new List<SalesRecord>();
var readPaths = tempManualImportPaths.Count > 0 ? tempManualImportPaths : [filePath];
foreach (var readPath in readPaths)
records.AddRange(await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site));
return new DataSourceFetchResult return new DataSourceFetchResult
{ {
Records = records, Records = records,
ReferenceFilePath = filePath LocalOutputDirectoryOverride = localOutputDirectory,
SharePointUploadFolderOverride = sharePointUploadFolder,
SharePointUploadLandOverride = sharePointUploadFolder is null ? null : string.Empty
}; };
} }
finally finally
{ {
if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath)) foreach (var tempManualImportPath in tempManualImportPaths)
File.Delete(tempManualImportPath); {
if (File.Exists(tempManualImportPath))
File.Delete(tempManualImportPath);
}
} }
} }
@@ -90,4 +121,25 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("/Shared Documents/", 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];
}
} }
@@ -50,7 +50,7 @@ public sealed class SapGatewayDataSourceAdapter : IDataSourceAdapter
var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl);
var records = await _sapCompositionService.BuildSalesRecordsAsync( var records = await _sapCompositionService.BuildSalesRecordsAsync(
effectiveSite, sapSources, sapJoins, sapMappings, effectiveSite, sapSources, sapJoins, sapMappings,
credentials.Username, credentials.Password); credentials.Username, credentials.Password, context.PreferredImportYear);
return new DataSourceFetchResult { Records = records }; return new DataSourceFetchResult { Records = records };
} }
@@ -85,6 +85,7 @@ CREATE TABLE CentralSalesRecords (
SourceSystem TEXT NOT NULL, SourceSystem TEXT NOT NULL,
ExtractionDate TEXT NOT NULL, ExtractionDate TEXT NOT NULL,
Tsc TEXT NOT NULL, Tsc TEXT NOT NULL,
DocumentEntry INTEGER NOT NULL DEFAULT 0,
InvoiceNumber TEXT NOT NULL, InvoiceNumber TEXT NOT NULL,
PositionOnInvoice INTEGER NOT NULL, PositionOnInvoice INTEGER NOT NULL,
Material TEXT NOT NULL, Material TEXT NOT NULL,
@@ -103,8 +104,16 @@ CREATE TABLE CentralSalesRecords (
PurchaseOrderNumber TEXT NOT NULL, PurchaseOrderNumber TEXT NOT NULL,
SalesPriceValue TEXT NOT NULL, SalesPriceValue TEXT NOT NULL,
SalesCurrency TEXT NOT NULL, SalesCurrency TEXT NOT NULL,
DocumentCurrency TEXT NOT NULL DEFAULT '',
DocumentTotalForeignCurrency TEXT NOT NULL DEFAULT '0',
DocumentTotalLocalCurrency TEXT NOT NULL DEFAULT '0',
VatSumForeignCurrency TEXT NOT NULL DEFAULT '0',
VatSumLocalCurrency TEXT NOT NULL DEFAULT '0',
DocumentRate TEXT NOT NULL DEFAULT '0',
CompanyCurrency TEXT NOT NULL DEFAULT '',
Incoterms2020 TEXT NOT NULL, Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL, SalesResponsibleEmployee TEXT NOT NULL,
PostingDate TEXT NULL,
InvoiceDate TEXT NULL, InvoiceDate TEXT NULL,
OrderDate TEXT NULL, OrderDate TEXT NULL,
Land TEXT NOT NULL, Land TEXT NOT NULL,
@@ -149,4 +158,53 @@ CREATE TABLE SapFieldMappings (
SortOrder INTEGER NOT NULL DEFAULT 0, SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id) FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);"; );";
internal static string GetManualExcelColumnMappingsCreateSql() => @"
CREATE TABLE ManualExcelColumnMappings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
TargetField TEXT NOT NULL,
SourceHeader TEXT NOT NULL,
IsRequired INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
internal static string GetFinanceReferencesCreateSql() => @"
CREATE TABLE FinanceReferences (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Key TEXT NOT NULL,
Label TEXT NOT NULL,
Year INTEGER NOT NULL DEFAULT 2025,
LocalCurrencyValue TEXT NULL,
CheckValue TEXT NULL,
Notes TEXT NOT NULL DEFAULT '',
IsActive INTEGER NOT NULL DEFAULT 1
);";
internal static string GetFinanceIntercompanyRulesCreateSql() => @"
CREATE TABLE FinanceIntercompanyRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ScopeKey TEXT NOT NULL DEFAULT '',
CustomerNumber TEXT NOT NULL DEFAULT '',
CustomerNameContains TEXT NOT NULL DEFAULT '',
Notes TEXT NOT NULL DEFAULT '',
IsActive INTEGER NOT NULL DEFAULT 1
);";
internal static string GetFinanceRulesCreateSql() => @"
CREATE TABLE FinanceRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ScopeKey TEXT NOT NULL DEFAULT '',
Year INTEGER NULL,
RuleType TEXT NOT NULL DEFAULT 'Exclude',
FieldName TEXT NOT NULL DEFAULT '',
MatchType TEXT NOT NULL DEFAULT 'Contains',
MatchValue TEXT NOT NULL DEFAULT '',
NumericValue TEXT NULL,
Notes TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
} }
@@ -34,12 +34,25 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureTransformationTable(db); EnsureTransformationTable(db);
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'"); AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
EnsureCurrencyExchangeRateTable(db); EnsureCurrencyExchangeRateTable(db);
EnsureFinanceReferenceTable(db);
EnsureFinanceIntercompanyRuleTable(db);
EnsureFinanceRuleTable(db);
EnsureSourceSystemDefinitionTable(db); EnsureSourceSystemDefinitionTable(db);
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
EnsureSapSourceTable(db); EnsureSapSourceTable(db);
EnsureSapJoinTable(db); EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db); EnsureSapFieldMappingTable(db);
EnsureManualExcelColumnMappingTable(db);
EnsureCentralSalesRecordTable(db); EnsureCentralSalesRecordTable(db);
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "PostingDate", "TEXT NULL");
EnsureAppEventLogTable(db); EnsureAppEventLogTable(db);
} }
@@ -184,16 +197,19 @@ FROM Sites_old;";
("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()), ("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()),
("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()), ("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()),
("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()), ("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()),
("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql()) ("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql()),
("ManualExcelColumnMappings", DatabaseSchemaSql.GetManualExcelColumnMappingsCreateSql())
}; };
foreach (var (tableName, createSql) in siteDependentTables) foreach (var (tableName, createSql) in siteDependentTables)
{ {
if (DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old")) if (DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old") ||
DatabaseSchemaTools.TableReferencesObsoleteTable(conn, tableName, "Sites"))
DatabaseSchemaTools.RebuildTable(conn, tableName, createSql); DatabaseSchemaTools.RebuildTable(conn, tableName, createSql);
} }
if (DatabaseSchemaTools.TableReferences(conn, "Sites", "HanaServers_repair_old")) if (DatabaseSchemaTools.TableReferences(conn, "Sites", "HanaServers_repair_old") ||
DatabaseSchemaTools.TableReferencesObsoleteTable(conn, "Sites", "HanaServers"))
DatabaseSchemaTools.RebuildTable(conn, "Sites", DatabaseSchemaSql.GetSitesCreateSql()); DatabaseSchemaTools.RebuildTable(conn, "Sites", DatabaseSchemaSql.GetSitesCreateSql());
} }
@@ -280,6 +296,39 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private static void EnsureFinanceReferenceTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetFinanceReferencesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureFinanceIntercompanyRuleTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetFinanceIntercompanyRulesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureFinanceRuleTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetFinanceRulesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureSapJoinTable(AppDbContext db) private static void EnsureSapJoinTable(AppDbContext db)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
@@ -302,6 +351,17 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private static void EnsureManualExcelColumnMappingTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetManualExcelColumnMappingsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureCentralSalesRecordTable(AppDbContext db) private static void EnsureCentralSalesRecordTable(AppDbContext db)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
@@ -362,6 +422,25 @@ internal static class DatabaseSchemaTools
return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase); return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase);
} }
internal static bool TableReferencesObsoleteTable(System.Data.Common.DbConnection connection, string tableName, string currentTableName)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
var parameter = command.CreateParameter();
parameter.ParameterName = "$tableName";
parameter.Value = tableName;
command.Parameters.Add(parameter);
var sql = command.ExecuteScalar()?.ToString() ?? string.Empty;
var obsoletePrefix = $"{currentTableName}_";
return sql.Contains($"REFERENCES {obsoletePrefix}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"REFERENCES \"{obsoletePrefix}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"REFERENCES [{obsoletePrefix}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"REFERENCES `{obsoletePrefix}", StringComparison.OrdinalIgnoreCase);
}
internal static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql) internal static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql)
{ {
using var disableFk = connection.CreateCommand(); using var disableFk = connection.CreateCommand();
@@ -12,6 +12,14 @@ public class DatabaseSeedService : IDatabaseSeedService
EnsureRecommendedTransformationRules(db); EnsureRecommendedTransformationRules(db);
EnsureSourceSystemDefinitions(db); EnsureSourceSystemDefinitions(db);
EnsureCentralHanaServerRecords(db); EnsureCentralHanaServerRecords(db);
EnsureSpainManualExcelSite(db);
EnsureGermanyManualExcelSite(db);
EnsureUkManualExcelFolder(db);
EnsureSapODataDachSite(db);
EnsureFinanceReferenceDefaults(db);
EnsureBudgetExchangeRateDefaults(db);
EnsureFinanceIntercompanyRuleDefaults(db);
EnsureFinanceRuleDefaults(db);
} }
private static void SeedIfEmpty(AppDbContext db) private static void SeedIfEmpty(AppDbContext db)
@@ -171,7 +179,8 @@ public class DatabaseSeedService : IDatabaseSeedService
{ {
var defaults = new[] var defaults = new[]
{ {
new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true }, new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP OData", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true },
new SourceSystemDefinition { Code = "SAP_HANA", DisplayName = "SAP HANA Tables/Views", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true }, new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true }, new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true } new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true }
@@ -196,6 +205,12 @@ public class DatabaseSeedService : IDatabaseSeedService
current.DisplayName = item.DisplayName; current.DisplayName = item.DisplayName;
changed = true; changed = true;
} }
else if ((current.Code == "SAP" && current.DisplayName == "SAP") ||
(current.Code == "SAP_HANA" && current.DisplayName == "SAP HANA"))
{
current.DisplayName = item.DisplayName;
changed = true;
}
if (string.IsNullOrWhiteSpace(current.ConnectionKind)) if (string.IsNullOrWhiteSpace(current.ConnectionKind))
{ {
@@ -222,4 +237,696 @@ public class DatabaseSeedService : IDatabaseSeedService
if (changed) if (changed)
db.SaveChanges(); db.SaveChanges();
} }
private static void EnsureSpainManualExcelSite(AppDbContext db)
{
if (db.Sites.Count() <= 1)
return;
var existing = db.Sites
.OrderBy(x => x.Id)
.FirstOrDefault(x =>
x.TSC == "TRSE" ||
x.TSC == "TRES" ||
x.Land == "Spanien" ||
x.Land == "Spain");
if (existing is not null)
{
var changed = false;
if (string.IsNullOrWhiteSpace(existing.TSC))
{
existing.TSC = "TRES";
changed = true;
}
if (string.IsNullOrWhiteSpace(existing.Land))
{
existing.Land = "Spanien";
changed = true;
}
if (string.IsNullOrWhiteSpace(existing.SourceSystem))
{
existing.SourceSystem = "MANUAL_EXCEL";
changed = true;
}
if (changed)
db.SaveChanges();
return;
}
db.Sites.Add(new Site
{
Schema = string.Empty,
TSC = "TRES",
Land = "Spanien",
SourceSystem = "MANUAL_EXCEL",
IsActive = false
});
db.SaveChanges();
}
private static void EnsureGermanyManualExcelSite(AppDbContext db)
{
if (db.Sites.Count() <= 1)
return;
var existing = db.Sites
.OrderBy(x => x.Id)
.FirstOrDefault(x =>
x.TSC == "TRDE" ||
x.Land == "Deutschland" ||
x.Land == "Germany");
if (existing is null)
{
existing = new Site
{
Schema = string.Empty,
TSC = "TRDE",
Land = "Deutschland",
SourceSystem = "MANUAL_EXCEL",
IsActive = false
};
db.Sites.Add(existing);
db.SaveChanges();
}
else
{
var changed = false;
if (string.IsNullOrWhiteSpace(existing.TSC))
{
existing.TSC = "TRDE";
changed = true;
}
if (string.IsNullOrWhiteSpace(existing.Land))
{
existing.Land = "Deutschland";
changed = true;
}
if (string.IsNullOrWhiteSpace(existing.SourceSystem))
{
existing.SourceSystem = "MANUAL_EXCEL";
changed = true;
}
if (changed)
db.SaveChanges();
}
if (CanSeedSiteDependentTable(db, "ManualExcelColumnMappings"))
EnsureGermanyManualExcelMapping(db, existing.Id);
}
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.Contains("/England", StringComparison.OrdinalIgnoreCase)))
{
existing.ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1";
changed = true;
}
if (changed)
db.SaveChanges();
if (CanSeedSiteDependentTable(db, "ManualExcelColumnMappings"))
EnsureUkManualExcelMapping(db, existing.Id);
}
private static bool CanSeedSiteDependentTable(AppDbContext db, string tableName)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, tableName);
if (columns.Count == 0)
return false;
return !DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old") &&
!DatabaseSchemaTools.TableReferencesObsoleteTable(conn, tableName, "Sites");
}
private static void EnsureUkManualExcelMapping(AppDbContext db, int siteId)
{
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.Tsc), "TSC", false),
(nameof(SalesRecord.Land), "Land", false),
(nameof(SalesRecord.InvoiceNumber), "Invoice Number", true),
(nameof(SalesRecord.PositionOnInvoice), "Position on invoice", false),
(nameof(SalesRecord.Material), "Material", false),
(nameof(SalesRecord.Name), "Name", false),
(nameof(SalesRecord.ProductGroup), "Product Group", false),
(nameof(SalesRecord.Quantity), "Quantity", true),
(nameof(SalesRecord.CustomerNumber), "Customer number", false),
(nameof(SalesRecord.CustomerName), "Customer name", false),
(nameof(SalesRecord.CustomerCountry), "Customer country", false),
(nameof(SalesRecord.SalesPriceValue), "=SageNetSales([Sales Price/Value], [Quantity], [Document Type], [DocumentType], [Type])", true),
(nameof(SalesRecord.SalesCurrency), "=GBP", false),
(nameof(SalesRecord.DocumentCurrency), "=GBP", false),
(nameof(SalesRecord.CompanyCurrency), "=GBP", false),
(nameof(SalesRecord.PostingDate), "invoice date", false),
(nameof(SalesRecord.InvoiceDate), "invoice date", false),
(nameof(SalesRecord.DocumentType), "Document Type", false)
};
var changed = false;
for (var i = 0; i < mappings.Length; i++)
{
var mapping = db.ManualExcelColumnMappings
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.TargetField == mappings[i].Target);
if (mapping is null)
{
db.ManualExcelColumnMappings.Add(new ManualExcelColumnMapping
{
SiteId = siteId,
TargetField = mappings[i].Target,
SourceHeader = mappings[i].Source,
IsRequired = mappings[i].Required,
IsActive = true,
SortOrder = i
});
changed = true;
continue;
}
if (mapping.SourceHeader != mappings[i].Source)
{
mapping.SourceHeader = mappings[i].Source;
changed = true;
}
if (mapping.IsRequired != mappings[i].Required)
{
mapping.IsRequired = mappings[i].Required;
changed = true;
}
if (!mapping.IsActive)
{
mapping.IsActive = true;
changed = true;
}
if (mapping.SortOrder != i)
{
mapping.SortOrder = i;
changed = true;
}
}
if (changed)
db.SaveChanges();
}
private static void EnsureGermanyManualExcelMapping(AppDbContext db, int siteId)
{
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.ExtractionDate), "Export-Datum", false),
(nameof(SalesRecord.Tsc), "=TRDE", false),
(nameof(SalesRecord.Land), "=Deutschland", false),
(nameof(SalesRecord.InvoiceNumber), "Belegnummer", true),
(nameof(SalesRecord.PositionOnInvoice), "Position", false),
(nameof(SalesRecord.Material), "ArtikelNummer", false),
(nameof(SalesRecord.Name), "ArtikelBezeichnung", false),
(nameof(SalesRecord.ProductGroup), "Warengruppen-Bezeichnung", false),
(nameof(SalesRecord.Quantity), "Anz. VE", false),
(nameof(SalesRecord.SupplierNumber), "Lieferanten Nummer", false),
(nameof(SalesRecord.SupplierName), "Name Lieferant", false),
(nameof(SalesRecord.SupplierCountry), "Land Lieferant", false),
(nameof(SalesRecord.CustomerNumber), "AdressNummer-Kunde", false),
(nameof(SalesRecord.CustomerName), "Name Kunde", false),
(nameof(SalesRecord.CustomerCountry), "Land Kunde", false),
(nameof(SalesRecord.CustomerIndustry), "Branche", false),
(nameof(SalesRecord.StandardCost), "EinstandsPreis", false),
(nameof(SalesRecord.StandardCostCurrency), "W\u00e4hrung", false),
(nameof(SalesRecord.SalesPriceValue), "NettoPreisGesamtX", true),
(nameof(SalesRecord.SalesCurrency), "W\u00e4hrung", false),
(nameof(SalesRecord.DocumentCurrency), "W\u00e4hrung", false),
(nameof(SalesRecord.CompanyCurrency), "W\u00e4hrung", false),
(nameof(SalesRecord.Incoterms2020), "Versandbedingung", false),
(nameof(SalesRecord.SalesResponsibleEmployee), "AdressNummer_V", false),
(nameof(SalesRecord.PostingDate), "Belegdatum-Rechnung", false),
(nameof(SalesRecord.InvoiceDate), "Belegdatum-Rechnung", false),
(nameof(SalesRecord.OrderDate), "BelegDatum Auftrag", false),
(nameof(SalesRecord.DocumentType), "=Alphaplan Excel", false)
};
var changed = false;
for (var i = 0; i < mappings.Length; i++)
{
var mapping = db.ManualExcelColumnMappings
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.TargetField == mappings[i].Target);
if (mapping is null)
{
db.ManualExcelColumnMappings.Add(new ManualExcelColumnMapping
{
SiteId = siteId,
TargetField = mappings[i].Target,
SourceHeader = mappings[i].Source,
IsRequired = mappings[i].Required,
IsActive = true,
SortOrder = i
});
changed = true;
continue;
}
if (mapping.SourceHeader != mappings[i].Source)
{
mapping.SourceHeader = mappings[i].Source;
changed = true;
}
if (mapping.IsRequired != mappings[i].Required)
{
mapping.IsRequired = mappings[i].Required;
changed = true;
}
if (!mapping.IsActive)
{
mapping.IsActive = true;
changed = true;
}
if (mapping.SortOrder != i)
{
mapping.SortOrder = i;
changed = true;
}
}
if (changed)
db.SaveChanges();
}
private static void EnsureSapODataDachSite(AppDbContext db)
{
if (db.Sites.Count() <= 1)
return;
var existing = db.Sites
.OrderBy(x => x.Id)
.FirstOrDefault(x =>
x.TSC == "ZSCHWEIZ" ||
x.Land == "Schweiz/Oesterreich" ||
x.Land == "DACH");
if (existing is not null)
{
var changed = false;
if (string.IsNullOrWhiteSpace(existing.TSC))
{
existing.TSC = "ZSCHWEIZ";
changed = true;
}
if (string.IsNullOrWhiteSpace(existing.Land))
{
existing.Land = "Schweiz/Oesterreich";
changed = true;
}
if (string.IsNullOrWhiteSpace(existing.SourceSystem) ||
string.Equals(existing.SourceSystem, "SAP_HANA", StringComparison.OrdinalIgnoreCase))
{
existing.SourceSystem = "SAP";
changed = true;
}
if (changed)
db.SaveChanges();
EnsureSapODataDachMapping(db, existing.Id);
return;
}
var site = new Site
{
Schema = string.Empty,
TSC = "ZSCHWEIZ",
Land = "Schweiz/Oesterreich",
SourceSystem = "SAP",
IsActive = false
};
db.Sites.Add(site);
db.SaveChanges();
EnsureSapODataDachMapping(db, site.Id);
}
private static void EnsureSapODataDachMapping(AppDbContext db, int siteId)
{
var changed = false;
var source = db.SapSourceDefinitions
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.Alias == "Z");
if (source is null)
{
db.SapSourceDefinitions.Add(new SapSourceDefinition
{
SiteId = siteId,
Alias = "Z",
EntitySet = "FinanzdataSchweizOeSet",
IsPrimary = true,
IsActive = true,
SortOrder = 0
});
changed = true;
}
else
{
if (source.EntitySet != "FinanzdataSchweizOeSet")
{
source.EntitySet = "FinanzdataSchweizOeSet";
changed = true;
}
if (!source.IsPrimary)
{
source.IsPrimary = true;
changed = true;
}
if (!source.IsActive)
{
source.IsActive = true;
changed = true;
}
if (source.SortOrder != 0)
{
source.SortOrder = 0;
changed = true;
}
}
var obsoleteSources = db.SapSourceDefinitions
.Where(x => x.SiteId == siteId && x.Alias != "Z")
.ToList();
foreach (var obsoleteSource in obsoleteSources)
{
if (obsoleteSource.IsActive)
{
obsoleteSource.IsActive = false;
changed = true;
}
if (obsoleteSource.IsPrimary)
{
obsoleteSource.IsPrimary = false;
changed = true;
}
}
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.Tsc), "Z.Tsc", true),
(nameof(SalesRecord.Land), "Z.Land1", true),
(nameof(SalesRecord.DocumentEntry), "Z.Vbeln", false),
(nameof(SalesRecord.InvoiceNumber), "Z.Vbeln", true),
(nameof(SalesRecord.PositionOnInvoice), "Z.Posnr", true),
(nameof(SalesRecord.PostingDate), "Z.Fkdat", true),
(nameof(SalesRecord.InvoiceDate), "Z.Fkdat", true),
(nameof(SalesRecord.Material), "Z.Matnr", false),
(nameof(SalesRecord.Name), "Z.Arktx", false),
(nameof(SalesRecord.ProductGroup), "Z.Prodh", false),
(nameof(SalesRecord.Quantity), "Z.Fkimg", false),
(nameof(SalesRecord.CustomerNumber), "Z.Kunnr", false),
(nameof(SalesRecord.CustomerName), "Z.Name1", false),
(nameof(SalesRecord.CustomerCountry), "Z.CustomerLand", false),
(nameof(SalesRecord.StandardCost), "=0", false),
(nameof(SalesRecord.StandardCostCurrency), "Z.Hwaer", false),
(nameof(SalesRecord.SalesPriceValue), "Z.NetwrHc", true),
(nameof(SalesRecord.SalesCurrency), "Z.Hwaer", true),
(nameof(SalesRecord.DocumentCurrency), "Z.Waerk", false),
(nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NetwrDc", false),
(nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NetwrHc", false),
(nameof(SalesRecord.VatSumForeignCurrency), "=0", false),
(nameof(SalesRecord.VatSumLocalCurrency), "=0", false),
(nameof(SalesRecord.DocumentRate), "Z.Kurrf", false),
(nameof(SalesRecord.CompanyCurrency), "Z.Hwaer", true),
(nameof(SalesRecord.DocumentType), "Z.Fkart", false)
};
for (var i = 0; i < mappings.Length; i++)
{
var mapping = db.SapFieldMappings
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.TargetField == mappings[i].Target);
if (mapping is null)
{
db.SapFieldMappings.Add(new SapFieldMapping
{
SiteId = siteId,
TargetField = mappings[i].Target,
SourceExpression = mappings[i].Source,
IsRequired = mappings[i].Required,
IsActive = true,
SortOrder = i
});
changed = true;
continue;
}
if (mapping.SourceExpression != mappings[i].Source)
{
mapping.SourceExpression = mappings[i].Source;
changed = true;
}
if (mapping.IsRequired != mappings[i].Required)
{
mapping.IsRequired = mappings[i].Required;
changed = true;
}
if (!mapping.IsActive)
{
mapping.IsActive = true;
changed = true;
}
if (mapping.SortOrder != i)
{
mapping.SortOrder = i;
changed = true;
}
}
if (changed)
db.SaveChanges();
}
private static void EnsureFinanceReferenceDefaults(AppDbContext db)
{
var defaults = new[]
{
new FinanceReference { Key = "AT", Label = "Trafag AT", Year = 2025, LocalCurrencyValue = 3443863m },
new FinanceReference { Key = "CH", Label = "Trafag CH", Year = 2025 },
new FinanceReference { Key = "CN", Label = "Trafag CN", Year = 2025 },
new FinanceReference { Key = "CZ", Label = "Trafag CZ", Year = 2025, LocalCurrencyValue = 95458782m },
new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, LocalCurrencyValue = 3652394.46m },
new FinanceReference { Key = "ES", Label = "Trafag ES", Year = 2025, LocalCurrencyValue = 3102333.61m },
new FinanceReference { Key = "FR", Label = "Trafag FR", Year = 2025, LocalCurrencyValue = 1450582m, CheckValue = 1471218m },
new FinanceReference { Key = "GFS", Label = "Trafag GfS", Year = 2025, LocalCurrencyValue = 6495513m },
new FinanceReference { Key = "IN", Label = "Trafag IN", Year = 2025, LocalCurrencyValue = 747341702m, CheckValue = 750936591m },
new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, LocalCurrencyValue = 7669840m },
new FinanceReference { Key = "JP", Label = "Trafag JP", Year = 2025, LocalCurrencyValue = 187739814m },
new FinanceReference { Key = "MS", Label = "Trafag MS", Year = 2025, LocalCurrencyValue = 1850199m },
new FinanceReference { Key = "MSA", Label = "Trafag MSA", Year = 2025, LocalCurrencyValue = 1445258m },
new FinanceReference { Key = "PL", Label = "Trafag PL Poltraf", Year = 2025, LocalCurrencyValue = 11279297m },
new FinanceReference { Key = "RU", Label = "Trafag RU", Year = 2025 },
new FinanceReference { Key = "UK", Label = "Trafag UK", Year = 2025, LocalCurrencyValue = 3538972m },
new FinanceReference { Key = "US", Label = "Trafag US", Year = 2025, LocalCurrencyValue = 3896728m, CheckValue = 3749865m }
};
var existing = db.FinanceReferences.ToList();
var changed = false;
foreach (var item in defaults)
{
var current = existing.FirstOrDefault(x => x.Year == item.Year && x.Key == item.Key);
if (current is not null)
{
if (current.Key == "UK" && current.Year == 2025)
{
if (current.LocalCurrencyValue != 3538972m)
{
current.LocalCurrencyValue = 3538972m;
changed = true;
}
if (current.CheckValue.HasValue)
{
current.CheckValue = null;
changed = true;
}
}
if (current.Key == "ES" && current.Year == 2025 && current.LocalCurrencyValue != 3102333.61m)
{
current.LocalCurrencyValue = 3102333.61m;
changed = true;
}
if (current.Key == "DE" && current.Year == 2025 && current.LocalCurrencyValue != 3652394.46m)
{
current.LocalCurrencyValue = 3652394.46m;
changed = true;
}
continue;
}
db.FinanceReferences.Add(item);
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureBudgetExchangeRateDefaults(AppDbContext db)
{
var defaults = new (string From, string To, decimal Rate)[]
{
("CHF", "CHF", 1m),
("USD", "CHF", 0.85m),
("EUR", "CHF", 0.95m),
("GBP", "CHF", 1.13m),
("CNY", "CHF", 1m / 8.50m),
("INR", "CHF", 1m / 90.91m),
("CZK", "CHF", 1m / 25.64m),
("PLN", "CHF", 0.22m),
("JPY", "CHF", 1m / 156.25m)
};
var changed = false;
foreach (var item in defaults)
{
var exists = db.CurrencyExchangeRates.Any(x =>
x.FromCurrency == item.From &&
x.ToCurrency == item.To &&
x.ValidFrom == new DateTime(2025, 1, 1) &&
x.Notes == "Budget 2025");
if (exists)
continue;
db.CurrencyExchangeRates.Add(new CurrencyExchangeRate
{
FromCurrency = item.From,
ToCurrency = item.To,
Rate = item.Rate,
ValidFrom = new DateTime(2025, 1, 1),
ValidTo = new DateTime(2025, 12, 31),
Notes = "Budget 2025",
IsActive = true
});
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureFinanceIntercompanyRuleDefaults(AppDbContext db)
{
var defaults = new[]
{
new FinanceIntercompanyRule { CustomerNameContains = "TRAFAG", Notes = "Default IC name marker" },
new FinanceIntercompanyRule { CustomerNameContains = "MAGNETIC SENSE", Notes = "Default IC name marker" },
new FinanceIntercompanyRule { CustomerNameContains = "MAGNETS SENSE", Notes = "Default IC name marker" },
new FinanceIntercompanyRule { CustomerNameContains = "GESELLSCHAFT FUER SENSORIK", Notes = "Default IC name marker" },
new FinanceIntercompanyRule { CustomerNameContains = "GESELLSCHAFT FUR SENSORIK", Notes = "Default IC name marker" },
new FinanceIntercompanyRule { ScopeKey = "IT", CustomerNumber = "C_IT01_0306794", Notes = "IT IC customer number" },
new FinanceIntercompanyRule { ScopeKey = "IT", CustomerNumber = "C_CH01_0302179", Notes = "IT IC customer number" }
};
var changed = false;
foreach (var item in defaults)
{
var exists = db.FinanceIntercompanyRules.Any(x =>
x.ScopeKey == item.ScopeKey &&
x.CustomerNumber == item.CustomerNumber &&
x.CustomerNameContains == item.CustomerNameContains);
if (exists)
continue;
db.FinanceIntercompanyRules.Add(item);
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureFinanceRuleDefaults(AppDbContext db)
{
if (!CanUseTable(db, "FinanceRules"))
return;
var changed = false;
foreach (var item in FinanceRuleEngine.CreateDefaultRules())
{
var exists = db.FinanceRules.Any(rule =>
rule.ScopeKey == item.ScopeKey &&
rule.RuleType == item.RuleType &&
rule.FieldName == item.FieldName &&
rule.MatchType == item.MatchType &&
rule.MatchValue == item.MatchValue);
if (exists)
continue;
db.FinanceRules.Add(item);
changed = true;
}
if (changed)
db.SaveChanges();
}
private static bool CanUseTable(AppDbContext db, string tableName)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
return DatabaseSchemaTools.GetTableColumns(conn, transaction: null, tableName).Count > 0;
}
} }
@@ -1,16 +1,29 @@
using ClosedXML.Excel; using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class ExcelExportService : IExcelExportService public class ExcelExportService : IExcelExportService
{ {
private readonly IDbContextFactory<AppDbContext>? _dbFactory;
public ExcelExportService()
{
}
public ExcelExportService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records) public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{ {
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx"; var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName); var fullPath = Path.Combine(outputDirectory, fileName);
WriteWorkbook(fullPath, records); WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: false);
return fullPath; return fullPath;
} }
@@ -19,7 +32,7 @@ public class ExcelExportService : IExcelExportService
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx"; var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName); var fullPath = Path.Combine(outputDirectory, fileName);
WriteWorkbook(fullPath, records); WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: true);
return fullPath; return fullPath;
} }
@@ -33,15 +46,39 @@ public class ExcelExportService : IExcelExportService
return fullPath; return fullPath;
} }
private static void WriteWorkbook(string fullPath, List<SalesRecord> records) private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
=> WriteWorkbook(fullPath, records, includeFinanceHelpSheet, FinanceRuleEngine.CreateDefaultRules());
private void WriteWorkbookWithConfiguredRules(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
=> WriteWorkbook(fullPath, records, includeFinanceHelpSheet, LoadFinanceRules());
private IReadOnlyList<FinanceRule> LoadFinanceRules()
{
if (_dbFactory is null)
return FinanceRuleEngine.CreateDefaultRules();
using var db = _dbFactory.CreateDbContext();
var rules = db.FinanceRules
.AsNoTracking()
.Where(rule => rule.IsActive)
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToList();
return rules.Count == 0 ? FinanceRuleEngine.CreateDefaultRules() : rules;
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet, IReadOnlyList<FinanceRule> financeRules)
{ {
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales"); var ws = workbook.Worksheets.Add("Sales");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var headers = new[] var headers = new[]
{ {
"extraction date", "extraction date",
"TSC", "TSC",
"Document Entry",
"Invoice Number", "Invoice Number",
"Position on invoice", "Position on invoice",
"Material", "Material",
@@ -60,12 +97,27 @@ public class ExcelExportService : IExcelExportService
"Purchase Order number", "Purchase Order number",
"Sales Price/Value", "Sales Price/Value",
"Sales Currency", "Sales Currency",
"Document Currency",
"Document Total FC",
"Document Total LC",
"VAT Sum FC",
"VAT Sum LC",
"Document Rate",
"Company Currency",
"Incoterms 2020", "Incoterms 2020",
"Sales responsible employee", "Sales responsible employee",
"posting date",
"invoice date", "invoice date",
"order date", "order date",
"Land", "Land",
"Document Type" "Document Type",
"Finance | Year",
"Finance | Country Key",
"Finance | Date",
"Finance | Net Sales Actual",
"Finance | Currency",
"Finance | Include",
"Finance | Source Value Field"
}; };
for (var i = 0; i < headers.Length; i++) for (var i = 0; i < headers.Length; i++)
@@ -79,37 +131,239 @@ public class ExcelExportService : IExcelExportService
{ {
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss"); ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc; ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = record.InvoiceNumber; ws.Cell(row, 3).Value = record.DocumentEntry;
ws.Cell(row, 4).Value = record.PositionOnInvoice; ws.Cell(row, 4).Value = record.InvoiceNumber;
ws.Cell(row, 5).Value = record.Material; ws.Cell(row, 5).Value = record.PositionOnInvoice;
ws.Cell(row, 6).Value = record.Name; ws.Cell(row, 6).Value = record.Material;
ws.Cell(row, 7).Value = record.ProductGroup; ws.Cell(row, 7).Value = record.Name;
ws.Cell(row, 8).Value = record.Quantity; ws.Cell(row, 8).Value = record.ProductGroup;
ws.Cell(row, 9).Value = record.SupplierNumber; ws.Cell(row, 9).Value = record.Quantity;
ws.Cell(row, 10).Value = record.SupplierName; ws.Cell(row, 10).Value = record.SupplierNumber;
ws.Cell(row, 11).Value = record.SupplierCountry; ws.Cell(row, 11).Value = record.SupplierName;
ws.Cell(row, 12).Value = record.CustomerNumber; ws.Cell(row, 12).Value = record.SupplierCountry;
ws.Cell(row, 13).Value = record.CustomerName; ws.Cell(row, 13).Value = record.CustomerNumber;
ws.Cell(row, 14).Value = record.CustomerCountry; ws.Cell(row, 14).Value = record.CustomerName;
ws.Cell(row, 15).Value = record.CustomerIndustry; ws.Cell(row, 15).Value = record.CustomerCountry;
ws.Cell(row, 16).Value = record.StandardCost; ws.Cell(row, 16).Value = record.CustomerIndustry;
ws.Cell(row, 17).Value = record.StandardCostCurrency; ws.Cell(row, 17).Value = record.StandardCost;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber; ws.Cell(row, 18).Value = record.StandardCostCurrency;
ws.Cell(row, 19).Value = record.SalesPriceValue; ws.Cell(row, 19).Value = record.PurchaseOrderNumber;
ws.Cell(row, 20).Value = record.SalesCurrency; ws.Cell(row, 20).Value = record.SalesPriceValue;
ws.Cell(row, 21).Value = record.Incoterms2020; ws.Cell(row, 21).Value = record.SalesCurrency;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee; ws.Cell(row, 22).Value = record.DocumentCurrency;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 23).Value = record.DocumentTotalForeignCurrency;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 24).Value = record.DocumentTotalLocalCurrency;
ws.Cell(row, 25).Value = record.Land; ws.Cell(row, 25).Value = record.VatSumForeignCurrency;
ws.Cell(row, 26).Value = record.DocumentType; ws.Cell(row, 26).Value = record.VatSumLocalCurrency;
ws.Cell(row, 27).Value = record.DocumentRate;
ws.Cell(row, 28).Value = record.CompanyCurrency;
ws.Cell(row, 29).Value = record.Incoterms2020;
ws.Cell(row, 30).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 31).Value = record.PostingDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 34).Value = record.Land;
ws.Cell(row, 35).Value = record.DocumentType;
var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, financeCountryKey);
var financeInclude = financeRuleEngine.ShouldInclude(record, financeCountryKey);
var financeNetSalesActual = financeRuleEngine.ResolveNetSalesActual(record, financeCountryKey, financeInclude);
ws.Cell(row, 36).Value = financeDate.Year;
ws.Cell(row, 37).Value = financeCountryKey;
ws.Cell(row, 38).Value = financeDate.ToString("dd.MM.yyyy");
ws.Cell(row, 39).Value = financeNetSalesActual;
ws.Cell(row, 40).Value = ResolveFinanceCurrency(record);
ws.Cell(row, 41).Value = financeInclude && financeNetSalesActual != 0m ? "TRUE" : "FALSE";
ws.Cell(row, 42).Value = financeInclude
? "Sales Price/Value"
: financeRuleEngine.ResolveExclusionReason(record, financeCountryKey);
row++; row++;
} }
ws.Columns().AdjustToContents(); ws.Columns().AdjustToContents();
if (includeFinanceHelpSheet)
{
AddFinanceSummarySheet(workbook, records, financeRules);
AddFinanceHelpSheet(workbook);
}
workbook.SaveAs(fullPath); workbook.SaveAs(fullPath);
} }
private static void AddFinanceSummarySheet(XLWorkbook workbook, List<SalesRecord> records, IReadOnlyList<FinanceRule> financeRules)
{
var ws = workbook.Worksheets.Add("Finance Summary");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
ws.Position = 1;
ws.Cell(1, 1).Value = "Finance Summary";
ws.Cell(1, 1).Style.Font.Bold = true;
ws.Cell(1, 1).Style.Font.FontSize = 14;
ws.Cell(2, 1).Value = "Diese Summen verwenden dieselbe Finance-Sicht wie die Spalten Finance | ... im Blatt Sales.";
var headers = new[]
{
"Year",
"Country Key",
"Currency",
"Included Rows",
"Net Sales Actual",
"Excluded Rows",
"Hinweis"
};
for (var i = 0; i < headers.Length; i++)
{
ws.Cell(4, i + 1).Value = headers[i];
ws.Cell(4, i + 1).Style.Font.Bold = true;
}
var summaryRows = records
.Select(record =>
{
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, countryKey);
var rawInclude = financeRuleEngine.ShouldInclude(record, countryKey);
var value = financeRuleEngine.ResolveNetSalesActual(record, countryKey, rawInclude);
var include = rawInclude && value != 0m;
return new
{
Year = financeDate.Year,
CountryKey = countryKey,
Currency = ResolveFinanceCurrency(record),
Include = include,
Value = value
};
})
.GroupBy(row => new { row.Year, row.CountryKey, row.Currency })
.OrderBy(group => group.Key.Year)
.ThenBy(group => group.Key.CountryKey, StringComparer.OrdinalIgnoreCase)
.ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase)
.Select(group => new
{
group.Key.Year,
group.Key.CountryKey,
group.Key.Currency,
IncludedRows = group.Count(row => row.Include),
NetSalesActual = group.Sum(row => row.Value),
ExcludedRows = group.Count(row => !row.Include)
})
.ToList();
var rowIndex = 5;
foreach (var row in summaryRows)
{
ws.Cell(rowIndex, 1).Value = row.Year;
ws.Cell(rowIndex, 2).Value = row.CountryKey;
ws.Cell(rowIndex, 3).Value = row.Currency;
ws.Cell(rowIndex, 4).Value = row.IncludedRows;
ws.Cell(rowIndex, 5).Value = row.NetSalesActual;
ws.Cell(rowIndex, 6).Value = row.ExcludedRows;
ws.Cell(rowIndex, 7).Value = BuildFinanceSummaryHint(row.CountryKey);
rowIndex++;
}
ws.Column(5).Style.NumberFormat.Format = "#,##0.00";
ws.Columns().AdjustToContents();
}
private static string BuildFinanceSummaryHint(string countryKey)
=> countryKey.ToUpperInvariant() switch
{
"DE" => "DE Alphaplan Jahresfile 2025: Weiterberechnungen ausgeschlossen; GS negativ, GS2510095 2024.",
"IT" => "IT: Trafag Italia ausgeschlossen; doppelte Blank-Supplier-Zeilen nur einmal.",
"UK" => "UK: Sage/Manual Excel, Credit Notes negativ.",
"ES" => "ES: Sage CSV/Manual Excel, REC/Credit Notes negativ.",
_ => string.Empty
};
private static void AddFinanceHelpSheet(XLWorkbook workbook)
{
var ws = workbook.Worksheets.Add("Finance Filter Hilfe");
ws.Cell(1, 1).Value = "Finance-Filter fuer Soll/Ist-Abgleich";
ws.Cell(1, 1).Style.Font.Bold = true;
ws.Cell(1, 1).Style.Font.FontSize = 14;
var rows = new (string Label, string Value)[]
{
("Ziel", "Diese Spalten bilden im Blatt Sales die zusammengehoerige Finance-Sicht fuer den Abgleich gegen check.xlsx."),
("1. Jahr filtern", "Finance | Year = gewuenschtes Jahr, z.B. 2025"),
("2. Land filtern", "Finance | Country Key = CH, AT, DE, ES, FR, IN, IT, UK oder US"),
("3. Gueltige Zeilen filtern", "Finance | Include = TRUE"),
("4. Summe bilden", "Finance | Net Sales Actual summieren"),
("Waehrung", "Finance | Currency zeigt die fuer den Finance-Abgleich fuehrende Hauswaehrung."),
("Datum", "Finance | Date verwendet PostingDate, danach InvoiceDate, danach ExtractionDate. DE Alphaplan wird als Jahresfile 2025 behandelt."),
("Wertquelle", "Finance | Source Value Field zeigt, aus welchem Rohfeld der Finance-Wert kommt."),
("DE-Sonderregel", "Fuer DE gilt die Deutschland-Rueckmeldung: Trafag AG und Magnetic Sense ausgeschlossen, GS-Gutschriften negativ, GS2510095 nicht in 2025."),
("IT-Sonderregel", "Fuer IT wird Trafag Italia im Finance-Wert ausgeschlossen; doppelte IT-Zeilen ohne Supplier country werden nur einmal gezaehlt."),
("Nicht verwenden", "Nicht Land, TSC, Document Total LC oder andere Betragsspalten fuer den CFO-Abgleich erraten."),
("Hinweis", "Offene fachliche Differenzen bleiben sichtbar; diese Excel-Sicht soll die gleiche Ist-Summe wie das Testprogramm reproduzieren.")
};
ws.Cell(3, 1).Value = "Feld";
ws.Cell(3, 2).Value = "Anwendung";
ws.Range(3, 1, 3, 2).Style.Font.Bold = true;
for (var i = 0; i < rows.Length; i++)
{
ws.Cell(i + 4, 1).Value = rows[i].Label;
ws.Cell(i + 4, 2).Value = rows[i].Value;
}
var financeColumns = new[]
{
"Finance | Year",
"Finance | Country Key",
"Finance | Date",
"Finance | Net Sales Actual",
"Finance | Currency",
"Finance | Include",
"Finance | Source Value Field"
};
var startRow = rows.Length + 6;
ws.Cell(startRow, 1).Value = "Zusammengehoerige Spalten im Blatt Sales";
ws.Cell(startRow, 1).Style.Font.Bold = true;
for (var i = 0; i < financeColumns.Length; i++)
{
ws.Cell(startRow + 1 + i, 1).Value = financeColumns[i];
}
ws.Columns().AdjustToContents();
}
private static string ResolveFinanceCurrency(SalesRecord record)
=> ResolveFinanceCountryKey(record.Land, record.Tsc) switch
{
"CH" => "CHF",
"AT" => "EUR",
"DE" => "EUR",
"ES" => "EUR",
"FR" => "EUR",
"IN" => "INR",
"IT" => "EUR",
"UK" => "GBP",
"US" => "USD",
_ => string.IsNullOrWhiteSpace(record.CompanyCurrency) ? record.SalesCurrency : record.CompanyCurrency
};
private static string ResolveFinanceCountryKey(string land, string tsc)
{
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";
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";
return normalizedTsc.Replace("TR", string.Empty);
}
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows) private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{ {
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
@@ -7,16 +7,25 @@ namespace TrafagSalesExporter.Services;
public class ExportLogService : IExportLogService public class ExportLogService : IExportLogService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ILogger<ExportLogService> _logger;
public ExportLogService(IDbContextFactory<AppDbContext> dbFactory) public ExportLogService(IDbContextFactory<AppDbContext> dbFactory, ILogger<ExportLogService> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_logger = logger;
} }
public async Task WriteAsync(ExportLog log) public async Task WriteAsync(ExportLog log)
{ {
using var db = await _dbFactory.CreateDbContextAsync(); try
db.ExportLogs.Add(log); {
await db.SaveChangesAsync(); using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log);
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "ExportLog konnte nicht gespeichert werden: {Land} ({TSC})", log.Land, log.TSC);
}
} }
} }
@@ -10,6 +10,7 @@ public class ExportOrchestrationService
private readonly ISiteExportService _siteExportService; private readonly ISiteExportService _siteExportService;
private readonly IConsolidatedExportService _consolidatedExportService; private readonly IConsolidatedExportService _consolidatedExportService;
private readonly IExportLogService _exportLogService; private readonly IExportLogService _exportLogService;
private readonly IAppEventLogService _appEventLogService;
public event Action? OnExportStatusChanged; public event Action? OnExportStatusChanged;
@@ -22,12 +23,14 @@ public class ExportOrchestrationService
IDbContextFactory<AppDbContext> dbFactory, IDbContextFactory<AppDbContext> dbFactory,
ISiteExportService siteExportService, ISiteExportService siteExportService,
IConsolidatedExportService consolidatedExportService, IConsolidatedExportService consolidatedExportService,
IExportLogService exportLogService) IExportLogService exportLogService,
IAppEventLogService appEventLogService)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_siteExportService = siteExportService; _siteExportService = siteExportService;
_consolidatedExportService = consolidatedExportService; _consolidatedExportService = consolidatedExportService;
_exportLogService = exportLogService; _exportLogService = exportLogService;
_appEventLogService = appEventLogService;
} }
public bool IsExporting(int siteId) public bool IsExporting(int siteId)
@@ -66,45 +69,40 @@ public class ExportOrchestrationService
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var consolidatedRecords = new List<SalesRecord>();
foreach (var site in sites) foreach (var site in sites)
{ await ExportSiteAsync(site);
var result = await ExportSiteAsync(site);
if (result?.Records is { Count: > 0 })
consolidatedRecords.AddRange(result.Records);
}
await RunConsolidatedExportAsync(consolidatedRecords); await RunConsolidatedExportAsync();
} }
public async Task<string?> ExportConsolidatedOnlyAsync() public async Task<string?> ExportConsolidatedOnlyAsync()
{ {
return await RunConsolidatedExportAsync(null); return await RunConsolidatedExportAsync();
} }
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId) public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId, int? preferredImportYear = null)
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId); var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return null; if (site is null) return null;
return await ExportSiteAsync(site); return await ExportSiteAsync(site, preferredImportYear);
} }
private async Task<SiteExportResult?> ExportSiteAsync(Site site) private async Task<SiteExportResult?> ExportSiteAsync(Site site, int? preferredImportYear = null)
{ {
SiteExportResult? result = null; SiteExportResult? result = null;
lock (_lock) lock (_lock)
{ {
if (_runningExports.ContainsKey(site.Id)) return null; if (_runningExports.ContainsKey(site.Id)) return null;
_runningExports[site.Id] = "HANA Abfrage..."; _runningExports[site.Id] = BuildInitialExportStatus(site);
} }
NotifyChanged(); NotifyChanged();
try try
{ {
result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status)); result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status), preferredImportYear);
return result; return result;
} }
finally finally
@@ -136,7 +134,18 @@ public class ExportOrchestrationService
OnExportStatusChanged?.Invoke(); OnExportStatusChanged?.Invoke();
} }
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? records) private static string BuildInitialExportStatus(Site site)
{
var sourceSystem = (site.SourceSystem ?? string.Empty).Trim().ToUpperInvariant();
return sourceSystem switch
{
"MANUAL_EXCEL" => "Manuelle Excel/CSV lesen...",
"SAP" => "SAP OData lesen...",
_ => "Quelldaten lesen..."
};
}
private async Task<string?> RunConsolidatedExportAsync()
{ {
lock (_lock) lock (_lock)
{ {
@@ -150,7 +159,12 @@ public class ExportOrchestrationService
try try
{ {
return await _consolidatedExportService.ExportAsync(records ?? []); return await _consolidatedExportService.ExportAsync();
}
catch (Exception ex)
{
await _appEventLogService.WriteAsync("Export", "Zentrale Datei fehlgeschlagen", "Error", details: ex.ToString());
return null;
} }
finally finally
{ {
@@ -0,0 +1,74 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface IFinanceCockpitAccessService
{
bool IsEnabled { get; }
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
void Lock();
}
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
{
private readonly FinanceCockpitAccessOptions _options;
public FinanceCockpitAccessService(IOptions<FinanceCockpitAccessOptions> options)
{
_options = options.Value;
}
public bool IsEnabled => _options.Enabled;
public bool IsConfigured =>
!IsEnabled ||
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked { get; private set; }
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
IsUnlocked = true;
return true;
}
if (!IsConfigured ||
string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!FixedEquals(username.Trim(), _options.Username.Trim()))
{
return false;
}
var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash)
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
return valid;
}
public void Lock() => IsUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
return FixedEquals(passwordHash, configuredHash.Trim());
}
private static bool FixedEquals(string left, string right)
{
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);
return leftBytes.Length == rightBytes.Length &&
CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
}
}
@@ -0,0 +1,482 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IFinanceReconciliationService
{
Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025);
}
public sealed class FinanceReconciliationService : IFinanceReconciliationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var financeReferences = await db.FinanceReferences
.AsNoTracking()
.Where(r => r.IsActive && r.Year == year)
.OrderBy(r => r.Label)
.ToListAsync();
var budgetRatesToChf = await LoadBudgetRatesToChfAsync(db, year);
var intercompanyRules = await db.FinanceIntercompanyRules
.AsNoTracking()
.Where(r => r.IsActive)
.ToListAsync();
var financeRules = await db.FinanceRules
.AsNoTracking()
.Where(r => r.IsActive)
.OrderBy(r => r.SortOrder)
.ThenBy(r => r.Id)
.ToListAsync();
if (financeRules.Count == 0)
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var centralRecords = await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
var centralRows = centralRecords
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
.Where(row => row is not null)
.Select(row => row!)
.ToList();
var groupedActuals = centralRows
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => BuildNetSalesActual(g.Key, g, budgetRatesToChf, intercompanyRules),
StringComparer.OrdinalIgnoreCase);
return financeReferences
.Select(reference => BuildReferenceRow(reference, groupedActuals))
.OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static NetSalesReferenceRow BuildReferenceRow(
FinanceReference reference,
IReadOnlyDictionary<string, NetSalesActual> groupedActuals)
{
groupedActuals.TryGetValue(reference.Key, out var actual);
var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue;
var selected = actual?.Candidates
.OrderByDescending(candidate => candidate.IsPreferred)
.ThenByDescending(candidate => candidate.Key == "NetDocumentLocalCurrencyPosition")
.ThenByDescending(candidate => candidate.Key == "NetDocumentLocalCurrencyDocument")
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
.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 = "check.xlsx Soll",
ReferenceCurrency = reference.CheckValue.HasValue ? "Sollwert" : "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,
IsPreferred = candidate.IsPreferred,
Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null,
DifferenceExcludingIntercompany = referenceValue.HasValue
? candidate.ValueExcludingIntercompany - referenceValue.Value
: null
}).ToList() ?? []
};
}
private static async Task<IReadOnlyDictionary<string, decimal>> LoadBudgetRatesToChfAsync(AppDbContext db, int year)
{
var validFrom = new DateTime(year, 1, 1);
var rates = await db.CurrencyExchangeRates
.AsNoTracking()
.Where(r => r.IsActive && r.Notes == $"Budget {year}" && r.ValidFrom <= validFrom && (!r.ValidTo.HasValue || r.ValidTo >= validFrom))
.ToListAsync();
var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["CHF"] = 1m
};
foreach (var rate in rates)
{
if (rate.ToCurrency.Equals("CHF", StringComparison.OrdinalIgnoreCase))
result[rate.FromCurrency] = rate.Rate;
else if (rate.FromCurrency.Equals("CHF", StringComparison.OrdinalIgnoreCase) && rate.Rate != 0m)
result[rate.ToCurrency] = 1m / rate.Rate;
}
return result;
}
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
{
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
if (financeRuleEngine.ResolveFinanceDate(record, referenceKey).Year != year)
return null;
var include = financeRuleEngine.ShouldInclude(record, referenceKey);
if (!include)
return null;
var salesPriceValue = financeRuleEngine.ResolveNetSalesActual(record, referenceKey, include);
return new NetSalesActualSourceRow(
record.Land,
record.Tsc,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.DocumentType,
record.PostingDate,
record.InvoiceDate,
record.ExtractionDate,
record.CustomerNumber,
record.CustomerName,
record.SupplierCountry,
record.SalesCurrency,
record.DocumentCurrency,
record.CompanyCurrency,
salesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency);
}
private static NetSalesActual BuildNetSalesActual(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows,
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
{
var rowList = rows.ToList();
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
var documentRows = rowList
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var repeatedDocumentTotals = LooksLikeRepeatedDocumentTotals(rowList);
var salesPriceValue = rowList.Sum(row => row.SalesPriceValue);
var salesPriceIntercompanyValue = rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue);
var candidates = new List<NetSalesCandidate>
{
new(
"SalesPriceValue",
"Positions-Netto (Sales Price/Value)",
houseCurrency,
salesPriceValue,
salesPriceIntercompanyValue,
repeatedDocumentTotals && salesPriceValue != 0m)
};
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(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency),
false));
var positionNetDocumentLocalCurrency = rowList.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
if (positionNetDocumentLocalCurrency != 0m)
candidates.Add(new(
"NetDocumentLocalCurrencyPosition",
"Nettofakturawert Hauswaehrung pro Position",
houseCurrency,
positionNetDocumentLocalCurrency,
rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency),
!repeatedDocumentTotals));
var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
if (netDocumentLocalCurrency != 0m)
candidates.Add(new(
"NetDocumentLocalCurrencyDocument",
"Nettofakturawert Hauswaehrung pro Beleg dedupliziert",
houseCurrency,
netDocumentLocalCurrency,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency),
repeatedDocumentTotals && salesPriceValue == 0m));
var selectedNetRows = repeatedDocumentTotals ? documentRows : rowList;
var budgetChf = selectedNetRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(houseCurrency, row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf));
if (budgetChf != 0m)
candidates.Add(new(
"NetDocumentLocalCurrencyBudgetChf",
$"Nettofakturawert Hauswaehrung -> CHF Budget 2025 ({(repeatedDocumentTotals ? "Beleg" : "Position")})",
"CHF",
budgetChf,
selectedNetRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(houseCurrency, row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf)),
false));
return new NetSalesActual
{
RowCount = rowList.Count,
Currencies = houseCurrency,
Candidates = candidates
};
}
private static bool LooksLikeRepeatedDocumentTotals(IReadOnlyList<NetSalesActualSourceRow> rows)
{
var multiLineGroups = rows
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.ToList();
if (multiLineGroups.Count == 0)
return false;
var repeatedGroups = multiLineGroups.Count(group =>
group.Select(row => Math.Round(row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, 2))
.Distinct()
.Count() == 1);
return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m;
}
private static decimal ConvertHouseCurrencyNetToBudgetChf(
string houseCurrency,
NetSalesActualSourceRow row,
decimal value,
IReadOnlyDictionary<string, decimal> budgetRatesToChf)
{
var currency = !string.IsNullOrWhiteSpace(houseCurrency) && houseCurrency != "-"
? houseCurrency.Trim().ToUpperInvariant()
: (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m;
}
private static string ResolveHouseCurrency(string referenceKey, IReadOnlyList<NetSalesActualSourceRow> rows)
{
var configured = referenceKey.ToUpperInvariant() switch
{
"CH" => "CHF",
"AT" => "EUR",
"DE" => "EUR",
"ES" => "EUR",
"FR" => "EUR",
"IN" => "INR",
"IT" => "EUR",
"UK" => "GBP",
"US" => "USD",
_ => string.Empty
};
return string.IsNullOrWhiteSpace(configured)
? ResolveCurrencyLabel(rows.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency))
: configured;
}
private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList<FinanceIntercompanyRule> rules)
{
var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty;
var customerName = row.CustomerName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName))
return false;
var normalizedCustomerName = NormalizeRuleText(customerName);
var referenceKey = ResolveReferenceKey(row.Land, row.Tsc);
foreach (var rule in rules)
{
if (!string.IsNullOrWhiteSpace(rule.ScopeKey) &&
!rule.ScopeKey.Equals(referenceKey, StringComparison.OrdinalIgnoreCase) &&
!rule.ScopeKey.Equals(row.Tsc, StringComparison.OrdinalIgnoreCase))
continue;
if (!string.IsNullOrWhiteSpace(rule.CustomerNumber) &&
customerNumber.Equals(rule.CustomerNumber.Trim(), StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(rule.CustomerNameContains) &&
normalizedCustomerName.Contains(NormalizeRuleText(rule.CustomerNameContains), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string NormalizeRuleText(string value)
=> (value ?? string.Empty)
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToUpperInvariant();
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 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 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";
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";
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 bool IsPreferred { get; set; }
public decimal? Difference { get; set; }
public decimal? DifferenceExcludingIntercompany { get; set; }
}
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,
int PositionOnInvoice,
string Material,
string Name,
decimal Quantity,
string DocumentType,
DateTime? PostingDate,
DateTime? InvoiceDate,
DateTime ExtractionDate,
string CustomerNumber,
string CustomerName,
string SupplierCountry,
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, bool IsPreferred)
{
public decimal ValueExcludingIntercompany => Value - IntercompanyValue;
}
@@ -0,0 +1,238 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class FinanceRuleEngine
{
private readonly IReadOnlyList<FinanceRule> _rules;
private readonly Dictionary<string, HashSet<string>> _deduplicationKeys = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, PropertyInfo> SalesRecordProperties = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(x => x.Name, x => x, StringComparer.OrdinalIgnoreCase);
public FinanceRuleEngine(IEnumerable<FinanceRule> rules)
{
_rules = rules
.Where(rule => rule.IsActive)
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToList();
}
public DateTime ResolveFinanceDate(SalesRecord record, string countryKey)
{
var forceYear = _rules.FirstOrDefault(rule =>
IsRuleInScope(rule, countryKey) &&
rule.RuleType.Equals(FinanceRuleTypes.ForceYear, StringComparison.OrdinalIgnoreCase) &&
RuleMatches(rule, record));
if (forceYear?.Year is > 0)
return new DateTime(forceYear.Year.Value, 12, 31);
return record.PostingDate ?? record.InvoiceDate ?? record.ExtractionDate;
}
public bool ShouldInclude(SalesRecord record, string countryKey)
{
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!RuleMatches(rule, record))
continue;
if (rule.RuleType.Equals(FinanceRuleTypes.Exclude, StringComparison.OrdinalIgnoreCase))
return false;
if (rule.RuleType.Equals(FinanceRuleTypes.DeduplicateBlankSupplierCountry, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(record.SupplierCountry))
{
var seen = GetDeduplicationSet(rule, countryKey);
return seen.Add(BuildBlankSupplierCountryDeduplicationKey(record));
}
}
return true;
}
public decimal ResolveNetSalesActual(SalesRecord record, string countryKey, bool include)
{
if (!include)
return 0m;
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!rule.RuleType.Equals(FinanceRuleTypes.NegateAmount, StringComparison.OrdinalIgnoreCase) ||
!RuleMatches(rule, record))
continue;
return -Math.Abs(record.SalesPriceValue);
}
return record.SalesPriceValue;
}
public string ResolveExclusionReason(SalesRecord record, string countryKey)
{
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!RuleMatches(rule, record))
continue;
if (rule.RuleType.Equals(FinanceRuleTypes.Exclude, StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(rule.Notes) ? $"Excluded {countryKey}" : rule.Notes;
if (rule.RuleType.Equals(FinanceRuleTypes.DeduplicateBlankSupplierCountry, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(record.SupplierCountry))
return string.IsNullOrWhiteSpace(rule.Notes) ? $"Excluded {countryKey} duplicate without Supplier country" : rule.Notes;
}
return $"Excluded {countryKey}";
}
public static IReadOnlyList<FinanceRule> CreateDefaultRules()
=>
[
new FinanceRule
{
ScopeKey = "DE",
Year = 2025,
RuleType = FinanceRuleTypes.ForceYear,
MatchType = FinanceRuleMatchTypes.Always,
Notes = "DE Alphaplan Jahresfile 2025",
SortOrder = 100
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Equal,
MatchValue = "Trafag AG",
Notes = "Excluded DE Weiterberechnung Trafag AG",
SortOrder = 110
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
MatchValue = "Magnetic Sense",
Notes = "Excluded DE Weiterberechnung Magnetic Sense",
SortOrder = 120
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.InvoiceNumber),
MatchType = FinanceRuleMatchTypes.Equal,
MatchValue = "GS2510095",
Notes = "Excluded DE GS2510095 already captured in 2024",
SortOrder = 130
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.NegateAmount,
FieldName = nameof(SalesRecord.InvoiceNumber),
MatchType = FinanceRuleMatchTypes.StartsWith,
MatchValue = "GS",
Notes = "DE Gutschriften negativ",
SortOrder = 140
},
new FinanceRule
{
ScopeKey = "IT",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
MatchValue = "Trafag Italia",
Notes = "Excluded IT customer: Trafag Italia",
SortOrder = 200
},
new FinanceRule
{
ScopeKey = "IT",
RuleType = FinanceRuleTypes.DeduplicateBlankSupplierCountry,
FieldName = nameof(SalesRecord.SupplierCountry),
MatchType = FinanceRuleMatchTypes.IsBlank,
Notes = "Excluded IT duplicate without Supplier country",
SortOrder = 210
}
];
private HashSet<string> GetDeduplicationSet(FinanceRule rule, string countryKey)
{
var key = $"{countryKey}|{rule.Id}|{rule.SortOrder}|{rule.RuleType}";
if (!_deduplicationKeys.TryGetValue(key, out var set))
{
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_deduplicationKeys[key] = set;
}
return set;
}
private static bool IsRuleInScope(FinanceRule rule, string countryKey)
=> string.IsNullOrWhiteSpace(rule.ScopeKey) ||
rule.ScopeKey.Equals(countryKey, StringComparison.OrdinalIgnoreCase);
private static bool RuleMatches(FinanceRule rule, SalesRecord record)
{
if (rule.MatchType.Equals(FinanceRuleMatchTypes.Always, StringComparison.OrdinalIgnoreCase))
return true;
var value = ReadRecordValue(record, rule.FieldName);
var normalizedValue = NormalizeFinanceText(value);
var normalizedMatch = NormalizeFinanceText(rule.MatchValue);
return rule.MatchType switch
{
FinanceRuleMatchTypes.Equal => normalizedValue.Equals(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.Contains => normalizedValue.Contains(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.StartsWith => normalizedValue.StartsWith(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.IsBlank => string.IsNullOrWhiteSpace(value),
_ => false
};
}
private static string ReadRecordValue(SalesRecord record, string fieldName)
{
if (string.IsNullOrWhiteSpace(fieldName))
return string.Empty;
return SalesRecordProperties.TryGetValue(fieldName, out var property)
? property.GetValue(record)?.ToString() ?? string.Empty
: string.Empty;
}
private static string BuildBlankSupplierCountryDeduplicationKey(SalesRecord record)
=> string.Join("|",
record.Tsc,
record.DocumentType,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.CustomerNumber,
record.CustomerName,
record.SalesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency,
record.PostingDate?.ToString("O") ?? string.Empty,
record.InvoiceDate?.ToString("O") ?? string.Empty);
private static string NormalizeFinanceText(string value)
=> (value ?? string.Empty)
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToUpperInvariant();
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IFinanceRulesPageService
{
Task<List<FinanceRule>> LoadAsync();
Task<List<FinanceRule>> SaveAllAsync(List<FinanceRule> rules);
}
public sealed class FinanceRulesPageService : IFinanceRulesPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public FinanceRulesPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<FinanceRule>> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var rules = await db.FinanceRules
.AsNoTracking()
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToListAsync();
if (rules.Count > 0)
return rules;
return FinanceRuleEngine.CreateDefaultRules()
.Select(CloneRule)
.ToList();
}
public async Task<List<FinanceRule>> SaveAllAsync(List<FinanceRule> rules)
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.FinanceRules.RemoveRange(db.FinanceRules);
db.FinanceRules.AddRange(rules
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.Select(CloneRule));
await db.SaveChangesAsync();
return await db.FinanceRules
.AsNoTracking()
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToListAsync();
}
private static FinanceRule CloneRule(FinanceRule rule)
=> new()
{
ScopeKey = rule.ScopeKey.Trim().ToUpperInvariant(),
Year = rule.Year,
RuleType = string.IsNullOrWhiteSpace(rule.RuleType) ? FinanceRuleTypes.Exclude : rule.RuleType,
FieldName = rule.FieldName ?? string.Empty,
MatchType = string.IsNullOrWhiteSpace(rule.MatchType) ? FinanceRuleMatchTypes.Contains : rule.MatchType,
MatchValue = rule.MatchValue ?? string.Empty,
NumericValue = rule.NumericValue,
Notes = rule.Notes ?? string.Empty,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
};
}
+340 -70
View File
@@ -5,46 +5,53 @@ namespace TrafagSalesExporter.Services;
public class HanaQueryService : IHanaQueryService public class HanaQueryService : IHanaQueryService
{ {
private const string TscParameterName = "tsc";
private const string DateFilterParameterName = "dateFilter";
private readonly IAppEventLogService _appEventLogService; private readonly IAppEventLogService _appEventLogService;
private readonly IMappedSalesRecordComposer _composer;
public HanaQueryService(IAppEventLogService appEventLogService) public HanaQueryService(IAppEventLogService appEventLogService, IMappedSalesRecordComposer composer)
{ {
_appEventLogService = appEventLogService; _appEventLogService = appEventLogService;
_composer = composer;
} }
public List<SalesRecord> GetSalesRecords(HanaServer server, public async Task<List<SalesRecord>> GetSalesRecordsAsync(HanaServer server,
string schema, string tsc, string land, string dateFilter) string schema, string tsc, string land, string dateFilter, CancellationToken cancellationToken = default)
{ {
var connectionString = server.BuildConnectionString(); var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>(); var result = new List<SalesRecord>();
try try
{ {
_appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land, await _appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land,
details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}").GetAwaiter().GetResult(); details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}");
using var connection = new HanaConnection(connectionString); using var connection = new HanaConnection(connectionString);
connection.Open(); await connection.OpenAsync(cancellationToken);
_appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land, await _appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land,
details: $"Schema={schema} | TSC={tsc}").GetAwaiter().GetResult(); details: $"Schema={schema} | TSC={tsc}");
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter); var invoiceQuery = GetInvoiceQuery(schema);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter); var creditNoteQuery = GetCreditNoteQuery(schema);
var parsedDateFilter = ParseDateFilter(dateFilter);
_appEventLogService.WriteAsync("HANA", "Invoice-Query gestartet", land: land, details: invoiceQuery).GetAwaiter().GetResult(); await _appEventLogService.WriteAsync("HANA", "Invoice-Query gestartet", land: land,
var invoiceRecords = ReadRecords(connection, invoiceQuery, land, "Invoice"); details: BuildQueryLogDetails(invoiceQuery, schema, tsc, parsedDateFilter));
var invoiceRecords = await ReadRecordsAsync(connection, invoiceQuery, tsc, parsedDateFilter, land, "Invoice", cancellationToken);
result.AddRange(invoiceRecords); result.AddRange(invoiceRecords);
_appEventLogService.WriteAsync("HANA", "Invoice-Query beendet", land: land, details: $"Zeilen={invoiceRecords.Count}").GetAwaiter().GetResult(); await _appEventLogService.WriteAsync("HANA", "Invoice-Query beendet", land: land, details: $"Zeilen={invoiceRecords.Count}");
_appEventLogService.WriteAsync("HANA", "Credit-Query gestartet", land: land, details: creditNoteQuery).GetAwaiter().GetResult(); await _appEventLogService.WriteAsync("HANA", "Credit-Query gestartet", land: land,
var creditRecords = ReadRecords(connection, creditNoteQuery, land, "Credit"); details: BuildQueryLogDetails(creditNoteQuery, schema, tsc, parsedDateFilter));
var creditRecords = await ReadRecordsAsync(connection, creditNoteQuery, tsc, parsedDateFilter, land, "Credit", cancellationToken);
result.AddRange(creditRecords); result.AddRange(creditRecords);
_appEventLogService.WriteAsync("HANA", "Credit-Query beendet", land: land, details: $"Zeilen={creditRecords.Count}").GetAwaiter().GetResult(); await _appEventLogService.WriteAsync("HANA", "Credit-Query beendet", land: land, details: $"Zeilen={creditRecords.Count}");
} }
catch (Exception ex) catch (Exception ex)
{ {
_appEventLogService.WriteAsync("HANA", "HANA-Abfrage fehlgeschlagen", "Error", land: land, details: ex.ToString()).GetAwaiter().GetResult(); await _appEventLogService.WriteAsync("HANA", "HANA-Abfrage fehlgeschlagen", "Error", land: land, details: ex.ToString());
throw; throw;
} }
@@ -60,7 +67,7 @@ public class HanaQueryService : IHanaQueryService
return result; return result;
} }
public ConnectionTestResult TestConnectionDetailed(HanaServer server) public async Task<ConnectionTestResult> TestConnectionDetailedAsync(HanaServer server, CancellationToken cancellationToken = default)
{ {
var testResult = new ConnectionTestResult var testResult = new ConnectionTestResult
{ {
@@ -71,20 +78,20 @@ public class HanaQueryService : IHanaQueryService
try try
{ {
_appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet", await _appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet",
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult(); details: testResult.ConnectionStringPreview);
var connectionString = server.BuildConnectionString(); var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString); using var connection = new HanaConnection(connectionString);
connection.Open(); await connection.OpenAsync(cancellationToken);
testResult.Stage = "Ping-Query"; testResult.Stage = "Ping-Query";
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection); using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
command.ExecuteScalar(); await command.ExecuteScalarAsync(cancellationToken);
testResult.Success = true; testResult.Success = true;
testResult.Stage = "OK"; testResult.Stage = "OK";
_appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich", await _appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich",
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult(); details: testResult.ConnectionStringPreview);
return testResult; return testResult;
} }
catch (Exception ex) catch (Exception ex)
@@ -92,24 +99,24 @@ public class HanaQueryService : IHanaQueryService
testResult.Success = false; testResult.Success = false;
testResult.ErrorMessage = ex.Message; testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name; testResult.ExceptionType = ex.GetType().Name;
_appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error", await _appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error",
details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}").GetAwaiter().GetResult(); details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}");
return testResult; return testResult;
} }
} }
public void TestConnection(HanaServer server) public async Task TestConnectionAsync(HanaServer server, CancellationToken cancellationToken = default)
{ {
var connectionString = server.BuildConnectionString(); var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString); using var connection = new HanaConnection(connectionString);
connection.Open(); await connection.OpenAsync(cancellationToken);
} }
public List<string> GetAvailableSchemas(HanaServer server) public async Task<List<string>> GetAvailableSchemasAsync(HanaServer server, CancellationToken cancellationToken = default)
{ {
var connectionString = server.BuildConnectionString(); var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString); using var connection = new HanaConnection(connectionString);
connection.Open(); await connection.OpenAsync(cancellationToken);
const string query = """ const string query = """
SELECT schema_name SELECT schema_name
@@ -124,10 +131,10 @@ public class HanaQueryService : IHanaQueryService
"""; """;
using var command = new HanaCommand(query, connection); using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader(); using var reader = await command.ExecuteReaderAsync(cancellationToken);
var schemas = new List<string>(); var schemas = new List<string>();
while (reader.Read()) while (await reader.ReadAsync(cancellationToken))
{ {
var schema = reader["schema_name"]?.ToString()?.Trim(); var schema = reader["schema_name"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(schema)) if (!string.IsNullOrWhiteSpace(schema))
@@ -137,22 +144,130 @@ public class HanaQueryService : IHanaQueryService
return schemas; return schemas;
} }
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName) public async Task<List<string>> GetAvailableTablesAsync(HanaServer server, string schema, CancellationToken cancellationToken = default)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
await connection.OpenAsync(cancellationToken);
const string query = """
SELECT table_name
FROM sys.tables
WHERE schema_name = :schema
UNION
SELECT view_name AS table_name
FROM sys.views
WHERE schema_name = :schema
ORDER BY table_name;
""";
using var command = new HanaCommand(query, connection);
command.Parameters.Add(new HanaParameter("schema", HanaDbType.NVarChar) { Value = schema.Trim().ToUpperInvariant() });
using var reader = await command.ExecuteReaderAsync(cancellationToken);
var tables = new List<string>();
while (await reader.ReadAsync(cancellationToken))
{
var table = reader["table_name"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(table))
tables.Add(table);
}
return tables;
}
public async Task<List<string>> GetTableFieldNamesAsync(HanaServer server, string schema, string tableName, CancellationToken cancellationToken = default)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
await connection.OpenAsync(cancellationToken);
const string query = """
SELECT column_name
FROM sys.table_columns
WHERE schema_name = :schema AND table_name = :table
UNION
SELECT column_name
FROM sys.view_columns
WHERE schema_name = :schema AND view_name = :table
ORDER BY column_name;
""";
using var command = new HanaCommand(query, connection);
command.Parameters.Add(new HanaParameter("schema", HanaDbType.NVarChar) { Value = schema.Trim().ToUpperInvariant() });
command.Parameters.Add(new HanaParameter("table", HanaDbType.NVarChar) { Value = tableName.Trim().ToUpperInvariant() });
using var reader = await command.ExecuteReaderAsync(cancellationToken);
var fields = new List<string>();
while (await reader.ReadAsync(cancellationToken))
{
var field = reader["column_name"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(field))
fields.Add(field);
}
return fields;
}
public async Task<List<SalesRecord>> GetMappedSalesRecordsAsync(
HanaServer server,
string schema,
Site site,
IReadOnlyList<SapSourceDefinition> sources,
IReadOnlyList<SapJoinDefinition> joins,
IReadOnlyList<SapFieldMapping> mappings,
string dateFilter,
CancellationToken cancellationToken = default)
{
var activeSources = sources
.Where(s => s.IsActive)
.OrderBy(s => s.SortOrder)
.ThenBy(s => s.Id)
.ToList();
if (activeSources.Count == 0)
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven HANA-Quellen.");
if (!mappings.Any(m => m.IsActive))
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven HANA-Feldmappings.");
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
await connection.OpenAsync(cancellationToken);
var parsedDateFilter = ParseDateFilter(dateFilter);
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
await _appEventLogService.WriteDebugAsync("HANA", "Mapping-Quelle wird gelesen", site.Id, site.Land,
$"Alias={source.Alias} | Tabelle/View={source.EntitySet}");
sourceRows[source.Alias] = await ReadMappedSourceRowsAsync(connection, schema, source.EntitySet, parsedDateFilter, cancellationToken);
await _appEventLogService.WriteDebugAsync("HANA", "Mapping-Quelle gelesen", site.Id, site.Land,
$"Alias={source.Alias} | Tabelle/View={source.EntitySet} | Zeilen={sourceRows[source.Alias].Count}");
}
return _composer.Compose(site, activeSources, joins, mappings, sourceRows, "HANA");
}
private async Task<List<SalesRecord>> ReadRecordsAsync(HanaConnection connection, string query, string tsc, DateTime dateFilter, string land, string queryName, CancellationToken cancellationToken)
{ {
var records = new List<SalesRecord>(); var records = new List<SalesRecord>();
using var command = new HanaCommand(query, connection); using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader(); command.Parameters.Add(new HanaParameter(TscParameterName, HanaDbType.NVarChar) { Value = tsc });
command.Parameters.Add(new HanaParameter(DateFilterParameterName, HanaDbType.Date) { Value = dateFilter.Date });
using var reader = await command.ExecuteReaderAsync(cancellationToken);
var counter = 0; var counter = 0;
while (reader.Read()) while (await reader.ReadAsync(cancellationToken))
{ {
records.Add(new SalesRecord records.Add(new SalesRecord
{ {
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")), ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")), Tsc = reader.GetString(reader.GetOrdinal("tsc")),
DocumentEntry = Convert.ToInt32(reader["document_entry"]),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]), PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
PostingDate = reader.IsDBNull(reader.GetOrdinal("posting_date")) ? null : reader.GetDateTime(reader.GetOrdinal("posting_date")),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")), InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty, Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty, Name = reader["material_name"]?.ToString() ?? string.Empty,
@@ -170,6 +285,13 @@ public class HanaQueryService : IHanaQueryService
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty, PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]), SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty, SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
DocumentCurrency = reader["document_currency"]?.ToString() ?? string.Empty,
DocumentTotalForeignCurrency = Convert.ToDecimal(reader["document_total_fc"]),
DocumentTotalLocalCurrency = Convert.ToDecimal(reader["document_total_lc"]),
VatSumForeignCurrency = Convert.ToDecimal(reader["vat_sum_fc"]),
VatSumLocalCurrency = Convert.ToDecimal(reader["vat_sum_lc"]),
DocumentRate = Convert.ToDecimal(reader["document_rate"]),
CompanyCurrency = reader["company_currency"]?.ToString() ?? string.Empty,
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty, Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty, SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")), OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
@@ -180,21 +302,81 @@ public class HanaQueryService : IHanaQueryService
counter++; counter++;
if (counter % 250 == 0) if (counter % 250 == 0)
{ {
_appEventLogService.WriteDebugAsync("HANA", $"{queryName}-Query liest Daten", land: land, await _appEventLogService.WriteDebugAsync("HANA", $"{queryName}-Query liest Daten", land: land,
details: $"Bisher gelesene Zeilen={counter}").GetAwaiter().GetResult(); details: $"Bisher gelesene Zeilen={counter}");
} }
} }
return records; return records;
} }
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@" private static async Task<List<Dictionary<string, object?>>> ReadMappedSourceRowsAsync(
HanaConnection connection,
string schema,
string tableName,
DateTime dateFilter,
CancellationToken cancellationToken)
{
var schemaPrefix = BuildSchemaPrefix(schema);
var tableIdentifier = BuildIdentifier(tableName);
var hasFkdat = await HasColumnAsync(connection, schema, tableName, "FKDAT", cancellationToken);
var query = hasFkdat
? $@"SELECT * FROM {schemaPrefix}{tableIdentifier} WHERE ""FKDAT"" >= :{DateFilterParameterName}"
: $@"SELECT * FROM {schemaPrefix}{tableIdentifier}";
using var command = new HanaCommand(query, connection);
if (hasFkdat)
command.Parameters.Add(new HanaParameter(DateFilterParameterName, HanaDbType.Date) { Value = dateFilter.Date });
using var reader = await command.ExecuteReaderAsync(cancellationToken);
var rows = new List<Dictionary<string, object?>>();
while (await reader.ReadAsync(cancellationToken))
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
rows.Add(row);
}
return rows;
}
private static async Task<bool> HasColumnAsync(HanaConnection connection, string schema, string tableName, string columnName, CancellationToken cancellationToken)
{
const string query = """
SELECT COUNT(*) AS cnt
FROM (
SELECT column_name
FROM sys.table_columns
WHERE schema_name = :schema AND table_name = :table AND column_name = :column
UNION ALL
SELECT column_name
FROM sys.view_columns
WHERE schema_name = :schema AND view_name = :table AND column_name = :column
) x;
""";
using var command = new HanaCommand(query, connection);
command.Parameters.Add(new HanaParameter("schema", HanaDbType.NVarChar) { Value = schema.Trim().ToUpperInvariant() });
command.Parameters.Add(new HanaParameter("table", HanaDbType.NVarChar) { Value = tableName.Trim().ToUpperInvariant() });
command.Parameters.Add(new HanaParameter("column", HanaDbType.NVarChar) { Value = columnName.Trim().ToUpperInvariant() });
var count = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(count) > 0;
}
private static string GetInvoiceQuery(string schema)
{
var schemaPrefix = BuildSchemaPrefix(schema);
var revenueAccountFilter = BuildRevenueAccountFilter(schema, "h", "p");
return $@"
SELECT SELECT
CURRENT_TIMESTAMP AS extraction_date, CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc, :{TscParameterName} AS tsc,
h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number, h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position, p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date, h.""DocDate"" AS posting_date,
h.""TaxDate"" AS invoice_date,
p.""ItemCode"" AS material, p.""ItemCode"" AS material,
p.""Dscription"" AS material_name, p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group, COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
@@ -207,42 +389,57 @@ SELECT
COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry, COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost, p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency,
CASE WHEN p.""BaseType"" = 22 CASE WHEN p.""BaseType"" = 22
THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number, ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value, p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(adm.""MainCurncy"", '') AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) AS document_total_fc,
COALESCE(h.""DocTotal"", 0) AS document_total_lc,
COALESCE(h.""VatSumFC"", 0) AS vat_sum_fc,
COALESCE(h.""VatSum"", 0) AS vat_sum_lc,
COALESCE(h.""DocRate"", 0) AS document_rate,
COALESCE(adm.""MainCurncy"", '') AS company_currency,
'' AS incoterms_2020, '' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible, COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17 CASE WHEN p.""BaseType"" = 17
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o THEN (SELECT o.""DocDate"" FROM {schemaPrefix}""ORDR"" o
WHERE o.""DocEntry"" = p.""BaseEntry"") WHERE o.""DocEntry"" = p.""BaseEntry"")
ELSE NULL END AS order_date, ELSE NULL END AS order_date,
'INV' AS doc_type 'INV' AS doc_type
FROM {schema}.""OINV"" h FROM {schemaPrefix}""OINV"" h
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry"" INNER JOIN {schemaPrefix}""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" CROSS JOIN {schemaPrefix}""OADM"" adm
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S' AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B' AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName}{revenueAccountFilter}
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@" private static string GetCreditNoteQuery(string schema)
{
var schemaPrefix = BuildSchemaPrefix(schema);
var revenueAccountFilter = BuildRevenueAccountFilter(schema, "h", "p");
return $@"
SELECT SELECT
CURRENT_TIMESTAMP AS extraction_date, CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc, :{TscParameterName} AS tsc,
h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number, h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position, p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date, h.""DocDate"" AS posting_date,
h.""TaxDate"" AS invoice_date,
p.""ItemCode"" AS material, p.""ItemCode"" AS material,
p.""Dscription"" AS material_name, p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group, COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
@@ -255,29 +452,102 @@ SELECT
COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry, COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost, p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency,
'' AS purchase_order_number, '' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value, p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(adm.""MainCurncy"", '') AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc,
COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc,
COALESCE(h.""VatSumFC"", 0) * -1 AS vat_sum_fc,
COALESCE(h.""VatSum"", 0) * -1 AS vat_sum_lc,
COALESCE(h.""DocRate"", 0) AS document_rate,
COALESCE(adm.""MainCurncy"", '') AS company_currency,
'' AS incoterms_2020, '' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible, COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date, NULL AS order_date,
'CRN' AS doc_type 'CRN' AS doc_type
FROM {schema}.""ORIN"" h FROM {schemaPrefix}""ORIN"" h
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry"" INNER JOIN {schemaPrefix}""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" CROSS JOIN {schemaPrefix}""OADM"" adm
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S' AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B' AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName}{revenueAccountFilter}
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
private static string BuildRevenueAccountFilter(string schema, string headerAlias, string lineAlias)
{
if (!schema.Equals("it01_p", StringComparison.OrdinalIgnoreCase))
return string.Empty;
// Italy's Finance/B1 GUI reconciles against account group 47005
// "Ricavi vendite e prestazioni". The 4700504* autofattura accounts
// are outside the displayed net-sales subtotal from the screenshot.
// The customer exclusion is a provisional working filter derived from
// the current IT cache; it must be replaced by the official B1/Rhino
// report criterion once Italy confirms the common business rule.
return $@" AND {lineAlias}.""AcctCode"" LIKE '47005%'
AND {lineAlias}.""AcctCode"" NOT LIKE '4700504%'
AND {headerAlias}.""CardCode"" NOT IN (
'C_IT01_0022987',
'C_IT01_0306928',
'C_IT01_0306138',
'C_IT01_0309653',
'C_IT01_0304885',
'C_IT01_0306475'
)";
}
private static DateTime ParseDateFilter(string dateFilter)
{
if (DateTime.TryParse(dateFilter, out var parsed))
return parsed.Date;
throw new InvalidOperationException($"Ungueltiger HANA-DateFilter: '{dateFilter}'. Erwartet wird ein parsebares Datum.");
}
private static string BuildQueryLogDetails(string query, string schema, string tsc, DateTime dateFilter)
=> $"{query}{Environment.NewLine}-- schema={schema}; tsc={tsc}; dateFilter={dateFilter:yyyy-MM-dd}";
private static string BuildSchemaPrefix(string identifier)
{
var value = identifier?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException("HANA-Schema darf nicht leer sein.");
foreach (var ch in value)
{
if (!(char.IsLetterOrDigit(ch) || ch == '_'))
throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'.");
}
return $"{value}.";
}
private static string BuildIdentifier(string identifier)
{
var value = identifier?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException("HANA-Identifier darf nicht leer sein.");
foreach (var ch in value)
{
if (!(char.IsLetterOrDigit(ch) || ch == '_'))
throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'.");
}
return $"\"{value}\"";
}
} }
public class ConnectionTestResult public class ConnectionTestResult
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface IHrKpiAccessService
{
bool IsEnabled { get; }
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
void Lock();
}
public sealed class HrKpiAccessService : IHrKpiAccessService
{
private readonly HrKpiAccessOptions _options;
public HrKpiAccessService(IOptions<HrKpiAccessOptions> options)
{
_options = options.Value;
}
public bool IsEnabled => _options.Enabled;
public bool IsConfigured =>
!IsEnabled ||
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked { get; private set; }
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
IsUnlocked = true;
return true;
}
if (!IsConfigured ||
string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!FixedEquals(username.Trim(), _options.Username.Trim()))
{
return false;
}
var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash)
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
return valid;
}
public void Lock() => IsUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
return FixedEquals(passwordHash, configuredHash.Trim());
}
private static bool FixedEquals(string left, string right)
{
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);
return leftBytes.Length == rightBytes.Length &&
CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
}
}
@@ -0,0 +1,22 @@
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IHrKpiService
{
Task<HrKpiResult> BuildAsync(HrKpiOptions options);
}
public sealed class HrKpiService : IHrKpiService
{
private readonly HrKpiDataSourceOptions _dataSources;
public HrKpiService(IOptions<HrKpiDataSourceOptions>? dataSources = null)
{
_dataSources = (dataSources?.Value ?? new HrKpiDataSourceOptions()).Normalize();
}
public Task<HrKpiResult> BuildAsync(HrKpiOptions options)
=> Task.FromResult(new HrKpiDashboardBuilder(_dataSources).Build(options));
}
@@ -1,8 +1,6 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public interface IConsolidatedExportService public interface IConsolidatedExportService
{ {
Task<string?> ExportAsync(List<SalesRecord> records); Task<string?> ExportAsync();
} }
@@ -4,8 +4,11 @@ namespace TrafagSalesExporter.Services;
public interface IHanaQueryService public interface IHanaQueryService
{ {
List<SalesRecord> GetSalesRecords(HanaServer server, string schema, string tsc, string land, string dateFilter); Task<List<SalesRecord>> GetSalesRecordsAsync(HanaServer server, string schema, string tsc, string land, string dateFilter, CancellationToken cancellationToken = default);
List<string> GetAvailableSchemas(HanaServer server); Task<List<SalesRecord>> GetMappedSalesRecordsAsync(HanaServer server, string schema, Site site, IReadOnlyList<SapSourceDefinition> sources, IReadOnlyList<SapJoinDefinition> joins, IReadOnlyList<SapFieldMapping> mappings, string dateFilter, CancellationToken cancellationToken = default);
ConnectionTestResult TestConnectionDetailed(HanaServer server); Task<List<string>> GetAvailableSchemasAsync(HanaServer server, CancellationToken cancellationToken = default);
void TestConnection(HanaServer server); Task<List<string>> GetAvailableTablesAsync(HanaServer server, string schema, CancellationToken cancellationToken = default);
Task<List<string>> GetTableFieldNamesAsync(HanaServer server, string schema, string tableName, CancellationToken cancellationToken = default);
Task<ConnectionTestResult> TestConnectionDetailedAsync(HanaServer server, CancellationToken cancellationToken = default);
Task TestConnectionAsync(HanaServer server, CancellationToken cancellationToken = default);
} }
@@ -5,7 +5,11 @@ namespace TrafagSalesExporter.Services;
public interface IManagementCockpitService public interface IManagementCockpitService
{ {
Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync(); Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync();
IReadOnlyList<ManagementCockpitValueFieldOption> GetValueFieldOptions();
Task<ManagementCockpitResult> AnalyzeAsync(string filePath); Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
Task<ManagementCockpitResult> AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options);
Task<List<int>> GetAvailableCentralYearsAsync(); Task<List<int>> GetAvailableCentralYearsAsync();
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month); Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month);
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options);
Task<ManagementFinanceSummaryResult> AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency);
} }
@@ -0,0 +1,14 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IMappedSalesRecordComposer
{
List<SalesRecord> Compose(
Site site,
IReadOnlyList<SapSourceDefinition> sources,
IReadOnlyList<SapJoinDefinition> joins,
IReadOnlyList<SapFieldMapping> mappings,
IReadOnlyDictionary<string, List<Dictionary<string, object?>>> sourceRows,
string defaultDocumentType);
}
@@ -11,5 +11,6 @@ public interface ISapCompositionService
IReadOnlyList<SapFieldMapping> mappings, IReadOnlyList<SapFieldMapping> mappings,
string username, string username,
string password, string password,
int? preferredYear = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
@@ -5,5 +5,5 @@ public interface ISapGatewayService
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default); Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default); Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default); Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default); Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default);
} }
@@ -4,5 +4,9 @@ public interface ISharePointUploadService
{ {
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath); 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<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, int? preferredYear = null);
Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl); Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl);
} }
public sealed record SharePointFileReference(string FileReference, DateTimeOffset? LastModifiedUtc);
@@ -4,5 +4,5 @@ namespace TrafagSalesExporter.Services;
public interface ISiteExportService public interface ISiteExportService
{ {
Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null); Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null);
} }
@@ -0,0 +1,329 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using static TrafagSalesExporter.Services.CockpitValueAggregator;
namespace TrafagSalesExporter.Services;
internal sealed class CentralCockpitAnalyzer
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly CockpitValueAggregator _aggregator;
public CentralCockpitAnalyzer(IDbContextFactory<AppDbContext> dbFactory, CockpitValueAggregator aggregator)
{
_dbFactory = dbFactory;
_aggregator = aggregator;
}
public async Task<List<int>> GetAvailableCentralYearsAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var years = await db.CentralSalesRecords
.Select(r => r.InvoiceDate.HasValue ? r.InvoiceDate.Value.Year : r.ExtractionDate.Year)
.Distinct()
.OrderBy(x => x)
.ToListAsync();
return years;
}
public async Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options)
{
var aggregation = _aggregator.ResolveAggregation(options);
using var db = await _dbFactory.CreateDbContextAsync();
var baseRows = await db.CentralSalesRecords
.Select(r => new CentralCockpitRow
{
SourceSystem = r.SourceSystem,
Land = r.Land,
Tsc = r.Tsc,
InvoiceNumber = r.InvoiceNumber,
SalesCurrency = string.IsNullOrWhiteSpace(r.SalesCurrency) ? "-" : r.SalesCurrency,
StandardCostCurrency = string.IsNullOrWhiteSpace(r.StandardCostCurrency) ? "-" : r.StandardCostCurrency,
Quantity = r.Quantity,
StandardCost = r.StandardCost,
SalesValue = r.SalesPriceValue,
PeriodDate = r.InvoiceDate ?? r.ExtractionDate
})
.ToListAsync();
if (baseRows.Count == 0)
throw new InvalidOperationException("Die zentrale Tabelle enthält noch keine Datensätze.");
var aggregatedRows = baseRows
.Select(row => BuildCentralAggregationRow(row, aggregation))
.ToList();
var scopedRows = ApplyCentralDimensionFilters(aggregatedRows, options)
.ToList();
var selectedRows = scopedRows
.Where(r => r.PeriodDate.Year == year && (!month.HasValue || r.PeriodDate.Month == month.Value))
.ToList();
if (selectedRows.Count == 0)
throw new InvalidOperationException("Für den gewählten Zeitraum gibt es keine Datensätze in der zentralen Tabelle.");
var yearlyRows = scopedRows;
var dailyBaseRows = selectedRows
.Where(r => month.HasValue)
.ToList();
return new ManagementCockpitCentralResult
{
Filter = new ManagementCockpitCentralFilter
{
Year = year,
Month = month,
ValueField = aggregation.ValueField.Key,
TargetCurrency = aggregation.TargetCurrency,
Land = NormalizeOptionalFilter(options?.LandFilter),
Tsc = NormalizeOptionalFilter(options?.TscFilter)
},
Summary = new ManagementCockpitCentralSummary
{
RowCount = selectedRows.Count,
InvoiceCount = selectedRows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
SiteCount = selectedRows.Select(x => x.Tsc).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
CountryCount = selectedRows.Select(x => x.Land).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
CurrencyCount = selectedRows.Select(x => x.DisplayCurrency).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
ValueFieldKey = aggregation.ValueField.Key,
ValueFieldLabel = aggregation.ValueField.Label,
DisplayCurrency = BuildDisplayCurrencyLabel(selectedRows.Select(x => x.DisplayCurrency)),
ValueTotal = selectedRows.Sum(x => x.Value),
MissingExchangeRateCount = selectedRows.Count(x => x.MissingExchangeRate),
PeriodStart = selectedRows.Min(x => x.PeriodDate),
PeriodEnd = selectedRows.Max(x => x.PeriodDate)
},
AdditionalValueFields = aggregation.AdditionalValueFields
.Select(ToValueFieldOption)
.ToList(),
Notices = BuildCentralNotices(aggregation, selectedRows.Count(x => x.MissingExchangeRate), options),
YearlyTotals = yearlyRows
.GroupBy(x => new { x.PeriodDate.Year, x.DisplayCurrency })
.OrderBy(g => g.Key.Year)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => BuildTimeValueRow(g, aggregation, g.Key.Year.ToString(), g.Key.Year, null, null, g.Key.DisplayCurrency))
.ToList(),
MonthlyTotals = selectedRows
.GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.DisplayCurrency })
.OrderBy(g => g.Key.Year)
.ThenBy(g => g.Key.Month)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => BuildTimeValueRow(g, aggregation, $"{g.Key.Year:D4}-{g.Key.Month:D2}", g.Key.Year, g.Key.Month, null, g.Key.DisplayCurrency))
.ToList(),
DailyTotals = dailyBaseRows
.GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.PeriodDate.Day, x.DisplayCurrency })
.OrderBy(g => g.Key.Year)
.ThenBy(g => g.Key.Month)
.ThenBy(g => g.Key.Day)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => BuildTimeValueRow(g, aggregation, $"{g.Key.Year:D4}-{g.Key.Month:D2}-{g.Key.Day:D2}", g.Key.Year, g.Key.Month, g.Key.Day, g.Key.DisplayCurrency))
.ToList(),
SourceSystemTotals = selectedRows
.GroupBy(x => new { x.SourceSystem, x.DisplayCurrency })
.OrderBy(g => g.Key.SourceSystem, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitDimensionValueRow
{
Label = g.Key.SourceSystem,
Currency = g.Key.DisplayCurrency,
SalesValue = g.Sum(x => x.Value),
RowCount = g.Count(),
InvoiceCount = g.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count()
})
.ToList(),
CountryTotals = selectedRows
.GroupBy(x => new { x.Land, x.DisplayCurrency })
.OrderByDescending(g => g.Sum(x => x.Value))
.ThenBy(g => g.Key.Land, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitDimensionValueRow
{
Label = g.Key.Land,
Currency = g.Key.DisplayCurrency,
SalesValue = g.Sum(x => x.Value),
RowCount = g.Count(),
InvoiceCount = g.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count()
})
.ToList()
};
}
private static IEnumerable<CentralAggregationRow> ApplyCentralDimensionFilters(
IEnumerable<CentralAggregationRow> rows,
ManagementCockpitAnalysisOptions? options)
{
var landFilter = NormalizeOptionalFilter(options?.LandFilter);
var tscFilter = NormalizeOptionalFilter(options?.TscFilter);
return rows.Where(row =>
(landFilter is null || string.Equals(row.Land, landFilter, StringComparison.OrdinalIgnoreCase)) &&
(tscFilter is null || string.Equals(row.Tsc, tscFilter, StringComparison.OrdinalIgnoreCase)));
}
private CentralAggregationRow BuildCentralAggregationRow(CentralCockpitRow row, AggregationSelection aggregation)
{
var value = ResolveValue(row, aggregation.ValueField);
var currency = ResolveCurrency(row, aggregation.ValueField);
var converted = _aggregator.ConvertValue(value, currency, aggregation.ValueField, aggregation, row.PeriodDate);
var additionalValues = aggregation.AdditionalValueFields.ToDictionary(
field => field.Key,
field =>
{
var additionalValue = ResolveValue(row, field);
var additionalCurrency = ResolveCurrency(row, field);
return _aggregator.ConvertValue(additionalValue, additionalCurrency, field, aggregation, row.PeriodDate);
},
StringComparer.OrdinalIgnoreCase);
return new CentralAggregationRow
{
SourceSystem = row.SourceSystem,
Land = row.Land,
Tsc = row.Tsc,
InvoiceNumber = row.InvoiceNumber,
PeriodDate = row.PeriodDate,
Value = converted.Value,
DisplayCurrency = converted.DisplayCurrency,
MissingExchangeRate = converted.MissingExchangeRate,
AdditionalValues = additionalValues
};
}
private static decimal ResolveValue(CentralCockpitRow row, ValueFieldDefinition field)
=> field.Key switch
{
ManagementCockpitValueFieldKeys.Quantity => row.Quantity,
ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost,
ManagementCockpitValueFieldKeys.StandardCostTotal => row.Quantity != 0m ? row.Quantity * row.StandardCost : row.StandardCost,
_ => row.SalesValue
};
private static string ResolveCurrency(CentralCockpitRow row, ValueFieldDefinition field)
=> field.CurrencySource switch
{
ValueCurrencySource.StandardCost => row.StandardCostCurrency,
ValueCurrencySource.Sales => row.SalesCurrency,
_ => "-"
};
private static List<string> BuildCentralNotices(
AggregationSelection aggregation,
int missingExchangeRateCount,
ManagementCockpitAnalysisOptions? options)
{
var notices = new List<string>
{
"Roh-Auswertung aus CentralSalesRecords.",
$"Summenfeld: {aggregation.ValueField.Label}.",
"Keine Intercompany-Bereinigung angewendet.",
"Kein Budget- und kein Spartemapping angewendet.",
"Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date."
};
var landFilter = NormalizeOptionalFilter(options?.LandFilter);
var tscFilter = NormalizeOptionalFilter(options?.TscFilter);
if (landFilter is not null || tscFilter is not null)
{
notices.Add($"Filter aus Auswahl: Land {(landFilter ?? "alle")}, TSC {(tscFilter ?? "alle")}.");
}
if (aggregation.AdditionalValueFields.Count > 0)
notices.Add($"Weitere Summenfelder: {string.Join(", ", aggregation.AdditionalValueFields.Select(x => x.Label))}.");
if (!aggregation.ValueField.IsCurrencyAmount)
{
notices.Add("Das gewaehlte Summenfeld ist kein Waehrungsbetrag; die Anzeige-Waehrung wird ignoriert.");
}
else if (aggregation.TargetCurrency == ManagementCockpitCurrencyOptions.Native)
{
notices.Add("Keine Waehrungsumrechnung angewendet; Werte bleiben in der jeweiligen Quellwaehrung.");
}
else
{
notices.Add($"Betragswerte werden in {aggregation.TargetCurrency} angezeigt.");
if (missingExchangeRateCount > 0)
notices.Add($"{missingExchangeRateCount} Zeilen hatten keinen passenden Wechselkurs und sind in den Summen mit 0 enthalten.");
}
return notices;
}
private static ManagementCockpitTimeValueRow BuildTimeValueRow(
IEnumerable<CentralAggregationRow> groupRows,
AggregationSelection aggregation,
string label,
int? year,
int? month,
int? day,
string currency)
{
var rows = groupRows.ToList();
return new ManagementCockpitTimeValueRow
{
Label = label,
Year = year,
Month = month,
Day = day,
Currency = currency,
SalesValue = rows.Sum(x => x.Value),
AdditionalValues = BuildAdditionalValues(rows, aggregation),
RowCount = rows.Count
};
}
private static Dictionary<string, ManagementCockpitAggregatedFieldValue> BuildAdditionalValues(
IReadOnlyCollection<CentralAggregationRow> rows,
AggregationSelection aggregation)
{
var result = new Dictionary<string, ManagementCockpitAggregatedFieldValue>(StringComparer.OrdinalIgnoreCase);
foreach (var field in aggregation.AdditionalValueFields)
{
var values = rows
.Select(row => row.AdditionalValues.TryGetValue(field.Key, out var value) ? value : new ConvertedValue(0m, "-", false))
.ToList();
result[field.Key] = new ManagementCockpitAggregatedFieldValue
{
FieldKey = field.Key,
Label = field.Label,
Currency = BuildDisplayCurrencyLabel(values.Select(x => x.DisplayCurrency)),
Value = values.Sum(x => x.Value),
MissingExchangeRateCount = values.Count(x => x.MissingExchangeRate)
};
}
return result;
}
private class CentralCockpitRow
{
public string SourceSystem { get; set; } = string.Empty;
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public string SalesCurrency { get; set; } = string.Empty;
public string StandardCostCurrency { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal StandardCost { get; set; }
public decimal SalesValue { get; set; }
public DateTime PeriodDate { get; set; }
}
private class CentralAggregationRow
{
public string SourceSystem { get; set; } = string.Empty;
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public DateTime PeriodDate { get; set; }
public decimal Value { get; set; }
public string DisplayCurrency { get; set; } = string.Empty;
public bool MissingExchangeRate { get; set; }
public Dictionary<string, ConvertedValue> AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,161 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
internal sealed class CockpitValueAggregator
{
private readonly ICurrencyExchangeRateService _exchangeRateService;
public CockpitValueAggregator(ICurrencyExchangeRateService exchangeRateService)
{
_exchangeRateService = exchangeRateService;
}
private static readonly List<ValueFieldDefinition> ValueFieldDefinitions =
[
new()
{
Key = ManagementCockpitValueFieldKeys.SalesPriceValue,
Label = "Sales Price/Value",
IsCurrencyAmount = true,
CurrencySource = ValueCurrencySource.Sales
},
new()
{
Key = ManagementCockpitValueFieldKeys.StandardCostTotal,
Label = "Quantity * Standard cost",
IsCurrencyAmount = true,
CurrencySource = ValueCurrencySource.StandardCost
},
new()
{
Key = ManagementCockpitValueFieldKeys.StandardCost,
Label = "Standard cost",
IsCurrencyAmount = true,
CurrencySource = ValueCurrencySource.StandardCost
},
new()
{
Key = ManagementCockpitValueFieldKeys.Quantity,
Label = "Quantity",
IsCurrencyAmount = false,
CurrencySource = ValueCurrencySource.None
}
];
public IReadOnlyList<ManagementCockpitValueFieldOption> GetValueFieldOptions()
=> ValueFieldDefinitions
.Select(ToValueFieldOption)
.ToList();
public AggregationSelection ResolveAggregation(ManagementCockpitAnalysisOptions? options)
{
var selectedField = ValueFieldDefinitions.FirstOrDefault(x =>
string.Equals(x.Key, options?.ValueField, StringComparison.OrdinalIgnoreCase))
?? ValueFieldDefinitions.First(x => x.Key == ManagementCockpitValueFieldKeys.SalesPriceValue);
var additionalFields = (options?.AdditionalValueFields ?? [])
.Select(key => ValueFieldDefinitions.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)))
.Where(x => x is not null && !string.Equals(x.Key, selectedField.Key, StringComparison.OrdinalIgnoreCase))
.Cast<ValueFieldDefinition>()
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var targetCurrency = (options?.TargetCurrency ?? ManagementCockpitCurrencyOptions.Native).Trim().ToUpperInvariant();
if (targetCurrency is not ManagementCockpitCurrencyOptions.Eur and not ManagementCockpitCurrencyOptions.Usd)
targetCurrency = ManagementCockpitCurrencyOptions.Native;
return new AggregationSelection(
selectedField,
additionalFields,
targetCurrency,
new Dictionary<string, decimal?>(StringComparer.OrdinalIgnoreCase));
}
public ConvertedValue ConvertValue(decimal value, string sourceCurrency, ValueFieldDefinition field, AggregationSelection aggregation, DateTime? effectiveDate)
{
if (!field.IsCurrencyAmount)
return new ConvertedValue(value, "-", false);
var normalizedSource = _exchangeRateService.NormalizeCurrencyCode(sourceCurrency);
if (string.IsNullOrWhiteSpace(normalizedSource) || normalizedSource == "-")
{
normalizedSource = "-";
if (aggregation.TargetCurrency != ManagementCockpitCurrencyOptions.Native)
return new ConvertedValue(0m, aggregation.TargetCurrency, true);
}
if (aggregation.TargetCurrency == ManagementCockpitCurrencyOptions.Native)
return new ConvertedValue(value, normalizedSource, false);
if (string.Equals(normalizedSource, aggregation.TargetCurrency, StringComparison.OrdinalIgnoreCase))
return new ConvertedValue(value, aggregation.TargetCurrency, false);
var rateDate = (effectiveDate ?? DateTime.UtcNow).Date;
var cacheKey = BuildRateCacheKey(normalizedSource, aggregation.TargetCurrency, rateDate);
if (!aggregation.RateCache.TryGetValue(cacheKey, out var rate))
{
rate = _exchangeRateService.ResolveRate(normalizedSource, aggregation.TargetCurrency, rateDate);
aggregation.RateCache[cacheKey] = rate;
}
if (!rate.HasValue)
return new ConvertedValue(0m, aggregation.TargetCurrency, true);
return new ConvertedValue(value * rate.Value, aggregation.TargetCurrency, false);
}
private static string BuildRateCacheKey(string fromCurrency, string toCurrency, DateTime date)
=> $"{fromCurrency}|{toCurrency}|{date:yyyy-MM-dd}";
public static string BuildDisplayCurrencyLabel(IEnumerable<string> currencies)
{
var distinct = currencies
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
return distinct.Count switch
{
0 => "-",
1 => distinct[0],
_ => "Mixed"
};
}
public static string? NormalizeOptionalFilter(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
public static ManagementCockpitValueFieldOption ToValueFieldOption(ValueFieldDefinition field)
=> new()
{
Key = field.Key,
Label = field.Label,
IsCurrencyAmount = field.IsCurrencyAmount
};
}
internal sealed record AggregationSelection(
ValueFieldDefinition ValueField,
IReadOnlyList<ValueFieldDefinition> AdditionalValueFields,
string TargetCurrency,
Dictionary<string, decimal?> RateCache);
internal sealed record ConvertedValue(decimal Value, string DisplayCurrency, bool MissingExchangeRate);
internal sealed class ValueFieldDefinition
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public bool IsCurrencyAmount { get; set; }
public ValueCurrencySource CurrencySource { get; set; }
}
internal enum ValueCurrencySource
{
None,
Sales,
StandardCost
}
@@ -0,0 +1,448 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using static TrafagSalesExporter.Services.CockpitValueAggregator;
namespace TrafagSalesExporter.Services;
internal sealed class ExcelCockpitAnalyzer
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly CockpitValueAggregator _aggregator;
public ExcelCockpitAnalyzer(IDbContextFactory<AppDbContext> dbFactory, CockpitValueAggregator aggregator)
{
_dbFactory = dbFactory;
_aggregator = aggregator;
}
public async Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var exportLogs = await db.ExportLogs
.Where(x => x.Status == "OK" && !string.IsNullOrWhiteSpace(x.FilePath))
.OrderByDescending(x => x.Timestamp)
.Take(200)
.ToListAsync();
var files = new Dictionary<string, ManagementCockpitFileOption>(StringComparer.OrdinalIgnoreCase);
foreach (var log in exportLogs)
{
if (!File.Exists(log.FilePath))
continue;
files[log.FilePath] = new ManagementCockpitFileOption
{
Path = log.FilePath,
DisplayName = $"{log.Land} | {log.TSC} | {Path.GetFileName(log.FilePath)}",
LastModified = File.GetLastWriteTime(log.FilePath)
};
}
foreach (var directory in GetCandidateDirectories(settings))
{
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.EnumerateFiles(directory, "*.xlsx", SearchOption.TopDirectoryOnly))
{
if (files.ContainsKey(file))
continue;
var fileName = Path.GetFileName(file);
files[file] = new ManagementCockpitFileOption
{
Path = file,
DisplayName = fileName,
LastModified = File.GetLastWriteTime(file)
};
}
}
return files.Values
.OrderByDescending(x => x.LastModified)
.ThenBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden.");
var aggregation = _aggregator.ResolveAggregation(options);
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheets.First();
var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
var headerRow = usedRange.FirstRow();
var headers = headerRow.Cells()
.Select((cell, index) => new { Index = index + 1, Header = NormalizeHeader(cell.GetString()) })
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
var rows = new List<CockpitRow>();
foreach (var row in usedRange.RowsUsed().Skip(1))
{
if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString())))
continue;
rows.Add(ReadRow(row, headers));
}
if (rows.Count == 0)
throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen.");
ApplyAggregation(rows, aggregation);
var result = new ManagementCockpitResult
{
FilePath = filePath,
Summary = BuildSummary(rows, aggregation),
Findings = BuildFindings(rows, aggregation),
TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.AggregatedValue),
TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.AggregatedValue),
TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.AggregatedValue),
DataQualityCounts = BuildDataQualityCounts(rows)
};
return Task.FromResult(result);
}
private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings)
{
yield return Path.Combine(AppContext.BaseDirectory, "output");
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
yield return settings.LocalSiteExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
yield return settings.LocalConsolidatedExportFolder.Trim();
}
private void ApplyAggregation(List<CockpitRow> rows, AggregationSelection aggregation)
{
foreach (var row in rows)
{
var value = ResolveValue(row, aggregation.ValueField);
var currency = ResolveCurrency(row, aggregation.ValueField);
var converted = _aggregator.ConvertValue(value, currency, aggregation.ValueField, aggregation, row.InvoiceDate ?? row.OrderDate ?? row.ExtractionDate);
row.AggregatedValue = converted.Value;
row.AggregatedCurrency = converted.DisplayCurrency;
row.MissingExchangeRate = converted.MissingExchangeRate;
}
}
private static decimal ResolveValue(CockpitRow row, ValueFieldDefinition field)
=> field.Key switch
{
ManagementCockpitValueFieldKeys.Quantity => row.Quantity,
ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost,
ManagementCockpitValueFieldKeys.StandardCostTotal => row.EstimatedCostTotal,
_ => row.SalesValueTotal
};
private static string ResolveCurrency(CockpitRow row, ValueFieldDefinition field)
=> field.CurrencySource switch
{
ValueCurrencySource.StandardCost => row.StandardCostCurrency,
ValueCurrencySource.Sales => row.SalesCurrency,
_ => "-"
};
private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary<string, int> headers)
{
var quantity = GetDecimal(row, headers, "quantity");
var standardCost = GetDecimal(row, headers, "standardcost");
var salesValue = GetDecimal(row, headers, "salespricevalue");
var estimatedCostTotal = quantity != 0m ? quantity * standardCost : standardCost;
return new CockpitRow
{
ExtractionDate = GetDate(row, headers, "extractiondate"),
Tsc = GetText(row, headers, "tsc"),
InvoiceNumber = GetText(row, headers, "invoicenumber"),
PositionOnInvoice = GetText(row, headers, "positiononinvoice"),
Material = GetText(row, headers, "material"),
Name = GetText(row, headers, "name"),
ProductGroup = GetText(row, headers, "productgroup"),
Quantity = quantity,
SupplierNumber = GetText(row, headers, "suppliernumber"),
SupplierName = GetText(row, headers, "suppliername"),
SupplierCountry = GetText(row, headers, "suppliercountry"),
CustomerNumber = GetText(row, headers, "customernumber"),
CustomerName = GetText(row, headers, "customername"),
CustomerCountry = GetText(row, headers, "customercountry"),
CustomerIndustry = GetText(row, headers, "customerindustry"),
StandardCost = standardCost,
StandardCostCurrency = GetText(row, headers, "standardcostcurrency"),
SalesValueTotal = salesValue,
SalesCurrency = GetText(row, headers, "salescurrency"),
Incoterms2020 = GetText(row, headers, "incoterms2020"),
SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"),
InvoiceDate = GetDate(row, headers, "invoicedate"),
OrderDate = GetDate(row, headers, "orderdate"),
Land = GetText(row, headers, "land"),
EstimatedCostTotal = estimatedCostTotal,
EstimatedMarginTotal = salesValue - estimatedCostTotal
};
}
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> rows, AggregationSelection aggregation)
{
var aggregatedTotal = rows.Sum(x => x.AggregatedValue);
var salesTotal = rows.Sum(x => x.SalesValueTotal);
var costTotal = rows.Sum(x => x.EstimatedCostTotal);
var marginTotal = rows.Sum(x => x.EstimatedMarginTotal);
var serviceRows = rows.Where(x =>
x.ProductGroup.Contains("service", StringComparison.OrdinalIgnoreCase) ||
x.Name.Contains("port", StringComparison.OrdinalIgnoreCase) ||
x.Name.Contains("zeugnis", StringComparison.OrdinalIgnoreCase)).ToList();
return new ManagementCockpitSummary
{
Land = rows.Select(x => x.Land).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
Tsc = rows.Select(x => x.Tsc).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
ExtractionDate = rows.Select(x => x.ExtractionDate).FirstOrDefault(x => x.HasValue),
RowCount = rows.Count,
InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
ValueFieldKey = aggregation.ValueField.Key,
ValueFieldLabel = aggregation.ValueField.Label,
DisplayCurrency = BuildDisplayCurrencyLabel(rows.Select(x => x.AggregatedCurrency)),
MissingExchangeRateCount = rows.Count(x => x.MissingExchangeRate),
AggregatedValueTotal = aggregatedTotal,
SalesValueTotal = aggregatedTotal,
EstimatedCostTotal = costTotal,
EstimatedMarginTotal = marginTotal,
EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m,
ServiceSharePercent = salesTotal == 0 ? 0 : serviceRows.Sum(x => x.SalesValueTotal) / salesTotal * 100m,
MissingOrderDatePercent = rows.Count == 0 ? 0 : rows.Count(x => !x.OrderDate.HasValue) * 100m / rows.Count,
MissingSupplierPercent = rows.Count == 0 ? 0 : rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)) * 100m / rows.Count
};
}
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows, AggregationSelection aggregation)
{
var findings = new List<ManagementCockpitFinding>();
var salesTotal = rows.Sum(x => x.AggregatedValue);
var topCustomer = rows
.Where(x => !string.IsNullOrWhiteSpace(x.CustomerName))
.GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.AggregatedValue) })
.OrderByDescending(x => x.Sales)
.FirstOrDefault();
if (topCustomer is not null && salesTotal > 0)
{
var share = topCustomer.Sales / salesTotal * 100m;
findings.Add(new ManagementCockpitFinding
{
Severity = share >= 50 ? "Warning" : "Info",
Title = "Kundenkonzentration",
Detail = $"{topCustomer.Customer} trägt {share:F1}% des Umsatzes."
});
}
var missingExchangeRateRows = rows.Count(x => x.MissingExchangeRate);
if (missingExchangeRateRows > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = "Warning",
Title = "Fehlende Wechselkurse",
Detail = $"{missingExchangeRateRows} Zeilen konnten nicht in die gewaehlte Anzeige-Waehrung umgerechnet werden."
});
}
var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList();
if (zeroValueRows.Count > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = zeroValueRows.Count >= Math.Max(3, rows.Count / 10) ? "Warning" : "Info",
Title = "Nullwerte in Kosten oder Umsatz",
Detail = $"{zeroValueRows.Count} Zeilen haben 0 in Umsatz oder Standard Cost und sollten fachlich geprüft werden."
});
}
var missingOrderDates = rows.Count(x => !x.OrderDate.HasValue);
if (missingOrderDates > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingOrderDates > rows.Count / 2 ? "Warning" : "Info",
Title = "Fehlende Durchlaufzeit",
Detail = $"{missingOrderDates} von {rows.Count} Zeilen haben kein Order Date. Time-to-Invoice ist nur eingeschränkt beurteilbar."
});
}
var orderLeadTimes = rows
.Where(x => x.OrderDate.HasValue && x.InvoiceDate.HasValue)
.Select(x => (x.InvoiceDate!.Value - x.OrderDate!.Value).TotalDays)
.Where(x => x >= 0)
.ToList();
if (orderLeadTimes.Count > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = orderLeadTimes.Average() > 120 ? "Warning" : "Info",
Title = "Durchschnittliche Fakturierungszeit",
Detail = $"Zwischen Order Date und Invoice Date liegen im Schnitt {orderLeadTimes.Average():F0} Tage."
});
}
var missingIndustries = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry));
if (missingIndustries > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingIndustries > rows.Count / 2 ? "Warning" : "Info",
Title = "Stammdatenlücke Customer Industry",
Detail = $"{missingIndustries} Zeilen haben keine Customer Industry. Marktsegment-Analysen sind dadurch unvollständig."
});
}
var missingIncoterms = rows.Count(x => string.IsNullOrWhiteSpace(x.Incoterms2020));
if (missingIncoterms > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingIncoterms > rows.Count / 2 ? "Info" : "Info",
Title = "Incoterms unvollständig",
Detail = $"{missingIncoterms} Zeilen haben keine Incoterms-Angabe."
});
}
if (findings.Count == 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = "Info",
Title = "Keine auffälligen Datenqualitätsprobleme",
Detail = "Die Datei ist für eine erste Standortbeurteilung konsistent genug."
});
}
return findings;
}
private static List<ManagementCockpitTopItem> BuildTopItems(
List<CockpitRow> rows,
Func<CockpitRow, string> keySelector,
Func<CockpitRow, decimal> valueSelector)
{
var total = rows.Sum(valueSelector);
return rows
.Select(x => new { Label = keySelector(x), Value = valueSelector(x) })
.Where(x => !string.IsNullOrWhiteSpace(x.Label))
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitTopItem
{
Label = g.Key,
Value = g.Sum(x => x.Value),
SharePercent = total == 0 ? 0 : g.Sum(x => x.Value) / total * 100m
})
.OrderByDescending(x => x.Value)
.Take(5)
.ToList();
}
private static Dictionary<string, int> BuildDataQualityCounts(List<CockpitRow> rows)
{
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["Fehlende Supplier"] = rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)),
["Fehlende Customer Industry"] = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)),
["Fehlende Order Date"] = rows.Count(x => !x.OrderDate.HasValue),
["Fehlende Invoice Date"] = rows.Count(x => !x.InvoiceDate.HasValue),
["Null Umsatz/Kosten"] = rows.Count(x => x.SalesValueTotal == 0 || x.StandardCost == 0)
};
}
private static string NormalizeHeader(string value)
{
var chars = value
.ToLowerInvariant()
.Where(char.IsLetterOrDigit)
.ToArray();
return new string(chars);
}
private static string GetText(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
=> headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty;
private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
{
if (!headers.TryGetValue(key, out var index))
return 0m;
var text = row.Cell(index).GetFormattedString().Trim();
if (decimal.TryParse(text, out var direct))
return direct;
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var invariant))
return invariant;
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), out var local))
return local;
return 0m;
}
private static DateTime? GetDate(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
{
if (!headers.TryGetValue(key, out var index))
return null;
var cell = row.Cell(index);
if (cell.DataType == XLDataType.DateTime)
return cell.GetDateTime();
var text = cell.GetString().Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
if (DateTime.TryParse(text, out var direct))
return direct;
if (DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var invariant))
return invariant;
if (DateTime.TryParse(text, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), System.Globalization.DateTimeStyles.AssumeLocal, out var local))
return local;
return null;
}
private class CockpitRow
{
public DateTime? ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public string PositionOnInvoice { get; set; } = string.Empty;
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public decimal SalesValueTotal { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
public decimal AggregatedValue { get; set; }
public string AggregatedCurrency { get; set; } = string.Empty;
public bool MissingExchangeRate { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show More